- Prepaid credit system: org wallet, debit on usage, chat gate, admin credit console - Engine UI: /manager, /tasks, /tasks/[id], /workforce, /dashboard, /traces pages - Direct task creation (HTTP POST /tasks, MCP tasks_create) at pending_manager_review - Pause/resume/cancel/answer actions in engine API and UI - Employee cap (7 per org default), review pipeline guard (active reviewer required) - Scheduler: wide window prevents org starvation; archived employees excluded from cap - Tenant isolation: project-id cross-org 404, engine never uses another tenant's sandbox - Order route fix: manager built with pipeline states + employee profiles - Contract extension: ModelGrantPort.get_org_policy with DefaultOrgPolicy - Engine event types in @agy/contracts (task.step, task.gate, run.log, run.tool, team.changed) - api-engine.ts typed client; api-credit.ts client - Alembic migration c4d5e6f7a8b9: prepaid credit + engine UI tables - 859 tests passed, 1 skipped; ruff/mypy/tsc/eslint/vitest all green Refs: PLAN-v2.md §9 (T1–T43), todo-v2-final.md
153 lines
5.2 KiB
TypeScript
153 lines
5.2 KiB
TypeScript
/**
|
|
* 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> = {}): 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(<ThreadTaskCards tasks={[task({ stage: "pending_test" })]} />);
|
|
|
|
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(<ThreadTaskCards tasks={[legacy]} />);
|
|
|
|
expect(screen.getByText("کار قدیمی")).toBeInTheDocument();
|
|
expect(screen.getByText("انجام شد")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders nothing for a conversation without tasks", () => {
|
|
const { container } = render(<ThreadTaskCards tasks={[]} />);
|
|
|
|
expect(container).toBeEmptyDOMElement();
|
|
});
|
|
});
|
|
|
|
describe("DecisionBanner", () => {
|
|
it("is hidden when no task waits for an answer", () => {
|
|
const { container } = render(
|
|
<DecisionBanner orgId={ORG} tasks={[task({ stage: "pending_test" })]} onAnswered={vi.fn()} />,
|
|
);
|
|
|
|
expect(container).toBeEmptyDOMElement();
|
|
});
|
|
|
|
it("lists only the tasks at a pending decision", () => {
|
|
render(
|
|
<DecisionBanner
|
|
orgId={ORG}
|
|
tasks={[
|
|
task({ id: "task-1", title: "کدام پایگاه داده؟", stage: "pending_user_decision" }),
|
|
task({ id: "task-2", title: "تست واحد", stage: "pending_test" }),
|
|
]}
|
|
onAnswered={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<DecisionBanner
|
|
orgId={ORG}
|
|
tasks={[task({ id: "task-1", title: "کدام پایگاه داده؟", stage: "pending_user_decision" })]}
|
|
onAnswered={onAnswered}
|
|
/>,
|
|
);
|
|
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(
|
|
<DecisionBanner
|
|
orgId={ORG}
|
|
tasks={[task({ id: "task-1", title: "کدام پایگاه داده؟", stage: "pending_user_decision" })]}
|
|
onAnswered={onAnswered}
|
|
/>,
|
|
);
|
|
|
|
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");
|
|
});
|
|
});
|