fix(frontend): sprint change/removal on tickets, web workspace (#86 QA fix)

QA-flagged gap: the web surface could only ADD a ticket to a sprint, never
change or clear it, despite the backend already exposing both
ticket_assign_sprint and ticket_unassign_sprint.

- useTicketDetail: new setSprint(sprintId | null) method (additive, also
  available to the desktop TicketDetail — unused there today, no behaviour
  change), routing through the existing TicketGateway.setTicketSprint.
- WebTicketDetail: Sprint selector in "Statut et priorité", immediate save
  like status/priority; empty value clears back to "Sans sprint".
- WebSprintsView: each sprint card now shows its tickets (compact list,
  resolved client-side like the desktop SprintManager) with a "Retirer du
  sprint" action per ticket, calling assignSprint(ref, null) — symmetric
  with "Ajouter tickets". Extracted the card into a SprintCard subcomponent
  to keep the growing card readable.
- Tests: WebTicketsSprints.test.tsx now has 16 tests (was 10) — added
  sprint change/clear, sprint removal from the Sprints tab, agent
  assign/unassign, ticket link/unlink, carnet save, and free-text search
  filtering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 07:43:09 +02:00
parent e69361feb7
commit e7f67bada9
4 changed files with 396 additions and 103 deletions

View File

@ -11,9 +11,9 @@ import { describe, it, expect } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { DIProvider } from "@/app/di";
import { createMockGateways, MockTicketGateway } from "@/adapters/mock";
import { createMockGateways, MockTicketGateway, MockWorkStateGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import type { Ticket } from "@/domain";
import type { ProjectWorkState, Ticket } from "@/domain";
import { WebWorkspace } from "../WebWorkspace";
function ticket(over: Partial<Ticket> = {}): Ticket {
@ -43,6 +43,29 @@ async function setup() {
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}>
@ -145,6 +168,148 @@ describe("WebWorkspace — project tabs (ticket #86)", () => {
});
});
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;
@ -260,4 +425,24 @@ describe("WebWorkspace — Sprints tab (ticket #86)", () => {
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());
});
});