feat(sprints): surface frontend du modèle de sprints

Ticket #10 — volet frontend des sprints.

- Domaine et ports frontend étendus au modèle de sprints (domain/index.ts,
  ports/index.ts).
- Adaptateurs : ticket.ts et mock/index.ts câblent les opérations sprints.
- UI tickets : useTickets + TicketsPanel exposent le rattachement/gestion des
  sprints, avec tests Vitest associés.

Typecheck / vitest / build verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 10:57:38 +02:00
parent 48b652ff91
commit 69759d351c
7 changed files with 423 additions and 31 deletions

View File

@ -347,6 +347,71 @@ describe("TicketsView", () => {
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
});
it("groups tickets by sprint with a « Sans sprint » bucket (#10)", async () => {
// Two ordered sprints + one ticket in each + one loose ticket.
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Sprint One" });
ticket._seedSprint(PROJECT_ID, { id: "s2", order: 2, name: "Sprint Two" });
const t1 = await ticket.create(PROJECT_ID, { title: "In one" });
const t2 = await ticket.create(PROJECT_ID, { title: "In two" });
await ticket.create(PROJECT_ID, { title: "Loose" });
await ticket.setTicketSprint(PROJECT_ID, t1.ref, "s1", t1.version);
await ticket.setTicketSprint(PROJECT_ID, t2.ref, "s2", t2.version);
renderView(ticket, system, agent);
// Sections appear in sprint `order`, then the "Sans sprint" bucket last.
await screen.findByRole("region", { name: "sprint section Sprint One" });
const sections = screen
.getAllByRole("region")
.map((r) => r.getAttribute("aria-label"))
.filter((l) => l?.startsWith("sprint section"));
expect(sections).toEqual([
"sprint section Sprint One",
"sprint section Sprint Two",
"sprint section Sans sprint",
]);
// The loose ticket lives in the bucket, the others under their sprint.
const bucket = screen.getByRole("region", { name: "sprint section Sans sprint" });
expect(within(bucket).getByText("Loose")).toBeTruthy();
const one = screen.getByRole("region", { name: "sprint section Sprint One" });
expect(within(one).getByText("In one")).toBeTruthy();
});
it("assigns a ticket to a sprint via the row selector (#10)", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Sprint One" });
const t = await ticket.create(PROJECT_ID, { title: "Movable" });
renderView(ticket, system, agent);
// It starts in the "Sans sprint" bucket.
const bucket = await screen.findByRole("region", {
name: "sprint section Sans sprint",
});
expect(within(bucket).getByText("Movable")).toBeTruthy();
// Pick the sprint in the row selector → assign it.
fireEvent.change(screen.getByLabelText(`sprint for ${t.ref}`), {
target: { value: "s1" },
});
// The gateway recorded the membership…
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.sprintId).toBe("s1");
});
// …and the UI regrouped it under Sprint One, emptying the bucket.
await waitFor(() => {
const one = screen.getByRole("region", {
name: "sprint section Sprint One",
});
expect(within(one).getByText("Movable")).toBeTruthy();
});
expect(
screen.queryByRole("region", { name: "sprint section Sans sprint" }),
).toBeNull();
});
it("copies a delegation context prompt (F7)", async () => {
const writeText = stubClipboard();
const t = await ticket.create(PROJECT_ID, { title: "Delegate me" });