876529e668
- 删除 ProjectSupervisorView、SupervisorChatOnlyView、ProjectWorkspaceChatPane、AgentConversationOverlay、DeveloperProjectPanels、DeveloperRuntimePanels 与 features/agent-runtime/panels.tsx - 删除 Supervisor 独立调试窗口:windows.rs 的 supervisor_chat_window_url / open_project_supervisor_chat_window、main.rs 的 invoke 注册、?supervisor-chat 与 ?agent-chat 前端入口、developer.json capability 和对应 Rust 用例 - 删除工作台壳的开发者 Agent 面板:DeveloperAgentPanel、useDeveloperAgentPanel、useDeveloperAgentState、developerAgentControls - App.tsx 删除只服务退役面板的 state/ref/effect/handler(agentConversation*、文件/记忆/资产/画板/预览面板处理、commandLog、llmConfigStatus、editorBaseUrl、回放历史与 trace 面板状态、selectedAgent 等)以及由此产生的空分支,并把 requestRuntimeConfigOpen 接回本地 RuntimeConfigDialog - 立项策划独立成模块:Design Agent 与 Planning V2 的容器和表现移到 view/project-development/planning/(PlanningChatView、PlanningUserInputCard、GddApprovalCard、DesignAgentSurface、PlanningLaneRuntimeStrip、planningLane、planningSessionV2、planningSessionContract) - 改名到中性概念:ProjectSupervisorComponentProps→ProjectChatComponentProps、WorkspaceLauncherShellProps.ProjectSupervisor→ProjectChat、ProjectDevelopmentView.supervisor→chat、orchestrationMode→agentDockVisible、initialSupervisorMessageClaims→initialTurnClaims、ProjectManifestSnapshotSource 'supervisor'→'chat'、CSS project-supervisor-*/project-planning-*→project-chat-* - 删除 PROJECT_SUPERVISOR_AGENT_ID 与 PROJECT_SUPERVISOR_PLAN_SOURCE 常量 - 工作台壳自己订阅 game-creator-manifest-invalidated,用 revision→清单→revision 配对读(source: asset-event)并入 currentProjectContext,且不重新检查项目目录;测试夹具改为按监听器集合广播该事件 - 删除只钉住退役界面的 appSurface 用例(旧调试窗口、开发者面板、Agent 对话浮层、运行历史面板) - 同步文档:ADR、DirectProject 聊天模块抽离实施计划与里程碑、Provider 推理里程碑、策划会话 RuntimeV2、AI 游戏创作实施计划,并在 decision-log 新增 2026-09-19 条 - 删除随退役面板失去调用方的模块与导出:projectSummaryCommands.ts(1054 行旧 Supervisor 斜杠命令处理器)、AGENT_RUN_HISTORY_* 常量、agent-runtime/model.ts 与 project-summary/agentPresentation.ts 里的死导出,以及 agentRunTrace / memoryCommands / projectCommandPolicy 中无调用方的校验与解析函数 - 更新 scripts/check-config.mjs 与 scripts/check-native-shells.mjs 的窗口与命令守卫
242 lines
7.6 KiB
TypeScript
242 lines
7.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
import {
|
|
act,
|
|
cleanup,
|
|
fireEvent,
|
|
render,
|
|
renderHook,
|
|
screen,
|
|
waitFor,
|
|
} from '@testing-library/react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { GameCreatorDirectActiveTurn } from '../src/app/types';
|
|
import {
|
|
DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS,
|
|
useDirectActiveTurns,
|
|
} from '../src/features/agent-runtime/directActiveTurns';
|
|
import { ActiveProjectRunsPanel } from '../src/features/app-shell/ActiveProjectRunsPanel';
|
|
|
|
afterEach(() => cleanup());
|
|
|
|
const ACTIVE_TURN = {
|
|
projectPath: 'C:/projects/demo',
|
|
agentId: 'planning-agent-v2',
|
|
runId: 'run-1',
|
|
} as unknown as GameCreatorDirectActiveTurn;
|
|
|
|
describe('useDirectActiveTurns', () => {
|
|
it('keeps the snapshot identity when the poll returns the same content', async () => {
|
|
// 回归点:轮询每次都 setActiveTurns(新数组) 会让所有依赖 activeTurns 的 effect
|
|
// 反复重跑(窗口标题栏的活动项目面板曾因此无限 setState)。
|
|
const invoke = vi.fn(async () => [ACTIVE_TURN]) as never;
|
|
const { result } = renderHook(() =>
|
|
useDirectActiveTurns({
|
|
invoke,
|
|
enabled: true,
|
|
pollIntervalMs: DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS,
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.activeTurns).toHaveLength(1);
|
|
});
|
|
const firstSnapshot = result.current.activeTurns;
|
|
|
|
await act(async () => {
|
|
await result.current.refreshActiveTurns();
|
|
await result.current.refreshActiveTurns();
|
|
});
|
|
expect(result.current.activeTurns).toBe(firstSnapshot);
|
|
});
|
|
|
|
it('clears to a stable empty snapshot when the hook is disabled', async () => {
|
|
let resolveSnapshot!: (turns: GameCreatorDirectActiveTurn[]) => void;
|
|
const snapshot = new Promise<GameCreatorDirectActiveTurn[]>((resolve) => {
|
|
resolveSnapshot = resolve;
|
|
});
|
|
const invoke = vi.fn(() => snapshot) as never;
|
|
const { result, rerender } = renderHook(
|
|
({ enabled }: { enabled: boolean }) =>
|
|
useDirectActiveTurns({ invoke, enabled, pollIntervalMs: 60_000 }),
|
|
{ initialProps: { enabled: true } },
|
|
);
|
|
|
|
const emptySnapshot = result.current.activeTurns;
|
|
await act(async () => {
|
|
resolveSnapshot([]);
|
|
await snapshot;
|
|
});
|
|
expect(result.current.activeTurns).toBe(emptySnapshot);
|
|
rerender({ enabled: false });
|
|
expect(result.current.activeTurns).toBe(emptySnapshot);
|
|
});
|
|
|
|
it('读取失败后的重试定时器会在卸载后清理', async () => {
|
|
vi.useFakeTimers();
|
|
const invoke = vi.fn(async () => {
|
|
throw new Error('temporarily unavailable');
|
|
});
|
|
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout');
|
|
|
|
try {
|
|
const { unmount } = renderHook(() =>
|
|
useDirectActiveTurns({ invoke, enabled: true }),
|
|
);
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
expect(invoke).toHaveBeenCalledTimes(1);
|
|
|
|
unmount();
|
|
expect(clearTimeoutSpy).toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
});
|
|
expect(invoke).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
clearTimeoutSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('停用后晚到的非空快照不能恢复活动回合,手动刷新也不发请求', async () => {
|
|
let complete!: (turns: GameCreatorDirectActiveTurn[]) => void;
|
|
const invoke = vi.fn(
|
|
() =>
|
|
new Promise<GameCreatorDirectActiveTurn[]>((resolve) => {
|
|
complete = resolve;
|
|
}),
|
|
);
|
|
const { result, rerender } = renderHook(
|
|
({ enabled }) =>
|
|
useDirectActiveTurns({ invoke: invoke as never, enabled }),
|
|
{ initialProps: { enabled: true } },
|
|
);
|
|
rerender({ enabled: false });
|
|
const empty = result.current.activeTurns;
|
|
await act(async () => {
|
|
complete([ACTIVE_TURN]);
|
|
await result.current.refreshActiveTurns();
|
|
});
|
|
expect(result.current.activeTurns).toBe(empty);
|
|
expect(result.current.snapshotReadFailed).toBe(false);
|
|
expect(invoke).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('重新启用后读取新快照,旧请求晚到不能覆盖新快照', async () => {
|
|
let completeOld!: (turns: GameCreatorDirectActiveTurn[]) => void;
|
|
const invoke = vi
|
|
.fn()
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise<GameCreatorDirectActiveTurn[]>((resolve) => {
|
|
completeOld = resolve;
|
|
}),
|
|
)
|
|
.mockResolvedValue([{ ...ACTIVE_TURN, runId: 'new-run' }]);
|
|
const { result, rerender } = renderHook(
|
|
({ enabled }) =>
|
|
useDirectActiveTurns({ invoke: invoke as never, enabled }),
|
|
{ initialProps: { enabled: true } },
|
|
);
|
|
rerender({ enabled: false });
|
|
rerender({ enabled: true });
|
|
await waitFor(() =>
|
|
expect(result.current.activeTurns[0]?.runId).toBe('new-run'),
|
|
);
|
|
const current = result.current.activeTurns;
|
|
await act(async () => {
|
|
completeOld([ACTIVE_TURN]);
|
|
});
|
|
expect(result.current.activeTurns).toBe(current);
|
|
expect(invoke).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('ActiveProjectRunsPanel', () => {
|
|
it('按开始时间展示正在运行的项目并支持进入项目', () => {
|
|
const onOpenProject = vi.fn();
|
|
render(
|
|
<ActiveProjectRunsPanel
|
|
activeTurns={[
|
|
{
|
|
projectPath: 'C:/projects/later',
|
|
projectName: '后开始',
|
|
turnId: 'turn-later',
|
|
startedAt: 200,
|
|
status: 'streaming',
|
|
updatedAt: 220,
|
|
sequence: 2,
|
|
},
|
|
{
|
|
projectPath: 'C:/projects/first',
|
|
projectName: '先开始',
|
|
turnId: 'turn-first',
|
|
startedAt: 100,
|
|
status: 'running',
|
|
updatedAt: 120,
|
|
sequence: 1,
|
|
},
|
|
]}
|
|
onOpenProject={onOpenProject}
|
|
/>,
|
|
);
|
|
|
|
const items = screen.getAllByRole('button');
|
|
expect(items.map((item) => item.textContent?.includes('先开始'))).toEqual([
|
|
true,
|
|
false,
|
|
]);
|
|
fireEvent.click(items[0]);
|
|
expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
|
|
});
|
|
|
|
it('读取失败时保留明确的读取提示,不伪装成没有运行项目', () => {
|
|
render(<ActiveProjectRunsPanel activeTurns={[]} readFailed />);
|
|
|
|
expect(screen.getByRole('status').textContent).toBe(
|
|
'未能读取正在运行的项目',
|
|
);
|
|
});
|
|
|
|
it('标题栏入口只显示最后开始的项目,展开后列出全部项目', () => {
|
|
const onOpenProject = vi.fn();
|
|
render(
|
|
<ActiveProjectRunsPanel
|
|
placement="titlebar"
|
|
activeTurns={[
|
|
{
|
|
projectPath: 'C:/projects/first',
|
|
projectName: '先开始',
|
|
turnId: 'turn-first',
|
|
startedAt: 100,
|
|
status: 'running',
|
|
updatedAt: 120,
|
|
sequence: 1,
|
|
},
|
|
{
|
|
projectPath: 'C:/projects/later',
|
|
projectName: '后开始',
|
|
turnId: 'turn-later',
|
|
startedAt: 200,
|
|
status: 'streaming',
|
|
updatedAt: 220,
|
|
sequence: 2,
|
|
},
|
|
]}
|
|
onOpenProject={onOpenProject}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByRole('button', { name: /后开始/ })).toBeTruthy();
|
|
expect(screen.queryByRole('menu')).toBeNull();
|
|
fireEvent.click(screen.getByRole('button', { name: /后开始/ }));
|
|
expect(screen.getByRole('menu')).toBeTruthy();
|
|
expect(screen.getAllByRole('menuitem')).toHaveLength(2);
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /先开始/ }));
|
|
expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
|
|
});
|
|
});
|