Files
Genarrative/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
T
k88936 7288c6f641
Project CI / Repository checks (push) Successful in 2m59s
Project CI / Frontend tests (push) Successful in 3m29s
Project CI / Backend tests (push) Successful in 8m37s
Project CI / Native shell tests (push) Successful in 20m55s
Fix/修复无限画布图标文字模糊 (#285)
closes #279
closes #284

实现: 从2(暂定)倍放大绘制的画布scale 0.5* 真正的scale

before:
![shotmd-1788579338.jpg](/attachments/9d44ec5e-454c-449b-86bc-b2131fbbc8cc)
![shotmd-1788578910.jpg](/attachments/c9da61a2-b467-43eb-a5de-e5e4d5cb8f70)

after:
![shotmd-1788580930.jpg](/attachments/f1f06e8e-90bf-45e2-a30b-a63ccc308c19)
![shotmd-1788583400.jpg](/attachments/c48ca741-e470-42b3-8030-064e0579f63a)
svg看起来stroke窄了一点点, 可以接受

Reviewed-on: #285
2026-09-09 16:32:43 +08:00

7578 lines
254 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_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,
createGameCreationAppManifest,
createGameCreationAppSeedTasks,
createProjectSupervisorRuntimeHarness,
emptyProjectPolicy,
expect,
fireEvent,
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
type GameCreationAgentRunTrace,
it,
mockRoleAgentReply,
pickProjectFromLauncher,
ProjectDevelopmentView,
projectSupervisorResponseStream,
React,
readFileSync,
render,
renderAppAt,
renderLauncherProjectsAt,
resolve,
screen,
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;
},
};
}
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(),
}),
);
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(
screen
.getByRole('button', { name: '复位资源画布' })
.dispatchEvent(event),
).toBe(false);
});
};
zoomOut(120);
const dependencyViewport = readViewport(artCanvas);
expect(dependencyViewport).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
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: '按依赖' }));
const restoredDependencyCanvas = await screen.findByRole('region', {
name: '美术资源资源画布',
});
await waitFor(() =>
expect(readViewport(restoredDependencyCanvas)).toBe(dependencyViewport),
);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
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: '资源管理' }));
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(),
}),
);
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,
},
});
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(screen.queryByRole('button', { name: /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();
expect(
within(outline).queryByRole('button', { name: /游戏代码/ }),
).toBeNull();
expect(
screen.queryByRole('region', { name: '游戏代码资源画布' }),
).toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /文档/ }));
const dispatchPageWheel = (target: Element, deltaY = 160) => {
const event = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaX: 0,
deltaY,
});
let result = true;
act(() => {
result = target.dispatchEvent(event);
});
expect(result).toBe(false);
};
dispatchPageWheel(
within(outline).getByRole('button', { name: /设计文档/ }),
);
expect(
screen.getByRole('region', { name: '美术资源资源画布' }),
).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
dispatchPageWheel(
screen.getByRole('button', { name: /下一页\s*美术资源/ }),
);
expect(
screen.getByRole('region', { name: '美术资源资源画布' }),
).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
const documentCardButton = screen.getByRole('button', {
name: /打开资源详情:设计文档 section\.md/,
});
dispatchPageWheel(documentCardButton);
expect(
screen.getByRole('region', { name: '美术资源资源画布' }),
).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
const documentWorld = dependencyCanvas.querySelector<HTMLElement>(
'[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 viewportBeforeZoom = fittedViewport;
const zoomWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
ctrlKey: true,
deltaX: 0,
deltaY: -120,
});
let zoomWheelResult = true;
const resetButton = screen.getByRole('button', { name: '复位资源画布' });
act(() => {
zoomWheelResult = resetButton.dispatchEvent(zoomWheel);
});
expect(zoomWheelResult).toBe(false);
expect(readViewport()[2]).toBeGreaterThan(viewportBeforeZoom[2]!);
const documentViewportAfterZoom = readViewport();
fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ }));
expect(
screen.getByRole('region', { name: '美术资源资源画布' }),
).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
await waitFor(() =>
expect(readViewport()).toEqual(documentViewportAfterZoom),
);
expect(
screen.getByRole('region', { name: '设计文档资源画布' }),
).not.toBeNull();
fireEvent.click(
screen.getByRole('button', {
name: /打开资源详情:设计文档 section\.md/,
}),
);
expect(screen.getByRole('dialog', { name: 'section.md' })).not.toBeNull();
dispatchPageWheel(dependencyCanvas);
expect(
screen.getByRole('region', { name: '美术资源资源画布' }),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
dispatchPageWheel(dependencyCanvas);
expect(
screen.getByRole('region', { name: '美术资源资源画布' }),
).not.toBeNull();
dispatchPageWheel(dependencyCanvas);
const zoomWhilePageWheelQueued = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
ctrlKey: true,
deltaX: 0,
deltaY: -120,
clientX: 80,
clientY: 60,
});
act(() => {
expect(
screen
.getByRole('button', { name: '复位资源画布' })
.dispatchEvent(zoomWhilePageWheelQueued),
).toBe(false);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 220));
});
expect(
screen.getByRole('region', { name: '美术资源资源画布' }),
).not.toBeNull();
dispatchPageWheel(dependencyCanvas);
expect(
screen.getByRole('region', { name: '音乐音效资源画布' }),
).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
act(() => {
dependencyCanvas.dispatchEvent(
new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaX: 0,
deltaY: 160,
}),
);
within(outline)
.getByRole('button', { name: /^项目版本/ })
.click();
});
expect(
screen.getByRole('region', { name: '项目版本资源画布' }),
).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
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);
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
const viewportBeforePan = readViewport();
fireEvent.pointerDown(dependencyCanvas, {
button: 0,
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: 0,
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 pageWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaX: 0,
deltaY: 160,
});
let pageWheelResult = true;
act(() => {
pageWheelResult = dependencyCanvas.dispatchEvent(pageWheel);
});
expect(pageWheelResult).toBe(false);
expect(
screen.getByRole('region', { name: '美术资源资源画布' }),
).not.toBeNull();
expect(
screen.getByTestId('resource-dependency-overlay-art'),
).not.toBeNull();
expect(
screen.queryByTestId('resource-dependency-overlay-document'),
).toBeNull();
act(() => {
dependencyCanvas.dispatchEvent(
new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaX: 0,
deltaY: 160,
}),
);
});
fireEvent.pointerDown(dependencyCanvas, {
button: 0,
clientX: 100,
clientY: 100,
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 220));
});
expect(
screen.getByRole('region', { name: '美术资源资源画布' }),
).not.toBeNull();
fireEvent.pointerUp(dependencyCanvas);
act(() => {
dependencyCanvas.dispatchEvent(
new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaX: 0,
deltaY: 160,
}),
);
});
fireEvent.click(within(outline).getByRole('button', { name: /^项目版本/ }));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 220));
});
expect(
screen.getByRole('region', { name: '项目版本资源画布' }),
).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
act(() => {
dependencyCanvas.dispatchEvent(
new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaX: 0,
deltaY: 480,
}),
);
});
await waitFor(
() => {
expect(
screen.getByRole('region', { name: '项目版本资源画布' }),
).not.toBeNull();
},
{ timeout: 2_000 },
);
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 typePageWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaX: 0,
deltaY: 160,
});
act(() => {
typeCanvas.dispatchEvent(typePageWheel);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
});
expect(
screen.getByRole('region', { name: '音乐音效资源画布' }),
).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /^项目版本/ }));
expect(
screen.getByRole('region', { name: '项目版本资源画布' }),
).not.toBeNull();
expect(screen.queryByRole('button', { name: /缩小.*分区/ })).toBeNull();
expect(screen.queryByRole('button', { name: /放大.*内容/ })).toBeNull();
});
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(screen.queryByRole('button', { name: /game\.js/ })).toBeNull();
expect(
screen.queryByRole('region', { name: '游戏代码资源画布' }),
).toBeNull();
});
});
it('renders an imported image as a resource-canvas card after manifest refresh', 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: [
...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',
},
},
],
};
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 outline = screen.getByLabelText('资源栏目大纲');
const showResourcePage = (label: string) => {
fireEvent.click(
within(outline).getByRole('button', { name: new RegExp(label) }),
);
};
showResourcePage('美术资源');
let heroDetailButton = await screen.findByRole('button', {
name: /打开资源详情:美术资源 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');
});
showResourcePage('设计文档');
act(() => observer.triggerVisible());
const documentSummary = await screen.findByText(/这是安全的卡片正文摘要。/);
expect(
documentSummary
.closest('.game-resource-card')
?.querySelector('[data-resource-type="文档"]'),
).not.toBeNull();
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);
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();
showResourcePage('美术资源');
heroDetailButton = await screen.findByRole('button', {
name: /打开资源详情:美术资源 hero\.png/,
});
fireEvent.click(heroDetailButton);
const heroFocus = await screen.findByRole('dialog', { name: 'hero.png' });
expect(within(heroFocus).getByText('assets/hero.png')).not.toBeNull();
expect(within(heroFocus).getByText('Agent 生成')).not.toBeNull();
expect(within(heroFocus).getByText('image/png')).not.toBeNull();
expect(
within(heroFocus).getByRole('button', { name: '生成动画' }).className,
).toContain('game-resource-focus-action');
expect(
within(heroFocus).getByRole('button', { name: '编辑资源' }).className,
).toContain('game-resource-focus-action');
expect(
invoke.mock.calls.filter(
([command]) => command === 'read_local_project_image_preview',
),
).toHaveLength(1);
fireEvent.click(
within(heroFocus).getByRole('button', { name: '收起资源' }),
);
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(screen.getByRole('button', { name: /hero\.png/ })).not.toBeNull();
expect(screen.queryByRole('button', { name: /intro\.mp4/ })).toBeNull();
const typeHeroCard = screen
.getByRole('button', { name: /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 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 = screen
.getByRole('button', { name: /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 waitFor(() =>
expect(screen.getByRole('button', { name: /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);
const versionFocus = screen.getByRole('dialog', { name: '版本 2' });
expect(within(versionFocus).getByText('version-child')).not.toBeNull();
expect(within(versionFocus).getByText('version-root')).not.toBeNull();
expect(within(versionFocus).getByText('Agent 修订')).not.toBeNull();
expect(
within(versionFocus).getByText(
'player → asset-playerhistorical → asset-removed',
),
).not.toBeNull();
expect(screen.queryByText('asset:asset-removed')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
fireEvent.click(
within(screen.getByLabelText('资源栏目大纲')).getByRole('button', {
name: /美术资源/,
}),
);
const playerCard = screen
.getByRole('button', { name: /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 = screen
.getByRole('button', { name: /player\.png/ })
.closest('.game-resource-card');
expect(
dependencyPlayerCard?.classList.contains('is-relation-version-binding'),
).toBe(true);
});
});
it('keeps the resource toolbar and canvas interactive while a non-modal detail card is open', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-details',
'不应显示的项目标题',
);
render(
React.createElement(ProjectDevelopmentView, {
projectName: '不应显示的项目标题',
projectPath: '/tmp/workbench-resource-details',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
agentRuntimeSummaries: [
{
group: 'art',
label: '美术 Agent',
status: 'completed',
statusLabel: '已完成',
currentTask: '本轮工作已完成',
currentAction: null,
waitingOn: null,
completedCount: 4,
totalCount: 4,
},
],
agentResults: [
{
agentId: 'design-foundation',
runId: 'design-result-run',
label: '玩法策划 Agent',
title: '玩法策划 Agent 文本回执',
content: '策划回执正文',
updatedAt: 1,
},
{
agentId: 'art-asset-plan',
runId: 'art-result-run',
label: '美术资源计划 Agent',
title: '美术资源计划 Agent 文本回执',
content:
'# 美术计划\n\n- 仅有美术计划\n- 没有图片文件\n\n**等待实际素材生成。**',
updatedAt: 2,
},
],
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
expect(screen.queryByText('不应显示的项目标题')).toBeNull();
expect(screen.queryByRole('button', { name: '回首页' })).toBeNull();
expect(screen.queryByRole('button', { name: '项目组' })).toBeNull();
const resourceSortControl = screen.getByRole('group', {
name: '资源排列方式',
});
expect(
Array.from(resourceSortControl.children).map((child) => child.tagName),
).toEqual(['BUTTON', 'BUTTON']);
expect(
within(resourceSortControl)
.getAllByRole('button')
.map((button) => button.textContent?.trim()),
).toEqual(['依赖', '类型']);
expect(screen.getByText('仅完成计划')).not.toBeNull();
expect(
screen.getByText('美术资源计划已完成,尚未生成或登记图片'),
).not.toBeNull();
for (const label of [
'生成视频',
'生成音效',
'生成背景音乐',
'新增 UI 设计',
]) {
expect(screen.queryByRole('button', { name: label })).toBeNull();
}
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
const searchInput = screen.getByLabelText(
'搜索项目资源',
) as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: '美术资源计划' } });
const resourceCanvas = screen.getByLabelText(
'资源类型视图',
) as HTMLDivElement;
resourceCanvas.scrollLeft = 48;
resourceCanvas.scrollTop = 36;
const artReceiptButton = screen.getByRole('button', {
name: /打开资源详情:设计文档 美术资源计划 Agent 文本回执/,
});
const artReceiptCard = artReceiptButton.closest('.game-resource-card');
expect(artReceiptCard).not.toBeNull();
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
expect(styles).toMatch(
/\.game-resource-card-open\s*\{[^}]*cursor:\s*pointer[^}]*touch-action:\s*manipulation/s,
);
expect(styles).toMatch(
/\.game-resource-card-media-control\s*\{[^}]*transform:\s*scale\(var\(--genarrative-image-canvas-inverse-scale,\s*1\)\)[^}]*transform-origin:\s*bottom\s+right/s,
);
expect(styles).toMatch(
/\.game-resource-canvas\s*\{[^}]*user-select:\s*none/s,
);
expect(styles).toMatch(/\.game-resource-card\.is-dragging/);
fireEvent.click(artReceiptButton);
const workbenchStage = screen.getByLabelText('项目主视窗');
expect(workbenchStage.getAttribute('data-resource-view-state')).toBe(
'resources.focused.document',
);
expect(screen.getByLabelText('搜索项目资源')).not.toBeNull();
for (const label of [
'生成视频',
'生成音效',
'生成背景音乐',
'新增 UI 设计',
]) {
expect(screen.queryByRole('button', { name: label })).toBeNull();
}
expect(screen.getByRole('button', { name: '按依赖' })).not.toBeNull();
expect(
screen
.getByRole('button', { name: '按类型' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(screen.getByLabelText('资源类型视图')).not.toBeNull();
expect(document.querySelector('.game-resource-focus-backdrop')).toBeNull();
expect(screen.getByLabelText('陶泥儿 Agent 对话')).not.toBeNull();
expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull();
expect(
workbenchStage
.querySelector('.game-workbench-toolbar')
?.hasAttribute('inert'),
).toBe(false);
expect(screen.getByLabelText('资源类型视图').hasAttribute('inert')).toBe(
false,
);
const receiptFocus = screen.getByRole('dialog', {
name: '美术资源计划 Agent 文本回执',
});
expect(receiptFocus.hasAttribute('aria-modal')).toBe(false);
expect(
await within(receiptFocus).findByRole('heading', { name: '美术计划' }),
).not.toBeNull();
expect(within(receiptFocus).getByText('仅有美术计划')).not.toBeNull();
expect(within(receiptFocus).getByText('没有图片文件')).not.toBeNull();
expect(within(receiptFocus).getByText('等待实际素材生成。')).not.toBeNull();
expect(
receiptFocus.querySelector('.game-resource-focus-body'),
).not.toBeNull();
expect(receiptFocus.querySelector('ul')).not.toBeNull();
expect(receiptFocus.querySelector('strong')).not.toBeNull();
expect(receiptFocus.closest('.game-workbench-stage')).toBe(workbenchStage);
expect(receiptFocus.closest('.game-resource-focus-layer')).toBeNull();
expect(styles).toMatch(
/\.game-resource-focus\s*\{(?=[^}]*position:\s*absolute)(?=[^}]*top:\s*50%)(?=[^}]*left:\s*50%)(?=[^}]*width:\s*min\(560px,\s*calc\(100%\s*-\s*32px\)\))(?=[^}]*max-height:\s*calc\(100%\s*-\s*32px\))(?=[^}]*transform:\s*translate\(-50%,\s*-50%\))[^}]*\}/s,
);
expect(styles).not.toMatch(
/\.game-resource-focus\s*\{[^}]*(?:position:\s*fixed|right:)/s,
);
expect(styles).not.toMatch(
/\.game-resource-focus-titlebar\s*\{[^}]*cursor:/s,
);
expect(styles).not.toMatch(/\.game-resource-focus-backdrop/);
fireEvent.click(
within(receiptFocus).getByRole('button', { name: '收起资源' }),
);
expect(workbenchStage.getAttribute('data-resource-view-state')).toBe(
'resources.list',
);
expect(
(screen.getByLabelText('搜索项目资源') as HTMLInputElement).value,
).toBe('美术资源计划');
expect(
screen
.getByRole('button', { name: '按类型' })
.getAttribute('aria-pressed'),
).toBe('true');
const restoredCanvas = screen.getByLabelText(
'资源类型视图',
) as HTMLDivElement;
expect(restoredCanvas.scrollLeft).toBe(48);
expect(restoredCanvas.scrollTop).toBe(36);
const restoredCard = screen.getByRole('button', {
name: /打开资源详情:设计文档 美术资源计划 Agent 文本回执/,
});
expect(restoredCard?.getAttribute('aria-pressed')).toBe('true');
expect(
screen
.getByRole('button', { name: '按类型' })
.getAttribute('aria-pressed'),
).toBe('true');
fireEvent.click(restoredCard);
expect(
screen.getByRole('dialog', {
name: '美术资源计划 Agent 文本回执',
}),
).not.toBeNull();
fireEvent.keyDown(window, { key: 'Escape' });
expect(
screen.queryByRole('dialog', {
name: '美术资源计划 Agent 文本回执',
}),
).toBeNull();
expect(
screen
.getByRole('button', { name: '按类型' })
.getAttribute('aria-pressed'),
).toBe('true');
});
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(),
}),
);
fireEvent.click(await screen.findByRole('button', { name: /design\.md/ }));
const documentFocus = await screen.findByRole('dialog', {
name: 'design.md',
});
expect(
within(documentFocus).getByRole('heading', { name: '本地设计文档' }),
).not.toBeNull();
expect(documentFocus.querySelector('script')).toBeNull();
expect(documentFocus.querySelector('a')).toBeNull();
expect(within(documentFocus).getByText('图片:远程图片')).not.toBeNull();
expect(documentFocus.hasAttribute('aria-modal')).toBe(false);
fireEvent.click(
within(screen.getByLabelText('资源栏目大纲')).getByRole('button', {
name: /美术资源/,
}),
);
fireEvent.click(screen.getByRole('button', { name: /icon\.svg/ }));
expect(screen.queryByRole('dialog', { name: 'design.md' })).toBeNull();
const artDetails = await screen.findByRole('dialog', { name: 'icon.svg' });
expect(within(artDetails).getByText('assets/icon.svg')).not.toBeNull();
expect(within(artDetails).getByText('image/svg+xml')).not.toBeNull();
expect(within(artDetails).getByText('依赖层级')).not.toBeNull();
expect(
within(artDetails).getByRole('img', { name: 'icon.svg 预览' }),
).not.toBeNull();
fireEvent.click(
within(artDetails).getByRole('button', { name: '收起资源' }),
);
fireEvent.click(
screen.getByRole('button', {
name: '打开资源详情:美术资源 intro.mp4',
}),
);
const videoDetails = await screen.findByRole('dialog', {
name: 'intro.mp4',
});
expect(within(videoDetails).getByText('assets/intro.mp4')).not.toBeNull();
expect(within(videoDetails).getByText('video/mp4')).not.toBeNull();
expect(
within(videoDetails).getByLabelText('intro.mp4 视频预览'),
).not.toBeNull();
fireEvent.click(
within(videoDetails).getByRole('button', { name: '收起资源' }),
);
fireEvent.click(
within(screen.getByLabelText('资源栏目大纲')).getByRole('button', {
name: /音乐音效/,
}),
);
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);
const audioDetails = screen.getByRole('dialog', { name: 'bgm.mp3' });
fireEvent.click(
within(audioDetails).getByRole('button', { name: '播放 bgm.mp3' }),
);
const audio = (await screen.findByLabelText(
'bgm.mp3 音频播放器',
)) as HTMLAudioElement;
expect(audio.controls).toBe(true);
expect(audio.getAttribute('src')).toBe('blob:mock-attachment-preview');
expect(screen.getByText('载入后显示')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith(
'read_local_project_media_preview',
expect.objectContaining({ category: 'audio' }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: 'bgm.mp3' })).getByRole(
'button',
{ name: '收起资源' },
),
);
fireEvent.click(
within(screen.getByLabelText('资源栏目大纲')).getByRole('button', {
name: /文档/,
}),
);
fireEvent.click(screen.getByRole('button', { name: /blocked\.md/ }));
expect((await screen.findByRole('alert')).textContent).toBe(
'当前项目策略要求先确认读取文档,确认后请关闭详情并重试',
);
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,
}),
);
fireEvent.click(
await screen.findByRole('button', {
name: '打开资源详情:音乐音效 focus.mp3',
}),
);
const audioDetails = await screen.findByRole('dialog', {
name: 'focus.mp3',
});
fireEvent.click(
within(audioDetails).getByRole('button', { name: '播放 focus.mp3' }),
);
const audio = (await within(audioDetails).findByLabelText(
'focus.mp3 音频播放器',
)) as HTMLAudioElement;
audio.focus();
expect(document.activeElement).toBe(audio);
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(document.activeElement).toBe(audio);
expect(screen.getByRole('dialog', { name: 'focus.mp3' })).not.toBe(
document.activeElement,
);
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: { ...updatedManifest, assets: [] },
}),
);
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: 'focus.mp3' })).toBeNull();
expect(document.activeElement).toBe(
screen.getByLabelText('搜索项目资源'),
);
});
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(),
}),
);
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 = screen.getByRole('button', {
name: /spec-source\.json/,
});
const targetCard = screen.getByRole('button', {
name: /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')!);
const targetFocus = screen.getByRole('dialog', {
name: /ui-dependency\.json/u,
});
fireEvent.click(
within(targetFocus).getByRole('button', { name: '收起资源' }),
);
overlay = await screen.findByTestId('resource-dependency-overlay-document');
sourceCard = screen.getByRole('button', { name: /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 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]));
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));
const cards = [0, 1, 2].map((depth) =>
screen
.getByRole('button', {
name: new RegExp(`truncated-depth-${depth}\\.json`, 'u'),
})
.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();
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 = card?.closest<HTMLElement>(
'[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('dialog', { 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!);
expect(
screen.getByRole('dialog', { name: '布局持久化回执' }),
).not.toBeNull();
cleanup();
renderWorkbench();
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, '项目总控'),
}),
);
const cardButton = await screen.findByRole('button', {
name: '打开资源详情:美术资源 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('dialog', { name: 'clickable.png' }),
).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(),
}),
);
const cardButton = await screen.findByRole('button', {
name: '打开资源详情:美术资源 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(
screen.getByRole('dialog', { name: 'cancelable.png' }),
).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 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();
expect(
screen.getByRole('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*!focusedResource\s*&&\s*!assetCanvasRoute\s*&&\s*!resourceEditorRoute\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('invalidates only the committed asset preview after an in-place refine', () => {
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(
/const committedResourceId = `asset:\$\{notification\.assetId\}`;[\s\S]*?next\.set\(committedResourceId, notification\.commitId\)/,
);
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: '按类型' }));
expect(screen.getByLabelText('资源类型视图')).not.toBeNull();
fireEvent.change(screen.getByLabelText('搜索项目资源'), {
target: { value: 'hero.png' },
});
fireEvent.click(screen.getByRole('button', { name: /hero\.png/ }));
const heroDetails = screen.getByRole('dialog', { name: 'hero.png' });
expect(within(heroDetails).getByText('assets/hero.png')).not.toBeNull();
expect(within(heroDetails).getByText('image/png')).not.toBeNull();
expect(within(heroDetails).getByText('引用上游')).not.toBeNull();
expect(heroDetails.querySelector('img')).toBeNull();
expect(screen.queryByRole('img', { name: 'hero.png 图片预览' })).toBeNull();
});
it('marks an unvalidated UI prototype as a candidate image', () => {
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(),
}),
);
const candidate = screen.getByRole('button', {
name: /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(composerInput).toHaveProperty('value', '/canvas ');
fireEvent.click(screen.getByRole('button', { name: '灵感草稿' }));
expect(composerInput).toHaveProperty(
'value',
'像素风厨房弹幕小游戏:玩家用方向键躲避飞来的食材,收集调料加分,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).getByPlaceholderText(
'告诉陶泥儿接下来要做什么',
),
).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('陶泥儿对话内容');
fireEvent.change(input, {
target: { value: '从旧任务状态继续生成,但使用 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();
fireEvent.change(within(surface).getByLabelText('项目总控对话内容'), {
target: { value: '继续完成可玩原型' },
});
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('项目总控消息');
fireEvent.change(within(firstSurface).getByLabelText('项目总控对话内容'), {
target: { value: '这条草稿还没有发送' },
});
cleanup();
renderSupervisorChat();
const restoredInput = await screen.findByLabelText('项目总控对话内容');
expect(restoredInput).toHaveProperty('value', '这条草稿还没有发送');
});
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(
(
within(surface).getByLabelText(
'项目总控对话内容',
) as HTMLTextAreaElement
).disabled,
).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);
},
);
fireEvent.change(screen.getByLabelText('陶泥儿对话内容'), {
target: { value: '先完成正式客户端玩法拆解' },
});
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);
});
fireEvent.change(screen.getByLabelText('陶泥儿对话内容'), {
target: { value: '补充:优先复用现有素材' },
});
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('陶泥儿对话内容');
fireEvent.change(composer, { target: { value: '修改玩家移动脚本' } });
expect(fireEvent.keyDown(composer, { key: 'Enter', shiftKey: true })).toBe(
true,
);
expect(
invoke.mock.calls.some(
([command]) => command === 'chat_with_game_creator_direct_codex',
),
).toBe(false);
expect(
fireEvent.keyDown(composer, { key: 'Enter', isComposing: true }),
).toBe(true);
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',
);
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();
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(screen.getByLabelText('创作想法')).toHaveProperty(
'value',
'/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(
'Runtimerunning / 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(
'Runtimeidle / 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('Runtimerunning / 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(),
);
});
}