/** * B2 — reopen resume flow (ARCHITECTURE §15.2) wired to the stateful * `MockAgentGateway` / `MockLayoutGateway` via the real `DIProvider`. * * Covers both layers visible from the frontend: * - **Adapter** (`TauriAgentGateway.listResumableAgents`): invokes * `list_resumable_agents` with a top-level `{ projectId }` payload (NOT * `{ request: … }`) and unwraps `{ resumable }` → `ResumableAgent[]`. * - **Panel** (`ResumeProjectPanel`): renders nothing on an empty inventory; * lists the resumable agents (with their status) when non-empty. * - **Actions** (`useResumeProject`): Reprendre (with / without resume support), * Nouvelle conversation, Ignorer, Tout reprendre / Tout ignorer — each * asserting the exact `launchAgent` / `setCellConversation` calls. * - French labels on the main title + actions. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { MockAgentGateway, MockLayoutGateway, } from "@/adapters/mock"; import type { Gateways } from "@/ports"; import type { ResumableAgent } from "@/domain"; import { DIProvider } from "@/app/di"; import { ResumeProjectPanel } from "./ResumeProjectPanel"; // --------------------------------------------------------------------------- // Adapter: TauriAgentGateway.listResumableAgents payload + unwrap // --------------------------------------------------------------------------- const invokeMock = vi.fn(); vi.mock("@tauri-apps/api/core", () => ({ invoke: (...args: unknown[]) => invokeMock(...args), Channel: class {}, })); describe("TauriAgentGateway.listResumableAgents (adapter)", () => { beforeEach(() => invokeMock.mockReset()); it("invokes list_resumable_agents with top-level projectId and unwraps { resumable }", async () => { const { TauriAgentGateway } = await import("@/adapters/agent"); const wire: ResumableAgent[] = [ { agentId: "a1", name: "Architect", nodeId: "n1", conversationId: "c1", wasRunning: true, resumeSupported: true, }, ]; invokeMock.mockResolvedValueOnce({ resumable: wire }); const gw = new TauriAgentGateway(); const out = await gw.listResumableAgents("proj-42"); // Command name + EXACT payload: top-level projectId, not wrapped in request. expect(invokeMock).toHaveBeenCalledTimes(1); expect(invokeMock).toHaveBeenCalledWith("list_resumable_agents", { projectId: "proj-42", }); const [, payload] = invokeMock.mock.calls[0]; expect(payload).not.toHaveProperty("request"); // Unwrapped to the bare array. expect(out).toEqual(wire); }); }); // --------------------------------------------------------------------------- // Panel + actions: behind the real DIProvider with stateful mocks // --------------------------------------------------------------------------- const PROJECT_ID = "proj-resume-001"; const CWD = "/home/me/proj"; function entry(over: Partial = {}): ResumableAgent { return { agentId: "agent-1", name: "Architect", nodeId: "node-1", conversationId: "conv-1", wasRunning: true, resumeSupported: true, ...over, }; } function renderPanel(seed: ResumableAgent[]) { const agent = new MockAgentGateway(); const layout = new MockLayoutGateway(); agent._setResumableAgents(PROJECT_ID, seed); // Spy on the launch/mutate flow so we assert the exact calls without relying // on the singleton/agent-existence checks of the stateful mock. const launchSpy = vi .spyOn(agent, "launchAgent") .mockResolvedValue({} as never); // Resolve the layout mutation: the seeded `nodeId`s are synthetic and don't // exist in the mock's default tree, so the real `applyOperation` would throw. const mutateSpy = vi .spyOn(layout, "mutateLayout") .mockResolvedValue({} as never); const gateways = { agent, layout } as unknown as Gateways; const utils = render( , ); return { agent, layout, launchSpy, mutateSpy, ...utils }; } describe("ResumeProjectPanel (panel mounting)", () => { it("renders nothing when the resumable inventory is empty", async () => { const { container } = renderPanel([]); // No async inventory ⇒ stays null. Give the effect a tick to settle. await waitFor(() => { expect(screen.queryByTestId("resume-project-panel")).toBeNull(); }); expect(container.firstChild).toBeNull(); }); it("mounts the panel listing the resumable agents when non-empty", async () => { renderPanel([ entry({ agentId: "a-run", name: "Architect", wasRunning: true }), entry({ agentId: "a-closed", name: "Tester", nodeId: "node-2", wasRunning: false, }), ]); await screen.findByTestId("resume-project-panel"); expect(screen.getByTestId("resume-row-a-run")).toBeTruthy(); expect(screen.getByTestId("resume-row-a-closed")).toBeTruthy(); // Status derived from wasRunning. expect(screen.getByTestId("resume-status-a-run").textContent).toBe( "en cours", ); expect(screen.getByTestId("resume-status-a-closed").textContent).toBe( "clôt", ); }); it("uses French labels for the title and global actions", async () => { renderPanel([entry()]); await screen.findByTestId("resume-project-panel"); // Title carries a non-breaking space before "?" (` `). expect(screen.getByText(/Reprendre les agents\s*\?/)).toBeTruthy(); expect(screen.getByText("Tout reprendre")).toBeTruthy(); expect(screen.getByText("Tout ignorer")).toBeTruthy(); expect(screen.getByText("Nouvelle conversation")).toBeTruthy(); }); }); describe("ResumeProjectPanel — per-agent actions", () => { it("Reprendre (resumeSupported + conversationId) → launchAgent with nodeId + conversationId", async () => { const { launchSpy } = renderPanel([ entry({ agentId: "a1", nodeId: "node-9", conversationId: "conv-9", resumeSupported: true, }), ]); await screen.findByTestId("resume-project-panel"); fireEvent.click(screen.getByLabelText("resume Architect")); await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1)); const [projectId, agentId, options] = launchSpy.mock.calls[0]; expect(projectId).toBe(PROJECT_ID); expect(agentId).toBe("a1"); expect(options.nodeId).toBe("node-9"); expect(options.conversationId).toBe("conv-9"); // The button label is "Reprendre" when resume is supported. expect(screen.queryByText("Reprendre")).toBeNull(); // row drained after action }); it("Reprendre (resumeSupported === false) → 'relance à neuf' label + launch WITHOUT conversationId", async () => { const { launchSpy } = renderPanel([ entry({ agentId: "a2", nodeId: "node-7", conversationId: "conv-ignored", resumeSupported: false, name: "Legacy", }), ]); await screen.findByTestId("resume-project-panel"); // The "relance à neuf" wording is shown for an unsupported profile. expect(screen.getByTestId("resume-fresh-note-a2").textContent).toContain( "relance à neuf", ); // And the primary button reads "Relancer à neuf" (FR), not "Reprendre". const btn = screen.getByLabelText("resume Legacy"); expect(btn.textContent).toBe("Relancer à neuf"); fireEvent.click(btn); await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1)); const [, , options] = launchSpy.mock.calls[0]; expect(options.nodeId).toBe("node-7"); // Unsupported ⇒ NO conversation id passed (launches fresh). expect(options.conversationId).toBeUndefined(); }); it("Nouvelle conversation → setCellConversation(nodeId, null) then launch without conversationId", async () => { const { launchSpy, mutateSpy } = renderPanel([ entry({ agentId: "a3", nodeId: "node-3", conversationId: "conv-3" }), ]); await screen.findByTestId("resume-project-panel"); fireEvent.click(screen.getByLabelText("new conversation Architect")); await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1)); // setCellConversation cleared BEFORE the launch. expect(mutateSpy).toHaveBeenCalledWith(PROJECT_ID, { type: "setCellConversation", target: "node-3", conversationId: null, }); const mutateOrder = mutateSpy.mock.invocationCallOrder[0]; const launchOrder = launchSpy.mock.invocationCallOrder[0]; expect(mutateOrder).toBeLessThan(launchOrder); // Launch carries no conversation id. const [, , options] = launchSpy.mock.calls[0]; expect(options.conversationId).toBeUndefined(); }); it("Ignorer → drops the row locally, no launchAgent", async () => { const { launchSpy } = renderPanel([ entry({ agentId: "a4", name: "Doomed" }), ]); await screen.findByTestId("resume-project-panel"); fireEvent.click(screen.getByLabelText("ignore Doomed")); await waitFor(() => expect(screen.queryByTestId("resume-row-a4")).toBeNull(), ); expect(launchSpy).not.toHaveBeenCalled(); }); }); describe("ResumeProjectPanel — global actions", () => { it("Tout reprendre → launches every pending agent", async () => { const { launchSpy } = renderPanel([ entry({ agentId: "a1", nodeId: "n1" }), entry({ agentId: "a2", nodeId: "n2", name: "Tester" }), ]); await screen.findByTestId("resume-project-panel"); fireEvent.click(screen.getByLabelText("resume all agents")); await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(2)); const launchedAgentIds = launchSpy.mock.calls.map((c) => c[1]).sort(); expect(launchedAgentIds).toEqual(["a1", "a2"]); // Panel unmounts once the pending set drains. await waitFor(() => expect(screen.queryByTestId("resume-project-panel")).toBeNull(), ); }); it("Tout ignorer → no launch, panel unmounts", async () => { const { launchSpy } = renderPanel([ entry({ agentId: "a1" }), entry({ agentId: "a2", name: "Tester" }), ]); await screen.findByTestId("resume-project-panel"); fireEvent.click(screen.getByLabelText("ignore all agents")); await waitFor(() => expect(screen.queryByTestId("resume-project-panel")).toBeNull(), ); expect(launchSpy).not.toHaveBeenCalled(); }); });