mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
138 lines
4.5 KiB
TypeScript
138 lines
4.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|||
|
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
|
|
import type { ReactNode } from "react";
|
||
|
|
import type { RoadmapCycle } from "@/lib/api/roadmap";
|
||
|
|
|
||
|
|
const { resolveApproveRef } = vi.hoisted(() => ({
|
||
|
|
resolveApproveRef: { current: null as null | ((v: unknown) => void) },
|
||
|
|
}));
|
||
|
|
|
||
|
|
const { listCycles, approveItem, rejectItem } = vi.hoisted(() => ({
|
||
|
|
listCycles: vi.fn(
|
||
|
|
async () =>
|
||
|
|
[
|
||
|
|
{
|
||
|
|
task_id: "cycle-1",
|
||
|
|
title: "Roadmap exploration cycle",
|
||
|
|
status: "pending",
|
||
|
|
goal: "Close onboarding friction",
|
||
|
|
items: [
|
||
|
|
{
|
||
|
|
id: "item-0",
|
||
|
|
title: "Streamline signup",
|
||
|
|
description: "Cut the signup form from 8 fields to 3",
|
||
|
|
acceptance_criteria: ["signup takes < 30s"],
|
||
|
|
project_slug: "backend-svc",
|
||
|
|
team: "backend",
|
||
|
|
priority: 2,
|
||
|
|
rationale: "signup drop-off is the top funnel leak",
|
||
|
|
status: "proposed",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "item-1",
|
||
|
|
title: "Simplify pricing page",
|
||
|
|
description: "Remove the three-tier confusion",
|
||
|
|
acceptance_criteria: ["one clear CTA"],
|
||
|
|
project_slug: "frontend-app",
|
||
|
|
team: "frontend",
|
||
|
|
priority: 3,
|
||
|
|
rationale: "pricing page bounce rate is high",
|
||
|
|
status: "proposed",
|
||
|
|
},
|
||
|
|
],
|
||
|
|
},
|
||
|
|
] as RoadmapCycle[],
|
||
|
|
),
|
||
|
|
// Deferred so the test can freeze the approve mid-flight.
|
||
|
|
approveItem: vi.fn(
|
||
|
|
() =>
|
||
|
|
new Promise((r) => {
|
||
|
|
resolveApproveRef.current = r as (v: unknown) => void;
|
||
|
|
}),
|
||
|
|
),
|
||
|
|
rejectItem: vi.fn(async () => ({})),
|
||
|
|
}));
|
||
|
|
|
||
|
|
vi.mock("@/lib/api", () => ({
|
||
|
|
roadmapApi: { listCycles, approveItem, rejectItem },
|
||
|
|
}));
|
||
|
|
|
||
|
|
import { RoadmapReviewQueue } from "../roadmap-review-queue";
|
||
|
|
|
||
|
|
function withQueryClient(ui: ReactNode) {
|
||
|
|
const client = new QueryClient({
|
||
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||
|
|
});
|
||
|
|
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
|
||
|
|
}
|
||
|
|
|
||
|
|
describe("RoadmapReviewQueue", () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
listCycles.mockClear();
|
||
|
|
approveItem.mockClear();
|
||
|
|
rejectItem.mockClear();
|
||
|
|
resolveApproveRef.current = null;
|
||
|
|
});
|
||
|
|
afterEach(() => {
|
||
|
|
vi.clearAllMocks();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("renders the cycle goal and both item drafts", async () => {
|
||
|
|
render(withQueryClient(<RoadmapReviewQueue />));
|
||
|
|
expect(await screen.findByText("Close onboarding friction")).toBeInTheDocument();
|
||
|
|
expect(screen.getByText("Streamline signup")).toBeInTheDocument();
|
||
|
|
expect(screen.getByText("Simplify pricing page")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("disables only the item being approved, not every row's Approve", async () => {
|
||
|
|
render(withQueryClient(<RoadmapReviewQueue />));
|
||
|
|
|
||
|
|
const approveButtons = await screen.findAllByRole("button", {
|
||
|
|
name: /Approve/,
|
||
|
|
});
|
||
|
|
expect(approveButtons).toHaveLength(2);
|
||
|
|
expect(approveButtons[0]).not.toBeDisabled();
|
||
|
|
expect(approveButtons[1]).not.toBeDisabled();
|
||
|
|
|
||
|
|
fireEvent.click(approveButtons[0]);
|
||
|
|
await waitFor(() =>
|
||
|
|
expect(approveItem).toHaveBeenCalledWith("cycle-1", "item-0"),
|
||
|
|
);
|
||
|
|
|
||
|
|
await waitFor(() => expect(approveButtons[0]).toBeDisabled());
|
||
|
|
expect(approveButtons[1]).not.toBeDisabled();
|
||
|
|
|
||
|
|
resolveApproveRef.current?.({
|
||
|
|
status: "approved",
|
||
|
|
item_id: "item-0",
|
||
|
|
materialized_task_id: "t-1",
|
||
|
|
detail: "materialized into the backlog",
|
||
|
|
});
|
||
|
|
await waitFor(() => expect(approveButtons[0]).not.toBeDisabled());
|
||
|
|
});
|
||
|
|
|
||
|
|
it("rejects an item with a reason", async () => {
|
||
|
|
render(withQueryClient(<RoadmapReviewQueue />));
|
||
|
|
const rejectButtons = await screen.findAllByRole("button", {
|
||
|
|
name: "Reject",
|
||
|
|
});
|
||
|
|
fireEvent.click(rejectButtons[1]);
|
||
|
|
|
||
|
|
const reasonBox = await screen.findByLabelText("Reason");
|
||
|
|
fireEvent.change(reasonBox, { target: { value: "not a priority" } });
|
||
|
|
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
|
||
|
|
|
||
|
|
await waitFor(() =>
|
||
|
|
expect(rejectItem).toHaveBeenCalledWith("cycle-1", "item-1", "not a priority"),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("renders nothing when there is no authored cycle", async () => {
|
||
|
|
listCycles.mockResolvedValueOnce([]);
|
||
|
|
const { container } = render(withQueryClient(<RoadmapReviewQueue />));
|
||
|
|
await waitFor(() => expect(listCycles).toHaveBeenCalled());
|
||
|
|
expect(container).toBeEmptyDOMElement();
|
||
|
|
});
|
||
|
|
});
|