feat(tickets): suppression d'un ticket (backend + frontend)

Ajoute la suppression complète d'un ticket (#6).

Backend Rust :
- port IssueStore::delete (NotFound si absent) + event IssueDeleted{freed_sprint}
- FsIssueStore::delete : supprime .ideai/tickets/<N>/ et l'index sous lock
- use case DeleteIssue + adaptations des tests sprint/ticket_assistant au port
- commande Tauri ticket_delete + câblage events/state/lib

Frontend :
- gateway delete (ports, adapter ticket + mock, domain)
- useTicketDetail : retrait de la liste et fermeture du détail via event issueDeleted
- intégration TicketDetail + tests

Tests verts : application/issue_usecases (6), infrastructure/issue_store (7),
app-tauri --lib (56), frontend vitest (503), npm run build (exit 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 06:27:09 +02:00
parent d25e340b44
commit 5417bd756b
20 changed files with 689 additions and 95 deletions

View File

@ -14,7 +14,7 @@ import {
MockSystemGateway,
MockTicketGateway,
} from "@/adapters/mock";
import type { Agent } from "@/domain";
import type { Agent, DomainEvent } from "@/domain";
import type { Gateways } from "@/ports";
import { TicketsView } from "./TicketsView";
import { TicketDetail } from "./TicketDetail";
@ -108,6 +108,34 @@ describe("MockTicketGateway", () => {
).rejects.toMatchObject({ message: expect.stringContaining("version conflict") });
});
it("deletes a ticket, removing it and emitting issueDeleted with freedSprint (#6)", async () => {
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "S" });
const t = await ticket.create(PROJECT_ID, { title: "Doomed" });
await ticket.setTicketSprint(PROJECT_ID, t.ref, "s1", t.version);
const events: DomainEvent[] = [];
await system.onDomainEvent((e) => events.push(e));
await ticket.delete(PROJECT_ID, t.ref);
// Removed from the store (NotFound afterwards)…
await expect(ticket.read(PROJECT_ID, t.ref)).rejects.toMatchObject({
code: "NOT_FOUND",
});
// …and the sprint it was released from is reported on the event.
expect(events).toContainEqual({
type: "issueDeleted",
projectId: PROJECT_ID,
issueRef: t.ref,
freedSprint: "s1",
});
});
it("rejects deleting an unknown ticket with NotFound (#6)", async () => {
await expect(ticket.delete(PROJECT_ID, "#404")).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
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" });
@ -425,6 +453,69 @@ describe("TicketsView", () => {
).toBeNull();
});
it("deletes a ticket from the detail: confirmation ⇒ row gone + detail closed (#6)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "Deletable" });
renderView(ticket, system, agent);
fireEvent.click(await screen.findByText("Deletable"));
const detail = await screen.findByRole("dialog", {
name: `ticket ${t.ref}`,
});
// Open the confirmation, then confirm the deletion.
fireEvent.click(within(detail).getByText("Supprimer"));
const confirm = await screen.findByRole("dialog", {
name: "confirmer la suppression",
});
fireEvent.click(within(confirm).getByLabelText("confirm delete"));
// The gateway removed the ticket…
await waitFor(() =>
expect(ticket.read(PROJECT_ID, t.ref)).rejects.toMatchObject({
code: "NOT_FOUND",
}),
);
// …the detail closed (via the `issueDeleted` event → `deleted` flag)…
await waitFor(() =>
expect(
screen.queryByRole("dialog", { name: `ticket ${t.ref}` }),
).toBeNull(),
);
// …and the row disappeared from the list (event-driven refresh).
await waitFor(() => expect(screen.queryByText("Deletable")).toBeNull());
expect(await screen.findByText("No tickets.")).toBeTruthy();
});
it("cancelling the delete confirmation keeps the ticket (#6)", async () => {
const t = await ticket.create(PROJECT_ID, { title: "Keeper" });
const delSpy = vi.spyOn(ticket, "delete");
renderView(ticket, system, agent);
fireEvent.click(await screen.findByText("Keeper"));
const detail = await screen.findByRole("dialog", {
name: `ticket ${t.ref}`,
});
fireEvent.click(within(detail).getByText("Supprimer"));
const confirm = await screen.findByRole("dialog", {
name: "confirmer la suppression",
});
fireEvent.click(within(confirm).getByLabelText("cancel delete"));
// The confirmation is dismissed, delete was never called, ticket stays.
await waitFor(() =>
expect(
screen.queryByRole("dialog", { name: "confirmer la suppression" }),
).toBeNull(),
);
expect(delSpy).not.toHaveBeenCalled();
expect(
screen.getByRole("dialog", { name: `ticket ${t.ref}` }),
).toBeTruthy();
const fresh = await ticket.read(PROJECT_ID, t.ref);
expect(fresh.title).toBe("Keeper");
});
it("copies a delegation context prompt (F7)", async () => {
const writeText = stubClipboard();
const t = await ticket.create(PROJECT_ID, { title: "Delegate me" });