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:
164
frontend/src/adapters/conversationNormalization.test.ts
Normal file
164
frontend/src/adapters/conversationNormalization.test.ts
Normal file
@ -0,0 +1,164 @@
|
||||
/**
|
||||
* LS7 — wire → domain normalization for the conversation transcript read, and the
|
||||
* `TauriConversationGateway.readPage` argument shape (single flat `request`).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { normalizeTurnPage } from "./conversationNormalization";
|
||||
|
||||
const invokeMock = vi.fn();
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (...args: unknown[]) => invokeMock(...args),
|
||||
}));
|
||||
|
||||
describe("normalizeTurnPage", () => {
|
||||
it("maps a full camelCase TurnPageDto to the domain TurnPage", () => {
|
||||
const page = normalizeTurnPage({
|
||||
turns: [
|
||||
{
|
||||
id: "t1",
|
||||
atMs: 1_700_000_000_000,
|
||||
role: "prompt",
|
||||
source: { kind: "human" },
|
||||
text: "bonjour",
|
||||
textLen: 7,
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
atMs: 1_700_000_000_500,
|
||||
role: "response",
|
||||
source: { kind: "agent", agentId: "agent-7" },
|
||||
text: "réponse complète",
|
||||
textLen: 16,
|
||||
},
|
||||
{
|
||||
id: "t3",
|
||||
atMs: 1_700_000_001_000,
|
||||
role: "toolActivity",
|
||||
source: { kind: "agent", agentId: "agent-7" },
|
||||
text: "tool run",
|
||||
textLen: 8,
|
||||
},
|
||||
],
|
||||
hasMore: true,
|
||||
nextAnchor: "t3",
|
||||
});
|
||||
|
||||
expect(page.hasMore).toBe(true);
|
||||
expect(page.nextAnchor).toBe("t3");
|
||||
expect(page.turns).toHaveLength(3);
|
||||
expect(page.turns[0]).toEqual({
|
||||
id: "t1",
|
||||
atMs: 1_700_000_000_000,
|
||||
role: "prompt",
|
||||
source: { kind: "human" },
|
||||
text: "bonjour",
|
||||
textLen: 7,
|
||||
});
|
||||
// source.kind mapping: agent carries the agentId.
|
||||
expect(page.turns[1].source).toEqual({ kind: "agent", agentId: "agent-7" });
|
||||
// The three roles round-trip verbatim.
|
||||
expect(page.turns.map((t) => t.role)).toEqual([
|
||||
"prompt",
|
||||
"response",
|
||||
"toolActivity",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves the full (unbounded) turn text", () => {
|
||||
const big = "Z".repeat(40_000);
|
||||
const page = normalizeTurnPage({
|
||||
turns: [{ id: "t1", atMs: 1, role: "response", source: { kind: "human" }, text: big, textLen: 40_000 }],
|
||||
hasMore: false,
|
||||
});
|
||||
expect(page.turns[0].text).toHaveLength(40_000);
|
||||
expect(page.turns[0].textLen).toBe(40_000);
|
||||
});
|
||||
|
||||
it("omits nextAnchor when absent and defaults hasMore to false", () => {
|
||||
const page = normalizeTurnPage({ turns: [] });
|
||||
expect(page.turns).toEqual([]);
|
||||
expect(page.hasMore).toBe(false);
|
||||
expect("nextAnchor" in page).toBe(false);
|
||||
});
|
||||
|
||||
it("degrades defensively on a malformed payload (never throws)", () => {
|
||||
const page = normalizeTurnPage({
|
||||
turns: [{ id: 42, role: "weird", source: { kind: "ghost" } }, null, "nope"],
|
||||
hasMore: "yes",
|
||||
nextAnchor: 99,
|
||||
});
|
||||
// hasMore only true on a strict boolean; non-string nextAnchor dropped.
|
||||
expect(page.hasMore).toBe(false);
|
||||
expect("nextAnchor" in page).toBe(false);
|
||||
// Unknown role ⇒ "prompt"; non-record source ⇒ human; missing text ⇒ "".
|
||||
expect(page.turns[0].role).toBe("prompt");
|
||||
expect(page.turns[1].source).toEqual({ kind: "human" });
|
||||
expect(page.turns[0].text).toBe("");
|
||||
// An unknown source.kind degrades to human.
|
||||
expect(page.turns[0].source).toEqual({ kind: "human" });
|
||||
});
|
||||
|
||||
it("returns an empty page for a non-object payload", () => {
|
||||
expect(normalizeTurnPage(null)).toEqual({ turns: [], hasMore: false });
|
||||
expect(normalizeTurnPage(undefined)).toEqual({ turns: [], hasMore: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("TauriConversationGateway.readPage", () => {
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset();
|
||||
});
|
||||
|
||||
it("invokes read_conversation_page with a single flat request, default direction backward", async () => {
|
||||
invokeMock.mockResolvedValue({ turns: [], hasMore: false });
|
||||
const { TauriConversationGateway } = await import("./conversation");
|
||||
const gw = new TauriConversationGateway();
|
||||
|
||||
await gw.readPage("proj-1", "conv-1");
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||
const [command, args] = invokeMock.mock.calls[0];
|
||||
expect(command).toBe("read_conversation_page");
|
||||
// Single `request` argument with FLAT fields (no nested `cursor`).
|
||||
expect(args).toEqual({
|
||||
request: {
|
||||
projectId: "proj-1",
|
||||
conversationId: "conv-1",
|
||||
anchor: undefined,
|
||||
direction: "backward",
|
||||
limit: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards anchor/direction/limit and normalizes the result", async () => {
|
||||
invokeMock.mockResolvedValue({
|
||||
turns: [{ id: "t9", atMs: 5, role: "response", source: { kind: "human" }, text: "ok", textLen: 2 }],
|
||||
hasMore: true,
|
||||
nextAnchor: "t9",
|
||||
});
|
||||
const { TauriConversationGateway } = await import("./conversation");
|
||||
const gw = new TauriConversationGateway();
|
||||
|
||||
const page = await gw.readPage("p", "c", {
|
||||
anchor: "t5",
|
||||
direction: "forward",
|
||||
limit: 25,
|
||||
});
|
||||
|
||||
expect(invokeMock.mock.calls[0][1]).toEqual({
|
||||
request: {
|
||||
projectId: "p",
|
||||
conversationId: "c",
|
||||
anchor: "t5",
|
||||
direction: "forward",
|
||||
limit: 25,
|
||||
},
|
||||
});
|
||||
// Result is normalized to the domain TurnPage.
|
||||
expect(page.turns[0].id).toBe("t9");
|
||||
expect(page.nextAnchor).toBe("t9");
|
||||
expect(page.hasMore).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user