2a2e4c1cea
- 在途拦截补齐生成动画:提交记录的「属于哪张资源」从 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 导出
366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
/** @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);
|
||
});
|
||
});
|