/**
* 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();
});
});