Files
IdeA/frontend/src/features/tickets/tickets.test.tsx
Blomios 1a1429f10e feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)
Lot en un seul commit: les deux features partagent les mêmes contrats
(domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes
surfaces UI — la séparation par fichiers était impossible sans chirurgie
de hunks risquant des états intermédiaires non compilables (convention
lot des batches #310-#313 et #315).

#325 — icône configurable par projet, watermark CLI custom:
- domaine: ProjectIconSet (devicon, simple-icons, lucide, idea),
  ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon
  optionnel sur ProjectSettings; sérialisation camelCase et try_from
  strict
- application: UpdateProjectSettings.execute accepte un patch
  theme_color OU icon (absent = inchangé, null = défaut produit), tests
  in-dependants avec store mémoire
- backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto
  avec icon optionnel (désérialisation additive), commandes
  update_project_settings alignées Tauri/HTTP, tests DTO et HTTP
  (défaut null, set, clear, nom invalide rejeté)
- frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en
  grille multi-lignes dans Paramètres projet, affichage de l'icône dans
  ProjectTabs et watermark grand format à faible opacité en fond de CLI
  custom; ports ProjectSettings.icon, Partial<ProjectSettings>,
  adapters tauri/mock/http et tests alignés

#326 — localisation FR/EN et traduction des textes IdeA:
- domaine: IdeaLanguage (fr | en), champ language optionnel sur
  IdeaUserSettings (absence = défaut produit, série additive validée)
- backend/web-server: UserSettingsDto.language, update_user_settings
  accepte language, tests HTTP alignés; fixtures structured_launch_d3
  mises à jour
- frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression
  des sources visibles), GeneralSettings avec sélecteur de langue dans
  les paramètres, setupI18n dans vitest; migration de l'ensemble des
  textes hardcodés vers le catalogue sur toutes les surfaces (agents,
  layout, projets, tickets, sprints, web, plugins, permissions, git,
  templates, skills, mémoire, model servers, embedder, first-run,
  devices, scheduled tasks, workstate, dialogs), FR conservé par défaut;
  recherche d'icônes #325 en anglais conformément au ticket

QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
2026-09-11 21:53:56 +02:00

1403 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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("filters by creator and exposes createdBy on summaries (#109)", async () => {
await ticket.create(PROJECT_ID, { title: "User ticket" });
const agentTicket = await ticket.create(PROJECT_ID, { title: "Agent ticket" });
ticket._seedTicket(PROJECT_ID, {
...agentTicket,
createdBy: { kind: "agent", agentId: "agent-1" },
});
const byUser = await ticket.list(PROJECT_ID, { createdBy: { kind: "user" } });
expect(byUser.items.map((i) => i.title)).toEqual(["User ticket"]);
expect(byUser.items[0]?.createdBy).toEqual({ kind: "user" });
const byAgent = await ticket.list(PROJECT_ID, {
createdBy: { kind: "agent", agentId: "agent-1" },
});
expect(byAgent.items.map((i) => i.title)).toEqual(["Agent ticket"]);
expect(byAgent.items[0]?.createdBy).toEqual({
kind: "agent",
agentId: "agent-1",
});
});
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("adds ticket attachments and marks them summarized (#108)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "With attachment" });
const attached = await ticket.addAttachment(
PROJECT_ID,
t.ref,
"/tmp/spec.pdf",
t.version,
"application/pdf",
);
expect(attached.version).toBe(2);
expect(attached.attachments).toMatchObject([
{
filename: "spec.pdf",
mime: "application/pdf",
summarizedInCarnet: false,
},
]);
const content = await ticket.readAttachment(
PROJECT_ID,
t.ref,
attached.attachments[0].id,
);
expect(content.attachment.filename).toBe("spec.pdf");
expect(content.contentBase64).toBeTruthy();
const summarized = await ticket.markAttachmentSummarized(
PROJECT_ID,
t.ref,
attached.attachments[0].id,
attached.version,
);
expect(summarized.version).toBe(3);
expect(summarized.attachments[0]).toMatchObject({
summarizedInCarnet: true,
summarizedBy: { kind: "user" },
});
});
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("bulk updates status and reports partial failures in request order", async () => {
const a = await ticket.create(PROJECT_ID, { title: "A" });
const b = await ticket.create(PROJECT_ID, { title: "B" });
const out = await ticket.bulkUpdateStatus(
PROJECT_ID,
[a.ref, "#404", b.ref],
"QA",
);
expect(out.items.map((item) => [item.ref, item.ok])).toEqual([
[a.ref, true],
["#404", false],
[b.ref, true],
]);
expect((await ticket.read(PROJECT_ID, a.ref)).status).toBe("QA");
expect((await ticket.read(PROJECT_ID, b.ref)).status).toBe("QA");
});
it("bulk deletes existing tickets and keeps missing refs as item failures", async () => {
const a = await ticket.create(PROJECT_ID, { title: "A" });
const b = await ticket.create(PROJECT_ID, { title: "B" });
const out = await ticket.bulkDelete(PROJECT_ID, [a.ref, "#404", b.ref]);
expect(out.items.map((item) => [item.ref, item.ok])).toEqual([
[a.ref, true],
["#404", false],
[b.ref, true],
]);
await expect(ticket.read(PROJECT_ID, a.ref)).rejects.toMatchObject({
code: "NOT_FOUND",
});
await expect(ticket.read(PROJECT_ID, b.ref)).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("Aucun ticket.")).toBeTruthy();
await ticket.create(PROJECT_ID, { title: "Live created" });
// The panel refreshes on the issueCreated event.
expect(await screen.findByText("Live created")).toBeTruthy();
});
it("creates a ticket and assigns the sprint chosen via the picker (#38)", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Alpha" });
const setSpy = vi.spyOn(ticket, "setTicketSprint");
renderView(ticket, system, agent);
fireEvent.click(await screen.findByRole("button", { name: "Nouveau" }));
fireEvent.change(screen.getByLabelText("Titre du nouveau ticket"), {
target: { value: "With sprint" },
});
// Field defaults to "Sans sprint"; open the picker and pick Alpha.
const sprintField = screen.getByLabelText("Choisir un sprint pour le nouveau ticket");
expect(sprintField.textContent).toContain("Sans sprint");
fireEvent.click(sprintField);
fireEvent.click(await screen.findByLabelText("Sélectionner le sprint Alpha"));
expect(
screen.getByLabelText("Choisir un sprint pour le nouveau ticket").textContent,
).toContain("Alpha");
fireEvent.click(screen.getByRole("button", { name: "Créer" }));
// create → setTicketSprint(projectId, ref, "s1", version).
await waitFor(() => expect(setSpy).toHaveBeenCalledTimes(1));
const call = setSpy.mock.calls[0];
expect(call[0]).toBe(PROJECT_ID);
expect(call[2]).toBe("s1");
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, call[1]);
expect(fresh.sprintId).toBe("s1");
});
});
it("creates a ticket without a sprint — setTicketSprint is not called (#38)", async () => {
const setSpy = vi.spyOn(ticket, "setTicketSprint");
renderView(ticket, system, agent);
fireEvent.click(await screen.findByRole("button", { name: "Nouveau" }));
fireEvent.change(screen.getByLabelText("Titre du nouveau ticket"), {
target: { value: "No sprint" },
});
fireEvent.click(screen.getByRole("button", { name: "Créer" }));
await waitFor(async () => {
const list = await ticket.list(PROJECT_ID);
expect(list.items.map((i) => i.title)).toContain("No sprint");
});
expect(setSpy).not.toHaveBeenCalled();
});
it("keeps the created ticket and surfaces the error when sprint assignment fails (#38)", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Alpha" });
vi.spyOn(ticket, "setTicketSprint").mockRejectedValueOnce({
code: "INTERNAL",
message: "assign boom",
});
renderView(ticket, system, agent);
fireEvent.click(await screen.findByRole("button", { name: "Nouveau" }));
fireEvent.change(screen.getByLabelText("Titre du nouveau ticket"), {
target: { value: "Kept anyway" },
});
fireEvent.click(screen.getByLabelText("Choisir un sprint pour le nouveau ticket"));
fireEvent.click(await screen.findByLabelText("Sélectionner le sprint Alpha"));
fireEvent.click(screen.getByRole("button", { name: "Créer" }));
// The ticket was created despite the failed assignment (no data lost)…
await waitFor(async () => {
const list = await ticket.list(PROJECT_ID);
expect(list.items.map((i) => i.title)).toContain("Kept anyway");
});
// …and the assignment failure is surfaced.
expect(await screen.findByText(/assign boom/)).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("Filtrer le statut Ouvert"));
fireEvent.click(screen.getByLabelText("Filtrer le statut Fermé"));
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("Filtrer la priorité Haute"));
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("Effacer les filtres"));
await waitFor(() => expect(screen.getByText("Charlie")).toBeTruthy());
expect(screen.getByText("Bravo")).toBeTruthy();
expect(screen.getByText("Alpha")).toBeTruthy();
});
it("sorts via the « Trier par… » control: relays sort + toggles direction (#21)", async () => {
const listSpy = vi.spyOn(ticket, "list");
await ticket.create(PROJECT_ID, { title: "Alpha" });
await ticket.create(PROJECT_ID, { title: "Bravo" });
renderView(ticket, system, agent);
await screen.findByText("Alpha");
// No sort chosen ⇒ the gateway sees no `sort` (historical order preserved).
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined();
// Choose a field ⇒ ascending by default, relayed in the query.
fireEvent.click(screen.getByLabelText("Trier les tickets par"));
fireEvent.click(await screen.findByRole("option", { name: "Titre" }));
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
field: "title",
direction: "asc",
}),
);
// The direction toggle flips asc → desc.
fireEvent.click(
screen.getByLabelText("Sens de tri croissant"),
);
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({
field: "title",
direction: "desc",
}),
);
// Back to « Par défaut » ⇒ `sort` dropped from the query again.
fireEvent.click(screen.getByLabelText("Trier les tickets par"));
fireEvent.click(await screen.findByRole("option", { name: "Par défaut" }));
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toBeUndefined(),
);
});
it("shows ticket creators and filters the list by creator (#109)", async () => {
const creator = await seedAgent(agent, "CreatorAgent");
await ticket.create(PROJECT_ID, { title: "Created by user" });
const agentTicket = await ticket.create(PROJECT_ID, {
title: "Created by agent",
});
ticket._seedTicket(PROJECT_ID, {
...agentTicket,
createdBy: { kind: "agent", agentId: creator.id },
});
const listSpy = vi.spyOn(ticket, "list");
renderView(ticket, system, agent);
await screen.findByText("Created by user");
expect(screen.getByText("Créé par: Utilisateur")).toBeTruthy();
expect(screen.getByText("Créé par: Agent : CreatorAgent")).toBeTruthy();
fireEvent.click(screen.getByLabelText("Filtrer par créateur"));
fireEvent.click(await screen.findByRole("option", { name: "CreatorAgent" }));
await waitFor(() =>
expect(listSpy.mock.calls.at(-1)?.[1]?.createdBy).toEqual({
kind: "agent",
agentId: creator.id,
}),
);
await waitFor(() => expect(screen.queryByText("Created by user")).toBeNull());
expect(screen.getByText("Created by agent")).toBeTruthy();
fireEvent.click(screen.getByText("Created by agent"));
const dialog = await screen.findByRole("dialog", {
name: `Ticket ${agentTicket.ref}`,
});
expect(
within(dialog).getByText("Créé par: Agent : CreatorAgent"),
).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");
fireEvent.click(within(dialog).getByLabelText("Statut du ticket"));
fireEvent.click(await screen.findByRole("option", { name: "En cours" }));
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("Carnet du ticket"), {
target: { value: "## notes\nscoped body" },
});
fireEvent.click(within(dialog).getByText("Enregistrer le carnet"));
await waitFor(async () => {
const carnet = await ticket.readCarnet(PROJECT_ID, t.ref);
expect(carnet.carnet).toBe("## notes\nscoped body");
});
});
it("shows, adds, and marks ticket attachments from the detail (#108)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "Attachable" });
renderView(ticket, system, agent);
fireEvent.click(await screen.findByText("Attachable"));
const dialog = await screen.findByRole("dialog", { name: `Ticket ${t.ref}` });
expect(within(dialog).getByText("Pièces jointes")).toBeTruthy();
expect(within(dialog).getByText("Aucune pièce jointe.")).toBeTruthy();
fireEvent.click(within(dialog).getByText("Joindre"));
expect(await within(dialog).findByText("mock-attachment.txt")).toBeTruthy();
expect(within(dialog).getByText(/text\/plain/)).toBeTruthy();
expect(within(dialog).getByText("Résumé: non")).toBeTruthy();
const attached = await ticket.read(PROJECT_ID, t.ref);
expect(attached.attachments).toHaveLength(1);
fireEvent.click(
within(dialog).getByLabelText("Marquer mock-attachment.txt comme résumé"),
);
expect(await within(dialog).findByText("Résumé: oui")).toBeTruthy();
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.attachments[0].summarizedInCarnet).toBe(true);
});
});
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.click(within(dialog).getByLabelText("Assigner un agent"));
fireEvent.click(await screen.findByRole("option", { name: "Backend" }));
fireEvent.click(within(dialog).getByText("Assigner"));
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("Titre"), {
target: { value: "Racy edited" },
});
fireEvent.click(within(dialog).getByText("Enregistrer les modifications"));
expect(
await within(dialog).findByText(/modifié ailleurs et rechargé/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("Titre") 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("Titre") 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.click(within(dialog).getByLabelText("Priorité du ticket"));
fireEvent.click(await screen.findByRole("option", { name: "Haute" }));
// 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("Titre") 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("Fermer"));
expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("dialog", { name: "Modifications non enregistrées" })).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("Fermer"));
// The confirmation appears and onClose was NOT called again.
const confirm = await screen.findByRole("dialog", { name: "Modifications non enregistrées" });
expect(onClose).toHaveBeenCalledTimes(1);
// "Annuler" dismisses the popup and keeps the editor open.
fireEvent.click(within(confirm).getByLabelText("Annuler la fermeture"));
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Modifications non enregistrées" })).toBeNull(),
);
expect(onClose).toHaveBeenCalledTimes(1);
// Re-open the confirmation and choose "Fermer sans sauvegarder".
fireEvent.click(within(detail).getByText("Fermer"));
const confirm2 = await screen.findByRole("dialog", { name: "Modifications non enregistrées" });
fireEvent.click(within(confirm2).getByLabelText("Fermer sans enregistrer"));
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("Fermer"));
const confirm = await screen.findByRole("dialog", { name: "Modifications non enregistrées" });
fireEvent.click(within(confirm).getByLabelText("Enregistrer et fermer"));
// 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: "Section du sprint Sprint One" });
const sections = screen
.getAllByRole("region")
.map((r) => r.getAttribute("aria-label"))
.filter((l) => l?.startsWith("Section du sprint"));
expect(sections).toEqual([
"Section du sprint Sprint One",
"Section du sprint Sprint Two",
"Section du sprint Sans sprint",
]);
// The loose ticket lives in the bucket, the others under their sprint.
const bucket = screen.getByRole("region", { name: "Section du sprint Sans sprint" });
expect(within(bucket).getByText("Loose")).toBeTruthy();
const one = screen.getByRole("region", { name: "Section du sprint 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: "Section du sprint Sans sprint",
});
expect(within(bucket).getByText("Movable")).toBeTruthy();
// Pick the sprint in the row selector → assign it.
fireEvent.click(screen.getByLabelText(`Sprint de ${t.ref}`));
fireEvent.click(await screen.findByRole("option", { name: "Sprint One" }));
// 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: "Section du sprint Sprint One",
});
expect(within(one).getByText("Movable")).toBeTruthy();
});
expect(
screen.queryByRole("region", { name: "Section du sprint 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("Confirmer la suppression"));
// 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("Aucun ticket.")).toBeTruthy();
});
it("selects several rows and changes their status from the bulk action bar", async () => {
const a = await ticket.create(PROJECT_ID, { title: "Bulk A" });
const b = await ticket.create(PROJECT_ID, { title: "Bulk B" });
await ticket.create(PROJECT_ID, { title: "Unselected" });
const bulkSpy = vi.spyOn(ticket, "bulkUpdateStatus");
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`Sélectionner le ticket ${a.ref}`));
fireEvent.click(screen.getByLabelText(`Sélectionner le ticket ${b.ref}`));
expect(screen.getByRole("toolbar", { name: "Actions groupées sur les tickets" })).toBeTruthy();
fireEvent.click(screen.getByLabelText("Changer le statut"));
fireEvent.click(await screen.findByRole("option", { name: "QA" }));
await waitFor(() =>
expect(bulkSpy).toHaveBeenCalledWith(PROJECT_ID, [a.ref, b.ref], "QA"),
);
await waitFor(async () => {
expect((await ticket.read(PROJECT_ID, a.ref)).status).toBe("QA");
expect((await ticket.read(PROJECT_ID, b.ref)).status).toBe("QA");
});
expect(screen.queryByRole("toolbar", { name: "Actions groupées sur les tickets" })).toBeNull();
});
it("selects several rows and assigns their sprint from the bulk action bar (#112)", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" });
const a = await ticket.create(PROJECT_ID, { title: "Sprint Bulk A" });
const b = await ticket.create(PROJECT_ID, { title: "Sprint Bulk B" });
await ticket.create(PROJECT_ID, { title: "Unselected sprint item" });
const setSpy = vi.spyOn(ticket, "setTicketSprint");
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`Sélectionner le ticket ${a.ref}`));
fireEvent.click(screen.getByLabelText(`Sélectionner le ticket ${b.ref}`));
const toolbar = screen.getByRole("toolbar", { name: "Actions groupées sur les tickets" });
fireEvent.click(within(toolbar).getByRole("button", { name: "Sprint" }));
fireEvent.click(await screen.findByLabelText("Sélectionner le sprint Target"));
await waitFor(() => {
expect(setSpy).toHaveBeenCalledWith(PROJECT_ID, a.ref, "s1", a.version);
expect(setSpy).toHaveBeenCalledWith(PROJECT_ID, b.ref, "s1", b.version);
});
await waitFor(async () => {
expect((await ticket.read(PROJECT_ID, a.ref)).sprintId).toBe("s1");
expect((await ticket.read(PROJECT_ID, b.ref)).sprintId).toBe("s1");
});
expect(screen.getByRole("status").textContent).toMatch(
/Sprint mis à jour pour 2 tickets/i,
);
expect(screen.queryByRole("toolbar", { name: "Actions groupées sur les tickets" })).toBeNull();
});
it("keeps failed refs selected after a partial bulk sprint assignment (#112)", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" });
const a = await ticket.create(PROJECT_ID, { title: "Sprint Partial A" });
const b = await ticket.create(PROJECT_ID, { title: "Sprint Partial B" });
const originalSetTicketSprint = ticket.setTicketSprint.bind(ticket);
vi.spyOn(ticket, "setTicketSprint").mockImplementation(
async (projectId, ref, sprintId, expectedVersion) => {
if (ref === b.ref) {
throw { code: "CONFLICT", message: "version conflict" };
}
return originalSetTicketSprint(projectId, ref, sprintId, expectedVersion);
},
);
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`Sélectionner le ticket ${a.ref}`));
fireEvent.click(screen.getByLabelText(`Sélectionner le ticket ${b.ref}`));
const toolbar = screen.getByRole("toolbar", { name: "Actions groupées sur les tickets" });
fireEvent.click(within(toolbar).getByRole("button", { name: "Sprint" }));
fireEvent.click(await screen.findByLabelText("Sélectionner le sprint Target"));
await waitFor(async () => {
expect((await ticket.read(PROJECT_ID, a.ref)).sprintId).toBe("s1");
expect((await ticket.read(PROJECT_ID, b.ref)).sprintId).toBeNull();
});
expect((await screen.findByRole("alert")).textContent).toContain(
`1 ticket(s) non modifié(s): ${b.ref}`,
);
expect(screen.getByRole("status").textContent).toMatch(/1\/2 tickets/i);
expect(
(screen.getByLabelText(`Sélectionner le ticket ${b.ref}`) as HTMLInputElement)
.checked,
).toBe(true);
expect(
(screen.getByLabelText(`Sélectionner le ticket ${a.ref}`) as HTMLInputElement)
.checked,
).toBe(false);
});
it("selects several rows and deletes them after bulk confirmation", async () => {
const a = await ticket.create(PROJECT_ID, { title: "Delete A" });
const b = await ticket.create(PROJECT_ID, { title: "Delete B" });
await ticket.create(PROJECT_ID, { title: "Keep me" });
const bulkSpy = vi.spyOn(ticket, "bulkDelete");
renderView(ticket, system, agent);
fireEvent.click(await screen.findByLabelText(`Sélectionner le ticket ${a.ref}`));
fireEvent.click(screen.getByLabelText(`Sélectionner le ticket ${b.ref}`));
fireEvent.click(screen.getByRole("button", { name: "Supprimer" }));
const confirm = await screen.findByRole("dialog", {
name: "Confirmer la suppression groupée",
});
fireEvent.click(within(confirm).getByLabelText("Confirmer la suppression groupée"));
await waitFor(() =>
expect(bulkSpy).toHaveBeenCalledWith(PROJECT_ID, [a.ref, b.ref]),
);
await waitFor(() => expect(screen.queryByText("Delete A")).toBeNull());
expect(screen.queryByText("Delete B")).toBeNull();
expect(screen.getByText("Keep me")).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("Annuler la suppression"));
// 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("Contexte de délégation"));
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}` });
// The assistant is opened from the prominent bottom-right floating button;
// it is not inline anymore (#17).
expect(within(detail).queryByLabelText("profil de l'assistant")).toBeNull();
fireEvent.click(within(detail).getByLabelText("Ouvrir l’assistant IA"));
// It opens in its own nested window.
const assistant = await screen.findByRole("dialog", {
name: `Assistant IA — ${t.ref}`,
});
const profileSelect = await within(assistant).findByLabelText(
"profil de l'assistant",
);
expect(within(assistant).getByText("Ouvrir la conversation")).toBeTruthy();
fireEvent.click(profileSelect);
fireEvent.click(await screen.findByRole("option", { name: "QA Assistant" }));
fireEvent.click(within(assistant).getByText("Ouvrir la conversation"));
expect(
await within(assistant).findByLabelText("message à l'assistant"),
).toBeTruthy();
fireEvent.change(within(assistant).getByLabelText("message à l'assistant"), {
target: { value: "Crée un plan de correction" },
});
fireEvent.click(within(assistant).getByText("Envoyer"));
expect(
await within(assistant).findByText("Crée un plan de correction"),
).toBeTruthy();
expect(
await within(assistant).findByText(
"Assistant: reçu « Crée un plan de correction ».",
),
).toBeTruthy();
fireEvent.click(within(assistant).getByText("Fermer la conversation"));
await waitFor(() =>
expect(
within(assistant).queryByLabelText("message à l'assistant"),
).toBeNull(),
);
expect(within(assistant).getByText("Ouvrir la conversation")).toBeTruthy();
expect(
within(assistant).queryByText("Crée un plan de correction"),
).toBeNull();
expect(
within(assistant).queryByText(
"Assistant: reçu « Crée un plan de correction ».",
),
).toBeNull();
});
it("adds a link via the TicketPicker popup instead of a manual #id (#17)", async () => {
const a = await ticket.create(PROJECT_ID, { title: "Source ticket" });
const b = await ticket.create(PROJECT_ID, { title: "Target ticket" });
const gateways = { ticket, system, agent } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<TicketDetail
projectId={PROJECT_ID}
ticketRef={a.ref}
onClose={() => {}}
onOpenRef={() => {}}
/>
</DIProvider>,
);
const detail = await screen.findByRole("dialog", { name: `Ticket ${a.ref}` });
// The old manual "#id" input is gone.
expect(within(detail).queryByLabelText("link target ref")).toBeNull();
// Choose the link kind, then open the picker and select the target ticket.
fireEvent.click(within(detail).getByLabelText("Type de lien"));
fireEvent.click(await screen.findByRole("option", { name: "bloque" }));
fireEvent.click(within(detail).getByLabelText("Ajouter un lien"));
// The picker excludes the ticket itself; the target is offered.
expect(screen.queryByLabelText(`Sélectionner le ticket ${a.ref}`)).toBeNull();
fireEvent.click(await screen.findByLabelText(`Sélectionner le ticket ${b.ref}`));
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, a.ref);
expect(fresh.links).toEqual([{ targetRef: b.ref, kind: "blocks" }]);
});
});
});
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: /gérer les sprints/i }),
).toBeTruthy(),
);
fireEvent.click(screen.getByRole("button", { name: /gérer les sprints/i }));
return screen.findByRole("dialog", { name: /gérer les sprints/i });
}
it("creates a sprint", async () => {
const dialog = await openManager();
fireEvent.change(within(dialog).getByLabelText("nom du nouveau sprint"), {
target: { value: "Alpha" },
});
fireEvent.click(within(dialog).getByText("Créer le sprint"));
await waitFor(async () => {
const sprints = await ticket.listSprints(PROJECT_ID);
expect(sprints.map((s) => s.name)).toEqual(["Alpha"]);
});
expect(
await within(dialog).findByLabelText("ligne du sprint 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("nom du nouveau sprint") as HTMLInputElement)
.value,
).toBe("");
fireEvent.click(within(dialog).getByText("Créer le 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("ligne du sprint 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("renommer le sprint Old"),
{ target: { value: "New" } },
);
fireEvent.click(within(dialog).getByLabelText("enregistrer le 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("descendre le sprint One"),
);
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("supprimer le sprint Doomed"),
);
// The confirmation explains tickets are unassigned, not deleted.
const confirm = await screen.findByRole("dialog", {
name: "confirmation de suppression du sprint",
});
expect(confirm.textContent).toMatch(/ne seront pas supprimés/i);
fireEvent.click(within(confirm).getByLabelText("confirmer la suppression du 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 several backlog tickets to a sprint in one pass via the multi picker (#19/#41)", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" });
const t1 = await ticket.create(PROJECT_ID, { title: "Backlog item" });
const t2 = await ticket.create(PROJECT_ID, { title: "Backlog item 2" });
const dialog = await openManager();
// Open the add-ticket picker for the Target sprint (multi-select).
fireEvent.click(
await within(dialog).findByLabelText("ajouter un ticket au sprint Target"),
);
const picker = await screen.findByRole("dialog", {
name: /Ajouter des tickets au sprint/,
});
// The picker toggles rows without assigning; confirm applies the batch.
fireEvent.click(await within(picker).findByLabelText(`Sélectionner le ticket ${t1.ref}`));
fireEvent.click(await within(picker).findByLabelText(`Sélectionner le ticket ${t2.ref}`));
fireEvent.click(within(picker).getByLabelText("Confirmer la sélection de tickets"));
await waitFor(async () => {
expect((await ticket.read(PROJECT_ID, t1.ref)).sprintId).toBe("s1");
expect((await ticket.read(PROJECT_ID, t2.ref)).sprintId).toBe("s1");
});
});
it("excludes tickets already in the sprint from the add picker (#19)", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" });
const member = await ticket.create(PROJECT_ID, { title: "Already in" });
await ticket.setTicketSprint(PROJECT_ID, member.ref, "s1", member.version);
const backlog = await ticket.create(PROJECT_ID, { title: "Still backlog" });
const dialog = await openManager();
fireEvent.click(
await within(dialog).findByLabelText("ajouter un ticket au sprint Target"),
);
const picker = await screen.findByRole("dialog", {
name: /Ajouter des tickets au sprint/,
});
// The backlog ticket is offered; the one already in the sprint is excluded.
expect(
await within(picker).findByLabelText(`Sélectionner le ticket ${backlog.ref}`),
).toBeTruthy();
expect(
within(picker).queryByLabelText(`Sélectionner le ticket ${member.ref}`),
).toBeNull();
});
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(`retirer ${t.ref} du sprint`),
);
await waitFor(async () => {
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.sprintId).toBeNull();
});
});
/** Seeds sprint "Holder" with one open and one closed ticket assigned. */
async function seedSprintWithOpenAndClosed(): Promise<void> {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Holder" });
const openT = await ticket.create(PROJECT_ID, {
title: "Open member",
priority: "high",
});
await ticket.setTicketSprint(PROJECT_ID, openT.ref, "s1", openT.version);
const closedT = await ticket.create(PROJECT_ID, {
title: "Closed member",
priority: "low",
});
const upd = await ticket.update(PROJECT_ID, closedT.ref, {
status: "closed",
expectedVersion: closedT.version,
});
await ticket.setTicketSprint(PROJECT_ID, closedT.ref, "s1", upd.version);
}
it("keeps closed tickets visible in the sprint with status/priority badges (#37)", async () => {
await seedSprintWithOpenAndClosed();
const dialog = await openManager();
const row = await within(dialog).findByLabelText("ligne du sprint Holder");
// Both the open and the closed ticket are listed (closed is NOT hidden).
expect(within(row).getByText("Open member")).toBeTruthy();
expect(within(row).getByText("Closed member")).toBeTruthy();
// Each row carries its status + priority badge (scoped to the sprint row so
// the facet-bar labels don't collide).
expect(within(row).getByText("Fermé")).toBeTruthy();
expect(within(row).getByText("Ouvert")).toBeTruthy();
expect(within(row).getByText("Haute")).toBeTruthy();
expect(within(row).getByText("Faible")).toBeTruthy();
});
it("filters the sprint tickets by status via its own facets bar (#37)", async () => {
await seedSprintWithOpenAndClosed();
const dialog = await openManager();
await within(dialog).findByText("Open member");
expect(within(dialog).getByText("Closed member")).toBeTruthy();
// Constrain the sprint view to Closed only → the open ticket drops out.
fireEvent.click(within(dialog).getByLabelText("Filtrer le statut Fermé"));
await waitFor(() =>
expect(within(dialog).queryByText("Open member")).toBeNull(),
);
expect(within(dialog).getByText("Closed member")).toBeTruthy();
// Clearing the facet brings the open ticket back.
fireEvent.click(within(dialog).getByLabelText("Filtrer le statut Fermé"));
await waitFor(() =>
expect(within(dialog).getByText("Open member")).toBeTruthy(),
);
});
it("sprint ticket view is independent of the main panel filter (#37)", async () => {
await seedSprintWithOpenAndClosed();
renderView(ticket, system, agent);
await waitFor(() =>
expect(
screen.getByRole("button", { name: /gérer les sprints/i }),
).toBeTruthy(),
);
// Constrain the MAIN Tickets panel to Open only → the closed ticket leaves
// the main list (there is a single main facets bar on screen at this point).
fireEvent.click(screen.getByLabelText("Filtrer le statut Ouvert"));
await waitFor(() => expect(screen.queryByText("Closed member")).toBeNull());
// Open the sprint manager: its own query ignores the main filter, so the
// closed ticket is still shown inside its sprint.
fireEvent.click(screen.getByRole("button", { name: /gérer les sprints/i }));
const dialog = await screen.findByRole("dialog", { name: /gérer les sprints/i });
const row = await within(dialog).findByLabelText("ligne du sprint Holder");
expect(await within(row).findByText("Closed member")).toBeTruthy();
expect(within(row).getByText("Open member")).toBeTruthy();
});
});