Ajoute le sélecteur de tri dans TicketFacetsBar et propage le critère
via useTicketSearch jusqu'à TicketsPanel et TicketPicker, sur le contrat
de tri backend (e523f44). Couvre le tri par les tests tickets et
TicketPicker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
245 lines
8.0 KiB
TypeScript
245 lines
8.0 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();
|
|
});
|
|
});
|