Files
kandev/apps/web/components/task/simple/OfficeSimplePane.test.tsx
T

211 lines
6.6 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { StateProvider } from "@/components/state-provider";
import type { Task, TaskSession } from "@/app/office/tasks/[id]/types";
import {
OfficeTopbarChromeProvider,
useOfficeTopbarChrome,
} from "@/app/office/components/office-topbar-context";
const { CHAT_EDITABLE, CHAT_READONLY, CHAT_READONLY_TEST_ID } = vi.hoisted(() => ({
CHAT_EDITABLE: "editable",
CHAT_READONLY: "readonly",
CHAT_READONLY_TEST_ID: "chat-readonly",
}));
vi.mock("@/components/routing/app-link", () => ({
default: ({ href, children, ...props }: { href: string; children: ReactNode }) => (
<a href={href} {...props}>
{children}
</a>
),
}));
vi.mock("@kandev/ui/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("@/app/office/components/execution-indicator", () => ({
ExecutionIndicator: ({ status }: { status: string }) => (
<div data-testid="execution-indicator">{status}</div>
),
}));
vi.mock("./components/topbar-working-indicator", () => ({
TopbarWorkingIndicator: () => <div data-testid="topbar-working-indicator" />,
}));
vi.mock("./task-properties", () => ({
TaskProperties: () => <aside data-testid="task-properties" />,
}));
vi.mock("./task-documents", () => ({
TaskDocuments: () => <section data-testid="task-documents" />,
}));
vi.mock("../task-detail-context-panel", () => ({
TaskDetailContextPanel: () => <section data-testid="task-detail-context" />,
}));
vi.mock("@/hooks/use-task-context", () => ({
useTaskContext: () => ({ data: null, isLoading: false }),
}));
vi.mock("./stage-progress-bar", () => ({
StageProgressBar: () => <div data-testid="stage-progress" />,
}));
vi.mock("./subtask-stepper", () => ({
SubtaskStepper: () => <div data-testid="subtask-stepper" />,
}));
vi.mock("@/app/office/components/new-task-dialog", () => ({
NewTaskDialog: () => null,
}));
vi.mock("@/components/task/TreeCancelDialog", () => ({
TreeCancelDialog: () => null,
}));
vi.mock("@/lib/api/domains/tree-api", () => ({
cancelTaskTree: vi.fn(),
pauseTaskTree: vi.fn(),
previewTaskTree: vi.fn().mockResolvedValue({ active_hold: null }),
restoreTaskTree: vi.fn(),
resumeTaskTree: vi.fn(),
}));
vi.mock("./chat-activity-tabs", () => ({
ChatActivityTabs: ({ readOnly }: { readOnly: boolean }) => (
<div data-testid={CHAT_READONLY_TEST_ID}>{readOnly ? CHAT_READONLY : CHAT_EDITABLE}</div>
),
}));
import { OfficeSimplePane } from "./OfficeSimplePane";
afterEach(() => cleanup());
const baseTask: Task = {
id: "task-1",
workspaceId: "workspace-1",
identifier: "E2E-1",
title: "Projectless office task",
status: "todo",
priority: "medium",
labels: [],
assigneeAgentProfileId: "agent-1",
blockedBy: [],
blocking: [],
children: [],
reviewers: [],
approvers: [],
decisions: [],
createdBy: "",
createdAt: "2026-05-01T10:00:00Z",
updatedAt: "2026-05-01T10:00:00Z",
};
const completedSession: TaskSession = {
id: "session-1",
agentProfileId: "agent-1",
agentName: "CEO",
agentRole: "agent",
state: "COMPLETED",
isPrimary: true,
startedAt: "2026-05-01T10:00:00Z",
completedAt: "2026-05-01T10:05:00Z",
updatedAt: "2026-05-01T10:05:00Z",
};
const failedSession: TaskSession = {
...completedSession,
id: "session-failed",
state: "FAILED",
completedAt: "2026-05-01T10:06:00Z",
updatedAt: "2026-05-01T10:06:00Z",
};
const runningSession: TaskSession = {
...completedSession,
id: "session-running",
state: "RUNNING",
completedAt: undefined,
updatedAt: "2026-05-01T10:07:00Z",
};
function TopbarActions() {
const chrome = useOfficeTopbarChrome();
return <>{chrome?.actions}</>;
}
function PaneHarness({ task, sessions }: { task: Task; sessions: TaskSession[] }) {
return (
<StateProvider>
<OfficeTopbarChromeProvider>
<OfficeSimplePane task={task} comments={[]} activity={[]} sessions={sessions} />
<TopbarActions />
</OfficeTopbarChromeProvider>
</StateProvider>
);
}
function renderPane(task: Task, sessions: TaskSession[]) {
return render(<PaneHarness task={task} sessions={sessions} />);
}
describe("OfficeSimplePane comment composer", () => {
it("keeps projectless office tasks editable after a completed session loads", () => {
const task = { ...baseTask, status: "done" as const };
const view = renderPane(task, []);
view.rerender(<PaneHarness task={task} sessions={[completedSession]} />);
expect(screen.getByTestId(CHAT_READONLY_TEST_ID).textContent).toBe(CHAT_EDITABLE);
});
it("keeps closed tasks read-only when the latest session cannot be reused", () => {
renderPane({ ...baseTask, status: "done" }, [completedSession, failedSession]);
expect(screen.getByTestId(CHAT_READONLY_TEST_ID).textContent).toBe(CHAT_READONLY);
});
it("keeps completed tasks editable while a follow-up session is active", () => {
renderPane({ ...baseTask, status: "done" }, [completedSession, runningSession]);
expect(screen.getByTestId(CHAT_READONLY_TEST_ID).textContent).toBe(CHAT_EDITABLE);
});
it("keeps cancelled tasks read-only even with a reusable latest session", () => {
renderPane({ ...baseTask, status: "cancelled" }, [completedSession]);
expect(screen.getByTestId(CHAT_READONLY_TEST_ID).textContent).toBe(CHAT_READONLY);
});
});
describe("OfficeSimplePane ExecutionIndicator wiring", () => {
// Regression: the detail page's store-seeded task must feed ExecutionIndicator
// the raw backend status, not the normalized canonical one, so it still tells
// SCHEDULING (live) apart from WAITING_FOR_INPUT (not live) — the same
// distinction task-row.tsx already preserves for the board/list views.
it("prefers rawStatus over status so a SCHEDULING task still shows Live", () => {
renderPane({ ...baseTask, status: "todo", rawStatus: "SCHEDULING" }, []);
expect(screen.getByTestId("execution-indicator").textContent).toBe("SCHEDULING");
});
it("prefers rawStatus over status so a WAITING_FOR_INPUT task does not falsely show Live", () => {
renderPane({ ...baseTask, status: "in_progress", rawStatus: "WAITING_FOR_INPUT" }, []);
expect(screen.getByTestId("execution-indicator").textContent).toBe("WAITING_FOR_INPUT");
});
it("falls back to status when rawStatus is absent", () => {
renderPane({ ...baseTask, status: "in_progress" }, []);
expect(screen.getByTestId("execution-indicator").textContent).toBe("in_progress");
});
});