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, MockSystemGateway, MockTicketGateway, } from "@/adapters/mock"; import type { Agent } 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( , ); } 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 status and searches text", 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, { status: "open" }); expect(open.items.map((i) => i.ref)).toEqual(["#1"]); const search = await ticket.list(PROJECT_ID, { text: "beta" }); expect(search.items.map((i) => i.ref)).toEqual(["#2"]); }); 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("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("opens the detail and edits status with a version bump", async () => { const t = await ticket.create(PROJECT_ID, { title: "Editable" }); renderView(ticket, system, agent); fireEvent.click(await screen.findByText("Editable")); const dialog = await screen.findByRole("dialog"); const statusSelect = within(dialog).getByLabelText("ticket status"); fireEvent.change(statusSelect, { target: { value: "inProgress" } }); await waitFor(async () => { const fresh = await ticket.read(PROJECT_ID, t.ref); expect(fresh.status).toBe("inProgress"); expect(fresh.version).toBe(2); }); }); it("saves the ticket-scoped carnet (replaceable)", async () => { const t = await ticket.create(PROJECT_ID, { title: "WithCarnet" }); renderView(ticket, system, agent); fireEvent.click(await screen.findByText("WithCarnet")); const dialog = await screen.findByRole("dialog"); fireEvent.change(within(dialog).getByLabelText("ticket carnet"), { target: { value: "## notes\nscoped body" }, }); fireEvent.click(within(dialog).getByText("Save carnet")); await waitFor(async () => { const carnet = await ticket.readCarnet(PROJECT_ID, t.ref); expect(carnet.carnet).toBe("## notes\nscoped body"); }); }); it("assigns only known project agents", async () => { const known = await seedAgent(agent, "Backend"); const t = await ticket.create(PROJECT_ID, { title: "Assignable" }); renderView(ticket, system, agent); fireEvent.click(await screen.findByText("Assignable")); const dialog = await screen.findByRole("dialog"); fireEvent.change(within(dialog).getByLabelText("assign agent"), { target: { value: known.id }, }); fireEvent.click(within(dialog).getByText("Assign")); await waitFor(async () => { const fresh = await ticket.read(PROJECT_ID, t.ref); expect(fresh.assignedAgentIds).toEqual([known.id]); }); expect(within(dialog).getAllByText("Backend").length).toBeGreaterThan(0); }); it("surfaces a version-conflict banner and reloads (F3)", async () => { // No system gateway ⇒ the detail does not auto-refresh on events, so we can // deterministically make its held version go stale under an external write. const t = await ticket.create(PROJECT_ID, { title: "Racy" }); const gateways = { ticket, agent } as unknown as Gateways; render( {}} 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("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`, ), ); }); });