7288c6f641
closes #279 closes #284 实现: 从2(暂定)倍放大绘制的画布scale 0.5* 真正的scale before:   after:   svg看起来stroke窄了一点点, 可以接受 Reviewed-on: #285
7578 lines
254 KiB
TypeScript
7578 lines
254 KiB
TypeScript
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-player;historical → 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\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(
|
||
'Runtime:running / planning · Loop 2/3 · 整理目标和约束 · 等待 Agent 输出计划或回复 · 下一步 等待 Agent 输出计划或回复 · run runtime-design-director-1',
|
||
);
|
||
expect(designCard.textContent).toContain('当前目标:补齐第一关节奏目标');
|
||
expect(designCard.textContent).toContain('当前任务:拆解关卡节奏');
|
||
expect(designCard.textContent).toContain(
|
||
'当前计划步骤:#1 active · 读取项目上下文 · 正在整理目标和约束',
|
||
);
|
||
expect(designCard.textContent).toContain(
|
||
'任务队列:pending 1 · running 1 · waiting 0 · needsInput 0 · cancelled 0 · completed 0 · failed 0 · total 2 · latest runtime-design-director-1',
|
||
);
|
||
expect(designCard.textContent).toContain(
|
||
'最近任务:pending / queued · 排队补齐世界观拆解',
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'resume_game_creator_agent_runtime_tasks',
|
||
),
|
||
).toHaveLength(1);
|
||
});
|
||
|
||
await act(async () => {
|
||
runtimeUpdateHandler?.({
|
||
payload: {
|
||
projectPath: '/tmp/authorized-game',
|
||
agentId: 'design-director',
|
||
runId: 'runtime-design-director-1',
|
||
status: 'idle',
|
||
phase: 'completed',
|
||
runtime: {
|
||
state: completedRuntimeState,
|
||
sessionPath:
|
||
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
|
||
eventPath:
|
||
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
|
||
taskPath:
|
||
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
|
||
taskQueue: completedRuntimeState.taskQueue,
|
||
recentEvents: [],
|
||
recentTasks: [completedRuntimeTask],
|
||
},
|
||
},
|
||
});
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const designCard = within(agentStatusList).getByRole('button', {
|
||
name: /拆解创作方向/,
|
||
});
|
||
expect(designCard.textContent).toContain(
|
||
'Runtime:idle / completed · Loop 2/3 · 等待下一轮输入 · 等待 开发者下一轮输入 · 下一步 等待下一轮输入 · run runtime-design-director-1',
|
||
);
|
||
expect(designCard.textContent).toContain(
|
||
'任务队列:pending 1 · running 0 · waiting 0 · needsInput 0 · cancelled 0 · completed 1 · failed 0 · total 2 · latest runtime-design-director-1',
|
||
);
|
||
expect(designCard.textContent).toContain(
|
||
'最近任务:completed / completed · 排队补齐世界观拆解',
|
||
);
|
||
});
|
||
});
|
||
|
||
it('lets the developer schedule ready manifest tasks into agent runtimes', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
const runtimeTask = {
|
||
schemaVersion: 'game-creator-agent-runtime-task.v1',
|
||
agentId: 'design-director',
|
||
taskId: 'design-director',
|
||
sessionId: 'agent-session-design-director',
|
||
runId: 'ready-design-director-1',
|
||
source: 'agent-ready-task-scheduler',
|
||
task: '执行 Ready 任务:拆解创作方向',
|
||
status: 'running',
|
||
phase: 'planning',
|
||
currentAction: '准备读取项目上下文',
|
||
error: null,
|
||
updatedAt: 20,
|
||
};
|
||
const runtimeState = {
|
||
schemaVersion: 'game-creator-agent-runtime.v1',
|
||
agentId: 'design-director',
|
||
taskId: 'design-director',
|
||
sessionId: 'agent-session-design-director',
|
||
runId: 'ready-design-director-1',
|
||
source: 'agent-ready-task-scheduler',
|
||
status: 'running',
|
||
phase: 'planning',
|
||
currentTask: '执行 Ready 任务:拆解创作方向',
|
||
currentGoal: '把 ready 任务投递给对应 Agent',
|
||
currentAction: '准备读取项目上下文',
|
||
waitingOn: 'Agent 输出计划或回复',
|
||
loopIteration: 1,
|
||
maxLoopIterations: 3,
|
||
toolActionBudget: 3,
|
||
plan: ['读取项目上下文'],
|
||
planSteps: [],
|
||
activePlanStepIndex: null,
|
||
observations: [],
|
||
taskQueue: {
|
||
total: 1,
|
||
pending: 0,
|
||
running: 1,
|
||
completed: 0,
|
||
failed: 0,
|
||
latestRunId: 'ready-design-director-1',
|
||
updatedAt: 20,
|
||
},
|
||
allowedTools: ['conversation.read'],
|
||
lastResponse: null,
|
||
error: null,
|
||
updatedAt: 20,
|
||
};
|
||
const runtimeResult = {
|
||
state: runtimeState,
|
||
sessionPath:
|
||
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
|
||
eventPath:
|
||
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
|
||
taskPath:
|
||
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
|
||
taskQueue: runtimeState.taskQueue,
|
||
recentEvents: [],
|
||
recentTasks: [runtimeTask],
|
||
};
|
||
let scheduled = false;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'append_local_permission_log') {
|
||
return {};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'read_project_permission_policy') {
|
||
return emptyProjectPolicy();
|
||
}
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
if (command === 'read_game_creator_agent_runtimes') {
|
||
return scheduled ? [runtimeResult] : [];
|
||
}
|
||
if (command === 'schedule_game_creator_agent_ready_tasks') {
|
||
scheduled = true;
|
||
return [runtimeResult];
|
||
}
|
||
if (command === 'append_local_conversation_message') {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
if (command === 'read_local_project_file') {
|
||
throw new Error(
|
||
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
|
||
);
|
||
}
|
||
if (command === 'list_local_project_files') {
|
||
return { projectPath: String(args?.projectPath ?? ''), files: [] };
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
await screen.findByText('想做什么游戏?');
|
||
const scheduleButton = await screen.findByRole('button', {
|
||
name: '调度 Ready',
|
||
});
|
||
await waitFor(() => {
|
||
expect((scheduleButton as HTMLButtonElement).disabled).toBe(false);
|
||
});
|
||
|
||
fireEvent.click(scheduleButton);
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command, args]) =>
|
||
command === 'schedule_game_creator_agent_ready_tasks' &&
|
||
args?.projectPath === '/tmp/authorized-game' &&
|
||
args?.limit === 16,
|
||
),
|
||
).toBe(true);
|
||
});
|
||
expect(await screen.findAllByText('已调度 1 个 Ready 任务。')).toHaveLength(
|
||
2,
|
||
);
|
||
const agentStatusList = screen.getByLabelText('Agent 状态列表');
|
||
await waitFor(() => {
|
||
const designCard = within(agentStatusList).getByRole('button', {
|
||
name: /拆解创作方向/,
|
||
});
|
||
expect(designCard.textContent).toContain('Runtime:running / planning');
|
||
expect(designCard.textContent).toContain(
|
||
'当前目标:把 ready 任务投递给对应 Agent',
|
||
);
|
||
expect(designCard.textContent).toContain(
|
||
'最近任务:running / planning · 执行 Ready 任务:拆解创作方向',
|
||
);
|
||
});
|
||
});
|
||
|
||
it('keeps ready task scheduling out of the normal user window', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'append_local_permission_log') {
|
||
return {};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
if (command === 'read_game_creator_agent_runtimes') {
|
||
return [];
|
||
}
|
||
if (command === 'read_local_project_file') {
|
||
throw new Error(
|
||
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
|
||
);
|
||
}
|
||
if (command === 'list_local_project_files') {
|
||
return { projectPath: String(args?.projectPath ?? ''), files: [] };
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
await screen.findByText('想做什么游戏?');
|
||
|
||
expect(screen.queryByRole('button', { name: '调度 Ready' })).toBeNull();
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command]) => command === 'schedule_game_creator_agent_ready_tasks',
|
||
),
|
||
).toBe(false);
|
||
});
|
||
|
||
it('ignores stale agent conversation reads after switching agents', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
let releaseOldConversation: (() => void) | null = null;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'append_local_permission_log') {
|
||
return {};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'read_project_permission_policy') {
|
||
return emptyProjectPolicy();
|
||
}
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
if (command === 'read_game_creator_agent_runtimes') {
|
||
return [];
|
||
}
|
||
if (command === 'read_local_conversation') {
|
||
if (args?.agentId === null) {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (args?.agentId === 'design-director') {
|
||
return await new Promise((resolve) => {
|
||
releaseOldConversation = () =>
|
||
resolve({
|
||
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
|
||
agentId: 'design-director',
|
||
messages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'user',
|
||
content: '旧 Agent 慢速消息',
|
||
agentId: 'design-director',
|
||
updatedAt: 1,
|
||
},
|
||
],
|
||
});
|
||
});
|
||
}
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/agents/art-director.jsonl',
|
||
agentId: args?.agentId,
|
||
messages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'user',
|
||
content: '新 Agent 历史消息',
|
||
agentId: String(args?.agentId ?? ''),
|
||
updatedAt: 2,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
if (command === 'read_local_agent_memory') {
|
||
return {
|
||
taskId: args?.taskId,
|
||
path: `/tmp/authorized-game/memory/agents/${String(
|
||
args?.taskId ?? '',
|
||
)}.md`,
|
||
content:
|
||
args?.taskId === 'art-director'
|
||
? '新 Agent 私有记忆'
|
||
: '旧 Agent 私有记忆',
|
||
exists: true,
|
||
};
|
||
}
|
||
if (command === 'read_local_project_file') {
|
||
throw new Error(
|
||
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
|
||
);
|
||
}
|
||
if (command === 'list_local_project_files') {
|
||
return { projectPath: String(args?.projectPath ?? ''), files: [] };
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
await screen.findByText('想做什么游戏?');
|
||
const agentStatusList = screen.getByLabelText('Agent 状态列表');
|
||
const designAgentButton = within(agentStatusList).getByRole('button', {
|
||
name: /拆解创作方向/,
|
||
});
|
||
await waitFor(() => {
|
||
expect((designAgentButton as HTMLButtonElement).disabled).toBe(false);
|
||
});
|
||
fireEvent.click(designAgentButton);
|
||
await waitFor(() => {
|
||
expect(releaseOldConversation).not.toBeNull();
|
||
});
|
||
fireEvent.click(
|
||
within(agentStatusList).getByRole('button', { name: /确定视觉方向/ }),
|
||
);
|
||
|
||
expect(await screen.findByText('新 Agent 历史消息')).not.toBeNull();
|
||
expect(screen.getByLabelText('Agent 私有记忆').textContent).toContain(
|
||
'新 Agent 私有记忆',
|
||
);
|
||
await act(async () => {
|
||
releaseOldConversation?.();
|
||
});
|
||
|
||
expect(screen.queryByText('旧 Agent 慢速消息')).toBeNull();
|
||
expect(screen.getByText('新 Agent 历史消息')).not.toBeNull();
|
||
expect(screen.getByLabelText('Agent 私有记忆').textContent).not.toContain(
|
||
'旧 Agent 私有记忆',
|
||
);
|
||
});
|
||
|
||
it('ignores stale agent conversation saves after switching agents', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
let releaseOldSave: (() => void) | null = null;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'append_local_permission_log') {
|
||
return {};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'read_project_permission_policy') {
|
||
return emptyProjectPolicy();
|
||
}
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
if (command === 'read_game_creator_agent_runtimes') {
|
||
return [];
|
||
}
|
||
if (command === 'read_local_conversation') {
|
||
if (args?.agentId === null) {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (args?.agentId === 'art-director') {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/agents/art-director.jsonl',
|
||
agentId: 'art-director',
|
||
messages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'user',
|
||
content: '新 Agent 留存消息',
|
||
agentId: 'art-director',
|
||
updatedAt: 2,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
|
||
agentId: 'design-director',
|
||
messages: [],
|
||
};
|
||
}
|
||
if (command === 'read_local_agent_memory') {
|
||
return {
|
||
taskId: args?.taskId,
|
||
path: `/tmp/authorized-game/memory/agents/${String(
|
||
args?.taskId ?? '',
|
||
)}.md`,
|
||
content: '',
|
||
exists: false,
|
||
};
|
||
}
|
||
if (command === 'append_local_conversation_message') {
|
||
const message = args?.message as {
|
||
role: 'user' | 'assistant';
|
||
content: string;
|
||
agentId: string | null;
|
||
};
|
||
return await new Promise((resolve) => {
|
||
releaseOldSave = () =>
|
||
resolve({
|
||
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
|
||
agentId: 'design-director',
|
||
messages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: message.role,
|
||
content: message.content,
|
||
agentId: message.agentId,
|
||
updatedAt: 1,
|
||
},
|
||
],
|
||
});
|
||
});
|
||
}
|
||
if (command === 'read_local_project_file') {
|
||
throw new Error(
|
||
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
|
||
);
|
||
}
|
||
if (command === 'list_local_project_files') {
|
||
return { projectPath: String(args?.projectPath ?? ''), files: [] };
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
await screen.findByText('想做什么游戏?');
|
||
const agentStatusList = screen.getByLabelText('Agent 状态列表');
|
||
const designAgentButton = within(agentStatusList).getByRole('button', {
|
||
name: /拆解创作方向/,
|
||
});
|
||
await waitFor(() => {
|
||
expect((designAgentButton as HTMLButtonElement).disabled).toBe(false);
|
||
});
|
||
fireEvent.click(designAgentButton);
|
||
const input = await screen.findByLabelText('Agent 对话内容');
|
||
fireEvent.change(input, { target: { value: '旧 Agent 保存回包' } });
|
||
fireEvent.submit(input.closest('form') as HTMLFormElement);
|
||
await screen.findByText('正在保存用户消息');
|
||
await waitFor(() => {
|
||
expect(releaseOldSave).not.toBeNull();
|
||
});
|
||
fireEvent.click(
|
||
within(agentStatusList).getByRole('button', { name: /确定视觉方向/ }),
|
||
);
|
||
|
||
expect(await screen.findByText('新 Agent 留存消息')).not.toBeNull();
|
||
await act(async () => {
|
||
releaseOldSave?.();
|
||
});
|
||
|
||
expect(screen.getByText('新 Agent 留存消息')).not.toBeNull();
|
||
expect(screen.queryByText('旧 Agent 保存回包')).toBeNull();
|
||
expect(screen.queryByText(/已保存 1 条/)).toBeNull();
|
||
});
|
||
|
||
it('ignores stale agent reads after closing the agent dialog', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
let releaseConversation: (() => void) | null = null;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'append_local_permission_log') {
|
||
return {};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'read_project_permission_policy') {
|
||
return emptyProjectPolicy();
|
||
}
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
if (command === 'read_game_creator_agent_runtimes') {
|
||
return [];
|
||
}
|
||
if (command === 'read_local_conversation') {
|
||
if (args?.agentId === null) {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
return await new Promise((resolve) => {
|
||
releaseConversation = () =>
|
||
resolve({
|
||
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
|
||
agentId: 'design-director',
|
||
messages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'user',
|
||
content: '关闭后不该写入界面状态',
|
||
agentId: 'design-director',
|
||
updatedAt: 1,
|
||
},
|
||
],
|
||
});
|
||
});
|
||
}
|
||
if (command === 'read_local_agent_memory') {
|
||
return {
|
||
taskId: args?.taskId,
|
||
path: '/tmp/authorized-game/memory/agents/design/director.md',
|
||
content: '关闭后不该读取私有记忆',
|
||
exists: true,
|
||
};
|
||
}
|
||
if (command === 'read_local_project_file') {
|
||
throw new Error(
|
||
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
|
||
);
|
||
}
|
||
if (command === 'list_local_project_files') {
|
||
return { projectPath: String(args?.projectPath ?? ''), files: [] };
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
await screen.findByText('想做什么游戏?');
|
||
await waitFor(() => {
|
||
expect(
|
||
(
|
||
screen.getByRole('button', {
|
||
name: '刷新 Agent',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(false);
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ }));
|
||
await waitFor(() => {
|
||
expect(releaseConversation).not.toBeNull();
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
|
||
expect(screen.queryByLabelText('Agent 对话')).toBeNull();
|
||
|
||
await act(async () => {
|
||
releaseConversation?.();
|
||
});
|
||
|
||
expect(screen.queryByText('关闭后不该写入界面状态')).toBeNull();
|
||
expect(screen.queryByText('conversation.read')).toBeNull();
|
||
expect(screen.queryByText('memory.agent.read')).toBeNull();
|
||
});
|
||
|
||
it('updates the open agent dialog when agent status is refreshed', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
let runReadCount = 0;
|
||
const makeTrace = (withAgentStep: boolean) =>
|
||
({
|
||
schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
|
||
runId: 'run-open-agent-refresh',
|
||
commandId: 'game.generate_draft',
|
||
status: 'running',
|
||
passes: 1,
|
||
maxPasses: 3,
|
||
toolCallCount: 1,
|
||
maxToolCalls: 128,
|
||
stopReason: 'running',
|
||
goal: '做一个厨房弹幕游戏',
|
||
coordination: 'Planner',
|
||
steps: withAgentStep
|
||
? [
|
||
{
|
||
pass: 1,
|
||
agent: 'Planner',
|
||
phase: 'plan',
|
||
taskId: 'design-director',
|
||
group: 'design',
|
||
role: 'Director',
|
||
status: 'running',
|
||
inputPaths: ['memory/session.md'],
|
||
outputPaths: ['.agent/spec.md'],
|
||
summary: '刷新后的拆解方向',
|
||
toolCalls: [
|
||
{
|
||
toolId: 'llm.planner.refresh',
|
||
status: 'ok',
|
||
inputPaths: ['memory/session.md'],
|
||
outputPaths: ['.agent/spec.md'],
|
||
summary: '刷新后工具调用',
|
||
},
|
||
...Array.from({ length: 5 }, (_, index) => ({
|
||
toolId: `llm.extra.${index + 1}`,
|
||
status: 'ok',
|
||
inputPaths: [],
|
||
outputPaths: [],
|
||
summary: `额外工具调用 ${index + 1}`,
|
||
})),
|
||
],
|
||
},
|
||
]
|
||
: [],
|
||
artifacts: [],
|
||
taskGraph: {
|
||
goal: '做一个厨房弹幕游戏',
|
||
readyTaskIds: [],
|
||
activeTaskIds: withAgentStep ? ['design-director'] : [],
|
||
carriedTaskIds: [],
|
||
repairFocus: [],
|
||
repairRoutes: [],
|
||
tasks: createGameCreationAppSeedTasks(),
|
||
},
|
||
passPlans: [],
|
||
nextStep: 'continue',
|
||
error: null,
|
||
updatedAt: runReadCount,
|
||
}) satisfies GameCreationAgentRunTrace;
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'append_local_permission_log') {
|
||
return {};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'read_local_conversation') {
|
||
return {
|
||
path: args?.agentId
|
||
? '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl'
|
||
: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: args?.agentId ?? null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (command === 'read_local_agent_memory') {
|
||
return {
|
||
taskId: args?.taskId,
|
||
path: '/tmp/authorized-game/memory/agents/design/director.md',
|
||
content: '',
|
||
exists: false,
|
||
};
|
||
}
|
||
if (command === 'read_project_permission_policy') {
|
||
return emptyProjectPolicy();
|
||
}
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
if (command === 'read_game_creator_agent_runtimes') {
|
||
return [];
|
||
}
|
||
if (command === 'read_local_project_file') {
|
||
runReadCount += 1;
|
||
return {
|
||
path: String(args?.relativePath ?? ''),
|
||
absolutePath: `${String(args?.projectPath ?? '')}/${String(
|
||
args?.relativePath ?? '',
|
||
)}`,
|
||
content: JSON.stringify(makeTrace(runReadCount > 1)),
|
||
};
|
||
}
|
||
if (command === 'list_local_project_files') {
|
||
return { projectPath: String(args?.projectPath ?? ''), files: [] };
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
await screen.findByText('已打开:authorized-game');
|
||
fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ }));
|
||
expect(await screen.findByLabelText('Agent 对话')).not.toBeNull();
|
||
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
|
||
'暂无最近运行证据',
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '刷新 Agent' }));
|
||
|
||
expect(await screen.findByText('刷新后的拆解方向')).not.toBeNull();
|
||
await waitFor(() => {
|
||
expect(screen.getByLabelText('Agent 对话').textContent).toContain(
|
||
'刷新后的拆解方向',
|
||
);
|
||
});
|
||
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
|
||
'in: memory/session.md',
|
||
);
|
||
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
|
||
'out: .agent/spec.md',
|
||
);
|
||
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
|
||
'tool: llm.planner.refresh · ok · 刷新后工具调用',
|
||
);
|
||
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
|
||
'tool: llm.planner.refresh · ok · 刷新后工具调用 · in memory/session.md · out .agent/spec.md',
|
||
);
|
||
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
|
||
'还有 1 个工具调用',
|
||
);
|
||
});
|
||
|
||
it('confirms before refreshing agents when trace read policy requires it', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
const trace: GameCreationAgentRunTrace = {
|
||
schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
|
||
runId: 'run-agent-refresh-confirm',
|
||
commandId: 'game.generate_draft',
|
||
status: 'running',
|
||
passes: 1,
|
||
maxPasses: 3,
|
||
toolCallCount: 1,
|
||
maxToolCalls: 128,
|
||
stopReason: 'running',
|
||
goal: '做一个厨房弹幕游戏',
|
||
coordination: 'Planner',
|
||
steps: [],
|
||
artifacts: [],
|
||
taskGraph: {
|
||
goal: '做一个厨房弹幕游戏',
|
||
readyTaskIds: [],
|
||
activeTaskIds: [],
|
||
carriedTaskIds: [],
|
||
repairFocus: [],
|
||
repairRoutes: [],
|
||
tasks: createGameCreationAppSeedTasks(),
|
||
},
|
||
passPlans: [],
|
||
nextStep: 'continue',
|
||
error: null,
|
||
updatedAt: 1,
|
||
};
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'append_local_permission_log') {
|
||
return {};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'read_local_conversation') {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (command === 'read_project_permission_policy') {
|
||
return {
|
||
path: '.agent/policy.json',
|
||
policy: {
|
||
deniedCommands: [],
|
||
confirmCommands: ['agent.trace_read'],
|
||
},
|
||
};
|
||
}
|
||
if (command === 'read_local_project_file') {
|
||
return {
|
||
path: '.agent/run.latest.json',
|
||
absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`,
|
||
content: JSON.stringify(trace),
|
||
};
|
||
}
|
||
if (command === 'list_local_project_files') {
|
||
return { projectPath: String(args?.projectPath ?? ''), files: [] };
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
expect(await screen.findByText('想做什么游戏?')).not.toBeNull();
|
||
invoke.mockClear();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '刷新 Agent' }));
|
||
|
||
expect(await screen.findByText('agent.trace_read')).not.toBeNull();
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'read_local_project_file',
|
||
expect.anything(),
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith('read_local_project_file', {
|
||
projectPath: '/tmp/authorized-game',
|
||
relativePath: '.agent/run.latest.json',
|
||
commandId: 'agent.trace_read',
|
||
});
|
||
});
|
||
});
|
||
|
||
it('cancels agent run trace refresh policy confirmation from the panel', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'append_local_permission_log') {
|
||
return {};
|
||
}
|
||
if (command === 'init_local_game_project') {
|
||
const projectPath = String(args?.projectPath ?? '');
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'read_local_conversation') {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (command === 'read_project_permission_policy') {
|
||
return {
|
||
path: '.agent/policy.json',
|
||
policy: {
|
||
deniedCommands: [],
|
||
confirmCommands: ['agent.trace_read'],
|
||
},
|
||
};
|
||
}
|
||
if (command === 'read_local_project_file') {
|
||
throw new Error('should wait for trace confirmation');
|
||
}
|
||
if (command === 'list_local_project_files') {
|
||
return { projectPath: String(args?.projectPath ?? ''), files: [] };
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
expect(await screen.findByText('想做什么游戏?')).not.toBeNull();
|
||
invoke.mockClear();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '刷新 Agent' }));
|
||
|
||
const traceReadCommand = await screen.findByText('agent.trace_read');
|
||
fireEvent.click(
|
||
within(
|
||
traceReadCommand.closest('.pending-command') as HTMLElement,
|
||
).getByRole('button', { name: '取消' }),
|
||
);
|
||
|
||
expect(
|
||
await screen.findByText('run: 已取消读取 Agent trace'),
|
||
).not.toBeNull();
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'read_local_project_file',
|
||
expect.anything(),
|
||
);
|
||
});
|
||
}
|