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>
450 lines
17 KiB
TypeScript
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());
|
|
});
|
|
});
|