diff --git a/src/testing/index.ts b/src/testing/index.ts index 799c28a..73801a2 100644 --- a/src/testing/index.ts +++ b/src/testing/index.ts @@ -1,5 +1,7 @@ export * from "./FakeInstance"; export * from "./lemmyv1"; +export * from "./pagination"; export * from "./piefed"; +export * from "./search"; export * from "./seed"; export type * from "./wire"; diff --git a/src/testing/lemmyv1/builders.ts b/src/testing/lemmyv1/builders.ts index f830711..3470288 100644 --- a/src/testing/lemmyv1/builders.ts +++ b/src/testing/lemmyv1/builders.ts @@ -386,6 +386,31 @@ export function createLemmyV1Builders({ }; } + function personView(subject: Wire): Wire { + return { banned: false, is_admin: false, person: subject }; + } + + /** `GET /api/v4/search` (search) */ + function searchResponse( + over: { + comments?: Wire[]; + communities?: Wire[]; + nextPage?: null | string; + persons?: Wire[]; + posts?: Wire[]; + } = {}, + ): Wire { + return { + comments: over.comments ?? [], + communities: over.communities ?? [], + multi_communities: [], + next_page: over.nextPage ?? null, + persons: over.persons ?? [], + posts: over.posts ?? [], + prev_page: null, + }; + } + /** `GET /api/v4/person` (getPersonDetails) */ function personResponse( subject: Wire, @@ -549,10 +574,12 @@ export function createLemmyV1Builders({ pagedResponse, person, personResponse, + personView, post, postResponse, postView, privateMessageNotification, privateMessageView, + searchResponse, }; } diff --git a/src/testing/lemmyv1/index.ts b/src/testing/lemmyv1/index.ts index a37705d..9d72c7b 100644 --- a/src/testing/lemmyv1/index.ts +++ b/src/testing/lemmyv1/index.ts @@ -6,6 +6,8 @@ import { OperationDef, RecordedCall, } from "../FakeInstance"; +import { depthOf, paginateByCursor } from "../pagination"; +import { searchSeed, SeedSearchType } from "../search"; import { SeedComment, SeedCommunity, @@ -210,8 +212,11 @@ export type LemmyV1Operation = keyof typeof LEMMY_V1_OPERATIONS; * fake.seed.loggedInAs(alex); * ``` * - * Derived: site, post list/detail, comment list, community, person (+ - * person content), account, unread counts, notifications, modlog. Use + * Derived: site, post list/detail, comment list (honoring `parent_id` and + * `max_depth`), search, community, person (+ person content), account, + * unread counts, notifications, modlog, and the vote/save/create/edit/ + * delete/mark-read writes (which mutate the store). Lists paginate with + * opaque `page_cursor` strings, like the real server. Use * `mock()` for error injection or endpoints outside this set, and * `calls()` / `waitForCall()` to assert on outgoing requests. Wire-level * builders stay available on `build`. @@ -284,7 +289,7 @@ export class FakeLemmyV1Instance extends FakeInstance { }); const commentView = (subject: SeedComment) => build.commentView({ - child_count: subject.childCount, + child_count: seed.childCountOf(subject), content: subject.content, creator: person(subject.creator), deleted: subject.deleted, @@ -316,6 +321,23 @@ export class FakeLemmyV1Instance extends FakeInstance { recipient_id: seed.loggedInPerson?.id ?? 0, }); + // v1 pages with opaque cursors the server round-trips + const pageOf = (items: T[], call: RecordedCall) => { + const limit = call.query.get("limit"); + return paginateByCursor(items, { + cursor: call.query.get("page_cursor") ?? undefined, + limit: limit === null ? undefined : Number(limit), + }); + }; + const pagedFrom = ( + items: T[], + call: RecordedCall, + toWire: (item: T) => W, + ) => { + const { items: page, nextPage } = pageOf(items, call); + return build.pagedResponse(page.map(toWire), nextPage ?? null); + }; + const notFound = { json: { error: "not_found" }, status: 404 } as const; this.mock("GET /api/v4/site", () => ({ @@ -325,8 +347,8 @@ export class FakeLemmyV1Instance extends FakeInstance { }), })); - this.mock("GET /api/v4/post/list", () => ({ - json: build.pagedResponse(seed.posts.map(postView)), + this.mock("GET /api/v4/post/list", (call) => ({ + json: pagedFrom(seed.posts, call, postView), })); this.mock("GET /api/v4/post", (call) => { @@ -339,15 +361,31 @@ export class FakeLemmyV1Instance extends FakeInstance { this.mock("GET /api/v4/comment/list", (call) => { const postId = call.query.get("post_id"); const parentId = call.query.get("parent_id"); + const maxDepth = call.query.get("max_depth"); + let comments = postId ? seed.comments.filter((comment) => comment.post.id === Number(postId)) : seed.comments; + // parent_id = the comment's subtree (path segments include it) if (parentId) comments = comments.filter((comment) => comment.path.split(".").includes(parentId), ); - return { json: build.pagedResponse(comments.map(commentView)) }; + + // max_depth is relative to the requested parent, so fetching a + // subtree returns that comment plus max_depth levels beneath it + if (maxDepth) { + const parent = parentId + ? seed.comments.find((comment) => comment.id === Number(parentId)) + : undefined; + const baseDepth = parent ? depthOf(parent.path) : 0; + comments = comments.filter( + (comment) => depthOf(comment.path) - baseDepth <= Number(maxDepth), + ); + } + + return { json: pagedFrom(comments, call, commentView) }; }); this.mock("GET /api/v4/community", (call) => { @@ -381,7 +419,52 @@ export class FakeLemmyV1Instance extends FakeInstance { ...commentView(comment), })), ]; - return { json: build.pagedResponse(items) }; + const { items: page, nextPage } = pageOf(items, call); + return { json: build.pagedResponse(page, nextPage ?? null) }; + }); + + this.mock("GET /api/v4/search", (call) => { + // v1 sends canonical (lowercase) search types on the wire + const results = searchSeed(seed, { + term: call.query.get("search_term") ?? undefined, + type: (call.query.get("type_") ?? undefined) as + | SeedSearchType + | undefined, + }); + + // One cursor across the concatenated result set, matching how the + // adapter flattens the buckets into a single canonical list + const { items, nextPage } = pageOf( + [ + ...results.comments.map((comment) => ["comment", comment] as const), + ...results.posts.map((post) => ["post", post] as const), + ...results.communities.map( + (community) => ["community", community] as const, + ), + ...results.people.map((person) => ["person", person] as const), + ], + call, + ); + + return { + json: build.searchResponse({ + comments: items.flatMap(([kind, item]) => + kind === "comment" ? [commentView(item)] : [], + ), + communities: items.flatMap(([kind, item]) => + kind === "community" + ? [build.communityView({ community: community(item) })] + : [], + ), + nextPage, + persons: items.flatMap(([kind, item]) => + kind === "person" ? [build.personView(person(item))] : [], + ), + posts: items.flatMap(([kind, item]) => + kind === "post" ? [postView(item)] : [], + ), + }), + }; }); this.mock("GET /api/v4/modlog", () => ({ @@ -422,9 +505,7 @@ export class FakeLemmyV1Instance extends FakeInstance { notifications = notifications.filter( (notification) => !notification.read, ); - return { - json: build.pagedResponse(notifications.map(notificationView)), - }; + return { json: pagedFrom(notifications, call, notificationView) }; }); // Fire-and-forget side effect of many logged-in interactions diff --git a/src/testing/pagination.ts b/src/testing/pagination.ts new file mode 100644 index 0000000..1fa38b7 --- /dev/null +++ b/src/testing/pagination.ts @@ -0,0 +1,83 @@ +/** + * Pagination for derived fake responses. + * + * The two supported softwares page differently — Lemmy v1 hands out opaque + * cursor strings, PieFed uses 1-based page numbers — so the fakes derive + * pages the same way their real counterparts do, and consumers exercise + * their real pagination code paths. + */ + +/** + * Note: real servers reject a non-positive or non-numeric `limit` (Lemmy + * 400s with `invalid_fetch_limit`) and a corrupt cursor. These helpers + * degrade to an empty page / offset 0 instead — never to a cursor that + * fails to advance, so a consumer's paging loop can't spin forever. + */ + +/** Prefix marks fake cursors as opaque: nothing should parse them */ +const CURSOR_PREFIX = "seed-offset:"; + +export interface Page { + items: T[]; + /** Wire `next_page` value; absent when the last page was served */ + nextPage?: string; +} + +/** + * Depth of a comment from its materialized path (`0.24.27` → 2); top-level + * comments are depth 1. + */ +export function depthOf(path: string): number { + return path.split(".").length - 1; +} + +/** + * Lemmy v1 style: opaque `page_cursor` strings the server round-trips. + */ +export function paginateByCursor( + items: T[], + { cursor, limit }: { cursor?: string; limit?: number }, +): Page { + const offset = cursor?.startsWith(CURSOR_PREFIX) + ? Number(cursor.slice(CURSOR_PREFIX.length)) + : 0; + const end = limit === undefined ? items.length : offset + limit; + const page = items.slice(offset, end); + + return { + items: page, + // Real Lemmy hands out a cursor whenever it filled the page — so the + // last full page is followed by an empty one, and consumers that stop + // on "no cursor" are exercised properly. `limit > 0` keeps a + // degenerate limit from emitting a cursor that never advances. + nextPage: + limit !== undefined && limit > 0 && page.length === limit + ? `${CURSOR_PREFIX}${end}` + : undefined, + }; +} + +/** + * PieFed style: 1-based `page` numbers. Without a `limit` there's only one + * page, so later pages are empty (matching a server that has nothing more + * to give). + */ +export function paginateByPage( + items: T[], + { limit, page }: { limit?: number; page?: number }, +): Page { + const current = Math.max(1, page ?? 1); + + if (limit === undefined) + return { items: current > 1 ? [] : items, nextPage: undefined }; + + const start = (current - 1) * limit; + const end = start + limit; + const items_ = items.slice(start, end); + + return { + items: items_, + nextPage: + limit > 0 && items_.length === limit ? String(current + 1) : undefined, + }; +} diff --git a/src/testing/piefed/builders.ts b/src/testing/piefed/builders.ts index e32571b..38a4620 100644 --- a/src/testing/piefed/builders.ts +++ b/src/testing/piefed/builders.ts @@ -242,6 +242,25 @@ export function createPiefedBuilders({ }; } + /** `GET /api/alpha/search` (search) */ + function searchResponse( + over: { + comments?: Wire[]; + communities?: Wire[]; + posts?: Wire[]; + type_?: Schemas["SearchResponse"]["type_"]; + users?: Wire[]; + } = {}, + ): Wire { + return { + comments: over.comments ?? [], + communities: over.communities ?? [], + posts: over.posts ?? [], + type_: over.type_ ?? "Posts", + users: over.users ?? [], + }; + } + /** `GET /api/alpha/user` (getPersonDetails) */ function userResponse( subject: Wire, @@ -435,6 +454,7 @@ export function createPiefedBuilders({ privateMessageListResponse, privateMessageView, repliesResponse, + searchResponse, userResponse, }; } diff --git a/src/testing/piefed/index.ts b/src/testing/piefed/index.ts index f445df7..8d92d73 100644 --- a/src/testing/piefed/index.ts +++ b/src/testing/piefed/index.ts @@ -6,6 +6,8 @@ import { OperationDef, RecordedCall, } from "../FakeInstance"; +import { depthOf, paginateByPage } from "../pagination"; +import { searchSeed, SeedSearchType } from "../search"; import { SeedComment, SeedCommunity, @@ -218,6 +220,11 @@ const PIEFED_OPERATIONS = { export type PiefedOperation = keyof typeof PIEFED_OPERATIONS; +/** PieFed's capitalized wire search types (`SearchResponse.type_`) */ +type PiefedSearchType = NonNullable< + Parameters[0] +>["type_"]; + const STATUS_TEXT: Record = { 400: "Bad Request", 401: "Unauthorized", @@ -243,9 +250,11 @@ export interface FakePiefedInstanceOptions { * fake.seed.post({ name: "Hello **world**", creator: alex }); * ``` * - * Derived: site, post list/detail, comment list, community, person, - * unread counts, the notification fan-out (replies/mentions/private - * messages), and mark-as-read writes (which mutate the seed store). Use + * Derived: site, post list/detail, comment list (honoring `parent_id` and + * `max_depth`), search, community, person, unread counts, the notification + * fan-out (replies/mentions/private messages), and the vote/save/create/ + * edit/delete/mark-read writes (which mutate the store). Lists paginate by + * 1-based `page` number, like the real server. Use * `mock()` for error injection or endpoints outside this set. Wire-level * builders stay available on `build`. */ @@ -325,7 +334,7 @@ export class FakePiefedInstance extends FakeInstance { const commentView = (subject: SeedComment) => build.commentView({ body: subject.content, - child_count: subject.childCount, + child_count: seed.childCountOf(subject), creator: person(subject.creator), deleted: subject.deleted, id: subject.id, @@ -337,6 +346,16 @@ export class FakePiefedInstance extends FakeInstance { score: subject.score, }); + // PieFed pages by 1-based page number + const pageOf = (items: T[], call: RecordedCall) => { + const limit = call.query.get("limit"); + const page = call.query.get("page"); + return paginateByPage(items, { + limit: limit === null ? undefined : Number(limit), + page: page === null ? undefined : Number(page), + }); + }; + // Seed misses render PieFed's real error responses as observed live // (piefed.social 2026-07-02): 400s whose message is prose, mapped to // NotFoundError in the condition table. Verified by the fidelity suite. @@ -404,7 +423,10 @@ export class FakePiefedInstance extends FakeInstance { const posts = personId ? seed.posts.filter((post) => post.creator.id === Number(personId)) : seed.posts; - return { json: build.postListResponse(posts.map(postView)) }; + const { items, nextPage } = pageOf(posts, call); + return { + json: build.postListResponse(items.map(postView), nextPage ?? null), + }; }); this.mock("GET /api/alpha/post", (call) => { @@ -433,7 +455,30 @@ export class FakePiefedInstance extends FakeInstance { comments = comments.filter((comment) => comment.path.split(".").includes(parentId), ); - return { json: build.commentListResponse(comments.map(commentView)) }; + + // max_depth is relative to the requested parent, so fetching a + // subtree returns that comment plus max_depth levels beneath it. + // Verified live: with a parent PieFed matches Lemmy, but without one + // it counts levels *below* top-level (max_depth=0 → the roots), + // where Lemmy counts from the post (max_depth=0 → nothing). + const maxDepth = call.query.get("max_depth"); + if (maxDepth) { + const parent = parentId + ? seed.comments.find((comment) => comment.id === Number(parentId)) + : undefined; + const baseDepth = parent ? depthOf(parent.path) : 1; + comments = comments.filter( + (comment) => depthOf(comment.path) - baseDepth <= Number(maxDepth), + ); + } + + const { items, nextPage } = pageOf(comments, call); + return { + json: build.commentListResponse( + items.map(commentView), + nextPage ?? null, + ), + }; }); this.mock("GET /api/alpha/community", (call) => { @@ -477,34 +522,82 @@ export class FakePiefedInstance extends FakeInstance { : [], ); + const repliesPage = (kind: "mention" | "reply", call: RecordedCall) => { + const { items, nextPage } = pageOf( + repliesOf(kind, unreadOnly(call)), + call, + ); + return build.repliesResponse(items, nextPage ?? null); + }; + this.mock("GET /api/alpha/user/replies", (call) => seed.loggedInPerson - ? { json: build.repliesResponse(repliesOf("reply", unreadOnly(call))) } + ? { json: repliesPage("reply", call) } : unauthenticated, ); this.mock("GET /api/alpha/user/mentions", (call) => seed.loggedInPerson - ? { - json: build.repliesResponse(repliesOf("mention", unreadOnly(call))), - } + ? { json: repliesPage("mention", call) } : unauthenticated, ); - this.mock("GET /api/alpha/private_message/list", (call) => - seed.loggedInPerson - ? { - json: build.privateMessageListResponse( - seed.notifications.flatMap((notification) => - notification.kind === "private_message" && - (!unreadOnly(call) || !notification.read) - ? [privateMessageView(notification)] - : [], - ), - ), - } - : unauthenticated, - ); + this.mock("GET /api/alpha/private_message/list", (call) => { + if (!seed.loggedInPerson) return unauthenticated; + + const messages = seed.notifications.flatMap((notification) => + notification.kind === "private_message" && + (!unreadOnly(call) || !notification.read) + ? [privateMessageView(notification)] + : [], + ); + + return { + json: build.privateMessageListResponse(pageOf(messages, call).items), + }; + }); + + this.mock("GET /api/alpha/search", (call) => { + // PieFed capitalizes search types on the wire + const wireType = call.query.get("type_"); + const type = wireType?.toLowerCase() as SeedSearchType | undefined; + const results = searchSeed(seed, { + term: call.query.get("q") ?? undefined, + type, + }); + + const { items } = pageOf( + [ + ...results.communities.map( + (community) => ["community", community] as const, + ), + ...results.posts.map((post) => ["post", post] as const), + ...results.people.map((person) => ["person", person] as const), + ...results.comments.map((comment) => ["comment", comment] as const), + ], + call, + ); + + return { + json: build.searchResponse({ + comments: items.flatMap(([kind, item]) => + kind === "comment" ? [commentView(item)] : [], + ), + communities: items.flatMap(([kind, item]) => + kind === "community" + ? [build.communityView({ community: community(item) })] + : [], + ), + posts: items.flatMap(([kind, item]) => + kind === "post" ? [postView(item)] : [], + ), + type_: (wireType ?? "Posts") as PiefedSearchType, + users: items.flatMap(([kind, item]) => + kind === "person" ? [build.personView(person(item))] : [], + ), + }), + }; + }); // Vote/save writes mutate the seed store, so the returned view — and // every subsequent read — reflects the new state. PieFed's like body diff --git a/src/testing/search.ts b/src/testing/search.ts new file mode 100644 index 0000000..f95534d --- /dev/null +++ b/src/testing/search.ts @@ -0,0 +1,63 @@ +/** + * Provider-agnostic search over the seed store. Each fake renders these + * results into its own software's search wire shape, so one seeded scenario + * produces equivalent search results on every provider. + */ + +import type { + SeedComment, + SeedCommunity, + SeedPerson, + SeedPost, + SeedStore, +} from "./seed"; + +export interface SeedSearchResults { + comments: SeedComment[]; + communities: SeedCommunity[]; + people: SeedPerson[]; + posts: SeedPost[]; +} + +/** Canonical `SearchType` values (see src/types/SearchType.ts) */ +export type SeedSearchType = + | "all" + | "comments" + | "communities" + | "posts" + | "users"; + +/** + * Case-insensitive substring match over the fields a user would expect to + * search (post title/body, comment content, community name/title, person + * name). An absent term matches everything — browse-style search UIs rely + * on that. + */ +export function searchSeed( + seed: SeedStore, + { term, type = "all" }: { term?: string; type?: SeedSearchType }, +): SeedSearchResults { + const matches = (...fields: (string | undefined)[]) => + !term || + fields.some((field) => field?.toLowerCase().includes(term.toLowerCase())); + + const wanted = (bucket: Exclude) => + type === "all" || type === bucket; + + return { + comments: wanted("comments") + ? seed.comments.filter((comment) => matches(comment.content)) + : [], + communities: wanted("communities") + ? seed.communities.filter((community) => + matches(community.name, community.title), + ) + : [], + people: wanted("users") + ? seed.people.filter((person) => matches(person.name, person.displayName)) + : [], + posts: wanted("posts") + ? seed.posts.filter((post) => matches(post.name, post.body)) + : [], + }; +} diff --git a/src/testing/seed.ts b/src/testing/seed.ts index 99cd045..b3f889f 100644 --- a/src/testing/seed.ts +++ b/src/testing/seed.ts @@ -12,7 +12,11 @@ */ export interface SeedComment { - childCount: number; + /** + * Overrides the count derived from seeded descendants — set it to model + * replies that exist server-side but were never seeded. + */ + childCount?: number; content: string; creator: SeedPerson; /** Deleted by its creator (mutated by delete writes) */ @@ -97,6 +101,24 @@ export class SeedStore { // High start so explicit ids in tests never collide with generated ones #nextId = 1000; + /** + * Total replies beneath a comment — every descendant, matching Lemmy's + * `child_count` ("the total number of children in this comment branch"). + * Derived from materialized paths, so seeding a deep reply automatically + * gives its ancestors a non-zero count and "N more replies" affordances + * appear when `max_depth` keeps that reply out of the response. + */ + childCountOf(comment: SeedComment): number { + return ( + comment.childCount ?? + this.comments.filter( + (candidate) => + candidate !== comment && + candidate.path.split(".").includes(String(comment.id)), + ).length + ); + } + /** Wipe all seeded content (e.g. to replace a fixture's default feed) */ clear(): void { this.comments = []; @@ -124,7 +146,7 @@ export class SeedStore { const id = over.id ?? this.#nextId++; const comment: SeedComment = { - childCount: over.childCount ?? 0, + childCount: over.childCount, content: over.content, creator: over.creator ?? post.creator, deleted: over.deleted ?? false, diff --git a/test/testing-fake-instance.test.ts b/test/testing-fake-instance.test.ts index a688166..b7d6f44 100644 --- a/test/testing-fake-instance.test.ts +++ b/test/testing-fake-instance.test.ts @@ -168,7 +168,10 @@ describe("FakeLemmyV1Instance + ThreadiverseClient round trip", () => { it("answers unmocked endpoints with a 404 error", async () => { const { client } = setup(); - await expect(client.search({ search_term: "x" })).rejects.toThrow(); + // resolveObject has no derived default — the fake 404s loudly + await expect( + client.resolveObject({ q: "https://example.com/post/1" }), + ).rejects.toThrow(); }); it("simulates aborts as network failures", async () => { diff --git a/test/testing-seed-matrix.test.ts b/test/testing-seed-matrix.test.ts index 81231b2..e668824 100644 --- a/test/testing-seed-matrix.test.ts +++ b/test/testing-seed-matrix.test.ts @@ -235,6 +235,150 @@ describe.each([ ]); }); + it("paginates derived feeds with the provider's own cursor model", async () => { + const { client, fake } = setup(); + + fake.seed.clear(); + const alex = fake.seed.person({ name: "alex" }); + fake.seed.loggedInAs(alex); + for (const index of [1, 2, 3, 4, 5]) + fake.seed.post({ creator: alex, id: index, name: `Post ${index}` }); + + const first = await client.getPosts({ limit: 2 }); + expect(first.data.map((view) => view.post.name)).toEqual([ + "Post 1", + "Post 2", + ]); + expect(first.next_page).toBeDefined(); + + // Feed the cursor back exactly as the app would + const second = await client.getPosts({ + limit: 2, + page_cursor: first.next_page, + } as Parameters[0]); + expect(second.data.map((view) => view.post.name)).toEqual([ + "Post 3", + "Post 4", + ]); + + const third = await client.getPosts({ + limit: 2, + page_cursor: second.next_page, + } as Parameters[0]); + expect(third.data.map((view) => view.post.name)).toEqual(["Post 5"]); + + // End-of-feed differs by provider: Lemmy stops handing out cursors, + // while the piefed adapter always computes the next page number — so + // consumers there stop on a short page instead. + if (mode === "lemmyv1") expect(third.next_page).toBeUndefined(); + }); + + it("serves a trailing empty page when the last page was full", async () => { + const { client, fake } = setup(); + + fake.seed.clear(); + const alex = fake.seed.person({ name: "alex" }); + for (const index of [1, 2]) + fake.seed.post({ creator: alex, id: index, name: `Post ${index}` }); + + // Exactly `limit` items: real servers still hand out a cursor, and the + // page behind it is empty — consumers that stop on "no cursor" must + // survive that extra round trip + const first = await client.getPosts({ limit: 2 }); + expect(first.data).toHaveLength(2); + expect(first.next_page).toBeDefined(); + + const second = await client.getPosts({ + limit: 2, + page_cursor: first.next_page, + } as Parameters[0]); + expect(second.data).toHaveLength(0); + }); + + it("honors max_depth relative to the requested parent", async () => { + const { client, fake, post } = setup(); + + // parent → child → grandchild, all on the seeded post + const parent = fake.seed.comment({ content: "parent", id: 20, post }); + fake.seed.comment({ + content: "child", + id: 21, + path: `0.${parent.id}.21`, + post, + }); + fake.seed.comment({ + content: "grandchild", + id: 22, + path: `0.${parent.id}.21.22`, + post, + }); + + // Shallowest depth = top-level only. The providers count differently + // without a parent (verified against live servers): Lemmy counts from + // the post, PieFed counts levels below top-level. + const shallow = await client.getComments({ + max_depth: mode === "piefed" ? 0 : 1, + post_id: post.id, + }); + expect(shallow.data.map((view) => view.comment.content)).toEqual([ + "First!", + "parent", + ]); + + // The excluded descendants still count toward child_count — which is + // what makes a consumer's "N more replies" affordance render + const shallowParent = shallow.data.find( + (view) => view.comment.content === "parent", + ); + expect(shallowParent?.comment.child_count).toBe(2); + + // Depth 1 from the parent = the parent plus its direct children + const subtree = await client.getComments({ + max_depth: 1, + parent_id: parent.id, + post_id: post.id, + }); + expect(subtree.data.map((view) => view.comment.content)).toEqual([ + "parent", + "child", + ]); + }); + + it("derives search results from seeded content", async () => { + const { client, fake } = setup(); + + fake.seed.community({ name: "cats_only", title: "Cats Only" }); + fake.seed.person({ name: "catlover" }); + + const posts = await client.search({ + search_term: "hello", + type_: "posts", + }); + expect( + posts.data.map((item) => ("post" in item ? item.post.name : "?")), + ).toEqual(["Hello **world**"]); + + // Type filtering keeps other buckets out + const communities = await client.search({ + search_term: "cats", + type_: "communities", + }); + expect( + communities.data.map((item) => + "community" in item && !("post" in item) ? item.community.name : "?", + ), + ).toEqual(["cats", "cats_only"]); + + const users = await client.search({ search_term: "cat", type_: "users" }); + expect( + users.data.map((item) => ("person" in item ? item.person.name : "?")), + ).toEqual(["catlover"]); + + // A term nothing matches yields an empty result set, not an error + const none = await client.search({ search_term: "zzzz", type_: "posts" }); + expect(none.data).toHaveLength(0); + }); + it("seed.clear() empties the derived feed", async () => { const { client, fake } = setup();