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.
1403 lines
53 KiB
TypeScript
1403 lines
53 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("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();
|
||
});
|
||
});
|