feat(frontend): viewer humain fil-par-paire en lecture seule (LS7)
Frontend pur, lecture seule, consomme read_conversation_page (LS6) ; aucun changement backend. - adapters : conversation.ts (gateway) + conversationNormalization.ts. - features/conversations : useConversationThread, ConversationViewer, index. - domain (types LS7), ports (port + Gateways.conversation), adapters/index, mock (+ clé inventaire). - features/workstate : ProjectWorkStatePanel prop onOpenConversation. - features/projects : ProjectsView swap viewer. - tests (QA, verts) : conversationNormalization, mock/conversationGateway, useConversationThread, ConversationViewer, ProjectsView.ls7. tsc 0, vitest 443/443 (+36). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
144
frontend/src/adapters/mock/conversationGateway.test.ts
Normal file
144
frontend/src/adapters/mock/conversationGateway.test.ts
Normal file
@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user