feat(tickets): attribution d'un sprint à la création via popup SprintPicker (#38)
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>
This commit is contained in:
164
frontend/src/features/tickets/SprintPicker.test.tsx
Normal file
164
frontend/src/features/tickets/SprintPicker.test.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user