Files
IdeA/frontend/src/features/conversations/ConversationViewer.test.tsx
Blomios c19e375849 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>
2026-06-22 17:30:51 +02:00

176 lines
6.0 KiB
TypeScript

/**
* 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) => (
<DIProvider gateways={gateways}>{children}</DIProvider>
);
return {
onClose,
...render(
ui(
<ConversationViewer
projectId={P}
conversationId={C}
onClose={onClose}
/>,
),
),
};
}
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);
});
});