/** * The manager page's engine parts. The task cards under a conversation show each engine task's * Persian stage (and a legacy task's status), and link to the task page. The decision banner lists * only the tasks that wait for an answer, is hidden when there are none, posts the answer, and * shows a refusal from the server in Persian without losing what was typed. */ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ answerTask: vi.fn(), })); vi.mock("@/lib/api", () => { class FakeApiError extends Error { constructor( readonly status: number, readonly code: string, message: string, ) { super(message); } } return { ApiError: FakeApiError }; }); vi.mock("@/lib/api-engine", () => ({ engineApi: { answerTask: mocks.answerTask } })); import { ApiError } from "@/lib/api"; import type { EngineTask } from "@/lib/api-engine"; import { DecisionBanner } from "@/app/(app)/manager/_components/decision-banner"; import { ThreadTaskCards } from "@/app/(app)/manager/_components/task-cards"; const ORG = "org-1"; function task(overrides: Partial = {}): EngineTask { return { id: "task-1", title: "صفحهٔ گزارش فروش", status: "running", stage: "pending_test", pipeline: "default", kind: "feature", cycle: 1, max_cycles: 3, thread_id: "thread-1", assigned_employee_id: null, assignee_name: null, created_at: "2026-10-01T00:00:00Z", updated_at: "2026-10-01T00:00:00Z", ...overrides, }; } beforeEach(() => { mocks.answerTask.mockReset(); }); describe("ThreadTaskCards", () => { it("shows each engine task's Persian stage and links the card to the task page", () => { render(); expect(screen.getByText("در انتظار تست")).toBeInTheDocument(); expect(screen.getByText("صفحهٔ گزارش فروش").closest("a")).toHaveAttribute( "href", "/tasks/task-1", ); }); it("shows a legacy task by its status, since the engine has no stage for it", () => { const legacy = task({ id: "task-2", title: "کار قدیمی", stage: null, status: "done" }); render(); expect(screen.getByText("کار قدیمی")).toBeInTheDocument(); expect(screen.getByText("انجام شد")).toBeInTheDocument(); }); it("renders nothing for a conversation without tasks", () => { const { container } = render(); expect(container).toBeEmptyDOMElement(); }); }); describe("DecisionBanner", () => { it("is hidden when no task waits for an answer", () => { const { container } = render( , ); expect(container).toBeEmptyDOMElement(); }); it("lists only the tasks at a pending decision", () => { render( , ); expect(screen.getByText("در انتظار تصمیم شما")).toBeInTheDocument(); expect(screen.getByText("کدام پایگاه داده؟")).toBeInTheDocument(); expect(screen.queryByText("تست واحد")).toBeNull(); }); it("posts the trimmed answer to the task and reports it", async () => { mocks.answerTask.mockResolvedValue({}); const onAnswered = vi.fn(); render( , ); const send = screen.getByRole("button", { name: "ارسال پاسخ" }); expect(send).toBeDisabled(); fireEvent.change(screen.getByLabelText("پاسخ شما"), { target: { value: " PostgreSQL " } }); expect(send).toBeEnabled(); fireEvent.click(send); await waitFor(() => expect(onAnswered).toHaveBeenCalledTimes(1)); expect(mocks.answerTask).toHaveBeenCalledWith(ORG, "task-1", "PostgreSQL"); }); it("shows the server's refusal in Persian and keeps the typed answer", async () => { mocks.answerTask.mockRejectedValue( new ApiError(409, "conflict", "این کار دیگر منتظر پاسخ نیست."), ); const onAnswered = vi.fn(); render( , ); fireEvent.change(screen.getByLabelText("پاسخ شما"), { target: { value: "PostgreSQL" } }); fireEvent.click(screen.getByRole("button", { name: "ارسال پاسخ" })); expect(await screen.findByRole("alert")).toHaveTextContent("این کار دیگر منتظر پاسخ نیست."); expect(onAnswered).not.toHaveBeenCalled(); expect(screen.getByLabelText("پاسخ شما")).toHaveValue("PostgreSQL"); }); });