import userEvent from '@testing-library/user-event'; import type { GameCreationAppAssetKind, ProjectResourceCanvasLayout, ProjectResourceCanvasPosition, } from '../../../../packages/shared/src/contracts/gameCreationApp'; import { GAME_CREATION_APP_UI_DESIGN_ASSET_KIND, GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND, GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE, } from '../../../../packages/shared/src/contracts/gameCreationApp'; import { RESOURCE_REFERENCE_INSERT_EVENT } from '../../src/features/project-workspace/resourceReferences'; import { ApprovalModeDialog } from '../../src/view/project-development/ApprovalModeDialog'; import { RESOURCE_BOOK_OVERVIEW_STACK_LIMIT } from '../../src/view/project-development/resourceBookLayout'; import { RESOURCE_CANVAS_CARD_WIDTH, RESOURCE_CANVAS_COLUMN_GAP, RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP, RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE, } from '../../src/view/project-development/resourceCanvasLayoutModel'; import { normalizeProjectResourceGraph } from '../../src/view/project-development/resourceDependencyGraphModel'; import { ResourceDependencyOverlay } from '../../src/view/project-development/ResourceDependencyOverlay'; import type { ProjectAgentResultSummary } from '../../src/view/project-development/resourceProjectionModel'; import { projectResourcesFromReadModels } from '../../src/view/project-development/resourceProjectionModel'; import { repoPath } from '../repoPath'; import { generationPromptText, typeGenerationPrompt, } from '../resourceGenerationPromptTestUtils'; import { act, cleanup, createGameCreationAppManifest, expect, findResourceSelectButton, fireEvent, getResourceSelectButton, installResizeObserverStub, it, openResourceFilterPanel, ProjectDevelopmentView, queryResourceSelectButton, React, readFileSync, render, renderAppAt, screen, 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; }, }; } async function openResourceBookCategory(label: string) { const categoryByLabel: Record = { 'UI 交互': 'ui-interaction', 角色与对象: 'character', 场景与环境: 'scene', 音频: 'audio', 文档: 'document', 待归类: 'unclassified', 项目版本: 'version', }; const category = categoryByLabel[label]; // 左侧栏目大纲导航已按用户要求删除:切栏目走「资源总览」的栏目缩略卡片。 if (document.querySelector('[data-resource-book-view="child"]')) { fireEvent.click(await screen.findByRole('button', { name: '收起资源' })); await waitFor(() => expect( document.querySelector('[data-resource-book-view="main"]'), ).not.toBeNull(), ); } fireEvent.click(await screen.findByRole('button', { name: `打开${label}` })); await waitFor(() => { const manager = document.querySelector('[data-resource-book-view="child"]'); expect(manager).not.toBeNull(); if (category) { expect( manager?.querySelector( `.game-resource-book-scene-titlebar.is-active[data-resource-book-category="${category}"]`, ), ).not.toBeNull(); } }); } /** 工具栏用例记录的 invoke 调用:命令名 + 原始参数。 */ type BottomToolbarInvokeCall = { command: string; args?: Record; }; function invokeStringField( record: Record | undefined, key: string, ) { const value = record?.[key]; return typeof value === 'string' ? value : undefined; } function deriveInputEditKind(call: BottomToolbarInvokeCall) { const input = call.args?.input; if (!input || typeof input !== 'object' || !('editKind' in input)) { return undefined; } return typeof input.editKind === 'string' ? input.editKind : undefined; } /** * 栏目画布底部工具栏用例共用的 invoke 桩。 * * 生成 / 上传之后的「配对读清单」是既有链路的一部分,所以这里必须让 * `get_local_game_project_revision` 与 `get_local_game_manifest` 都答得出来, * 否则刷新路径会被 mock 挡住、测出来的是桩而不是真实链路。 */ function installResourceBookBottomToolbarInvoke( manifest: GameCreationAppManifest, ) { const calls: BottomToolbarInvokeCall[] = []; const generatedTasks = new Map>(); const invoke = vi.fn( async (command: string, args?: Record) => { calls.push({ command, args }); 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, }, }; } if (command === 'start_local_project_asset_generation') { // 生成已经后台化:提交这一条只负责入参与落账,进度由 `list_...` 轮询后端账本。 // 桩直接回一条已完成记录,让用例仍然只验证「界面发出的载荷」这一件事。 const kind = invokeStringField(args, 'kind') ?? 'unknown'; const taskId = invokeStringField(args, 'taskId') ?? `task-${kind}`; const task = { taskId, projectId: String(args?.projectId ?? manifest.projectId), kind, assetName: invokeStringField(args, 'assetName') ?? '', status: 'completed', phaseDetail: '生成已完成。', createdAtMillis: 1, startedAtMillis: 1, finishedAtMillis: 2, assetId: `generated-${kind}`, error: null, }; generatedTasks.set(taskId, task); return task; } if (command === 'list_local_project_asset_generations') { return [...generatedTasks.values()]; } if (command === 'derive_local_project_resource') { const editKind = deriveInputEditKind({ command, args }) ?? 'unknown'; return { operationId: `operation-${editKind}`, sourceResourceId: `create:operation-${editKind}`, committedProjectRevision: 7, asset: { id: `audio-${editKind}`, kind: editKind, mediaType: 'audio/mpeg', localPath: `assets/canvas-generated/${editKind}.mp3`, source: { kind: 'canvas' }, }, version: null, manifest, }; } if (command === 'upload_local_asset') { return { id: 'uploaded-1', localPath: String(args?.fileName), absolutePath: `/tmp/${String(args?.fileName)}`, manifestPath: '/tmp/.agent/manifest.json', }; } if (command === 'get_local_game_project_revision') { return { revision: 7 }; } if (command === 'get_local_game_manifest') { return manifest; } throw new Error(`unexpected invoke ${command}`); }, ); window.__TAURI__ = { core: { invoke } }; return { calls }; } /** 走一次工具栏入口:可选先展开二级菜单,再填提示词提交。 */ async function submitBottomToolbarPanel( label: string, options: { menu?: string; prompt: string }, ) { if (options.menu) { fireEvent.click(screen.getByRole('button', { name: options.menu })); fireEvent.click(await screen.findByRole('menuitem', { name: label })); } else { fireEvent.click(screen.getByRole('button', { name: label })); } const panel = await screen.findByRole('dialog', { name: label }); // 提示词输入区是与聊天同一份 `@` 引用输入区(Lexical):jsdom 没有可用 Selection, // 浏览器输入事件不会落字,只能走编辑器更新(见 resourceGenerationPromptTestUtils)。 await typeGenerationPrompt(panel, options.prompt); fireEvent.click(within(panel).getByRole('button', { name: label })); // 成功路径由宿主卸载面板:等它消失,下一个入口才不会撞上残留的浮层。 await waitFor(() => expect(screen.queryByRole('dialog', { name: label })).toBeNull(), ); } /** * 一次图片类生成提交的载荷。 * * 后台化之后「提交」这一步是 `start_local_project_asset_generation`(入参校验 + 落项目内账本), * 所以载荷断言看的是它;生成本身由 Rust 后台任务跑。 * * 三条规范类入口共用同一个 canonical kind(本地 IPC 只有一条规范通道),kind 不足以定位, * 所以再按 `assetName` 挑一次,避免取到同 kind 的另一条调用。 */ function generateCall( calls: readonly BottomToolbarInvokeCall[], kind: GameCreationAppAssetKind, assetName?: string, ) { const call = calls.find( (entry) => entry.command === 'start_local_project_asset_generation' && invokeStringField(entry.args, 'kind') === kind && (assetName === undefined || invokeStringField(entry.args, 'assetName') === assetName), ); if (!call?.args) { throw new Error( `missing start_local_project_asset_generation call for ${ assetName ?? kind }`, ); } return call.args; } function uploadCall( calls: readonly BottomToolbarInvokeCall[], fileName: string, ) { const call = calls.find( (entry) => entry.command === 'upload_local_asset' && invokeStringField(entry.args, 'fileName') === fileName, ); if (!call?.args) { throw new Error(`missing upload_local_asset call for ${fileName}`); } return call.args; } /** 取 CSS 源文件里某条规则的声明体;jsdom 不加载这些样式表,可见性只能钉在声明上。 */ function styleRuleBody(styles: string, selector: string) { const match = new RegExp(`${selector}\\s*\\{([^}]*)\\}`, 'su').exec(styles); expect(match, `${selector} 规则缺失`).not.toBeNull(); return match![1]!; } function styleNumber(body: string, property: string) { return Number(new RegExp(`${property}\\s*:\\s*(\\d+)`).exec(body)?.[1]); } /** 按渲染顺序读出只读信息字段,用来比对画布浮层与运行页签两处是否同一份字段。 */ function resourceInfoFieldRows(scope: HTMLElement) { const labels = Array.from(scope.querySelectorAll('dt')).map( (node) => node.textContent, ); const values = Array.from(scope.querySelectorAll('dd')).map( (node) => node.textContent, ); return labels.map((label, index) => [label, values[index]]); } function colorAlpha(value: string) { const match = /rgba?\(([^)]*)\)/u.exec(value); if (!match) { return 1; } const parts = match[1]!.split(',').map((part) => part.trim()); return parts.length === 4 ? Number(parts[3]) : 1; } 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, chat: 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(); // 面板顶部已按 Codex 风格精简:审批入口不再长在会话列头部,改从面板自己的设置浮层进入, // 所以这里不该再出现「审批配置」按钮,也不该出现审批对话框。 expect(screen.queryByRole('button', { name: /审批配置/ })).toBeNull(); expect( screen.queryByRole('dialog', { name: '陶泥儿的操作权限' }), ).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('keeps the approval-mode choices reachable in their own dialog surface', () => { // 审批模式原来是长在会话列头部按钮里的对话框;面板顶部精简之后它由设置浮层里的 // 「操作权限」行打开,但选项与「不可用项给出说明」的语义必须完整保留。 function ApprovalDialogHost() { const [mode, setMode] = React.useState<'strict' | 'risk' | 'none'>( 'strict', ); const [notice, setNotice] = React.useState(''); return React.createElement(ApprovalModeDialog, { approvalMode: mode, notice, onSelect: setMode, onNotice: setNotice, onClose: () => undefined, }); } render(React.createElement(ApprovalDialogHost)); expect( screen.getByRole('dialog', { name: '陶泥儿的操作权限' }), ).not.toBeNull(); const strictApproval = screen.getByRole('radio', { name: /严格审批/ }); expect(strictApproval.getAttribute('aria-checked')).toBe('true'); const riskApproval = screen.getByRole('radio', { name: /风险审批/ }); expect(riskApproval.getAttribute('data-unavailable')).toBe('true'); fireEvent.click(riskApproval); // 不可用项不改变选中态,而是给出原因(选项里那份 + 说明那一行,共两处)。 expect(riskApproval.getAttribute('aria-checked')).toBe('false'); expect(screen.getAllByText('Rank 规则待定,当前暂不可用')).toHaveLength(2); fireEvent.click(screen.getByRole('button', { name: '关闭审批配置' })); }); 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, chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), ); await openResourceBookCategory('角色与对象'); const artCanvas = await screen.findByRole('region', { name: '角色与对象', }); const readViewport = (canvas: HTMLElement) => canvas .querySelector('[data-resource-viewport]') ?.getAttribute('data-resource-viewport'); const zoomOut = (deltaY: number) => { const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true, deltaY, clientX: 80, clientY: 60, }); act(() => { expect(artCanvas.dispatchEvent(event)).toBe(false); }); }; zoomOut(120); const dependencyViewport = readViewport(artCanvas); expect(dependencyViewport).toBeTruthy(); // 切换排序模式时栏目页保持挂载:这里换的是「按依赖 / 按类型」各自的 viewport, // 不需要(也不再有)切栏目动作——旧的栏目大纲入口点在同栏目上是提前返回的空操作。 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, chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), ); await waitFor(() => { const manager = document.querySelector( '[data-resource-book-view="main"]', ); const sceneWorld = manager?.querySelector( '.game-resource-book-scene-world', ); expect( manager?.querySelector( '.game-resource-book-scene-titlebar[data-resource-book-category="unclassified"]', ), ).not.toBeNull(); expect( manager ?.querySelector( '.game-resource-book-scene-titlebar[data-resource-book-category="unclassified"]', ) ?.getAttribute('style'), ).toContain('opacity: 1'); expect( manager?.querySelector( '.game-resource-book-scene-card[data-resource-book-category="unclassified"] .game-resource-card', ), ).not.toBeNull(); expect(sceneWorld?.style.transform).toContain('scale(1)'); }); const mainScene = document.querySelector( '[data-resource-book-view="main"] .game-resource-book-scene', ); const mainSceneWorld = mainScene?.querySelector( '.game-resource-book-scene-world', ); const mainCanvas = screen.getByRole('region', { name: '资源总览' }); const mainWorld = mainCanvas.querySelector( '.game-resource-book-main-world', )!; expect(mainWorld).not.toBeNull(); const mainTransformBeforeZoom = mainSceneWorld?.style.transform; fireEvent.click(screen.getByRole('button', { name: '放大画布' })); expect(mainSceneWorld?.style.transform).not.toBe(mainTransformBeforeZoom); expect(mainCanvas.style.transform).toBe(''); expect(mainWorld.style.transform).toBe(mainSceneWorld?.style.transform); const mainSetPointerCapture = vi.fn(); const mainReleasePointerCapture = vi.fn(); Object.defineProperties(mainCanvas, { setPointerCapture: { configurable: true, value: mainSetPointerCapture }, hasPointerCapture: { configurable: true, value: vi.fn(() => true) }, releasePointerCapture: { configurable: true, value: mainReleasePointerCapture, }, }); const readMainViewport = () => mainWorld.style.transform.match(/-?\d+(?:\.\d+)?/g)!.map(Number); const mainBeforePan = readMainViewport(); const dragMain = (x: number, y: number) => { fireEvent.pointerDown(mainCanvas, { pointerId: 81, button: 0, clientX: 100, clientY: 100, }); fireEvent.pointerMove(mainCanvas, { pointerId: 81, clientX: 100 + x, clientY: 100 + y, }); fireEvent.pointerUp(mainCanvas, { pointerId: 81 }); }; dragMain(-4_000, 3_000); expect(readMainViewport()).toEqual([ mainBeforePan[0]! - 4_000, mainBeforePan[1]! + 3_000, mainBeforePan[2], ]); dragMain(8_000, -6_000); expect(readMainViewport()).toEqual([ mainBeforePan[0]! + 4_000, mainBeforePan[1]! - 3_000, mainBeforePan[2], ]); expect(mainSetPointerCapture).toHaveBeenCalledWith(81); expect(mainReleasePointerCapture).toHaveBeenCalledWith(81); dragMain(-4_000, 3_000); expect(readMainViewport()).toEqual(mainBeforePan); const mainWheel = (init: WheelEventInit) => { const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, ...init, }); act(() => { mainCanvas.dispatchEvent(event); }); expect(event.defaultPrevented).toBe(true); }; mainWheel({ deltaX: 70, deltaY: 120 }); expect(readMainViewport()).toEqual([ mainBeforePan[0]! - 70, mainBeforePan[1]! - 120, mainBeforePan[2], ]); mainWheel({ deltaY: 80, shiftKey: true }); expect(readMainViewport()).toEqual([ mainBeforePan[0]! - 150, mainBeforePan[1]! - 120, mainBeforePan[2], ]); mainWheel({ deltaY: -120, ctrlKey: true, clientX: 80, clientY: 60 }); expect(readMainViewport()[2]).toBeGreaterThan(mainBeforePan[2]!); expect(mainCanvas.style.transform).toBe(''); expect(mainWorld.style.transform).toBe(mainSceneWorld?.style.transform); const mainViewportBeforeNavigation = mainWorld.style.transform; const dependencyCanvas = (await screen.findByLabelText( '资源依赖视图', )) as HTMLDivElement; Object.defineProperties(dependencyCanvas, { clientWidth: { configurable: true, get: () => 800 }, clientHeight: { configurable: true, get: () => 600 }, }); const pageCanvas = dependencyCanvas.querySelector( '.game-resource-page-canvas', ); expect(pageCanvas).not.toBeNull(); Object.defineProperties(pageCanvas!, { clientWidth: { configurable: true, get: () => 500 }, clientHeight: { configurable: true, get: () => 300 }, }); const setPointerCapture = vi.fn(); const releasePointerCapture = vi.fn(); const hasPointerCapture = vi.fn(() => true); Object.defineProperties(dependencyCanvas, { setPointerCapture: { configurable: true, value: setPointerCapture }, hasPointerCapture: { configurable: true, value: hasPointerCapture }, releasePointerCapture: { configurable: true, value: releasePointerCapture, }, }); const resourceBookManager = document.querySelector( '[data-resource-book-view="main"]', ); if (resourceBookManager) { Object.defineProperties(resourceBookManager, { clientWidth: { configurable: true, get: () => 500 }, clientHeight: { configurable: true, get: () => 300 }, }); vi.spyOn(resourceBookManager, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, top: 0, left: 0, right: 500, bottom: 300, width: 500, height: 300, toJSON: () => ({}), } as DOMRect); } window.dispatchEvent(new Event('resize')); // 左侧栏目大纲导航已删除:栏目顺序改由「资源总览」缩略卡片承载(第 0 张是「所有资源」)。 expect( Array.from( document.querySelectorAll('.game-resource-book-thumbnail'), (thumbnail) => thumbnail.getAttribute('aria-label'), ), ).toEqual([ '打开所有资源', '打开UI 交互', '打开角色与对象', '打开场景与环境', '打开音频', '打开文档', '打开待归类', '打开项目版本', ]); expect(graphResourceIds).toContain('asset:section-code'); expect(queryResourceSelectButton('section.js')).not.toBeNull(); expect(screen.queryByRole('region', { name: '游戏代码' })).toBeNull(); expect(screen.getByRole('region', { name: '角色与对象' })).not.toBeNull(); expect( screen.getByTestId('resource-dependency-overlay-character'), ).not.toBeNull(); expect( screen.getByRole('button', { name: /下一页\s*场景与环境/ }), ).not.toBeNull(); const documentCardBefore = document.querySelector( '.game-resource-book-scene-card[data-resource-book-category="unclassified"] .game-resource-card', ); const documentTitlebarBefore = document.querySelector( '.game-resource-book-scene-titlebar[data-resource-book-category="unclassified"]', ); expect(documentCardBefore).not.toBeNull(); expect(documentTitlebarBefore).not.toBeNull(); await openResourceBookCategory('待归类'); // 真实元素 FLIP:卡片在总览与子画布之间是同一批 DOM 节点,转场不引入克隆快照层。 expect(documentCardBefore!.isConnected).toBe(true); expect(documentCardBefore!.closest('[data-resource-book-view]')).toBe( document.querySelector('[data-resource-book-view="child"]'), ); /** * 钉在视口上的标题栏是**屏幕坐标系里的另一份宿主**:它不挂进带 `scale()` 的 world * (见 `ResourceBookScene.renderTitlebar`),所以进入栏目时总览那一份卸下、钉住的那一份挂上。 * 转场仍然是真实元素的 FLIP:控制器按 key 记下 First 屏幕矩形,动画在新宿主上从该矩形回到静止位 * (见 `resourceBookController.play` 的 `remounted` 分支),没有克隆层。 */ const pinnedTitlebar = document.querySelector( '[data-resource-book-view="child"] .game-resource-book-scene-titlebar.is-active[data-resource-book-category="unclassified"]', ); expect(pinnedTitlebar).not.toBeNull(); expect( pinnedTitlebar!.closest('.game-resource-book-scene-world'), ).toBeNull(); expect( document.querySelector('.game-resource-book-transition-layer'), ).toBeNull(); expect(screen.queryByRole('region', { name: '游戏代码' })).toBeNull(); const dispatchViewWheel = (deltaY = 160) => { const scene = document.querySelector( '[data-resource-book-view="child"]', ); expect(scene).not.toBeNull(); const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaX: 0, deltaY, }); act(() => { scene!.dispatchEvent(event); }); expect(event.defaultPrevented).toBe(true); }; dispatchViewWheel(); expect(screen.getByRole('region', { name: '待归类' })).not.toBeNull(); const documentWorld = dependencyCanvas.querySelector( '.game-resource-page-canvas[data-resource-section-scroll="unclassified"] [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(); // 子画布不再为了塞下整栏把卡片缩到 1:1 以下:拟合结果低于 1 时保持可读的 scale 1。 // 「待归类」现在同时收下 section.md 与 section.js 两张卡,拟合值本来就小于 1。 const expectedDocumentFitScale = Math.max( 1, Math.min( RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE, 468 / fittedBounds[2]!, 268 / fittedBounds[3]!, ), ); expect(fittedViewport[2]).toBeCloseTo(expectedDocumentFitScale, 8); const childCard = document.querySelector( '[data-resource-book-view="child"] .game-resource-book-scene-card.is-expanded .game-resource-card', ); expect(childCard).not.toBeNull(); const childTitlebar = document.querySelector( '[data-resource-book-view="child"] .game-resource-book-scene-titlebar.is-active', ); expect(childTitlebar).not.toBeNull(); const childScene = childCard?.closest( '.game-resource-book-scene', ); const childSceneWorld = childScene?.querySelector( '.game-resource-book-scene-world', ); /** * 钉在视口上的那一行(栏目名 / 计数 / `资源总览`)必须活在**屏幕坐标系**里: * 它是缩放层 world 的兄弟节点,自身不带任何抵消缩放的 transform。 * 反过来(挂进 world 再用 `scale(1 / s)` 抵消)整条标题栏的文字都要在被缩放的祖先里栅格化, * 会出现整行文字一起发虚。这里把结构钉死,避免以后又被搬回 world。 */ expect(childTitlebar!.style.transform).toBe(''); expect( childTitlebar!.closest('.game-resource-book-scene-world'), ).toBeNull(); expect(childTitlebar!.parentElement).toBe(childScene); // world 仍然只包画布内容:卡片留在缩放层里(缩略卡与场景卡共用同一个坐标系)。 expect(childCard!.closest('.game-resource-book-scene-world')).toBe( childSceneWorld, ); const childSceneTransformBeforeZoom = childSceneWorld?.style.transform; fireEvent.click(screen.getByRole('button', { name: '放大画布' })); await waitFor(() => expect(childSceneWorld?.style.transform).not.toBe( childSceneTransformBeforeZoom, ), ); const viewportBeforeZoom = fittedViewport; const zoomWheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true, deltaX: 0, deltaY: -120, }); let zoomWheelResult = true; act(() => { zoomWheelResult = dependencyCanvas.dispatchEvent(zoomWheel); }); expect(zoomWheelResult).toBe(false); expect(readViewport()[2]).toBeGreaterThan(viewportBeforeZoom[2]!); const documentViewportAfterZoom = readViewport(); await openResourceBookCategory('角色与对象'); await openResourceBookCategory('待归类'); await waitFor(() => expect(readViewport()).toEqual(documentViewportAfterZoom), ); expect(screen.getByRole('region', { name: '待归类' })).not.toBeNull(); fireEvent.click( screen.getByRole('button', { name: /选中资源:待归类 section\.md/, }), ); expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); dispatchViewWheel(); expect(screen.getByRole('region', { name: '待归类' })).not.toBeNull(); fireEvent.click( within( document.querySelector( '.game-resource-book-scene-titlebar.is-active', ) as HTMLElement, ).getByRole('button', { name: '收起资源' }), ); // jsdom has no Web Animations: navigation must settle immediately, // without waiting for a guessed CSS duration. expect( screen .getByRole('region', { name: '资源总览' }) .closest('[data-resource-book-view]') ?.getAttribute('data-resource-book-transition'), ).toBe('idle'); expect(mainWorld.style.transform).toBe(mainViewportBeforeNavigation); await openResourceBookCategory('待归类'); const viewportBeforeWheelNavigation = readViewport(); dispatchViewWheel(); await waitFor(() => expect(readViewport()[1]).toBeLessThan(viewportBeforeWheelNavigation[1]!), ); expect(screen.getByRole('region', { name: '待归类' })).not.toBeNull(); const zoomAfterViewWheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true, deltaX: 0, deltaY: -120, clientX: 80, clientY: 60, }); act(() => { expect( screen .getByRole('button', { name: '复位资源视图' }) .dispatchEvent(zoomAfterViewWheel), ).toBe(false); }); await waitFor(() => expect(readViewport()[2]).toBeGreaterThan( viewportBeforeWheelNavigation[2]!, ), ); expect(screen.getByRole('region', { name: '待归类' })).not.toBeNull(); const dragCard = screen .getByRole('button', { name: /选中资源:待归类 section\.md/, }) .closest('.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(screen.getByRole('button', { name: /^下一页/ })); expect(cardReleasePointerCapture).toHaveBeenCalledWith(91); fireEvent.pointerUp(dragCard!, { pointerId: 91, clientX: 100, clientY: 50, }); expect( invoke.mock.calls.filter( ([command]) => command === 'update_local_project_resource_canvas_layout', ), ).toHaveLength(layoutUpdateCountBeforeCancelledDrag); await openResourceBookCategory('待归类'); const viewportBeforePan = readViewport(); // 与美术画布一致:左键在空白处是框选,平移用中键(或按住空格)。 fireEvent.pointerDown(dependencyCanvas, { button: 1, clientX: 100, clientY: 100, }); fireEvent.pointerMove(dependencyCanvas, { clientX: 30, clientY: 70 }); fireEvent.pointerUp(dependencyCanvas); expect(setPointerCapture).toHaveBeenCalled(); await waitFor(() => { const [x, y] = readViewport(); expect(x).toBeLessThan(viewportBeforePan[0]!); expect(y).toBeLessThanOrEqual(viewportBeforePan[1]!); }); fireEvent.pointerDown(dependencyCanvas, { button: 1, clientX: 100, clientY: 100, }); fireEvent.pointerMove(dependencyCanvas, { clientX: -5_000, clientY: -5_000, }); fireEvent.pointerUp(dependencyCanvas); await waitFor(() => { const [x, y] = readViewport(); expect(x).toBeLessThan(-1_000); expect(y).toBeLessThan(-1_000); }); const sectionViewportBeforeWheel = readViewport(); dispatchViewWheel(160); await waitFor(() => expect(readViewport()[1]).toBeLessThan(sectionViewportBeforeWheel[1]!), ); expect(screen.getByRole('region', { name: '待归类' })).not.toBeNull(); expect( screen.getByTestId('resource-dependency-overlay-unclassified'), ).not.toBeNull(); expect( screen.queryByTestId('resource-dependency-overlay-character'), ).toBeNull(); await openResourceBookCategory('角色与对象'); fireEvent.click(screen.getByRole('button', { name: '按类型' })); const typeCanvas = screen.getByLabelText('资源类型视图') as HTMLDivElement; expect( within(typeCanvas).getByRole('button', { name: '复位资源视图' }), ).not.toBeNull(); expect(screen.getByRole('region', { name: '角色与对象' })).not.toBeNull(); expect( typeCanvas.querySelector('.game-resource-dependency-overlay'), ).toBeNull(); const readTypeViewport = () => Number( ( typeCanvas .querySelector('[data-resource-viewport]') ?.getAttribute('data-resource-viewport') ?? '0,0,1' ).split(',')[1], ); const typeViewportBeforeWheel = readTypeViewport(); dispatchViewWheel(160); await waitFor(() => expect(readTypeViewport()).toBeLessThan(typeViewportBeforeWheel), ); expect(screen.getByRole('region', { name: '角色与对象' })).not.toBeNull(); expect(screen.queryByRole('region', { name: '音频' })).toBeNull(); expect(screen.queryByRole('button', { name: /缩小.*分区/ })).toBeNull(); expect(screen.queryByRole('button', { name: /放大.*内容/ })).toBeNull(); }, 10_000); it('caps the overview stack for a crowded resource type without trimming the child canvas', async () => { const manifest = createGameCreationAppManifest( 'workbench-overview-stack-cap', '资源总览堆叠项目', ); manifest.assets = Array.from({ length: 5 }, (_value, index) => ({ id: `overview-art-${index}`, kind: 'character', mediaType: 'image/png', localPath: `assets/overview-art-${index}.png`, source: { kind: 'generated', taskId: 'art-asset-plan' }, })); let layoutRevision = 0; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'read_local_project_resource_graph') { return resourceGraphForInputs(args); } if (command === 'read_local_project_resource_canvas_layout') { return { schemaVersion: 'game-creator-resource-layout.v1', projectId: args?.expectedProjectId, mode: args?.mode, revision: layoutRevision, positions: [], updatedAt: layoutRevision, }; } if (command === 'update_local_project_resource_canvas_layout') { layoutRevision += 1; return { status: 'updated', layout: { schemaVersion: 'game-creator-resource-layout.v1', projectId: args?.expectedProjectId, mode: args?.mode, revision: layoutRevision, positions: args?.positions, updatedAt: layoutRevision, }, }; } throw new Error(`unexpected invoke ${command}`); }, ); window.__TAURI__ = { core: { invoke } }; render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, projectPath: '/tmp/workbench-overview-stack-cap', manifest, attachments: [], recentRunStatus: null, recentRunStopReason: null, chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), ); await waitFor(() => expect( document.querySelectorAll( '.game-resource-book-scene-card[data-resource-book-category="character"]', ), ).toHaveLength(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT), ); // 「N 项」徽标现在同样出现在总览第 0 张「所有资源」卡上(全量计数), // 因此这里必须钉到 character 栏自己的标题栏,而不是按可见文本全文档查找。 expect( document.querySelector( '.game-resource-book-scene-titlebar[data-resource-book-category="character"] small', )?.textContent, ).toBe('5 项'); const overviewCardLayers = Array.from( document.querySelectorAll( '.game-resource-book-scene-card[data-resource-book-category="character"]', ), ).map((card) => Number(card.style.zIndex)); expect(overviewCardLayers[0]).toBeGreaterThan(overviewCardLayers.at(-1)!); // 摞身份(栏目 × 列号 × 摞内下标)写在卡片宿主上:总览每摞只铺 N 张,进栏目时其余卡片在 // 总览侧没有节点、拿不到 First 帧,转场层靠这两个数把它们认回自己那一摞的起飞点 // (见 `resourceBookController` 的堆锚点)。 expect( Array.from( document.querySelectorAll( '.game-resource-book-scene-card[data-resource-book-category="character"]', ), ).map((card) => card.dataset.resourceBookStackIndex), ).toEqual(['0', '1', '2']); await openResourceBookCategory('角色与对象'); await waitFor(() => expect( document.querySelectorAll( '.game-resource-book-scene-card.is-expanded[data-resource-book-category="character"]', ), ).toHaveLength(5), ); // 进栏目后铺满全部卡片,每张卡都还带着自己那一摞的身份:没有 First 帧的那几张正是靠它 // 从摞里飞出来(只带下标、不带列号,或者干脆不带,都会退化成原地淡入)。 expect( Array.from( document.querySelectorAll( '.game-resource-book-scene-card.is-expanded[data-resource-book-category="character"]', ), ).map((card) => card.dataset.resourceBookStackIndex), ).toEqual(['0', '1', '2', '3', '4']); expect( Array.from( document.querySelectorAll( '.game-resource-book-scene-card.is-expanded[data-resource-book-category="character"]', ), ).map((card) => card.dataset.resourceBookStackColumn), ).toEqual(['0', '0', '0', '0', '0']); fireEvent.change(openResourceFilterPanel(), { target: { value: 'overview-art-4' }, }); fireEvent.click(screen.getByRole('button', { name: '收起资源' })); await waitFor(() => expect( document.querySelectorAll( '.game-resource-book-scene-card[data-resource-book-category="character"]', ), ).toHaveLength(1), ); }); it('keeps the resource filter panel above the titlebar band and anchored to the dock', () => { const styles = readFileSync( repoPath('apps/ai-game-creator-shell/src/styles.css'), 'utf8', ); const tsxSource = readFileSync( repoPath( 'apps/ai-game-creator-shell/src/view/project-development/index.tsx', ), 'utf8', ); // 卡片(z-index 25)与栏目标题栏(30)都画在铺满资源区的场景里:筛选面板必须比场景更高, // 否则放开 display 也只是"画了但看不见、点不到"。 const sceneZIndex = styleNumber( styleRuleBody(styles, '\\.game-resource-book-scene'), 'z-index', ); expect(sceneZIndex).toBeGreaterThan(0); // 画布只剩这一个浮层(关键词 / 所在区域 / 自定义标签都在里面):它自己必须给出定位, // 共享的 `PlatformFilterPanel` 不自带 position。`display: none` 这条声明级断言沿用 // 搜索浮层时代的口径——jsdom 里 `toBeVisible()` 是恒真的假守卫,只能读 CSS 源文件。 const filterPanel = styleRuleBody(styles, '\\.game-resource-filter-panel'); expect(filterPanel).not.toMatch(/display:\s*none/u); expect(filterPanel).toMatch(/position:\s*absolute/u); expect(styleNumber(filterPanel, 'z-index')).toBeGreaterThan(sceneZIndex); // 「清除搜索并定位」只在搜索把刚提交的资源挡掉时出现,它所在的提示条同样要在场景之上。 const notice = styleRuleBody(styles, '\\.game-resource-live-notice'); expect(notice).toMatch(/position:\s*relative/u); expect(styleNumber(notice, 'z-index')).toBeGreaterThan(sceneZIndex); // 常驻搜索条连同它占的那条排布带一起撤掉:管理区不再为工具条留 padding-top, // 画本场景重新铺满管理区盒子——栏目标题栏回到管理区顶边正常占位。 expect(styles).not.toContain('--game-resource-book-tools-height'); expect(styles).not.toContain('game-resource-book-tools'); expect(tsxSource).not.toContain('game-resource-book-tools'); expect( styleRuleBody(styles, '(?:^|\\n)\\.game-resource-manager'), ).not.toMatch(/padding-top/u); expect(styleRuleBody(styles, '\\.game-resource-book-scene')).toMatch( /inset:\s*0;/u, ); // 筛选面板贴着右下角 Dock 向上展开:浮层底边 = Dock 底边 + Dock 高度 + 间隙。 // 两个数字都读自声明,任何一处改小都会让浮层压到 Dock 或下滑到标题栏那条带上。 const dock = styleRuleBody(styles, '\\.game-resource-book-zoom'); const dockBottom = styleNumber(dock, 'bottom'); const dockHeight = styleNumber(dock, 'height'); expect(dockBottom).toBeGreaterThan(0); expect(dockHeight).toBeGreaterThan(0); expect(styleNumber(filterPanel, 'bottom')).toBeGreaterThanOrEqual( dockBottom + dockHeight, ); // 浮层右边缘与 Dock 右边缘对齐。 expect(styleNumber(filterPanel, 'right')).toBe(styleNumber(dock, 'right')); // 浮层只按底边 + 右边锚定:一旦补上 top,它会重新浮回管理区顶部那条标题栏带上。 expect(filterPanel).not.toMatch(/(?:^|[;\s])top:/u); const titlebar = styleRuleBody( styles, '\\.game-resource-book-scene-titlebar', ); const titlebarHeight = styleNumber(titlebar, 'min-height'); expect(titlebarHeight).toBeGreaterThan(0); // 「浮层只在打开时渲染、且挂在管理区下(与画本场景并列)」由下面的 // `renders the resource filter panel as a sibling of the book scene` 用例按渲染出来的 // DOM 行为断言,不再钉源码文本正则——正则只能守住"这句话还在",改实现就假红。 // 浮层提示层是绝对定位的浮层,容器不吃点击:否则这条横跨管理区的空盒子会把 // 「资源总览」(回到资源总览)这类按钮的命中区域整段吃掉。 const notices = styleRuleBody(styles, '\\.game-resource-book-notices'); expect(notices).toMatch(/position:\s*absolute/u); expect(notices).toMatch(/pointer-events:\s*none/u); expect(styleNumber(notices, 'z-index')).toBeGreaterThan(sceneZIndex); expect( styleRuleBody(styles, '\\.game-resource-book-notices > \\*'), ).toMatch(/pointer-events:\s*auto/u); // 提示条必须落在栏目标题栏下面:顶边 ≥ 标题栏高度,两者不共享同一条水平带。 expect(styleNumber(notices, 'top')).toBeGreaterThanOrEqual(titlebarHeight); }); it('keeps the resource overview grid off fit-content so its columns stay responsive', () => { const styles = readFileSync( repoPath('apps/ai-game-creator-shell/src/styles.css'), 'utf8', ); // 先剥掉 CSS 注释再取声明体:这条修复的注释里就写着 `width: 100%` 等字样, // 不剥掉的话断言可能匹配到注释而"假绿"——删除真正的声明也照样通过。 const declarationsOnly = styles.replace(/\/\*[\s\S]*?\*\//gu, ''); /** * 声明级断言:只能钉住「CSS 源文件里写了什么」,**验不到布局**。 * * AGC 的 vitest 没开 `css: true`、`styles.css` 不会被加载,jsdom 也没有布局引擎; * `gridTemplateColumns` 的解析结果与缩略卡的实际列数只能在真机上量。真机判据见下。 */ const grid = styleRuleBody( declarationsOnly, '\\.game-resource-book-main-grid', ); // 父级 `.game-resource-book-main-world` 是列向 flex 容器,交叉轴 auto 外边距会取消 stretch // 并让本网格按 fit-content 定宽;fit-content 定宽时 auto-fit 的重复次数恒为 1 // ⇒ grid 只有一张缩略卡那么宽、只出 1 列。`width: 100%` 把 inline size 定下来, // auto-fit 才会按下面的 max-width 解析重复次数。删掉它 = 资源总览退回纵向长条。 expect(grid).toMatch(/width:\s*100%/u); expect(grid).toMatch(/max-width:\s*1120px/u); expect(grid).toMatch(/margin:\s*0\s*auto/u); expect(grid).toMatch(/grid-template-columns:\s*repeat\(auto-fit/u); // 同一个 flex 列里的标题栏带同款退化(只占

宽度、space-between 无从展开),一并钉住。 const heading = styleRuleBody( declarationsOnly, '\\.game-resource-book-main-heading', ); expect(heading).toMatch(/width:\s*100%/u); expect(heading).toMatch(/max-width:\s*1120px/u); /* * 真机判据(修复后必须同时成立,供人工核对): * 1) `getComputedStyle(grid).gridTemplateColumns` 给出 3 个非 0 轨道(约 361.33px), * 而不是单轨道 `432px`; * 2) grid 的 `getBoundingClientRect().width` 由 432 变成 1120; * 3) 8 张 `.game-resource-book-thumbnail` 的 `top` 只有 3 个不同取值。 * 取样要在**总览态**做:栏目态该层带 `is-background`(opacity 为 0),是背景层。 */ }); it('renders the resource filter panel as a sibling of the book scene', async () => { const manifest = createGameCreationAppManifest( 'workbench-search-overlay', '资源搜索浮层测试', ); manifest.assets = [ { id: 'search-overlay-art', kind: 'character', mediaType: 'image/png', localPath: 'assets/search-overlay-art.png', source: { kind: 'generated', taskId: 'art-asset-plan' }, }, ]; 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: 0, positions: [], updatedAt: 0, }; } throw new Error(`unexpected invoke ${command}`); }, ); window.__TAURI__ = { core: { invoke } }; render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, projectPath: '/tmp/workbench-search-overlay', manifest, attachments: [], recentRunStatus: null, recentRunStopReason: null, chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), ); // 筛选面板与栏目标题栏分属两条排布带,这不是 CSS 声明能表达的:画本场景是管理区里 // 铺满整块的绝对定位层,面板只要落在场景内部,就会跟着场景走、和栏目标题栏共享 // 同一条水平带。所以这里钉住渲染出来的 DOM 归属关系:浮层是管理区的直接子节点, // 与画本场景并列,而且只在打开时存在。 expect(screen.queryByLabelText('查找素材')).toBeNull(); openResourceFilterPanel(); const filterPanel = document.querySelector('.game-resource-filter-panel'); expect(filterPanel).not.toBeNull(); const manager = filterPanel?.closest('.game-resource-manager') ?? null; expect(manager).not.toBeNull(); // 入场动画会替换场景节点,所以这里用祖先链判断归属关系,不依赖某一个具体节点的 // 身份,也不受节点替换影响。 expect(filterPanel?.closest('.game-resource-book-scene')).toBeNull(); expect(filterPanel?.closest('.game-resource-book-main')).toBeNull(); // 管理区下只有一层 `#resourceFilterId` 包装,放大镜按钮的 `aria-controls` 就指向它。 const trigger = screen.getByRole('button', { name: '搜索资源' }); expect(filterPanel?.parentElement?.id).toBe( trigger.getAttribute('aria-controls'), ); expect(filterPanel?.parentElement?.parentElement).toBe(manager); // 浮层提示层同样挂在管理区上,与画本场景并列,而不是场景内部的流式内容。 const notices = manager?.querySelector( ':scope > .game-resource-book-notices', ); expect(notices).not.toBeNull(); expect(notices?.closest('.game-resource-book-scene')).toBeNull(); // 画本场景是管理区里铺满整块的绝对定位层,筛选面板与它并列、不在它内部。 expect( manager?.querySelector(':scope > .game-resource-book-scene'), ).not.toBeNull(); }); it('keeps the shared selection overlay colours resolvable from the scene root', () => { const sharedStyles = readFileSync( repoPath('packages/image-canvas-react/src/styles.css'), 'utf8', ); // 消费端 `border/background: var(...)` 没有 fallback:token 缺失或被写透明,框选就没有颜色。 const root = styleRuleBody(sharedStyles, '\\.genarrative-image-canvas'); const borderToken = /--genarrative-image-canvas-selection-border:\s*([^;]+);/u.exec(root); const fillToken = /--genarrative-image-canvas-selection-fill:\s*([^;]+);/u.exec(root); expect(borderToken, '框选描边 token 缺失').not.toBeNull(); expect(fillToken, '框选底色 token 缺失').not.toBeNull(); expect(borderToken![1]!.trim()).not.toMatch(/transparent|none/u); expect(fillToken![1]!.trim()).not.toMatch(/transparent|none/u); expect(colorAlpha(borderToken![1]!)).toBeGreaterThan(0.5); expect(colorAlpha(fillToken![1]!)).toBeGreaterThan(0); const overlay = styleRuleBody( sharedStyles, '\\.genarrative-image-canvas__selection-overlay', ); expect(overlay).toMatch( /border:\s*1px solid var\(--genarrative-image-canvas-selection-border\)/u, ); expect(overlay).toMatch( /background:\s*var\(--genarrative-image-canvas-selection-fill\)/u, ); }); it('filters resources through the resource search box in both views', async () => { const manifest = createGameCreationAppManifest( 'workbench-search-visibility', '资源搜索可见性测试', ); manifest.assets = [ { id: 'search-alpha', kind: 'character', mediaType: 'image/png', localPath: 'assets/alpha-hero.png', source: { kind: 'generated', taskId: 'art-asset-plan' }, }, { id: 'search-beta', kind: 'character', mediaType: 'image/png', localPath: 'assets/beta-enemy.png', source: { kind: 'generated', taskId: 'art-asset-plan' }, }, ]; 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: 0, positions: [], updatedAt: 0, }; } throw new Error(`unexpected invoke ${command}`); }, ); window.__TAURI__ = { core: { invoke } }; render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, projectPath: '/tmp/workbench-search-visibility', manifest, attachments: [], recentRunStatus: null, recentRunStopReason: null, chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), ); // 搜索框不再常驻:常态只有右下角 Dock 里的放大镜按钮,浮层由按钮或 Ctrl/Cmd+F 叫出, // 关键词字段就在这一个筛选面板里。 expect(screen.queryByLabelText('查找素材')).toBeNull(); const keywordInput = openResourceFilterPanel(); expect(keywordInput.type).toBe('search'); expect( document.querySelector('.game-resource-filter-panel'), ).not.toBeNull(); const overviewCards = () => document.querySelectorAll( '.game-resource-book-scene-card[data-resource-book-category="character"]', ); await waitFor(() => expect(overviewCards()).toHaveLength(2)); // 总览态:输入即过滤总览摞上的卡片。 fireEvent.change(keywordInput, { target: { value: 'alpha-hero' } }); await waitFor(() => expect(overviewCards()).toHaveLength(1)); // 分页画布态:同一条件继续生效。点栏目大纲属于「点外部」,浮层按既有口径收起, // 但筛选条件跟着状态走,不随浮层消失。 await openResourceBookCategory('角色与对象'); expect(screen.queryByLabelText('查找素材')).toBeNull(); await waitFor(() => expect(getResourceSelectButton('alpha-hero.png')).not.toBeNull(), ); expect(queryResourceSelectButton('beta-enemy.png')).toBeNull(); // 清空后恢复:证明这条链路确实由面板里的关键词驱动。 fireEvent.change(openResourceFilterPanel(), { target: { value: '' } }); await waitFor(() => expect(queryResourceSelectButton('beta-enemy.png')).not.toBeNull(), ); }); it('opens the single resource filter panel by shortcut and closes it without clearing the condition', async () => { const manifest = createGameCreationAppManifest( 'workbench-search-overlay', '资源搜索浮层开合测试', ); manifest.assets = [ { id: 'overlay-alpha', kind: 'character', mediaType: 'image/png', localPath: 'assets/alpha-hero.png', tags: ['主角'], source: { kind: 'generated', taskId: 'art-asset-plan' }, }, { id: 'overlay-beta', kind: 'character', mediaType: 'image/png', localPath: 'assets/beta-enemy.png', source: { kind: 'generated', taskId: 'art-asset-plan' }, }, ]; 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: 0, positions: [], updatedAt: 0, }; } throw new Error(`unexpected invoke ${command}`); }, ); window.__TAURI__ = { core: { invoke } }; render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, projectPath: '/tmp/workbench-search-overlay', manifest, attachments: [], recentRunStatus: null, recentRunStopReason: null, chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), ); const overviewCards = () => document.querySelectorAll( '.game-resource-book-scene-card[data-resource-book-category="character"]', ); await waitFor(() => expect(overviewCards()).toHaveLength(2)); const filterTrigger = () => screen.getByRole('button', { name: '搜索资源' }); expect(filterTrigger().getAttribute('aria-expanded')).toBe('false'); // Ctrl/Cmd+F 与右下角放大镜叫出的是同一个面板:关键词 / 所在区域 / 自定义标签三个 // 字段都在里面,焦点自动落到关键词(PRD「焦点落到资源搜索框」的落点入口)。 fireEvent.keyDown(window, { key: 'f', ctrlKey: true }); const keywordInput = screen.getByLabelText('查找素材') as HTMLInputElement; expect(filterTrigger().getAttribute('aria-expanded')).toBe('true'); expect(document.activeElement).toBe(keywordInput); expect(screen.getByLabelText('所在区域')).toBeTruthy(); expect(screen.getByRole('group', { name: '自定义标签' })).toBeTruthy(); fireEvent.change(keywordInput, { target: { value: 'alpha-hero' } }); await waitFor(() => expect(overviewCards()).toHaveLength(1)); // 有筛选条件时 Dock 上的放大镜按钮保持高亮:浮层收起后条件仍在,按钮必须看得出来。 expect(filterTrigger().className).toContain('is-active'); // Esc 只收起浮层:筛选条件不跟着被清掉(关闭不等于清除搜索),焦点还给放大镜。 fireEvent.keyDown(document, { key: 'Escape' }); expect(screen.queryByLabelText('查找素材')).toBeNull(); expect(overviewCards()).toHaveLength(1); expect(filterTrigger().getAttribute('aria-expanded')).toBe('false'); expect(document.activeElement).toBe(filterTrigger()); // 点放大镜同样能开合,且不会被「点外部关闭」抢在前面。 fireEvent.click(filterTrigger()); expect(screen.getByLabelText('查找素材')).not.toBeNull(); fireEvent.click(filterTrigger()); expect(screen.queryByLabelText('查找素材')).toBeNull(); // 点浮层外部关闭,条件继续保留。 fireEvent.click(filterTrigger()); expect(screen.getByLabelText('查找素材')).not.toBeNull(); fireEvent.click(document.body); expect(screen.queryByLabelText('查找素材')).toBeNull(); expect(overviewCards()).toHaveLength(1); // 「清除搜索并定位」那条链路的入口还在:清空关键词后过滤恢复。 fireEvent.click(filterTrigger()); fireEvent.change(screen.getByLabelText('查找素材'), { target: { value: '' }, }); await waitFor(() => expect(overviewCards()).toHaveLength(2)); expect(filterTrigger().className).not.toContain('is-active'); }); it('keeps the overview-return button out of the floating notice layer hit region', () => { const styles = readFileSync( repoPath('apps/ai-game-creator-shell/src/styles.css'), 'utf8', ); const tsxSource = readFileSync( repoPath( 'apps/ai-game-creator-shell/src/view/project-development/index.tsx', ), 'utf8', ); const sceneZIndex = styleNumber( styleRuleBody(styles, '\\.game-resource-book-scene'), 'z-index', ); const titlebar = styleRuleBody( styles, '\\.game-resource-book-scene-titlebar', ); const titlebarZIndex = styleNumber(titlebar, 'z-index'); const titlebarHeight = styleNumber(titlebar, 'min-height'); expect(titlebarZIndex).toBeGreaterThan(sceneZIndex); expect(titlebarHeight).toBeGreaterThan(0); // 提示层为了看得见必须浮在栏目标题栏之上——正因为如此,它必须把命中区域限死在提示条 // 自己的盒子里:容器 `pointer-events: none` + 提示条 `auto`。分页态标题栏右端的 // 「资源总览」(回到资源总览)就在这条带里,容器一旦吃点击,那颗按钮就点不动。 const notices = styleRuleBody(styles, '\\.game-resource-book-notices'); expect(styleNumber(notices, 'z-index')).toBeGreaterThan(titlebarZIndex); expect(notices).toMatch(/pointer-events:\s*none/u); expect( styleRuleBody(styles, '\\.game-resource-book-notices > \\*'), ).toMatch(/pointer-events:\s*auto/u); // 提示条顶边在标题栏那条带以下:连视觉重叠都不会发生,不只是命中被放行。 expect(styleNumber(notices, 'top')).toBeGreaterThanOrEqual(titlebarHeight); // 成因本身也钉住:横跨整行、z-index 40 的常驻工具条带(当初为常驻搜索框引入)已经不在了, // 它盖在标题栏那条带上时,正好吃掉了返回按钮的命中区域。 expect(styles).not.toContain('game-resource-book-tools'); expect(tsxSource).not.toContain('game-resource-book-tools'); }); it('returns to the overview from the paged titlebar while the notice layer is on screen', async () => { const manifest = createGameCreationAppManifest( 'workbench-return-overview-hit', '回到总览命中区测试', ); manifest.assets = [ { id: 'return-overview-art', kind: 'character', mediaType: 'image/png', localPath: 'assets/return-hero.png', source: { kind: 'generated', taskId: 'art-asset-plan' }, }, ]; 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: 0, positions: [], updatedAt: 0, }; } throw new Error(`unexpected invoke ${command}`); }, ); window.__TAURI__ = { core: { invoke } }; render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, projectPath: '/tmp/workbench-return-overview-hit', manifest, attachments: [ { fileName: 'broken.txt', mediaType: 'text/plain', status: 'failed' as const, error: '附件导入失败', }, ], recentRunStatus: null, recentRunStopReason: null, chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), ); // 提示层在场:附件导入失败条渲染在这层浮层里,而这层浮层不属于画本场景。 const notices = document.querySelector('.game-resource-book-notices'); expect(notices).not.toBeNull(); expect(notices?.querySelector('.game-attachment-errors')).not.toBeNull(); expect(notices?.closest('.game-resource-book-scene')).toBeNull(); expect(notices?.querySelector('.game-resource-book-scene')).toBeNull(); await openResourceBookCategory('角色与对象'); const activeTitlebar = document.querySelector( '.game-resource-book-scene-titlebar.is-active', ); expect(activeTitlebar).not.toBeNull(); const returnButton = within(activeTitlebar as HTMLElement).getByRole( 'button', { name: '收起资源' }, ) as HTMLButtonElement; // 按钮可点:不是 disabled,祖先链上也没有提示层那类跨整行的浮层容器。 expect(returnButton.disabled).toBe(false); expect(returnButton.closest('.game-resource-book-notices')).toBeNull(); // jsdom 不做布局命中的判定,能钉住的是"层级关系 + 按钮真的接在返回总览上": // 默认运行时不带 aria-disabled、点击真的切回总览态。 fireEvent.click(returnButton); expect( document .querySelector('[data-resource-book-view]') ?.getAttribute('data-resource-book-view'), ).toBe('main'); expect(screen.getByRole('region', { name: '资源总览' })).not.toBeNull(); }); it('surfaces the read-time section realignment and skipped coordinates once per project', async () => { const manifest = createGameCreationAppManifest( 'workbench-layout-read-report', '布局读时归并提示测试', ); manifest.assets = [ { id: 'legacy-art', kind: 'character', mediaType: 'image/png', localPath: 'assets/legacy-art.png', source: { kind: 'generated', taskId: 'art-asset-plan' }, }, { id: 'mismatched-audio', kind: 'sound-effect', mediaType: 'audio/mpeg', localPath: 'assets/mismatched-audio.mp3', source: { kind: 'generated', taskId: 'art-asset-plan' }, }, ]; let layoutRevision = 0; let writtenPositions: ProjectResourceCanvasPosition[] = []; const storedPositions: ProjectResourceCanvasPosition[] = [ // 旧 `art` 分区 + 资源当前分类 `character`:打开项目时归并成 `character`。 { resourceId: 'asset:legacy-art', section: 'art', x: 10, y: 20, manuallyPlaced: true, }, // 资源仍在,但持久化分区与资源当前分类不匹配:归并到资源当前分类,坐标保持不动。 { resourceId: 'asset:mismatched-audio', section: 'document', x: 30, y: 40, manuallyPlaced: true, }, // 资源已不在 manifest:这条坐标被跳过。 { resourceId: 'asset:ghost', section: 'art', x: 50, y: 60, manuallyPlaced: true, }, ]; 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: storedPositions, updatedAt: layoutRevision, }; } if (command === 'update_local_project_resource_canvas_layout') { layoutRevision += 1; writtenPositions = args?.positions as ProjectResourceCanvasPosition[]; return { status: 'updated', layout: { schemaVersion: 'game-creator-resource-layout.v1', projectId: manifest.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: manifest.name, projectPath: '/tmp/workbench-layout-read-report', manifest, attachments: [], recentRunStatus: null, recentRunStopReason: null, chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), ); const notice = await waitFor(() => { const element = document.querySelector( '.game-resource-book-notices [data-resource-canvas-layout-dropped]', ); expect(element).not.toBeNull(); return element as HTMLElement; }); expect(notice.querySelector('span')?.textContent).toBe( '已把 2 条旧分区坐标对齐到新分区并写回,坐标位置未变;另有 1 条坐标无法对齐已跳过(1 条资源已不在项目中)', ); // 排障口径:完全没丢是 0 / 0,丢的是哪一类由两个子计数分开。 // 「现行分区与资源分类不一致」不再丢弃 —— 它归并到资源当前分类、坐标保持不动。 expect(notice.dataset.resourceCanvasLayoutNormalized).toBe('2'); expect(notice.dataset.resourceCanvasLayoutDropped).toBe('1'); expect(notice.dataset.resourceCanvasLayoutDroppedMissingResource).toBe('1'); expect(notice.dataset.resourceCanvasLayoutDroppedSectionMismatch).toBe('0'); // 读时归并会当场写回一次 sidecar,提示说的就是这次写回。 await waitFor(() => expect(layoutRevision).toBeGreaterThan(0)); // 用户可见判据:分类变过的那张卡必须**保住手摆的坐标**,不能被丢掉后自动重排。 expect( writtenPositions.find( ({ resourceId }) => resourceId === 'asset:mismatched-audio', ), ).toMatchObject({ section: 'audio', x: 30, y: 40, manuallyPlaced: true }); // 一次性提示,不是常驻说明:关掉即从 DOM 消失。 fireEvent.click(within(notice).getByRole('button', { name: '知道了' })); expect( document.querySelector('[data-resource-canvas-layout-dropped]'), ).toBeNull(); }, 20_000); it('does not surface a read-time notice when the stored sidecar already matches the current sections', async () => { const manifest = createGameCreationAppManifest( 'workbench-layout-read-clean', '布局读时无需归并测试', ); manifest.assets = [ { id: 'aligned-art', kind: 'character', mediaType: 'image/png', localPath: 'assets/aligned-art.png', source: { kind: 'generated', taskId: 'art-asset-plan' }, }, ]; let layoutReads = 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') { layoutReads += 1; return { schemaVersion: 'game-creator-resource-layout.v1', projectId: manifest.projectId, mode: args?.mode, revision: 0, positions: [ { resourceId: 'asset:aligned-art', section: 'character', x: 10, y: 20, manuallyPlaced: true, }, ], updatedAt: 0, }; } if (command === 'list_local_project_asset_generations') { // 生成任务账本是只读的项目内文件,与「布局读时提示 / 写回」不是同一条链路; // 这里显式登记成「空账本」,否则严格桩会把这条读判成 unexpected invoke、 // 让读不到账本的提示混进本用例要断言的「没有任何提示」里。 return []; } // 没有归并、没有丢弃就不该写回:这里失败关闭,避免「悄悄写了一次」被漏掉。 throw new Error(`unexpected invoke ${command}`); }, ); window.__TAURI__ = { core: { invoke } }; render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, projectPath: '/tmp/workbench-layout-read-clean', manifest, attachments: [], recentRunStatus: null, recentRunStopReason: null, chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), ); await waitFor(() => expect( document.querySelector('[data-resource-book-view="main"]'), ).not.toBeNull(), ); expect(layoutReads).toBeGreaterThan(0); // 读盘已经落地:此刻既没有读时提示,也没有任何写回。 await act(async () => {}); expect( document.querySelector('[data-resource-canvas-layout-dropped]'), ).toBeNull(); expect(document.querySelector('.game-resource-live-notice')).toBeNull(); }, 20_000); it('keeps the resource view when only the persisted preview record says running', async () => { const manifest = createGameCreationAppManifest( 'workbench-persisted-preview', '落盘预览记录测试', ); // manifest.preview 是"上次留下的一条落盘记录":进程退出后它仍会写着 running, // 但预览服务器是进程内线程、端口还是随机临时端口,真实情况是已经打不开了。 manifest.preview = { status: 'running', url: 'http://127.0.0.1:4173/', port: 4173, }; 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: 0, positions: [], updatedAt: 0, }; } throw new Error(`unexpected invoke ${command}`); }, ); window.__TAURI__ = { core: { invoke } }; render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, projectPath: '/tmp/workbench-persisted-preview', manifest, attachments: [], recentRunStatus: null, recentRunStopReason: null, chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), ); // 初始视图是资源管理,不是运行:落盘记录不触发自动切换。 expect( screen .getByRole('tab', { name: '资源管理' }) .getAttribute('aria-selected'), ).toBe('true'); expect( screen.getByRole('tab', { name: '运行' }).getAttribute('aria-selected'), ).toBe('false'); expect(screen.queryByLabelText('运行表现层')).toBeNull(); expect(document.querySelector('[data-resource-view-state]')).not.toBeNull(); // 但运行入口照旧可用(PRD §4.1:入口不可用时仍允许点击,只是不切换状态)。 const runTab = screen.getByRole('tab', { name: '运行', }) as HTMLButtonElement; expect(runTab.disabled).toBe(false); expect(runTab.getAttribute('data-unavailable')).toBeNull(); // 用户点一下仍然能进运行视图("回到仍在运行的预览"从自动变手动)。 fireEvent.click(runTab); expect(runTab.getAttribute('aria-selected')).toBe('true'); expect(screen.getByLabelText('运行表现层')).not.toBeNull(); }); it('switches to the run view when the session confirms a live preview', async () => { const manifest = createGameCreationAppManifest( 'workbench-session-preview', '会话活体预览测试', ); 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: 0, positions: [], updatedAt: 0, }; } throw new Error(`unexpected invoke ${command}`); }, ); window.__TAURI__ = { core: { invoke } }; render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, projectPath: '/tmp/workbench-session-preview', manifest, attachments: [], recentRunStatus: null, recentRunStopReason: null, // 会话预览:launcher 按内存 registry 的活体确认后交进来(或本次会话里 // preview.start / preview.status 的返回值)。 preview: { status: 'running', url: 'http://127.0.0.1:4173/', port: 4173, }, chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), ); await waitFor(() => expect( screen.getByRole('tab', { name: '运行' }).getAttribute('aria-selected'), ).toBe('true'), ); expect(screen.getByLabelText('运行表现层')).not.toBeNull(); }); it('paints the marquee selection box with the scene token root and non-empty geometry', async () => { const manifest = createGameCreationAppManifest( 'workbench-marquee-colour', '框选颜色测试', ); manifest.assets = [ { id: 'marquee-art', kind: 'character', mediaType: 'image/png', localPath: 'assets/marquee-art.png', source: { kind: 'generated', taskId: 'art-asset-plan' }, }, ]; 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: 0, positions: [], updatedAt: 0, }; } throw new Error(`unexpected invoke ${command}`); }, ); window.__TAURI__ = { core: { invoke } }; render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, projectPath: '/tmp/workbench-marquee-colour', manifest, attachments: [], recentRunStatus: null, recentRunStopReason: null, chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), ); fireEvent.click(screen.getByRole('button', { name: '按类型' })); await openResourceBookCategory('角色与对象'); const canvas = screen.getByLabelText('资源类型视图') as HTMLDivElement; Object.defineProperties(canvas, { setPointerCapture: { configurable: true, value: vi.fn() }, hasPointerCapture: { configurable: true, value: vi.fn(() => true) }, releasePointerCapture: { configurable: true, value: vi.fn() }, }); fireEvent.pointerDown(canvas, { pointerId: 91, button: 0, clientX: 40, clientY: 60, }); fireEvent.pointerMove(canvas, { pointerId: 91, clientX: 140, clientY: 160, }); const overlay = document.querySelector( '.genarrative-image-canvas__selection-overlay', ); expect(overlay).not.toBeNull(); expect(Number.parseFloat(overlay!.style.width)).toBeGreaterThan(0); expect(Number.parseFloat(overlay!.style.height)).toBeGreaterThan(0); // 框选颜色 token 只声明在 `.genarrative-image-canvas` 根类上,场景根必须挂这个类, // 否则 `border/background: var(...)` 会在计算值阶段被整条丢弃(等于没有选择框)。 const tokenRoot = overlay!.closest('.genarrative-image-canvas'); expect(tokenRoot).not.toBeNull(); expect(tokenRoot!.classList.contains('game-resource-book-scene')).toBe( true, ); }); it('keeps a code-only project paged into the catch-all section', 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, chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }; const rendered = render( React.createElement(ProjectDevelopmentView, viewProps), ); // 默认停留在顺序里第一个非空栏目,也就是首个栏目资产所在的「角色与对象」。 await waitFor(() => expect(screen.getByRole('region', { name: '角色与对象' })).not.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.queryAllByText('暂无已登记资源')).toHaveLength(0); expect( screen .getByLabelText(/资源(?:依赖|类型)视图/) .classList.contains('game-resource-canvas--paged'), ).toBe(true); expect(queryResourceSelectButton('game.js')).not.toBeNull(); expect(screen.getByRole('region', { name: '待归类' })).not.toBeNull(); expect(screen.queryByRole('region', { name: '游戏代码' })).toBeNull(); }); }); it('has no outline nav and keeps every section reachable through the remaining entries', async () => { const manifest = createGameCreationAppManifest( 'workbench-no-outline-nav', '无栏目大纲导航测试', ); manifest.assets = [ { id: 'nav-document', kind: 'design-document', mediaType: 'text/markdown', localPath: 'memory/nav-document.md', source: { kind: 'generated', taskId: 'design-foundation' }, }, { id: 'nav-art', kind: 'character', mediaType: 'image/png', localPath: 'assets/nav-art.png', source: { kind: 'generated', taskId: 'art-asset-plan' }, }, ]; 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: 0, positions: [], updatedAt: 0, }; } throw new Error(`unexpected invoke ${command}`); }, ); window.__TAURI__ = { core: { invoke } }; render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, projectPath: '/tmp/workbench-no-outline-nav', manifest, attachments: [], recentRunStatus: null, recentRunStopReason: null, chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), ); await waitFor(() => expect( document.querySelector('.game-resource-book-thumbnail'), ).not.toBeNull(), ); // 反向守卫:左侧栏目大纲导航整条不再渲染(标签、类名、nav 角色都不出现)。 expect(screen.queryByLabelText('资源栏目大纲')).toBeNull(); expect(document.querySelector('.game-resource-outline')).toBeNull(); expect(document.querySelector('nav[aria-label="资源栏目大纲"]')).toBeNull(); // 链路一:总览缩略卡片 → 任意栏目(含空栏目)。 fireEvent.click(screen.getByRole('button', { name: '打开项目版本' })); await waitFor(() => expect( document.querySelector( '.game-resource-book-scene-titlebar.is-active[data-resource-book-category="version"]', ), ).not.toBeNull(), ); expect(screen.getByRole('region', { name: '项目版本' })).not.toBeNull(); // 链路二:页内「下一页」→ 顺序里的下一个栏目(项目版本之后回到 UI 交互)。 expect( screen.getByRole('button', { name: /下一页\s*UI 交互/ }), ).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: /^下一页/ })); await waitFor(() => expect( document.querySelector( '.game-resource-book-scene-titlebar.is-active[data-resource-book-category="ui-interaction"]', ), ).not.toBeNull(), ); expect(screen.getByRole('region', { name: 'UI 交互' })).not.toBeNull(); // 链路三:栏目标题栏的「资源总览」→ 回到总览。 fireEvent.click(screen.getByRole('button', { name: '收起资源' })); await waitFor(() => expect( document.querySelector('[data-resource-book-view="main"]'), ).not.toBeNull(), ); // 链路四:「所有资源」入口 → 同一套画本场景的展开态 → 再回总览,全程没有栏目大纲导航。 fireEvent.click(screen.getByRole('button', { name: '打开所有资源' })); await waitFor(() => expect( document.querySelector( '.game-resource-book-scene-titlebar.is-active[data-resource-book-category="all"]', ), ).not.toBeNull(), ); expect(screen.queryByLabelText('资源栏目大纲')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '收起资源' })); await waitFor(() => expect( document.querySelector('[data-resource-book-view="main"]'), ).not.toBeNull(), ); expect(screen.queryByLabelText('资源栏目大纲')).toBeNull(); }); it('refreshes resource pages when a same-count manifest update changes category', async () => { const manifest = createGameCreationAppManifest( 'workbench-imported-image-canvas', '导入图片资源测试', ); manifest.assets = [ { id: 'imported-design-doc', kind: 'design-document', mediaType: 'text/markdown', localPath: 'docs/plan.md', source: { kind: 'generated' }, }, ]; let layoutRevision = 0; const invoke = vi.fn( async (command: string, args?: Record) => { 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, chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }; const rendered = render( React.createElement(ProjectDevelopmentView, viewProps), ); const refreshedManifest = { ...manifest, assets: [ { id: 'imported-image', // canonical `icon` → 「UI 交互」:本用例钉的是"同数量 manifest 更新后栏目跟着变", // 不掺 UI 设计图「(待视觉验收)」标签那套,所以选一个不会带来额外后缀的 kind。 kind: 'icon', mediaType: 'image/png', localPath: 'assets/uploads/local-imported-image.png', source: { kind: 'uploaded' as const, generationRoute: 'agent.local-asset-import', }, }, ], }; expect(refreshedManifest.assets).toHaveLength(manifest.assets.length); rendered.rerender( React.createElement(ProjectDevelopmentView, { ...viewProps, manifest: refreshedManifest, }), ); await openResourceBookCategory('UI 交互'); expect( await screen.findByRole('button', { name: '选中资源:UI 交互 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, chat: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), ); const assertOverview = () => { 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(7); for (const label of [ 'UI 交互', '角色与对象', '场景与环境', '音频', '文档', '待归类', '项目版本', ]) { expect(screen.getByRole('region', { name: label })).not.toBeNull(); } }; await screen.findByRole('region', { name: 'UI 交互' }); assertOverview(); fireEvent.click(screen.getByRole('button', { name: '按类型' })); assertOverview(); fireEvent.click(screen.getByRole('button', { name: '按依赖' })); await screen.findByRole('region', { name: 'UI 交互' }); 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', // 视频与角色图必须落在同一栏目才能同屏比较卡片本体与单媒体播放, // 因此用同为「角色与对象」的 character-animation;卡面预览仍按 video/mp4 走