/** @vitest-environment jsdom */ import { readFileSync } from 'node:fs'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { createGameCreationAppManifest, findResourceSelectButton, fireEvent, installResizeObserverStub, ProjectDevelopmentView, React, render, screen, waitFor, within, } from './appSurface/harness'; import { repoPath } from './repoPath'; vi.mock('@tauri-apps/api/core', async () => ({ ...(await vi.importActual( '@tauri-apps/api/core', )), invoke: (command: string, args?: Record) => window.__TAURI__!.core.invoke(command, args), })); const PROJECT_PATH = '/tmp/workbench-card-name'; /** * 生成落盘名的真实形状:`assets/canvas-generated/{毫秒}-{assetName}.{ext}`。 * 卡面名称必须消费这条正式链路的结果,而不是另造一份显示名。 */ const GENERATED_IMAGE_NAME = '1758000000000-夜行侠主角三视图与战斗姿态设定稿.png'; const GENERATED_IMAGE_PATH = `assets/canvas-generated/${GENERATED_IMAGE_NAME}`; const DOCUMENT_BODY = '# 玩法摘要\n\n这段正文只应出现在独立详情浮层里。'; function createCardNameManifest(): GameCreationAppManifest { const manifest = createGameCreationAppManifest( 'workbench-card-name', '卡片名称测试', ); manifest.assets = [ { id: 'asset-hero', kind: 'character', mediaType: 'image/png', localPath: 'assets/hero.png', source: { kind: 'generated' }, }, { id: 'asset-scene', kind: 'scene', mediaType: 'image/png', localPath: GENERATED_IMAGE_PATH, source: { kind: 'generated' }, }, { id: 'asset-notes', kind: 'design-document', mediaType: 'text/markdown', localPath: 'memory/设计文档.md', source: { kind: 'generated' }, }, { id: 'asset-code', kind: 'code', mediaType: 'text/html', localPath: 'game/index.html', source: { kind: 'generated' }, }, { id: 'asset-bgm', kind: 'background-music', mediaType: 'audio/mpeg', localPath: 'assets/theme.mp3', source: { kind: 'generated' }, }, ]; manifest.versions = [ { versionId: 'version-initial', parentVersionId: null, projectRevision: 1, resourceBindings: [], createdReason: 'initial', createdAt: 1, }, ]; return manifest; } function installInvoke( implementation: ( command: string, args?: Record, ) => Promise, ) { const invoke = vi.fn(implementation); ( window as unknown as { __TAURI__?: { core?: { invoke?: typeof invoke } }; } ).__TAURI__ = { core: { invoke } }; return invoke; } function installManifestInvoke(projectId: string) { return installInvoke(async (command, args) => { if (command === 'read_local_project_resource_graph') { const ids = (args?.resources as Array<{ resourceId: string }>).map( (item) => item.resourceId, ); return { resourceIds: ids, referenceEdges: [], taskFlows: [], producerAssignments: [], dependencyDepths: ids.map((resourceId) => ({ resourceId, dependencyDepth: 0, })), connectionIndex: ids.map((resourceId) => ({ resourceId, upstreamReferenceResourceIds: [], downstreamReferenceResourceIds: [], referenceEdgeIds: [], taskFlowIds: [], })), unresolvedReferenceResourceIds: [], cyclicResourceIds: [], cyclicTaskIds: [], producerMappingTruncated: false, }; } if ( command === 'read_local_project_resource_canvas_layout' || command === 'update_local_project_resource_canvas_layout' ) { const layout = { schemaVersion: 'game-creator-resource-layout.v1', projectId, mode: args?.mode, revision: Number(args?.expectedRevision ?? 0) + 1, positions: args?.positions ?? [], updatedAt: 1, }; return command.startsWith('update_') ? { status: 'updated', layout } : layout; } if (command === 'read_local_project_image_preview') { return { path: String(args?.relativePath ?? ''), mediaType: 'image/png', byteLen: 12, dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB', }; } if (command === 'read_local_project_text_preview') { return { path: String(args?.relativePath ?? ''), mediaType: 'text/markdown', byteLen: DOCUMENT_BODY.length, content: DOCUMENT_BODY, }; } if (command === 'read_local_project_media_preview') { return { path: String(args?.relativePath ?? ''), mediaType: 'audio/mpeg', byteLen: 32, dataUrl: 'data:audio/mpeg;base64,SUQz', }; } if ( command === 'list_pending_local_project_resource_edits' || command === 'list_local_project_asset_generations' ) { return []; } throw new Error(`unexpected command: ${command}`); }); } /** 切栏目:左侧大纲导航已删除,一律走「资源总览」的栏目缩略卡片。 */ async function openResourceBookCategory(label: string) { 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(() => expect( document.querySelector('[data-resource-book-view="child"]'), ).not.toBeNull(), ); } /** * 卡面名称断言:名称条文本与稳定 DOM 判据是同一个正式资源名,**完整名挂在整卡选中按钮的 * `title` 上** —— 名称条 `pointer-events: none`,只有这颗覆盖整卡的按钮命中指针, * 「悬停读全名」必须落在它身上才真的会出 tooltip。 */ async function expectCardName(name: string) { const selectButton = await findResourceSelectButton(name); const card = selectButton.closest('.game-resource-card')!; const nameNode = card.querySelector('.game-resource-card-name'); expect(nameNode?.textContent).toBe(name); expect(nameNode?.getAttribute('data-resource-name')).toBe(name); // 名称条不挂 `title`:它悬停不到,挂上去就是一条永远不触发的死提示。 expect(nameNode?.getAttribute('title')).toBeNull(); expect(selectButton.getAttribute('title')).toBe(name); return card; } function renderWorkbench(manifest: GameCreationAppManifest) { return render( React.createElement(ProjectDevelopmentView, { projectName: manifest.name, projectPath: PROJECT_PATH, manifest, attachments: [], recentRunStatus: null, recentRunStopReason: null, supervisor: React.createElement('div', null, '项目总控'), onHomeOpen: vi.fn(), onProjectsOpen: vi.fn(), }), ); } function stylesSource() { return readFileSync( repoPath('apps/ai-game-creator-shell/src/styles.css'), 'utf8', ); } /** 取 CSS 源文件里某条规则的声明体。 */ function ruleBody(styles: string, selector: string) { const match = new RegExp(`${selector}\\s*\\{([^}]*)\\}`, 'su').exec(styles); expect(match, `${selector} 规则缺失`).not.toBeNull(); return match![1]!; } afterEach(() => { delete (window as unknown as { __TAURI__?: unknown }).__TAURI__; }); describe('资源卡名称与文档卡卡面', () => { /** * 名称口径:所有类型卡都显示**正式资源 label**——manifest `localPath` 的文件名。 * 生成时它是 `assetName` 的落盘名,用户重命名会改写同一份 `localPath`, * 卡片不另取临时输入或历史任务名,也不持久化第二份显示名。 */ it('所有类型卡都显示正式资源名,且不把目录路径铺到卡面上', async () => { installResizeObserverStub(); installManifestInvoke('workbench-card-name'); renderWorkbench(createCardNameManifest()); await openResourceBookCategory('角色与对象'); const heroCard = await expectCardName('hero.png'); expect(heroCard.textContent).not.toContain('assets/'); expect(heroCard.textContent).not.toContain('Agent 生成'); await openResourceBookCategory('场景与环境'); // 长名不被截断在数据层:这里拿到的是完整名,单行省略只由 CSS 负责, // 用户悬停 `title` 仍能读到完整名称。 const sceneCard = await expectCardName(GENERATED_IMAGE_NAME); expect(sceneCard.textContent).not.toContain('assets/'); await openResourceBookCategory('音频'); await expectCardName('theme.mp3'); await openResourceBookCategory('待归类'); await expectCardName('设计文档.md'); await expectCardName('index.html'); await openResourceBookCategory('项目版本'); await expectCardName('版本 1'); }); it('文档卡以居中图标呈现,正文只在独立详情里读', async () => { installResizeObserverStub(); const invoke = installManifestInvoke('workbench-card-name'); renderWorkbench(createCardNameManifest()); await openResourceBookCategory('待归类'); const documentCard = await expectCardName('设计文档.md'); // 先证明正文**确实读到过**:读取链路没有被这次改动掐掉,卡面仍旧不铺摘要。 await waitFor(() => expect(documentCard.getAttribute('data-preview-status')).toBe('loaded'), ); expect( invoke.mock.calls.some( ([command, args]) => command === 'read_local_project_text_preview' && args?.relativePath === 'memory/设计文档.md', ), ).toBe(true); expect( documentCard.querySelector('.game-resource-card-document-visual'), ).not.toBeNull(); expect(documentCard.textContent).not.toContain(DOCUMENT_BODY); expect(documentCard.textContent).not.toContain( '这段正文只应出现在独立详情浮层里', ); expect( documentCard.querySelector('.game-resource-card-document-summary'), ).toBeNull(); // 代码卡同样只在卡面给图标 + 类型标签,名称照常显示。 const codeCard = await expectCardName('index.html'); expect( codeCard.querySelector('.game-resource-card-code-visual'), ).not.toBeNull(); // 独立详情不回归:选中文档卡 → 工具条「预览」→ 正文按原文读到浮层里。 fireEvent.click( within(documentCard).getByRole('button', { name: '选中资源:待归类 设计文档.md', }), ); fireEvent.click(await screen.findByRole('button', { name: '预览' })); const dialog = await screen.findByRole('dialog', { name: '文档预览' }); await waitFor(() => expect(dialog.textContent).toContain('这段正文只应出现在独立详情浮层里'), ); }); /** * jsdom 不加载样式表:长名单行省略、名称条不挡点击这些声明本身在源码层钉住; * 真机像素表现仍需人工核验。 */ it('卡面名称的省略与层级声明固定在样式表里', () => { const styles = stylesSource(); const nameRule = ruleBody(styles, '\\.game-resource-card-name'); expect(nameRule).toMatch(/overflow:\s*hidden/u); expect(nameRule).toMatch(/text-overflow:\s*ellipsis/u); expect(nameRule).toMatch(/white-space:\s*nowrap/u); expect(nameRule).toMatch(/pointer-events:\s*none/u); // 名称条铺在整卡选中按钮之上、右下角播放钮之下,两边都不能被盖住。 expect(nameRule).toMatch(/z-index:\s*2/u); expect( ruleBody( styles, "\\.game-resource-card\\[data-preview-kind='audio'\\] \\.game-resource-card-name", ), ).toMatch(/padding-right:\s*50px/u); // 旧的卡面正文摘要样式必须随正文一起消失,不留墓碑规则。 expect(styles).not.toContain('.game-resource-card-document-summary'); // 文档卡与代码卡共用同一套居中排布。 const documentVisualRule = ruleBody( styles, '\\.game-resource-card-code-visual,\\s*\\.game-resource-card-document-visual', ); expect(documentVisualRule).toMatch(/place-items:\s*center/u); expect(documentVisualRule).toMatch(/align-content:\s*center/u); }); });