Nouveau composant SprintPicker permettant de choisir un sprint lors de la création d'un ticket depuis TicketsPanel ; export ajouté à l'index des features tickets. Frontend-pur. QA vert : tsc --noEmit exit 0, vitest 63 fichiers / 634 tests passés. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
165 lines
5.0 KiB
TypeScript
165 lines
5.0 KiB
TypeScript
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<React.ComponentProps<typeof SprintPicker>> = {},
|
|
) {
|
|
const onSelect = vi.fn();
|
|
const onClose = vi.fn();
|
|
const gateways = { ticket } as unknown as Gateways;
|
|
const utils = render(
|
|
<DIProvider gateways={gateways}>
|
|
<SprintPicker
|
|
open
|
|
projectId={PROJECT_ID}
|
|
onSelect={onSelect}
|
|
onClose={onClose}
|
|
{...props}
|
|
/>
|
|
</DIProvider>,
|
|
);
|
|
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(
|
|
<DIProvider gateways={gateways}>
|
|
<SprintPicker
|
|
open={false}
|
|
projectId={PROJECT_ID}
|
|
onSelect={vi.fn()}
|
|
onClose={vi.fn()}
|
|
/>
|
|
</DIProvider>,
|
|
);
|
|
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");
|
|
});
|
|
});
|