Files
IdeA/frontend/src/features/web/tickets/WebTicketsSprints.test.tsx
Blomios 30f6415d51 feat(frontend): ajoute les menus Panneaux/Paramètres à la version web (#90)
WebWorkspace gagne un menu « Panneaux » qui route la surface principale du
projet ouvert vers les panneaux transport-neutres déjà réutilisables
(contexte, agents, templates, skills, permissions, mémoire, git) en plus des
surfaces web existantes (travail live, tickets, sprints), ainsi qu'un variant
web du panneau Projets (création par chemin serveur saisi manuellement, sans
browse natif). WebApp gagne un menu « Paramètres » (Profils IA, Appareils,
Déploiement désactivé "Desktop uniquement") qui remplace l'ancien bouton
unique "Appareils".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 18:33:22 +02:00

449 lines
17 KiB
TypeScript

/**
* 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> = {}): 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(
<DIProvider gateways={gateways}>
<WebWorkspace />
</DIProvider>,
);
}
/** 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());
});
});