/** * Ticket #86, lot 2 — the web workspace's Tickets/Sprints tabs. * * Pins the carnet #86 UX contract exercised through the real `WebWorkspace` * (project list → tab navigation), driven entirely by the same * `MockTicketGateway`/`MockSystemGateway` pair the desktop ticket tests use, so * behaviour (event-driven refresh, optimistic concurrency, sprint semantics) * stays identical — only the presentation is web-specific. */ import { describe, it, expect } from "vitest"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { DIProvider } from "@/app/di"; import { createMockGateways, MockTicketGateway, MockWorkStateGateway } from "@/adapters/mock"; import type { Gateways } from "@/ports"; import type { ProjectWorkState, Ticket } from "@/domain"; import { WebWorkspace } from "../WebWorkspace"; function ticket(over: Partial = {}): Ticket { return { id: "t-1", ref: "#1", number: 1, title: "Ajouter tickets/sprints au web", description: "", status: "open", priority: "medium", sprintId: null, links: [], assignedAgentIds: [], createdBy: { kind: "user" }, updatedBy: { kind: "user" }, createdAt: 1, updatedAt: 1, version: 1, ...over, }; } async function setup() { const gateways = createMockGateways(); const project = await gateways.project.createProject("IdeA", "/srv/idea"); return { gateways, projectId: project.id }; } /** * Seeds a project's work-state with one idle agent. `WebTicketDetail`/ * `WebTicketsView` resolve the assignable agent roster from * `get_project_work_state` (not `list_agents` — absent from the web-server * command allowlist, see `useWebProjectAgents`), so tests that need an * assignable agent must seed it here rather than via `MockAgentGateway`. */ function seedAgent(gateways: Gateways, projectId: string, agentId: string, name: string): void { const state: ProjectWorkState = { agents: [ { agentId, name, profileId: "p1", busy: { state: "idle" }, tickets: [], }, ], conversations: [], }; (gateways.workState as MockWorkStateGateway)._setProjectWorkState(projectId, state); } function renderWorkspace(gateways: Gateways) { return render( , ); } /** Opens the panel named `panelLabel` ("Tickets"/"Sprints"/…) via the "Panneaux" menu. */ async function openProjectAndTab(gateways: Gateways, panelLabel: string) { renderWorkspace(gateways); fireEvent.click(await screen.findByText("IdeA")); fireEvent.click(await screen.findByRole("button", { name: /^Panneaux :/ })); fireEvent.click(await screen.findByRole("button", { name: panelLabel })); } describe("WebWorkspace — project tabs (ticket #86)", () => { it("defaults to the Travail panel and lets the user switch to Tickets/Sprints via Panneaux", async () => { const { gateways } = await setup(); renderWorkspace(gateways); fireEvent.click(await screen.findByText("IdeA")); expect(await screen.findByRole("button", { name: "Panneaux : Travail" })).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Panneaux : Travail" })); fireEvent.click(await screen.findByRole("button", { name: "Tickets" })); expect(await screen.findByRole("heading", { name: "Tickets" })).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Panneaux : Tickets" })); fireEvent.click(await screen.findByRole("button", { name: "Sprints" })); expect(await screen.findByRole("heading", { name: "Sprints" })).toBeTruthy(); }); it("renders the ticket list grouped by sprint, in French", async () => { const { gateways, projectId } = await setup(); const tk = gateways.ticket as MockTicketGateway; tk._seedSprint(projectId, { id: "s1", order: 1, name: "Sprint courant" }); tk._seedTicket(projectId, ticket({ ref: "#1", number: 1, title: "Ajouter tickets/sprints au web", sprintId: "s1" })); tk._seedTicket(projectId, ticket({ ref: "#2", number: 2, title: "Langue uniforme Settings", status: "closed", priority: "low" })); await openProjectAndTab(gateways, "Tickets"); const list = await screen.findByTestId("web-ticket-list"); expect(within(list).getByText("Sprint courant")).toBeTruthy(); expect(within(list).getByText("Sans sprint")).toBeTruthy(); expect( screen.getByRole("button", { name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne", }), ).toBeTruthy(); expect( screen.getByRole("button", { name: "#2, Langue uniforme Settings, statut Fermé, priorité Faible", }), ).toBeTruthy(); }); it("shows the empty state with a create shortcut when the project has no tickets", async () => { const { gateways } = await setup(); await openProjectAndTab(gateways, "Tickets"); expect(await screen.findByText("Aucun ticket dans ce projet.")).toBeTruthy(); expect(screen.getByRole("button", { name: "Créer un ticket" })).toBeTruthy(); }); it("creates a ticket and opens its detail", async () => { const { gateways } = await setup(); await openProjectAndTab(gateways, "Tickets"); await screen.findByText("Aucun ticket dans ce projet."); fireEvent.click(screen.getByRole("button", { name: "+ Ticket" })); expect(await screen.findByRole("heading", { name: "Nouveau ticket" })).toBeTruthy(); fireEvent.change(screen.getByLabelText("Titre"), { target: { value: "Un nouveau ticket" } }); fireEvent.click(screen.getByRole("button", { name: "Créer" })); await waitFor(() => expect(screen.getByText("Un nouveau ticket")).toBeTruthy()); // Landed on the detail screen (back button visible), not the list. expect(screen.getByRole("button", { name: "← Tickets" })).toBeTruthy(); }); it("changes a ticket's status immediately from the detail view", async () => { const { gateways, projectId } = await setup(); const tk = gateways.ticket as MockTicketGateway; tk._seedTicket(projectId, ticket()); await openProjectAndTab(gateways, "Tickets"); fireEvent.click( await screen.findByRole("button", { name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne", }), ); const statusSelect = await screen.findByLabelText("Statut"); fireEvent.change(statusSelect, { target: { value: "closed" } }); await waitFor(async () => { const updated = await tk.read(projectId, "#1"); expect(updated.status).toBe("closed"); }); }); it("changes a ticket's sprint from the detail view, including clearing it back to Sans sprint", async () => { const { gateways, projectId } = await setup(); const tk = gateways.ticket as MockTicketGateway; tk._seedSprint(projectId, { id: "s1", order: 1, name: "Sprint courant" }); tk._seedSprint(projectId, { id: "s2", order: 2, name: "Sprint suivant" }); tk._seedTicket(projectId, ticket()); await openProjectAndTab(gateways, "Tickets"); fireEvent.click( await screen.findByRole("button", { name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne", }), ); const sprintSelect = await screen.findByLabelText("Sprint"); fireEvent.change(sprintSelect, { target: { value: "s2" } }); await waitFor(async () => { const updated = await tk.read(projectId, "#1"); expect(updated.sprintId).toBe("s2"); }); // Clear it back to "Sans sprint" (ticket_unassign_sprint via setSprint(null)). fireEvent.change(await screen.findByLabelText("Sprint"), { target: { value: "" } }); await waitFor(async () => { const updated = await tk.read(projectId, "#1"); expect(updated.sprintId).toBeNull(); }); }); it("assigns and unassigns an agent from the detail view", async () => { const { gateways, projectId } = await setup(); const tk = gateways.ticket as MockTicketGateway; tk._seedTicket(projectId, ticket()); seedAgent(gateways, projectId, "agent-1", "DevFrontend"); await openProjectAndTab(gateways, "Tickets"); fireEvent.click( await screen.findByRole("button", { name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne", }), ); fireEvent.click(await screen.findByRole("button", { name: "Agents assignés" })); fireEvent.change(screen.getByLabelText("Assigner un agent"), { target: { value: "agent-1" }, }); fireEvent.click(screen.getByRole("button", { name: "Assigner" })); await waitFor(async () => { const updated = await tk.read(projectId, "#1"); expect(updated.assignedAgentIds).toContain("agent-1"); }); await screen.findByText("DevFrontend"); fireEvent.click(screen.getByRole("button", { name: "Désassigner DevFrontend" })); await waitFor(async () => { const updated = await tk.read(projectId, "#1"); expect(updated.assignedAgentIds).not.toContain("agent-1"); }); }); it("links and unlinks a ticket from the detail view", async () => { const { gateways, projectId } = await setup(); const tk = gateways.ticket as MockTicketGateway; tk._seedTicket(projectId, ticket()); tk._seedTicket(projectId, ticket({ ref: "#2", number: 2, title: "Autre ticket" })); await openProjectAndTab(gateways, "Tickets"); fireEvent.click( await screen.findByRole("button", { name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne", }), ); fireEvent.click(await screen.findByRole("button", { name: "Liens" })); fireEvent.click(screen.getByRole("button", { name: "+ Lier" })); const sheet = await screen.findByRole("dialog", { name: "Lier un ticket" }); fireEvent.click(within(sheet).getByRole("button", { name: "#2, Autre ticket" })); await waitFor(async () => { const updated = await tk.read(projectId, "#1"); expect(updated.links).toEqual([{ targetRef: "#2", kind: "relatesTo" }]); }); await screen.findByText("#2"); fireEvent.click(screen.getByRole("button", { name: "Retirer" })); await waitFor(async () => { const updated = await tk.read(projectId, "#1"); expect(updated.links).toEqual([]); }); }); it("saves the carnet explicitly from the detail view", async () => { const { gateways, projectId } = await setup(); const tk = gateways.ticket as MockTicketGateway; tk._seedTicket(projectId, ticket()); await openProjectAndTab(gateways, "Tickets"); fireEvent.click( await screen.findByRole("button", { name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne", }), ); const carnetField = await screen.findByLabelText("Carnet"); const saveButton = screen.getByRole("button", { name: "Enregistrer le carnet" }); expect(saveButton).toHaveProperty("disabled", true); fireEvent.change(carnetField, { target: { value: "Notes de travail" } }); expect(saveButton).toHaveProperty("disabled", false); fireEvent.click(saveButton); await waitFor(async () => { const updated = await tk.readCarnet(projectId, "#1"); expect(updated.carnet).toBe("Notes de travail"); }); }); it("filters the ticket list by free-text search", async () => { const { gateways, projectId } = await setup(); const tk = gateways.ticket as MockTicketGateway; tk._seedTicket(projectId, ticket({ ref: "#1", number: 1, title: "Ajouter tickets/sprints au web" })); tk._seedTicket(projectId, ticket({ ref: "#2", number: 2, title: "Langue uniforme Settings" })); await openProjectAndTab(gateways, "Tickets"); await screen.findByText("Langue uniforme Settings"); fireEvent.change(screen.getByLabelText("Rechercher des tickets"), { target: { value: "Langue" }, }); await waitFor(() => { expect(screen.queryByText("Ajouter tickets/sprints au web")).toBeNull(); }); expect(screen.getByText("Langue uniforme Settings")).toBeTruthy(); }); it("deletes a ticket after confirmation and returns to the list", async () => { const { gateways, projectId } = await setup(); const tk = gateways.ticket as MockTicketGateway; tk._seedTicket(projectId, ticket()); await openProjectAndTab(gateways, "Tickets"); fireEvent.click( await screen.findByRole("button", { name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne", }), ); fireEvent.click(await screen.findByRole("button", { name: "Zone dangereuse" })); fireEvent.click(screen.getByRole("button", { name: "Supprimer" })); const dialog = await screen.findByRole("alertdialog", { name: "Supprimer ce ticket ?" }); expect(within(dialog).getByText(/sera supprimé définitivement/)).toBeTruthy(); fireEvent.click(within(dialog).getByRole("button", { name: "Supprimer" })); await waitFor(() => expect(screen.getByText("Aucun ticket dans ce projet.")).toBeTruthy()); }); }); describe("WebWorkspace — Sprints tab (ticket #86)", () => { it("shows the empty state with a create shortcut", async () => { const { gateways } = await setup(); await openProjectAndTab(gateways, "Sprints"); expect(await screen.findByText("Aucun sprint.")).toBeTruthy(); expect(screen.getByRole("button", { name: "Créer un sprint" })).toBeTruthy(); }); it("creates a sprint (disabled until a name is entered)", async () => { const { gateways, projectId } = await setup(); const tk = gateways.ticket as MockTicketGateway; await openProjectAndTab(gateways, "Sprints"); fireEvent.click(screen.getByRole("button", { name: "+ Sprint" })); const createButton = screen.getByRole("button", { name: "Créer" }); expect(createButton).toHaveProperty("disabled", true); fireEvent.change(screen.getByLabelText("Nom du sprint"), { target: { value: "Backlog client web" }, }); expect(createButton).toHaveProperty("disabled", false); fireEvent.click(createButton); await waitFor(async () => { const sprints = await tk.listSprints(projectId); expect(sprints.map((s) => s.name)).toContain("Backlog client web"); }); expect(await screen.findByText("Backlog client web")).toBeTruthy(); }); it("renames, reorders and deletes a sprint with confirmation", async () => { const { gateways, projectId } = await setup(); const tk = gateways.ticket as MockTicketGateway; tk._seedSprint(projectId, { id: "s1", order: 1, name: "Sprint A" }); tk._seedSprint(projectId, { id: "s2", order: 2, name: "Sprint B" }); await openProjectAndTab(gateways, "Sprints"); await screen.findByText("Sprint A"); // Rename. fireEvent.click(screen.getByRole("button", { name: "Renommer Sprint A" })); fireEvent.change(screen.getByLabelText("Renommer le sprint Sprint A"), { target: { value: "Sprint A renommé" }, }); fireEvent.click(screen.getByRole("button", { name: "Enregistrer" })); await waitFor(() => expect(screen.getByText("Sprint A renommé")).toBeTruthy()); // Reorder: move Sprint B up. fireEvent.click(screen.getByRole("button", { name: "Monter Sprint B" })); await waitFor(async () => { const sprints = await tk.listSprints(projectId); expect(sprints.find((s) => s.id === "s2")?.order).toBe(1); }); // Delete with confirmation. fireEvent.click(screen.getByRole("button", { name: "Supprimer Sprint B" })); const dialog = await screen.findByRole("alertdialog", { name: "Supprimer le sprint « Sprint B » ?", }); expect(within(dialog).getByText(/passeront en « Sans sprint »/)).toBeTruthy(); fireEvent.click(within(dialog).getByRole("button", { name: "Supprimer" })); await waitFor(async () => { const sprints = await tk.listSprints(projectId); expect(sprints.map((s) => s.id)).not.toContain("s2"); }); }); it("adds tickets to a sprint via the mobile picker", async () => { const { gateways, projectId } = await setup(); const tk = gateways.ticket as MockTicketGateway; tk._seedSprint(projectId, { id: "s1", order: 1, name: "Sprint A" }); tk._seedTicket(projectId, ticket({ ref: "#1", number: 1, title: "Ticket un" })); tk._seedTicket(projectId, ticket({ ref: "#2", number: 2, title: "Ticket deux" })); await openProjectAndTab(gateways, "Sprints"); await screen.findByText("Sprint A"); fireEvent.click(screen.getByRole("button", { name: "Ajouter tickets" })); const sheet = await screen.findByRole("dialog", { name: "Ajouter des tickets au sprint « Sprint A »", }); fireEvent.click(within(sheet).getByRole("checkbox", { name: "#1, Ticket un" })); fireEvent.click( within(sheet).getByRole("button", { name: /Ajouter au sprint/ }), ); await waitFor(async () => { const t = await tk.read(projectId, "#1"); expect(t.sprintId).toBe("s1"); }); }); it("shows the sprint's tickets and removes one from the sprint", async () => { const { gateways, projectId } = await setup(); const tk = gateways.ticket as MockTicketGateway; tk._seedSprint(projectId, { id: "s1", order: 1, name: "Sprint A" }); tk._seedTicket(projectId, ticket({ ref: "#1", number: 1, title: "Ticket un", sprintId: "s1" })); await openProjectAndTab(gateways, "Sprints"); await screen.findByText("Sprint A"); expect(screen.getByText("Ticket un")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Retirer #1 du sprint Sprint A" })); await waitFor(async () => { const t = await tk.read(projectId, "#1"); expect(t.sprintId).toBeNull(); }); await waitFor(() => expect(screen.queryByText("Ticket un")).toBeNull()); }); });