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 { 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( , ); } 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("No tickets.")).toBeTruthy(); await ticket.create(PROJECT_ID, { title: "Live created" }); // The panel refreshes on the issueCreated event. expect(await screen.findByText("Live created")).toBeTruthy(); }); it("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: "New" })); fireEvent.change(screen.getByLabelText("new ticket title"), { target: { value: "With sprint" }, }); // Field defaults to "Sans sprint"; open the picker and pick Alpha. const sprintField = screen.getByLabelText("choose sprint for new ticket"); expect(sprintField.textContent).toContain("Sans sprint"); fireEvent.click(sprintField); fireEvent.click(await screen.findByLabelText("select sprint Alpha")); expect( screen.getByLabelText("choose sprint for new ticket").textContent, ).toContain("Alpha"); fireEvent.click(screen.getByRole("button", { name: "Create" })); // 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: "New" })); fireEvent.change(screen.getByLabelText("new ticket title"), { target: { value: "No sprint" }, }); fireEvent.click(screen.getByRole("button", { name: "Create" })); 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: "New" })); fireEvent.change(screen.getByLabelText("new ticket title"), { target: { value: "Kept anyway" }, }); fireEvent.click(screen.getByLabelText("choose sprint for new ticket")); fireEvent.click(await screen.findByLabelText("select sprint Alpha")); fireEvent.click(screen.getByRole("button", { name: "Create" })); // 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("filter status Open")); fireEvent.click(screen.getByLabelText("filter status Closed")); await waitFor(() => expect(screen.queryByText("Charlie")).toBeNull()); expect(screen.getByText("Alpha")).toBeTruthy(); expect(screen.getByText("Bravo")).toBeTruthy(); // Add a priority → AND across facets ⇒ (open|closed) AND high ⇒ only Alpha. fireEvent.click(screen.getByLabelText("filter priority High")); await waitFor(() => expect(screen.queryByText("Bravo")).toBeNull()); expect(screen.getByText("Alpha")).toBeTruthy(); // The gateway received the expected multi-select query. await waitFor(() => expect(listSpy.mock.calls.at(-1)?.[1]).toMatchObject({ statuses: ["open", "closed"], priorities: ["high"], }), ); // « Effacer » empties both facets ⇒ all three back. fireEvent.click(screen.getByLabelText("clear filters")); await waitFor(() => expect(screen.getByText("Charlie")).toBeTruthy()); expect(screen.getByText("Bravo")).toBeTruthy(); expect(screen.getByText("Alpha")).toBeTruthy(); }); it("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("sort tickets by")); 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("sort direction ascending"), ); 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("sort tickets by")); 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("filter by creator")); 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("ticket status")); fireEvent.click(await screen.findByRole("option", { name: "In progress" })); await waitFor(async () => { const fresh = await ticket.read(PROJECT_ID, t.ref); expect(fresh.status).toBe("inProgress"); expect(fresh.version).toBe(2); }); }); it("saves the ticket-scoped carnet (replaceable)", async () => { const t = await ticket.create(PROJECT_ID, { title: "WithCarnet" }); renderView(ticket, system, agent); fireEvent.click(await screen.findByText("WithCarnet")); const dialog = await screen.findByRole("dialog"); fireEvent.change(within(dialog).getByLabelText("ticket carnet"), { target: { value: "## notes\nscoped body" }, }); fireEvent.click(within(dialog).getByText("Save carnet")); await waitFor(async () => { const carnet = await ticket.readCarnet(PROJECT_ID, t.ref); expect(carnet.carnet).toBe("## notes\nscoped body"); }); }); it("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 résumé mock-attachment.txt"), ); 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("assign agent")); fireEvent.click(await screen.findByRole("option", { name: "Backend" })); fireEvent.click(within(dialog).getByText("Assign")); await waitFor(async () => { const fresh = await ticket.read(PROJECT_ID, t.ref); expect(fresh.assignedAgentIds).toEqual([known.id]); }); expect(within(dialog).getAllByText("Backend").length).toBeGreaterThan(0); }); it("surfaces a version-conflict banner and reloads (F3)", async () => { // No system gateway ⇒ the detail does not auto-refresh on events, so we can // deterministically make its held version go stale under an external write. const t = await ticket.create(PROJECT_ID, { title: "Racy" }); const gateways = { ticket, agent } as unknown as Gateways; render( {}} onOpenRef={() => {}} /> , ); const dialog = await screen.findByRole("dialog"); // The detail now holds version 1. Bump the store to version 2 behind its back. await ticket.update(PROJECT_ID, t.ref, { description: "external", expectedVersion: t.version, }); fireEvent.change(within(dialog).getByLabelText("Title"), { target: { value: "Racy edited" }, }); fireEvent.click(within(dialog).getByText("Save changes")); expect( await within(dialog).findByText(/modified elsewhere and reloaded/i), ).toBeTruthy(); // After the reload the detail field reflects the reloaded (title unchanged) // ticket rather than the abandoned local edit. await waitFor(() => expect( (within(dialog).getByLabelText("Title") as HTMLInputElement) .value, ).toBe("Racy"), ); }); it("preserves an in-progress description edit across a priority change (#9)", async () => { const t = await ticket.create(PROJECT_ID, { title: "Persisted" }); renderView(ticket, system, agent); fireEvent.click(await screen.findByText("Persisted")); const dialog = await screen.findByRole("dialog"); // Start editing the description WITHOUT saving. const desc = within(dialog).getByLabelText("Description") as HTMLTextAreaElement; fireEvent.change(desc, { target: { value: "draft in progress" } }); // Also edit the title to cover both draft fields. const titleInput = within(dialog).getByLabelText("Title") as HTMLInputElement; fireEvent.change(titleInput, { target: { value: "Persisted (wip)" } }); // Change the priority — an immediate-apply mutation that re-fetches the ticket // and bumps its version. The unsaved draft must survive. fireEvent.click(within(dialog).getByLabelText("ticket priority")); fireEvent.click(await screen.findByRole("option", { name: "High" })); // The backend applied the priority bump… await waitFor(async () => { const fresh = await ticket.read(PROJECT_ID, t.ref); expect(fresh.priority).toBe("high"); expect(fresh.version).toBe(2); }); // …and the in-progress edits are still there (not reverted). expect( (within(dialog).getByLabelText("Description") as HTMLTextAreaElement).value, ).toBe("draft in progress"); expect( (within(dialog).getByLabelText("Title") as HTMLInputElement).value, ).toBe("Persisted (wip)"); // The description was never persisted. const persisted = await ticket.read(PROJECT_ID, t.ref); expect(persisted.description).toBe(""); }); it("prompts before closing with unsaved changes, and closes directly when clean (#9)", async () => { const t = await ticket.create(PROJECT_ID, { title: "CloseGuard" }); const onClose = vi.fn(); const gateways = { ticket, system, agent } as unknown as Gateways; render( {}} /> , ); const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` }); // Clean state → Close fires onClose directly, no confirmation dialog. fireEvent.click(within(detail).getByText("Close")); expect(onClose).toHaveBeenCalledTimes(1); expect(screen.queryByRole("dialog", { name: "unsaved changes" })).toBeNull(); // Now make the draft dirty and try to close again. fireEvent.change(within(detail).getByLabelText("Description"), { target: { value: "unsaved body" }, }); fireEvent.click(within(detail).getByText("Close")); // The confirmation appears and onClose was NOT called again. const confirm = await screen.findByRole("dialog", { name: "unsaved changes" }); expect(onClose).toHaveBeenCalledTimes(1); // "Annuler" dismisses the popup and keeps the editor open. fireEvent.click(within(confirm).getByLabelText("cancel close")); await waitFor(() => expect(screen.queryByRole("dialog", { name: "unsaved changes" })).toBeNull(), ); expect(onClose).toHaveBeenCalledTimes(1); // Re-open the confirmation and choose "Fermer sans sauvegarder". fireEvent.click(within(detail).getByText("Close")); const confirm2 = await screen.findByRole("dialog", { name: "unsaved changes" }); fireEvent.click(within(confirm2).getByLabelText("close without saving")); expect(onClose).toHaveBeenCalledTimes(2); // The unsaved description was discarded (never persisted). const fresh = await ticket.read(PROJECT_ID, t.ref); expect(fresh.description).toBe(""); }); it("saves unsaved changes then closes when choosing « Sauvegarder » (#9)", async () => { const t = await ticket.create(PROJECT_ID, { title: "SaveOnClose" }); const onClose = vi.fn(); const gateways = { ticket, system, agent } as unknown as Gateways; render( {}} /> , ); const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` }); fireEvent.change(within(detail).getByLabelText("Description"), { target: { value: "final body" }, }); fireEvent.click(within(detail).getByText("Close")); const confirm = await screen.findByRole("dialog", { name: "unsaved changes" }); fireEvent.click(within(confirm).getByLabelText("save and close")); // The change is persisted and the panel closes. await waitFor(async () => { const fresh = await ticket.read(PROJECT_ID, t.ref); expect(fresh.description).toBe("final body"); }); await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); }); it("groups tickets by sprint with a « Sans sprint » bucket (#10)", async () => { // Two ordered sprints + one ticket in each + one loose ticket. ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Sprint One" }); ticket._seedSprint(PROJECT_ID, { id: "s2", order: 2, name: "Sprint Two" }); const t1 = await ticket.create(PROJECT_ID, { title: "In one" }); const t2 = await ticket.create(PROJECT_ID, { title: "In two" }); await ticket.create(PROJECT_ID, { title: "Loose" }); await ticket.setTicketSprint(PROJECT_ID, t1.ref, "s1", t1.version); await ticket.setTicketSprint(PROJECT_ID, t2.ref, "s2", t2.version); renderView(ticket, system, agent); // Sections appear in sprint `order`, then the "Sans sprint" bucket last. await screen.findByRole("region", { name: "sprint section Sprint One" }); const sections = screen .getAllByRole("region") .map((r) => r.getAttribute("aria-label")) .filter((l) => l?.startsWith("sprint section")); expect(sections).toEqual([ "sprint section Sprint One", "sprint section Sprint Two", "sprint section Sans sprint", ]); // The loose ticket lives in the bucket, the others under their sprint. const bucket = screen.getByRole("region", { name: "sprint section Sans sprint" }); expect(within(bucket).getByText("Loose")).toBeTruthy(); const one = screen.getByRole("region", { name: "sprint section Sprint One" }); expect(within(one).getByText("In one")).toBeTruthy(); }); it("assigns a ticket to a sprint via the row selector (#10)", async () => { ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Sprint One" }); const t = await ticket.create(PROJECT_ID, { title: "Movable" }); renderView(ticket, system, agent); // It starts in the "Sans sprint" bucket. const bucket = await screen.findByRole("region", { name: "sprint section Sans sprint", }); expect(within(bucket).getByText("Movable")).toBeTruthy(); // Pick the sprint in the row selector → assign it. fireEvent.click(screen.getByLabelText(`sprint for ${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: "sprint section Sprint One", }); expect(within(one).getByText("Movable")).toBeTruthy(); }); expect( screen.queryByRole("region", { name: "sprint section Sans sprint" }), ).toBeNull(); }); it("deletes a ticket from the detail: confirmation ⇒ row gone + detail closed (#6)", async () => { const t = await ticket.create(PROJECT_ID, { title: "Deletable" }); renderView(ticket, system, agent); fireEvent.click(await screen.findByText("Deletable")); const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}`, }); // Open the confirmation, then confirm the deletion. fireEvent.click(within(detail).getByText("Supprimer")); const confirm = await screen.findByRole("dialog", { name: "confirmer la suppression", }); fireEvent.click(within(confirm).getByLabelText("confirm delete")); // The gateway removed the ticket… await waitFor(() => expect(ticket.read(PROJECT_ID, t.ref)).rejects.toMatchObject({ code: "NOT_FOUND", }), ); // …the detail closed (via the `issueDeleted` event → `deleted` flag)… await waitFor(() => expect( screen.queryByRole("dialog", { name: `ticket ${t.ref}` }), ).toBeNull(), ); // …and the row disappeared from the list (event-driven refresh). await waitFor(() => expect(screen.queryByText("Deletable")).toBeNull()); expect(await screen.findByText("No tickets.")).toBeTruthy(); }); it("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(`select ticket row ${a.ref}`)); fireEvent.click(screen.getByLabelText(`select ticket row ${b.ref}`)); expect(screen.getByRole("toolbar", { name: "bulk ticket actions" })).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: "bulk ticket actions" })).toBeNull(); }); 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(`select ticket row ${a.ref}`)); fireEvent.click(screen.getByLabelText(`select ticket row ${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("confirm bulk delete")); 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("cancel delete")); // The confirmation is dismissed, delete was never called, ticket stays. await waitFor(() => expect( screen.queryByRole("dialog", { name: "confirmer la suppression" }), ).toBeNull(), ); expect(delSpy).not.toHaveBeenCalled(); expect( screen.getByRole("dialog", { name: `ticket ${t.ref}` }), ).toBeTruthy(); const fresh = await ticket.read(PROJECT_ID, t.ref); expect(fresh.title).toBe("Keeper"); }); it("copies a delegation context prompt (F7)", async () => { const writeText = stubClipboard(); const t = await ticket.create(PROJECT_ID, { title: "Delegate me" }); renderView(ticket, system, agent); fireEvent.click(await screen.findByText("Delegate me")); const dialog = await screen.findByRole("dialog"); fireEvent.click(within(dialog).getByText("Delegation context")); await waitFor(() => expect(writeText).toHaveBeenCalledWith( `Travaille sur le ticket ${t.ref} : Delegate me`, ), ); }); it("opens, streams, and closes the ticket AI assistant chat (#8)", async () => { const t = await ticket.create(PROJECT_ID, { title: "Assistant target" }); const profile = new MockProfileGateway(); await seedAssistantProfile(profile); const gateways = { ticket, system, agent, profile } as unknown as Gateways; render( {}} onOpenRef={() => {}} /> , ); 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( {}} onOpenRef={() => {}} /> , ); 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("link kind")); fireEvent.click(await screen.findByRole("option", { name: "blocks" })); fireEvent.click(within(detail).getByLabelText("add link")); // The picker excludes the ticket itself; the target is offered. expect(screen.queryByLabelText(`select ticket ${a.ref}`)).toBeNull(); fireEvent.click(await screen.findByLabelText(`select 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 { renderView(ticket, system, agent); await waitFor(() => expect( screen.getByRole("button", { name: "manage sprints" }), ).toBeTruthy(), ); fireEvent.click(screen.getByRole("button", { name: "manage sprints" })); return screen.findByRole("dialog", { name: "manage sprints" }); } it("creates a sprint", async () => { const dialog = await openManager(); fireEvent.change(within(dialog).getByLabelText("new sprint name"), { target: { value: "Alpha" }, }); fireEvent.click(within(dialog).getByText("Create sprint")); await waitFor(async () => { const sprints = await ticket.listSprints(PROJECT_ID); expect(sprints.map((s) => s.name)).toEqual(["Alpha"]); }); expect( await within(dialog).findByLabelText("sprint row Alpha"), ).toBeTruthy(); }); it("creates a sprint with a default positional name when the name is empty", async () => { const dialog = await openManager(); // Leave the name field empty and create → default "Sprint N" (here N=1). expect( (within(dialog).getByLabelText("new sprint name") as HTMLInputElement) .value, ).toBe(""); fireEvent.click(within(dialog).getByText("Create sprint")); await waitFor(async () => { const sprints = await ticket.listSprints(PROJECT_ID); expect(sprints.map((s) => s.name)).toEqual(["Sprint 1"]); }); expect( await within(dialog).findByLabelText("sprint row Sprint 1"), ).toBeTruthy(); }); it("renames a sprint", async () => { ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Old" }); const dialog = await openManager(); fireEvent.change( await within(dialog).findByLabelText("rename sprint Old"), { target: { value: "New" } }, ); fireEvent.click(within(dialog).getByLabelText("save sprint Old")); await waitFor(async () => { const [s] = await ticket.listSprints(PROJECT_ID); expect(s.name).toBe("New"); }); }); it("reorders sprints via the accessible down button", async () => { ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "One" }); ticket._seedSprint(PROJECT_ID, { id: "s2", order: 2, name: "Two" }); const dialog = await openManager(); fireEvent.click( await within(dialog).findByLabelText("move sprint One down"), ); await waitFor(async () => { const sprints = await ticket.listSprints(PROJECT_ID); // "Two" is now first (order 1), "One" second. expect(sprints.map((s) => s.name)).toEqual(["Two", "One"]); }); }); it("deletes a sprint and unassigns (does not delete) its tickets", async () => { ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Doomed" }); const t = await ticket.create(PROJECT_ID, { title: "Kept" }); await ticket.setTicketSprint(PROJECT_ID, t.ref, "s1", t.version); const dialog = await openManager(); fireEvent.click( await within(dialog).findByLabelText("delete sprint Doomed"), ); // The confirmation explains tickets are unassigned, not deleted. const confirm = await screen.findByRole("dialog", { name: "delete sprint confirmation", }); expect(confirm.textContent).toMatch(/ne seront pas supprimés/i); fireEvent.click(within(confirm).getByLabelText("confirm delete sprint")); await waitFor(async () => { expect(await ticket.listSprints(PROJECT_ID)).toHaveLength(0); }); // The ticket still exists, just unassigned. const fresh = await ticket.read(PROJECT_ID, t.ref); expect(fresh.sprintId).toBeNull(); expect(fresh.title).toBe("Kept"); }); it("adds 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("add ticket to sprint Target"), ); // The picker toggles rows without assigning; confirm applies the batch. fireEvent.click(await screen.findByLabelText(`select ticket ${t1.ref}`)); fireEvent.click(await screen.findByLabelText(`select ticket ${t2.ref}`)); fireEvent.click(screen.getByLabelText("confirm ticket selection")); 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("add ticket to sprint Target"), ); // The backlog ticket is offered; the one already in the sprint is excluded. expect( await screen.findByLabelText(`select ticket ${backlog.ref}`), ).toBeTruthy(); expect( screen.queryByLabelText(`select 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(`remove ${t.ref} from 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 { 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("sprint row 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("Closed")).toBeTruthy(); expect(within(row).getByText("Open")).toBeTruthy(); expect(within(row).getByText("High")).toBeTruthy(); expect(within(row).getByText("Low")).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("filter status Closed")); 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("filter status Closed")); 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: "manage sprints" }), ).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("filter status Open")); 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: "manage sprints" })); const dialog = await screen.findByRole("dialog", { name: "manage sprints" }); const row = await within(dialog).findByLabelText("sprint row Holder"); expect(await within(row).findByText("Closed member")).toBeTruthy(); expect(within(row).getByText("Open member")).toBeTruthy(); }); });