import { describe, it, expect, beforeEach, vi } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import { DIProvider } from "@/app/di"; import { MockSystemGateway, MockTicketGateway } from "@/adapters/mock"; import type { Gateways } from "@/ports"; import { SprintPicker } from "./SprintPicker"; const PROJECT_ID = "project-sprint-picker"; 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 two ordered sprints. */ function seedSprints(ticket: MockTicketGateway) { const beta = ticket._seedSprint(PROJECT_ID, { id: "s2", order: 2, name: "Beta", ticketCount: 3, }); const alpha = ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Alpha", }); return { alpha, beta }; } describe("SprintPicker (#38)", () => { 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 the project's sprints (ordered) plus a « Sans sprint » entry", async () => { seedSprints(ticket); renderPicker(ticket); // "Sans sprint" is always present. expect(screen.getByLabelText("select no sprint")).toBeTruthy(); // Both sprints appear once loaded, ordered by `order` (Alpha before Beta). await screen.findByLabelText("select sprint Alpha"); const options = screen .getAllByRole("option") .map((n) => n.getAttribute("aria-label")); expect(options).toEqual([ "select no sprint", "select sprint Alpha", "select sprint Beta", ]); }); it("selecting a sprint hands back the sprint and closes", async () => { const { alpha } = seedSprints(ticket); const { onSelect, onClose } = renderPicker(ticket); fireEvent.click(await screen.findByLabelText("select sprint Alpha")); expect(onSelect).toHaveBeenCalledTimes(1); expect(onSelect.mock.calls[0][0]).toMatchObject({ id: alpha.id, name: "Alpha", }); expect(onClose).toHaveBeenCalledTimes(1); }); it("selecting « Sans sprint » hands back null and closes", async () => { seedSprints(ticket); const { onSelect, onClose } = renderPicker(ticket, { selectedSprintId: "s1", }); await screen.findByLabelText("select sprint Alpha"); fireEvent.click(screen.getByLabelText("select no sprint")); expect(onSelect).toHaveBeenCalledTimes(1); expect(onSelect.mock.calls[0][0]).toBeNull(); expect(onClose).toHaveBeenCalledTimes(1); }); it("marks the currently-selected sprint as aria-selected", async () => { seedSprints(ticket); renderPicker(ticket, { selectedSprintId: "s2" }); const beta = await screen.findByLabelText("select sprint Beta"); expect(beta.getAttribute("aria-selected")).toBe("true"); expect( screen.getByLabelText("select sprint Alpha").getAttribute("aria-selected"), ).toBe("false"); // "Sans sprint" is not selected when a sprint id is active. expect( screen.getByLabelText("select no sprint").getAttribute("aria-selected"), ).toBe("false"); }); it("shows an empty state when the project has no sprints", async () => { renderPicker(ticket); expect(await screen.findByText("No sprints yet.")).toBeTruthy(); // "Sans sprint" is still offered so the user can clear an assignment. expect(screen.getByLabelText("select no sprint")).toBeTruthy(); }); it("closes on Escape and on backdrop click", async () => { seedSprints(ticket); const { onClose } = renderPicker(ticket); await screen.findByLabelText("select sprint Alpha"); fireEvent.keyDown(document, { key: "Escape" }); expect(onClose).toHaveBeenCalledTimes(1); const dialog = screen.getByRole("dialog"); const backdrop = dialog.parentElement as HTMLElement; fireEvent.mouseDown(backdrop); expect(onClose).toHaveBeenCalledTimes(2); }); it("surfaces a listSprints failure", async () => { vi.spyOn(ticket, "listSprints").mockRejectedValueOnce({ code: "INTERNAL", message: "boom", }); renderPicker(ticket); const alert = await screen.findByRole("alert"); expect(alert.textContent).toContain("boom"); }); });