/** * LS7 — `ConversationViewer`: chronological turns, role/source labels, header * User↔Agent vs Agent↔Agent, collapsible tool activity, empty/error/loading * states, scroll-up "load older" trigger, and the back button (`onClose`). */ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import type { ReactNode } from "react"; import { DIProvider } from "@/app/di"; import { MockConversationGateway, MockWorkStateGateway } from "@/adapters/mock"; import type { ConversationGateway, Gateways } from "@/ports"; import type { TurnView } from "@/domain"; import { ConversationViewer } from "./ConversationViewer"; const P = "proj"; const C = "conv-abcdef123456"; function human(n: number, text = `humain ${n}`): TurnView { return { id: `t${n}`, atMs: 1_700_000_000_000 + n, role: "prompt", source: { kind: "human" }, text, textLen: text.length, }; } function agent(n: number, agentId: string, text = `agent ${n}`): TurnView { return { id: `t${n}`, atMs: 1_700_000_000_000 + n, role: "response", source: { kind: "agent", agentId }, text, textLen: text.length, }; } function renderViewer(opts: { conversation: ConversationGateway; workState?: MockWorkStateGateway; onClose?: () => void; }) { const workState = opts.workState ?? new MockWorkStateGateway(); const gateways = { conversation: opts.conversation, workState, } as unknown as Gateways; const onClose = opts.onClose ?? vi.fn(); const ui = (children: ReactNode) => ( {children} ); return { onClose, ...render( ui( , ), ), }; } function seeded(turns: TurnView[]): MockConversationGateway { const gw = new MockConversationGateway(); gw._setTurns(P, C, turns); return gw; } describe("ConversationViewer", () => { it("renders turns chronologically with their full text", async () => { const gw = seeded([human(1, "première"), agent(2, "a7", "deuxième")]); renderViewer({ conversation: gw }); expect(await screen.findByText("première")).toBeTruthy(); expect(screen.getByText("deuxième")).toBeTruthy(); // Role labels are rendered. expect(screen.getAllByText(/· Prompt/).length).toBeGreaterThan(0); expect(screen.getAllByText(/· Response/).length).toBeGreaterThan(0); }); it("titles the header User ↔ Agent and resolves the agent name from work-state", async () => { const gw = seeded([human(1), agent(2, "agent-7")]); const workState = new MockWorkStateGateway(); workState._setProjectWorkState(P, { agents: [ { agentId: "agent-7", name: "Architecte", profileId: "x", busy: { state: "idle" } }, ], }); renderViewer({ conversation: gw, workState }); expect(await screen.findByText("User ↔ Architecte")).toBeTruthy(); }); it("titles the header Agent ↔ Agent for a delegation thread", async () => { const gw = seeded([agent(1, "agent-a"), agent(2, "agent-b")]); const workState = new MockWorkStateGateway(); workState._setProjectWorkState(P, { agents: [ { agentId: "agent-a", name: "Alpha", profileId: "x", busy: { state: "idle" } }, { agentId: "agent-b", name: "Bravo", profileId: "x", busy: { state: "idle" } }, ], }); renderViewer({ conversation: gw, workState }); expect(await screen.findByText("Alpha ↔ Bravo")).toBeTruthy(); }); it("dims and collapses tool activity, expanding on click", async () => { const gw = seeded([ human(1), { ...agent(2, "a7"), role: "toolActivity", text: "détails outillés secrets" }, ]); renderViewer({ conversation: gw }); const toggle = await screen.findByRole("button", { name: /Tool activity/ }); // Collapsed by default ⇒ the tool text is hidden. expect(toggle.getAttribute("aria-expanded")).toBe("false"); expect(screen.queryByText("détails outillés secrets")).toBeNull(); fireEvent.click(toggle); expect(toggle.getAttribute("aria-expanded")).toBe("true"); expect(screen.getByText("détails outillés secrets")).toBeTruthy(); }); it("shows the empty state for a conversation with no turns", async () => { renderViewer({ conversation: seeded([]) }); expect(await screen.findByText("Aucun tour pour le moment.")).toBeTruthy(); }); it("shows the loading state while the first page is in flight", async () => { const gated: ConversationGateway = { readPage: () => new Promise(() => {}) }; renderViewer({ conversation: gated }); expect(await screen.findByText("Chargement du fil…")).toBeTruthy(); }); it("shows an error alert when the gateway rejects", async () => { const failing: ConversationGateway = { readPage: () => Promise.reject(new Error("lecture impossible")), }; renderViewer({ conversation: failing }); const alert = await screen.findByRole("alert"); expect(alert.textContent).toContain("lecture impossible"); }); it("offers a 'load older' control when more turns exist and triggers loadOlder", async () => { // 120 turns ⇒ the first page (latest 50) has older turns ⇒ the button appears. const gw = seeded( Array.from({ length: 120 }, (_, i) => human(i + 1, `tour ${i + 1}`)), ); renderViewer({ conversation: gw }); const older = await screen.findByRole("button", { name: "Charger les tours précédents", }); // Before: the oldest visible is t71. expect(screen.queryByText("tour 21")).toBeNull(); fireEvent.click(older); // After loadOlder: older turns (e.g. tour 21) are prepended into the view. expect(await screen.findByText("tour 21")).toBeTruthy(); }); it("calls onClose when the back button is clicked", async () => { const gw = seeded([human(1)]); const { onClose } = renderViewer({ conversation: gw }); fireEvent.click( await screen.findByRole("button", { name: "← Retour aux terminaux" }), ); expect(onClose).toHaveBeenCalledTimes(1); }); });