Ticket #11 — surface UI de gestion des sprints (frontend pur). - Nouveau composant SprintManager : création (nom optionnel), édition et gestion du cycle de vie d'un sprint, contrôles accessibles (pas de DnD). - useTickets étendu aux opérations de gestion de sprint ; ports, adaptateurs (ticket.ts, mock/index.ts) et TicketsPanel câblés en conséquence. - Tests Vitest associés (tickets.test.tsx). Typecheck / vitest (497+ tests) / build verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
583 lines
20 KiB
TypeScript
583 lines
20 KiB
TypeScript
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("preserves an in-progress description edit across a priority change (#9)", async () => {
|
|
const t = await ticket.create(PROJECT_ID, { title: "Persisted" });
|
|
renderView(ticket, system, agent);
|
|
|
|
fireEvent.click(await screen.findByText("Persisted"));
|
|
const dialog = await screen.findByRole("dialog");
|
|
|
|
// Start editing the description WITHOUT saving.
|
|
const desc = within(dialog).getByLabelText("Description") as HTMLTextAreaElement;
|
|
fireEvent.change(desc, { target: { value: "draft in progress" } });
|
|
// Also edit the title to cover both draft fields.
|
|
const titleInput = within(dialog).getByLabelText("Title") as HTMLInputElement;
|
|
fireEvent.change(titleInput, { target: { value: "Persisted (wip)" } });
|
|
|
|
// Change the priority — an immediate-apply mutation that re-fetches the ticket
|
|
// and bumps its version. The unsaved draft must survive.
|
|
fireEvent.change(within(dialog).getByLabelText("ticket priority"), {
|
|
target: { value: "high" },
|
|
});
|
|
|
|
// The backend applied the priority bump…
|
|
await waitFor(async () => {
|
|
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
|
expect(fresh.priority).toBe("high");
|
|
expect(fresh.version).toBe(2);
|
|
});
|
|
|
|
// …and the in-progress edits are still there (not reverted).
|
|
expect(
|
|
(within(dialog).getByLabelText("Description") as HTMLTextAreaElement).value,
|
|
).toBe("draft in progress");
|
|
expect(
|
|
(within(dialog).getByLabelText("Title") as HTMLInputElement).value,
|
|
).toBe("Persisted (wip)");
|
|
// The description was never persisted.
|
|
const persisted = await ticket.read(PROJECT_ID, t.ref);
|
|
expect(persisted.description).toBe("");
|
|
});
|
|
|
|
it("prompts before closing with unsaved changes, and closes directly when clean (#9)", async () => {
|
|
const t = await ticket.create(PROJECT_ID, { title: "CloseGuard" });
|
|
const onClose = vi.fn();
|
|
const gateways = { ticket, system, agent } as unknown as Gateways;
|
|
render(
|
|
<DIProvider gateways={gateways}>
|
|
<TicketDetail
|
|
projectId={PROJECT_ID}
|
|
ticketRef={t.ref}
|
|
onClose={onClose}
|
|
onOpenRef={() => {}}
|
|
/>
|
|
</DIProvider>,
|
|
);
|
|
|
|
const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
|
|
|
|
// Clean state → Close fires onClose directly, no confirmation dialog.
|
|
fireEvent.click(within(detail).getByText("Close"));
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
expect(screen.queryByRole("dialog", { name: "unsaved changes" })).toBeNull();
|
|
|
|
// Now make the draft dirty and try to close again.
|
|
fireEvent.change(within(detail).getByLabelText("Description"), {
|
|
target: { value: "unsaved body" },
|
|
});
|
|
fireEvent.click(within(detail).getByText("Close"));
|
|
|
|
// The confirmation appears and onClose was NOT called again.
|
|
const confirm = await screen.findByRole("dialog", { name: "unsaved changes" });
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
|
|
// "Annuler" dismisses the popup and keeps the editor open.
|
|
fireEvent.click(within(confirm).getByLabelText("cancel close"));
|
|
await waitFor(() =>
|
|
expect(screen.queryByRole("dialog", { name: "unsaved changes" })).toBeNull(),
|
|
);
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
|
|
// Re-open the confirmation and choose "Fermer sans sauvegarder".
|
|
fireEvent.click(within(detail).getByText("Close"));
|
|
const confirm2 = await screen.findByRole("dialog", { name: "unsaved changes" });
|
|
fireEvent.click(within(confirm2).getByLabelText("close without saving"));
|
|
expect(onClose).toHaveBeenCalledTimes(2);
|
|
// The unsaved description was discarded (never persisted).
|
|
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
|
expect(fresh.description).toBe("");
|
|
});
|
|
|
|
it("saves unsaved changes then closes when choosing « Sauvegarder » (#9)", async () => {
|
|
const t = await ticket.create(PROJECT_ID, { title: "SaveOnClose" });
|
|
const onClose = vi.fn();
|
|
const gateways = { ticket, system, agent } as unknown as Gateways;
|
|
render(
|
|
<DIProvider gateways={gateways}>
|
|
<TicketDetail
|
|
projectId={PROJECT_ID}
|
|
ticketRef={t.ref}
|
|
onClose={onClose}
|
|
onOpenRef={() => {}}
|
|
/>
|
|
</DIProvider>,
|
|
);
|
|
|
|
const detail = await screen.findByRole("dialog", { name: `ticket ${t.ref}` });
|
|
fireEvent.change(within(detail).getByLabelText("Description"), {
|
|
target: { value: "final body" },
|
|
});
|
|
fireEvent.click(within(detail).getByText("Close"));
|
|
|
|
const confirm = await screen.findByRole("dialog", { name: "unsaved changes" });
|
|
fireEvent.click(within(confirm).getByLabelText("save and close"));
|
|
|
|
// The change is persisted and the panel closes.
|
|
await waitFor(async () => {
|
|
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
|
expect(fresh.description).toBe("final body");
|
|
});
|
|
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
|
});
|
|
|
|
it("groups tickets by sprint with a « Sans sprint » bucket (#10)", async () => {
|
|
// Two ordered sprints + one ticket in each + one loose ticket.
|
|
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Sprint One" });
|
|
ticket._seedSprint(PROJECT_ID, { id: "s2", order: 2, name: "Sprint Two" });
|
|
const t1 = await ticket.create(PROJECT_ID, { title: "In one" });
|
|
const t2 = await ticket.create(PROJECT_ID, { title: "In two" });
|
|
await ticket.create(PROJECT_ID, { title: "Loose" });
|
|
await ticket.setTicketSprint(PROJECT_ID, t1.ref, "s1", t1.version);
|
|
await ticket.setTicketSprint(PROJECT_ID, t2.ref, "s2", t2.version);
|
|
|
|
renderView(ticket, system, agent);
|
|
|
|
// Sections appear in sprint `order`, then the "Sans sprint" bucket last.
|
|
await screen.findByRole("region", { name: "sprint section Sprint One" });
|
|
const sections = screen
|
|
.getAllByRole("region")
|
|
.map((r) => r.getAttribute("aria-label"))
|
|
.filter((l) => l?.startsWith("sprint section"));
|
|
expect(sections).toEqual([
|
|
"sprint section Sprint One",
|
|
"sprint section Sprint Two",
|
|
"sprint section Sans sprint",
|
|
]);
|
|
|
|
// The loose ticket lives in the bucket, the others under their sprint.
|
|
const bucket = screen.getByRole("region", { name: "sprint section Sans sprint" });
|
|
expect(within(bucket).getByText("Loose")).toBeTruthy();
|
|
const one = screen.getByRole("region", { name: "sprint section Sprint One" });
|
|
expect(within(one).getByText("In one")).toBeTruthy();
|
|
});
|
|
|
|
it("assigns a ticket to a sprint via the row selector (#10)", async () => {
|
|
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Sprint One" });
|
|
const t = await ticket.create(PROJECT_ID, { title: "Movable" });
|
|
|
|
renderView(ticket, system, agent);
|
|
|
|
// It starts in the "Sans sprint" bucket.
|
|
const bucket = await screen.findByRole("region", {
|
|
name: "sprint section Sans sprint",
|
|
});
|
|
expect(within(bucket).getByText("Movable")).toBeTruthy();
|
|
|
|
// Pick the sprint in the row selector → assign it.
|
|
fireEvent.change(screen.getByLabelText(`sprint for ${t.ref}`), {
|
|
target: { value: "s1" },
|
|
});
|
|
|
|
// The gateway recorded the membership…
|
|
await waitFor(async () => {
|
|
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
|
expect(fresh.sprintId).toBe("s1");
|
|
});
|
|
// …and the UI regrouped it under Sprint One, emptying the bucket.
|
|
await waitFor(() => {
|
|
const one = screen.getByRole("region", {
|
|
name: "sprint section Sprint One",
|
|
});
|
|
expect(within(one).getByText("Movable")).toBeTruthy();
|
|
});
|
|
expect(
|
|
screen.queryByRole("region", { name: "sprint section Sans sprint" }),
|
|
).toBeNull();
|
|
});
|
|
|
|
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`,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("SprintManager (#11)", () => {
|
|
let system: MockSystemGateway;
|
|
let ticket: MockTicketGateway;
|
|
let agent: MockAgentGateway;
|
|
|
|
beforeEach(() => {
|
|
system = new MockSystemGateway();
|
|
ticket = new MockTicketGateway(system);
|
|
agent = new MockAgentGateway();
|
|
});
|
|
|
|
/** Renders the view and opens the sprint-management overlay. */
|
|
async function openManager(): Promise<HTMLElement> {
|
|
renderView(ticket, system, agent);
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByRole("button", { name: "manage sprints" }),
|
|
).toBeTruthy(),
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: "manage sprints" }));
|
|
return screen.findByRole("dialog", { name: "manage sprints" });
|
|
}
|
|
|
|
it("creates a sprint", async () => {
|
|
const dialog = await openManager();
|
|
|
|
fireEvent.change(within(dialog).getByLabelText("new sprint name"), {
|
|
target: { value: "Alpha" },
|
|
});
|
|
fireEvent.click(within(dialog).getByText("Create sprint"));
|
|
|
|
await waitFor(async () => {
|
|
const sprints = await ticket.listSprints(PROJECT_ID);
|
|
expect(sprints.map((s) => s.name)).toEqual(["Alpha"]);
|
|
});
|
|
expect(
|
|
await within(dialog).findByLabelText("sprint row Alpha"),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it("creates a sprint with a default positional name when the name is empty", async () => {
|
|
const dialog = await openManager();
|
|
|
|
// Leave the name field empty and create → default "Sprint N" (here N=1).
|
|
expect(
|
|
(within(dialog).getByLabelText("new sprint name") as HTMLInputElement)
|
|
.value,
|
|
).toBe("");
|
|
fireEvent.click(within(dialog).getByText("Create sprint"));
|
|
|
|
await waitFor(async () => {
|
|
const sprints = await ticket.listSprints(PROJECT_ID);
|
|
expect(sprints.map((s) => s.name)).toEqual(["Sprint 1"]);
|
|
});
|
|
expect(
|
|
await within(dialog).findByLabelText("sprint row Sprint 1"),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it("renames a sprint", async () => {
|
|
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Old" });
|
|
const dialog = await openManager();
|
|
|
|
fireEvent.change(
|
|
await within(dialog).findByLabelText("rename sprint Old"),
|
|
{ target: { value: "New" } },
|
|
);
|
|
fireEvent.click(within(dialog).getByLabelText("save sprint Old"));
|
|
|
|
await waitFor(async () => {
|
|
const [s] = await ticket.listSprints(PROJECT_ID);
|
|
expect(s.name).toBe("New");
|
|
});
|
|
});
|
|
|
|
it("reorders sprints via the accessible down button", async () => {
|
|
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "One" });
|
|
ticket._seedSprint(PROJECT_ID, { id: "s2", order: 2, name: "Two" });
|
|
const dialog = await openManager();
|
|
|
|
fireEvent.click(
|
|
await within(dialog).findByLabelText("move sprint One down"),
|
|
);
|
|
|
|
await waitFor(async () => {
|
|
const sprints = await ticket.listSprints(PROJECT_ID);
|
|
// "Two" is now first (order 1), "One" second.
|
|
expect(sprints.map((s) => s.name)).toEqual(["Two", "One"]);
|
|
});
|
|
});
|
|
|
|
it("deletes a sprint and unassigns (does not delete) its tickets", async () => {
|
|
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Doomed" });
|
|
const t = await ticket.create(PROJECT_ID, { title: "Kept" });
|
|
await ticket.setTicketSprint(PROJECT_ID, t.ref, "s1", t.version);
|
|
|
|
const dialog = await openManager();
|
|
|
|
fireEvent.click(
|
|
await within(dialog).findByLabelText("delete sprint Doomed"),
|
|
);
|
|
// The confirmation explains tickets are unassigned, not deleted.
|
|
const confirm = await screen.findByRole("dialog", {
|
|
name: "delete sprint confirmation",
|
|
});
|
|
expect(confirm.textContent).toMatch(/ne seront pas supprimés/i);
|
|
fireEvent.click(within(confirm).getByLabelText("confirm delete sprint"));
|
|
|
|
await waitFor(async () => {
|
|
expect(await ticket.listSprints(PROJECT_ID)).toHaveLength(0);
|
|
});
|
|
// The ticket still exists, just unassigned.
|
|
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
|
expect(fresh.sprintId).toBeNull();
|
|
expect(fresh.title).toBe("Kept");
|
|
});
|
|
|
|
it("adds a backlog ticket to a sprint via the add selector", async () => {
|
|
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Target" });
|
|
const t = await ticket.create(PROJECT_ID, { title: "Backlog item" });
|
|
|
|
const dialog = await openManager();
|
|
|
|
fireEvent.change(
|
|
await within(dialog).findByLabelText("add ticket to sprint Target"),
|
|
{ target: { value: t.ref } },
|
|
);
|
|
|
|
await waitFor(async () => {
|
|
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
|
expect(fresh.sprintId).toBe("s1");
|
|
});
|
|
});
|
|
|
|
it("removes a ticket from a sprint", async () => {
|
|
ticket._seedSprint(PROJECT_ID, { id: "s1", order: 1, name: "Holder" });
|
|
const t = await ticket.create(PROJECT_ID, { title: "Member" });
|
|
await ticket.setTicketSprint(PROJECT_ID, t.ref, "s1", t.version);
|
|
|
|
const dialog = await openManager();
|
|
|
|
fireEvent.click(
|
|
await within(dialog).findByLabelText(`remove ${t.ref} from sprint`),
|
|
);
|
|
|
|
await waitFor(async () => {
|
|
const fresh = await ticket.read(PROJECT_ID, t.ref);
|
|
expect(fresh.sprintId).toBeNull();
|
|
});
|
|
});
|
|
});
|