feat(tickets): surface frontend V1 du système de tickets
Surface UI complète des tickets (domaine Issue exposé « ticket »), validée QA de bout en bout : cargo build + tests backend verts, npm run build + npm test (459) verts. - Domaine : DTO Ticket, 9 events Issue* + guard isTicketEvent. - Ports : TicketGateway (+ types query/input) ajouté à Gateways. - Adapters : TauriTicketGateway réel (+ isTicketVersionConflict), wiring, et MockTicketGateway pour les tests. - Feature tickets : hooks (useTickets, useTicketDetail, useProjectAgents), TicketsPanel, TicketDetail, TicketsView, ticketMeta + tests. - ProjectsView : onglet sidebar « Tickets ». Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
245
frontend/src/features/tickets/tickets.test.tsx
Normal file
245
frontend/src/features/tickets/tickets.test.tsx
Normal file
@ -0,0 +1,245 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
|
||||
import { DIProvider } from "@/app/di";
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockSystemGateway,
|
||||
MockTicketGateway,
|
||||
} from "@/adapters/mock";
|
||||
import type { Agent } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { TicketsView } from "./TicketsView";
|
||||
import { TicketDetail } from "./TicketDetail";
|
||||
|
||||
const PROJECT_ID = "project-tickets-test";
|
||||
|
||||
function stubClipboard() {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, { clipboard: { writeText } });
|
||||
return writeText;
|
||||
}
|
||||
|
||||
async function seedAgent(agent: MockAgentGateway, name: string): Promise<Agent> {
|
||||
return agent.createAgent(PROJECT_ID, { name, profileId: "p-1" });
|
||||
}
|
||||
|
||||
function renderView(
|
||||
ticket: MockTicketGateway,
|
||||
system: MockSystemGateway,
|
||||
agent: MockAgentGateway,
|
||||
) {
|
||||
const gateways = { ticket, system, agent } as unknown as Gateways;
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TicketsView projectId={PROJECT_ID} />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MockTicketGateway", () => {
|
||||
let system: MockSystemGateway;
|
||||
let ticket: MockTicketGateway;
|
||||
|
||||
beforeEach(() => {
|
||||
system = new MockSystemGateway();
|
||||
ticket = new MockTicketGateway(system);
|
||||
});
|
||||
|
||||
it("assigns sequential #refs and emits issueCreated", async () => {
|
||||
const events: string[] = [];
|
||||
await system.onDomainEvent((e) => events.push(e.type));
|
||||
|
||||
const a = await ticket.create(PROJECT_ID, { title: "First" });
|
||||
const b = await ticket.create(PROJECT_ID, { title: "Second" });
|
||||
|
||||
expect(a.ref).toBe("#1");
|
||||
expect(b.ref).toBe("#2");
|
||||
expect(b.version).toBe(1);
|
||||
expect(events).toContain("issueCreated");
|
||||
});
|
||||
|
||||
it("filters by status and searches text", async () => {
|
||||
await ticket.create(PROJECT_ID, { title: "alpha bug", status: "open" });
|
||||
const t2 = await ticket.create(PROJECT_ID, { title: "beta task" });
|
||||
await ticket.update(PROJECT_ID, t2.ref, {
|
||||
status: "closed",
|
||||
expectedVersion: t2.version,
|
||||
});
|
||||
|
||||
const open = await ticket.list(PROJECT_ID, { status: "open" });
|
||||
expect(open.items.map((i) => i.ref)).toEqual(["#1"]);
|
||||
|
||||
const search = await ticket.list(PROJECT_ID, { text: "beta" });
|
||||
expect(search.items.map((i) => i.ref)).toEqual(["#2"]);
|
||||
});
|
||||
|
||||
it("rejects a stale write with a version conflict", async () => {
|
||||
const t = await ticket.create(PROJECT_ID, { title: "x" });
|
||||
await ticket.update(PROJECT_ID, t.ref, {
|
||||
title: "y",
|
||||
expectedVersion: t.version,
|
||||
});
|
||||
|
||||
await expect(
|
||||
ticket.update(PROJECT_ID, t.ref, {
|
||||
title: "z",
|
||||
expectedVersion: t.version, // stale (1, now 2)
|
||||
}),
|
||||
).rejects.toMatchObject({ message: expect.stringContaining("version conflict") });
|
||||
});
|
||||
|
||||
it("links and unlinks tickets, bumping the version", async () => {
|
||||
const a = await ticket.create(PROJECT_ID, { title: "a" });
|
||||
await ticket.create(PROJECT_ID, { title: "b" });
|
||||
const linked = await ticket.link(PROJECT_ID, a.ref, "#2", "blocks", a.version);
|
||||
expect(linked.links).toEqual([{ targetRef: "#2", kind: "blocks" }]);
|
||||
const unlinked = await ticket.unlink(
|
||||
PROJECT_ID,
|
||||
a.ref,
|
||||
"#2",
|
||||
linked.version,
|
||||
"blocks",
|
||||
);
|
||||
expect(unlinked.links).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TicketsView", () => {
|
||||
let system: MockSystemGateway;
|
||||
let ticket: MockTicketGateway;
|
||||
let agent: MockAgentGateway;
|
||||
|
||||
beforeEach(() => {
|
||||
system = new MockSystemGateway();
|
||||
ticket = new MockTicketGateway(system);
|
||||
agent = new MockAgentGateway();
|
||||
});
|
||||
|
||||
it("shows the empty state then a created ticket", async () => {
|
||||
renderView(ticket, system, agent);
|
||||
expect(await screen.findByText("No tickets.")).toBeTruthy();
|
||||
|
||||
await ticket.create(PROJECT_ID, { title: "Live created" });
|
||||
// The panel refreshes on the issueCreated event.
|
||||
expect(await screen.findByText("Live created")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens the detail and edits status with a version bump", async () => {
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Editable" });
|
||||
renderView(ticket, system, agent);
|
||||
|
||||
fireEvent.click(await screen.findByText("Editable"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
|
||||
const statusSelect = within(dialog).getByLabelText("ticket status");
|
||||
fireEvent.change(statusSelect, { target: { value: "inProgress" } });
|
||||
|
||||
await waitFor(async () => {
|
||||
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
||||
expect(fresh.status).toBe("inProgress");
|
||||
expect(fresh.version).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("saves the ticket-scoped carnet (replaceable)", async () => {
|
||||
const t = await ticket.create(PROJECT_ID, { title: "WithCarnet" });
|
||||
renderView(ticket, system, agent);
|
||||
|
||||
fireEvent.click(await screen.findByText("WithCarnet"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText("ticket carnet"), {
|
||||
target: { value: "## notes\nscoped body" },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByText("Save carnet"));
|
||||
|
||||
await waitFor(async () => {
|
||||
const carnet = await ticket.readCarnet(PROJECT_ID, t.ref);
|
||||
expect(carnet.carnet).toBe("## notes\nscoped body");
|
||||
});
|
||||
});
|
||||
|
||||
it("assigns only known project agents", async () => {
|
||||
const known = await seedAgent(agent, "Backend");
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Assignable" });
|
||||
renderView(ticket, system, agent);
|
||||
|
||||
fireEvent.click(await screen.findByText("Assignable"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText("assign agent"), {
|
||||
target: { value: known.id },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByText("Assign"));
|
||||
|
||||
await waitFor(async () => {
|
||||
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
||||
expect(fresh.assignedAgentIds).toEqual([known.id]);
|
||||
});
|
||||
expect(within(dialog).getAllByText("Backend").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("surfaces a version-conflict banner and reloads (F3)", async () => {
|
||||
// No system gateway ⇒ the detail does not auto-refresh on events, so we can
|
||||
// deterministically make its held version go stale under an external write.
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Racy" });
|
||||
const gateways = { ticket, agent } as unknown as Gateways;
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<TicketDetail
|
||||
projectId={PROJECT_ID}
|
||||
ticketRef={t.ref}
|
||||
onClose={() => {}}
|
||||
onOpenRef={() => {}}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
// The detail now holds version 1. Bump the store to version 2 behind its back.
|
||||
await ticket.update(PROJECT_ID, t.ref, {
|
||||
description: "external",
|
||||
expectedVersion: t.version,
|
||||
});
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText("Title"), {
|
||||
target: { value: "Racy edited" },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByText("Save changes"));
|
||||
|
||||
expect(
|
||||
await within(dialog).findByText(/modified elsewhere and reloaded/i),
|
||||
).toBeTruthy();
|
||||
// After the reload the detail field reflects the reloaded (title unchanged)
|
||||
// ticket rather than the abandoned local edit.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(within(dialog).getByLabelText("Title") as HTMLInputElement)
|
||||
.value,
|
||||
).toBe("Racy"),
|
||||
);
|
||||
});
|
||||
|
||||
it("copies a delegation context prompt (F7)", async () => {
|
||||
const writeText = stubClipboard();
|
||||
const t = await ticket.create(PROJECT_ID, { title: "Delegate me" });
|
||||
renderView(ticket, system, agent);
|
||||
|
||||
fireEvent.click(await screen.findByText("Delegate me"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
fireEvent.click(within(dialog).getByText("Delegation context"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
`Travaille sur le ticket ${t.ref} : Delegate me`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user