/** * LS7 — `MockConversationGateway` real pagination, mirroring the LS6 backend * slicing (strict anchor, clamp `[1,200]`/default 50, `hasMore`, `nextAnchor` = * last/most-recent turn of the page, full text preserved). */ import { describe, it, expect, beforeEach } from "vitest"; import type { TurnView } from "@/domain"; import { MockConversationGateway } from "./index"; const P = "proj"; const C = "conv"; function turn(n: number, text = `texte ${n}`): TurnView { return { id: `t${n}`, atMs: 1_700_000_000_000 + n, role: n % 2 === 0 ? "response" : "prompt", source: { kind: "human" }, text, textLen: text.length, }; } function seed(gw: MockConversationGateway, count: number): void { gw._setTurns( P, C, Array.from({ length: count }, (_, i) => turn(i + 1)), ); } let gw: MockConversationGateway; beforeEach(() => { gw = new MockConversationGateway(); }); describe("MockConversationGateway.readPage", () => { it("backward with no anchor returns the LAST page (most recent), oldest→newest", async () => { seed(gw, 10); const page = await gw.readPage(P, C, { direction: "backward", limit: 4 }); expect(page.turns.map((t) => t.id)).toEqual(["t7", "t8", "t9", "t10"]); expect(page.hasMore).toBe(true); // older turns exist expect(page.nextAnchor).toBe("t10"); // last/most-recent of the page }); it("backward anchored returns the strictly-older turns, ascending", async () => { seed(gw, 10); const page = await gw.readPage(P, C, { direction: "backward", anchor: "t7", limit: 3, }); // Strictly before t7, closest three: t4,t5,t6 — ascending. expect(page.turns.map((t) => t.id)).toEqual(["t4", "t5", "t6"]); expect(page.hasMore).toBe(true); // t1..t3 remain expect(page.nextAnchor).toBe("t6"); }); it("forward anchored returns the strictly-newer turns, ascending", async () => { seed(gw, 10); const page = await gw.readPage(P, C, { direction: "forward", anchor: "t4", limit: 3, }); expect(page.turns.map((t) => t.id)).toEqual(["t5", "t6", "t7"]); expect(page.hasMore).toBe(true); expect(page.nextAnchor).toBe("t7"); }); it("forward with no anchor returns the FIRST page (oldest)", async () => { seed(gw, 10); const page = await gw.readPage(P, C, { direction: "forward", limit: 4 }); expect(page.turns.map((t) => t.id)).toEqual(["t1", "t2", "t3", "t4"]); expect(page.hasMore).toBe(true); }); it("clamps the limit to [1, 200] and defaults to 50", async () => { seed(gw, 60); // Default (no limit) ⇒ 50. const def = await gw.readPage(P, C, { direction: "backward" }); expect(def.turns).toHaveLength(50); // 0 ⇒ default 50. const zero = await gw.readPage(P, C, { direction: "backward", limit: 0 }); expect(zero.turns).toHaveLength(50); // Over 200 ⇒ clamped (only 60 exist, so all 60 here, proving > limit never throws). const big = await gw.readPage(P, C, { direction: "backward", limit: 999 }); expect(big.turns).toHaveLength(60); // A non-positive (negative) limit is treated as "unset" ⇒ default 50. const neg = await gw.readPage(P, C, { direction: "backward", limit: -5 }); expect(neg.turns).toHaveLength(50); // An explicit positive 1 is honoured (lower bound of the clamp). const one = await gw.readPage(P, C, { direction: "forward", limit: 1 }); expect(one.turns).toHaveLength(1); }); it("clamps a limit above 200 down to 200", async () => { seed(gw, 250); const page = await gw.readPage(P, C, { direction: "forward", limit: 500 }); expect(page.turns).toHaveLength(200); expect(page.hasMore).toBe(true); }); it("returns an empty page for an empty conversation", async () => { const page = await gw.readPage(P, C, { direction: "backward" }); expect(page.turns).toEqual([]); expect(page.hasMore).toBe(false); expect(page.nextAnchor).toBeUndefined(); }); it("returns an empty page for an unknown anchor", async () => { seed(gw, 5); const page = await gw.readPage(P, C, { direction: "forward", anchor: "does-not-exist", }); expect(page.turns).toEqual([]); expect(page.hasMore).toBe(false); }); it("hasMore is false when the page reaches the thread end", async () => { seed(gw, 3); const page = await gw.readPage(P, C, { direction: "backward", limit: 50 }); expect(page.turns.map((t) => t.id)).toEqual(["t1", "t2", "t3"]); expect(page.hasMore).toBe(false); }); it("preserves the full (unbounded) turn text", async () => { const big = "Z".repeat(30_000); gw._setTurns(P, C, [turn(1, big)]); const page = await gw.readPage(P, C, { direction: "backward" }); expect(page.turns[0].text).toHaveLength(30_000); }); it("isolates threads per (project, conversation)", async () => { gw._setTurns(P, C, [turn(1)]); gw._setTurns(P, "other", [turn(2), turn(3)]); const a = await gw.readPage(P, C, { direction: "backward" }); const b = await gw.readPage(P, "other", { direction: "backward" }); expect(a.turns.map((t) => t.id)).toEqual(["t1"]); expect(b.turns.map((t) => t.id)).toEqual(["t2", "t3"]); }); });