import { describe, it, expect, beforeEach, vi } from "vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { DIProvider } from "@/app/di"; import { MockSystemGateway, MockTicketGateway } from "@/adapters/mock"; import type { Ticket } from "@/domain"; import type { Gateways } from "@/ports"; import { TicketPicker, type TicketPickerResult } from "./TicketPicker"; const PROJECT_ID = "project-picker-test"; function renderPicker( ticket: MockTicketGateway, props: Partial> = {}, ) { const onSelect = vi.fn(); const onClose = vi.fn(); const gateways = { ticket } as unknown as Gateways; const utils = render( , ); return { ...utils, onSelect, onClose }; } /** Seeds three tickets with distinct status/priority for filter assertions. */ async function seedTrio(ticket: MockTicketGateway): Promise { const a = await ticket.create(PROJECT_ID, { title: "Alpha bug", status: "open", priority: "high", }); const b = await ticket.create(PROJECT_ID, { title: "Bravo task", priority: "low", }); await ticket.update(PROJECT_ID, b.ref, { status: "closed", expectedVersion: b.version, }); const c = await ticket.create(PROJECT_ID, { title: "Charlie chore", status: "QA", priority: "medium", }); return [a, b, c]; } describe("TicketPicker (#18)", () => { let system: MockSystemGateway; let ticket: MockTicketGateway; beforeEach(() => { system = new MockSystemGateway(); ticket = new MockTicketGateway(system); }); it("renders nothing when closed", () => { const gateways = { ticket } as unknown as Gateways; const { container } = render( , ); expect(container.querySelector('[role="dialog"]')).toBeNull(); }); it("lists tickets and single-select hands back the full result + closes", async () => { const [a] = await seedTrio(ticket); const { onSelect, onClose } = renderPicker(ticket); // Rows appear. await screen.findByText("Alpha bug"); expect(screen.getByText("Bravo task")).toBeTruthy(); expect(screen.getByText("Charlie chore")).toBeTruthy(); fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`)); await waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1)); const result = onSelect.mock.calls[0][0] as TicketPickerResult; expect(result).toMatchObject({ ref: a.ref, id: a.id, number: a.number, title: "Alpha bug", status: "open", priority: "high", }); expect(onClose).toHaveBeenCalledTimes(1); }); it("hides excludeRefs client-side", async () => { const [a, b] = await seedTrio(ticket); renderPicker(ticket, { excludeRefs: [a.ref] }); await screen.findByText("Bravo task"); expect(screen.queryByText("Alpha bug")).toBeNull(); // Excluded ref cannot be selected either. expect(screen.queryByLabelText(`select ticket ${a.ref}`)).toBeNull(); expect(screen.getByLabelText(`select ticket ${b.ref}`)).toBeTruthy(); }); it("filters by status facet (OR within, AND across)", async () => { await seedTrio(ticket); renderPicker(ticket); await screen.findByText("Alpha bug"); // Tick "open" ⇒ only Alpha (open) remains; Bravo (closed) & Charlie (QA) go. fireEvent.click(screen.getByLabelText("filter status Open")); await waitFor(() => expect(screen.queryByText("Bravo task")).toBeNull()); expect(screen.getByText("Alpha bug")).toBeTruthy(); expect(screen.queryByText("Charlie chore")).toBeNull(); // Add priority "low" ⇒ (open) AND (low) ⇒ nothing (Alpha is high). fireEvent.click(screen.getByLabelText("filter priority Low")); await waitFor(() => expect(screen.getByText("No matching tickets.")).toBeTruthy(), ); // Clear facets ⇒ all three back. fireEvent.click(screen.getByLabelText("clear filters")); await waitFor(() => expect(screen.getByText("Charlie chore")).toBeTruthy()); expect(screen.getByText("Alpha bug")).toBeTruthy(); expect(screen.getByText("Bravo task")).toBeTruthy(); }); it("sorts the results via the « Trier par… » control (#21)", async () => { const listSpy = vi.spyOn(ticket, "list"); await seedTrio(ticket); renderPicker(ticket); await screen.findByText("Alpha bug"); // The sort control is present in the picker too (shared TicketFacetsBar). const sortBy = screen.getByLabelText("sort tickets by"); fireEvent.change(sortBy, { target: { value: "title" } }); await waitFor(() => expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({ field: "title", direction: "asc", }), ); // Ascending by title ⇒ Alpha before Charlie in the DOM. await waitFor(() => { const rendered = screen .getAllByText(/Alpha bug|Bravo task|Charlie chore/) .map((n) => n.textContent); expect(rendered.indexOf("Alpha bug")).toBeLessThan( rendered.indexOf("Charlie chore"), ); }); // Flip to descending ⇒ Charlie now precedes Alpha. fireEvent.click(screen.getByLabelText("sort direction ascending")); await waitFor(() => expect(listSpy.mock.calls.at(-1)?.[1]?.sort).toEqual({ field: "title", direction: "desc", }), ); await waitFor(() => { const rendered = screen .getAllByText(/Alpha bug|Bravo task|Charlie chore/) .map((n) => n.textContent); expect(rendered.indexOf("Charlie chore")).toBeLessThan( rendered.indexOf("Alpha bug"), ); }); }); it("debounced text search narrows the list", async () => { await seedTrio(ticket); renderPicker(ticket); await screen.findByText("Alpha bug"); fireEvent.change(screen.getByLabelText("search tickets"), { target: { value: "charlie" }, }); // Wait on the removal (all three are visible until the debounced refetch). await waitFor(() => expect(screen.queryByText("Alpha bug")).toBeNull()); expect(screen.getByText("Charlie chore")).toBeTruthy(); expect(screen.queryByText("Bravo task")).toBeNull(); }); it("honors initialQuery as a seed filter", async () => { await seedTrio(ticket); renderPicker(ticket, { initialQuery: { statuses: ["QA"] } }); await screen.findByText("Charlie chore"); expect(screen.queryByText("Alpha bug")).toBeNull(); expect(screen.queryByText("Bravo task")).toBeNull(); }); it("closes on Escape and on backdrop click", async () => { await seedTrio(ticket); const { onClose } = renderPicker(ticket); await screen.findByText("Alpha bug"); fireEvent.keyDown(document, { key: "Escape" }); expect(onClose).toHaveBeenCalledTimes(1); // Backdrop (the outermost fixed overlay) closes on mousedown. const dialog = screen.getByRole("dialog"); const backdrop = dialog.parentElement as HTMLElement; fireEvent.mouseDown(backdrop); expect(onClose).toHaveBeenCalledTimes(2); }); it("uses a custom title and surfaces a resolve failure without closing", async () => { const [a] = await seedTrio(ticket); const readSpy = vi .spyOn(ticket, "read") .mockRejectedValueOnce({ code: "NOT_FOUND", message: "gone" }); const { onSelect, onClose } = renderPicker(ticket, { title: "Choisir un ticket à lier", }); await screen.findByText("Alpha bug"); expect( screen.getByRole("dialog", { name: "Choisir un ticket à lier" }), ).toBeTruthy(); fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`)); await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("gone")); expect(onSelect).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled(); readSpy.mockRestore(); }); });