Écoute l'event Tauri `app-exit-work-guard` (émis par le backend quand la fermeture de la fenêtre main est interceptée) et affiche une popup modale "Du travail est encore en cours" avec le résumé pluralisé exact du carnet #83, le détail agents/tâches capé à 5 lignes, et les deux actions Annuler (focus par défaut, no-op local) / Quitter quand même (danger, appelle confirm_app_exit). Pas d'option "ne plus demander". - domain/ports/adapters (Tauri listen+invoke, HTTP desktop-only stub, mock avec helpers de test) : onAppExitWorkGuard/confirmAppExit sur SystemGateway, suivant le patron déjà utilisé pour focused-project et les domain events. - AppExitConfirmDialog : mounted une fois près de la racine (App.tsx), à côté d'AnnouncementsProvider — role="alertdialog", focus trap, Échap = Annuler, pas de fermeture au clic extérieur, ne se referme jamais automatiquement (un event pendant l'ouverture rafraîchit juste le résumé). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
230 lines
6.7 KiB
TypeScript
230 lines
6.7 KiB
TypeScript
/**
|
|
* 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(
|
|
<DIProvider gateways={gateways}>
|
|
<AppExitConfirmDialog />
|
|
</DIProvider>,
|
|
);
|
|
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> = {}): 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();
|
|
});
|
|
});
|