Files
IdeaSDK/frontend/src/features/tickets/tickets.test.tsx
Blomios feeca3462c feat(tickets): filtres multi-critères par cases à cocher (backend + frontend)
Passe les filtres de la liste des tickets en sélection multiple (#12).

Backend Rust :
- IssueListFilter : statuses/priorities en Vec, filter_matches en OR intra-champ
  et AND inter-champs
- TicketListRequestDto en tableaux + from_request (parse/déduplication)
- MCP idea_ticket_list aligné sur le nouveau contrat

Frontend :
- TicketListQuery.statuses/priorities
- UI de cases à cocher, toggle/clear des filtres

Tests verts : issue_store (7), app-tauri --lib (59), mcp_server (24),
issue_usecases (6), sprint_usecases (6), frontend vitest (505), npm run build (exit 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 06:41:01 +02:00

842 lines
29 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { DIProvider } from "@/app/di";
import {
MockAgentGateway,
MockProfileGateway,
MockSystemGateway,
MockTicketGateway,
} from "@/adapters/mock";
import type { Agent, DomainEvent } from "@/domain";
import type { Gateways } from "@/ports";
import { TicketsView } from "./TicketsView";
import { TicketDetail } from "./TicketDetail";
const PROJECT_ID = "project-tickets-test";
function stubClipboard() {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });
return writeText;
}
async function seedAgent(agent: MockAgentGateway, name: string): Promise<Agent> {
return agent.createAgent(PROJECT_ID, { name, profileId: "p-1" });
}
function renderView(
ticket: MockTicketGateway,
system: MockSystemGateway,
agent: MockAgentGateway,
) {
const gateways = { ticket, system, agent } as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<TicketsView projectId={PROJECT_ID} />
</DIProvider>,
);
}
async function seedAssistantProfile(profile: MockProfileGateway) {
return profile.saveProfile({
id: "qa-assistant",
name: "QA Assistant",
command: "codex",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: null,
cwdTemplate: "{projectRoot}",
});
}
describe("MockTicketGateway", () => {
let system: MockSystemGateway;
let ticket: MockTicketGateway;
beforeEach(() => {
system = new MockSystemGateway();
ticket = new MockTicketGateway(system);
});
it("assigns sequential #refs and emits issueCreated", async () => {
const events: string[] = [];
await system.onDomainEvent((e) => events.push(e.type));
const a = await ticket.create(PROJECT_ID, { title: "First" });
const b = await ticket.create(PROJECT_ID, { title: "Second" });
expect(a.ref).toBe("#1");
expect(b.ref).toBe("#2");
expect(b.version).toBe(1);
expect(events).toContain("issueCreated");
});
it("filters by statuses (OR within facet) and searches text (#12)", async () => {
await ticket.create(PROJECT_ID, { title: "alpha bug", status: "open" });
const t2 = await ticket.create(PROJECT_ID, { title: "beta task" });
await ticket.update(PROJECT_ID, t2.ref, {
status: "closed",
expectedVersion: t2.version,
});
const open = await ticket.list(PROJECT_ID, { statuses: ["open"] });
expect(open.items.map((i) => i.ref)).toEqual(["#1"]);
// OR within the status facet: both values pass.
const either = await ticket.list(PROJECT_ID, {
statuses: ["open", "closed"],
});
expect(either.items.map((i) => i.ref).sort()).toEqual(["#1", "#2"]);
// An empty set ⇒ no status constraint (all pass).
const all = await ticket.list(PROJECT_ID, { statuses: [] });
expect(all.items).toHaveLength(2);
const search = await ticket.list(PROJECT_ID, { text: "beta" });
expect(search.items.map((i) => i.ref)).toEqual(["#2"]);
});
it("combines status and priority facets with AND (#12)", async () => {
await ticket.create(PROJECT_ID, {
title: "a",
status: "open",
priority: "high",
});
await ticket.create(PROJECT_ID, {
title: "b",
status: "open",
priority: "low",
});
await ticket.create(PROJECT_ID, {
title: "c",
status: "closed",
priority: "high",
});
// statuses:[open] AND priorities:[high] ⇒ only #1.
const both = await ticket.list(PROJECT_ID, {
statuses: ["open"],
priorities: ["high"],
});
expect(both.items.map((i) => i.ref)).toEqual(["#1"]);
// OR within status (open|closed) AND priorities:[high] ⇒ #1 and #3.
const mix = await ticket.list(PROJECT_ID, {
statuses: ["open", "closed"],
priorities: ["high"],
});
expect(mix.items.map((i) => i.ref).sort()).toEqual(["#1", "#3"]);
});
it("rejects a stale write with a version conflict", async () => {
const t = await ticket.create(PROJECT_ID, { title: "x" });
await ticket.update(PROJECT_ID, t.ref, {
title: "y",
expectedVersion: t.version,
});
await expect(
ticket.update(PROJECT_ID, t.ref, {
title: "z",
expectedVersion: t.version, // stale (1, now 2)
}),
).rejects.toMatchObject({ message: expect.stringContaining("version conflict") });
});
it("deletes a ticket, removing it and emitting issueDeleted with freedSprint (#6)", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "S" });
const t = await ticket.create(PROJECT_ID, { title: "Doomed" });
await ticket.setTicketSprint(PROJECT_ID, t.ref, "s1", t.version);
const events: DomainEvent[] = [];
await system.onDomainEvent((e) => events.push(e));
await ticket.delete(PROJECT_ID, t.ref);
// Removed from the store (NotFound afterwards)…
await expect(ticket.read(PROJECT_ID, t.ref)).rejects.toMatchObject({
code: "NOT_FOUND",
});
// …and the sprint it was released from is reported on the event.
expect(events).toContainEqual({
type: "issueDeleted",
projectId: PROJECT_ID,
issueRef: t.ref,
freedSprint: "s1",
});
});
it("rejects deleting an unknown ticket with NotFound (#6)", async () => {
await expect(ticket.delete(PROJECT_ID, "#404")).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("links and unlinks tickets, bumping the version", async () => {
const a = await ticket.create(PROJECT_ID, { title: "a" });
await ticket.create(PROJECT_ID, { title: "b" });
const linked = await ticket.link(PROJECT_ID, a.ref, "#2", "blocks", a.version);
expect(linked.links).toEqual([{ targetRef: "#2", kind: "blocks" }]);
const unlinked = await ticket.unlink(
PROJECT_ID,
a.ref,
"#2",
linked.version,
"blocks",
);
expect(unlinked.links).toEqual([]);
});
});
describe("TicketsView", () => {
let system: MockSystemGateway;
let ticket: MockTicketGateway;
let agent: MockAgentGateway;
beforeEach(() => {
system = new MockSystemGateway();
ticket = new MockTicketGateway(system);
agent = new MockAgentGateway();
});
it("shows the empty state then a created ticket", async () => {
renderView(ticket, system, agent);
expect(await screen.findByText("No tickets.")).toBeTruthy();
await ticket.create(PROJECT_ID, { title: "Live created" });
// The panel refreshes on the issueCreated event.
expect(await screen.findByText("Live created")).toBeTruthy();
});
it("multi-selects status/priority facets (OR intra, AND inter) and clears (#12)", async () => {
const listSpy = vi.spyOn(ticket, "list");
await ticket.create(PROJECT_ID, {
title: "Alpha",
status: "open",
priority: "high",
});
await ticket.create(PROJECT_ID, {
title: "Bravo",
status: "closed",
priority: "low",
});
await ticket.create(PROJECT_ID, {
title: "Charlie",
status: "QA",
priority: "high",
});
renderView(ticket, system, agent);
// All three visible initially (no facet ⇒ all pass).
await screen.findByText("Alpha");
expect(screen.getByText("Bravo")).toBeTruthy();
expect(screen.getByText("Charlie")).toBeTruthy();
// Tick two statuses → OR within the facet ⇒ open|closed ⇒ Alpha + Bravo,
// Charlie (QA) drops out.
fireEvent.click(screen.getByLabelText("filter status Open"));
fireEvent.click(screen.getByLabelText("filter status Closed"));
await waitFor(() => expect(screen.queryByText("Charlie")).toBeNull());
expect(screen.getByText("Alpha")).toBeTruthy();
expect(screen.getByText("Bravo")).toBeTruthy();
// Add a priority → AND across facets ⇒ (open|closed) AND high ⇒ only Alpha.
fireEvent.click(screen.getByLabelText("filter priority High"));
await waitFor(() => expect(screen.queryByText("Bravo")).toBeNull());
expect(screen.getByText("Alpha")).toBeTruthy();
// The gateway received the expected multi-select query.
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]).toMatchObject({
statuses: ["open", "closed"],
priorities: ["high"],
}),
);
// « Effacer » empties both facets ⇒ all three back.
fireEvent.click(screen.getByLabelText("clear filters"));
await waitFor(() => expect(screen.getByText("Charlie")).toBeTruthy());
expect(screen.getByText("Bravo")).toBeTruthy();
expect(screen.getByText("Alpha")).toBeTruthy();
});
it("opens the detail and edits status with a version bump", async () => {
const t = await ticket.create(PROJECT_ID, { title: "Editable" });
renderView(ticket, system, agent);
fireEvent.click(await screen.findByText("Editable"));
const dialog = await screen.findByRole("dialog");
const statusSelect = within(dialog).getByLabelText("ticket status");
fireEvent.change(statusSelect, { target: { value: "inProgress" } });
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.status).toBe("inProgress");
expect(fresh.version).toBe(2);
});
});
it("saves the ticket-scoped carnet (replaceable)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "WithCarnet" });
renderView(ticket, system, agent);
fireEvent.click(await screen.findByText("WithCarnet"));
const dialog = await screen.findByRole("dialog");
fireEvent.change(within(dialog).getByLabelText("ticket carnet"), {
target: { value: "## notes\nscoped body" },
});
fireEvent.click(within(dialog).getByText("Save carnet"));
await waitFor(async () => {
const carnet = await ticket.readCarnet(PROJECT_ID, t.ref);
expect(carnet.carnet).toBe("## notes\nscoped body");
});
});
it("assigns only known project agents", async () => {
const known = await seedAgent(agent, "Backend");
const t = await ticket.create(PROJECT_ID, { title: "Assignable" });
renderView(ticket, system, agent);
fireEvent.click(await screen.findByText("Assignable"));
const dialog = await screen.findByRole("dialog");
fireEvent.change(within(dialog).getByLabelText("assign agent"), {
target: { value: known.id },
});
fireEvent.click(within(dialog).getByText("Assign"));
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.assignedAgentIds).toEqual([known.id]);
});
expect(within(dialog).getAllByText("Backend").length).toBeGreaterThan(0);
});
it("surfaces a version-conflict banner and reloads (F3)", async () => {
// No system gateway ⇒ the detail does not auto-refresh on events, so we can
// deterministically make its held version go stale under an external write.
const t = await ticket.create(PROJECT_ID, { title: "Racy" });
const gateways = { ticket, agent } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<TicketDetail
projectId={PROJECT_ID}
ticketRef={t.ref}
onClose={() => {}}
onOpenRef={() => {}}
/>
</DIProvider>,
);
const dialog = await screen.findByRole("dialog");
// The detail now holds version 1. Bump the store to version 2 behind its back.
await ticket.update(PROJECT_ID, t.ref, {
description: "external",
expectedVersion: t.version,
});
fireEvent.change(within(dialog).getByLabelText("Title"), {
target: { value: "Racy edited" },
});
fireEvent.click(within(dialog).getByText("Save changes"));
expect(
await within(dialog).findByText(/modified elsewhere and reloaded/i),
).toBeTruthy();
// After the reload the detail field reflects the reloaded (title unchanged)
// ticket rather than the abandoned local edit.
await waitFor(() =>
expect(
(within(dialog).getByLabelText("Title") as HTMLInputElement)
.value,
).toBe("Racy"),
);
});
it("preserves an in-progress description edit across a priority change (#9)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "Persisted" });
renderView(ticket, system, agent);
fireEvent.click(await screen.findByText("Persisted"));
const dialog = await screen.findByRole("dialog");
// Start editing the description WITHOUT saving.
const desc = within(dialog).getByLabelText("Description") as HTMLTextAreaElement;
fireEvent.change(desc, { target: { value: "draft in progress" } });
// Also edit the title to cover both draft fields.
const titleInput = within(dialog).getByLabelText("Title") as HTMLInputElement;
fireEvent.change(titleInput, { target: { value: "Persisted (wip)" } });
// Change the priority — an immediate-apply mutation that re-fetches the ticket
// and bumps its version. The unsaved draft must survive.
fireEvent.change(within(dialog).getByLabelText("ticket priority"), {
target: { value: "high" },
});
// The backend applied the priority bump…
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.priority).toBe("high");
expect(fresh.version).toBe(2);
});
// …and the in-progress edits are still there (not reverted).
expect(
(within(dialog).getByLabelText("Description") as HTMLTextAreaElement).value,
).toBe("draft in progress");
expect(
(within(dialog).getByLabelText("Title") as HTMLInputElement).value,
).toBe("Persisted (wip)");
// The description was never persisted.
const persisted = await ticket.read(PROJECT_ID, t.ref);
expect(persisted.description).toBe("");
});
it("prompts before closing with unsaved changes, and closes directly when clean (#9)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "CloseGuard" });
const onClose = vi.fn();
const gateways = { ticket, system, agent } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<TicketDetail
projectId={PROJECT_ID}
ticketRef={t.ref}
onClose={onClose}
onOpenRef={() => {}}
/>
</DIProvider>,
);
const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
// Clean state → Close fires onClose directly, no confirmation dialog.
fireEvent.click(within(detail).getByText("Close"));
expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("dialog", { name: "unsaved changes" })).toBeNull();
// Now make the draft dirty and try to close again.
fireEvent.change(within(detail).getByLabelText("Description"), {
target: { value: "unsaved body" },
});
fireEvent.click(within(detail).getByText("Close"));
// The confirmation appears and onClose was NOT called again.
const confirm = await screen.findByRole("dialog", { name: "unsaved changes" });
expect(onClose).toHaveBeenCalledTimes(1);
// "Annuler" dismisses the popup and keeps the editor open.
fireEvent.click(within(confirm).getByLabelText("cancel close"));
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "unsaved changes" })).toBeNull(),
);
expect(onClose).toHaveBeenCalledTimes(1);
// Re-open the confirmation and choose "Fermer sans sauvegarder".
fireEvent.click(within(detail).getByText("Close"));
const confirm2 = await screen.findByRole("dialog", { name: "unsaved changes" });
fireEvent.click(within(confirm2).getByLabelText("close without saving"));
expect(onClose).toHaveBeenCalledTimes(2);
// The unsaved description was discarded (never persisted).
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.description).toBe("");
});
it("saves unsaved changes then closes when choosing « Sauvegarder » (#9)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "SaveOnClose" });
const onClose = vi.fn();
const gateways = { ticket, system, agent } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<TicketDetail
projectId={PROJECT_ID}
ticketRef={t.ref}
onClose={onClose}
onOpenRef={() => {}}
/>
</DIProvider>,
);
const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
fireEvent.change(within(detail).getByLabelText("Description"), {
target: { value: "final body" },
});
fireEvent.click(within(detail).getByText("Close"));
const confirm = await screen.findByRole("dialog", { name: "unsaved changes" });
fireEvent.click(within(confirm).getByLabelText("save and close"));
// The change is persisted and the panel closes.
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.description).toBe("final body");
});
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("deletes a ticket from the detail: confirmation ⇒ row gone + detail closed (#6)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "Deletable" });
renderView(ticket, system, agent);
fireEvent.click(await screen.findByText("Deletable"));
const detail = await screen.findByRole("dialog", {
name: `ticket ${t.ref}`,
});
// Open the confirmation, then confirm the deletion.
fireEvent.click(within(detail).getByText("Supprimer"));
const confirm = await screen.findByRole("dialog", {
name: "confirmer la suppression",
});
fireEvent.click(within(confirm).getByLabelText("confirm delete"));
// The gateway removed the ticket…
await waitFor(() =>
expect(ticket.read(PROJECT_ID, t.ref)).rejects.toMatchObject({
code: "NOT_FOUND",
}),
);
// …the detail closed (via the `issueDeleted` event → `deleted` flag)…
await waitFor(() =>
expect(
screen.queryByRole("dialog", { name: `ticket ${t.ref}` }),
).toBeNull(),
);
// …and the row disappeared from the list (event-driven refresh).
await waitFor(() => expect(screen.queryByText("Deletable")).toBeNull());
expect(await screen.findByText("No tickets.")).toBeTruthy();
});
it("cancelling the delete confirmation keeps the ticket (#6)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "Keeper" });
const delSpy = vi.spyOn(ticket, "delete");
renderView(ticket, system, agent);
fireEvent.click(await screen.findByText("Keeper"));
const detail = await screen.findByRole("dialog", {
name: `ticket ${t.ref}`,
});
fireEvent.click(within(detail).getByText("Supprimer"));
const confirm = await screen.findByRole("dialog", {
name: "confirmer la suppression",
});
fireEvent.click(within(confirm).getByLabelText("cancel delete"));
// The confirmation is dismissed, delete was never called, ticket stays.
await waitFor(() =>
expect(
screen.queryByRole("dialog", { name: "confirmer la suppression" }),
).toBeNull(),
);
expect(delSpy).not.toHaveBeenCalled();
expect(
screen.getByRole("dialog", { name: `ticket ${t.ref}` }),
).toBeTruthy();
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.title).toBe("Keeper");
});
it("copies a delegation context prompt (F7)", async () => {
const writeText = stubClipboard();
const t = await ticket.create(PROJECT_ID, { title: "Delegate me" });
renderView(ticket, system, agent);
fireEvent.click(await screen.findByText("Delegate me"));
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByText("Delegation context"));
await waitFor(() =>
expect(writeText).toHaveBeenCalledWith(
`Travaille sur le ticket ${t.ref} : Delegate me`,
),
);
});
it("opens, streams, and closes the ticket AI assistant chat (#8)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "Assistant target" });
const profile = new MockProfileGateway();
await seedAssistantProfile(profile);
const gateways = { ticket, system, agent, profile } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<TicketDetail
projectId={PROJECT_ID}
ticketRef={t.ref}
onClose={() => {}}
onOpenRef={() => {}}
/>
</DIProvider>,
);
const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
expect(within(detail).getByText("Assistant IA")).toBeTruthy();
const profileSelect = await within(detail).findByLabelText(
"profil de l'assistant",
);
expect(within(detail).getByText("Ouvrir la conversation")).toBeTruthy();
fireEvent.change(profileSelect, { target: { value: "qa-assistant" } });
fireEvent.click(within(detail).getByText("Ouvrir la conversation"));
expect(
await within(detail).findByLabelText("message à l'assistant"),
).toBeTruthy();
fireEvent.change(within(detail).getByLabelText("message à l'assistant"), {
target: { value: "Crée un plan de correction" },
});
fireEvent.click(within(detail).getByText("Envoyer"));
expect(await within(detail).findByText("Crée un plan de correction")).toBeTruthy();
expect(
await within(detail).findByText(
"Assistant: reçu « Crée un plan de correction ».",
),
).toBeTruthy();
fireEvent.click(within(detail).getByText("Fermer la conversation"));
await waitFor(() =>
expect(
within(detail).queryByLabelText("message à l'assistant"),
).toBeNull(),
);
expect(within(detail).getByText("Ouvrir la conversation")).toBeTruthy();
expect(
within(detail).queryByText("Crée un plan de correction"),
).toBeNull();
expect(
within(detail).queryByText("Assistant: reçu « Crée un plan de correction »."),
).toBeNull();
});
});
describe("SprintManager (#11)", () => {
let system: MockSystemGateway;
let ticket: MockTicketGateway;
let agent: MockAgentGateway;
beforeEach(() => {
system = new MockSystemGateway();
ticket = new MockTicketGateway(system);
agent = new MockAgentGateway();
});
/** Renders the view and opens the sprint-management overlay. */
async function openManager(): Promise<HTMLElement> {
renderView(ticket, system, agent);
await waitFor(() =>
expect(
screen.getByRole("button", { name: "manage sprints" }),
).toBeTruthy(),
);
fireEvent.click(screen.getByRole("button", { name: "manage sprints" }));
return screen.findByRole("dialog", { name: "manage sprints" });
}
it("creates a sprint", async () => {
const dialog = await openManager();
fireEvent.change(within(dialog).getByLabelText("new sprint name"), {
target: { value: "Alpha" },
});
fireEvent.click(within(dialog).getByText("Create sprint"));
await waitFor(async () => {
const sprints = await ticket.listSprints(PROJECT_ID);
expect(sprints.map((s) => s.name)).toEqual(["Alpha"]);
});
expect(
await within(dialog).findByLabelText("sprint row Alpha"),
).toBeTruthy();
});
it("creates a sprint with a default positional name when the name is empty", async () => {
const dialog = await openManager();
// Leave the name field empty and create → default "Sprint N" (here N=1).
expect(
(within(dialog).getByLabelText("new sprint name") as HTMLInputElement)
.value,
).toBe("");
fireEvent.click(within(dialog).getByText("Create sprint"));
await waitFor(async () => {
const sprints = await ticket.listSprints(PROJECT_ID);
expect(sprints.map((s) => s.name)).toEqual(["Sprint 1"]);
});
expect(
await within(dialog).findByLabelText("sprint row Sprint 1"),
).toBeTruthy();
});
it("renames a sprint", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Old" });
const dialog = await openManager();
fireEvent.change(
await within(dialog).findByLabelText("rename sprint Old"),
{ target: { value: "New" } },
);
fireEvent.click(within(dialog).getByLabelText("save sprint Old"));
await waitFor(async () => {
const [s] = await ticket.listSprints(PROJECT_ID);
expect(s.name).toBe("New");
});
});
it("reorders sprints via the accessible down button", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "One" });
ticket._seedSprint(PROJECT_ID, { id: "s2", order: 2, name: "Two" });
const dialog = await openManager();
fireEvent.click(
await within(dialog).findByLabelText("move sprint One down"),
);
await waitFor(async () => {
const sprints = await ticket.listSprints(PROJECT_ID);
// "Two" is now first (order 1), "One" second.
expect(sprints.map((s) => s.name)).toEqual(["Two", "One"]);
});
});
it("deletes a sprint and unassigns (does not delete) its tickets", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Doomed" });
const t = await ticket.create(PROJECT_ID, { title: "Kept" });
await ticket.setTicketSprint(PROJECT_ID, t.ref, "s1", t.version);
const dialog = await openManager();
fireEvent.click(
await within(dialog).findByLabelText("delete sprint Doomed"),
);
// The confirmation explains tickets are unassigned, not deleted.
const confirm = await screen.findByRole("dialog", {
name: "delete sprint confirmation",
});
expect(confirm.textContent).toMatch(/ne seront pas supprimés/i);
fireEvent.click(within(confirm).getByLabelText("confirm delete sprint"));
await waitFor(async () => {
expect(await ticket.listSprints(PROJECT_ID)).toHaveLength(0);
});
// The ticket still exists, just unassigned.
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.sprintId).toBeNull();
expect(fresh.title).toBe("Kept");
});
it("adds a backlog ticket to a sprint via the add selector", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" });
const t = await ticket.create(PROJECT_ID, { title: "Backlog item" });
const dialog = await openManager();
fireEvent.change(
await within(dialog).findByLabelText("add ticket to sprint Target"),
{ target: { value: t.ref } },
);
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.sprintId).toBe("s1");
});
});
it("removes a ticket from a sprint", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Holder" });
const t = await ticket.create(PROJECT_ID, { title: "Member" });
await ticket.setTicketSprint(PROJECT_ID, t.ref, "s1", t.version);
const dialog = await openManager();
fireEvent.click(
await within(dialog).findByLabelText(`remove ${t.ref} from sprint`),
);
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.sprintId).toBeNull();
});
});
});