Files
IdeaSDK/frontend/src/features/web/tickets/WebTicketsSprints.test.tsx
Blomios 8158057b1d feat(tickets): ajoute les pièces jointes sur tickets (#108)
Stockage flat côté ticket + métadonnées d'attachments, lecture exposée côté
backend/MCP, et UI minimale de liste/ajout dans TicketDetail. Traverse le
domaine (Issue, ports), l'application (usecases + assistant de ticket), les
adaptateurs infra/MCP (issues store, orchestrateur), les DTO backend/web-
server/app-tauri, et le frontend (domain/ports/adapters/hooks/UI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 11:08:45 +02:00

450 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: [],
attachments: [],
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());
});
});