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>
176 lines
6.0 KiB
TypeScript
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);
|
|
});
|
|
});
|