Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx
T
suzmii 5c31ae91ca 返修资源卡完整名悬停入口并收短文档卡注释
- 完整资源名改挂在覆盖整卡、真正命中指针的选中按钮 title 上,名称条自身不再挂悬停不到的 title

- 无障碍名保持原有 aria-label 不变,长名仍是 CSS 单行省略

- 断言改为校验选中按钮 title,并钉住名称条不挂 title

- 文档卡分支注释只写当前行为,去掉旧实现说明
2026-09-17 17:37:48 +08:00

366 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** @vitest-environment jsdom */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
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';
vi.mock('@tauri-apps/api/core', async () => ({
...(await vi.importActual<typeof import('@tauri-apps/api/core')>(
'@tauri-apps/api/core',
)),
invoke: (command: string, args?: Record<string, unknown>) =>
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<string, unknown>,
) => Promise<unknown>,
) {
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<HTMLElement>('.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(
resolve(process.cwd(), '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);
});
});