fix(frontend): sprint change/removal on tickets, web workspace (#86 QA fix)
QA-flagged gap: the web surface could only ADD a ticket to a sprint, never change or clear it, despite the backend already exposing both ticket_assign_sprint and ticket_unassign_sprint. - useTicketDetail: new setSprint(sprintId | null) method (additive, also available to the desktop TicketDetail — unused there today, no behaviour change), routing through the existing TicketGateway.setTicketSprint. - WebTicketDetail: Sprint selector in "Statut et priorité", immediate save like status/priority; empty value clears back to "Sans sprint". - WebSprintsView: each sprint card now shows its tickets (compact list, resolved client-side like the desktop SprintManager) with a "Retirer du sprint" action per ticket, calling assignSprint(ref, null) — symmetric with "Ajouter tickets". Extracted the card into a SprintCard subcomponent to keep the growing card readable. - Tests: WebTicketsSprints.test.tsx now has 16 tests (was 10) — added sprint change/clear, sprint removal from the Sprints tab, agent assign/unassign, ticket link/unlink, carnet save, and free-text search filtering. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -11,9 +11,9 @@ import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { createMockGateways, MockTicketGateway } from "@/adapters/mock";
|
||||
import { createMockGateways, MockTicketGateway, MockWorkStateGateway } from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import type { Ticket } from "@/domain";
|
||||
import type { ProjectWorkState, Ticket } from "@/domain";
|
||||
import { WebWorkspace } from "../WebWorkspace";
|
||||
|
||||
function ticket(over: Partial<Ticket> = {}): Ticket {
|
||||
@ -43,6 +43,29 @@ async function setup() {
|
||||
return { gateways, projectId: project.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds a project's work-state with one idle agent. `WebTicketDetail`/
|
||||
* `WebTicketsView` resolve the assignable agent roster from
|
||||
* `get_project_work_state` (not `list_agents` — absent from the web-server
|
||||
* command allowlist, see `useWebProjectAgents`), so tests that need an
|
||||
* assignable agent must seed it here rather than via `MockAgentGateway`.
|
||||
*/
|
||||
function seedAgent(gateways: Gateways, projectId: string, agentId: string, name: string): void {
|
||||
const state: ProjectWorkState = {
|
||||
agents: [
|
||||
{
|
||||
agentId,
|
||||
name,
|
||||
profileId: "p1",
|
||||
busy: { state: "idle" },
|
||||
tickets: [],
|
||||
},
|
||||
],
|
||||
conversations: [],
|
||||
};
|
||||
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(projectId, state);
|
||||
}
|
||||
|
||||
function renderWorkspace(gateways: Gateways) {
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
@ -145,6 +168,148 @@ describe("WebWorkspace — project tabs (ticket #86)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("changes a ticket's sprint from the detail view, including clearing it back to Sans sprint", async () => {
|
||||
const { gateways, projectId } = await setup();
|
||||
const tk = gateways.ticket as MockTicketGateway;
|
||||
tk._seedSprint(projectId, { id: "s1", order: 1, name: "Sprint courant" });
|
||||
tk._seedSprint(projectId, { id: "s2", order: 2, name: "Sprint suivant" });
|
||||
tk._seedTicket(projectId, ticket());
|
||||
|
||||
await openProjectAndTab(gateways, "Tickets");
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne",
|
||||
}),
|
||||
);
|
||||
|
||||
const sprintSelect = await screen.findByLabelText("Sprint");
|
||||
fireEvent.change(sprintSelect, { target: { value: "s2" } });
|
||||
|
||||
await waitFor(async () => {
|
||||
const updated = await tk.read(projectId, "#1");
|
||||
expect(updated.sprintId).toBe("s2");
|
||||
});
|
||||
|
||||
// Clear it back to "Sans sprint" (ticket_unassign_sprint via setSprint(null)).
|
||||
fireEvent.change(await screen.findByLabelText("Sprint"), { target: { value: "" } });
|
||||
|
||||
await waitFor(async () => {
|
||||
const updated = await tk.read(projectId, "#1");
|
||||
expect(updated.sprintId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("assigns and unassigns an agent from the detail view", async () => {
|
||||
const { gateways, projectId } = await setup();
|
||||
const tk = gateways.ticket as MockTicketGateway;
|
||||
tk._seedTicket(projectId, ticket());
|
||||
seedAgent(gateways, projectId, "agent-1", "DevFrontend");
|
||||
|
||||
await openProjectAndTab(gateways, "Tickets");
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne",
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Agents assignés" }));
|
||||
fireEvent.change(screen.getByLabelText("Assigner un agent"), {
|
||||
target: { value: "agent-1" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Assigner" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const updated = await tk.read(projectId, "#1");
|
||||
expect(updated.assignedAgentIds).toContain("agent-1");
|
||||
});
|
||||
await screen.findByText("DevFrontend");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Désassigner DevFrontend" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const updated = await tk.read(projectId, "#1");
|
||||
expect(updated.assignedAgentIds).not.toContain("agent-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("links and unlinks a ticket from the detail view", async () => {
|
||||
const { gateways, projectId } = await setup();
|
||||
const tk = gateways.ticket as MockTicketGateway;
|
||||
tk._seedTicket(projectId, ticket());
|
||||
tk._seedTicket(projectId, ticket({ ref: "#2", number: 2, title: "Autre ticket" }));
|
||||
|
||||
await openProjectAndTab(gateways, "Tickets");
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne",
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Liens" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Lier" }));
|
||||
|
||||
const sheet = await screen.findByRole("dialog", { name: "Lier un ticket" });
|
||||
fireEvent.click(within(sheet).getByRole("button", { name: "#2, Autre ticket" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const updated = await tk.read(projectId, "#1");
|
||||
expect(updated.links).toEqual([{ targetRef: "#2", kind: "relatesTo" }]);
|
||||
});
|
||||
await screen.findByText("#2");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retirer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const updated = await tk.read(projectId, "#1");
|
||||
expect(updated.links).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("saves the carnet explicitly from the detail view", async () => {
|
||||
const { gateways, projectId } = await setup();
|
||||
const tk = gateways.ticket as MockTicketGateway;
|
||||
tk._seedTicket(projectId, ticket());
|
||||
|
||||
await openProjectAndTab(gateways, "Tickets");
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne",
|
||||
}),
|
||||
);
|
||||
|
||||
const carnetField = await screen.findByLabelText("Carnet");
|
||||
const saveButton = screen.getByRole("button", { name: "Enregistrer le carnet" });
|
||||
expect(saveButton).toHaveProperty("disabled", true);
|
||||
|
||||
fireEvent.change(carnetField, { target: { value: "Notes de travail" } });
|
||||
expect(saveButton).toHaveProperty("disabled", false);
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(async () => {
|
||||
const updated = await tk.readCarnet(projectId, "#1");
|
||||
expect(updated.carnet).toBe("Notes de travail");
|
||||
});
|
||||
});
|
||||
|
||||
it("filters the ticket list by free-text search", async () => {
|
||||
const { gateways, projectId } = await setup();
|
||||
const tk = gateways.ticket as MockTicketGateway;
|
||||
tk._seedTicket(projectId, ticket({ ref: "#1", number: 1, title: "Ajouter tickets/sprints au web" }));
|
||||
tk._seedTicket(projectId, ticket({ ref: "#2", number: 2, title: "Langue uniforme Settings" }));
|
||||
|
||||
await openProjectAndTab(gateways, "Tickets");
|
||||
await screen.findByText("Langue uniforme Settings");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Rechercher des tickets"), {
|
||||
target: { value: "Langue" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Ajouter tickets/sprints au web")).toBeNull();
|
||||
});
|
||||
expect(screen.getByText("Langue uniforme Settings")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("deletes a ticket after confirmation and returns to the list", async () => {
|
||||
const { gateways, projectId } = await setup();
|
||||
const tk = gateways.ticket as MockTicketGateway;
|
||||
@ -260,4 +425,24 @@ describe("WebWorkspace — Sprints tab (ticket #86)", () => {
|
||||
expect(t.sprintId).toBe("s1");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the sprint's tickets and removes one from the sprint", async () => {
|
||||
const { gateways, projectId } = await setup();
|
||||
const tk = gateways.ticket as MockTicketGateway;
|
||||
tk._seedSprint(projectId, { id: "s1", order: 1, name: "Sprint A" });
|
||||
tk._seedTicket(projectId, ticket({ ref: "#1", number: 1, title: "Ticket un", sprintId: "s1" }));
|
||||
|
||||
await openProjectAndTab(gateways, "Sprints");
|
||||
await screen.findByText("Sprint A");
|
||||
|
||||
expect(screen.getByText("Ticket un")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retirer #1 du sprint Sprint A" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const t = await tk.read(projectId, "#1");
|
||||
expect(t.sprintId).toBeNull();
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByText("Ticket un")).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user