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:
175
frontend/src/features/conversations/ConversationViewer.test.tsx
Normal file
175
frontend/src/features/conversations/ConversationViewer.test.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user