Le TicketPicker permet la sélection multiple de tickets ; SprintManager consomme la sélection multiple. Frontend-pur. QA vert : tsc --noEmit exit 0, vitest 62 fichiers / 620 tests passés. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
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<React.ComponentProps<typeof TicketPicker>> = {},
|
|
) {
|
|
const onSelect = vi.fn();
|
|
const onClose = vi.fn();
|
|
const gateways = { ticket } as unknown as Gateways;
|
|
const utils = render(
|
|
<DIProvider gateways={gateways}>
|
|
<TicketPicker
|
|
open
|
|
projectId={PROJECT_ID}
|
|
onSelect={onSelect}
|
|
onClose={onClose}
|
|
{...props}
|
|
/>
|
|
</DIProvider>,
|
|
);
|
|
return { ...utils, onSelect, onClose };
|
|
}
|
|
|
|
/** Seeds three tickets with distinct status/priority for filter assertions. */
|
|
async function seedTrio(ticket: MockTicketGateway): Promise<Ticket[]> {
|
|
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(
|
|
<DIProvider gateways={gateways}>
|
|
<TicketPicker
|
|
open={false}
|
|
projectId={PROJECT_ID}
|
|
onSelect={vi.fn()}
|
|
onClose={vi.fn()}
|
|
/>
|
|
</DIProvider>,
|
|
);
|
|
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();
|
|
});
|
|
|
|
describe("multi-select (#41)", () => {
|
|
it("toggles rows without closing and confirms the resolved array", async () => {
|
|
const [a, , c] = await seedTrio(ticket);
|
|
const { onSelect, onClose } = renderPicker(ticket, {
|
|
selectionMode: "multi",
|
|
confirmLabel: "Ajouter au sprint",
|
|
});
|
|
|
|
await screen.findByText("Alpha bug");
|
|
|
|
const confirm = screen.getByLabelText(
|
|
"confirm ticket selection",
|
|
) as HTMLButtonElement;
|
|
// Empty selection ⇒ confirm disabled, count reads 0.
|
|
expect(confirm.disabled).toBe(true);
|
|
expect(screen.getByText("0 sélectionné")).toBeTruthy();
|
|
|
|
// Select two rows; the picker stays open and does not fire onSelect yet.
|
|
fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
|
|
fireEvent.click(screen.getByLabelText(`select ticket ${c.ref}`));
|
|
expect(onSelect).not.toHaveBeenCalled();
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
|
|
// Visual state: the toggled rows expose aria-checked.
|
|
expect(
|
|
screen.getByLabelText(`select ticket ${a.ref}`).getAttribute("aria-checked"),
|
|
).toBe("true");
|
|
expect(screen.getByText("2 sélectionnés")).toBeTruthy();
|
|
expect(confirm.textContent).toContain("2");
|
|
expect(confirm.disabled).toBe(false);
|
|
|
|
// Toggle one back off ⇒ count drops, still open.
|
|
fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
|
|
expect(screen.getByText("1 sélectionné")).toBeTruthy();
|
|
expect(
|
|
screen.getByLabelText(`select ticket ${a.ref}`).getAttribute("aria-checked"),
|
|
).toBe("false");
|
|
|
|
// Re-select and confirm ⇒ onSelect receives the resolved array, then close.
|
|
fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
|
|
fireEvent.click(confirm);
|
|
|
|
await waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
|
|
const results = onSelect.mock.calls[0][0] as TicketPickerResult[];
|
|
expect(Array.isArray(results)).toBe(true);
|
|
expect(results.map((r) => r.ref).sort()).toEqual([a.ref, c.ref].sort());
|
|
expect(results).toContainEqual(
|
|
expect.objectContaining({ ref: a.ref, id: a.id, number: a.number }),
|
|
);
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("confirm stays disabled when selection is empty", async () => {
|
|
await seedTrio(ticket);
|
|
const { onSelect } = renderPicker(ticket, { selectionMode: "multi" });
|
|
await screen.findByText("Alpha bug");
|
|
|
|
const confirm = screen.getByLabelText(
|
|
"confirm ticket selection",
|
|
) as HTMLButtonElement;
|
|
expect(confirm.disabled).toBe(true);
|
|
// Clicking a disabled button is a no-op.
|
|
fireEvent.click(confirm);
|
|
expect(onSelect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("respects excludeRefs and facet filters in multi mode", async () => {
|
|
const [a, b] = await seedTrio(ticket);
|
|
renderPicker(ticket, {
|
|
selectionMode: "multi",
|
|
excludeRefs: [a.ref],
|
|
});
|
|
|
|
// Excluded ref is not selectable.
|
|
await screen.findByText("Bravo task");
|
|
expect(screen.queryByLabelText(`select ticket ${a.ref}`)).toBeNull();
|
|
expect(screen.getByLabelText(`select ticket ${b.ref}`)).toBeTruthy();
|
|
|
|
// Facets still narrow the list (Open ⇒ only Alpha, which is excluded ⇒ none).
|
|
fireEvent.click(screen.getByLabelText("filter status Open"));
|
|
await waitFor(() =>
|
|
expect(screen.getByText("No matching tickets.")).toBeTruthy(),
|
|
);
|
|
});
|
|
|
|
it("closes on Escape and backdrop without confirming", async () => {
|
|
const [a] = await seedTrio(ticket);
|
|
const { onSelect, onClose } = renderPicker(ticket, {
|
|
selectionMode: "multi",
|
|
});
|
|
await screen.findByText("Alpha bug");
|
|
fireEvent.click(screen.getByLabelText(`select ticket ${a.ref}`));
|
|
|
|
fireEvent.keyDown(document, { key: "Escape" });
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
expect(onSelect).not.toHaveBeenCalled();
|
|
|
|
const backdrop = screen.getByRole("dialog").parentElement as HTMLElement;
|
|
fireEvent.mouseDown(backdrop);
|
|
expect(onClose).toHaveBeenCalledTimes(2);
|
|
expect(onSelect).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|