Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx
T
suzmii 2a2e4c1cea 按 review 修:动画在途拦截、样式守卫扩到三面板、窄屏差异显式放行、用例不再依赖 cwd
- 在途拦截补齐生成动画:提交记录的「属于哪张资源」从 draftKey 拆成 resourcePath,
  `resourceCanvasResourceEditSubmissionBlocksResource` 被快速编辑与生成动画共用;
  此前动画这条键对不上、拦不住,重开面板再提交会复用同一 operationId 卡在原生锁上,
  或(改了提示词)直接多出一笔付费生成
- 样式缺口守卫扩到三块面板(动画面板 / 快速编辑面板 / 生图面板),判据改为按
  「选择器分支 + 声明」逐条比对;两个面板的实测缺口一并照抄补上(快速编辑面板的
  `overflow: auto → visible` 与窄屏底栏两条按宿主差异显式登记,不静默漂移)
- 窄屏只搬动画面板那一支:网页端 ≤760px 会把快速编辑浮层也变成整宽底栏,AGC 的
  快速编辑是「居中贴资源卡」的绝对定位,跟随它会压住栏目工具栏与任务侧栏
- 用例路径不再依赖 cwd:新增 tests/repoPath.ts(按 import.meta.url 反推仓库根),
  19 个用例文件的 `resolve(process.cwd(), …)` 统一改用它;顺手修掉
  `HOST_STYLE_SHEETS.filter(existsSync)` 把仓库相对路径交给 cwd 解析的假红
- 清掉两处死代码:不可达的 `quickEditPanel.status === 'generating'` 判据与
  未被使用的 prune 导出
2026-09-21 01:23:35 +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 { 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<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(
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);
});
});