/** * Ticket #83 — the app-exit "work in progress" confirmation popup. * * Pins the carnet #83 contract: the popup only appears on the backend's * `app-exit-work-guard` event (never derived locally), with the exact title * and pluralized body copy, a compact capped detail list, `Annuler` as a pure * local no-op, and `Quitter quand même` calling `confirmAppExit()`. */ import { describe, it, expect } from "vitest"; import { render, screen, act, fireEvent } from "@testing-library/react"; import type { AppExitWorkGuardState } from "@/domain"; import type { Gateways } from "@/ports"; import { MockSystemGateway } from "@/adapters/mock"; import { DIProvider } from "@/app/di"; import { AppExitConfirmDialog } from "./AppExitConfirmDialog"; function setup() { const system = new MockSystemGateway(); const gateways = { system } as unknown as Gateways; render( , ); return { system }; } /** The gateway subscribes asynchronously; flush the microtask before emitting. */ async function flush() { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); } function state(over: Partial = {}): AppExitWorkGuardState { return { hasWorkInProgress: true, busyAgentCount: 0, activeBackgroundTaskCount: 0, totalWorkCount: 0, details: [], ...over, }; } const dialog = () => screen.queryByRole("alertdialog"); describe("AppExitConfirmDialog", () => { it("renders nothing until the app-exit-work-guard event fires", async () => { await setup(); await flush(); expect(dialog()).toBeNull(); }); it("shows the exact title and singular body for exactly one active work item", async () => { const { system } = await setup(); await flush(); act(() => { system.emitAppExitWorkGuard( state({ busyAgentCount: 1, totalWorkCount: 1 }), ); }); expect(dialog()).not.toBeNull(); expect(screen.getByText("Du travail est encore en cours")).toBeTruthy(); expect( screen.getByText( "1 travail actif sera interrompu si vous quittez IdeA maintenant.", ), ).toBeTruthy(); expect( screen.getByText( "Annulez la fermeture pour laisser les agents et les tâches se terminer.", ), ).toBeTruthy(); }); it("pluralizes and distinguishes agent/background-task counts when both are present", async () => { const { system } = await setup(); await flush(); act(() => { system.emitAppExitWorkGuard( state({ busyAgentCount: 2, activeBackgroundTaskCount: 1, totalWorkCount: 3 }), ); }); expect( screen.getByText( "2 agents travaillent encore et 1 tâche de fond est encore active. Ces travaux seront interrompus si vous quittez IdeA maintenant.", ), ).toBeTruthy(); }); it("shows up to 5 detail lines then a '+N autres' summary", async () => { const { system } = await setup(); await flush(); const details: AppExitWorkGuardState["details"] = Array.from({ length: 7 }, (_, i) => ({ kind: "busyAgent", projectId: "p1", projectName: "IdeA", agentId: `a${i}`, agentName: `Agent${i}`, ticketId: null, })); act(() => { system.emitAppExitWorkGuard( state({ busyAgentCount: 7, totalWorkCount: 7, details }), ); }); expect(screen.getByText("• Agent Agent0 — IdeA")).toBeTruthy(); expect(screen.getByText("• Agent Agent4 — IdeA")).toBeTruthy(); expect(screen.queryByText("• Agent Agent5 — IdeA")).toBeNull(); expect(screen.getByText("+ 2 autres")).toBeTruthy(); }); it("formats a background-task detail line using the short task id fallback", async () => { const { system } = await setup(); await flush(); act(() => { system.emitAppExitWorkGuard( state({ activeBackgroundTaskCount: 1, totalWorkCount: 1, details: [ { kind: "activeBackgroundTask", projectId: "p1", projectName: "IdeA", agentId: "a1", agentName: "DevBackend", taskId: "0123456789abcdef", state: "running", taskKind: "command", }, ], }), ); }); expect(screen.getByText("• Tâche de fond 01234567 — IdeA")).toBeTruthy(); }); it("'Annuler' closes the popup locally without calling confirmAppExit", async () => { const { system } = await setup(); await flush(); act(() => { system.emitAppExitWorkGuard(state({ busyAgentCount: 1, totalWorkCount: 1 })); }); expect(dialog()).not.toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Annuler" })); expect(dialog()).toBeNull(); expect(system.confirmAppExitCallCount).toBe(0); }); it("Escape is equivalent to Annuler", async () => { const { system } = await setup(); await flush(); act(() => { system.emitAppExitWorkGuard(state({ busyAgentCount: 1, totalWorkCount: 1 })); }); expect(dialog()).not.toBeNull(); fireEvent.keyDown(window, { key: "Escape" }); expect(dialog()).toBeNull(); expect(system.confirmAppExitCallCount).toBe(0); }); it("'Quitter quand même' calls confirmAppExit", async () => { const { system } = await setup(); await flush(); act(() => { system.emitAppExitWorkGuard(state({ busyAgentCount: 1, totalWorkCount: 1 })); }); fireEvent.click(screen.getByRole("button", { name: /Quitter quand même/ })); await flush(); expect(system.confirmAppExitCallCount).toBe(1); }); it("focuses 'Annuler' by default when the popup opens", async () => { const { system } = await setup(); await flush(); act(() => { system.emitAppExitWorkGuard(state({ busyAgentCount: 1, totalWorkCount: 1 })); }); expect(document.activeElement).toBe(screen.getByRole("button", { name: "Annuler" })); }); it("does not auto-close when a fresh guard event arrives while already open", async () => { const { system } = await setup(); await flush(); act(() => { system.emitAppExitWorkGuard(state({ busyAgentCount: 1, totalWorkCount: 1 })); }); expect(dialog()).not.toBeNull(); act(() => { system.emitAppExitWorkGuard( state({ busyAgentCount: 2, activeBackgroundTaskCount: 1, totalWorkCount: 3 }), ); }); // Still open, summary refreshed in place. expect(dialog()).not.toBeNull(); expect( screen.getByText( "2 agents travaillent encore et 1 tâche de fond est encore active. Ces travaux seront interrompus si vous quittez IdeA maintenant.", ), ).toBeTruthy(); }); });