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) { 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; observer: IntersectionObserver; }> = []; class ResourceCardIntersectionObserver { readonly root = null; readonly rootMargin = '160px'; readonly thresholds = [0]; readonly observed = new Set(); 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() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((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) => { 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('[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, ) { 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) => { 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( '.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( '[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('.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) => { 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) => { 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) => { 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) => { 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) => { 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('.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) => { if (command === 'read_local_project_resource_graph') { return resourceGraphForInputs(args); } if (command === 'read_local_project_resource_canvas_layout') { return { schemaVersion: 'game-creator-resource-layout.v1', projectId: manifest.projectId, mode: args?.mode, revision: layoutRevision, positions: [], updatedAt: 0, }; } if (command === 'update_local_project_resource_canvas_layout') { layoutRevision += 1; return { status: 'updated', layout: { schemaVersion: 'game-creator-resource-layout.v1', projectId: manifest.projectId, mode: args?.mode, revision: layoutRevision, positions: args?.positions, updatedAt: layoutRevision, }, }; } if (command === 'read_local_project_text_preview') { if (args?.relativePath === 'game/blocked.md') { throw new Error('项目权限策略要求用户确认:file.read'); } expect(args).toMatchObject({ projectPath: '/tmp/workbench-resource-media', relativePath: 'game/design.md', }); return { path: 'game/design.md', mediaType: 'text/markdown', byteLen: 64, content: '# 本地设计文档\n\n[外部链接](https://example.com)\n\n![远程图片](https://example.com/image.png)\n\n', }; } 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) => { 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) => { 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( '.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( '.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('[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( '[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( '[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) => { 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) => { 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) => { 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) => { 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( '[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) => { 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) => { 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('.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) => { 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) => { 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( //, ); expect(projectDevelopmentSource).toMatch( /
\{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* { 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) => { 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) => { 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) => { if (command === 'get_design_agent_runtime_mode') return null; 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) => { 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) => { if (command === 'get_design_agent_runtime_mode') return null; 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) => { if (command === 'get_design_agent_runtime_mode') return null; 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) => { if (command === 'get_design_agent_runtime_mode') return null; 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) => { if (command === 'get_design_agent_runtime_mode') return null; 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) => { 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 }) => void) | null = null; const listen = vi.fn( async ( eventName: string, handler: (event: { payload: Record }) => void, ) => { if (eventName === 'game-creator-direct-turn-update') { directTurnUpdateHandler = handler; return () => { if (directTurnUpdateHandler === handler) { directTurnUpdateHandler = null; } }; } return supervisorHarness.listen( eventName, handler as Parameters[1], ); }, ); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_design_agent_runtime_mode') return null; 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 | 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(); const secondDirectReply = createDeferred(); let directReplyCount = 0; invoke.mockImplementation( async (command: string, args?: Record) => { 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 | 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 | 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) => { if (command === 'get_design_agent_runtime_mode') return null; 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) => { if (command === 'get_design_agent_runtime_mode') return null; 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) => { if (command === 'pick_local_project_directory') { return projectPath; } if (command === 'get_design_agent_runtime_mode') return null; 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) => { 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) => { 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) => { 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 }) => void) | null = null; const listen = vi.fn( async ( eventName: string, handler: (event: { payload: Record }) => 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) => { 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) => { 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) => { 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) => { 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) => { 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) => { 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) => { 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) => { 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(), ); }); }