Files
Genarrative/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
T
suzmii ecca7050b5 音频资源工具条断言改判据:不再要求渲染空回调的改造按钮
- project-development.suite.ts 的音频工具条断言由「必须存在改造按钮」改为「不得存在改造按钮」+「按钮集合精确等于分类与标签、重命名」

- fed17ea08 已按共享记忆 pitfalls.md 的判据(渲染出来的动作必须真的能跑通)把 redraw 与 character-animation 移出 supportedActions,音频分支只剩宿主编排层真实接通的分类与标签、重命名,原断言断言的是被该提交推翻的旧合同

- 新断言比旧断言更严:除钉住音频分支仍在渲染,还钉住整条工具条的按钮集合,避免再出现点了没反应的假按钮
2026-09-10 17:53:15 +08:00

7479 lines
252 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
ProjectResourceCanvasLayout,
ProjectResourceCanvasPosition,
} from '../../../../packages/shared/src/contracts/gameCreationApp';
import { RESOURCE_BOOK_OVERVIEW_STACK_LIMIT } from '../../src/view/project-development/resourceBookLayout';
import {
RESOURCE_CANVAS_CARD_WIDTH,
RESOURCE_CANVAS_COLUMN_GAP,
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
} from '../../src/view/project-development/resourceCanvasLayoutModel';
import { normalizeProjectResourceGraph } from '../../src/view/project-development/resourceDependencyGraphModel';
import { ResourceDependencyOverlay } from '../../src/view/project-development/ResourceDependencyOverlay';
import type { ProjectAgentResultSummary } from '../../src/view/project-development/resourceProjectionModel';
import {
act,
agentRuntimeUserInputRequest,
App,
cleanup,
composerDisabled,
composerText,
composerValue,
createGameCreationAppManifest,
createGameCreationAppSeedTasks,
createProjectSupervisorRuntimeHarness,
emptyProjectPolicy,
expect,
findResourceSelectButton,
fireEvent,
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
type GameCreationAgentRunTrace,
getResourceSelectButton,
it,
mockRoleAgentReply,
pickProjectFromLauncher,
ProjectDevelopmentView,
projectSupervisorResponseStream,
queryResourceSelectButton,
React,
readFileSync,
render,
renderAppAt,
renderLauncherProjectsAt,
resolve,
screen,
setComposerText,
submitChat,
vi,
waitFor,
within,
} from './harness';
function resourceGraphForInputs(args?: Record<string, unknown>) {
const resources =
(args?.resources as
| Array<{ resourceId: string; producerTaskId: string | null }>
| undefined) ?? [];
return {
resourceIds: resources.map(({ resourceId }) => resourceId),
referenceEdges: [],
taskFlows: [],
connectionIndex: resources.map(({ resourceId }) => ({
resourceId,
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: [],
referenceEdgeIds: [],
taskFlowIds: [],
})),
producerAssignments: resources.flatMap((resource) =>
resource.producerTaskId
? [
{
resourceId: resource.resourceId,
taskId: resource.producerTaskId,
},
]
: [],
),
dependencyDepths: resources.map((resource) => ({
resourceId: resource.resourceId,
dependencyDepth: 0,
})),
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
function installResourceCardIntersectionObserver() {
const instances: Array<{
callback: IntersectionObserverCallback;
observed: Set<Element>;
observer: IntersectionObserver;
}> = [];
class ResourceCardIntersectionObserver {
readonly root = null;
readonly rootMargin = '160px';
readonly thresholds = [0];
readonly observed = new Set<Element>();
constructor(readonly callback: IntersectionObserverCallback) {
instances.push({
callback,
observed: this.observed,
observer: this as unknown as IntersectionObserver,
});
}
observe(element: Element) {
this.observed.add(element);
}
unobserve(element: Element) {
this.observed.delete(element);
}
disconnect() {
this.observed.clear();
}
takeRecords() {
return [];
}
}
Object.defineProperty(window, 'IntersectionObserver', {
configurable: true,
value: ResourceCardIntersectionObserver,
});
return {
triggerVisible(elements?: Element[]) {
const instance = instances.at(-1);
if (!instance) {
throw new Error('resource card IntersectionObserver was not created');
}
const targets = elements ?? Array.from(instance.observed);
instance.callback(
targets.map(
(target) =>
({
target,
isIntersecting: true,
intersectionRatio: 1,
}) as IntersectionObserverEntry,
),
instance.observer,
);
},
observedCount() {
return instances.at(-1)?.observed.size ?? 0;
},
};
}
async function openResourceBookCategory(label: string) {
const categoryByLabel: Record<string, string> = {
设计文档: 'document',
美术资源: 'art',
音乐音效: 'audio',
项目版本: 'version',
游戏代码: 'code',
};
const category = categoryByLabel[label];
const outline = await screen.findByLabelText('资源栏目大纲');
fireEvent.click(
within(outline).getByRole('button', { name: new RegExp(label) }),
);
await waitFor(() => {
const manager = document.querySelector('[data-resource-book-view="child"]');
expect(manager).not.toBeNull();
if (category) {
expect(
manager?.querySelector(
`.game-resource-book-scene-titlebar.is-active[data-resource-book-category="${category}"]`,
),
).not.toBeNull();
}
});
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, reject, resolve };
}
export function registerProjectWorkbenchFoundationTests() {
it('renders the first project workbench slice with honest disabled run and local approval UI', () => {
const manifest = createGameCreationAppManifest(
'workbench-draft',
'陶泥儿工作台测试',
);
expect(
manifest.tasks.find((task) => task.id === 'design-foundation'),
).toMatchObject({
title: '确定玩法规格与界面原型',
artifacts: [
'memory/project.md',
'game/game_design.md',
'assets/ui-prototype.png',
],
acceptanceCriteria: [
'核心循环、胜负条件和第一版关卡目标明确,且已基于规范图生成可读的 16:9 横屏界面原型图',
],
});
expect(
manifest.tasks.find((task) => task.id === 'art-asset-plan'),
).toMatchObject({
title: '生成首版美术素材',
artifacts: ['assets/manifest.art.json', 'assets/art-spritesheet.png'],
acceptanceCriteria: [
'角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记',
],
});
render(
React.createElement(ProjectDevelopmentView, {
projectName: '陶泥儿工作台测试',
projectPath: '/tmp/workbench-draft',
manifest,
attachments: [
{
fileName: 'broken-reference.png',
mediaType: 'image/png',
status: 'failed',
error: '图片解码失败',
},
],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement(
'div',
{ 'aria-label': '测试项目总控' },
'项目总控对话内容',
),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
expect(screen.getByLabelText('项目开发工作台')).not.toBeNull();
expect(screen.getByLabelText('项目主视窗')).not.toBeNull();
expect(screen.getByLabelText('陶泥儿 Agent 对话')).not.toBeNull();
expect(screen.getByLabelText('测试项目总控')).not.toBeNull();
expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull();
expect(
screen.getByRole('article', { name: /设计实现 Agent/ }),
).not.toBeNull();
expect(screen.getByRole('article', { name: /美术 Agent/ })).not.toBeNull();
expect(screen.getByRole('article', { name: /程序 Agent/ })).not.toBeNull();
const runTab = screen.getByRole('tab', {
name: '运行',
}) as HTMLButtonElement;
expect(runTab.disabled).toBe(false);
expect(runTab.getAttribute('data-unavailable')).toBe('true');
const playButton = screen.getByRole('button', {
name: '播放',
}) as HTMLButtonElement;
expect(playButton.disabled).toBe(true);
fireEvent.click(runTab);
expect(runTab.getAttribute('aria-selected')).toBe('false');
expect(
screen.getByText('首个可运行原型尚未完成,运行视图暂不可用'),
).not.toBeNull();
expect(screen.getByLabelText('附件导入失败')).not.toBeNull();
expect(screen.getByText('broken-reference.png')).not.toBeNull();
expect(screen.getByText('图片解码失败')).not.toBeNull();
fireEvent.click(
screen.getByRole('button', {
name: '审批配置,当前严格审批',
}),
);
expect(
screen.getByRole('dialog', { name: '陶泥儿的操作权限' }),
).not.toBeNull();
const riskApproval = screen.getByRole('radio', { name: /风险审批/ });
fireEvent.click(riskApproval);
expect(riskApproval.getAttribute('aria-checked')).toBe('false');
expect(riskApproval.getAttribute('data-unavailable')).toBe('true');
expect(screen.getAllByText('Rank 规则待定,当前暂不可用')).toHaveLength(2);
fireEvent.click(screen.getByRole('button', { name: '完成' }));
expect(
screen.getByRole('button', {
name: '审批配置,当前严格审批',
}),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '更多小组' }));
expect(screen.getByRole('article', { name: /数值 Agent/ })).not.toBeNull();
expect(screen.getByRole('article', { name: /音频 Agent/ })).not.toBeNull();
expect(screen.getByRole('article', { name: /发布 Agent/ })).not.toBeNull();
});
it('preserves independent art viewports across sort and workbench mode switches', async () => {
const manifest = createGameCreationAppManifest(
'workbench-art-viewport-memory',
'美术资源视口记忆',
);
const codeTask = manifest.tasks.find(
(task) => task.id === 'code-prototype',
);
expect(codeTask).toBeDefined();
codeTask!.status = 'completed';
manifest.assets = [
{
id: 'entry-art',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/entry-art.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
];
window.__TAURI__ = {
core: {
invoke: vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
),
},
};
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-art-viewport-memory',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('美术资源');
const artCanvas = await screen.findByRole('region', {
name: '美术资源',
});
const readViewport = (canvas: HTMLElement) =>
canvas
.querySelector<HTMLElement>('[data-resource-viewport]')
?.getAttribute('data-resource-viewport');
const zoomOut = (deltaY: number) => {
const event = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
ctrlKey: true,
deltaY,
clientX: 80,
clientY: 60,
});
act(() => {
expect(artCanvas.dispatchEvent(event)).toBe(false);
});
};
zoomOut(120);
const dependencyViewport = readViewport(artCanvas);
expect(dependencyViewport).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
const outline = screen.getByLabelText('资源栏目大纲');
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
await openResourceBookCategory('美术资源');
const typeCanvas = await screen.findByRole('region', {
name: '美术资源',
});
await waitFor(() =>
expect(readViewport(typeCanvas)).not.toBe(dependencyViewport),
);
zoomOut(240);
zoomOut(240);
const typeViewport = readViewport(typeCanvas);
expect(typeViewport).toBeTruthy();
expect(typeViewport).not.toBe(dependencyViewport);
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
await openResourceBookCategory('美术资源');
const restoredDependencyCanvas = await screen.findByRole('region', {
name: '美术资源',
});
await waitFor(() =>
expect(readViewport(restoredDependencyCanvas)).toBe(dependencyViewport),
);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
await openResourceBookCategory('美术资源');
const restoredTypeCanvas = await screen.findByRole('region', {
name: '美术资源',
});
await waitFor(() =>
expect(readViewport(restoredTypeCanvas)).toBe(typeViewport),
);
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
expect(screen.getByRole('region', { name: '运行表现层' })).not.toBeNull();
fireEvent.click(screen.getByRole('tab', { name: '资源管理' }));
await openResourceBookCategory('美术资源');
const restoredArtCanvas = await screen.findByRole('region', {
name: '美术资源',
});
await waitFor(() =>
expect(readViewport(restoredArtCanvas)).toBe(typeViewport),
);
});
it('uses one full-page canvas per resource section with dependency-only guide lines', async () => {
function addSectionResources(
manifest: ReturnType<typeof createGameCreationAppManifest>,
) {
manifest.assets = [
{
id: 'section-document',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'memory/section.md',
source: { kind: 'generated', taskId: 'design-foundation' },
},
{
id: 'section-art',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/section.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
{
id: 'section-audio',
kind: 'background-music',
mediaType: 'audio/mpeg',
localPath: 'assets/section.mp3',
source: { kind: 'generated', taskId: 'audio-asset-plan' },
},
{
id: 'section-code',
kind: 'game-code',
mediaType: 'text/javascript',
localPath: 'game/section.js',
source: { kind: 'generated', taskId: 'code-prototype' },
},
];
manifest.versions = [
{
versionId: 'section-version',
parentVersionId: null,
projectRevision: 1,
resourceBindings: [],
createdReason: 'initial',
createdAt: 1,
},
];
}
const manifest = createGameCreationAppManifest(
'workbench-section-pages',
'分页资源项目',
);
addSectionResources(manifest);
let layoutRevision = 0;
let graphResourceIds: string[] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
graphResourceIds = (
(args?.resources as Array<{ resourceId: string }> | undefined) ?? []
).map(({ resourceId }) => resourceId);
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: layoutRevision,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-section-pages',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await waitFor(() => {
const manager = document.querySelector<HTMLElement>(
'[data-resource-book-view="main"]',
);
const sceneWorld = manager?.querySelector<HTMLElement>(
'.game-resource-book-scene-world',
);
expect(
manager?.querySelector(
'.game-resource-book-scene-titlebar[data-resource-book-category="document"]',
),
).not.toBeNull();
expect(
manager
?.querySelector(
'.game-resource-book-scene-titlebar[data-resource-book-category="document"]',
)
?.getAttribute('style'),
).toContain('opacity: 1');
expect(
manager?.querySelector(
'.game-resource-book-scene-card[data-resource-book-category="document"] .game-resource-card',
),
).not.toBeNull();
expect(sceneWorld?.style.transform).toContain('scale(1)');
});
const mainScene = document.querySelector<HTMLElement>(
'[data-resource-book-view="main"] .game-resource-book-scene',
);
const mainSceneWorld = mainScene?.querySelector<HTMLElement>(
'.game-resource-book-scene-world',
);
const mainCanvas = screen.getByRole('region', { name: '资源总览' });
const mainWorld = mainCanvas.querySelector<HTMLElement>(
'.game-resource-book-main-world',
)!;
expect(mainWorld).not.toBeNull();
const mainTransformBeforeZoom = mainSceneWorld?.style.transform;
fireEvent.click(screen.getByRole('button', { name: '放大画布' }));
expect(mainSceneWorld?.style.transform).not.toBe(mainTransformBeforeZoom);
expect(mainCanvas.style.transform).toBe('');
expect(mainWorld.style.transform).toBe(mainSceneWorld?.style.transform);
const mainSetPointerCapture = vi.fn();
const mainReleasePointerCapture = vi.fn();
Object.defineProperties(mainCanvas, {
setPointerCapture: { configurable: true, value: mainSetPointerCapture },
hasPointerCapture: { configurable: true, value: vi.fn(() => true) },
releasePointerCapture: {
configurable: true,
value: mainReleasePointerCapture,
},
});
const readMainViewport = () =>
mainWorld.style.transform.match(/-?\d+(?:\.\d+)?/g)!.map(Number);
const mainBeforePan = readMainViewport();
const dragMain = (x: number, y: number) => {
fireEvent.pointerDown(mainCanvas, {
pointerId: 81,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(mainCanvas, {
pointerId: 81,
clientX: 100 + x,
clientY: 100 + y,
});
fireEvent.pointerUp(mainCanvas, { pointerId: 81 });
};
dragMain(-4_000, 3_000);
expect(readMainViewport()).toEqual([
mainBeforePan[0]! - 4_000,
mainBeforePan[1]! + 3_000,
mainBeforePan[2],
]);
dragMain(8_000, -6_000);
expect(readMainViewport()).toEqual([
mainBeforePan[0]! + 4_000,
mainBeforePan[1]! - 3_000,
mainBeforePan[2],
]);
expect(mainSetPointerCapture).toHaveBeenCalledWith(81);
expect(mainReleasePointerCapture).toHaveBeenCalledWith(81);
dragMain(-4_000, 3_000);
expect(readMainViewport()).toEqual(mainBeforePan);
const mainWheel = (init: WheelEventInit) => {
const event = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
...init,
});
act(() => {
mainCanvas.dispatchEvent(event);
});
expect(event.defaultPrevented).toBe(true);
};
mainWheel({ deltaX: 70, deltaY: 120 });
expect(readMainViewport()).toEqual([
mainBeforePan[0]! - 70,
mainBeforePan[1]! - 120,
mainBeforePan[2],
]);
mainWheel({ deltaY: 80, shiftKey: true });
expect(readMainViewport()).toEqual([
mainBeforePan[0]! - 150,
mainBeforePan[1]! - 120,
mainBeforePan[2],
]);
mainWheel({ deltaY: -120, ctrlKey: true, clientX: 80, clientY: 60 });
expect(readMainViewport()[2]).toBeGreaterThan(mainBeforePan[2]!);
expect(mainCanvas.style.transform).toBe('');
expect(mainWorld.style.transform).toBe(mainSceneWorld?.style.transform);
const mainViewportBeforeNavigation = mainWorld.style.transform;
const dependencyCanvas = (await screen.findByLabelText(
'资源依赖视图',
)) as HTMLDivElement;
Object.defineProperties(dependencyCanvas, {
clientWidth: { configurable: true, get: () => 800 },
clientHeight: { configurable: true, get: () => 600 },
});
const pageCanvas = dependencyCanvas.querySelector<HTMLElement>(
'.game-resource-page-canvas',
);
expect(pageCanvas).not.toBeNull();
Object.defineProperties(pageCanvas!, {
clientWidth: { configurable: true, get: () => 500 },
clientHeight: { configurable: true, get: () => 300 },
});
const setPointerCapture = vi.fn();
const releasePointerCapture = vi.fn();
const hasPointerCapture = vi.fn(() => true);
Object.defineProperties(dependencyCanvas, {
setPointerCapture: { configurable: true, value: setPointerCapture },
hasPointerCapture: { configurable: true, value: hasPointerCapture },
releasePointerCapture: {
configurable: true,
value: releasePointerCapture,
},
});
const resourceBookManager = document.querySelector<HTMLElement>(
'[data-resource-book-view="main"]',
);
if (resourceBookManager) {
Object.defineProperties(resourceBookManager, {
clientWidth: { configurable: true, get: () => 500 },
clientHeight: { configurable: true, get: () => 300 },
});
vi.spyOn(resourceBookManager, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
top: 0,
left: 0,
right: 500,
bottom: 300,
width: 500,
height: 300,
toJSON: () => ({}),
} as DOMRect);
}
window.dispatchEvent(new Event('resize'));
const outline = screen.getByLabelText('资源栏目大纲');
expect(
Array.from(outline.querySelectorAll('strong')).map(
(item) => item.textContent,
),
).toEqual(['设计文档', '美术资源', '音乐音效', '项目版本']);
expect(graphResourceIds).toContain('asset:section-code');
expect(queryResourceSelectButton('section.js')).toBeNull();
expect(screen.queryByRole('region', { name: '游戏代码' })).toBeNull();
expect(outline.querySelectorAll('small')).toHaveLength(0);
expect(screen.getByRole('region', { name: '设计文档' })).not.toBeNull();
expect(
screen.getByTestId('resource-dependency-overlay-document'),
).not.toBeNull();
expect(
screen.getByRole('button', { name: /下一页\s*美术资源/ }),
).not.toBeNull();
const documentCardBefore = document.querySelector<HTMLElement>(
'.game-resource-book-scene-card[data-resource-book-category="document"] .game-resource-card',
);
const documentTitlebarBefore = document.querySelector<HTMLElement>(
'.game-resource-book-scene-titlebar[data-resource-book-category="document"]',
);
expect(documentCardBefore).not.toBeNull();
expect(documentTitlebarBefore).not.toBeNull();
await openResourceBookCategory('设计文档');
// 真实元素 FLIP:卡片和标题栏在总览与子画布之间是同一批 DOM 节点,
// 转场不引入克隆快照层。
expect(documentCardBefore!.isConnected).toBe(true);
expect(documentCardBefore!.closest('[data-resource-book-view]')).toBe(
document.querySelector('[data-resource-book-view="child"]'),
);
expect(documentTitlebarBefore!.isConnected).toBe(true);
expect(documentTitlebarBefore!.classList.contains('is-active')).toBe(true);
expect(
document.querySelector('.game-resource-book-transition-layer'),
).toBeNull();
expect(
within(outline).queryByRole('button', { name: /游戏代码/ }),
).toBeNull();
expect(screen.queryByRole('region', { name: '游戏代码' })).toBeNull();
const dispatchViewWheel = (deltaY = 160) => {
const scene = document.querySelector<HTMLElement>(
'[data-resource-book-view="child"]',
);
expect(scene).not.toBeNull();
const event = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaX: 0,
deltaY,
});
act(() => {
scene!.dispatchEvent(event);
});
expect(event.defaultPrevented).toBe(true);
};
dispatchViewWheel();
expect(screen.getByRole('region', { name: '设计文档' })).not.toBeNull();
const documentWorld = dependencyCanvas.querySelector<HTMLElement>(
'.game-resource-page-canvas[data-resource-section-scroll="document"] [data-resource-viewport]',
);
expect(documentWorld).not.toBeNull();
await waitFor(() =>
expect(documentWorld?.getAttribute('data-resource-viewport')).not.toBe(
'48,48,1',
),
);
const readViewport = () =>
(documentWorld?.getAttribute('data-resource-viewport') ?? '')
.split(',')
.map(Number);
const readFitBounds = () =>
(documentWorld?.getAttribute('data-resource-fit-boundary') ?? '')
.split(',')
.map(Number);
const fittedViewport = readViewport();
const fittedBounds = readFitBounds();
const expectedDocumentFitScale = Math.min(
RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
468 / fittedBounds[2]!,
268 / fittedBounds[3]!,
);
expect(fittedViewport[2]).toBeCloseTo(expectedDocumentFitScale, 8);
const childCard = document.querySelector<HTMLElement>(
'[data-resource-book-view="child"] .game-resource-book-scene-card.is-expanded .game-resource-card',
);
expect(childCard).not.toBeNull();
const childTitlebar = document.querySelector<HTMLElement>(
'[data-resource-book-view="child"] .game-resource-book-scene-titlebar.is-active',
);
expect(childTitlebar?.style.transform).toContain('scale(');
const childScene = childCard?.closest<HTMLElement>(
'.game-resource-book-scene',
);
const childSceneWorld = childScene?.querySelector<HTMLElement>(
'.game-resource-book-scene-world',
);
const childSceneTransformBeforeZoom = childSceneWorld?.style.transform;
fireEvent.click(screen.getByRole('button', { name: '放大画布' }));
await waitFor(() =>
expect(childSceneWorld?.style.transform).not.toBe(
childSceneTransformBeforeZoom,
),
);
const viewportBeforeZoom = fittedViewport;
const zoomWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
ctrlKey: true,
deltaX: 0,
deltaY: -120,
});
let zoomWheelResult = true;
act(() => {
zoomWheelResult = dependencyCanvas.dispatchEvent(zoomWheel);
});
expect(zoomWheelResult).toBe(false);
expect(readViewport()[2]).toBeGreaterThan(viewportBeforeZoom[2]!);
const documentViewportAfterZoom = readViewport();
await openResourceBookCategory('美术资源');
await openResourceBookCategory('设计文档');
await waitFor(() =>
expect(readViewport()).toEqual(documentViewportAfterZoom),
);
expect(screen.getByRole('region', { name: '设计文档' })).not.toBeNull();
fireEvent.click(
screen.getByRole('button', {
name: /选中资源:设计文档 section\.md/,
}),
);
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
dispatchViewWheel();
expect(screen.getByRole('region', { name: '设计文档' })).not.toBeNull();
fireEvent.click(
within(
document.querySelector<HTMLElement>(
'.game-resource-book-scene-titlebar.is-active',
) as HTMLElement,
).getByRole('button', { name: '收起资源' }),
);
// jsdom has no Web Animations: navigation must settle immediately,
// without waiting for a guessed CSS duration.
expect(
screen
.getByRole('region', { name: '资源总览' })
.closest('[data-resource-book-view]')
?.getAttribute('data-resource-book-transition'),
).toBe('idle');
expect(mainWorld.style.transform).toBe(mainViewportBeforeNavigation);
await openResourceBookCategory('设计文档');
const viewportBeforeWheelNavigation = readViewport();
dispatchViewWheel();
await waitFor(() =>
expect(readViewport()[1]).toBeLessThan(viewportBeforeWheelNavigation[1]!),
);
expect(screen.getByRole('region', { name: '设计文档' })).not.toBeNull();
const zoomAfterViewWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
ctrlKey: true,
deltaX: 0,
deltaY: -120,
clientX: 80,
clientY: 60,
});
act(() => {
expect(
screen
.getByRole('button', { name: '复位资源视图' })
.dispatchEvent(zoomAfterViewWheel),
).toBe(false);
});
await waitFor(() =>
expect(readViewport()[2]).toBeGreaterThan(
viewportBeforeWheelNavigation[2]!,
),
);
expect(screen.getByRole('region', { name: '设计文档' })).not.toBeNull();
const dragCard = screen
.getByRole('button', {
name: /选中资源:设计文档 section\.md/,
})
.closest<HTMLElement>('.game-resource-card');
expect(dragCard).not.toBeNull();
const cardSetPointerCapture = vi.fn();
const cardReleasePointerCapture = vi.fn();
Object.defineProperties(dragCard!, {
setPointerCapture: {
configurable: true,
value: cardSetPointerCapture,
},
hasPointerCapture: { configurable: true, value: vi.fn(() => true) },
releasePointerCapture: {
configurable: true,
value: cardReleasePointerCapture,
},
});
const layoutUpdateCountBeforeCancelledDrag = invoke.mock.calls.filter(
([command]) => command === 'update_local_project_resource_canvas_layout',
).length;
fireEvent.pointerDown(dragCard!, {
pointerId: 91,
button: 0,
clientX: 40,
clientY: 50,
});
expect(cardSetPointerCapture).toHaveBeenCalledWith(91);
fireEvent.pointerMove(dragCard!, {
pointerId: 91,
clientX: 100,
clientY: 50,
});
fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ }));
expect(cardReleasePointerCapture).toHaveBeenCalledWith(91);
fireEvent.pointerUp(dragCard!, {
pointerId: 91,
clientX: 100,
clientY: 50,
});
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'update_local_project_resource_canvas_layout',
),
).toHaveLength(layoutUpdateCountBeforeCancelledDrag);
await openResourceBookCategory('设计文档');
const viewportBeforePan = readViewport();
// 与美术画布一致:左键在空白处是框选,平移用中键(或按住空格)。
fireEvent.pointerDown(dependencyCanvas, {
button: 1,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(dependencyCanvas, { clientX: 30, clientY: 70 });
fireEvent.pointerUp(dependencyCanvas);
expect(setPointerCapture).toHaveBeenCalled();
await waitFor(() => {
const [x, y] = readViewport();
expect(x).toBeLessThan(viewportBeforePan[0]!);
expect(y).toBeLessThanOrEqual(viewportBeforePan[1]!);
});
fireEvent.pointerDown(dependencyCanvas, {
button: 1,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(dependencyCanvas, {
clientX: -5_000,
clientY: -5_000,
});
fireEvent.pointerUp(dependencyCanvas);
await waitFor(() => {
const [x, y] = readViewport();
expect(x).toBeLessThan(-1_000);
expect(y).toBeLessThan(-1_000);
});
const sectionViewportBeforeWheel = readViewport();
dispatchViewWheel(160);
await waitFor(() =>
expect(readViewport()[1]).toBeLessThan(sectionViewportBeforeWheel[1]!),
);
expect(screen.getByRole('region', { name: '设计文档' })).not.toBeNull();
expect(
screen.getByTestId('resource-dependency-overlay-document'),
).not.toBeNull();
expect(screen.queryByTestId('resource-dependency-overlay-art')).toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ }));
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
const typeCanvas = screen.getByLabelText('资源类型视图') as HTMLDivElement;
expect(
within(typeCanvas).getByRole('button', { name: '复位资源视图' }),
).not.toBeNull();
expect(screen.getByRole('region', { name: '美术资源' })).not.toBeNull();
expect(
typeCanvas.querySelector('.game-resource-dependency-overlay'),
).toBeNull();
const readTypeViewport = () =>
Number(
(
typeCanvas
.querySelector<HTMLElement>('[data-resource-viewport]')
?.getAttribute('data-resource-viewport') ?? '0,0,1'
).split(',')[1],
);
const typeViewportBeforeWheel = readTypeViewport();
dispatchViewWheel(160);
await waitFor(() =>
expect(readTypeViewport()).toBeLessThan(typeViewportBeforeWheel),
);
expect(screen.getByRole('region', { name: '美术资源' })).not.toBeNull();
expect(screen.queryByRole('region', { name: '音乐音效' })).toBeNull();
expect(screen.queryByRole('button', { name: /缩小.*分区/ })).toBeNull();
expect(screen.queryByRole('button', { name: /放大.*内容/ })).toBeNull();
}, 10_000);
it('caps the overview stack for a crowded resource type without trimming the child canvas', async () => {
const manifest = createGameCreationAppManifest(
'workbench-overview-stack-cap',
'资源总览堆叠项目',
);
manifest.assets = Array.from({ length: 5 }, (_value, index) => ({
id: `overview-art-${index}`,
kind: 'character',
mediaType: 'image/png',
localPath: `assets/overview-art-${index}.png`,
source: { kind: 'generated', taskId: 'art-asset-plan' },
}));
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: layoutRevision,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-overview-stack-cap',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await waitFor(() =>
expect(
document.querySelectorAll(
'.game-resource-book-scene-card[data-resource-book-category="art"]',
),
).toHaveLength(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT),
);
expect(screen.getByText('5 项')).not.toBeNull();
const overviewCardLayers = Array.from(
document.querySelectorAll<HTMLElement>(
'.game-resource-book-scene-card[data-resource-book-category="art"]',
),
).map((card) => Number(card.style.zIndex));
expect(overviewCardLayers[0]).toBeGreaterThan(overviewCardLayers.at(-1)!);
await openResourceBookCategory('美术资源');
await waitFor(() =>
expect(
document.querySelectorAll(
'.game-resource-book-scene-card.is-expanded[data-resource-book-category="art"]',
),
).toHaveLength(5),
);
fireEvent.change(screen.getByLabelText('搜索项目资源'), {
target: { value: 'overview-art-4' },
});
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
await waitFor(() =>
expect(
document.querySelectorAll(
'.game-resource-book-scene-card[data-resource-book-category="art"]',
),
).toHaveLength(1),
);
});
it('marks newly added resources on inactive section tabs until the user opens them', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-unread',
'资源未读标识测试',
);
manifest.assets = [
{
id: 'unread-art-initial',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/initial.png',
source: { kind: 'generated' },
},
];
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: layoutRevision,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const viewProps = {
projectName: manifest.name,
projectPath: '/tmp/workbench-resource-unread',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
};
const rendered = render(
React.createElement(ProjectDevelopmentView, viewProps),
);
const outline = await screen.findByLabelText('资源栏目大纲');
expect(
within(outline).queryByRole('button', { name: /有新资源/ }),
).toBeNull();
expect(
within(outline)
.getByRole('button', { name: '美术资源' })
.getAttribute('aria-current'),
).toBe('page');
const manifestWithCurrentArt = {
...manifest,
assets: [
...manifest.assets,
{
id: 'unread-art-current',
kind: 'character' as const,
mediaType: 'image/png',
localPath: 'assets/current.png',
source: { kind: 'generated' as const },
},
],
};
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: manifestWithCurrentArt,
}),
);
expect(
within(outline).queryByRole('button', { name: /有新资源/ }),
).toBeNull();
const manifestWithAudio = {
...manifestWithCurrentArt,
assets: [
...manifestWithCurrentArt.assets,
{
id: 'unread-audio-new',
kind: 'background-music' as const,
mediaType: 'audio/mpeg',
localPath: 'assets/new.mp3',
source: { kind: 'generated' as const },
},
],
};
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: manifestWithAudio,
}),
);
const unreadAudioTab = await within(outline).findByRole('button', {
name: '音乐音效,有新资源',
});
expect(
unreadAudioTab.querySelector('.game-resource-outline-unread'),
).not.toBeNull();
expect(
within(outline).queryByRole('button', { name: '美术资源,有新资源' }),
).toBeNull();
fireEvent.click(unreadAudioTab);
await waitFor(() => {
expect(
within(outline)
.getByRole('button', { name: '音乐音效' })
.getAttribute('aria-current'),
).toBe('page');
expect(
within(outline).queryByRole('button', { name: /有新资源/ }),
).toBeNull();
});
const nextProjectManifest = createGameCreationAppManifest(
'workbench-resource-unread-next',
'下一个资源项目',
);
nextProjectManifest.assets = [
{
id: 'next-project-code',
kind: 'game-code',
mediaType: 'text/javascript',
localPath: 'game/game.js',
source: { kind: 'generated' },
},
];
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
projectName: nextProjectManifest.name,
projectPath: '/tmp/workbench-resource-unread-next',
manifest: nextProjectManifest,
}),
);
await waitFor(() => {
expect(screen.queryByLabelText('资源栏目大纲')).toBeNull();
expect(screen.getAllByText('暂无已登记资源')).toHaveLength(4);
expect(
screen
.getByLabelText(/资源(?:依赖|类型)视图/)
.classList.contains('game-resource-canvas--paged'),
).toBe(false);
expect(queryResourceSelectButton('game.js')).toBeNull();
expect(screen.queryByRole('region', { name: '游戏代码' })).toBeNull();
});
});
it('refreshes resource pages when a same-count manifest update changes category', async () => {
const manifest = createGameCreationAppManifest(
'workbench-imported-image-canvas',
'导入图片资源测试',
);
manifest.assets = [
{
id: 'imported-design-doc',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'docs/plan.md',
source: { kind: 'generated' },
},
];
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: layoutRevision,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const viewProps = {
projectName: manifest.name,
projectPath: '/tmp/workbench-imported-image-canvas',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
};
const rendered = render(
React.createElement(ProjectDevelopmentView, viewProps),
);
const outline = await screen.findByLabelText('资源栏目大纲');
expect(
within(outline).getByRole('button', { name: '设计文档' }),
).not.toBeNull();
const refreshedManifest = {
...manifest,
assets: [
{
id: 'imported-image',
kind: 'ui',
mediaType: 'image/png',
localPath: 'assets/uploads/local-imported-image.png',
source: {
kind: 'uploaded' as const,
generationRoute: 'agent.local-asset-import',
},
},
],
};
expect(refreshedManifest.assets).toHaveLength(manifest.assets.length);
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: refreshedManifest,
}),
);
fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ }));
expect(
await screen.findByRole('button', {
name: '选中资源:美术资源 local-imported-image.png',
}),
).not.toBeNull();
});
it('keeps the empty resource overview identical in both modes', async () => {
const manifest = createGameCreationAppManifest(
'workbench-empty-section-overview',
'空资源项目',
);
window.__TAURI__ = {
core: {
invoke: vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
),
},
};
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-empty-section-overview',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
const assertOverview = () => {
expect(screen.queryByLabelText('资源栏目大纲')).toBeNull();
expect(
screen
.getByLabelText(/资源(?:依赖|类型)视图/)
.classList.contains('game-resource-canvas--paged'),
).toBe(false);
expect(
screen
.getByLabelText(/资源(?:依赖|类型)视图/)
.classList.contains('game-resource-canvas--dependency'),
).toBe(false);
expect(screen.getAllByText('暂无已登记资源')).toHaveLength(4);
for (const label of ['设计文档', '美术资源', '音乐音效', '项目版本']) {
expect(screen.getByRole('region', { name: label })).not.toBeNull();
}
};
await screen.findByRole('region', { name: '设计文档' });
assertOverview();
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
assertOverview();
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
await screen.findByRole('region', { name: '设计文档' });
assertOverview();
});
it('renders one body-first card system in both layouts and separates detail from single-media playback', async () => {
const observer = installResourceCardIntersectionObserver();
const manifest = createGameCreationAppManifest(
'workbench-body-first-cards',
'本体化资源卡测试',
);
manifest.assets = [
{
id: 'hero-image',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
{
id: 'intro-video',
kind: 'video',
mediaType: 'video/mp4',
localPath: 'assets/intro.mp4',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
{
id: 'theme-audio',
kind: 'background-music',
mediaType: 'audio/mpeg',
localPath: 'assets/theme.mp3',
source: { kind: 'generated', taskId: 'audio-asset-plan' },
},
{
id: 'design-document',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'memory/design.md',
source: { kind: 'generated', taskId: 'design-foundation' },
},
];
manifest.versions = [
{
versionId: 'version-initial',
parentVersionId: null,
projectRevision: 1,
resourceBindings: [],
createdReason: 'initial',
createdAt: 1,
},
];
let layoutRevision = 0;
const imageDataUrl =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB';
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
if (command === 'read_local_project_image_preview') {
return {
path: String(args?.relativePath ?? ''),
mediaType: 'image/png',
byteLen: 12,
dataUrl: imageDataUrl,
};
}
if (command === 'read_local_project_text_preview') {
return {
path: String(args?.relativePath ?? ''),
mediaType: 'text/markdown',
byteLen: 24,
content: '# 玩法摘要\n\n这是安全的卡片正文摘要。',
};
}
if (command === 'read_local_project_media_preview') {
const relativePath = String(args?.relativePath ?? '');
return relativePath.endsWith('.mp3')
? {
path: relativePath,
mediaType: 'audio/mpeg',
byteLen: 32,
dataUrl: 'data:audio/mpeg;base64,SUQz',
}
: {
path: relativePath,
mediaType: 'video/mp4',
byteLen: 48,
dataUrl: 'data:video/mp4;base64,AAAAIGZ0eXA=',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const play = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockResolvedValue(undefined);
const pause = vi
.spyOn(HTMLMediaElement.prototype, 'pause')
.mockImplementation(() => undefined);
const viewProps = {
projectName: manifest.name,
projectPath: '/tmp/workbench-body-first-cards',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
};
const rendered = render(
React.createElement(ProjectDevelopmentView, viewProps),
);
const showResourcePage = (label: string) => openResourceBookCategory(label);
await showResourcePage('美术资源');
let heroDetailButton = await findResourceSelectButton('hero.png');
const heroCard = heroDetailButton.closest('.game-resource-card');
expect(heroCard?.textContent).not.toContain('hero.png');
expect(heroCard?.textContent).not.toContain('assets/hero.png');
expect(heroCard?.textContent).not.toContain('Agent 生成');
expect(
heroCard?.querySelector('[data-resource-type="图片"]')?.textContent,
).toBe('图片');
expect(
heroCard?.querySelector('.game-resource-card-open button'),
).toBeNull();
await waitFor(() =>
expect(observer.observedCount()).toBeGreaterThanOrEqual(2),
);
act(() => observer.triggerVisible());
await waitFor(() => {
expect(
heroCard
?.querySelector('.game-resource-card-visual > img')
?.getAttribute('src'),
).toBe('blob:mock-attachment-preview');
});
await showResourcePage('设计文档');
act(() => observer.triggerVisible());
const documentSummary = await screen.findByText(/这是安全的卡片正文摘要。/);
expect(
documentSummary
.closest('.game-resource-card')
?.querySelector('[data-resource-type="文档"]'),
).not.toBeNull();
await showResourcePage('美术资源');
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'read_local_project_media_preview' &&
args?.category === 'audio',
),
).toBe(false);
const videoControl = screen.getByRole('button', {
name: '播放 intro.mp4',
});
const videoCard = videoControl.closest('.game-resource-card');
expect(
videoCard?.querySelector('[data-resource-type="视频"]'),
).not.toBeNull();
const video = videoCard?.querySelector('video');
expect(video).not.toBeNull();
expect(video?.preload).toBe('auto');
fireEvent.loadedData(video!);
const layoutUpdatesBeforePlayback = invoke.mock.calls.filter(
([command]) => command === 'update_local_project_resource_canvas_layout',
).length;
fireEvent.pointerDown(videoControl, {
pointerId: 77,
button: 0,
clientX: 20,
clientY: 20,
});
fireEvent.pointerMove(videoControl, {
pointerId: 77,
clientX: 80,
clientY: 20,
});
fireEvent.pointerUp(videoControl, {
pointerId: 77,
clientX: 80,
clientY: 20,
});
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'update_local_project_resource_canvas_layout',
),
).toHaveLength(layoutUpdatesBeforePlayback);
fireEvent.click(videoControl);
expect(screen.queryByRole('dialog', { name: 'intro.mp4' })).toBeNull();
expect(screen.getByLabelText('资源依赖视图')).not.toBeNull();
const pauseVideoControl = screen.getByRole('button', {
name: '暂停 intro.mp4',
});
pauseVideoControl.focus();
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: { ...manifest },
}),
);
const stableVideoControl = screen.getByRole('button', {
name: '暂停 intro.mp4',
});
expect(stableVideoControl).toBe(pauseVideoControl);
expect(document.activeElement).toBe(stableVideoControl);
await showResourcePage('音乐音效');
const audioControl = screen.getByRole('button', { name: '播放 theme.mp3' });
expect(
audioControl
.closest('.game-resource-card')
?.querySelector('[data-resource-type="音频"]'),
).not.toBeNull();
fireEvent.click(audioControl);
await waitFor(() => {
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'read_local_project_media_preview' &&
args?.category === 'audio',
),
).toBe(true);
expect(play).toHaveBeenCalled();
});
expect(pause.mock.instances).toContain(video);
expect(screen.queryByRole('dialog', { name: 'theme.mp3' })).toBeNull();
await showResourcePage('美术资源');
heroDetailButton = await findResourceSelectButton('hero.png');
fireEvent.click(heroDetailButton);
const heroToolbar = await screen.findByRole('toolbar', {
name: '图片工具栏',
});
expect(
within(heroToolbar).getByRole('button', { name: '快速编辑' }),
).not.toBeNull();
expect(screen.queryByRole('dialog', { name: 'hero.png' })).toBeNull();
expect(
invoke.mock.calls.filter(
([command]) => command === 'read_local_project_image_preview',
),
).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
expect(observer.observedCount()).toBeGreaterThanOrEqual(2);
act(() => observer.triggerVisible());
expect(screen.getByLabelText('资源类型视图')).not.toBeNull();
const typeVideoControl = await screen.findByRole('button', {
name: '播放 intro.mp4',
});
const typeVideo = typeVideoControl
.closest('.game-resource-card')
?.querySelector('video');
expect(typeVideo).not.toBeNull();
fireEvent.loadedData(typeVideo!);
fireEvent.click(typeVideoControl);
await waitFor(() =>
expect(
screen.getByRole('button', { name: '暂停 intro.mp4' }),
).not.toBeNull(),
);
pause.mockClear();
const search = screen.getByLabelText('搜索项目资源');
fireEvent.change(search, { target: { value: 'assets/hero.png' } });
await waitFor(() => expect(pause.mock.instances).toContain(typeVideo));
expect(getResourceSelectButton('hero.png')).not.toBeNull();
expect(queryResourceSelectButton('intro.mp4')).toBeNull();
const typeHeroCard = getResourceSelectButton('hero.png').closest(
'.game-resource-card',
);
await waitFor(() =>
expect(
typeHeroCard?.querySelector('.game-resource-card-visual > img'),
).not.toBeNull(),
);
fireEvent.error(
typeHeroCard!.querySelector('.game-resource-card-visual > img')!,
);
expect(
typeHeroCard?.querySelector('.game-resource-card-visual > img'),
).toBeNull();
expect(
typeHeroCard?.querySelector('.game-resource-card-placeholder'),
).not.toBeNull();
});
it('bounds visible preview concurrency, deduplicates requests, evicts old entries, and drops late project results', async () => {
const observer = installResourceCardIntersectionObserver();
const manifest = createGameCreationAppManifest(
'workbench-preview-scheduler',
'预览调度测试',
);
manifest.assets = Array.from({ length: 49 }, (_, index) => ({
id: `image-${index}`,
kind: 'character',
mediaType: 'image/png',
localPath: `assets/image-${index}.png`,
source: { kind: 'generated' as const },
}));
const pending: Array<{
path: string;
resolve: (value: {
path: string;
mediaType: string;
byteLen: number;
pixelWidth?: number;
pixelHeight?: number;
dataUrl: string;
}) => void;
}> = [];
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
if (command === 'read_local_project_image_preview') {
const path = String(args?.relativePath ?? '');
return new Promise((resolvePreview) => {
pending.push({ path, resolve: resolvePreview });
});
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const props = {
projectName: manifest.name,
projectPath: '/tmp/workbench-preview-scheduler',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
};
const rendered = render(React.createElement(ProjectDevelopmentView, props));
await openResourceBookCategory('美术资源');
await waitFor(() => expect(observer.observedCount()).toBe(49));
act(() => {
observer.triggerVisible();
observer.triggerVisible();
});
await waitFor(() => expect(pending).toHaveLength(3));
expect(
invoke.mock.calls.filter(
([command]) => command === 'read_local_project_image_preview',
),
).toHaveLength(3);
for (let index = 0; index < 49; index += 1) {
await waitFor(() => expect(pending[index]).toBeDefined());
const request = pending[index]!;
act(() => {
request.resolve({
path: request.path,
mediaType: 'image/png',
byteLen: 1,
...(request.path === 'assets/image-0.png'
? { pixelWidth: 2_000, pixelHeight: 1_000 }
: {}),
dataUrl: `data:image/png;base64,${window.btoa(String(index))}`,
});
});
}
await waitFor(() => {
expect(
document.querySelectorAll('.game-resource-card-visual > img'),
).toHaveLength(48);
});
const wideImageCard = getResourceSelectButton(
'image-0.png',
).closest<HTMLElement>('.game-resource-card');
await waitFor(() => {
expect(
wideImageCard?.style.getPropertyValue('--resource-card-width'),
).toBe('220px');
expect(
wideImageCard?.style.getPropertyValue('--resource-card-height'),
).toBe('110px');
});
const lateManifest = createGameCreationAppManifest(
'workbench-preview-scheduler-next',
'新项目',
);
lateManifest.assets = [
{
id: 'next-image',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/next.png',
source: { kind: 'generated' },
},
];
manifest.projectId = lateManifest.projectId;
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...props,
projectPath: '/tmp/workbench-preview-scheduler-next',
projectName: lateManifest.name,
manifest: lateManifest,
}),
);
await openResourceBookCategory('美术资源');
await waitFor(() =>
expect(getResourceSelectButton('next.png')).not.toBeNull(),
);
await waitFor(() => expect(observer.observedCount()).toBe(1));
act(() => observer.triggerVisible());
await waitFor(() => expect(pending[49]).toBeDefined());
manifest.projectId = 'workbench-preview-scheduler-final';
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...props,
projectPath: '/tmp/workbench-preview-scheduler-final',
projectName: '最终空项目',
manifest: {
...createGameCreationAppManifest(
'workbench-preview-scheduler-final',
'最终空项目',
),
assets: [],
},
}),
);
const objectUrlCountBeforeLateResult = vi.mocked(URL.createObjectURL).mock
.calls.length;
act(() => {
pending[49]!.resolve({
path: 'assets/next.png',
mediaType: 'image/png',
byteLen: 1,
dataUrl: 'data:image/png;base64,LATE',
});
});
await act(async () => Promise.resolve());
expect(URL.createObjectURL).toHaveBeenCalledTimes(
objectUrlCountBeforeLateResult,
);
});
it('renders immutable manifest versions, their parent graph, and bound asset highlights', async () => {
const manifest = createGameCreationAppManifest(
'workbench-versions',
'版本工作台测试',
);
manifest.assets = [
{
id: 'asset-player',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/player.png',
source: { kind: 'generated' },
},
];
manifest.versions = [
{
versionId: 'version-root',
parentVersionId: null,
projectRevision: 3,
resourceBindings: [{ slotId: 'player', resourceId: 'asset-player' }],
createdReason: 'initial',
createdAt: 100,
},
{
versionId: 'version-child',
parentVersionId: 'version-root',
projectRevision: 4,
resourceBindings: [
{ slotId: 'player', resourceId: 'asset-player' },
{ slotId: 'historical', resourceId: 'asset-removed' },
],
createdReason: 'agent-revision',
createdAt: 200,
},
];
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-versions',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
fireEvent.click(
within(screen.getByLabelText('资源栏目大纲')).getByRole('button', {
name: /项目版本/,
}),
);
const rootVersionButton = screen.getByRole('button', {
name: /选中资源:项目版本 版本 1/,
});
const childVersionButton = screen.getByRole('button', {
name: /选中资源:项目版本 版本 2/,
});
const rootVersionCard = rootVersionButton.closest('.game-resource-card');
const childVersionCard = childVersionButton.closest('.game-resource-card');
expect(rootVersionCard?.textContent).toContain('1 个直接子版本');
expect(childVersionCard?.textContent).toContain('暂无直接子版本');
expect(childVersionCard?.textContent).not.toContain('version-root');
fireEvent.click(childVersionButton);
// 版本资源没有任何美术画布动作,选中反馈由卡片自身承担。
expect(childVersionButton.getAttribute('aria-pressed')).toBe('true');
expect(screen.queryByRole('toolbar')).toBeNull();
expect(screen.queryByText('asset:asset-removed')).toBeNull();
fireEvent.click(
within(screen.getByLabelText('资源栏目大纲')).getByRole('button', {
name: /美术资源/,
}),
);
const playerCard = getResourceSelectButton('player.png').closest(
'.game-resource-card',
);
expect(playerCard?.classList.contains('is-relation-version-binding')).toBe(
true,
);
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
await waitFor(() => {
const dependencyPlayerCard = getResourceSelectButton(
'player.png',
).closest('.game-resource-card');
expect(
dependencyPlayerCard?.classList.contains('is-relation-version-binding'),
).toBe(true);
});
});
it('资源卡 chrome 只保留选中按钮与当前版本边框', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
// 卡片本体不再自带描边;只有被当前版本绑定的素材才有边框。
expect(styles).toMatch(/\.game-resource-card\s*\{[^}]*border:\s*0;/s);
expect(styles).toMatch(
/\.game-resource-card\.is-current-version\s*\{[^}]*border:\s*1px solid/s,
);
expect(styles).toMatch(
/\.game-resource-card-select\s*\{[^}]*cursor:\s*pointer[^}]*touch-action:\s*manipulation/s,
);
expect(styles).not.toMatch(/\.game-resource-card-open\s*\{/);
});
it('loads registered documents, art media, video, and audio with safe failure states inside central focus', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-media',
'资源媒体测试',
);
manifest.assets.push(
{
id: 'design-document',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'game/design.md',
source: { kind: 'generated' },
},
{
id: 'art-svg',
kind: 'icon',
mediaType: 'image/svg+xml',
localPath: 'assets/icon.svg',
source: { kind: 'generated' },
},
{
id: 'art-video',
kind: 'animation',
mediaType: 'video/mp4',
localPath: 'assets/intro.mp4',
source: { kind: 'generated' },
},
{
id: 'audio-bgm',
kind: 'bgm',
mediaType: 'audio/mpeg',
localPath: 'assets/bgm.mp3',
source: { kind: 'generated' },
},
{
id: 'blocked-document',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'game/blocked.md',
source: { kind: 'generated' },
},
);
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
if (command === 'read_local_project_text_preview') {
if (args?.relativePath === 'game/blocked.md') {
throw new Error('项目权限策略要求用户确认:file.read');
}
expect(args).toMatchObject({
projectPath: '/tmp/workbench-resource-media',
relativePath: 'game/design.md',
});
return {
path: 'game/design.md',
mediaType: 'text/markdown',
byteLen: 64,
content:
'# 本地设计文档\n\n[外部链接](https://example.com)\n\n![远程图片](https://example.com/image.png)\n\n<script>window.pwned = true</script>',
};
}
if (command === 'read_local_project_media_preview') {
if (args?.category === 'art') {
if (args?.relativePath === 'assets/intro.mp4') {
return {
path: 'assets/intro.mp4',
mediaType: 'video/mp4',
byteLen: 128,
dataUrl: 'data:video/mp4;base64,AAAAIGZ0eXA=',
};
}
return {
path: 'assets/icon.svg',
mediaType: 'image/svg+xml',
byteLen: 48,
dataUrl: 'data:image/svg+xml;base64,PHN2Zy8+',
};
}
return {
path: 'assets/bgm.mp3',
mediaType: 'audio/mpeg',
byteLen: 1024,
dataUrl: 'data:audio/mpeg;base64,SUQz',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
render(
React.createElement(ProjectDevelopmentView, {
projectName: '资源媒体测试',
projectPath: '/tmp/workbench-resource-media',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('设计文档');
fireEvent.click(await findResourceSelectButton('design.md'));
// 选中资源浮出的是画布工具条;"资源详情"面板已随 C3 交互合同删除。
expect(screen.queryByRole('dialog', { name: 'design.md' })).toBeNull();
await openResourceBookCategory('美术资源');
fireEvent.click(getResourceSelectButton('icon.svg'));
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
expect(screen.queryByRole('dialog', { name: 'design.md' })).toBeNull();
fireEvent.click(
screen.getByRole('button', {
name: '选中资源:美术资源 intro.mp4',
}),
);
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
await openResourceBookCategory('音乐音效');
fireEvent.click(
screen.getByRole('button', {
name: '选中资源:音乐音效 bgm.mp3',
}),
);
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'read_local_project_media_preview' &&
args?.category === 'audio',
),
).toBe(false);
// 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」),
// 并且只渲染宿主编排层真实接通的动作:「分类与标签」「重命名」已接面板,
// 「改造」在宿主编排层仍是空回调,不能再渲染成点了没反应的按钮。
const audioToolbar = screen.getByRole('toolbar', {
name: '素材工具栏',
});
expect(
within(audioToolbar).queryByRole('button', { name: '改造' }),
).toBeNull();
expect(
within(audioToolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['分类与标签', '重命名']);
await openResourceBookCategory('设计文档');
fireEvent.click(getResourceSelectButton('blocked.md'));
expect(
getResourceSelectButton('blocked.md').getAttribute('aria-pressed'),
).toBe('true');
expect(screen.getByLabelText('陶泥儿 Agent 对话')).not.toBeNull();
expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull();
});
it('preserves internal media focus across same-resource manifest updates and falls back when the resource is deleted', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-focus-updates',
'资源焦点更新测试',
);
manifest.assets = [
{
id: 'focus-audio',
kind: 'background-music',
mediaType: 'audio/mpeg',
localPath: 'assets/focus.mp3',
source: { kind: 'generated' },
},
];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
if (command === 'read_local_project_media_preview') {
return {
path: 'assets/focus.mp3',
mediaType: 'audio/mpeg',
byteLen: 1024,
dataUrl: 'data:audio/mpeg;base64,SUQz',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
const viewProps = {
projectName: manifest.name,
projectPath: '/tmp/workbench-resource-focus-updates',
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
};
const rendered = render(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest,
}),
);
await openResourceBookCategory('音乐音效');
fireEvent.click(await findResourceSelectButton('focus.mp3'));
expect(screen.getByRole('toolbar', { name: '素材工具栏' })).not.toBeNull();
const updatedManifest = {
...manifest,
tasks: manifest.tasks.map((task) =>
task.id === 'audio-director'
? { ...task, status: 'completed' as const }
: task,
),
assets: manifest.assets.map((asset) => ({
...asset,
source: { ...asset.source, taskId: 'audio-director' },
})),
};
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: updatedManifest,
}),
);
expect(screen.getByRole('toolbar', { name: '素材工具栏' })).not.toBeNull();
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: { ...updatedManifest, assets: [] },
}),
);
await waitFor(() => {
expect(screen.queryByRole('toolbar', { name: '素材工具栏' })).toBeNull();
expect(screen.queryByRole('toolbar', { name: '图片工具栏' })).toBeNull();
});
expect(
screen
.queryAllByTitle('选中资源')
.some((card) => card.getAttribute('aria-pressed') === 'true'),
).toBe(false);
});
it('renders, filters, and destroys section-page resource dependency lines', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-graph',
'资源依赖图测试',
);
manifest.assets.push(
{
id: 'dependency-spec',
kind: 'metadata',
mediaType: 'application/json',
localPath: 'assets/spec-source.json',
source: {
kind: 'canvas',
taskId: 'task-1',
resourceId: 'canvas-spec-source',
},
},
{
id: 'dependency-ui',
kind: 'metadata',
mediaType: 'application/json',
localPath: 'assets/ui-dependency.json',
source: {
kind: 'canvas',
taskId: 'task-2',
resourceId: 'canvas-ui-target',
referenceResourceIds: ['canvas-spec-source'],
},
},
{
id: 'unrelated-cycle',
kind: 'metadata',
mediaType: 'application/json',
localPath: 'assets/unrelated-cycle.json',
source: {
kind: 'canvas',
resourceId: 'canvas-unrelated',
referenceResourceIds: ['canvas-unrelated'],
},
},
);
const referenceId =
'asset-reference:["asset:dependency-spec","asset:dependency-ui"]';
const selfReferenceId =
'asset-reference:["asset:unrelated-cycle","asset:unrelated-cycle"]';
const crossReferenceId =
'asset-reference:["asset:dependency-spec","asset:unrelated-cycle"]';
const flowId = 'task-flow:["art-director","design-foundation"]';
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
expect(args?.resources).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'asset:dependency-spec',
manifestAssetId: 'dependency-spec',
producerTaskId: null,
}),
]),
);
return {
resourceIds: [
'asset:dependency-spec',
'asset:dependency-ui',
'asset:unrelated-cycle',
],
referenceEdges: [
{
id: referenceId,
kind: 'asset-reference',
sourceResourceId: 'asset:dependency-spec',
targetResourceId: 'asset:dependency-ui',
cyclic: false,
},
{
id: selfReferenceId,
kind: 'asset-reference',
sourceResourceId: 'asset:unrelated-cycle',
targetResourceId: 'asset:unrelated-cycle',
cyclic: true,
},
{
id: crossReferenceId,
kind: 'asset-reference',
sourceResourceId: 'asset:dependency-spec',
targetResourceId: 'asset:unrelated-cycle',
cyclic: false,
},
],
taskFlows: [
{
id: flowId,
kind: 'task-flow',
sourceTaskId: 'art-director',
targetTaskId: 'design-foundation',
sourceResourceIds: ['asset:dependency-spec'],
targetResourceIds: ['asset:dependency-ui'],
cyclic: false,
},
],
connectionIndex: [
{
resourceId: 'asset:dependency-spec',
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: [
'asset:dependency-ui',
'asset:unrelated-cycle',
],
referenceEdgeIds: [referenceId, crossReferenceId],
taskFlowIds: [flowId],
},
{
resourceId: 'asset:dependency-ui',
upstreamReferenceResourceIds: ['asset:dependency-spec'],
downstreamReferenceResourceIds: [],
referenceEdgeIds: [referenceId],
taskFlowIds: [flowId],
},
{
resourceId: 'asset:unrelated-cycle',
upstreamReferenceResourceIds: [
'asset:dependency-spec',
'asset:unrelated-cycle',
],
downstreamReferenceResourceIds: ['asset:unrelated-cycle'],
referenceEdgeIds: [selfReferenceId, crossReferenceId],
taskFlowIds: [],
},
],
producerAssignments: [
{
resourceId: 'asset:dependency-spec',
taskId: 'art-director',
},
{
resourceId: 'asset:dependency-ui',
taskId: 'design-foundation',
},
],
dependencyDepths: [
{
resourceId: 'asset:dependency-spec',
dependencyDepth: 0,
},
{
resourceId: 'asset:dependency-ui',
dependencyDepth: 1,
},
{
resourceId: 'asset:unrelated-cycle',
dependencyDepth: 0,
},
],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: ['asset:unrelated-cycle'],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'workbench-resource-graph',
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'workbench-resource-graph',
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const view = render(
React.createElement(ProjectDevelopmentView, {
projectName: '资源依赖图测试',
projectPath: '/tmp/workbench-resource-graph',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('设计文档');
let overlay = await screen.findByTestId(
'resource-dependency-overlay-document',
);
await waitFor(() => {
expect(
overlay.querySelectorAll('[data-edge-kind="asset-reference"]'),
).toHaveLength(3);
expect(
overlay.querySelectorAll('[data-edge-kind="task-flow"]'),
).toHaveLength(0);
});
const dependencyCanvas = screen.getByLabelText('资源依赖视图');
const descriptionId = dependencyCanvas.getAttribute('aria-describedby');
expect(descriptionId).not.toBeNull();
const relationshipDescription = document.getElementById(descriptionId!);
expect(relationshipDescription?.textContent).toContain(
'ui-dependency.json 引用 spec-source.json',
);
expect(
overlay.querySelector(`[data-edge-id='${crossReferenceId}']`),
).not.toBeNull();
expect(relationshipDescription?.textContent).toContain(
'unrelated-cycle.json 引用 spec-source.json',
);
expect(relationshipDescription?.textContent).not.toContain('art-director');
expect(
Array.from(overlay.querySelectorAll('svg')).every(
(sectionOverlay) =>
sectionOverlay.getAttribute('aria-hidden') === 'true',
),
).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
expect(
screen.queryByTestId('resource-dependency-overlay-document'),
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
overlay = await screen.findByTestId('resource-dependency-overlay-document');
const dependencyWorld = dependencyCanvas.querySelector<HTMLElement>(
'.game-resource-canvas-content',
);
const boundaryBeforeSearch = dependencyWorld?.dataset.resourceBoundary;
const search = screen.getByLabelText('搜索项目资源');
fireEvent.change(search, { target: { value: 'ui-dependency' } });
await waitFor(() => {
expect(overlay.querySelector('[data-edge-kind]')).toBeNull();
});
expect(dependencyWorld?.dataset.resourceBoundary).toBe(
boundaryBeforeSearch,
);
fireEvent.change(search, { target: { value: '' } });
let sourceCard = getResourceSelectButton('spec-source.json');
const targetCard = getResourceSelectButton('ui-dependency.json');
const referenceSelector =
'[data-edge-kind="asset-reference"]' +
'[data-source-resource-id="asset:dependency-spec"]' +
'[data-target-resource-id="asset:dependency-ui"]';
const firstPath = await waitFor(() => {
const path = overlay.querySelector(referenceSelector);
expect(path).not.toBeNull();
return path?.getAttribute('d');
});
const sourceCardContainer = sourceCard.closest<HTMLElement>(
'.game-resource-card',
);
expect(sourceCardContainer).not.toBeNull();
Object.defineProperties(sourceCardContainer, {
setPointerCapture: { configurable: true, value: vi.fn() },
hasPointerCapture: { configurable: true, value: vi.fn(() => true) },
releasePointerCapture: { configurable: true, value: vi.fn() },
});
const sourceStyle = sourceCardContainer?.getAttribute('style');
const layoutUpdatesBeforePointer = invoke.mock.calls.filter(
([command]) => command === 'update_local_project_resource_canvas_layout',
).length;
fireEvent.pointerDown(sourceCard, {
pointerId: 27,
button: 0,
clientX: 0,
clientY: 0,
});
fireEvent.pointerMove(sourceCard, {
pointerId: 27,
clientX: 72,
clientY: 28,
});
fireEvent.pointerUp(sourceCard, {
pointerId: 27,
clientX: 72,
clientY: 28,
});
await waitFor(() => {
expect(sourceCardContainer?.getAttribute('style')).not.toBe(sourceStyle);
expect(
overlay.querySelector(referenceSelector)?.getAttribute('d'),
).not.toBe(firstPath);
});
expect(sourceCardContainer?.classList.contains('is-dragging')).toBe(false);
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'update_local_project_resource_canvas_layout',
).length,
).toBeGreaterThan(layoutUpdatesBeforePointer);
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'update_local_project_resource_canvas_layout' &&
Array.isArray(args?.positions) &&
args.positions.some(
(position) =>
position.resourceId === 'asset:dependency-spec' &&
position.manuallyPlaced,
),
),
).toBe(true);
fireEvent.click(sourceCardContainer!);
fireEvent.click(targetCard.closest('.game-resource-card')!);
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
overlay = await screen.findByTestId('resource-dependency-overlay-document');
sourceCard = getResourceSelectButton('spec-source.json');
await waitFor(() =>
expect(overlay.querySelector(referenceSelector)).not.toBeNull(),
);
expect(sourceCard.classList.contains('is-relation-upstream')).toBe(false);
expect(
overlay
.querySelector(referenceSelector)
?.classList.contains('is-highlighted'),
).toBe(false);
expect(
overlay
.querySelector('[data-source-resource-id="asset:unrelated-cycle"]')
?.classList.contains('is-dimmed'),
).toBe(false);
const previousReferencePaths = Array.from(
overlay.querySelectorAll('[data-edge-kind]'),
);
const nextManifest = createGameCreationAppManifest(
'workbench-resource-graph-next',
'新资源依赖图测试',
);
view.rerender(
React.createElement(ProjectDevelopmentView, {
projectName: '新资源依赖图测试',
projectPath: '/tmp/workbench-resource-graph-next',
manifest: nextManifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
expect(previousReferencePaths).not.toHaveLength(0);
await waitFor(() => {
expect(
previousReferencePaths.every(
(previousReferencePath) => !previousReferencePath.isConnected,
),
).toBe(true);
expect(
screen.queryByTestId('resource-dependency-overlay-document'),
).toBeNull();
});
});
it('hides dependency edges whose code endpoint is not visible while preserving visible edges', async () => {
const visibleEdgeId = 'asset-reference:["art-a","art-b"]';
const hiddenEdgeId = 'asset-reference:["code","art-a"]';
const graph = normalizeProjectResourceGraph({
resourceIds: ['code', 'art-a', 'art-b'],
referenceEdges: [
{
id: visibleEdgeId,
kind: 'asset-reference',
sourceResourceId: 'art-a',
targetResourceId: 'art-b',
cyclic: false,
},
{
id: hiddenEdgeId,
kind: 'asset-reference',
sourceResourceId: 'code',
targetResourceId: 'art-a',
cyclic: false,
},
],
taskFlows: [],
connectionIndex: [],
producerAssignments: [],
dependencyDepths: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
});
const positions: ProjectResourceCanvasPosition[] = [
{
resourceId: 'code',
section: 'code',
x: 0,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'art-a',
section: 'art',
x: 0,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'art-b',
section: 'art',
x: 0,
y: 144,
manuallyPlaced: false,
},
];
render(
React.createElement(ResourceDependencyOverlay, {
graph,
positions,
section: 'art',
visibleResourceIds: new Set(['art-a', 'art-b']),
}),
);
const overlay = await screen.findByTestId(
'resource-dependency-overlay-art',
);
await waitFor(() => {
const edgeById = (edgeId: string) =>
Array.from(
overlay.querySelectorAll<SVGPathElement>('[data-edge-id]'),
).find((edge) => edge.getAttribute('data-edge-id') === edgeId);
expect(edgeById(visibleEdgeId)).not.toBeNull();
expect(edgeById(hiddenEdgeId)).toBeUndefined();
});
});
it('coalesces section scroll geometry, keeps partial endpoints stable, and cleans one dependency observer', async () => {
const referenceId = 'asset-reference:["resource-a","resource-b"]';
const graph = normalizeProjectResourceGraph({
resourceIds: ['resource-a', 'resource-b'],
referenceEdges: [
{
id: referenceId,
kind: 'asset-reference',
sourceResourceId: 'resource-a',
targetResourceId: 'resource-b',
cyclic: false,
},
],
taskFlows: [],
connectionIndex: [
{
resourceId: 'resource-a',
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: ['resource-b'],
referenceEdgeIds: [referenceId],
taskFlowIds: [],
},
{
resourceId: 'resource-b',
upstreamReferenceResourceIds: ['resource-a'],
downstreamReferenceResourceIds: [],
referenceEdgeIds: [referenceId],
taskFlowIds: [],
},
],
producerAssignments: [],
dependencyDepths: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
});
const positions: ProjectResourceCanvasPosition[] = [
{
resourceId: 'resource-a',
section: 'art',
x: 0,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'resource-b',
section: 'art',
x: 0,
y: 144,
manuallyPlaced: false,
},
];
let observerCount = 0;
let observerDisconnected = false;
class TestResizeObserver {
constructor(readonly callback: ResizeObserverCallback) {
observerCount += 1;
}
observe() {}
unobserve() {}
disconnect() {
observerDisconnected = true;
}
}
Object.defineProperty(window, 'ResizeObserver', {
configurable: true,
value: TestResizeObserver,
});
const animationFrame = vi.spyOn(window, 'requestAnimationFrame');
const originalGetBoundingClientRect =
HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
function getSectionRect(this: HTMLElement) {
const rect = (
left: number,
top: number,
width: number,
height: number,
) =>
({
x: left,
y: top,
left,
top,
right: left + width,
bottom: top + height,
width,
height,
toJSON: () => ({}),
}) as DOMRect;
if (this.classList.contains('test-resource-content')) {
return rect(0, 0, 600, 800);
}
if (this.dataset.resourceSectionScroll === 'art') {
return rect(0, 100, 600, 300);
}
if (this.dataset.resourceSectionPlane === 'art') {
const viewport = this.closest<HTMLElement>(
'[data-resource-section-scroll="art"]',
);
return rect(
-(viewport?.scrollLeft ?? 0),
100 - (viewport?.scrollTop ?? 0),
600,
400,
);
}
return originalGetBoundingClientRect.call(this);
},
);
const rendered = render(
React.createElement(
'div',
{ className: 'test-resource-outer' },
React.createElement(
'div',
{
className: 'test-resource-content',
'data-testid': 'resource-dependency-overlay',
},
React.createElement(
'section',
null,
React.createElement(
'div',
{
'data-resource-section-scroll': 'art',
},
React.createElement(
'div',
{
'data-resource-section-plane': 'art',
'data-resource-section-scale': '1',
},
React.createElement(ResourceDependencyOverlay, {
graph,
positions,
section: 'art',
visibleResourceIds: new Set(['resource-a', 'resource-b']),
geometryRevision: '340@1',
}),
),
),
),
),
),
);
const overlay = await screen.findByTestId('resource-dependency-overlay');
const referenceSelector = `[data-edge-id='${referenceId}']`;
await waitFor(() => {
const referencePath = overlay.querySelector(referenceSelector);
expect(referencePath?.getAttribute('d')).toBeTruthy();
expect(referencePath?.getAttribute('data-route-axis')).toBe('vertical');
expect(referencePath?.getAttribute('marker-end')).toMatch(
/^url\(#.+-asset-reference-arrow\)$/u,
);
});
expect(observerCount).toBe(1);
expect(
overlay
.querySelector('[data-testid="resource-dependency-overlay-art"]')
?.parentElement?.getAttribute('data-resource-section-plane'),
).toBe('art');
expect(overlay.querySelector('clipPath')).toBeNull();
const viewport = rendered.container.querySelector<HTMLElement>(
'[data-resource-section-scroll="art"]',
);
if (!viewport) {
throw new Error('missing art section viewport');
}
const removeViewportListener = vi.spyOn(viewport, 'removeEventListener');
const referenceBeforeScroll = overlay.querySelector(referenceSelector);
expect(referenceBeforeScroll).not.toBeNull();
const frameCountBeforeScroll = animationFrame.mock.calls.length;
viewport.scrollTop = 80;
fireEvent.scroll(viewport);
fireEvent.scroll(viewport);
fireEvent.scroll(viewport);
// A geometry frame can already be pending from the initial height pass.
// In that case every scroll reuses it; otherwise the first scroll schedules
// exactly one frame. Both cases satisfy the one-frame coalescing contract.
expect(
animationFrame.mock.calls.length - frameCountBeforeScroll,
).toBeLessThanOrEqual(1);
await waitFor(() => {
expect(overlay.querySelector(referenceSelector)).toBe(
referenceBeforeScroll,
);
expect(
overlay
.querySelector('[data-testid="resource-dependency-overlay-art"]')
?.getAttribute('data-logical-viewport'),
).toBe('0,80,600,300');
});
viewport.scrollTop = 0;
fireEvent.scroll(viewport);
await waitFor(() =>
expect(overlay.querySelector(referenceSelector)).toBe(
referenceBeforeScroll,
),
);
rendered.unmount();
expect(observerDisconnected).toBe(true);
expect(removeViewportListener).toHaveBeenCalledWith(
'scroll',
expect.any(Function),
);
});
it('waits for the scoped resource graph before initializing dependency layout', async () => {
const projectId = 'workbench-delayed-resource-graph';
const projectPath = '/tmp/workbench-delayed-resource-graph';
const manifest = createGameCreationAppManifest(projectId, '延迟依赖图测试');
const agentResults = [
{
agentId: 'design-foundation',
runId: 'delayed-graph-run',
label: '玩法策划 Agent',
title: '延迟依赖图回执',
content: '图就绪后再初始化布局',
updatedAt: 1,
},
];
let resolveGraph: (() => void) | null = null;
let dependencyLayoutReads = 0;
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return await new Promise((resolve) => {
resolveGraph = () => resolve(resourceGraphForInputs(args));
});
}
if (command === 'read_local_project_resource_canvas_layout') {
if (args?.mode === 'dependency') {
dependencyLayoutReads += 1;
}
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '延迟依赖图测试',
projectPath,
manifest,
attachments: [],
agentResults,
supervisor: React.createElement('div', null, '项目总控'),
}),
);
await openResourceBookCategory('设计文档');
await waitFor(() => expect(resolveGraph).not.toBeNull());
expect(dependencyLayoutReads).toBe(0);
expect(
screen.queryByRole('button', {
name: '选中资源:设计文档 延迟依赖图回执',
}),
).toBeNull();
await act(async () => {
resolveGraph?.();
await Promise.resolve();
});
expect(
await screen.findByRole('button', {
name: '选中资源:设计文档 延迟依赖图回执',
}),
).not.toBeNull();
expect(dependencyLayoutReads).toBe(1);
});
it('keeps resource cards mounted across equivalent rerenders and graph refreshes', async () => {
const projectId = 'workbench-stable-resource-graph';
const projectPath = '/tmp/workbench-stable-resource-graph';
const manifest = createGameCreationAppManifest(projectId, '稳定依赖图测试');
const firstResult = {
agentId: 'design-foundation',
runId: 'stable-graph-run',
label: '玩法策划 Agent',
title: '稳定依赖图回执',
content: '轮询刷新时保留现有资源卡',
updatedAt: 1,
};
let graphReads = 0;
let layoutRevision = 0;
let resolveGraphRefresh: (() => void) | null = null;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
graphReads += 1;
if (graphReads === 1) {
return resourceGraphForInputs(args);
}
return await new Promise((resolve) => {
resolveGraphRefresh = () => resolve(resourceGraphForInputs(args));
});
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: 'dependency',
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: 'dependency',
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const renderView = (agentResults: ProjectAgentResultSummary[]) =>
React.createElement(ProjectDevelopmentView, {
projectName: '稳定依赖图测试',
projectPath,
manifest,
attachments: [],
agentResults,
supervisor: React.createElement('div', null, '项目总控'),
});
const view = render(renderView([firstResult]));
await openResourceBookCategory('设计文档');
const firstCardButton = await screen.findByRole('button', {
name: '选中资源:设计文档 稳定依赖图回执',
});
const firstCard = firstCardButton.closest('.game-resource-card');
expect(firstCard).not.toBeNull();
expect(graphReads).toBe(1);
await act(async () => {
view.rerender(renderView([{ ...firstResult }]));
await Promise.resolve();
});
expect(graphReads).toBe(1);
expect(firstCard?.isConnected).toBe(true);
const secondResult = {
agentId: 'code-director',
runId: 'stable-graph-code-run',
label: '程序 Agent',
title: '刷新期间新增回执',
content: '新图返回前也不清空旧布局',
updatedAt: 2,
};
view.rerender(renderView([{ ...firstResult }, secondResult]));
await waitFor(() => expect(graphReads).toBe(2));
expect(resolveGraphRefresh).not.toBeNull();
expect(firstCard?.isConnected).toBe(true);
expect(
screen.getByRole('button', {
name: '选中资源:设计文档 稳定依赖图回执',
}),
).not.toBeNull();
await act(async () => {
resolveGraphRefresh?.();
await Promise.resolve();
});
expect(
await screen.findByRole('button', {
name: '选中资源:设计文档 刷新期间新增回执',
}),
).not.toBeNull();
expect(firstCard?.isConnected).toBe(true);
});
it('keeps trusted truncated-graph depths through the workbench without persisting a flat automatic layout', async () => {
const projectId = 'workbench-truncated-resource-graph';
const projectPath = '/tmp/workbench-truncated-resource-graph';
const manifest = createGameCreationAppManifest(
projectId,
'截断依赖图布局保护测试',
);
manifest.assets.push(
{
id: 'truncated-depth-0',
kind: 'design-spec',
mediaType: 'application/json',
localPath: 'assets/truncated-depth-0.json',
source: {
kind: 'canvas',
resourceId: 'external-truncated-depth-0',
},
},
{
id: 'truncated-depth-1',
kind: 'metadata',
mediaType: 'application/json',
localPath: 'assets/truncated-depth-1.json',
source: {
kind: 'canvas',
resourceId: 'external-truncated-depth-1',
referenceResourceIds: ['external-truncated-depth-0'],
},
},
{
id: 'truncated-depth-2',
kind: 'metadata',
mediaType: 'application/json',
localPath: 'assets/truncated-depth-2.json',
source: {
kind: 'canvas',
resourceId: 'external-truncated-depth-2',
referenceResourceIds: ['external-truncated-depth-1'],
},
},
);
const slotWidth =
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP;
const existingPositions: ProjectResourceCanvasPosition[] = [
{
resourceId: 'asset:truncated-depth-0',
section: 'document',
x: 0,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'asset:truncated-depth-1',
section: 'document',
x: slotWidth,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'asset:truncated-depth-2',
section: 'document',
x: slotWidth * 2,
y: 0,
manuallyPlaced: false,
},
];
const layoutUpdates: Array<{
mode: unknown;
positions: ProjectResourceCanvasPosition[];
}> = [];
let layoutReads = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return {
resourceIds: existingPositions.map(({ resourceId }) => resourceId),
referenceEdges: [
{
id: 'reference:truncated-0-1',
kind: 'asset-reference',
sourceResourceId: 'asset:truncated-depth-0',
targetResourceId: 'asset:truncated-depth-1',
cyclic: false,
},
{
id: 'reference:truncated-1-2',
kind: 'asset-reference',
sourceResourceId: 'asset:truncated-depth-1',
targetResourceId: 'asset:truncated-depth-2',
cyclic: false,
},
],
taskFlows: [
{
id: 'flow:untrusted-producer',
kind: 'task-flow',
sourceTaskId: 'art-director',
targetTaskId: 'design-foundation',
sourceResourceIds: ['asset:truncated-depth-0'],
targetResourceIds: ['asset:truncated-depth-1'],
cyclic: true,
},
],
connectionIndex: [
{
resourceId: 'asset:truncated-depth-0',
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: ['asset:truncated-depth-1'],
referenceEdgeIds: ['reference:truncated-0-1'],
taskFlowIds: ['flow:untrusted-producer'],
},
{
resourceId: 'asset:truncated-depth-1',
upstreamReferenceResourceIds: ['asset:truncated-depth-0'],
downstreamReferenceResourceIds: ['asset:truncated-depth-2'],
referenceEdgeIds: [
'reference:truncated-0-1',
'reference:truncated-1-2',
],
taskFlowIds: ['flow:untrusted-producer'],
},
{
resourceId: 'asset:truncated-depth-2',
upstreamReferenceResourceIds: ['asset:truncated-depth-1'],
downstreamReferenceResourceIds: [],
referenceEdgeIds: ['reference:truncated-1-2'],
taskFlowIds: [],
},
],
producerAssignments: [
{
resourceId: 'asset:truncated-depth-0',
taskId: 'art-director',
},
],
dependencyDepths: [
{
resourceId: 'asset:truncated-depth-0',
dependencyDepth: 0,
},
{
resourceId: 'asset:truncated-depth-1',
dependencyDepth: 1,
},
{
resourceId: 'asset:truncated-depth-2',
dependencyDepth: 2,
},
],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: ['art-director'],
producerMappingTruncated: true,
};
}
if (command === 'read_local_project_resource_canvas_layout') {
layoutReads += 1;
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: args?.mode,
revision: 7,
positions: structuredClone(existingPositions),
updatedAt: 7,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutUpdates.push({
mode: args?.mode,
positions: structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
),
});
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: args?.mode,
revision: 8,
positions: structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
),
updatedAt: 8,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '截断依赖图布局保护测试',
projectPath,
manifest,
attachments: [],
agentResults: [],
supervisor: React.createElement('div', null, '项目总控'),
}),
);
await waitFor(() => expect(layoutReads).toBe(1));
await openResourceBookCategory('设计文档');
const cards = [0, 1, 2].map((depth) =>
getResourceSelectButton(`truncated-depth-${depth}.json`).closest(
'.game-resource-card',
),
);
await waitFor(() => {
expect(cards[0]?.getAttribute('style')).toContain('--resource-x: 0px');
expect(cards[1]?.getAttribute('style')).toContain(
`--resource-x: ${slotWidth}px`,
);
expect(cards[2]?.getAttribute('style')).toContain(
`--resource-x: ${slotWidth * 2}px`,
);
});
await act(async () => {
await Promise.resolve();
});
expect(
layoutUpdates.filter((update) => update.mode === 'dependency'),
).toEqual([]);
});
it('moves historical resource positions from pointer input and persists one manual CAS', async () => {
const projectId = 'workbench-layout-persistence';
const projectPath = '/tmp/workbench-layout-persistence';
const resourceId = 'agent-result:design-foundation:layout-result-run';
const manifest = createGameCreationAppManifest(projectId, '布局持久化测试');
const agentResults = [
{
agentId: 'design-foundation',
runId: 'layout-result-run',
label: '玩法策划 Agent',
title: '布局持久化回执',
content: '布局持久化正文',
updatedAt: 1,
},
];
const persistedLayout: ProjectResourceCanvasLayout = {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: 'dependency',
revision: 1,
positions: [
{
resourceId,
section: 'document',
x: 12,
y: 24,
manuallyPlaced: true,
},
],
updatedAt: 100,
};
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return structuredClone(persistedLayout);
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 2,
positions: args?.positions,
updatedAt: 101,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
function renderWorkbench() {
render(
React.createElement(ProjectDevelopmentView, {
projectName: '布局持久化测试',
projectPath,
manifest,
attachments: [],
agentResults,
supervisor: React.createElement('div', null, '项目总控'),
}),
);
}
renderWorkbench();
await openResourceBookCategory('设计文档');
const cardButton = await screen.findByRole('button', {
name: '选中资源:设计文档 布局持久化回执',
});
const card = cardButton.closest('.game-resource-card');
expect(card).not.toBeNull();
await waitFor(() => {
expect(card?.getAttribute('style')).toContain('--resource-x: 12px');
expect(card?.getAttribute('style')).toContain('--resource-y: 24px');
});
expect(card).not.toBeNull();
const viewportElement = document.querySelector<HTMLElement>(
'.game-resource-page-canvas[data-resource-section-scroll="document"] [data-resource-viewport]',
);
const viewportScale = Number(
viewportElement?.getAttribute('data-resource-viewport')?.split(',').at(2),
);
expect(viewportScale).toBeGreaterThan(0);
const expectedX = Math.round(12 + (-100 - 20) / viewportScale);
const expectedY = Math.round(24 + (60 - 30) / viewportScale);
const setCardPointerCapture = vi.fn();
Object.defineProperties(card, {
setPointerCapture: { configurable: true, value: setCardPointerCapture },
hasPointerCapture: { configurable: true, value: vi.fn(() => true) },
releasePointerCapture: { configurable: true, value: vi.fn() },
});
fireEvent.pointerDown(cardButton, {
pointerId: 11,
button: 0,
clientX: 20,
clientY: 30,
});
expect(setCardPointerCapture).toHaveBeenCalledWith(11);
fireEvent.pointerMove(cardButton, {
pointerId: 11,
clientX: -100,
clientY: 60,
});
fireEvent.pointerUp(cardButton, {
pointerId: 11,
clientX: -100,
clientY: 60,
});
fireEvent.click(card!);
expect(screen.queryByRole('toolbar', { name: '图片工具栏' })).toBeNull();
await waitFor(() => {
expect(card?.getAttribute('style')).toContain(
`--resource-x: ${expectedX}px`,
);
expect(card?.getAttribute('style')).toContain(
`--resource-y: ${expectedY}px`,
);
});
expect(invoke).toHaveBeenCalledWith(
'update_local_project_resource_canvas_layout',
expect.objectContaining({
positions: expect.arrayContaining([
expect.objectContaining({
resourceId,
section: 'document',
x: expectedX,
y: expectedY,
manuallyPlaced: true,
}),
]),
}),
);
fireEvent.click(card!);
// 该卡片是 Agent 回执(没有正式素材身份),选中反馈由卡片自身承担。
expect(card?.querySelector('[aria-pressed="true"]')).not.toBeNull();
cleanup();
renderWorkbench();
await openResourceBookCategory('设计文档');
const restoredCard = (
await screen.findByRole('button', {
name: '选中资源:设计文档 布局持久化回执',
})
).closest('.game-resource-card');
await waitFor(() => {
expect(restoredCard?.getAttribute('style')).toContain(
'--resource-x: 12px',
);
expect(restoredCard?.getAttribute('style')).toContain(
'--resource-y: 24px',
);
});
});
it('opens resource details after a plain pointer press without treating it as a drag', async () => {
const projectId = 'workbench-card-click-no-drag';
const projectPath = '/tmp/workbench-card-click-no-drag';
const manifest = createGameCreationAppManifest(
projectId,
'卡片点击不误判拖动测试',
);
manifest.assets.push({
id: 'clickable-art',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/clickable.png',
source: { kind: 'generated' },
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '卡片点击不误判拖动测试',
projectPath,
manifest,
attachments: [],
supervisor: React.createElement('div', null, '项目总控'),
}),
);
await openResourceBookCategory('美术资源');
const cardButton = await findResourceSelectButton('clickable.png');
fireEvent.pointerDown(cardButton, {
pointerId: 7,
button: 0,
clientX: 40,
clientY: 50,
});
fireEvent.pointerMove(cardButton, {
pointerId: 7,
clientX: 42,
clientY: 51,
});
fireEvent.pointerUp(cardButton, {
pointerId: 7,
clientX: 42,
clientY: 51,
});
fireEvent.click(cardButton.closest('.game-resource-card')!);
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'update_local_project_resource_canvas_layout' &&
Array.isArray(args?.positions) &&
args.positions.some(
(position) =>
position.resourceId === 'asset:clickable-art' &&
position.manuallyPlaced,
),
),
).toBe(false);
});
it('does not leave a stale click suppression after a cancelled card drag', async () => {
const projectId = 'workbench-card-drag-cancel';
const projectPath = '/tmp/workbench-card-drag-cancel';
const manifest = createGameCreationAppManifest(
projectId,
'卡片拖拽取消点击测试',
);
manifest.assets.push({
id: 'cancelable-art',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/cancelable.png',
source: { kind: 'generated' },
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '卡片拖拽取消点击测试',
projectPath,
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('美术资源');
const cardButton = await findResourceSelectButton('cancelable.png');
const card = cardButton.closest<HTMLElement>('.game-resource-card');
expect(card).not.toBeNull();
Object.defineProperties(card!, {
setPointerCapture: { configurable: true, value: vi.fn() },
hasPointerCapture: { configurable: true, value: vi.fn(() => true) },
releasePointerCapture: { configurable: true, value: vi.fn() },
});
fireEvent.pointerDown(card, {
pointerId: 41,
button: 0,
clientX: 40,
clientY: 50,
});
fireEvent.pointerMove(card, {
pointerId: 41,
clientX: 120,
clientY: 50,
});
fireEvent.pointerCancel(card, { pointerId: 41 });
fireEvent.click(card!);
expect(card?.querySelector('[aria-pressed="true"]')).not.toBeNull();
});
it('maps manifest asset kinds into stable type layout ordering', async () => {
const projectId = 'workbench-layout-asset-subtypes';
const manifest = createGameCreationAppManifest(
projectId,
'资源子类型排序测试',
);
manifest.assets.push(
{
id: 'ui-prototype-first-by-label',
kind: 'ui-prototype',
mediaType: 'image/png',
localPath: 'assets/a-ui-prototype.png',
source: { kind: 'generated', taskId: 'design-foundation' },
},
{
id: 'art-spritesheet-last-by-label',
kind: 'art-spritesheet',
mediaType: 'image/png',
localPath: 'assets/z-art-spritesheet.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
);
const updates: Array<{
mode: 'dependency' | 'type';
positions: ProjectResourceCanvasPosition[];
}> = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
const mode = args?.mode as 'dependency' | 'type';
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
const positions = structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
);
updates.push({ mode, positions });
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode,
revision: 1,
positions,
updatedAt: 1,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '资源子类型排序测试',
projectPath: '/tmp/workbench-layout-asset-subtypes',
manifest,
attachments: [],
agentResults: [],
supervisor: React.createElement('div', null, '项目总控'),
}),
);
await openResourceBookCategory('设计文档');
await waitFor(() =>
expect(updates.some(({ mode }) => mode === 'dependency')).toBe(true),
);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
await waitFor(() =>
expect(updates.some(({ mode }) => mode === 'type')).toBe(true),
);
const typePositions = updates.find(
({ mode }) => mode === 'type',
)?.positions;
expect(typePositions).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'asset:art-spritesheet-last-by-label',
x: 0,
y: 0,
}),
expect.objectContaining({
resourceId: 'asset:ui-prototype-first-by-label',
x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
y: 0,
}),
]),
);
});
it('keeps newly reconciled resources visible when their automatic layout save fails', async () => {
const projectId = 'workbench-layout-save-failure';
const manifest = createGameCreationAppManifest(projectId, '布局失败测试');
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: 'dependency',
revision: 2,
positions: [],
updatedAt: 200,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
throw new Error('disk full');
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '布局失败测试',
projectPath: '/tmp/workbench-layout-save-failure',
manifest,
attachments: [],
agentResults: [
{
agentId: 'design-foundation',
runId: 'save-failure-run',
label: '玩法策划 Agent',
title: '自动排版失败回执',
content: '自动排版失败正文',
updatedAt: 1,
},
],
supervisor: React.createElement('div', null, '项目总控'),
}),
);
expect(
await screen.findByText('布局保存失败,已保留当前会话布局'),
).not.toBeNull();
await openResourceBookCategory('设计文档');
expect(
await screen.findByRole('button', {
name: '选中资源:设计文档 自动排版失败回执',
}),
).not.toBeNull();
});
it('keeps the landscape workbench edge-to-edge with internal chat scrolling', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
expect(styles).toMatch(
/\.game-project-workbench\s*\{[^}]*width:\s*calc\(100vw - var\(--launcher-sidebar-width\)\)[^}]*padding:\s*0[^}]*overflow:\s*hidden/s,
);
expect(styles).toMatch(
/\.launcher-main:has\(\.game-project-workbench\)\s*\{[^}]*padding-bottom:\s*0/s,
);
expect(styles).toMatch(
/\.game-workbench-toolbar\s*\{[^}]*position:\s*relative[^}]*padding:\s*8px 12px[^}]*background:\s*transparent/s,
);
expect(styles).toMatch(
/\.game-workbench-view-actions \.game-workbench-play-button\s*\{[^}]*position:\s*absolute[^}]*left:\s*50%[^}]*transform:\s*translateX\(-50%\)/s,
);
expect(styles).toMatch(
/@media \(max-width: 1000px\)[\s\S]*?\.game-workbench-view-actions \.game-workbench-play-button\s*\{[^}]*position:\s*static[^}]*transform:\s*none/s,
);
expect(styles).toMatch(
/@media \(min-width: 761px\)[\s\S]*?\.game-project-workbench\s*\{[^}]*grid-template-rows:\s*minmax\(0, 1fr\) auto[^}]*height:\s*100dvh/,
);
expect(styles).toMatch(
/\.game-workbench-chat \.project-supervisor-conversation\s*\{[^}]*position:\s*relative[^}]*display:\s*block[^}]*height:\s*100%[^}]*min-height:\s*0[^}]*overflow:\s*hidden/s,
);
expect(styles).toMatch(
/\.game-workbench-chat \.project-supervisor-message-list\s*\{[^}]*height:\s*100%[^}]*min-height:\s*96px[^}]*overflow-y:\s*auto[^}]*padding-bottom:\s*196px[^}]*scroll-padding-bottom:\s*196px/s,
);
expect(styles).toMatch(
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-message-list\s*\{[^}]*position:\s*absolute[^}]*bottom:\s*104px[^}]*left:\s*0/s,
);
expect(styles).toMatch(
/\.game-workbench-chat \.agent-runtime-status\s*\{[^}]*max-height:\s*clamp\(120px, 24dvh, 240px\)[^}]*overflow-y:\s*auto/s,
);
expect(styles).toMatch(
/\.game-workbench-chat \.project-runtime-summary\s*\{[^}]*position:\s*sticky[^}]*top:\s*-10px/s,
);
expect(styles).toMatch(
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) 82px[^}]*z-index:\s*2[^}]*padding-top:\s*8px[^}]*background:\s*transparent/s,
);
const composerRule = styles.match(
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\s*\{([^}]*)\}/s,
);
expect(composerRule?.[1]).not.toContain('border-top:');
expect(styles).toMatch(
/\.supervisor-chat-only-runtime-controls \.pending-command\s*\{[^}]*display:\s*grid[^}]*grid-template-columns:\s*minmax\(0, 1fr\) auto/s,
);
expect(styles).toMatch(
/\.game-workbench-chat \.pending-command-actions button\s*\{[^}]*min-width:\s*52px[^}]*flex:\s*0 0 auto/s,
);
expect(styles).toMatch(
/\.game-workbench-chat\s+\.agent-runtime-status\s+\.project-runtime-pending-command\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) 136px/s,
);
expect(styles).toMatch(
/\.game-workbench-chat \.project-runtime-pending-command\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) 136px/s,
);
expect(styles).toMatch(
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\s+button\s*\{[^}]*min-height:\s*72px[^}]*white-space:\s*nowrap/s,
);
expect(styles).toMatch(
/\.game-run-surface\s*\{[^}]*grid-template-rows:\s*minmax\(300px, 1fr\) auto[^}]*grid-row:\s*2 \/ -1[^}]*height:\s*100%/s,
);
expect(styles).toMatch(
/\.local-game-preview-frame\s*\{[^}]*position:\s*relative[^}]*width:\s*100%[^}]*height:\s*100%[^}]*min-width:\s*0[^}]*min-height:\s*0[^}]*overflow:\s*hidden/s,
);
expect(styles).toMatch(
/\.local-game-preview-frame iframe\s*\{[^}]*position:\s*absolute[^}]*top:\s*50%[^}]*left:\s*50%[^}]*display:\s*block[^}]*max-width:\s*none[^}]*max-height:\s*none[^}]*border:\s*0[^}]*transform-origin:\s*center/s,
);
const runPreviewIframeRule =
styles.match(/\.game-run-preview iframe\s*\{([^}]*)\}/s)?.[1] ?? '';
expect(runPreviewIframeRule).toContain('min-height: 0;');
expect(runPreviewIframeRule).not.toMatch(
/(?:^|;)\s*(?:position|inset|width|height)\s*:/,
);
expect(styles).not.toMatch(/\.game-run-slice-controls/);
const agentDockRule = styles.match(/\.game-agent-dock\s*\{([^}]*)\}/s);
expect(agentDockRule?.[1]).toContain('overflow: visible;');
expect(agentDockRule?.[1]).not.toContain('overflow-x:');
expect(agentDockRule?.[1]).toContain('z-index: 30;');
expect(agentDockRule?.[1]).toContain('isolation: isolate;');
expect(agentDockRule?.[1]).toContain('min-height: 58px;');
expect(styles).toMatch(
/\.game-agent-dock-item\s*\{[^}]*flex:\s*1 1 128px[^}]*min-width:\s*0[^}]*max-width:\s*170px/s,
);
expect(styles).toMatch(
/@media \(max-width: 760px\)[\s\S]*?\.game-workbench-layout\s*\{[^}]*height:\s*auto/,
);
expect(styles).toMatch(
/@media \(max-width: 760px\)[\s\S]*?\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-message-list\s*\{[^}]*position:\s*static[^}]*height:\s*52vh[^}]*flex:\s*1 1 auto/s,
);
expect(styles).toMatch(
/\.game-resource-canvas-content\s*\{[^}]*width:\s*100%[^}]*min-width:\s*max\(100%, 620px\)/s,
);
expect(styles).toMatch(
/\.game-resource-section\s*\{[^}]*grid-template-rows:\s*auto minmax\(0, 1fr\)[^}]*height:\s*var\(--resource-section-height\)[^}]*overflow:\s*hidden/s,
);
expect(styles).toMatch(
/\.game-resource-section-viewport\s*\{[^}]*overflow:\s*auto[^}]*overscroll-behavior:\s*contain/s,
);
expect(styles).not.toMatch(
/\.game-resource-section\s*\{[^}]*position:\s*absolute/s,
);
});
it('keeps the wallet entry available when the workbench opens the UI editor', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const projectDevelopmentSource = readFileSync(
resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
),
'utf8',
);
expect(styles).toMatch(
/\.launcher-main:has\(\.game-project-workbench\)\s*>\s*\.launcher-account-bar\s*\{[^}]*display:\s*none/s,
);
expect(projectDevelopmentSource).toMatch(
/<UiEditorPage[\s\S]*?walletEntry=\{walletEntry\}[\s\S]*?\/>/,
);
expect(projectDevelopmentSource).toMatch(
/<div className="game-workbench-chat-wallet">\{walletEntry\}<\/div>/,
);
expect(projectDevelopmentSource).toMatch(
/const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*selectedResourceIds\.length === 0\s*&&\s*!uiEditorRoute/s,
);
expect(projectDevelopmentSource).toMatch(
/aria-describedby=\{\s*showRunUnavailableHint\s*\?\s*'run-unavailable-hint'\s*:\s*undefined\s*\}/s,
);
expect(projectDevelopmentSource).toMatch(
/\{showRunUnavailableHint\s*\?\s*\(\s*<p\s+id="run-unavailable-hint"/s,
);
});
it('keeps the resource preview version cache wired and cleared on project switch', () => {
const projectDevelopmentSource = readFileSync(
resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
),
'utf8',
);
expect(projectDevelopmentSource).toMatch(
/previewVersionByResourceId:\s*resourcePreviewVersionByResourceId/,
);
expect(projectDevelopmentSource).toMatch(
/setResourcePreviewVersionByResourceId\(new Map\(\)\)/,
);
});
it('keeps resource sort tab keyboard focus inside the clipped segmented control', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
expect(styles).toMatch(
/\.game-workbench-tabs\.game-resource-sort-tabs button:focus-visible\s*\{[^}]*box-shadow:\s*inset 0 0 0 3px var\(--platform-input-focus-ring\)/s,
);
});
it('keeps workbench chat bubbles aligned without shrinking process cards', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const messageListRule =
styles.match(
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-message-list\s*\{([^}]*)\}/s,
)?.[1] ?? '';
const messageRule =
styles.match(
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-message-list\s+\.message\s*\{([^}]*)\}/s,
)?.[1] ?? '';
const userMessageRule =
styles.match(
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-message-list\s+\.message--user\s*\{([^}]*)\}/s,
)?.[1] ?? '';
const processCardRules = Array.from(
styles.matchAll(
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-message-list\s*>\s*\.project-supervisor-process-card\s*\{([^}]*)\}/gs,
),
(match) => match[1],
);
expect(messageListRule).not.toBe('');
expect(messageListRule).not.toContain('display: flex;');
expect(messageListRule).not.toContain('align-items:');
expect(messageRule).toContain('width: fit-content;');
expect(messageRule).toContain('max-width: min(88%, 720px);');
expect(userMessageRule).not.toBe('');
expect(userMessageRule).toContain('margin-left: auto;');
expect(userMessageRule).not.toContain('align-self:');
expect(userMessageRule).toContain('border-radius: 14px 14px 4px;');
expect(userMessageRule).toContain('background: var(--platform-warm-bg);');
expect(userMessageRule).toContain('color: var(--platform-text-base);');
expect(processCardRules).toHaveLength(1);
expect(processCardRules[0]).toContain('width: 100%;');
});
it('enables the run presentation and renders registered images in the resource viewer', async () => {
const manifest = createGameCreationAppManifest(
'workbench-runnable',
'可运行工作台',
);
const codePrototype = manifest.tasks.find(
(task) => task.id === 'code-prototype',
);
if (!codePrototype) {
throw new Error('missing code-prototype seed task');
}
codePrototype.status = 'completed';
manifest.preview = {
status: 'running',
url: 'http://127.0.0.1:4173',
port: 4173,
};
manifest.assets.push({
id: 'hero-art',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
source: {
kind: 'generated',
taskId: 'art-asset-plan',
},
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_image_preview') {
expect(args).toEqual({
projectPath: '/tmp/workbench-runnable',
relativePath: 'assets/hero.png',
});
return {
path: 'assets/hero.png',
mediaType: 'image/png',
byteLen: 12,
dataUrl:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const onPlay = vi.fn();
render(
React.createElement(ProjectDevelopmentView, {
projectName: '可运行工作台',
projectPath: '/tmp/workbench-runnable',
manifest,
attachments: [],
recentRunStatus: 'completed',
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
onPlay,
}),
);
fireEvent.click(screen.getByRole('button', { name: '播放' }));
expect(onPlay).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('运行表现层')).not.toBeNull();
const runTab = screen.getByRole('tab', {
name: '运行',
}) as HTMLButtonElement;
expect(runTab.disabled).toBe(false);
fireEvent.click(runTab);
expect(screen.getByLabelText('运行表现层')).not.toBeNull();
const previewFrame = screen.getByTitle(
'可运行工作台 游戏运行画面',
) as HTMLIFrameElement;
expect(previewFrame.getAttribute('src')).toBe('http://127.0.0.1:4173/');
expect(previewFrame.getAttribute('sandbox')).toBe(
'allow-scripts allow-same-origin allow-forms allow-pointer-lock',
);
expect(screen.queryByLabelText('测试切片控件')).toBeNull();
expect(screen.getByLabelText('资源信息面板')).not.toBeNull();
expect(screen.getByLabelText('数值微调面板')).not.toBeNull();
fireEvent.click(screen.getByRole('tab', { name: '资源管理' }));
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
await openResourceBookCategory('美术资源');
expect(screen.getByLabelText('资源类型视图')).not.toBeNull();
fireEvent.change(screen.getByLabelText('搜索项目资源'), {
target: { value: 'hero.png' },
});
fireEvent.click(await findResourceSelectButton('hero.png'));
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
expect(screen.queryByRole('dialog', { name: 'hero.png' })).toBeNull();
});
it('marks an unvalidated UI prototype as a candidate image', async () => {
const manifest = createGameCreationAppManifest(
'workbench-ui-candidate',
'候选界面图测试',
);
manifest.assets.push({
id: 'ui-prototype-candidate',
kind: 'ui-prototype',
mediaType: 'image/png',
localPath: 'assets/ui-prototype.png',
source: {
kind: 'canvas',
taskId: 'design-foundation',
},
});
render(
React.createElement(ProjectDevelopmentView, {
projectName: '候选界面图测试',
projectPath: '/tmp/workbench-ui-candidate',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
agentRuntimeSummaries: [
{
group: 'art',
label: '美术 Agent',
status: 'completed',
statusLabel: '已完成',
currentTask: '本轮工作已完成',
currentAction: null,
waitingOn: null,
completedCount: 4,
totalCount: 4,
},
],
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('美术资源');
const candidate = await findResourceSelectButton(
'ui-prototype.png(待视觉验收)',
);
const candidateCard = candidate.closest('.game-resource-card');
expect(candidateCard?.textContent).not.toContain('画板 · 候选界面图');
expect(candidateCard?.textContent).not.toContain('assets/ui-prototype.png');
expect(screen.getByText('仅完成计划')).not.toBeNull();
expect(
screen.getByText('美术资源计划已完成,尚未生成或登记图片'),
).not.toBeNull();
});
it('refuses to embed a non-loopback game preview in the client workbench', () => {
const manifest = createGameCreationAppManifest(
'workbench-remote-preview',
'远程预览拒绝测试',
);
const codePrototype = manifest.tasks.find(
(task) => task.id === 'code-prototype',
);
if (!codePrototype) {
throw new Error('missing code-prototype seed task');
}
codePrototype.status = 'completed';
manifest.preview = {
status: 'running',
url: 'https://example.com/game',
port: 443,
};
render(
React.createElement(ProjectDevelopmentView, {
projectName: '远程预览拒绝测试',
projectPath: '/tmp/workbench-remote-preview',
manifest,
attachments: [],
recentRunStatus: 'completed',
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
expect(screen.queryByTitle('远程预览拒绝测试 游戏运行画面')).toBeNull();
expect(screen.getByText('客户端运行画面尚未载入')).not.toBeNull();
expect(
screen.getByText('点击顶部播放按钮后将在这里直接运行游戏'),
).not.toBeNull();
expect(screen.queryByText(/\/run|\/preview/)).toBeNull();
});
}
export function registerUserSurfaceBoundaryTests() {
it('keeps the user surface to chat, upload, config and command confirmation', async () => {
renderAppAt('/?main');
expect(screen.getByLabelText('聊天')).not.toBeNull();
expect(screen.getByLabelText('Agent 状态')).not.toBeNull();
const composerInput = screen.getByLabelText('创作想法');
expect(composerInput).not.toBeNull();
expect(screen.getByText('上传')).not.toBeNull();
expect(screen.getByRole('button', { name: '命令' })).not.toBeNull();
expect(screen.getByRole('button', { name: '能力' })).not.toBeNull();
expect(screen.getByRole('button', { name: '配置' })).not.toBeNull();
expect(screen.getByRole('button', { name: 'LLM状态' })).not.toBeNull();
expect(screen.getByRole('button', { name: '显示目录' })).not.toBeNull();
expect(screen.queryByLabelText('项目摘要')).toBeNull();
expect(
(screen.getByRole('button', { name: '灵感草稿' }) as HTMLButtonElement)
.disabled,
).toBe(false);
expect(
(screen.getByRole('button', { name: '项目状态' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '权限' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '审计' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '运行' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '资产' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '打开画板' }) as HTMLButtonElement)
.disabled,
).toBe(false);
expect(
(
screen.getByRole('button', {
name: '导入画板资产',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '任务' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '索引确认' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(
screen.getByRole('button', {
name: '资产登记确认',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(
(
screen.getByRole('button', {
name: '记忆写入确认',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '预览确认' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(
screen.getByRole('button', {
name: '打开预览确认',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(
(
screen.getByRole('button', {
name: '停止预览确认',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: 'Agent确认' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(
screen.getByRole('button', {
name: '读对话确认',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(
(
screen.getByRole('button', {
name: '存对话确认',
}) as HTMLButtonElement
).disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: 'Trace' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '文件' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '索引' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '记忆' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '短期记忆' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '黑板' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '记到黑板' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '覆盖黑板' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '清空黑板' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '快照' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '快照列表' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '历史' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '白名单' }) as HTMLButtonElement)
.disabled,
).toBe(false);
expect(
(screen.getByRole('button', { name: '静态自检' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '启动预览' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '打开预览' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '预览状态' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '停止预览' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '刷新状态' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '刷新 Agent' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '状态' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '终止' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '重试' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '继续' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '继续说明' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '输出' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '活动' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole('button', { name: '上下文包' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(
screen.getByRole('button', {
name: /拆解创作方向/,
}) as HTMLButtonElement
).disabled,
).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '打开画板' }));
expect(await composerValue(composerInput)).toBe('/canvas ');
fireEvent.click(screen.getByRole('button', { name: '灵感草稿' }));
expect(await composerValue(composerInput)).toBe(
'像素风厨房弹幕小游戏:玩家用方向键躲避飞来的食材,收集调料加分,60 秒内尽量高分,失败后可一键重开。',
);
expect(screen.queryByText('game.generate_draft')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '白名单' }));
expect(await screen.findByText(/可运行受限命令:/)).not.toBeNull();
expect(screen.getByRole('button', { name: '切换项目' })).not.toBeNull();
expect(screen.getByText('想做什么游戏?')).not.toBeNull();
expect(screen.getAllByText('暂无最近运行证据').length).toBeGreaterThan(0);
expect(screen.queryByLabelText('工作区管理')).toBeNull();
expect(screen.queryByLabelText('开发环境')).toBeNull();
expect(screen.queryByLabelText('运行时配置')).toBeNull();
expect(screen.queryByText('Agent 能力')).toBeNull();
expect(screen.queryByText('编排 Trace')).toBeNull();
});
}
export function registerProjectSupervisorSurfaceTests() {
it('allows selecting the model on the first direct-project entry', async () => {
const projectPath = '/tmp/first-entry-model-select';
const manifest = createGameCreationAppManifest(
'first-entry-model-select',
'首次进入模型选择',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
initialSessionExists: false,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_manifest') {
return manifest;
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
initialProjectManifest: manifest,
projectSupervisorOnly: true,
}),
);
const surface = await screen.findByLabelText('陶泥儿项目对话');
const trigger = within(surface).getByRole('button', { name: '对话模型' });
await waitFor(() => expect(trigger.hasAttribute('disabled')).toBe(false));
fireEvent.click(trigger);
await waitFor(() =>
expect(
within(surface).getByRole('option', { name: '快速' }),
).not.toBeNull(),
);
fireEvent.click(within(surface).getByRole('option', { name: '快速' }));
await waitFor(() => expect(trigger.textContent).toContain('快速'));
});
it('runs a top workbench play request without a second confirmation', async () => {
const projectPath = '/tmp/top-play-request';
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: '顶部播放请求',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'start_local_game_preview') {
return {
url: 'http://127.0.0.1:43127/',
port: 43127,
root: projectPath,
};
}
if (command === 'get_local_game_manifest') {
return createGameCreationAppManifest(
'top-play-request',
'顶部播放请求',
);
}
return supervisorHarness.invoke(command, args);
},
);
const handled = vi.fn();
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
playRequest: { projectPath, requestId: 7 },
onPlayRequestHandled: handled,
}),
);
expect(
await screen.findByText(
/运行通过,已载入客户端运行视图/,
{},
{ timeout: 3_000 },
),
).not.toBeNull();
expect(handled).toHaveBeenCalledWith(7);
expect(invoke).toHaveBeenCalledWith('start_local_game_preview', {
projectPath,
});
expect(screen.queryByText('game.run_local')).toBeNull();
});
it('starts a direct product preview without the legacy Canvas-only static smoke', async () => {
const projectPath = '/tmp/direct-dom-preview';
const manifest = createGameCreationAppManifest(
'direct-dom-preview',
'DOM 游戏预览',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'start_local_game_preview') {
return {
url: 'http://127.0.0.1:43126/',
port: 43126,
root: projectPath,
};
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
initialProjectManifest: manifest,
projectSupervisorOnly: true,
playRequest: { projectPath, requestId: 8 },
}),
);
expect(
await screen.findByText(
'运行通过,已载入客户端运行视图:http://127.0.0.1:43126/',
),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('start_local_game_preview', {
projectPath,
});
expect(invoke).not.toHaveBeenCalledWith('run_limited_local_command', {
projectPath,
commandId: 'game.static_smoke',
});
});
it('keeps the direct Codex welcome surface free of legacy Supervisor status before the first message', async () => {
const projectPath = '/tmp/launcher-empty-supervisor-game';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-empty-supervisor-game',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
initialSessionExists: false,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-empty-supervisor-game',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher(projectPath);
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
const messageList = within(supervisorSurface).getByLabelText('陶泥儿消息');
expect(
await within(messageList).findByText('想做什么游戏?'),
).not.toBeNull();
expect(
within(supervisorSurface).queryByLabelText('项目总控 Agent 状态'),
).toBeNull();
expect(
within(supervisorSurface).getByText(
'告诉陶泥儿接下来要做什么,或输入 @ 选择资源',
),
).not.toBeNull();
expect(
within(supervisorSurface).getByRole('button', { name: '发送' }),
).toHaveProperty('disabled', false);
expect(screen.queryByLabelText('项目总控对话')).toBeNull();
});
it('keeps a persisted legacy Supervisor failure out of the direct product chat', async () => {
const projectPath = '/tmp/launcher-legacy-failure-supervisor-game';
const legacyFailure =
'后台任务失败:kind=codex-app-server-context-window-exceeded ' +
`fingerprint=${'a'.repeat(64)} chars=2048`;
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
supervisorMessages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: legacyFailure,
agentId: 'project-supervisor',
messageId: 'legacy-failure-message',
updatedAt: 2000,
},
],
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-legacy-failure-supervisor-game',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return createGameCreationAppManifest(
'local-project-draft',
'launcher-legacy-failure-supervisor-game',
);
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher(projectPath);
const messageList = await screen.findByLabelText('陶泥儿消息');
expect(
within(messageList).queryByText(
'项目总控 Agent 模型上下文已超限,请缩小任务范围后重试',
),
).toBeNull();
expect(messageList.textContent).not.toContain('fingerprint');
expect(messageList.textContent).not.toContain('chars=');
});
it('keeps a persisted needs-reconciliation Supervisor runtime out of direct product chat', async () => {
const projectPath = '/tmp/launcher-reconciliation-supervisor-game';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-reconciliation-supervisor-game',
);
const reconciliationRuntime = {
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'project-supervisor',
taskId: 'project-supervisor',
sessionId: 'persisted-supervisor-session',
runId: 'persisted-reconciliation-run',
source: 'project-supervisor',
status: 'needs-reconciliation',
phase: 'needs-reconciliation',
currentTask: '帮我生成一个贪吃蛇',
currentGoal: '完成贪吃蛇原型',
currentAction: '等待核对 Provider 回复交接',
waitingOn: '人工核对',
nextStep: '核对后继续或取消',
plan: [],
observations: [],
allowedTools: [],
pendingToolAction: null,
lastResponse: null,
error: 'tool-plan-unknown',
updatedAt: 7000,
};
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
initialSessionExists: false,
initialRuntime: reconciliationRuntime,
runtimeMapLoader: async () => [reconciliationRuntime],
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-reconciliation-supervisor-game',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher(projectPath);
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
expect(
within(supervisorSurface).queryByLabelText('项目总控 Agent 状态'),
).toBeNull();
expect(
within(supervisorSurface).queryByText('项目总控 Agent · 待核对'),
).toBeNull();
expect(
within(supervisorSurface).queryByText('tool-plan-unknown'),
).toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'read_game_creator_agent_runtimes',
expect.anything(),
);
expect(
within(supervisorSurface).queryByRole('button', {
name: '已核对,结束旧任务',
}),
).toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'resume_game_creator_agent_runtime_tasks',
expect.anything(),
);
expect(invoke).not.toHaveBeenCalledWith(
'cancel_game_creator_agent_runtime_task',
expect.anything(),
);
expect(invoke).not.toHaveBeenCalledWith(
'confirm_retry_game_creator_agent_runtime_task',
expect.anything(),
);
expect(
within(supervisorSurface).queryByRole('button', {
name: '在当前项目重试总控',
}),
).toBeNull();
});
it('does not hydrate a reconciled legacy Supervisor while allowing a new direct Codex turn', async () => {
const projectPath = '/tmp/launcher-reconciliation-empty-queue';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-reconciliation-empty-queue',
);
const runId = 'reconciliation-empty-queue-run';
const reconciliationRuntime = {
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'project-supervisor',
taskId: 'project-supervisor',
sessionId: 'reconciliation-empty-queue-session',
runId,
source: 'project-supervisor',
status: 'needs-reconciliation',
phase: 'needs-reconciliation',
currentTask: '生成贪吃蛇原型',
currentGoal: '完成可玩原型',
currentAction: '等待核对 Provider 回复交接',
waitingOn: '人工核对',
nextStep: '核对后结束旧任务',
plan: [],
observations: [],
allowedTools: [],
pendingToolAction: null,
lastResponse: null,
error: 'tool-plan-unknown',
updatedAt: 7000,
};
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
sessionId: 'reconciliation-empty-queue-session',
initialSessionExists: false,
initialRuntime: reconciliationRuntime,
runtimeMapLoader: async () => [reconciliationRuntime],
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-reconciliation-empty-queue',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'chat_with_game_creator_direct_codex') {
return 'DIRECT_AFTER_RECONCILIATION_OK';
}
if (command === 'get_local_game_preview_status') {
return { status: 'stopped', url: null, port: null, root: null };
}
if (command === 'start_local_game_preview') {
return {
url: 'http://127.0.0.1:43123/game/index.html',
port: 43123,
root: projectPath,
};
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher(projectPath);
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
expect(
within(supervisorSurface).queryByLabelText('项目总控 Agent 状态'),
).toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'read_game_creator_agent_runtimes',
expect.anything(),
);
const input = within(supervisorSurface).getByLabelText('陶泥儿对话内容');
await setComposerText(input, '从旧任务状态继续生成,但使用 direct Codex');
fireEvent.submit(input.closest('form') as HTMLFormElement);
expect(
await within(supervisorSurface).findByText(
'DIRECT_AFTER_RECONCILIATION_OK',
),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', {
projectPath,
prompt: '从旧任务状态继续生成,但使用 direct Codex',
clientTurnId: expect.any(String),
});
expect(invoke).not.toHaveBeenCalledWith(
'cancel_game_creator_agent_runtime_task',
expect.anything(),
);
expect(invoke).not.toHaveBeenCalledWith(
'confirm_retry_game_creator_agent_runtime_task',
expect.anything(),
);
});
it('loads and continues the active Project Supervisor Session in the standalone chat surface', async () => {
const projectPath = '/tmp/supervisor-chat-only-game';
const historyMessage = '已持久化的项目总控历史';
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
expectedRunProfile: 'standard',
supervisorMessages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: historyMessage,
agentId: 'project-supervisor',
messageId: 'supervisor-chat-only-history',
updatedAt: 2000,
},
],
});
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
supervisorChatOnly: true,
}),
);
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
const messageList = within(surface).getByLabelText('项目总控消息');
expect(await within(messageList).findByText(historyMessage)).not.toBeNull();
expect(harness.invoke).toHaveBeenCalledWith(
'list_game_creator_agent_sessions',
{
projectPath,
agentId: 'project-supervisor',
},
);
expect(harness.invoke).toHaveBeenCalledWith('read_local_conversation', {
projectPath,
agentId: 'project-supervisor',
sessionId: harness.sessionId,
});
expect(
within(surface).getByRole('button', { name: '设置' }),
).not.toBeNull();
expect(screen.queryByLabelText('选择 Agent')).toBeNull();
expect(screen.queryByLabelText('项目总控 Agent 状态')).toBeNull();
expect(screen.queryByLabelText('专业 Agent 协作状态')).toBeNull();
await setComposerText(
within(surface).getByLabelText('项目总控对话内容'),
'继续完成可玩原型',
);
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(harness.invoke).toHaveBeenCalledWith(
'start_game_creator_supervisor_runtime_task',
{
projectPath,
sessionId: harness.sessionId,
task: '继续完成可玩原型',
runId: expect.stringMatching(/^project-supervisor-task-/),
runProfile: 'standard',
source: 'project-supervisor-gui',
},
);
});
expect(harness.invoke).not.toHaveBeenCalledWith(
'create_game_creator_agent_session',
expect.anything(),
);
expect(harness.invoke).not.toHaveBeenCalledWith(
'chat_with_game_creator_agent',
expect.anything(),
);
});
it('keeps an unsent standalone Project Supervisor draft across window navigation reloads', async () => {
const projectPath = '/tmp/supervisor-chat-only-draft';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
const renderSupervisorChat = () =>
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
supervisorChatOnly: true,
}),
);
renderSupervisorChat();
const firstSurface = await screen.findByLabelText('项目总控 Agent 纯聊天');
await within(firstSurface).findByLabelText('项目总控消息');
await setComposerText(
within(firstSurface).getByLabelText('项目总控对话内容'),
'这条草稿还没有发送',
);
cleanup();
renderSupervisorChat();
const restoredInput = await screen.findByLabelText('项目总控对话内容');
expect(await composerValue(restoredInput)).toBe('这条草稿还没有发送');
});
it('shows and handles runtime recovery confirmation in the standalone Project Supervisor chat', async () => {
const projectPath = '/tmp/supervisor-chat-only-resume';
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'resume_game_creator_agent_runtime_tasks') {
throw new Error('项目权限策略要求用户确认:agent.resume');
}
if (command === 'confirm_resume_game_creator_agent_runtime_tasks') {
return [];
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
supervisorChatOnly: true,
}),
);
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
const detail = await within(surface).findByText(
`恢复 ${projectPath} 中未完成的 Agent Runtime 任务`,
);
expect(within(surface).queryByText(/项目总控 Agent 恢复失败/)).toBeNull();
const confirmation = detail.closest('.pending-command');
expect(confirmation).not.toBeNull();
fireEvent.click(
within(confirmation as HTMLElement).getByRole('button', {
name: '确认',
}),
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'confirm_resume_game_creator_agent_runtime_tasks',
{ projectPath },
);
expect(
within(surface).queryByText(
`恢复 ${projectPath} 中未完成的 Agent Runtime 任务`,
),
).toBeNull();
});
});
it('confirms and rejects pending actions in the standalone Project Supervisor chat', async () => {
const projectPath = '/tmp/supervisor-chat-only-confirmation';
const runId = 'supervisor-chat-only-confirmation-run';
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
initialRuntime: {
runId,
status: 'waiting-for-confirmation',
phase: 'waiting-for-confirmation',
pendingToolAction: {
actionId: 'standalone-action-confirm',
actionFingerprint: 'standalone-fingerprint-confirm',
tool: 'file.write',
inputSummary: 'game/index.html',
reason: null,
requestedAt: 3000,
},
},
});
harness.setConfirmRuntime(
harness.runtimeState({
runId,
status: 'waiting-for-confirmation',
phase: 'waiting-for-confirmation',
pendingToolAction: {
actionId: 'standalone-action-reject',
actionFingerprint: 'standalone-fingerprint-reject',
tool: 'command.exec',
inputSummary: 'npm test',
reason: null,
requestedAt: 4000,
},
}),
);
harness.setRejectRuntime(
harness.runtimeState({
runId,
status: 'running',
phase: 'planning',
pendingToolAction: null,
}),
);
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
supervisorChatOnly: true,
}),
);
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
let pendingAction =
await within(surface).findByLabelText('项目总控 Agent 待确认动作');
expect(within(pendingAction).getByText('file.write')).not.toBeNull();
expect(within(pendingAction).getByText('game/index.html')).not.toBeNull();
fireEvent.click(
within(pendingAction).getByRole('button', { name: '确认' }),
);
await waitFor(() => {
expect(harness.invoke).toHaveBeenCalledWith(
'confirm_game_creator_agent_runtime_task',
{
projectPath,
agentId: 'project-supervisor',
runId,
actionId: 'standalone-action-confirm',
note: '用户已确认待执行工具动作',
},
);
});
pendingAction =
await within(surface).findByLabelText('项目总控 Agent 待确认动作');
expect(within(pendingAction).getByText('command.exec')).not.toBeNull();
expect(within(pendingAction).getByText('npm test')).not.toBeNull();
fireEvent.click(
within(pendingAction).getByRole('button', { name: '拒绝' }),
);
await waitFor(() => {
expect(harness.invoke).toHaveBeenCalledWith(
'reject_game_creator_agent_runtime_task',
{
projectPath,
agentId: 'project-supervisor',
runId,
actionId: 'standalone-action-reject',
note: '用户已拒绝待执行工具动作',
},
);
});
});
it('answers structured questions in the standalone Project Supervisor chat', async () => {
const projectPath = '/tmp/supervisor-chat-only-user-input';
const sessionId = 'supervisor-chat-only-user-input-session';
const runId = 'supervisor-chat-only-user-input-run';
const request = agentRuntimeUserInputRequest({
agentId: 'project-supervisor',
sessionId,
runId,
});
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
sessionId,
initialRuntime: {
runId,
status: 'waiting-for-user-input',
phase: 'waiting-for-user-input',
currentTask: '准备首版角色规范图',
currentAction: '等待用户补充关键信息',
waitingOn: '你的澄清回答',
nextStep: '提交全部回答后继续同一 Run',
userInputRequest: request,
updatedAt: 6000,
},
});
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
supervisorChatOnly: true,
}),
);
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
const card = await within(surface).findByLabelText('Needs input');
expect(within(card).getByText('1. 美术方向')).not.toBeNull();
expect(
within(card).getByText('首版角色规范图采用哪种美术方向?'),
).not.toBeNull();
expect(within(card).getByText('优先验证轮廓与动作可读性。')).not.toBeNull();
expect(await composerDisabled('项目总控对话内容', within(surface))).toBe(
true,
);
fireEvent.click(within(card).getByRole('button', { name: /像素风/ }));
fireEvent.click(within(card).getByRole('button', { name: '提交回答' }));
await waitFor(() => {
expect(harness.invoke).toHaveBeenCalledWith(
'answer_game_creator_agent_runtime_user_input',
{
projectPath,
agentId: 'project-supervisor',
runId,
actionId: request.actionId,
requestId: request.requestId,
responseId: expect.stringMatching(/^app-user-input-/),
answers: { visual_direction: '像素风' },
},
);
});
await waitFor(() => {
expect(within(surface).queryByLabelText('Needs input')).toBeNull();
});
});
it('opens an existing project without hydrating legacy Supervisor history, then sends consecutive direct Codex turns', async () => {
const projectPath = '/tmp/launcher-supervisor-game';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-supervisor-game',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
});
let directTurnUpdateHandler:
| ((event: { payload: Record<string, unknown> }) => void)
| null = null;
const listen = vi.fn(
async (
eventName: string,
handler: (event: { payload: Record<string, unknown> }) => void,
) => {
if (eventName === 'game-creator-direct-turn-update') {
directTurnUpdateHandler = handler;
return () => {
if (directTurnUpdateHandler === handler) {
directTurnUpdateHandler = null;
}
};
}
return supervisorHarness.listen(
eventName,
handler as Parameters<typeof supervisorHarness.listen>[1],
);
},
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-supervisor-game',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'chat_with_game_creator_direct_codex') {
return `DIRECT_REPLY:${String(args?.prompt ?? '')}`;
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen },
};
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher(projectPath);
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
expect(
within(supervisorSurface).queryByText('已恢复的项目总控历史'),
).toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'list_game_creator_agent_sessions',
expect.anything(),
);
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'read_local_conversation' &&
(args as Record<string, unknown> | undefined)?.agentId ===
'project-supervisor',
),
).toBe(false);
expect(screen.queryByLabelText('专业 Agent 协作状态')).toBeNull();
expect(screen.queryByRole('status', { name: '自动执行' })).toBeNull();
expect(screen.queryByLabelText('子 Agent 状态栏')).toBeNull();
expect(screen.queryByText('严格审批')).toBeNull();
expect(screen.queryByRole('button', { name: /审批配置/ })).toBeNull();
expect(screen.queryByLabelText('选择 Agent')).toBeNull();
expect(screen.queryByRole('dialog', { name: 'Agent 对话' })).toBeNull();
expect(supervisorHarness.listen).not.toHaveBeenCalledWith(
'game-creator-agent-runtime-update',
expect.any(Function),
);
const policyReadCountBeforeChat = invoke.mock.calls.filter(
([command]) => command === 'read_project_permission_policy',
).length;
const firstDirectReply = createDeferred<string>();
const secondDirectReply = createDeferred<string>();
let directReplyCount = 0;
invoke.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'chat_with_game_creator_direct_codex') {
directReplyCount += 1;
return directReplyCount === 1
? firstDirectReply.promise
: secondDirectReply.promise;
}
return supervisorHarness.invoke(command, args);
},
);
await setComposerText(
screen.getByLabelText('陶泥儿对话内容'),
'先完成正式客户端玩法拆解',
);
fireEvent.click(
within(supervisorSurface).getByRole('button', { name: '发送' }),
);
await waitFor(() =>
expect(
within(
within(supervisorSurface).getByLabelText('陶泥儿执行过程'),
).getByText('需求已接收'),
).not.toBeNull(),
);
const directMessageList =
within(supervisorSurface).getByLabelText('陶泥儿消息');
Object.defineProperties(directMessageList, {
clientHeight: { configurable: true, value: 180 },
scrollHeight: { configurable: true, value: 640 },
scrollTop: { configurable: true, value: 0, writable: true },
});
const waitingProcessCard =
within(directMessageList).getByLabelText('陶泥儿执行过程');
expect(waitingProcessCard.parentElement).toBe(directMessageList);
expect(
within(waitingProcessCard).getByText('正在等待陶泥儿开始'),
).not.toBeNull();
expect(
within(waitingProcessCard).getByLabelText('陶泥儿正在执行的内容')
.textContent,
).toBe('正在等待陶泥儿开始');
const firstDirectCall = invoke.mock.calls.find(
([command]) => command === 'chat_with_game_creator_direct_codex',
);
const firstTurnId = String(
(firstDirectCall?.[1] as Record<string, unknown> | undefined)
?.clientTurnId ?? '',
);
expect(firstTurnId).not.toBe('');
expect(firstDirectCall?.[1]).toEqual({
projectPath,
prompt: '先完成正式客户端玩法拆解',
clientTurnId: firstTurnId,
});
await act(async () => {
supervisorHarness.emitProgress('direct', '正在准备安全阶段');
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 0,
status: 'accepted',
activity: 'request-accepted',
updatedAt: 1000,
},
});
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 1,
status: 'running',
activity: 'preparing',
updatedAt: 1200,
},
});
});
const thinkingProcessCard =
within(directMessageList).getByLabelText('陶泥儿执行过程');
expect(within(thinkingProcessCard).getByText('任务执行中')).not.toBeNull();
expect(
within(thinkingProcessCard).getByLabelText('陶泥儿正在执行的内容')
.textContent,
).toBe('正在思考中');
await act(async () => {
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 2,
status: 'running',
activity: 'file-read',
accumulatedText: `正在读取 game/index.html,${'这是一段非常长的执行内容。'.repeat(12)}用于验证执行详情展开状态不会因为后续事件更新而被重置。`,
updatedAt: 1500,
},
});
});
const runningProcessCard =
within(directMessageList).getByLabelText('陶泥儿执行过程');
expect(within(runningProcessCard).getByText('任务执行中')).not.toBeNull();
expect(
within(runningProcessCard).getByRole('button', { name: '展开' }),
).not.toBeNull();
fireEvent.click(
within(runningProcessCard).getByRole('button', { name: '展开' }),
);
await act(async () => {
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 3,
status: 'streaming',
activity: 'controlled-tool',
accumulatedText: 'DIRECT_STREAM:先完成正式客户端玩法拆解',
updatedAt: 2000,
},
});
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 1,
status: 'streaming',
activity: 'file-write',
accumulatedText: '乱序事件不能回退正文',
updatedAt: 1500,
},
});
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: 'old-direct-turn',
sequence: 99,
status: 'streaming',
activity: 'validation',
accumulatedText: '旧回合不能覆盖正文',
updatedAt: 3000,
},
});
directTurnUpdateHandler?.({
payload: {
projectPath: '/tmp/wrong-direct-project',
turnId: firstTurnId,
sequence: 100,
status: 'streaming',
activity: 'response-finalization',
accumulatedText: '错误项目不能覆盖正文',
updatedAt: 4000,
},
});
supervisorHarness.emitProgress('direct', '旧 fallback 不能覆盖精确事件');
});
const streamingProcessCard =
within(directMessageList).getByLabelText('陶泥儿执行过程');
expect(within(streamingProcessCard).getByText('回复生成中')).not.toBeNull();
expect(
within(streamingProcessCard).getByLabelText('陶泥儿正在执行的内容')
.textContent,
).toBe('正在生成回复');
expect(
within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent,
).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解');
expect(directMessageList.scrollTop).toBe(640);
expect(within(supervisorSurface).queryByText(/不能覆盖正文/u)).toBeNull();
expect(
within(supervisorSurface).queryByText('旧 fallback 不能覆盖精确事件'),
).toBeNull();
directMessageList.scrollTop = 100;
fireEvent.scroll(directMessageList);
await act(async () => {
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 4,
status: 'running',
accumulatedText: `正在执行 npm test,${'工具输出很长时需要保持展开状态。'.repeat(10)}`,
updatedAt: 4500,
},
});
});
expect(directMessageList.scrollTop).toBe(100);
expect(within(streamingProcessCard).getByText('任务执行中')).not.toBeNull();
expect(
within(streamingProcessCard).getByText(/正在执行 npm test/u),
).not.toBeNull();
await act(async () => {
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 5,
status: 'running',
activity: 'command-exec',
accumulatedText: `正在执行命令:npm run smoke,${'heartbeat 不得覆盖具体命令。'.repeat(12)}`,
updatedAt: 5000,
},
});
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 6,
status: 'running',
activity: 'command-exec',
updatedAt: 5100,
},
});
});
expect(
within(streamingProcessCard).getByText(/heartbeat 不得覆盖具体命令/u),
).not.toBeNull();
expect(within(streamingProcessCard).queryByText('正在执行命令')).toBeNull();
await act(async () => {
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 7,
status: 'running',
activity: 'controlled-tool',
accumulatedText: `正在写入文件:game/index.html,${'写入详情需要保持展开状态。'.repeat(12)}`,
updatedAt: 5200,
},
});
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 8,
status: 'running',
activity: 'controlled-tool',
updatedAt: 5300,
},
});
});
expect(
within(streamingProcessCard).getByText(
/正在写入文件:game\/index\.html/u,
),
).not.toBeNull();
expect(within(streamingProcessCard).queryByText('正在调用工具')).toBeNull();
expect(
within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent,
).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解');
expect(
(
within(streamingProcessCard).getByRole('button', {
name: '收起',
}) as HTMLButtonElement
).getAttribute('aria-expanded'),
).toBe('true');
await act(async () => {
firstDirectReply.resolve('DIRECT_REPLY:先完成正式客户端玩法拆解');
});
await waitFor(() => {
expect(
within(supervisorSurface).getAllByText(
'DIRECT_REPLY:先完成正式客户端玩法拆解',
),
).toHaveLength(1);
expect(
within(supervisorSurface).queryByLabelText('陶泥儿正在执行的内容'),
).toBeNull();
expect(
within(supervisorSurface).queryByText(
'DIRECT_STREAM:先完成正式客户端玩法拆解',
),
).toBeNull();
expect(
within(directMessageList).queryByLabelText('陶泥儿执行过程'),
).toBeNull();
});
await waitFor(() => {
expect(
(
within(supervisorSurface).getByRole('button', {
name: '发送',
}) as HTMLButtonElement
).disabled,
).toBe(false);
});
await setComposerText(
screen.getByLabelText('陶泥儿对话内容'),
'补充:优先复用现有素材',
);
fireEvent.click(
within(supervisorSurface).getByRole('button', { name: '发送' }),
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'chat_with_game_creator_direct_codex',
{
projectPath,
prompt: '补充:优先复用现有素材',
clientTurnId: expect.any(String),
},
);
});
const directCalls = invoke.mock.calls.filter(
([command]) => command === 'chat_with_game_creator_direct_codex',
);
const secondTurnId = String(
(directCalls[1]?.[1] as Record<string, unknown> | undefined)
?.clientTurnId ?? '',
);
expect(secondTurnId).not.toBe(firstTurnId);
await act(async () => {
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: secondTurnId,
sequence: 0,
status: 'streaming',
activity: 'not-a-public-activity',
accumulatedText: '即将失败的临时正文',
updatedAt: 5000,
},
});
});
const failedTurnProcessCard =
within(directMessageList).getByLabelText('陶泥儿执行过程');
expect(
within(failedTurnProcessCard).getByText('回复生成中'),
).not.toBeNull();
expect(
within(failedTurnProcessCard).getByLabelText('陶泥儿正在执行的内容')
.textContent,
).toBe('正在生成回复');
expect(
within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent,
).toBe('即将失败的临时正文');
await act(async () => {
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: secondTurnId,
sequence: 1,
status: 'failed',
activity: 'none',
accumulatedText: '失败事件不得保留这段正文',
updatedAt: 5100,
},
});
});
expect(
within(supervisorSurface).queryByLabelText('陶泥儿实时回复'),
).toBeNull();
const failedStatusProcessCard =
within(directMessageList).getByLabelText('陶泥儿执行过程');
expect(
within(failedStatusProcessCard).getByText('处理失败'),
).not.toBeNull();
expect(
within(failedStatusProcessCard).getByLabelText('陶泥儿正在执行的内容')
.textContent,
).toBe('正在记录失败原因');
await act(async () => {
secondDirectReply.reject(new Error('模拟 direct 失败'));
});
await waitFor(() => {
expect(
within(supervisorSurface).queryByLabelText('陶泥儿实时回复'),
).toBeNull();
expect(
within(directMessageList).queryByLabelText('陶泥儿执行过程'),
).toBeNull();
expect(
(
within(supervisorSurface).getByRole('button', {
name: '发送',
}) as HTMLButtonElement
).disabled,
).toBe(false);
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
),
).toHaveLength(0);
expect(
invoke.mock.calls.filter(
([command]) => command === 'steer_game_creator_agent_runtime_task',
),
).toHaveLength(0);
expect(
invoke.mock.calls.filter(
([command]) => command === 'chat_with_game_creator_direct_codex',
),
).toHaveLength(2);
expect(
invoke.mock.calls.filter(
([command]) => command === 'get_local_game_preview_status',
),
).toHaveLength(0);
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(0);
expect(
invoke.mock.calls.filter(
([command]) => command === 'read_project_permission_policy',
),
).toHaveLength(policyReadCountBeforeChat + 1);
});
it('does not poll or render legacy professional Agent runtime state in direct product chat', async () => {
const projectPath = '/tmp/launcher-runtime-status-game';
let professionalRuntimeReadCount = 0;
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-runtime-status-game',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
runtimeMapLoader: async () => {
professionalRuntimeReadCount += 1;
return [];
},
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-runtime-status-game',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher(projectPath);
expect(await screen.findByLabelText('陶泥儿项目对话')).not.toBeNull();
await act(async () => {
await Promise.resolve();
});
expect(professionalRuntimeReadCount).toBe(0);
expect(invoke).not.toHaveBeenCalledWith(
'read_game_creator_agent_runtimes',
expect.anything(),
);
expect(screen.queryByLabelText('专业 Agent 协作状态')).toBeNull();
expect(screen.queryByLabelText('专业 Agent 实时状态')).toBeNull();
expect(screen.queryByLabelText('项目总控 Agent 状态')).toBeNull();
expect(screen.queryByLabelText('子 Agent 状态栏')).toBeNull();
});
it('does not hydrate a ready legacy Supervisor stream into direct product chat', async () => {
const projectPath = '/tmp/launcher-supervisor-finalizing';
const reply = '已经落盘的唯一项目总控回复';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-supervisor-finalizing',
);
const runId = 'supervisor-finalizing-run';
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
supervisorMessages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: reply,
agentId: 'project-supervisor',
messageId: 'launcher-supervisor-final-assistant',
updatedAt: 7000,
},
],
initialRuntime: {
runId,
status: 'running',
phase: 'finalizing',
loopIteration: 1,
appliedSteerCursor: 0,
updatedAt: 7000,
},
initialResponseStream: projectSupervisorResponseStream({
runId,
sequence: 8,
accumulatedText: reply,
status: 'ready',
}),
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-supervisor-finalizing',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher(projectPath);
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
expect(within(supervisorSurface).queryByText(reply)).toBeNull();
expect(
within(supervisorSurface).queryByLabelText('项目总控 Agent 实时回复'),
).toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'read_game_creator_agent_runtimes',
expect.anything(),
);
expect(supervisorHarness.listen).not.toHaveBeenCalledWith(
'game-creator-agent-runtime-update',
expect.any(Function),
);
});
it('rejects picker project paths with control characters before project calls', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'pick_local_project_directory') {
return '/tmp/bad\u0007path';
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherProjectsAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
expect(await screen.findByText('项目目录不能包含控制字符')).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'is_local_project_directory_non_empty',
expect.anything(),
);
});
it('opens a selected Godot project as the active root and keeps direct Codex composer keyboard semantics', async () => {
const projectPath = '/tmp/existing-godot-project';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'existing-godot-project',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
expectedRunProfile: 'standard',
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'pick_local_project_directory') {
return projectPath;
}
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: false,
isGodotProject: true,
godotProjectRoot: 'game-source',
projectName: null,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'import_local_godot_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'chat_with_game_creator_direct_codex') {
return 'DIRECT_GODOT_OK';
}
if (command === 'get_local_game_preview_status') {
return { status: 'stopped', url: null, port: null, root: null };
}
if (command === 'start_local_game_preview') {
return {
url: 'http://127.0.0.1:43125/game/index.html',
port: 43125,
root: projectPath,
};
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '打开项目' }));
const surface = await screen.findByLabelText('陶泥儿项目对话');
expect(invoke).toHaveBeenCalledWith('import_local_godot_project', {
projectPath,
projectId: expect.stringMatching(/^local-project-/),
name: 'existing-godot-project',
});
expect(
invoke.mock.calls.some(
([command]) => command === 'init_local_game_project',
),
).toBe(false);
const composer = within(surface).getByLabelText('陶泥儿对话内容');
await setComposerText(composer, '修改玩家移动脚本');
// 输入区已从原生 textarea 换成 Lexical 富文本:Shift+Enter 由编辑器自己插入换行
// (按键被编辑器消化,因此 fireEvent 返回 false),语义仍是「换行而不是发送」。
expect(fireEvent.keyDown(composer, { key: 'Enter', shiftKey: true })).toBe(
false,
);
// composerValue 会先等编辑器提交这一帧,再校验草稿保留且确实插入了换行。
expect(await composerValue(composer)).toBe('修改玩家移动脚本');
expect(
composer.querySelector('br:not([data-lexical-managed-linebreak])'),
).not.toBeNull();
expect(
invoke.mock.calls.some(
([command]) => command === 'chat_with_game_creator_direct_codex',
),
).toBe(false);
// 组合态 Enter 同样不发送:按键被编辑器消化,草稿原样保留。
expect(
fireEvent.keyDown(composer, { key: 'Enter', isComposing: true }),
).toBe(false);
expect(await composerValue(composer)).toBe('修改玩家移动脚本');
expect(
invoke.mock.calls.some(
([command]) => command === 'chat_with_game_creator_direct_codex',
),
).toBe(false);
expect(fireEvent.keyDown(composer, { key: 'Enter' })).toBe(false);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'chat_with_game_creator_direct_codex',
{
projectPath,
prompt: '修改玩家移动脚本',
clientTurnId: expect.any(String),
},
);
});
expect(invoke).not.toHaveBeenCalledWith(
'start_game_creator_supervisor_runtime_task',
expect.anything(),
);
});
}
export function registerProjectWorkbenchNavigationTests() {
it('keeps project switching inside the single-window client flow', async () => {
const invoke = vi.fn(async () => undefined);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main');
fireEvent.click(screen.getByRole('button', { name: '切换项目' }));
expect(screen.getByText('请回到首页的项目组切换项目。')).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_launcher_window',
);
await submitChat('/switch-project');
expect(
screen.getAllByText('请回到首页的项目组切换项目。').length,
).toBeGreaterThanOrEqual(2);
expect(invoke).not.toHaveBeenCalledWith(
'open_game_creator_launcher_window',
);
});
it('opens the current project directory from the main project window', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_project_permission_policy') {
return emptyProjectPolicy();
}
if (command === 'chat_with_game_creator_agent') {
return {
replyText: `主聊天回复:${String(args?.prompt ?? '')}`,
};
}
if (command === 'read_local_conversation') {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
if (command === 'chat_with_game_creator_role_agent') {
return {
replyText: mockRoleAgentReply(),
};
}
if (command === 'append_local_conversation_message') {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
if (command === 'open_local_project_directory') {
return undefined;
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('已打开:authorized-game');
const revealButtons = screen.getAllByRole('button', { name: '显示目录' });
fireEvent.click(revealButtons[revealButtons.length - 1]);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('open_local_project_directory', {
projectPath: '/tmp/authorized-game',
});
});
expect(await screen.findByText('已打开项目目录。')).not.toBeNull();
await submitChat('/open-project');
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'open_local_project_directory',
),
).toHaveLength(2);
});
expect(
screen.getAllByText('已打开项目目录。').length,
).toBeGreaterThanOrEqual(2);
});
it('fills an asset registration draft from recent project files without registering immediately', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'read_project_permission_policy') {
return emptyProjectPolicy();
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_local_conversation') {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
if (command === 'read_local_project_file') {
throw new Error('missing trace');
}
if (command === 'list_local_project_files') {
return {
projectPath: String(args?.projectPath ?? ''),
files: [
{
path: 'assets/uploads/hero.png',
kind: 'file',
size: 4,
modifiedAt: 1700000001,
},
],
};
}
if (command === 'register_local_asset') {
throw new Error('should only fill the chat draft');
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
await act(async () => {
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
});
await screen.findByText('已打开:authorized-game');
invoke.mockClear();
fireEvent.click(screen.getByRole('button', { name: '文件' }));
expect(await screen.findByText(/本地项目文件:/)).not.toBeNull();
fireEvent.click(
within(screen.getByLabelText('最近项目文件')).getByRole('button', {
name: '登记资产 assets/uploads/hero.png',
}),
);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByLabelText('创作想法')),
);
expect(await composerText()).toBe(
'/asset-register assets/uploads/hero.png image image/png',
);
expect(
screen.queryByText('asset.register · assets/uploads/hero.png'),
).toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'register_local_asset',
expect.anything(),
);
});
}
export function registerProjectAgentStatusTests() {
it('shows runtime status and recent tasks in the main agent status list', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const runtimeTask = {
schemaVersion: 'game-creator-agent-runtime-task.v1',
agentId: 'design-director',
taskId: 'design-director',
sessionId: 'agent-session-design-director',
runId: 'runtime-design-director-1',
source: 'agent-background-task',
task: '排队补齐世界观拆解',
status: 'pending',
phase: 'queued',
currentAction: '等待当前任务完成',
error: null,
updatedAt: 10,
};
const runtimeState = {
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'design-director',
taskId: 'design-director',
sessionId: 'agent-session-design-director',
runId: 'runtime-design-director-1',
source: 'agent-background-task',
status: 'running',
phase: 'planning',
currentTask: '拆解关卡节奏',
currentGoal: '补齐第一关节奏目标',
currentAction: '整理目标和约束',
waitingOn: 'Agent 输出计划或回复',
loopIteration: 2,
maxLoopIterations: 3,
toolActionBudget: 3,
plan: ['读取项目上下文'],
planSteps: [
{
index: 0,
title: '读取项目上下文',
status: 'active',
detail: '正在整理目标和约束',
updatedAt: 11,
},
],
activePlanStepIndex: 0,
observations: ['已创建本轮 Agent Runtime run。'],
taskQueue: {
total: 2,
pending: 1,
running: 1,
completed: 0,
failed: 0,
latestRunId: 'runtime-design-director-1',
updatedAt: 10,
},
allowedTools: ['conversation.read'],
lastResponse: null,
error: null,
updatedAt: 10,
};
const completedRuntimeState = {
...runtimeState,
status: 'idle',
phase: 'completed',
currentAction: '等待下一轮输入',
waitingOn: '开发者下一轮输入',
nextStep: '等待下一轮输入',
taskQueue: {
total: 2,
pending: 1,
running: 0,
completed: 1,
failed: 0,
latestRunId: 'runtime-design-director-1',
updatedAt: 12,
},
lastResponse: '已补齐第一关节奏目标。',
updatedAt: 12,
};
const completedRuntimeTask = {
...runtimeTask,
status: 'completed',
phase: 'completed',
currentAction: '等待下一轮输入',
updatedAt: 12,
};
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
return [];
}
if (command === 'read_game_creator_agent_runtimes') {
return [
{
state: runtimeState,
sessionPath:
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
eventPath:
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
taskPath:
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
taskQueue: runtimeState.taskQueue,
recentEvents: [],
recentTasks: [runtimeTask],
},
];
}
if (command === 'read_local_project_file') {
throw new Error(
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
);
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
let runtimeUpdateHandler:
| ((event: { payload: Record<string, unknown> }) => void)
| null = null;
const listen = vi.fn(
async (
eventName: string,
handler: (event: { payload: Record<string, unknown> }) => void,
) => {
if (eventName === 'game-creator-agent-progress') {
return () => {};
}
if (eventName === 'game-creator-agent-runtime-update') {
runtimeUpdateHandler = handler;
return () => {
if (runtimeUpdateHandler === handler) {
runtimeUpdateHandler = null;
}
};
}
throw new Error(`unexpected listen ${eventName}`);
},
);
window.__TAURI__ = { core: { invoke }, event: { listen } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('想做什么游戏?');
const agentStatusList = screen.getByLabelText('Agent 状态列表');
await waitFor(() => {
const designCard = within(agentStatusList).getByRole('button', {
name: /拆解创作方向/,
});
expect(designCard.textContent).toContain(
'Runtime:running / planning · Loop 2/3 · 整理目标和约束 · 等待 Agent 输出计划或回复 · 下一步 等待 Agent 输出计划或回复 · run runtime-design-director-1',
);
expect(designCard.textContent).toContain('当前目标:补齐第一关节奏目标');
expect(designCard.textContent).toContain('当前任务:拆解关卡节奏');
expect(designCard.textContent).toContain(
'当前计划步骤:#1 active · 读取项目上下文 · 正在整理目标和约束',
);
expect(designCard.textContent).toContain(
'任务队列:pending 1 · running 1 · waiting 0 · needsInput 0 · cancelled 0 · completed 0 · failed 0 · total 2 · latest runtime-design-director-1',
);
expect(designCard.textContent).toContain(
'最近任务:pending / queued · 排队补齐世界观拆解',
);
});
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'resume_game_creator_agent_runtime_tasks',
),
).toHaveLength(1);
});
await act(async () => {
runtimeUpdateHandler?.({
payload: {
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
runId: 'runtime-design-director-1',
status: 'idle',
phase: 'completed',
runtime: {
state: completedRuntimeState,
sessionPath:
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
eventPath:
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
taskPath:
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
taskQueue: completedRuntimeState.taskQueue,
recentEvents: [],
recentTasks: [completedRuntimeTask],
},
},
});
});
await waitFor(() => {
const designCard = within(agentStatusList).getByRole('button', {
name: /拆解创作方向/,
});
expect(designCard.textContent).toContain(
'Runtime:idle / completed · Loop 2/3 · 等待下一轮输入 · 等待 开发者下一轮输入 · 下一步 等待下一轮输入 · run runtime-design-director-1',
);
expect(designCard.textContent).toContain(
'任务队列:pending 1 · running 0 · waiting 0 · needsInput 0 · cancelled 0 · completed 1 · failed 0 · total 2 · latest runtime-design-director-1',
);
expect(designCard.textContent).toContain(
'最近任务:completed / completed · 排队补齐世界观拆解',
);
});
});
it('lets the developer schedule ready manifest tasks into agent runtimes', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const runtimeTask = {
schemaVersion: 'game-creator-agent-runtime-task.v1',
agentId: 'design-director',
taskId: 'design-director',
sessionId: 'agent-session-design-director',
runId: 'ready-design-director-1',
source: 'agent-ready-task-scheduler',
task: '执行 Ready 任务:拆解创作方向',
status: 'running',
phase: 'planning',
currentAction: '准备读取项目上下文',
error: null,
updatedAt: 20,
};
const runtimeState = {
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'design-director',
taskId: 'design-director',
sessionId: 'agent-session-design-director',
runId: 'ready-design-director-1',
source: 'agent-ready-task-scheduler',
status: 'running',
phase: 'planning',
currentTask: '执行 Ready 任务:拆解创作方向',
currentGoal: '把 ready 任务投递给对应 Agent',
currentAction: '准备读取项目上下文',
waitingOn: 'Agent 输出计划或回复',
loopIteration: 1,
maxLoopIterations: 3,
toolActionBudget: 3,
plan: ['读取项目上下文'],
planSteps: [],
activePlanStepIndex: null,
observations: [],
taskQueue: {
total: 1,
pending: 0,
running: 1,
completed: 0,
failed: 0,
latestRunId: 'ready-design-director-1',
updatedAt: 20,
},
allowedTools: ['conversation.read'],
lastResponse: null,
error: null,
updatedAt: 20,
};
const runtimeResult = {
state: runtimeState,
sessionPath:
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
eventPath:
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
taskPath:
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
taskQueue: runtimeState.taskQueue,
recentEvents: [],
recentTasks: [runtimeTask],
};
let scheduled = false;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_project_permission_policy') {
return emptyProjectPolicy();
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
return [];
}
if (command === 'read_game_creator_agent_runtimes') {
return scheduled ? [runtimeResult] : [];
}
if (command === 'schedule_game_creator_agent_ready_tasks') {
scheduled = true;
return [runtimeResult];
}
if (command === 'append_local_conversation_message') {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'read_local_project_file') {
throw new Error(
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
);
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('想做什么游戏?');
const scheduleButton = await screen.findByRole('button', {
name: '调度 Ready',
});
await waitFor(() => {
expect((scheduleButton as HTMLButtonElement).disabled).toBe(false);
});
fireEvent.click(scheduleButton);
await waitFor(() => {
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'schedule_game_creator_agent_ready_tasks' &&
args?.projectPath === '/tmp/authorized-game' &&
args?.limit === 16,
),
).toBe(true);
});
expect(await screen.findAllByText('已调度 1 个 Ready 任务。')).toHaveLength(
2,
);
const agentStatusList = screen.getByLabelText('Agent 状态列表');
await waitFor(() => {
const designCard = within(agentStatusList).getByRole('button', {
name: /拆解创作方向/,
});
expect(designCard.textContent).toContain('Runtime:running / planning');
expect(designCard.textContent).toContain(
'当前目标:把 ready 任务投递给对应 Agent',
);
expect(designCard.textContent).toContain(
'最近任务:running / planning · 执行 Ready 任务:拆解创作方向',
);
});
});
it('keeps ready task scheduling out of the normal user window', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
return [];
}
if (command === 'read_game_creator_agent_runtimes') {
return [];
}
if (command === 'read_local_project_file') {
throw new Error(
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
);
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('想做什么游戏?');
expect(screen.queryByRole('button', { name: '调度 Ready' })).toBeNull();
expect(
invoke.mock.calls.some(
([command]) => command === 'schedule_game_creator_agent_ready_tasks',
),
).toBe(false);
});
it('ignores stale agent conversation reads after switching agents', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
let releaseOldConversation: (() => void) | null = null;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_project_permission_policy') {
return emptyProjectPolicy();
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
return [];
}
if (command === 'read_game_creator_agent_runtimes') {
return [];
}
if (command === 'read_local_conversation') {
if (args?.agentId === null) {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
if (args?.agentId === 'design-director') {
return await new Promise((resolve) => {
releaseOldConversation = () =>
resolve({
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
agentId: 'design-director',
messages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '旧 Agent 慢速消息',
agentId: 'design-director',
updatedAt: 1,
},
],
});
});
}
return {
path: '/tmp/authorized-game/.agent/conversations/agents/art-director.jsonl',
agentId: args?.agentId,
messages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '新 Agent 历史消息',
agentId: String(args?.agentId ?? ''),
updatedAt: 2,
},
],
};
}
if (command === 'read_local_agent_memory') {
return {
taskId: args?.taskId,
path: `/tmp/authorized-game/memory/agents/${String(
args?.taskId ?? '',
)}.md`,
content:
args?.taskId === 'art-director'
? '新 Agent 私有记忆'
: '旧 Agent 私有记忆',
exists: true,
};
}
if (command === 'read_local_project_file') {
throw new Error(
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
);
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('想做什么游戏?');
const agentStatusList = screen.getByLabelText('Agent 状态列表');
const designAgentButton = within(agentStatusList).getByRole('button', {
name: /拆解创作方向/,
});
await waitFor(() => {
expect((designAgentButton as HTMLButtonElement).disabled).toBe(false);
});
fireEvent.click(designAgentButton);
await waitFor(() => {
expect(releaseOldConversation).not.toBeNull();
});
fireEvent.click(
within(agentStatusList).getByRole('button', { name: /确定视觉方向/ }),
);
expect(await screen.findByText('新 Agent 历史消息')).not.toBeNull();
expect(screen.getByLabelText('Agent 私有记忆').textContent).toContain(
'新 Agent 私有记忆',
);
await act(async () => {
releaseOldConversation?.();
});
expect(screen.queryByText('旧 Agent 慢速消息')).toBeNull();
expect(screen.getByText('新 Agent 历史消息')).not.toBeNull();
expect(screen.getByLabelText('Agent 私有记忆').textContent).not.toContain(
'旧 Agent 私有记忆',
);
});
it('ignores stale agent conversation saves after switching agents', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
let releaseOldSave: (() => void) | null = null;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_project_permission_policy') {
return emptyProjectPolicy();
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
return [];
}
if (command === 'read_game_creator_agent_runtimes') {
return [];
}
if (command === 'read_local_conversation') {
if (args?.agentId === null) {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
if (args?.agentId === 'art-director') {
return {
path: '/tmp/authorized-game/.agent/conversations/agents/art-director.jsonl',
agentId: 'art-director',
messages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '新 Agent 留存消息',
agentId: 'art-director',
updatedAt: 2,
},
],
};
}
return {
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
agentId: 'design-director',
messages: [],
};
}
if (command === 'read_local_agent_memory') {
return {
taskId: args?.taskId,
path: `/tmp/authorized-game/memory/agents/${String(
args?.taskId ?? '',
)}.md`,
content: '',
exists: false,
};
}
if (command === 'append_local_conversation_message') {
const message = args?.message as {
role: 'user' | 'assistant';
content: string;
agentId: string | null;
};
return await new Promise((resolve) => {
releaseOldSave = () =>
resolve({
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
agentId: 'design-director',
messages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: message.role,
content: message.content,
agentId: message.agentId,
updatedAt: 1,
},
],
});
});
}
if (command === 'read_local_project_file') {
throw new Error(
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
);
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('想做什么游戏?');
const agentStatusList = screen.getByLabelText('Agent 状态列表');
const designAgentButton = within(agentStatusList).getByRole('button', {
name: /拆解创作方向/,
});
await waitFor(() => {
expect((designAgentButton as HTMLButtonElement).disabled).toBe(false);
});
fireEvent.click(designAgentButton);
const input = await screen.findByLabelText('Agent 对话内容');
fireEvent.change(input, { target: { value: '旧 Agent 保存回包' } });
fireEvent.submit(input.closest('form') as HTMLFormElement);
await screen.findByText('正在保存用户消息');
await waitFor(() => {
expect(releaseOldSave).not.toBeNull();
});
fireEvent.click(
within(agentStatusList).getByRole('button', { name: /确定视觉方向/ }),
);
expect(await screen.findByText('新 Agent 留存消息')).not.toBeNull();
await act(async () => {
releaseOldSave?.();
});
expect(screen.getByText('新 Agent 留存消息')).not.toBeNull();
expect(screen.queryByText('旧 Agent 保存回包')).toBeNull();
expect(screen.queryByText(/已保存 1 条/)).toBeNull();
});
it('ignores stale agent reads after closing the agent dialog', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
let releaseConversation: (() => void) | null = null;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_project_permission_policy') {
return emptyProjectPolicy();
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
return [];
}
if (command === 'read_game_creator_agent_runtimes') {
return [];
}
if (command === 'read_local_conversation') {
if (args?.agentId === null) {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
return await new Promise((resolve) => {
releaseConversation = () =>
resolve({
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
agentId: 'design-director',
messages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'user',
content: '关闭后不该写入界面状态',
agentId: 'design-director',
updatedAt: 1,
},
],
});
});
}
if (command === 'read_local_agent_memory') {
return {
taskId: args?.taskId,
path: '/tmp/authorized-game/memory/agents/design/director.md',
content: '关闭后不该读取私有记忆',
exists: true,
};
}
if (command === 'read_local_project_file') {
throw new Error(
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
);
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('想做什么游戏?');
await waitFor(() => {
expect(
(
screen.getByRole('button', {
name: '刷新 Agent',
}) as HTMLButtonElement
).disabled,
).toBe(false);
});
fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ }));
await waitFor(() => {
expect(releaseConversation).not.toBeNull();
});
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
expect(screen.queryByLabelText('Agent 对话')).toBeNull();
await act(async () => {
releaseConversation?.();
});
expect(screen.queryByText('关闭后不该写入界面状态')).toBeNull();
expect(screen.queryByText('conversation.read')).toBeNull();
expect(screen.queryByText('memory.agent.read')).toBeNull();
});
it('updates the open agent dialog when agent status is refreshed', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
let runReadCount = 0;
const makeTrace = (withAgentStep: boolean) =>
({
schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
runId: 'run-open-agent-refresh',
commandId: 'game.generate_draft',
status: 'running',
passes: 1,
maxPasses: 3,
toolCallCount: 1,
maxToolCalls: 128,
stopReason: 'running',
goal: '做一个厨房弹幕游戏',
coordination: 'Planner',
steps: withAgentStep
? [
{
pass: 1,
agent: 'Planner',
phase: 'plan',
taskId: 'design-director',
group: 'design',
role: 'Director',
status: 'running',
inputPaths: ['memory/session.md'],
outputPaths: ['.agent/spec.md'],
summary: '刷新后的拆解方向',
toolCalls: [
{
toolId: 'llm.planner.refresh',
status: 'ok',
inputPaths: ['memory/session.md'],
outputPaths: ['.agent/spec.md'],
summary: '刷新后工具调用',
},
...Array.from({ length: 5 }, (_, index) => ({
toolId: `llm.extra.${index + 1}`,
status: 'ok',
inputPaths: [],
outputPaths: [],
summary: `额外工具调用 ${index + 1}`,
})),
],
},
]
: [],
artifacts: [],
taskGraph: {
goal: '做一个厨房弹幕游戏',
readyTaskIds: [],
activeTaskIds: withAgentStep ? ['design-director'] : [],
carriedTaskIds: [],
repairFocus: [],
repairRoutes: [],
tasks: createGameCreationAppSeedTasks(),
},
passPlans: [],
nextStep: 'continue',
error: null,
updatedAt: runReadCount,
}) satisfies GameCreationAgentRunTrace;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_local_conversation') {
return {
path: args?.agentId
? '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl'
: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: args?.agentId ?? null,
messages: [],
};
}
if (command === 'read_local_agent_memory') {
return {
taskId: args?.taskId,
path: '/tmp/authorized-game/memory/agents/design/director.md',
content: '',
exists: false,
};
}
if (command === 'read_project_permission_policy') {
return emptyProjectPolicy();
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
return [];
}
if (command === 'read_game_creator_agent_runtimes') {
return [];
}
if (command === 'read_local_project_file') {
runReadCount += 1;
return {
path: String(args?.relativePath ?? ''),
absolutePath: `${String(args?.projectPath ?? '')}/${String(
args?.relativePath ?? '',
)}`,
content: JSON.stringify(makeTrace(runReadCount > 1)),
};
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
await screen.findByText('已打开:authorized-game');
fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ }));
expect(await screen.findByLabelText('Agent 对话')).not.toBeNull();
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
'暂无最近运行证据',
);
fireEvent.click(screen.getByRole('button', { name: '刷新 Agent' }));
expect(await screen.findByText('刷新后的拆解方向')).not.toBeNull();
await waitFor(() => {
expect(screen.getByLabelText('Agent 对话').textContent).toContain(
'刷新后的拆解方向',
);
});
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
'in: memory/session.md',
);
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
'out: .agent/spec.md',
);
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
'tool: llm.planner.refresh · ok · 刷新后工具调用',
);
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
'tool: llm.planner.refresh · ok · 刷新后工具调用 · in memory/session.md · out .agent/spec.md',
);
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
'还有 1 个工具调用',
);
});
it('confirms before refreshing agents when trace read policy requires it', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const trace: GameCreationAgentRunTrace = {
schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
runId: 'run-agent-refresh-confirm',
commandId: 'game.generate_draft',
status: 'running',
passes: 1,
maxPasses: 3,
toolCallCount: 1,
maxToolCalls: 128,
stopReason: 'running',
goal: '做一个厨房弹幕游戏',
coordination: 'Planner',
steps: [],
artifacts: [],
taskGraph: {
goal: '做一个厨房弹幕游戏',
readyTaskIds: [],
activeTaskIds: [],
carriedTaskIds: [],
repairFocus: [],
repairRoutes: [],
tasks: createGameCreationAppSeedTasks(),
},
passPlans: [],
nextStep: 'continue',
error: null,
updatedAt: 1,
};
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_local_conversation') {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
if (command === 'read_project_permission_policy') {
return {
path: '.agent/policy.json',
policy: {
deniedCommands: [],
confirmCommands: ['agent.trace_read'],
},
};
}
if (command === 'read_local_project_file') {
return {
path: '.agent/run.latest.json',
absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`,
content: JSON.stringify(trace),
};
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
expect(await screen.findByText('想做什么游戏?')).not.toBeNull();
invoke.mockClear();
fireEvent.click(screen.getByRole('button', { name: '刷新 Agent' }));
expect(await screen.findByText('agent.trace_read')).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'read_local_project_file',
expect.anything(),
);
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('read_local_project_file', {
projectPath: '/tmp/authorized-game',
relativePath: '.agent/run.latest.json',
commandId: 'agent.trace_read',
});
});
});
it('cancels agent run trace refresh policy confirmation from the panel', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_local_conversation') {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
if (command === 'read_project_permission_policy') {
return {
path: '.agent/policy.json',
policy: {
deniedCommands: [],
confirmCommands: ['agent.trace_read'],
},
};
}
if (command === 'read_local_project_file') {
throw new Error('should wait for trace confirmation');
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
expect(await screen.findByText('想做什么游戏?')).not.toBeNull();
invoke.mockClear();
fireEvent.click(screen.getByRole('button', { name: '刷新 Agent' }));
const traceReadCommand = await screen.findByText('agent.trace_read');
fireEvent.click(
within(
traceReadCommand.closest('.pending-command') as HTMLElement,
).getByRole('button', { name: '取消' }),
);
expect(
await screen.findByText('run: 已取消读取 Agent trace'),
).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'read_local_project_file',
expect.anything(),
);
});
}