Files
Genarrative/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
k88936 6981648796
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m48s
Project CI / Native shell tests (pull_request) Failing after 45s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Failing after 1m52s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m57s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m1s
合并 origin/master:Supervisor 永久退役,项目对话收敛为 DirectProject 与 Design Agent
- 解决 refactor/split-direct-project 与 origin/master 在 App.tsx、立项策划聊天视图、Direct composer/引用输入区、styles.css、Rust direct user item 与 appSurface 用例上的冲突,按「Supervisor 永久退役」口径保留 DirectProject 独立聊天容器与 Design Agent 两条产品路径
- 采纳 master 的策划 Agent V1/V2 退役:删除 GDD 审批卡、策划输入卡、planningLane、planningSessionV2、planningSessionContract、规划展示适配与 Rust planning_*_v2 命令、模块、契约及对应用例,不保留兼容别名或双跑路径
- 把 master「折叠思考显示单行预览」的目的落到当前结构:新增共享表现 chat/components/AgentReasoning/AgentReasoning.tsx(折叠态单行纯文本预览 + 箭头、展开态安全 Markdown),DirectProject 回合与策划回合共用,删掉两处写死的 pre 折叠实现
- 把 master「策划入口可选模型 / 推理档」的目的接到当前策划输入盒:复用 ConversationModelSelect 与 ComposerReasoningEffortSelect,配置写回仍走客户端配置通道
- App.tsx 删除只服务退役 Supervisor / 策划 V2 的 state、ref、effect、回调与死参数,并删除两条读路径都退役后的 workspaceProjectKind;openWorkspace 的工程类型入参保留为未使用契约
- Rust 侧保留本分支 canonical→wire 投影、无审计 Direct 回合与 direct user item 严格校验,并入 master 的 prepare_new_web_project_at 前置复核
- 更新 ADR 与 shared-memory 决策记录:策划当前只有 Design Agent、两条路径的共享表现清单,以及本次合并的口径、代价与验证证据
- 验证:AGC 与仓库 typecheck、check:encoding、check:doc-index、git diff --check、改动文件 eslint 0 error;AGC vitest 168 个文件中除 5 个 jsdom localStorage 环境失败文件与本分支既有 resourceTagStatsRefresh 失败外全绿,appSurface 198 passed / 13 skipped;Rust 定向用例 direct_codex_user_item、skill_pack、sessions 全过(整套分片在本容器受 /sbin -> usr/bin 触发沙箱预检失败,与本合并无关)
2026-09-21 21:09:34 +08:00

9037 lines
329 KiB
TypeScript
Raw Permalink 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.
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,
createGameCreationAppSeedTasks,
expect,
findResourceSelectButton,
fireEvent,
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
type GameCreationAgentRunTrace,
getResourceSelectButton,
installResizeObserverStub,
it,
openResourceFilterPanel,
ProjectDevelopmentView,
queryResourceSelectButton,
React,
readFileSync,
render,
renderAppAt,
screen,
vi,
waitFor,
within,
} from './harness';
function resourceGraphForInputs(args?: Record<string, unknown>) {
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<Element>;
observer: IntersectionObserver;
}> = [];
class ResourceCardIntersectionObserver {
readonly root = null;
readonly rootMargin = '160px';
readonly thresholds = [0];
readonly observed = new Set<Element>();
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<string, string> = {
'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<string, unknown>;
};
function invokeStringField(
record: Record<string, unknown> | 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<string, Record<string, unknown>>();
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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<string, unknown>) => {
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<HTMLElement>('[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<typeof createGameCreationAppManifest>,
) {
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<string, unknown>) => {
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<HTMLElement>(
'[data-resource-book-view="main"]',
);
const sceneWorld = manager?.querySelector<HTMLElement>(
'.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<HTMLElement>(
'[data-resource-book-view="main"] .game-resource-book-scene',
);
const mainSceneWorld = mainScene?.querySelector<HTMLElement>(
'.game-resource-book-scene-world',
);
const mainCanvas = screen.getByRole('region', { name: '资源总览' });
const mainWorld = mainCanvas.querySelector<HTMLElement>(
'.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<HTMLElement>(
'.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<HTMLElement>(
'[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<HTMLElement>(
'.game-resource-book-scene-card[data-resource-book-category="unclassified"] .game-resource-card',
);
const documentTitlebarBefore = document.querySelector<HTMLElement>(
'.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<HTMLElement>(
'[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<HTMLElement>(
'[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<HTMLElement>(
'.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<HTMLElement>(
'[data-resource-book-view="child"] .game-resource-book-scene-card.is-expanded .game-resource-card',
);
expect(childCard).not.toBeNull();
const childTitlebar = document.querySelector<HTMLElement>(
'[data-resource-book-view="child"] .game-resource-book-scene-titlebar.is-active',
);
expect(childTitlebar).not.toBeNull();
const childScene = childCard?.closest<HTMLElement>(
'.game-resource-book-scene',
);
const childSceneWorld = childScene?.querySelector<HTMLElement>(
'.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<HTMLElement>(
'.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<HTMLElement>('.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<HTMLElement>('[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<string, unknown>) => {
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<HTMLElement>(
'.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<HTMLElement>(
'.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<HTMLElement>(
'.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<HTMLElement>(
'.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 列里的标题栏带同款退化(只占 <h2> 宽度、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<string, unknown>) => {
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(...)` 没有 fallbacktoken 缺失或被写透明,框选就没有颜色。
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<HTMLElement>(
'.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<string, unknown>) => {
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<HTMLElement>(
'.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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<HTMLElement>(
'.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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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 走 <video> 分支。
kind: 'character-animation',
mediaType: 'video/mp4',
localPath: 'assets/intro.mp4',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
{
id: 'theme-audio',
kind: 'background-music',
mediaType: 'audio/mpeg',
localPath: 'assets/theme.mp3',
source: { kind: 'generated', taskId: 'audio-asset-plan' },
},
{
id: 'design-document',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'memory/design.md',
source: { kind: 'generated', taskId: 'design-foundation' },
},
];
manifest.versions = [
{
versionId: 'version-initial',
parentVersionId: null,
projectRevision: 1,
resourceBindings: [],
createdReason: 'initial',
createdAt: 1,
},
];
let layoutRevision = 0;
const imageDataUrl =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB';
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
if (command === 'read_local_project_image_preview') {
return {
path: String(args?.relativePath ?? ''),
mediaType: 'image/png',
byteLen: 12,
dataUrl: imageDataUrl,
};
}
if (command === 'read_local_project_text_preview') {
return {
path: String(args?.relativePath ?? ''),
mediaType: 'text/markdown',
byteLen: 24,
content: '# 玩法摘要\n\n这是安全的卡片正文摘要。',
};
}
if (command === 'read_local_project_media_preview') {
const relativePath = String(args?.relativePath ?? '');
return relativePath.endsWith('.mp3')
? {
path: relativePath,
mediaType: 'audio/mpeg',
byteLen: 32,
dataUrl: 'data:audio/mpeg;base64,SUQz',
}
: {
path: relativePath,
mediaType: 'video/mp4',
byteLen: 48,
dataUrl: 'data:video/mp4;base64,AAAAIGZ0eXA=',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const play = vi
.spyOn(HTMLMediaElement.prototype, 'play')
.mockResolvedValue(undefined);
const pause = vi
.spyOn(HTMLMediaElement.prototype, 'pause')
.mockImplementation(() => undefined);
const viewProps = {
projectName: manifest.name,
projectPath: '/tmp/workbench-body-first-cards',
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 showResourcePage = (label: string) => openResourceBookCategory(label);
await showResourcePage('角色与对象');
let heroDetailButton = await findResourceSelectButton('hero.png');
const heroCard = heroDetailButton.closest('.game-resource-card');
// 卡面名称就是正式资源名(manifest `localPath` 的文件名,生成时来自 assetName、
// 重命名时同步改写),长名由 CSS 省略、完整名挂在实际命中指针的整卡选中按钮 `title` 上。
const heroName = heroCard?.querySelector('.game-resource-card-name');
expect(heroName?.textContent).toBe('hero.png');
expect(heroName?.getAttribute('data-resource-name')).toBe('hero.png');
expect(
heroCard
?.querySelector('.game-resource-card-select')
?.getAttribute('title'),
).toBe('hero.png');
// 卡面仍然只给"名称 + 类型",不铺完整路径与来源这类详细文本。
expect(heroCard?.textContent).not.toContain('assets/hero.png');
expect(heroCard?.textContent).not.toContain('Agent 生成');
// 角标显示的是**资源类型**(功能分类),等于它所在的画布栏目;不再是"图片"这类媒体类型。
expect(
heroCard?.querySelector('[data-resource-type="角色与对象"]')?.textContent,
).toBe('角色与对象');
expect(
heroCard?.querySelector('.game-resource-card-open button'),
).toBeNull();
await waitFor(() =>
expect(observer.observedCount()).toBeGreaterThanOrEqual(2),
);
act(() => observer.triggerVisible());
await waitFor(() => {
expect(
heroCard
?.querySelector('.game-resource-card-visual > img')
?.getAttribute('src'),
).toBe('blob:mock-attachment-preview');
});
await showResourcePage('待归类');
act(() => observer.triggerVisible());
// 文档卡卡面**不铺正文**:只显示居中图标 + 正式资源名,正文只在独立的文档详情浮层里读。
const documentCard = (
await findResourceSelectButton('design.md')
).closest<HTMLElement>('.game-resource-card');
expect(
documentCard?.querySelector('.game-resource-card-name')?.textContent,
).toBe('design.md');
expect(
documentCard?.querySelector('.game-resource-card-document-visual'),
).not.toBeNull();
expect(documentCard?.textContent).not.toContain('这是安全的卡片正文摘要。');
// 角标仍是资源类型:`design-document` 不在 canonical 目录里 → 落「待归类」,
// 与它所在的栏目一致(这正是改前"标着文档、却在待归类栏"的分叉)。
expect(
documentCard?.querySelector('[data-resource-type="待归类"]'),
).not.toBeNull();
await showResourcePage('角色与对象');
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'read_local_project_media_preview' &&
args?.category === 'audio',
),
).toBe(false);
const videoControl = screen.getByRole('button', {
name: '播放 intro.mp4',
});
const videoCard = videoControl.closest('.game-resource-card');
expect(
videoCard?.querySelector('[data-resource-type="角色与对象"]'),
).not.toBeNull();
const video = videoCard?.querySelector('video');
expect(video).not.toBeNull();
expect(video?.preload).toBe('auto');
fireEvent.loadedData(video!);
const layoutUpdatesBeforePlayback = invoke.mock.calls.filter(
([command]) => command === 'update_local_project_resource_canvas_layout',
).length;
fireEvent.pointerDown(videoControl, {
pointerId: 77,
button: 0,
clientX: 20,
clientY: 20,
});
fireEvent.pointerMove(videoControl, {
pointerId: 77,
clientX: 80,
clientY: 20,
});
fireEvent.pointerUp(videoControl, {
pointerId: 77,
clientX: 80,
clientY: 20,
});
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'update_local_project_resource_canvas_layout',
),
).toHaveLength(layoutUpdatesBeforePlayback);
fireEvent.click(videoControl);
expect(screen.queryByRole('dialog', { name: 'intro.mp4' })).toBeNull();
expect(screen.getByLabelText('资源依赖视图')).not.toBeNull();
const pauseVideoControl = screen.getByRole('button', {
name: '暂停 intro.mp4',
});
pauseVideoControl.focus();
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: { ...manifest },
}),
);
const stableVideoControl = screen.getByRole('button', {
name: '暂停 intro.mp4',
});
expect(stableVideoControl).toBe(pauseVideoControl);
expect(document.activeElement).toBe(stableVideoControl);
await showResourcePage('音频');
const audioControl = screen.getByRole('button', { name: '播放 theme.mp3' });
expect(
audioControl
.closest('.game-resource-card')
?.querySelector('[data-resource-type="音频"]'),
).not.toBeNull();
fireEvent.click(audioControl);
await waitFor(() => {
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'read_local_project_media_preview' &&
args?.category === 'audio',
),
).toBe(true);
expect(play).toHaveBeenCalled();
});
expect(pause.mock.instances).toContain(video);
expect(screen.queryByRole('dialog', { name: 'theme.mp3' })).toBeNull();
await showResourcePage('角色与对象');
heroDetailButton = await findResourceSelectButton('hero.png');
// 同一身份去重:热预取已经把 hero.png 读过一次,再让 IntersectionObserver 报一次
// 可见、再打开一次详情,都不得新增物理读取。
// 不能钉全局总次数:60d8b8fbb 之后热预取只喂当前栏目,用例里每进出一次「角色与对象」
// 都会重读该栏目的可见卡(该 commit 已明确接受这个代价),全局次数会随进出漂移,
// 而「同一身份同时只读一次」这条合同与进出次数无关。
const heroImageReadsBeforeRepeat = invoke.mock.calls.filter(
([command]) => command === 'read_local_project_image_preview',
);
expect(heroImageReadsBeforeRepeat.length).toBeGreaterThan(0);
expect(
heroImageReadsBeforeRepeat.every(
([, args]) => args?.relativePath === 'assets/hero.png',
),
).toBe(true);
act(() => observer.triggerVisible());
fireEvent.click(heroDetailButton);
const heroToolbar = await screen.findByRole('toolbar', {
name: '图片工具栏',
});
expect(
within(heroToolbar).getByRole('button', { name: '快速编辑' }),
).not.toBeNull();
expect(screen.queryByRole('dialog', { name: 'hero.png' })).toBeNull();
expect(
invoke.mock.calls.filter(
([command]) => command === 'read_local_project_image_preview',
),
).toHaveLength(heroImageReadsBeforeRepeat.length);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
expect(observer.observedCount()).toBeGreaterThanOrEqual(2);
act(() => observer.triggerVisible());
expect(screen.getByLabelText('资源类型视图')).not.toBeNull();
const typeVideoControl = await screen.findByRole('button', {
name: '播放 intro.mp4',
});
const typeVideo = typeVideoControl
.closest('.game-resource-card')
?.querySelector('video');
expect(typeVideo).not.toBeNull();
fireEvent.loadedData(typeVideo!);
fireEvent.click(typeVideoControl);
await waitFor(() =>
expect(
screen.getByRole('button', { name: '暂停 intro.mp4' }),
).not.toBeNull(),
);
pause.mockClear();
const search = openResourceFilterPanel();
fireEvent.change(search, { target: { value: 'assets/hero.png' } });
await waitFor(() => expect(pause.mock.instances).toContain(typeVideo));
expect(getResourceSelectButton('hero.png')).not.toBeNull();
expect(queryResourceSelectButton('intro.mp4')).toBeNull();
const typeHeroCard = getResourceSelectButton('hero.png').closest(
'.game-resource-card',
);
await waitFor(() =>
expect(
typeHeroCard?.querySelector('.game-resource-card-visual > img'),
).not.toBeNull(),
);
fireEvent.error(
typeHeroCard!.querySelector('.game-resource-card-visual > img')!,
);
expect(
typeHeroCard?.querySelector('.game-resource-card-visual > img'),
).toBeNull();
expect(
typeHeroCard?.querySelector('.game-resource-card-placeholder'),
).not.toBeNull();
});
it('bounds visible preview concurrency, deduplicates requests, evicts old entries, and drops late project results', async () => {
const observer = installResourceCardIntersectionObserver();
const manifest = createGameCreationAppManifest(
'workbench-preview-scheduler',
'预览调度测试',
);
manifest.assets = Array.from({ length: 49 }, (_, index) => ({
id: `image-${index}`,
kind: 'character',
mediaType: 'image/png',
localPath: `assets/image-${index}.png`,
source: { kind: 'generated' as const },
}));
const pending: Array<{
path: string;
resolve: (value: {
path: string;
mediaType: string;
byteLen: number;
pixelWidth?: number;
pixelHeight?: number;
dataUrl: string;
}) => void;
}> = [];
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
if (command === 'read_local_project_image_preview') {
const path = String(args?.relativePath ?? '');
return new Promise((resolvePreview) => {
pending.push({ path, resolve: resolvePreview });
});
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const props = {
projectName: manifest.name,
projectPath: '/tmp/workbench-preview-scheduler',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
};
const rendered = render(React.createElement(ProjectDevelopmentView, props));
await openResourceBookCategory('角色与对象');
await waitFor(() => expect(observer.observedCount()).toBe(49));
act(() => {
observer.triggerVisible();
observer.triggerVisible();
});
await waitFor(() => expect(pending).toHaveLength(3));
expect(
invoke.mock.calls.filter(
([command]) => command === 'read_local_project_image_preview',
),
).toHaveLength(3);
for (let index = 0; index < 49; index += 1) {
await waitFor(() => expect(pending[index]).toBeDefined());
const request = pending[index]!;
act(() => {
request.resolve({
path: request.path,
mediaType: 'image/png',
byteLen: 1,
...(request.path === 'assets/image-0.png'
? { pixelWidth: 2_000, pixelHeight: 1_000 }
: {}),
dataUrl: `data:image/png;base64,${window.btoa(String(index))}`,
});
});
}
await waitFor(() => {
const previewCount = document.querySelectorAll(
'.game-resource-card-visual > img',
).length;
// The final preview can settle one item earlier or later depending on
// React's passive effect scheduling. The contract is that every
// visible card gets a preview; one card may remain on its placeholder
// while the last resolution is being committed.
expect(previewCount).toBeGreaterThanOrEqual(48);
expect(previewCount).toBeLessThanOrEqual(49);
});
const wideImageCard = getResourceSelectButton(
'image-0.png',
).closest<HTMLElement>('.game-resource-card');
await waitFor(() => {
expect(
wideImageCard?.style.getPropertyValue('--resource-card-width'),
).toBe('220px');
expect(
wideImageCard?.style.getPropertyValue('--resource-card-height'),
).toBe('110px');
});
const lateManifest = createGameCreationAppManifest(
'workbench-preview-scheduler-next',
'新项目',
);
lateManifest.assets = [
{
id: 'next-image',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/next.png',
source: { kind: 'generated' },
},
];
manifest.projectId = lateManifest.projectId;
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...props,
projectPath: '/tmp/workbench-preview-scheduler-next',
projectName: lateManifest.name,
manifest: lateManifest,
}),
);
await openResourceBookCategory('角色与对象');
await waitFor(() =>
expect(getResourceSelectButton('next.png')).not.toBeNull(),
);
await waitFor(() => expect(observer.observedCount()).toBe(1));
act(() => observer.triggerVisible());
await waitFor(() => expect(pending[49]).toBeDefined());
manifest.projectId = 'workbench-preview-scheduler-final';
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...props,
projectPath: '/tmp/workbench-preview-scheduler-final',
projectName: '最终空项目',
manifest: {
...createGameCreationAppManifest(
'workbench-preview-scheduler-final',
'最终空项目',
),
assets: [],
},
}),
);
const objectUrlCountBeforeLateResult = vi.mocked(URL.createObjectURL).mock
.calls.length;
act(() => {
pending[49]!.resolve({
path: 'assets/next.png',
mediaType: 'image/png',
byteLen: 1,
dataUrl: 'data:image/png;base64,LATE',
});
});
await act(async () => Promise.resolve());
expect(URL.createObjectURL).toHaveBeenCalledTimes(
objectUrlCountBeforeLateResult,
);
}, 20_000);
it('renders immutable manifest versions, their parent graph, and bound asset highlights', async () => {
const manifest = createGameCreationAppManifest(
'workbench-versions',
'版本工作台测试',
);
manifest.assets = [
{
id: 'asset-player',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/player.png',
source: { kind: 'generated' },
},
];
manifest.versions = [
{
versionId: 'version-root',
parentVersionId: null,
projectRevision: 3,
// `slotId` 是恒等映射 `asset:{assetId}`(见 `currentVersionResourceBindingIds`)。
// 这里曾经写成 `'player'`,于是「当前版本」判定永远不命中——照它写断言必成假守卫。
resourceBindings: [
{ slotId: 'asset:asset-player', resourceId: 'asset-player' },
],
createdReason: 'initial',
createdAt: 100,
},
{
versionId: 'version-child',
parentVersionId: 'version-root',
projectRevision: 4,
resourceBindings: [
{ slotId: 'asset:asset-player', resourceId: 'asset-player' },
{ slotId: 'asset:asset-removed', resourceId: 'asset-removed' },
],
createdReason: 'agent-revision',
createdAt: 200,
},
];
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-versions',
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 rootVersionButton = screen.getByRole('button', {
name: /选中资源:项目版本 版本 1/,
});
const childVersionButton = screen.getByRole('button', {
name: /选中资源:项目版本 版本 2/,
});
const rootVersionCard = rootVersionButton.closest('.game-resource-card');
const childVersionCard = childVersionButton.closest('.game-resource-card');
expect(rootVersionCard?.textContent).toContain('1 个直接子版本');
expect(childVersionCard?.textContent).toContain('暂无直接子版本');
expect(childVersionCard?.textContent).not.toContain('version-root');
fireEvent.click(childVersionButton);
// 版本资源没有任何美术画布动作,选中反馈由卡片自身承担。
expect(childVersionButton.getAttribute('aria-pressed')).toBe('true');
expect(screen.queryByRole('toolbar')).toBeNull();
expect(screen.queryByText('asset:asset-removed')).toBeNull();
await openResourceBookCategory('角色与对象');
const playerCard = getResourceSelectButton('player.png').closest(
'.game-resource-card',
);
expect(playerCard?.classList.contains('is-relation-version-binding')).toBe(
true,
);
// 夹具口径修正后的自证:`slotId` 写成恒等映射,默认(最新)版本绑定的
// player.png 必须真的命中「当前使用」,否则这条夹具又变回假守卫。
expect(playerCard?.getAttribute('data-used-by-current-version')).toBe(
'true',
);
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
await waitFor(() => {
const dependencyPlayerCard = getResourceSelectButton(
'player.png',
).closest('.game-resource-card');
expect(
dependencyPlayerCard?.classList.contains('is-relation-version-binding'),
).toBe(true);
});
});
it('资源卡 chrome 只保留选中按钮与当前版本边框', () => {
const styles = readFileSync(
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
/*
* 卡片本体只保留**透明**边框,可见描边仍然只属于「当前版本绑定」等状态。
*
* 透明而不是 `border: 0`:卡片是 `box-sizing: border-box`,卡面与角标又都是以 padding
* box 为包含块的绝对定位元素 —— 底态 0 宽、状态态 1px 宽时,一次悬停就会把内容盒四边
* 各吃掉 1px,卡片里的东西跟着位移。常驻 1px 透明边框让所有状态的 padding box 一致,
* 视觉上仍"本体无描边"。
*/
expect(styles).toMatch(
/\.game-resource-card\s*\{[^}]*border:\s*1px solid transparent;/s,
);
// 状态态只允许点亮颜色,不允许改动宽度:宽度一变就又回到"内容跟着动"。
expect(styles).toMatch(
/\.game-resource-card:hover,[^{]*\{[^}]*border:\s*1px solid/s,
);
expect(styles).not.toMatch(
/\.game-resource-card[^{,]*\{[^}]*border-width:/s,
);
expect(styles).not.toMatch(
/\.game-resource-card[^{,]*\{[^}]*border:\s*[2-9]px/s,
);
expect(styles).toMatch(
/\.game-resource-card\.is-current-version\s*\{[^}]*border:\s*1px solid/s,
);
expect(styles).toMatch(
/\.game-resource-card-select\s*\{[^}]*cursor:\s*pointer[^}]*touch-action:\s*manipulation/s,
);
expect(styles).not.toMatch(/\.game-resource-card-open\s*\{/);
});
/**
* 替换血缘标注是**会话内状态**:没发生过替换时,画布上任何一张卡都不该带这两个判据属性,
* 也不该有血缘角标。这条守住"默认渲染不凭空长标注"(血缘常态就是空)。
*/
it('替换血缘标注只在真发生过替换的卡上出现:默认渲染一张都不带', async () => {
const manifest = createGameCreationAppManifest(
'workbench-replacement-lineage',
'替换血缘标注',
);
manifest.assets = [
{
id: 'asset-player',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/player.png',
source: { kind: 'generated' as const },
},
];
manifest.versions = [
{
versionId: 'version-root',
parentVersionId: null,
projectRevision: 3,
resourceBindings: [
{ slotId: 'asset:asset-player', resourceId: 'asset-player' },
],
createdReason: 'initial',
createdAt: 100,
},
];
// 不配 `__TAURI__`:本用例只看卡面标注,视图在无 IPC 时会走各自的兜底分支
// (与相邻的版本高亮用例同一口径)。给 invoke 返回 `undefined` 反而会让
// 「未完成编辑」这类列表状态被写成 undefined。
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-replacement-lineage',
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 card = getResourceSelectButton('player.png').closest(
'.game-resource-card',
);
if (!card) throw new Error('资源卡未渲染:player.png');
expect(card.getAttribute('data-resource-replaced-by')).toBeNull();
expect(card.getAttribute('data-resource-replacement-of')).toBeNull();
expect(card.querySelector('.game-resource-card-lineage-badge')).toBeNull();
});
it('资源卡的 @ 引用入口只留在选中工具条里', () => {
const styles = readFileSync(
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
// 卡片右上角那个圆钮连同它撑出来的 44×44 热区一起退役:卡片本体只剩
// 「选中资源」与媒体播放钮,卡片 chrome 用例(上一条)继续钉这条口径。
expect(styles).not.toContain('.game-resource-card-reference');
const source = readFileSync(
repoPath(
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
),
'utf8',
);
expect(source).not.toContain('game-resource-card-reference');
// 反向守卫:卡片组件内部不能再出现任何插入素材引用的调用。
const cardStart = source.indexOf('function ResourceCard(');
const cardEnd = source.indexOf('function ResourceBookThumbnail(');
expect(cardStart).toBeGreaterThan(0);
expect(cardEnd).toBeGreaterThan(cardStart);
expect(source.slice(cardStart, cardEnd)).not.toContain(
'dispatchResourceReferenceInsert',
);
// 入口挪进选中工具条的 `extraActions`:用插槽边界切片,确保它落在宿主编排层
// 注入的动作里,而不是又被挂回卡片或塞到工具条外面。
const extraStart = source.indexOf('extraActions={');
const extraEnd = source.indexOf(
'onOpenQuickEditPanel={openResourceQuickEditPanel}',
);
expect(extraStart).toBeGreaterThan(0);
expect(extraEnd).toBeGreaterThan(extraStart);
const extraActions = source.slice(extraStart, extraEnd);
expect(extraActions).toContain(
'label={`引用资源 ${selectedResource.label}`}',
);
// 可见文案不再自带 `@`:图标本身就是 `@`(`AtSign`),文案再写一次会渲染成「@ @引用」。
// 无障碍名(`label`)保留完整说法,读屏仍能读出「引用资源 <素材名>」。
expect(extraActions).toContain('title="引用"');
expect(extraActions).toContain('<span>引用</span>');
expect(extraActions).not.toContain('@引用');
expect(extraActions).toMatch(
/<CanvasChromeButton[\s\S]*?label=\{`引用资源 \$\{selectedResource\.label\}`\}[\s\S]*?dispatchResourceReferenceInsert\(\{/u,
);
// 出站契约逐字未变:仍是 `resource-card` 来源的 resource 引用。
const insertStart = extraActions.indexOf(
'dispatchResourceReferenceInsert({',
);
expect(insertStart).toBeGreaterThan(0);
expect(extraActions.slice(insertStart)).toMatch(
/\{\s*type: 'resource',\s*resourceId:\s*selectedResource\.manifestAssetId!,\s*kind:\s*parseGameCreationAppAssetKind\(\s*selectedResource\.subtype,\s*'project-development\.resource-reference',\s*\),\s*mediaType: selectedResource\.mediaType,\s*label: selectedResource\.label,\s*category:\s*projectResourceAssetCategory\(\s*selectedResource,\s*\),\s*tags: selectedResource\.assetTags \?\? \[\],\s*source: 'resource-card',/u,
);
});
it('工具条里的「引用」用键盘也能插进聊天输入框', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-reference',
'引用入口测试',
);
manifest.assets.push({
id: 'scene-hero',
kind: 'image',
category: 'scene',
mediaType: 'image/png',
localPath: 'assets/hero.png',
tags: ['主舞台'],
source: { kind: 'generated' },
});
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
if (command === 'read_local_project_media_preview') {
return {
path: 'assets/hero.png',
mediaType: 'image/png',
byteLen: 48,
dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '引用入口测试',
projectPath: '/tmp/workbench-resource-reference',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('场景与环境');
fireEvent.click(await findResourceSelectButton('hero.png'));
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
const referenceButton = within(toolbar).getByRole('button', {
name: '引用资源 hero.png',
});
// 原生按钮 = 天生进 Tab 序;标题与可见文案一致,且都不再自带 `@`
// —— 那个 `@` 由 `AtSign` 图标承担,文案里重复写会渲染成「@ @引用」。
expect(referenceButton.tagName).toBe('BUTTON');
expect(referenceButton.getAttribute('type')).toBe('button');
expect(referenceButton.getAttribute('title')).toBe('引用');
expect(
referenceButton.querySelector(
'.genarrative-image-canvas__chrome-button-label',
)?.textContent,
).toBe('引用');
// 无障碍名保留完整说法:图标不带语义,读屏只能靠它知道引用的是哪张素材。
expect(referenceButton.getAttribute('aria-label')).toBe(
'引用资源 hero.png',
);
const inserted: unknown[] = [];
const onInsert = (event: Event) => {
inserted.push(
(event as CustomEvent<{ reference: unknown }>).detail.reference,
);
};
window.addEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert);
try {
// 键盘通路:聚焦后回车确认。App 侧监听同一个事件并把它插成输入框里的引用 chip
// `App.tsx` 的 `handleResourceReferenceInsert`),所以这里钉的是真链路而不是按钮长相。
referenceButton.focus();
expect(document.activeElement).toBe(referenceButton);
await userEvent.setup().keyboard('{Enter}');
expect(inserted).toHaveLength(1);
// 鼠标通路仍然只派发一次;两条通路带的是逐字相同的出站负载。
fireEvent.click(referenceButton);
expect(inserted).toHaveLength(2);
} finally {
window.removeEventListener(RESOURCE_REFERENCE_INSERT_EVENT, onInsert);
}
expect(inserted).toEqual([
{
type: 'resource',
resourceId: 'scene-hero',
kind: 'image',
mediaType: 'image/png',
label: 'hero.png',
category: 'scene',
tags: ['主舞台'],
source: 'resource-card',
},
{
type: 'resource',
resourceId: 'scene-hero',
kind: 'image',
mediaType: 'image/png',
label: 'hero.png',
category: 'scene',
tags: ['主舞台'],
source: 'resource-card',
},
]);
});
it('信息面板在画布浮层与运行页签里渲染同一份只读字段', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-info',
'资源信息测试',
);
const codePrototype = manifest.tasks.find(
(task) => task.id === 'code-prototype',
);
if (!codePrototype) {
throw new Error('missing code-prototype seed task');
}
codePrototype.status = 'completed';
manifest.preview = {
status: 'running',
url: 'http://127.0.0.1:4173',
port: 4173,
};
manifest.assets.push({
id: 'character-hero',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
tags: ['主角', '待定稿'],
source: { kind: 'generated' },
});
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
if (command === 'read_local_project_media_preview') {
return {
path: 'assets/hero.png',
mediaType: 'image/png',
byteLen: 48,
dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '资源信息测试',
projectPath: '/tmp/workbench-resource-info',
manifest,
attachments: [],
recentRunStatus: 'completed',
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
// 只读字段与运行页签同源:名称 / 路径 / 类型 + 只读的分类与标签,
// 不含「来源任务」「资产 ID」这类 manifest 内部标识。
const expectedRows = [
['名称', 'hero.png'],
['路径', 'assets/hero.png'],
['类型', 'image/png'],
['分类', '角色与对象'],
['标签', '主角、待定稿'],
];
await openResourceBookCategory('角色与对象');
const infoButton = await screen.findByRole('button', {
name: '查看hero.png资源信息',
});
expect(infoButton.getAttribute('aria-pressed')).toBe('false');
// 未选中的卡片直接打开信息,不被选中变化 effect 立即关闭。
fireEvent.click(infoButton);
const canvasPanel = await screen.findByRole('dialog', {
name: '资源信息',
});
expect(resourceInfoFieldRows(canvasPanel)).toEqual(expectedRows);
expect(infoButton.getAttribute('aria-pressed')).toBe('true');
// 再点一次同一个动作即收起,不用去别处找关闭入口。
fireEvent.click(infoButton);
expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull();
// 关闭按钮只收面板:选中与工具条留着,方便接着看别的动作。
fireEvent.click(infoButton);
fireEvent.click(
within(await screen.findByRole('dialog', { name: '资源信息' })).getByRole(
'button',
{ name: '关闭信息面板' },
),
);
expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull();
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
// 画布浮层的统一关闭时机:点画布以外立即收起。
fireEvent.click(infoButton);
expect(
await screen.findByRole('dialog', { name: '资源信息' }),
).not.toBeNull();
fireEvent.click(document.body);
expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull();
// Esc 与快速编辑浮层同一口径:既收浮层也清选中,整个工具条一起收起。
fireEvent.click(await findResourceSelectButton('hero.png'));
await screen.findByRole('toolbar', {
name: '图片工具栏',
});
fireEvent.click(
screen.getByRole('button', { name: '查看hero.png资源信息' }),
);
expect(
await screen.findByRole('dialog', { name: '资源信息' }),
).not.toBeNull();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull();
expect(screen.queryByRole('toolbar', { name: '图片工具栏' })).toBeNull();
// 运行页签的「信息展示」渲染的是同一份字段,不是第二套口径。
fireEvent.click(await findResourceSelectButton('hero.png'));
const runTab = screen.getByRole('tab', { name: '运行' });
fireEvent.click(runTab);
const runInfoPanel = screen.getByLabelText('资源信息面板');
expect(resourceInfoFieldRows(runInfoPanel)).toEqual(expectedRows);
// 画布浮层只属于画布:切到运行视图后不再渲染。
expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull();
});
it('loads registered documents, art media, video, and audio with safe failure states inside central focus', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-media',
'资源媒体测试',
);
manifest.assets.push(
{
id: 'design-document',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'game/design.md',
source: { kind: 'generated' },
},
{
id: 'art-svg',
kind: 'icon',
mediaType: 'image/svg+xml',
localPath: 'assets/icon.svg',
source: { kind: 'generated' },
},
{
id: 'art-video',
kind: 'character-animation',
mediaType: 'video/mp4',
localPath: 'assets/intro.mp4',
source: { kind: 'generated' },
},
{
id: 'audio-bgm',
kind: 'bgm',
mediaType: 'audio/mpeg',
localPath: 'assets/bgm.mp3',
source: { kind: 'generated' },
},
{
id: 'blocked-document',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'game/blocked.md',
source: { kind: 'generated' },
},
);
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
if (command === 'read_local_project_text_preview') {
if (args?.relativePath === 'game/blocked.md') {
throw new Error('项目权限策略要求用户确认:file.read');
}
expect(args).toMatchObject({
projectPath: '/tmp/workbench-resource-media',
relativePath: 'game/design.md',
});
return {
path: 'game/design.md',
mediaType: 'text/markdown',
byteLen: 64,
content:
'# 本地设计文档\n\n[外部链接](https://example.com)\n\n![远程图片](https://example.com/image.png)\n\n<script>window.pwned = true</script>',
};
}
if (command === 'read_local_project_media_preview') {
// 媒体读取的 `category` 是原生侧的**文件读取分支**(只认 art / audio),
// 不是资源所在的画布栏目:栏目取值(unclassified / ui-interaction / …)
// 传过去会被 `read_local_project_media_preview_at` 直接拒绝。
if (args?.relativePath === 'assets/bgm.mp3') {
return {
path: 'assets/bgm.mp3',
mediaType: 'audio/mpeg',
byteLen: 1024,
dataUrl: 'data:audio/mpeg;base64,SUQz',
};
}
if (args?.relativePath === 'assets/intro.mp4') {
return {
path: 'assets/intro.mp4',
mediaType: 'video/mp4',
byteLen: 128,
dataUrl: 'data:video/mp4;base64,AAAAIGZ0eXA=',
};
}
return {
path: 'assets/icon.svg',
mediaType: 'image/svg+xml',
byteLen: 48,
dataUrl: 'data:image/svg+xml;base64,PHN2Zy8+',
};
}
if (command === 'save_local_project_asset_file') {
const input = args?.input as { destinationPath?: string } | undefined;
return {
destinationPath: input?.destinationPath ?? '',
byteLen: 1024,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
render(
React.createElement(ProjectDevelopmentView, {
projectName: '资源媒体测试',
projectPath: '/tmp/workbench-resource-media',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('待归类');
fireEvent.click(await findResourceSelectButton('design.md'));
// 选中资源浮出的是画布工具条;"资源详情"面板已随 C3 交互合同删除。
expect(screen.queryByRole('dialog', { name: 'design.md' })).toBeNull();
await openResourceBookCategory('UI 交互');
// 栏目标签「UI 交互」自带空格,harness 里用 `\S+` 拼出的选中按钮正则匹配不到,
// 这里改按完整可访问名查询同一条选中按钮。
fireEvent.click(
screen.getByRole('button', { name: '选中资源:UI 交互 icon.svg' }),
);
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
expect(screen.queryByRole('dialog', { name: 'design.md' })).toBeNull();
// SVG 在「UI 交互」栏目,但它走的是原生侧的美术读取分支:线上 `category`
// 必须是 art,不能是栏目取值 ui-interaction,否则原生侧直接拒绝、卡片读不出预览。
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'read_local_project_media_preview' &&
args?.relativePath === 'assets/icon.svg' &&
args?.category === 'art',
),
).toBe(true);
// 视频(canonical `character-animation`)落在「角色与对象」,
// 音频(`bgm` 不是 canonical kind)落在「待归类」。
await openResourceBookCategory('角色与对象');
fireEvent.click(
screen.getByRole('button', {
name: '选中资源:角色与对象 intro.mp4',
}),
);
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
await openResourceBookCategory('待归类');
fireEvent.click(
screen.getByRole('button', {
name: '选中资源:待归类 bgm.mp3',
}),
);
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'read_local_project_media_preview' &&
args?.relativePath === 'assets/bgm.mp3',
),
).toBe(false);
// 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」),
// 并且只渲染宿主编排层真实接通的五个动作;信息与类型由卡片角标承接。
// 「导出」复用资源面板同一条落盘链路,「改造」在宿主编排层仍是空回调,
// 不能再渲染成点了没反应的按钮。
const audioToolbar = screen.getByRole('toolbar', {
name: '素材工具栏',
});
expect(
within(audioToolbar).queryByRole('button', { name: '改造' }),
).toBeNull();
expect(
within(audioToolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['引用资源 bgm.mp3', '编辑标签', '重命名', '导出', '删除素材']);
// 工具条的「导出」必须真的走通落盘链路:原生保存对话框 + Rust 分块复制,
// 而不是只渲染一个按钮。原生对话框由入口文件 mock 成"用户选了
// /tmp/native-export/<建议文件名>",所以这里能钉住完整入参。
fireEvent.click(within(audioToolbar).getByRole('button', { name: '导出' }));
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('save_local_project_asset_file', {
input: {
projectPath: '/tmp/workbench-resource-media',
relativePath: 'assets/bgm.mp3',
destinationPath: '/tmp/native-export/bgm.mp3',
},
});
});
// 资源面板的「下载」必须仍在同一条链路上:抽出共用 handler 后两条入口都打到
// save_local_project_asset_file,而不是各自另写一份保存实现。
const saveCallCount = () =>
invoke.mock.calls.filter(
([command]) => command === 'save_local_project_asset_file',
).length;
expect(saveCallCount()).toBe(1);
fireEvent.click(screen.getByRole('button', { name: '资源面板' }));
const resourcePanel = await screen.findByRole('dialog', {
name: '资源面板',
});
fireEvent.click(
within(resourcePanel).getByRole('button', { name: '下载' }),
);
await waitFor(() => {
expect(saveCallCount()).toBe(2);
});
await openResourceBookCategory('待归类');
fireEvent.click(getResourceSelectButton('blocked.md'));
expect(
getResourceSelectButton('blocked.md').getAttribute('aria-pressed'),
).toBe('true');
expect(screen.getByLabelText('陶泥儿 Agent 对话')).not.toBeNull();
expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull();
});
it('preserves internal media focus across same-resource manifest updates and falls back when the resource is deleted', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-focus-updates',
'资源焦点更新测试',
);
manifest.assets = [
{
id: 'focus-audio',
kind: 'background-music',
mediaType: 'audio/mpeg',
localPath: 'assets/focus.mp3',
source: { kind: 'generated' },
},
];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
if (command === 'read_local_project_media_preview') {
return {
path: 'assets/focus.mp3',
mediaType: 'audio/mpeg',
byteLen: 1024,
dataUrl: 'data:audio/mpeg;base64,SUQz',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
const viewProps = {
projectName: manifest.name,
projectPath: '/tmp/workbench-resource-focus-updates',
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
};
const rendered = render(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest,
}),
);
await openResourceBookCategory('音频');
fireEvent.click(await findResourceSelectButton('focus.mp3'));
expect(screen.getByRole('toolbar', { name: '素材工具栏' })).not.toBeNull();
const updatedManifest = {
...manifest,
tasks: manifest.tasks.map((task) =>
task.id === 'audio-director'
? { ...task, status: 'completed' as const }
: task,
),
assets: manifest.assets.map((asset) => ({
...asset,
source: { ...asset.source, taskId: 'audio-director' },
})),
};
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: updatedManifest,
}),
);
expect(screen.getByRole('toolbar', { name: '素材工具栏' })).not.toBeNull();
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
manifest: { ...updatedManifest, assets: [] },
}),
);
await waitFor(() => {
expect(screen.queryByRole('toolbar', { name: '素材工具栏' })).toBeNull();
expect(screen.queryByRole('toolbar', { name: '图片工具栏' })).toBeNull();
});
expect(
Array.from(
document.querySelectorAll<HTMLElement>('.game-resource-card-select'),
).some((card) => card.getAttribute('aria-pressed') === 'true'),
).toBe(false);
});
it('renders, filters, and destroys section-page resource dependency lines', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-graph',
'资源依赖图测试',
);
manifest.assets.push(
{
id: 'dependency-spec',
kind: 'metadata',
mediaType: 'application/json',
localPath: 'assets/spec-source.json',
source: {
kind: 'canvas',
taskId: 'task-1',
resourceId: 'canvas-spec-source',
},
},
{
id: 'dependency-ui',
kind: 'metadata',
mediaType: 'application/json',
localPath: 'assets/ui-dependency.json',
source: {
kind: 'canvas',
taskId: 'task-2',
resourceId: 'canvas-ui-target',
referenceResourceIds: ['canvas-spec-source'],
},
},
{
id: 'unrelated-cycle',
kind: 'metadata',
mediaType: 'application/json',
localPath: 'assets/unrelated-cycle.json',
source: {
kind: 'canvas',
resourceId: 'canvas-unrelated',
referenceResourceIds: ['canvas-unrelated'],
},
},
);
const referenceId =
'asset-reference:["asset:dependency-spec","asset:dependency-ui"]';
const selfReferenceId =
'asset-reference:["asset:unrelated-cycle","asset:unrelated-cycle"]';
const crossReferenceId =
'asset-reference:["asset:dependency-spec","asset:unrelated-cycle"]';
const flowId = 'task-flow:["art-director","design-foundation"]';
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
expect(args?.resources).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'asset:dependency-spec',
manifestAssetId: 'dependency-spec',
producerTaskId: null,
}),
]),
);
return {
resourceIds: [
'asset:dependency-spec',
'asset:dependency-ui',
'asset:unrelated-cycle',
],
referenceEdges: [
{
id: referenceId,
kind: 'asset-reference',
sourceResourceId: 'asset:dependency-spec',
targetResourceId: 'asset:dependency-ui',
cyclic: false,
},
{
id: selfReferenceId,
kind: 'asset-reference',
sourceResourceId: 'asset:unrelated-cycle',
targetResourceId: 'asset:unrelated-cycle',
cyclic: true,
},
{
id: crossReferenceId,
kind: 'asset-reference',
sourceResourceId: 'asset:dependency-spec',
targetResourceId: 'asset:unrelated-cycle',
cyclic: false,
},
],
taskFlows: [
{
id: flowId,
kind: 'task-flow',
sourceTaskId: 'art-director',
targetTaskId: 'design-foundation',
sourceResourceIds: ['asset:dependency-spec'],
targetResourceIds: ['asset:dependency-ui'],
cyclic: false,
},
],
connectionIndex: [
{
resourceId: 'asset:dependency-spec',
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: [
'asset:dependency-ui',
'asset:unrelated-cycle',
],
referenceEdgeIds: [referenceId, crossReferenceId],
taskFlowIds: [flowId],
},
{
resourceId: 'asset:dependency-ui',
upstreamReferenceResourceIds: ['asset:dependency-spec'],
downstreamReferenceResourceIds: [],
referenceEdgeIds: [referenceId],
taskFlowIds: [flowId],
},
{
resourceId: 'asset:unrelated-cycle',
upstreamReferenceResourceIds: [
'asset:dependency-spec',
'asset:unrelated-cycle',
],
downstreamReferenceResourceIds: ['asset:unrelated-cycle'],
referenceEdgeIds: [selfReferenceId, crossReferenceId],
taskFlowIds: [],
},
],
producerAssignments: [
{
resourceId: 'asset:dependency-spec',
taskId: 'art-director',
},
{
resourceId: 'asset:dependency-ui',
taskId: 'design-foundation',
},
],
dependencyDepths: [
{
resourceId: 'asset:dependency-spec',
dependencyDepth: 0,
},
{
resourceId: 'asset:dependency-ui',
dependencyDepth: 1,
},
{
resourceId: 'asset:unrelated-cycle',
dependencyDepth: 0,
},
],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: ['asset:unrelated-cycle'],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'workbench-resource-graph',
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'workbench-resource-graph',
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const view = render(
React.createElement(ProjectDevelopmentView, {
projectName: '资源依赖图测试',
projectPath: '/tmp/workbench-resource-graph',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('待归类');
let overlay = await screen.findByTestId(
'resource-dependency-overlay-unclassified',
);
await waitFor(() => {
expect(
overlay.querySelectorAll('[data-edge-kind="asset-reference"]'),
).toHaveLength(3);
expect(
overlay.querySelectorAll('[data-edge-kind="task-flow"]'),
).toHaveLength(0);
});
const dependencyCanvas = screen.getByLabelText('资源依赖视图');
const descriptionId = dependencyCanvas.getAttribute('aria-describedby');
expect(descriptionId).not.toBeNull();
const relationshipDescription = document.getElementById(descriptionId!);
expect(relationshipDescription?.textContent).toContain(
'ui-dependency.json 引用 spec-source.json',
);
expect(
overlay.querySelector(`[data-edge-id='${crossReferenceId}']`),
).not.toBeNull();
expect(relationshipDescription?.textContent).toContain(
'unrelated-cycle.json 引用 spec-source.json',
);
expect(relationshipDescription?.textContent).not.toContain('art-director');
expect(
Array.from(overlay.querySelectorAll('svg')).every(
(sectionOverlay) =>
sectionOverlay.getAttribute('aria-hidden') === 'true',
),
).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
expect(
screen.queryByTestId('resource-dependency-overlay-unclassified'),
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
overlay = await screen.findByTestId(
'resource-dependency-overlay-unclassified',
);
const dependencyWorld = dependencyCanvas.querySelector<HTMLElement>(
'.game-resource-canvas-content',
);
const boundaryBeforeSearch = dependencyWorld?.dataset.resourceBoundary;
const search = openResourceFilterPanel();
fireEvent.change(search, { target: { value: 'ui-dependency' } });
await waitFor(() => {
expect(overlay.querySelector('[data-edge-kind]')).toBeNull();
});
expect(dependencyWorld?.dataset.resourceBoundary).toBe(
boundaryBeforeSearch,
);
fireEvent.change(search, { target: { value: '' } });
let sourceCard = getResourceSelectButton('spec-source.json');
const targetCard = getResourceSelectButton('ui-dependency.json');
const referenceSelector =
'[data-edge-kind="asset-reference"]' +
'[data-source-resource-id="asset:dependency-spec"]' +
'[data-target-resource-id="asset:dependency-ui"]';
const firstPath = await waitFor(() => {
const path = overlay.querySelector(referenceSelector);
expect(path).not.toBeNull();
return path?.getAttribute('d');
});
const sourceCardContainer = sourceCard.closest<HTMLElement>(
'.game-resource-card',
);
expect(sourceCardContainer).not.toBeNull();
Object.defineProperties(sourceCardContainer, {
setPointerCapture: { configurable: true, value: vi.fn() },
hasPointerCapture: { configurable: true, value: vi.fn(() => true) },
releasePointerCapture: { configurable: true, value: vi.fn() },
});
const sourceStyle = sourceCardContainer?.getAttribute('style');
const layoutUpdatesBeforePointer = invoke.mock.calls.filter(
([command]) => command === 'update_local_project_resource_canvas_layout',
).length;
fireEvent.pointerDown(sourceCard, {
pointerId: 27,
button: 0,
clientX: 0,
clientY: 0,
});
fireEvent.pointerMove(sourceCard, {
pointerId: 27,
clientX: 72,
clientY: 28,
});
fireEvent.pointerUp(sourceCard, {
pointerId: 27,
clientX: 72,
clientY: 28,
});
await waitFor(() => {
expect(sourceCardContainer?.getAttribute('style')).not.toBe(sourceStyle);
expect(
overlay.querySelector(referenceSelector)?.getAttribute('d'),
).not.toBe(firstPath);
});
expect(sourceCardContainer?.classList.contains('is-dragging')).toBe(false);
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'update_local_project_resource_canvas_layout',
).length,
).toBeGreaterThan(layoutUpdatesBeforePointer);
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'update_local_project_resource_canvas_layout' &&
Array.isArray(args?.positions) &&
args.positions.some(
(position) =>
position.resourceId === 'asset:dependency-spec' &&
position.manuallyPlaced,
),
),
).toBe(true);
fireEvent.click(sourceCardContainer!);
fireEvent.click(targetCard.closest('.game-resource-card')!);
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
overlay = await screen.findByTestId(
'resource-dependency-overlay-unclassified',
);
sourceCard = getResourceSelectButton('spec-source.json');
await waitFor(() =>
expect(overlay.querySelector(referenceSelector)).not.toBeNull(),
);
expect(sourceCard.classList.contains('is-relation-upstream')).toBe(false);
expect(
overlay
.querySelector(referenceSelector)
?.classList.contains('is-highlighted'),
).toBe(false);
expect(
overlay
.querySelector('[data-source-resource-id="asset:unrelated-cycle"]')
?.classList.contains('is-dimmed'),
).toBe(false);
const previousReferencePaths = Array.from(
overlay.querySelectorAll('[data-edge-kind]'),
);
const nextManifest = createGameCreationAppManifest(
'workbench-resource-graph-next',
'新资源依赖图测试',
);
view.rerender(
React.createElement(ProjectDevelopmentView, {
projectName: '新资源依赖图测试',
projectPath: '/tmp/workbench-resource-graph-next',
manifest: nextManifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
expect(previousReferencePaths).not.toHaveLength(0);
await waitFor(() => {
expect(
previousReferencePaths.every(
(previousReferencePath) => !previousReferencePath.isConnected,
),
).toBe(true);
expect(
screen.queryByTestId('resource-dependency-overlay-unclassified'),
).toBeNull();
});
});
it('hides dependency edges whose code endpoint is not visible while preserving visible edges', async () => {
const visibleEdgeId = 'asset-reference:["art-a","art-b"]';
const hiddenEdgeId = 'asset-reference:["code","art-a"]';
const graph = normalizeProjectResourceGraph({
resourceIds: ['code', 'art-a', 'art-b'],
referenceEdges: [
{
id: visibleEdgeId,
kind: 'asset-reference',
sourceResourceId: 'art-a',
targetResourceId: 'art-b',
cyclic: false,
},
{
id: hiddenEdgeId,
kind: 'asset-reference',
sourceResourceId: 'code',
targetResourceId: 'art-a',
cyclic: false,
},
],
taskFlows: [],
connectionIndex: [],
producerAssignments: [],
dependencyDepths: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
});
const positions: ProjectResourceCanvasPosition[] = [
{
resourceId: 'code',
section: 'unclassified',
x: 0,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'art-a',
section: 'scene',
x: 0,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'art-b',
section: 'scene',
x: 0,
y: 144,
manuallyPlaced: false,
},
];
render(
React.createElement(ResourceDependencyOverlay, {
graph,
positions,
section: 'scene',
visibleResourceIds: new Set(['art-a', 'art-b']),
}),
);
const overlay = await screen.findByTestId(
'resource-dependency-overlay-scene',
);
await waitFor(() => {
const edgeById = (edgeId: string) =>
Array.from(
overlay.querySelectorAll<SVGPathElement>('[data-edge-id]'),
).find((edge) => edge.getAttribute('data-edge-id') === edgeId);
expect(edgeById(visibleEdgeId)).not.toBeNull();
expect(edgeById(hiddenEdgeId)).toBeUndefined();
});
});
it('coalesces section scroll geometry, keeps partial endpoints stable, and cleans one dependency observer', async () => {
const referenceId = 'asset-reference:["resource-a","resource-b"]';
const graph = normalizeProjectResourceGraph({
resourceIds: ['resource-a', 'resource-b'],
referenceEdges: [
{
id: referenceId,
kind: 'asset-reference',
sourceResourceId: 'resource-a',
targetResourceId: 'resource-b',
cyclic: false,
},
],
taskFlows: [],
connectionIndex: [
{
resourceId: 'resource-a',
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: ['resource-b'],
referenceEdgeIds: [referenceId],
taskFlowIds: [],
},
{
resourceId: 'resource-b',
upstreamReferenceResourceIds: ['resource-a'],
downstreamReferenceResourceIds: [],
referenceEdgeIds: [referenceId],
taskFlowIds: [],
},
],
producerAssignments: [],
dependencyDepths: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
});
const positions: ProjectResourceCanvasPosition[] = [
{
resourceId: 'resource-a',
section: 'scene',
x: 0,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'resource-b',
section: 'scene',
x: 0,
y: 144,
manuallyPlaced: false,
},
];
const resizeObserver = installResizeObserverStub();
const animationFrame = vi.spyOn(window, 'requestAnimationFrame');
const originalGetBoundingClientRect =
HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
function getSectionRect(this: HTMLElement) {
const rect = (
left: number,
top: number,
width: number,
height: number,
) =>
({
x: left,
y: top,
left,
top,
right: left + width,
bottom: top + height,
width,
height,
toJSON: () => ({}),
}) as DOMRect;
if (this.classList.contains('test-resource-content')) {
return rect(0, 0, 600, 800);
}
if (this.dataset.resourceSectionScroll === 'scene') {
return rect(0, 100, 600, 300);
}
if (this.dataset.resourceSectionPlane === 'scene') {
const viewport = this.closest<HTMLElement>(
'[data-resource-section-scroll="scene"]',
);
return rect(
-(viewport?.scrollLeft ?? 0),
100 - (viewport?.scrollTop ?? 0),
600,
400,
);
}
return originalGetBoundingClientRect.call(this);
},
);
const rendered = render(
React.createElement(
'div',
{ className: 'test-resource-outer' },
React.createElement(
'div',
{
className: 'test-resource-content',
'data-testid': 'resource-dependency-overlay',
},
React.createElement(
'section',
null,
React.createElement(
'div',
{
'data-resource-section-scroll': 'scene',
},
React.createElement(
'div',
{
'data-resource-section-plane': 'scene',
'data-resource-section-scale': '1',
},
React.createElement(ResourceDependencyOverlay, {
graph,
positions,
section: 'scene',
visibleResourceIds: new Set(['resource-a', 'resource-b']),
geometryRevision: '340@1',
}),
),
),
),
),
),
);
const overlay = await screen.findByTestId('resource-dependency-overlay');
const referenceSelector = `[data-edge-id='${referenceId}']`;
await waitFor(() => {
const referencePath = overlay.querySelector(referenceSelector);
expect(referencePath?.getAttribute('d')).toBeTruthy();
expect(referencePath?.getAttribute('data-route-axis')).toBe('vertical');
expect(referencePath?.getAttribute('marker-end')).toMatch(
/^url\(#.+-asset-reference-arrow\)$/u,
);
});
expect(resizeObserver.observerCount()).toBe(1);
expect(
overlay
.querySelector('[data-testid="resource-dependency-overlay-scene"]')
?.parentElement?.getAttribute('data-resource-section-plane'),
).toBe('scene');
expect(overlay.querySelector('clipPath')).toBeNull();
const viewport = rendered.container.querySelector<HTMLElement>(
'[data-resource-section-scroll="scene"]',
);
if (!viewport) {
throw new Error('missing scene section viewport');
}
const removeViewportListener = vi.spyOn(viewport, 'removeEventListener');
const referenceBeforeScroll = overlay.querySelector(referenceSelector);
expect(referenceBeforeScroll).not.toBeNull();
const frameCountBeforeScroll = animationFrame.mock.calls.length;
viewport.scrollTop = 80;
fireEvent.scroll(viewport);
fireEvent.scroll(viewport);
fireEvent.scroll(viewport);
// A geometry frame can already be pending from the initial height pass.
// In that case every scroll reuses it; otherwise the first scroll schedules
// exactly one frame. Both cases satisfy the one-frame coalescing contract.
expect(
animationFrame.mock.calls.length - frameCountBeforeScroll,
).toBeLessThanOrEqual(1);
await waitFor(() => {
expect(overlay.querySelector(referenceSelector)).toBe(
referenceBeforeScroll,
);
expect(
overlay
.querySelector('[data-testid="resource-dependency-overlay-scene"]')
?.getAttribute('data-logical-viewport'),
).toBe('0,80,600,300');
});
viewport.scrollTop = 0;
fireEvent.scroll(viewport);
await waitFor(() =>
expect(overlay.querySelector(referenceSelector)).toBe(
referenceBeforeScroll,
),
);
rendered.unmount();
expect(resizeObserver.observerDisconnected()).toBe(true);
expect(removeViewportListener).toHaveBeenCalledWith(
'scroll',
expect.any(Function),
);
});
it('waits for the scoped resource graph before initializing dependency layout', async () => {
const projectId = 'workbench-delayed-resource-graph';
const projectPath = '/tmp/workbench-delayed-resource-graph';
const manifest = createGameCreationAppManifest(projectId, '延迟依赖图测试');
const agentResults = [
{
agentId: 'design-foundation',
runId: 'delayed-graph-run',
label: '玩法策划 Agent',
title: '延迟依赖图回执',
content: '图就绪后再初始化布局',
updatedAt: 1,
},
];
let resolveGraph: (() => void) | null = null;
let dependencyLayoutReads = 0;
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return await new Promise((resolve) => {
resolveGraph = () => resolve(resourceGraphForInputs(args));
});
}
if (command === 'read_local_project_resource_canvas_layout') {
if (args?.mode === 'dependency') {
dependencyLayoutReads += 1;
}
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
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: '延迟依赖图测试',
projectPath,
manifest,
attachments: [],
agentResults,
chat: React.createElement('div', null, '项目总控'),
}),
);
await openResourceBookCategory('文档');
await waitFor(() => expect(resolveGraph).not.toBeNull());
expect(dependencyLayoutReads).toBe(0);
expect(
screen.queryByRole('button', {
name: '选中资源:文档 延迟依赖图回执',
}),
).toBeNull();
await act(async () => {
resolveGraph?.();
await Promise.resolve();
});
expect(
await screen.findByRole('button', {
name: '选中资源:文档 延迟依赖图回执',
}),
).not.toBeNull();
expect(dependencyLayoutReads).toBe(1);
});
it('keeps resource cards mounted across equivalent rerenders and graph refreshes', async () => {
const projectId = 'workbench-stable-resource-graph';
const projectPath = '/tmp/workbench-stable-resource-graph';
const manifest = createGameCreationAppManifest(projectId, '稳定依赖图测试');
const firstResult = {
agentId: 'design-foundation',
runId: 'stable-graph-run',
label: '玩法策划 Agent',
title: '稳定依赖图回执',
content: '轮询刷新时保留现有资源卡',
updatedAt: 1,
};
let graphReads = 0;
let layoutRevision = 0;
let resolveGraphRefresh: (() => void) | null = null;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
graphReads += 1;
if (graphReads === 1) {
return resourceGraphForInputs(args);
}
return await new Promise((resolve) => {
resolveGraphRefresh = () => resolve(resourceGraphForInputs(args));
});
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: 'dependency',
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: 'dependency',
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const renderView = (agentResults: ProjectAgentResultSummary[]) =>
React.createElement(ProjectDevelopmentView, {
projectName: '稳定依赖图测试',
projectPath,
manifest,
attachments: [],
agentResults,
chat: React.createElement('div', null, '项目总控'),
});
const view = render(renderView([firstResult]));
await openResourceBookCategory('文档');
const firstCardButton = await screen.findByRole('button', {
name: '选中资源:文档 稳定依赖图回执',
});
const firstCard = firstCardButton.closest('.game-resource-card');
expect(firstCard).not.toBeNull();
expect(graphReads).toBe(1);
await act(async () => {
view.rerender(renderView([{ ...firstResult }]));
await Promise.resolve();
});
expect(graphReads).toBe(1);
expect(firstCard?.isConnected).toBe(true);
const secondResult = {
agentId: 'code-director',
runId: 'stable-graph-code-run',
label: '程序 Agent',
title: '刷新期间新增回执',
content: '新图返回前也不清空旧布局',
updatedAt: 2,
};
view.rerender(renderView([{ ...firstResult }, secondResult]));
await waitFor(() => expect(graphReads).toBe(2));
expect(resolveGraphRefresh).not.toBeNull();
expect(firstCard?.isConnected).toBe(true);
expect(
screen.getByRole('button', {
name: '选中资源:文档 稳定依赖图回执',
}),
).not.toBeNull();
await act(async () => {
resolveGraphRefresh?.();
await Promise.resolve();
});
expect(
await screen.findByRole('button', {
name: '选中资源:文档 刷新期间新增回执',
}),
).not.toBeNull();
expect(firstCard?.isConnected).toBe(true);
});
it('keeps trusted truncated-graph depths through the workbench without persisting a flat automatic layout', async () => {
const projectId = 'workbench-truncated-resource-graph';
const projectPath = '/tmp/workbench-truncated-resource-graph';
const manifest = createGameCreationAppManifest(
projectId,
'截断依赖图布局保护测试',
);
manifest.assets.push(
{
id: 'truncated-depth-0',
kind: 'design-spec',
mediaType: 'application/json',
localPath: 'assets/truncated-depth-0.json',
source: {
kind: 'canvas',
resourceId: 'external-truncated-depth-0',
},
},
{
id: 'truncated-depth-1',
kind: 'metadata',
mediaType: 'application/json',
localPath: 'assets/truncated-depth-1.json',
source: {
kind: 'canvas',
resourceId: 'external-truncated-depth-1',
referenceResourceIds: ['external-truncated-depth-0'],
},
},
{
id: 'truncated-depth-2',
kind: 'metadata',
mediaType: 'application/json',
localPath: 'assets/truncated-depth-2.json',
source: {
kind: 'canvas',
resourceId: 'external-truncated-depth-2',
referenceResourceIds: ['external-truncated-depth-1'],
},
},
);
const slotWidth =
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP;
const existingPositions: ProjectResourceCanvasPosition[] = [
{
resourceId: 'asset:truncated-depth-0',
section: 'unclassified',
x: 0,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'asset:truncated-depth-1',
section: 'unclassified',
x: slotWidth,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'asset:truncated-depth-2',
section: 'unclassified',
x: slotWidth * 2,
y: 0,
manuallyPlaced: false,
},
];
const layoutUpdates: Array<{
mode: unknown;
positions: ProjectResourceCanvasPosition[];
}> = [];
let layoutReads = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return {
resourceIds: existingPositions.map(({ resourceId }) => resourceId),
referenceEdges: [
{
id: 'reference:truncated-0-1',
kind: 'asset-reference',
sourceResourceId: 'asset:truncated-depth-0',
targetResourceId: 'asset:truncated-depth-1',
cyclic: false,
},
{
id: 'reference:truncated-1-2',
kind: 'asset-reference',
sourceResourceId: 'asset:truncated-depth-1',
targetResourceId: 'asset:truncated-depth-2',
cyclic: false,
},
],
taskFlows: [
{
id: 'flow:untrusted-producer',
kind: 'task-flow',
sourceTaskId: 'art-director',
targetTaskId: 'design-foundation',
sourceResourceIds: ['asset:truncated-depth-0'],
targetResourceIds: ['asset:truncated-depth-1'],
cyclic: true,
},
],
connectionIndex: [
{
resourceId: 'asset:truncated-depth-0',
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: ['asset:truncated-depth-1'],
referenceEdgeIds: ['reference:truncated-0-1'],
taskFlowIds: ['flow:untrusted-producer'],
},
{
resourceId: 'asset:truncated-depth-1',
upstreamReferenceResourceIds: ['asset:truncated-depth-0'],
downstreamReferenceResourceIds: ['asset:truncated-depth-2'],
referenceEdgeIds: [
'reference:truncated-0-1',
'reference:truncated-1-2',
],
taskFlowIds: ['flow:untrusted-producer'],
},
{
resourceId: 'asset:truncated-depth-2',
upstreamReferenceResourceIds: ['asset:truncated-depth-1'],
downstreamReferenceResourceIds: [],
referenceEdgeIds: ['reference:truncated-1-2'],
taskFlowIds: [],
},
],
producerAssignments: [
{
resourceId: 'asset:truncated-depth-0',
taskId: 'art-director',
},
],
dependencyDepths: [
{
resourceId: 'asset:truncated-depth-0',
dependencyDepth: 0,
},
{
resourceId: 'asset:truncated-depth-1',
dependencyDepth: 1,
},
{
resourceId: 'asset:truncated-depth-2',
dependencyDepth: 2,
},
],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: ['art-director'],
producerMappingTruncated: true,
};
}
if (command === 'read_local_project_resource_canvas_layout') {
layoutReads += 1;
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: args?.mode,
revision: 7,
positions: structuredClone(existingPositions),
updatedAt: 7,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutUpdates.push({
mode: args?.mode,
positions: structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
),
});
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: args?.mode,
revision: 8,
positions: structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
),
updatedAt: 8,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '截断依赖图布局保护测试',
projectPath,
manifest,
attachments: [],
agentResults: [],
chat: React.createElement('div', null, '项目总控'),
}),
);
await waitFor(() => expect(layoutReads).toBe(1));
await openResourceBookCategory('待归类');
const cards = [0, 1, 2].map((depth) =>
getResourceSelectButton(`truncated-depth-${depth}.json`).closest(
'.game-resource-card',
),
);
await waitFor(() => {
expect(cards[0]?.getAttribute('style')).toContain('--resource-x: 0px');
expect(cards[1]?.getAttribute('style')).toContain(
`--resource-x: ${slotWidth}px`,
);
expect(cards[2]?.getAttribute('style')).toContain(
`--resource-x: ${slotWidth * 2}px`,
);
});
await act(async () => {
await Promise.resolve();
});
expect(
layoutUpdates.filter((update) => update.mode === 'dependency'),
).toEqual([]);
});
it('moves historical resource positions from pointer input and persists one manual CAS', async () => {
const projectId = 'workbench-layout-persistence';
const projectPath = '/tmp/workbench-layout-persistence';
const resourceId = 'agent-result:design-foundation:layout-result-run';
const manifest = createGameCreationAppManifest(projectId, '布局持久化测试');
const agentResults = [
{
agentId: 'design-foundation',
runId: 'layout-result-run',
label: '玩法策划 Agent',
title: '布局持久化回执',
content: '布局持久化正文',
updatedAt: 1,
},
];
const persistedLayout: ProjectResourceCanvasLayout = {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: 'dependency',
revision: 1,
positions: [
{
resourceId,
section: 'document',
x: 12,
y: 24,
manuallyPlaced: true,
},
],
updatedAt: 100,
};
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return structuredClone(persistedLayout);
}
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: 2,
positions: args?.positions,
updatedAt: 101,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
function renderWorkbench() {
render(
React.createElement(ProjectDevelopmentView, {
projectName: '布局持久化测试',
projectPath,
manifest,
attachments: [],
agentResults,
chat: React.createElement('div', null, '项目总控'),
}),
);
}
renderWorkbench();
await openResourceBookCategory('文档');
const cardButton = await screen.findByRole('button', {
name: '选中资源:文档 布局持久化回执',
});
const card = cardButton.closest('.game-resource-card');
expect(card).not.toBeNull();
await waitFor(() => {
expect(card?.getAttribute('style')).toContain('--resource-x: 12px');
expect(card?.getAttribute('style')).toContain('--resource-y: 24px');
});
expect(card).not.toBeNull();
const viewportElement = document.querySelector<HTMLElement>(
'.game-resource-page-canvas[data-resource-section-scroll="document"] [data-resource-viewport]',
);
const viewportScale = Number(
viewportElement?.getAttribute('data-resource-viewport')?.split(',').at(2),
);
expect(viewportScale).toBeGreaterThan(0);
const expectedX = Math.round(12 + (-100 - 20) / viewportScale);
const expectedY = Math.round(24 + (60 - 30) / viewportScale);
const setCardPointerCapture = vi.fn();
Object.defineProperties(card, {
setPointerCapture: { configurable: true, value: setCardPointerCapture },
hasPointerCapture: { configurable: true, value: vi.fn(() => true) },
releasePointerCapture: { configurable: true, value: vi.fn() },
});
fireEvent.pointerDown(cardButton, {
pointerId: 11,
button: 0,
clientX: 20,
clientY: 30,
});
expect(setCardPointerCapture).toHaveBeenCalledWith(11);
fireEvent.pointerMove(cardButton, {
pointerId: 11,
clientX: -100,
clientY: 60,
});
fireEvent.pointerUp(cardButton, {
pointerId: 11,
clientX: -100,
clientY: 60,
});
fireEvent.click(card!);
expect(screen.queryByRole('toolbar', { name: '图片工具栏' })).toBeNull();
await waitFor(() => {
expect(card?.getAttribute('style')).toContain(
`--resource-x: ${expectedX}px`,
);
expect(card?.getAttribute('style')).toContain(
`--resource-y: ${expectedY}px`,
);
});
expect(invoke).toHaveBeenCalledWith(
'update_local_project_resource_canvas_layout',
expect.objectContaining({
positions: expect.arrayContaining([
expect.objectContaining({
resourceId,
section: 'document',
x: expectedX,
y: expectedY,
manuallyPlaced: true,
}),
]),
}),
);
fireEvent.click(card!);
// 该卡片是 Agent 回执(没有正式素材身份),选中反馈由卡片自身承担。
expect(card?.querySelector('[aria-pressed="true"]')).not.toBeNull();
cleanup();
renderWorkbench();
await openResourceBookCategory('文档');
const restoredCard = (
await screen.findByRole('button', {
name: '选中资源:文档 布局持久化回执',
})
).closest('.game-resource-card');
await waitFor(() => {
expect(restoredCard?.getAttribute('style')).toContain(
'--resource-x: 12px',
);
expect(restoredCard?.getAttribute('style')).toContain(
'--resource-y: 24px',
);
});
});
it('opens resource details after a plain pointer press without treating it as a drag', async () => {
const projectId = 'workbench-card-click-no-drag';
const projectPath = '/tmp/workbench-card-click-no-drag';
const manifest = createGameCreationAppManifest(
projectId,
'卡片点击不误判拖动测试',
);
manifest.assets.push({
id: 'clickable-art',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/clickable.png',
source: { kind: 'generated' },
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '卡片点击不误判拖动测试',
projectPath,
manifest,
attachments: [],
chat: React.createElement('div', null, '项目总控'),
}),
);
await openResourceBookCategory('角色与对象');
const cardButton = await findResourceSelectButton('clickable.png');
fireEvent.pointerDown(cardButton, {
pointerId: 7,
button: 0,
clientX: 40,
clientY: 50,
});
fireEvent.pointerMove(cardButton, {
pointerId: 7,
clientX: 42,
clientY: 51,
});
fireEvent.pointerUp(cardButton, {
pointerId: 7,
clientX: 42,
clientY: 51,
});
fireEvent.click(cardButton.closest('.game-resource-card')!);
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'update_local_project_resource_canvas_layout' &&
Array.isArray(args?.positions) &&
args.positions.some(
(position) =>
position.resourceId === 'asset:clickable-art' &&
position.manuallyPlaced,
),
),
).toBe(false);
});
it('does not leave a stale click suppression after a cancelled card drag', async () => {
const projectId = 'workbench-card-drag-cancel';
const projectPath = '/tmp/workbench-card-drag-cancel';
const manifest = createGameCreationAppManifest(
projectId,
'卡片拖拽取消点击测试',
);
manifest.assets.push({
id: 'cancelable-art',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/cancelable.png',
source: { kind: 'generated' },
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '卡片拖拽取消点击测试',
projectPath,
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('角色与对象');
const cardButton = await findResourceSelectButton('cancelable.png');
const card = cardButton.closest<HTMLElement>('.game-resource-card');
expect(card).not.toBeNull();
Object.defineProperties(card!, {
setPointerCapture: { configurable: true, value: vi.fn() },
hasPointerCapture: { configurable: true, value: vi.fn(() => true) },
releasePointerCapture: { configurable: true, value: vi.fn() },
});
fireEvent.pointerDown(card, {
pointerId: 41,
button: 0,
clientX: 40,
clientY: 50,
});
fireEvent.pointerMove(card, {
pointerId: 41,
clientX: 120,
clientY: 50,
});
fireEvent.pointerCancel(card, { pointerId: 41 });
fireEvent.click(card!);
expect(card?.querySelector('[aria-pressed="true"]')).not.toBeNull();
});
it('maps manifest asset kinds into stable type layout ordering', async () => {
const projectId = 'workbench-layout-asset-subtypes';
const manifest = createGameCreationAppManifest(
projectId,
'资源子类型排序测试',
);
manifest.assets.push(
{
id: 'ui-design-last-by-label',
kind: 'ui-design',
mediaType: 'image/png',
localPath: 'assets/a-ui-design.png',
source: { kind: 'generated', taskId: 'design-foundation' },
},
{
id: 'icon-spritesheet-first-by-label',
kind: 'icon-spritesheet',
mediaType: 'image/png',
localPath: 'assets/z-icon-spritesheet.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
);
const updates: Array<{
mode: 'dependency' | 'type';
positions: ProjectResourceCanvasPosition[];
}> = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
const mode = args?.mode as 'dependency' | 'type';
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
const positions = structuredClone(
args?.positions as ProjectResourceCanvasPosition[],
);
updates.push({ mode, positions });
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode,
revision: 1,
positions,
updatedAt: 1,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '资源子类型排序测试',
projectPath: '/tmp/workbench-layout-asset-subtypes',
manifest,
attachments: [],
agentResults: [],
chat: React.createElement('div', null, '项目总控'),
}),
);
await openResourceBookCategory('UI 交互');
await waitFor(() =>
expect(updates.some(({ mode }) => mode === 'dependency')).toBe(true),
);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
await waitFor(() =>
expect(updates.some(({ mode }) => mode === 'type')).toBe(true),
);
const typePositions = updates.find(
({ mode }) => mode === 'type',
)?.positions;
expect(typePositions).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'asset:icon-spritesheet-first-by-label',
x: 0,
y: 0,
}),
expect.objectContaining({
resourceId: 'asset:ui-design-last-by-label',
x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
y: 0,
}),
]),
);
});
it('keeps newly reconciled resources visible when their automatic layout save fails', async () => {
const projectId = 'workbench-layout-save-failure';
const manifest = createGameCreationAppManifest(projectId, '布局失败测试');
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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,
mode: 'dependency',
revision: 2,
positions: [],
updatedAt: 200,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
throw new Error('disk full');
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '布局失败测试',
projectPath: '/tmp/workbench-layout-save-failure',
manifest,
attachments: [],
agentResults: [
{
agentId: 'design-foundation',
runId: 'save-failure-run',
label: '玩法策划 Agent',
title: '自动排版失败回执',
content: '自动排版失败正文',
updatedAt: 1,
},
],
chat: React.createElement('div', null, '项目总控'),
}),
);
expect(
await screen.findByText('布局保存失败,已保留当前会话布局'),
).not.toBeNull();
await openResourceBookCategory('文档');
expect(
await screen.findByRole('button', {
name: '选中资源:文档 自动排版失败回执',
}),
).not.toBeNull();
});
it('keeps the landscape workbench edge-to-edge with internal chat scrolling', () => {
const styles = readFileSync(
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
expect(styles).toMatch(
/\.game-project-workbench\s*\{[^}]*width:\s*calc\(100vw - var\(--launcher-sidebar-width\)\)[^}]*padding:\s*0[^}]*overflow:\s*hidden/s,
);
expect(styles).toMatch(
/\.launcher-main:has\(\.game-project-workbench\)\s*\{[^}]*padding-bottom:\s*0/s,
);
expect(styles).toMatch(
/\.game-workbench-toolbar\s*\{[^}]*position:\s*relative[^}]*padding:\s*8px 12px[^}]*background:\s*transparent/s,
);
expect(styles).toMatch(
/\.game-workbench-view-tabs\s*\{[^}]*display:\s*flex[^}]*align-items:\s*center/s,
);
// 播放按钮跟着「资源管理 / 运行」左对齐,不再居中悬浮。
expect(styles).toMatch(
/\.game-workbench-view-tabs \.game-workbench-play-button\s*\{[^}]*position:\s*static[^}]*transform:\s*none/s,
);
expect(styles).not.toMatch(
/\.game-workbench-play-button\s*\{[^}]*position:\s*absolute/s,
);
// 新位置必须并进按钮基础外观与焦点环的规则列表:不然播放按钮会掉成零圆角、零内边距、
// 无边框、默认字号的裸按钮,而颜色规则看起来仍然生效。
expect(styles).toMatch(
/\.game-workbench-tabs button,\s*\.game-workbench-view-tabs button,\s*\.game-workbench-view-actions button\s*\{[^}]*border-radius:\s*999px/s,
);
expect(styles).toMatch(
/\.game-workbench-view-tabs button:focus-visible,\s*\.game-workbench-view-actions button:focus-visible/s,
);
expect(styles).toMatch(
/@media \(min-width: 761px\)[\s\S]*?\.game-project-workbench\s*\{[^}]*grid-template-rows:\s*minmax\(0, 1fr\) auto[^}]*height:\s*100dvh/,
);
expect(styles).toMatch(
/\.game-workbench-chat \.project-chat-conversation\s*\{[^}]*position:\s*relative[^}]*display:\s*block[^}]*height:\s*100%[^}]*min-height:\s*0[^}]*overflow:\s*hidden/s,
);
// 输入盒里的弹层不能被上面这条(连同 surface、聊天列共三层)裁掉:控制排最左侧是
// 「推理档」,它的菜单贴着触发钮右缘向左展开,窄布局(视口 ≤1000px 时面板只有
// 280px 宽)下会伸到面板左侧之外,档位文字正好落在被裁掉的那半边,点开只剩一个空
// 盒子。所以 direct-codex 这三层的裁切必须放开;菜单位置和尺寸不变,真机几何
// (整块可见、位置不动)由浏览器实测确认,这里只钉声明。
expect(styles).toMatch(
/\.game-workbench-chat:has\(\s*\.project-chat-composer\.is-direct-codex\s*\)\s*\{[^}]*overflow:\s*visible/s,
);
expect(styles).toMatch(
/\.game-workbench-chat \.project-chat-surface\.is-direct-codex\s*\{[^}]*overflow:\s*visible/s,
);
expect(styles).toMatch(
/\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-conversation\s*\{[^}]*overflow:\s*visible/s,
);
expect(styles).toMatch(
/\.game-workbench-chat \.project-chat-message-list\s*\{[^}]*height:\s*100%[^}]*min-height:\s*96px[^}]*overflow-y:\s*auto[^}]*padding-bottom:\s*12px[^}]*scroll-padding-bottom:\s*12px/s,
);
// direct-codex 的列表不再绝对定位在会话区上(那是"输入区浮在列表之上"那版几何):
// 它在文档流里靠 `flex: 1 1 auto` 吸收剩余高度,是整块面板唯一的滚动区。最终生效几何
// 由 tests/chatDialogFrameLayout.test.ts 按层叠求值验证,这里只钉住这两条声明在场。
const directMessageListRule =
styles.match(
/\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-message-list\s*\{([^}]*)\}/s,
)?.[1] ?? '';
expect(directMessageListRule).toContain('position: relative;');
expect(directMessageListRule).toContain('flex: 1 1 auto;');
expect(styles).toMatch(
/\.game-workbench-chat \.agent-runtime-status\s*\{[^}]*max-height:\s*clamp\(120px, 24dvh, 240px\)[^}]*overflow-y:\s*auto/s,
);
expect(styles).toMatch(
/\.game-workbench-chat \.project-runtime-summary\s*\{[^}]*position:\s*sticky[^}]*top:\s*-10px/s,
);
// 输入盒是两行网格(文本区 / 控制排)的文档流块,不再是贴在列表下边的
// 绝对定位浮层,也不再与 82px 的发送钮列共用网格。
const composerRule = styles.match(
/\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-composer\.is-direct-codex\s*\{([^}]*)\}/s,
);
expect(composerRule?.[1]).not.toBeUndefined();
expect(composerRule?.[1]).toContain('position: relative;');
expect(composerRule?.[1]).toContain('grid-template-rows: auto auto;');
expect(composerRule?.[1]).toContain('z-index: 1;');
// 四边留白统一 16px、盒内内边距四边同为 12px(文字左内缩必须等于上内缩);
// 不允许再出现 `8px 12px 10px` / `10px 12px` 这类「左右一个值、上下另一个值」的写法。
expect(composerRule?.[1]).toContain('margin: 16px;');
expect(composerRule?.[1]).toContain('padding: 12px;');
expect(composerRule?.[1]).not.toContain('padding-top:');
expect(composerRule?.[1]).toContain(
'background: var(--platform-input-fill);',
);
expect(composerRule?.[1]).not.toContain('border-top:');
expect(styles).toMatch(
/\.game-workbench-chat \.pending-command-actions button\s*\{[^}]*min-width:\s*52px[^}]*flex:\s*0 0 auto/s,
);
expect(styles).toMatch(
/\.game-workbench-chat\s+\.agent-runtime-status\s+\.project-runtime-pending-command\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) 136px/s,
);
expect(styles).toMatch(
/\.game-workbench-chat \.project-runtime-pending-command\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) 136px/s,
);
// 提交按钮这条只发给 `.project-chat-submit-button`:以前是 composer 下所有
// `button`,会把绝对定位广播到输入区里的「AI 润色」上,把它浮到文本区中间。
// 旧版这里还钉着 `min-height: 72px` 的整行提交条;Codex 版的提交钮是控制排里的
// 28px 方钮,尺寸规则与 `+` / `@` 两只方钮同组,这里改成钉那组规则。
expect(styles).toMatch(
/\.project-chat-composer-controls\s+\.project-chat-attachment-trigger,[\s\S]*?\.project-chat-composer-controls\s+\.project-chat-submit-button\s*\{[^}]*width:\s*28px[^}]*flex:\s*0 0 28px/s,
);
expect(styles).not.toMatch(
/\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-composer\s+button\s*\{/s,
);
expect(styles).toMatch(
/\.game-run-surface\s*\{[^}]*grid-template-rows:\s*minmax\(300px, 1fr\) auto[^}]*grid-row:\s*2 \/ -1[^}]*height:\s*100%/s,
);
expect(styles).toMatch(
/\.local-game-preview-frame\s*\{[^}]*position:\s*relative[^}]*width:\s*100%[^}]*height:\s*100%[^}]*min-width:\s*0[^}]*min-height:\s*0[^}]*overflow:\s*hidden/s,
);
expect(styles).toMatch(
/\.local-game-preview-frame iframe\s*\{[^}]*position:\s*absolute[^}]*top:\s*50%[^}]*left:\s*50%[^}]*display:\s*block[^}]*max-width:\s*none[^}]*max-height:\s*none[^}]*border:\s*0[^}]*transform-origin:\s*center/s,
);
const runPreviewIframeRule =
styles.match(/\.game-run-preview iframe\s*\{([^}]*)\}/s)?.[1] ?? '';
expect(runPreviewIframeRule).toContain('min-height: 0;');
expect(runPreviewIframeRule).not.toMatch(
/(?:^|;)\s*(?:position|inset|width|height)\s*:/,
);
expect(styles).not.toMatch(/\.game-run-slice-controls/);
const agentDockRule = styles.match(/\.game-agent-dock\s*\{([^}]*)\}/s);
expect(agentDockRule?.[1]).toContain('overflow: visible;');
expect(agentDockRule?.[1]).not.toContain('overflow-x:');
expect(agentDockRule?.[1]).toContain('z-index: 30;');
expect(agentDockRule?.[1]).toContain('isolation: isolate;');
expect(agentDockRule?.[1]).toContain('min-height: 58px;');
expect(styles).toMatch(
/\.game-agent-dock-item\s*\{[^}]*flex:\s*1 1 128px[^}]*min-width:\s*0[^}]*max-width:\s*170px/s,
);
expect(styles).toMatch(
/@media \(max-width: 760px\)[\s\S]*?\.game-workbench-layout\s*\{[^}]*height:\s*auto/,
);
expect(styles).toMatch(
/@media \(max-width: 760px\)[\s\S]*?\.project-chat-surface\.is-direct-codex\s+\.project-chat-message-list\s*\{[^}]*padding-bottom:\s*0[^}]*scroll-padding-bottom:\s*0[^}]*background:\s*transparent/s,
);
expect(styles).toMatch(
/\.game-resource-canvas-content\s*\{[^}]*width:\s*100%[^}]*min-width:\s*max\(100%, 620px\)/s,
);
expect(styles).toMatch(
/\.game-resource-section\s*\{[^}]*grid-template-rows:\s*auto minmax\(0, 1fr\)[^}]*height:\s*var\(--resource-section-height\)[^}]*overflow:\s*hidden/s,
);
expect(styles).toMatch(
/\.game-resource-section-viewport\s*\{[^}]*overflow:\s*auto[^}]*overscroll-behavior:\s*contain/s,
);
expect(styles).not.toMatch(
/\.game-resource-section\s*\{[^}]*position:\s*absolute/s,
);
});
it('keeps the wallet entry available when the workbench opens the UI editor', () => {
const styles = readFileSync(
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const projectDevelopmentSource = readFileSync(
repoPath(
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
),
'utf8',
);
expect(styles).toMatch(
/\.launcher-main:has\(\.game-project-workbench\)\s*>\s*\.launcher-account-bar\s*\{[^}]*display:\s*none/s,
);
expect(projectDevelopmentSource).toMatch(
/<UiEditorPage[\s\S]*?walletEntry=\{walletEntry\}[\s\S]*?\/>/,
);
// `walletEntry` 现在落在布局层:策划工作台一处(`planningStartMode` 分支),主工作台
// 一处且只在非 UI 编辑器路由下渲染(编辑器自己会用同一份 walletEntry 渲染头部钱包)。
const layoutWalletSlots = projectDevelopmentSource.match(
/<div className="game-workbench-layout-account">\{walletEntry\}<\/div>/g,
);
expect(layoutWalletSlots).toHaveLength(2);
expect(projectDevelopmentSource).toMatch(
/\{!uiEditorRoute && walletEntry \? \(/,
);
expect(projectDevelopmentSource).toMatch(/walletEntry=\{walletEntry\}/);
expect(projectDevelopmentSource).toMatch(
/const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*!uiEditorRoute\s*;/s,
);
expect(projectDevelopmentSource).toMatch(
/aria-describedby=\{\s*showRunUnavailableHint\s*\?\s*'run-unavailable-hint'\s*:\s*undefined\s*\}/s,
);
expect(projectDevelopmentSource).toMatch(
/\{showRunUnavailableHint\s*\?\s*\(\s*<p\s+id="run-unavailable-hint"/s,
);
});
it('restores resource canvas panning after returning from the UI editor', async () => {
installResizeObserverStub();
const manifest = createGameCreationAppManifest(
'workbench-ui-editor-return-pan',
'UI 编辑器返回平移测试',
);
manifest.assets = [
{
id: 'ui-design-resource',
kind: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
mediaType: GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
localPath: 'assets/ui-design.json',
source: { kind: 'generated' },
},
];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
if (command === 'load_ui_design_state') {
return {
revision: 0,
state: {
ui_trees: [],
ui_design_images: {},
sprite_assets: {},
font_assets: {},
},
};
}
if (command === 'read_local_project_text_preview') {
return {
path: 'assets/ui-design.json',
mediaType: 'application/json',
byteLen: 2,
content: '{}',
uiDesignAssetId: 'ui-design-resource',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-ui-editor-return-pan',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('UI 交互');
const canvas = await screen.findByRole('region', { name: 'UI 交互' });
const scene = document.querySelector<HTMLElement>(
'[data-resource-book-view="child"] .game-resource-book-scene',
);
const wheel = (target: HTMLElement, deltaY: number) => {
const event = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY,
clientX: 120,
clientY: 100,
});
act(() => target.dispatchEvent(event));
expect(event.defaultPrevented).toBe(true);
};
wheel(scene ?? canvas, 120);
const world = canvas.querySelector<HTMLElement>('[data-resource-viewport]');
expect(world?.getAttribute('data-resource-viewport')).toMatch(
/^(?!48,48,1)/u,
);
fireEvent.click(
screen.getByRole('button', {
name: '选中资源:UI 交互 ui-design.json',
}),
);
fireEvent.click(await screen.findByRole('button', { name: 'UI 编辑器' }));
await screen.findByRole('button', { name: '返回资源' });
fireEvent.click(screen.getByRole('button', { name: '返回资源' }));
await waitFor(() =>
expect(screen.queryByRole('button', { name: '返回资源' })).toBeNull(),
);
const restoredCanvas = await screen.findByRole('region', {
name: 'UI 交互',
});
const restoredWorld = restoredCanvas.querySelector<HTMLElement>(
'[data-resource-viewport]',
);
const beforeReturn = world?.getAttribute('data-resource-viewport');
const restoredBeforePan = restoredWorld?.getAttribute(
'data-resource-viewport',
);
expect(restoredBeforePan).toBe(beforeReturn);
const restoredScene = document.querySelector<HTMLElement>(
'[data-resource-book-view="child"] .game-resource-book-scene',
);
const restoredWheelEvent = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: 120,
clientX: 120,
clientY: 100,
});
act(() =>
(restoredScene ?? restoredCanvas).dispatchEvent(restoredWheelEvent),
);
expect(restoredWheelEvent.defaultPrevented).toBe(true);
expect(restoredWorld?.getAttribute('data-resource-viewport')).not.toBe(
restoredBeforePan,
);
});
it('clears the resource preview cache and cancels the old preview scope on project switch', async () => {
// 60d8b8fbb 删掉了从未被写入的 `resourcePreviewVersionByResourceId` 死接线,原先钉
// 那条 prop 与它的清空语句的断言随之取消;用例真正要守的意图不变——项目切换必须把
// 预览缓存和旧 scope 的在途读取一起清掉,所以这里改成直接观察外部可见行为。
const manifest = createGameCreationAppManifest(
'workbench-preview-scope-switch',
'预览缓存清理项目',
);
manifest.assets = [
{
id: 'hero-image',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
];
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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: [],
updatedAt: layoutRevision,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
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 === 'cancel_local_project_resource_preview_scope') {
return null;
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const viewProps = {
projectName: manifest.name,
projectPath: '/tmp/workbench-preview-scope-switch-a',
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 openResourceBookCategory('角色与对象');
await waitFor(() =>
expect(
document
.querySelector('.game-resource-card-visual > img')
?.getAttribute('src'),
).toBe('blob:mock-attachment-preview'),
);
const firstPreviewCall = invoke.mock.calls.find(
([command]) => command === 'read_local_project_image_preview',
);
const firstScopeId = firstPreviewCall?.[1]?.scopeId;
expect(typeof firstScopeId).toBe('string');
// 只关心切换之后发生了什么,先把切换前的撤销调用清掉。
const revokeObjectUrl = URL.revokeObjectURL as unknown as ReturnType<
typeof vi.fn
>;
revokeObjectUrl.mockClear();
rendered.rerender(
React.createElement(ProjectDevelopmentView, {
...viewProps,
projectPath: '/tmp/workbench-preview-scope-switch-b',
}),
);
// ① 旧项目的预览缓存必须清掉:卡片预览的 Blob URL 要撤销,不能留在 WebView 里。
await waitFor(() =>
expect(revokeObjectUrl).toHaveBeenCalledWith(
'blob:mock-attachment-preview',
),
);
// ② 旧项目在途的读取要按旧 scopeId 取消,结果不得落进新项目的卡片。
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith(
'cancel_local_project_resource_preview_scope',
{ scopeId: firstScopeId },
),
);
// ③ 新项目不复用旧缓存:同一资源以新项目身份、新 scopeId 重新读取。
await waitFor(() => {
const switchedRead = invoke.mock.calls.find(
([command, args]) =>
command === 'read_local_project_image_preview' &&
args?.projectPath === '/tmp/workbench-preview-scope-switch-b',
);
expect(switchedRead).toBeDefined();
expect(switchedRead?.[1]?.scopeId).not.toBe(firstScopeId);
});
});
it('keeps resource sort tab keyboard focus inside the clipped segmented control', () => {
const styles = readFileSync(
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
expect(styles).toMatch(
/\.game-workbench-tabs\.game-resource-sort-tabs button:focus-visible\s*\{[^}]*box-shadow:\s*inset 0 0 0 3px var\(--platform-input-focus-ring\)/s,
);
});
it('keeps the chat composer an inset block inside the conversation dialog', () => {
const styles = readFileSync(
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
// 对话框就是消息列表那只铺满会话区的盒子;输入区是它内部的一块,不再是贴在它下边、
// 四边描边与它连成"两只盒子紧挨着"的第二只盒子。
const messageListRule =
styles.match(
/\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-message-list\s*\{([^}]*)\}/s,
)?.[1] ?? '';
expect(messageListRule).not.toBe('');
expect(messageListRule).toContain('top: 0;');
expect(messageListRule).toContain('right: 0;');
expect(messageListRule).toContain('bottom: 0;');
expect(messageListRule).toContain('left: 0;');
const composerRule =
styles.match(
/\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-composer\.is-direct-codex\s*\{([^}]*)\}/s,
)?.[1] ?? '';
expect(composerRule).not.toBe('');
// 输入盒在文档流里(Codex 三段式的第三段):不再用 right/bottom/left 内缩钉在
// 消息列表之上,四边一律归 0;它是整块面板里唯一有边框的容器。
expect(styleNumber(composerRule, 'right')).toBe(0);
expect(styleNumber(composerRule, 'left')).toBe(0);
expect(styleNumber(composerRule, 'bottom')).toBe(0);
expect(composerRule).toContain('position: relative;');
expect(composerRule).toContain(
'border: 1px solid var(--platform-surface-border);',
);
expect(composerRule).toContain('background: var(--platform-input-fill);');
// 输入盒在文档流里,消息列表不再需要给它留位置:底边归 0 留白,滚动到底
// 不会多出一段空白。具体几何(含窄屏)由 tests/chatDialogFrameLayout.test.ts 按
// 层叠生效值验证;这里钉住"旧模型的数字没有被写回来"。
expect(styleNumber(messageListRule, 'scroll-padding-bottom')).toBe(0);
expect(messageListRule).toContain('padding-bottom: 0;');
expect(messageListRule).toContain('flex: 1 1 auto;');
// 编辑器高度上限仍是输入盒高度的来源之一。
const editorRule =
styles.match(
/\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-composer\s+\.resource-reference-input-editor\s*\{([^}]*)\}/s,
)?.[1] ?? '';
expect(styleNumber(editorRule, 'max-height')).toBe(140);
expect(styleNumber(editorRule, 'min-height')).toBe(96);
});
it('keeps workbench chat bubbles aligned without shrinking process cards', () => {
const styles = readFileSync(
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const messageListRule =
styles.match(
/\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-message-list\s*\{([^}]*)\}/s,
)?.[1] ?? '';
const messageRule =
styles.match(
/\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-message-list\s+\.message\s*\{([^}]*)\}/s,
)?.[1] ?? '';
const userMessageRule =
styles.match(
/\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-message-list\s+\.message--user\s*\{([^}]*)\}/s,
)?.[1] ?? '';
const processCardRules = Array.from(
styles.matchAll(
/\.game-workbench-chat\s+\.project-chat-surface\.is-direct-codex\s+\.project-chat-message-list\s*>\s*\.project-chat-process-card\s*\{([^}]*)\}/gs,
),
(match) => match[1],
);
expect(messageListRule).not.toBe('');
expect(messageListRule).not.toContain('display: flex;');
expect(messageListRule).not.toContain('align-items:');
expect(messageRule).toContain('width: fit-content;');
expect(messageRule).toContain('max-width: min(88%, 720px);');
expect(userMessageRule).not.toBe('');
expect(userMessageRule).toContain('margin-left: auto;');
expect(userMessageRule).not.toContain('align-self:');
expect(userMessageRule).toContain('border-radius: 14px 14px 4px;');
expect(userMessageRule).toContain('background: var(--platform-warm-bg);');
expect(userMessageRule).toContain('color: var(--platform-text-base);');
// 执行过程卡有两条同选择器规则:第一条是几何(`width: 100%`),后面那条是 Codex 暖色
// 皮肤下的配色(把基础规则的绿系换成中性描边 + 暖底)。承重的是几何那条。
expect(processCardRules.length).toBeGreaterThanOrEqual(1);
expect(processCardRules[0]).toContain('width: 100%;');
});
it('enables the run presentation and renders registered images in the resource viewer', async () => {
const manifest = createGameCreationAppManifest(
'workbench-runnable',
'可运行工作台',
);
const codePrototype = manifest.tasks.find(
(task) => task.id === 'code-prototype',
);
if (!codePrototype) {
throw new Error('missing code-prototype seed task');
}
codePrototype.status = 'completed';
manifest.preview = {
status: 'running',
url: 'http://127.0.0.1:4173',
port: 4173,
};
manifest.assets.push({
id: 'hero-art',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
source: {
kind: 'generated',
taskId: 'art-asset-plan',
},
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_image_preview') {
expect(args).toEqual({
projectPath: '/tmp/workbench-runnable',
relativePath: 'assets/hero.png',
});
return {
path: 'assets/hero.png',
mediaType: 'image/png',
byteLen: 12,
dataUrl:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const onPlay = vi.fn();
render(
React.createElement(ProjectDevelopmentView, {
projectName: '可运行工作台',
projectPath: '/tmp/workbench-runnable',
manifest,
attachments: [],
recentRunStatus: 'completed',
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
onPlay,
}),
);
fireEvent.click(screen.getByRole('button', { name: '播放' }));
expect(onPlay).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('运行表现层')).not.toBeNull();
const runTab = screen.getByRole('tab', {
name: '运行',
}) as HTMLButtonElement;
expect(runTab.disabled).toBe(false);
fireEvent.click(runTab);
expect(screen.getByLabelText('运行表现层')).not.toBeNull();
const previewFrame = screen.getByTitle(
'可运行工作台 游戏运行画面',
) as HTMLIFrameElement;
expect(previewFrame.getAttribute('src')).toBe('http://127.0.0.1:4173/');
expect(previewFrame.getAttribute('sandbox')).toBe(
'allow-scripts allow-same-origin allow-forms allow-pointer-lock',
);
expect(screen.queryByLabelText('测试切片控件')).toBeNull();
expect(screen.getByLabelText('资源信息面板')).not.toBeNull();
expect(screen.getByLabelText('数值微调面板')).not.toBeNull();
fireEvent.click(screen.getByRole('tab', { name: '资源管理' }));
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
await openResourceBookCategory('角色与对象');
expect(screen.getByLabelText('资源类型视图')).not.toBeNull();
fireEvent.change(openResourceFilterPanel(), {
target: { value: 'hero.png' },
});
fireEvent.click(await findResourceSelectButton('hero.png'));
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
expect(screen.queryByRole('dialog', { name: 'hero.png' })).toBeNull();
});
it('marks an unvalidated UI prototype as a candidate image', async () => {
const manifest = createGameCreationAppManifest(
'workbench-ui-candidate',
'候选界面图测试',
);
manifest.assets.push({
id: 'ui-prototype-candidate',
kind: GAME_CREATION_APP_UI_DESIGN_ASSET_KIND,
mediaType: 'image/png',
localPath: 'assets/ui-prototype.png',
source: {
kind: 'canvas',
taskId: 'design-foundation',
},
});
render(
React.createElement(ProjectDevelopmentView, {
projectName: '候选界面图测试',
projectPath: '/tmp/workbench-ui-candidate',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
agentRuntimeSummaries: [
{
group: 'art',
label: '美术 Agent',
status: 'completed',
statusLabel: '已完成',
currentTask: '本轮工作已完成',
currentAction: null,
waitingOn: null,
completedCount: 4,
totalCount: 4,
},
],
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('UI 交互');
// 同上前一条:栏目标签「UI 交互」带空格,按完整可访问名查询选中按钮。
const candidate = await screen.findByRole('button', {
name: '选中资源:UI 交互 ui-prototype.png(待视觉验收)',
});
const candidateCard = candidate.closest('.game-resource-card');
expect(candidateCard?.textContent).not.toContain('画板 · 候选界面图');
expect(candidateCard?.textContent).not.toContain('assets/ui-prototype.png');
expect(screen.getByText('仅完成计划')).not.toBeNull();
expect(
screen.getByText('美术资源计划已完成,尚未生成或登记图片'),
).not.toBeNull();
});
it('opens the UI editor bridge from a canonical ui-design prototype image', async () => {
installResizeObserverStub();
const manifest = createGameCreationAppManifest(
'workbench-ui-prototype-entry',
'UI 原型入口测试',
);
manifest.assets = [
{
id: 'ui-prototype-asset',
kind: GAME_CREATION_APP_UI_DESIGN_ASSET_KIND,
mediaType: 'image/png',
localPath: 'assets/ui-prototype.png',
source: { kind: 'canvas', taskId: 'design-foundation' },
},
];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: manifest.projectId,
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
if (command === 'ensure_ui_design_resource_for_prototype') {
// 必须返回真实形状的成功结果:返回 `null` 会让 `result.manifest.projectId`
// 抛 TypeError 并被组件吞进错误分支,用例就永远测不到它名字里的成功路径。
return {
asset: {
id: 'ui-design-doc',
kind: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
mediaType: GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
localPath: 'assets/ui-design.json',
source: { kind: 'generated' as const },
},
manifest: {
...manifest,
assets: [
...manifest.assets,
{
id: 'ui-design-doc',
kind: GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND,
mediaType: GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE,
localPath: 'assets/ui-design.json',
source: { kind: 'generated' as const },
},
],
},
committedProjectRevision: 1,
created: true,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-ui-prototype-entry',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('UI 交互');
fireEvent.click(
await findResourceSelectButton('ui-prototype.png(待视觉验收)'),
);
fireEvent.click(await screen.findByRole('button', { name: 'UI 编辑器' }));
expect(invoke).toHaveBeenCalledWith(
'ensure_ui_design_resource_for_prototype',
expect.objectContaining({
input: expect.objectContaining({
prototypeAssetId: 'ui-prototype-asset',
}),
}),
);
// 成功路径:桥接结果被接受后画布切到 UI 编辑器视图;走错误分支时会停在
// `resources.selected.*` 并在提示条上留下原因,这条断言让用例真的覆盖它名字里的事。
await waitFor(() =>
expect(
screen
.getByLabelText('项目主视窗')
.getAttribute('data-resource-view-state'),
).toBe('resources.ui-editor'),
);
});
it('refuses to embed a non-loopback game preview in the client workbench', () => {
const manifest = createGameCreationAppManifest(
'workbench-remote-preview',
'远程预览拒绝测试',
);
const codePrototype = manifest.tasks.find(
(task) => task.id === 'code-prototype',
);
if (!codePrototype) {
throw new Error('missing code-prototype seed task');
}
codePrototype.status = 'completed';
manifest.preview = {
status: 'running',
url: 'https://example.com/game',
port: 443,
};
render(
React.createElement(ProjectDevelopmentView, {
projectName: '远程预览拒绝测试',
projectPath: '/tmp/workbench-remote-preview',
manifest,
attachments: [],
recentRunStatus: 'completed',
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
expect(screen.queryByTitle('远程预览拒绝测试 游戏运行画面')).toBeNull();
expect(screen.getByText('客户端运行画面尚未载入')).not.toBeNull();
expect(
screen.getByText('点击顶部播放按钮后将在这里直接运行游戏'),
).not.toBeNull();
expect(screen.queryByText(/\/run|\/preview/)).toBeNull();
});
}
export function registerProjectWorkbenchNavigationTests() {}
export function registerProjectAgentStatusTests() {
it('keeps ready task scheduling out of the normal user window', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
return [];
}
if (command === 'read_game_creator_agent_runtimes') {
return [];
}
if (command === 'read_local_project_file') {
throw new Error(
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
);
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
expect(screen.queryByRole('button', { name: '调度 Ready' })).toBeNull();
expect(
invoke.mock.calls.some(
([command]) => command === 'schedule_game_creator_agent_ready_tasks',
),
).toBe(false);
});
it.skip('confirms before refreshing agents when trace read policy requires it', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const trace: GameCreationAgentRunTrace = {
schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
runId: 'run-agent-refresh-confirm',
commandId: 'game.generate_draft',
status: 'running',
passes: 1,
maxPasses: 3,
toolCallCount: 1,
maxToolCalls: 128,
stopReason: 'running',
goal: '做一个厨房弹幕游戏',
coordination: 'Planner',
steps: [],
artifacts: [],
taskGraph: {
goal: '做一个厨房弹幕游戏',
readyTaskIds: [],
activeTaskIds: [],
carriedTaskIds: [],
repairFocus: [],
repairRoutes: [],
tasks: createGameCreationAppSeedTasks(),
},
passPlans: [],
nextStep: 'continue',
error: null,
updatedAt: 1,
};
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_local_conversation') {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
if (command === 'read_project_permission_policy') {
return {
path: '.agent/policy.json',
policy: {
deniedCommands: [],
confirmCommands: ['agent.trace_read'],
},
};
}
if (command === 'read_local_project_file') {
return {
path: '.agent/run.latest.json',
absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`,
content: JSON.stringify(trace),
};
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
invoke.mockClear();
fireEvent.click(screen.getByRole('button', { name: '刷新 Agent' }));
expect(await screen.findByText('agent.trace_read')).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'read_local_project_file',
expect.anything(),
);
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('read_local_project_file', {
projectPath: '/tmp/authorized-game',
relativePath: '.agent/run.latest.json',
commandId: 'agent.trace_read',
});
});
});
it.skip('cancels agent run trace refresh policy confirmation from the panel', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'read_local_conversation') {
return {
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
agentId: null,
messages: [],
};
}
if (command === 'read_project_permission_policy') {
return {
path: '.agent/policy.json',
policy: {
deniedCommands: [],
confirmCommands: ['agent.trace_read'],
},
};
}
if (command === 'read_local_project_file') {
throw new Error('should wait for trace confirmation');
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
invoke.mockClear();
fireEvent.click(screen.getByRole('button', { name: '刷新 Agent' }));
const traceReadCommand = await screen.findByText('agent.trace_read');
fireEvent.click(
within(
traceReadCommand.closest('.pending-command') as HTMLElement,
).getByRole('button', { name: '取消' }),
);
expect(
await screen.findByText('run: 已取消读取 Agent trace'),
).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'read_local_project_file',
expect.anything(),
);
});
/**
* 资源总览(main 态)必须为**每个非空栏目**挂载真实卡片本体。
*
* 回归背景:卡片预览 Hook 的 `resources` 入参同时决定 `identityByResourceId`,而
* `renderResourceBookCard` 在身份缺失时直接不挂载卡片。曾为缩小热预取范围把该入参
* 收窄成「当前分页栏目」,结果总览里除当前栏目外只剩栏目标题栏加空的层叠占位。
*
* 本用例自带 manifest(不改上面共用 fixture),并把 `category` 显式写死,
* 让 `audio` / `document` / `character` / `unclassified` / `version` 五个栏目同时非空。
* `audio` 刻意保持 `idle`(音频不接受可见性预取),用来证明卡片挂载不依赖预览是否已就绪。
*/
it('mounts a real card body in every non-empty resource section overview', async () => {
const manifest = createGameCreationAppManifest(
'workbench-section-card-bodies',
'总览卡片栏目项目',
);
// manifest 资产上的分类字段名是 `category`(落盘权威值);不写时只会按 `kind` 派生。
manifest.assets = [
{
id: 'section-card-document',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'memory/section-card.md',
source: { kind: 'generated', taskId: 'design-foundation' },
category: 'document',
},
{
id: 'section-card-character',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/section-card-character.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
category: 'character',
},
{
id: 'section-card-audio',
kind: 'background-music',
mediaType: 'audio/mpeg',
localPath: 'assets/section-card-audio.mp3',
source: { kind: 'generated', taskId: 'audio-asset-plan' },
category: 'audio',
},
{
id: 'section-card-unclassified',
kind: 'game-code',
mediaType: 'text/javascript',
localPath: 'game/section-card.js',
source: { kind: 'generated', taskId: 'code-prototype' },
category: 'unclassified',
},
];
manifest.versions = [
{
versionId: 'section-card-version',
parentVersionId: null,
projectRevision: 1,
resourceBindings: [],
createdReason: 'initial',
createdAt: 1,
},
];
const sectionProjectResources = projectResourcesFromReadModels(
manifest,
[],
[],
);
const expectedCategoryByResourceId = new Map(
sectionProjectResources.map((resource) => [
resource.id,
resource.category,
]),
);
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs([]);
}
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-card-bodies',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
// 逐栏目断言:栏目非空是前提,真实卡片本体存在才是回归判据。
for (const category of [
'character',
'audio',
'document',
'unclassified',
'version',
]) {
const resourcesInCategory = sectionProjectResources.filter(
(resource) => resource.category === category,
);
expect(resourcesInCategory.length).toBeGreaterThan(0);
const expectedCardId = resourcesInCategory[0]!.id;
await waitFor(() => {
const sceneCard = document.querySelector<HTMLElement>(
`.game-resource-book-scene-card[data-resource-book-category="${category}"]`,
);
expect(sceneCard).not.toBeNull();
const cardBody = sceneCard?.querySelector<HTMLElement>(
'.game-resource-card',
);
expect(cardBody).not.toBeNull();
expect(cardBody?.dataset.resourceCardId).toBe(expectedCardId);
expect(
expectedCategoryByResourceId.get(cardBody!.dataset.resourceCardId!),
).toBe(category);
});
}
});
/**
* 资源总览第 0 张「所有资源」卡。
*
* 三件事必须同时成立:它是总览的第一张、计数就是全量资源数(与各栏目计数之和同口径)、
* 点它进入的页面里能同时看到来自多个栏目的资源。只断言「页面上有全部资源入口」会让
* 一个只展示单栏目的实现也变绿,所以第三条按栏目分组逐组钉卡片本体。
*/
it('keeps an all-resources tile first in the overview and opens every section', async () => {
const manifest = createGameCreationAppManifest(
'workbench-all-resources',
'全部资源项目',
);
manifest.assets = [
{
id: 'all-resources-document',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'memory/all-resources.md',
source: { kind: 'generated', taskId: 'design-foundation' },
category: 'document',
},
{
id: 'all-resources-character',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/all-resources-character.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
category: 'character',
},
{
id: 'all-resources-audio',
kind: 'background-music',
mediaType: 'audio/mpeg',
localPath: 'assets/all-resources-audio.mp3',
source: { kind: 'generated', taskId: 'audio-asset-plan' },
category: 'audio',
},
];
manifest.versions = [
{
versionId: 'all-resources-version',
parentVersionId: null,
projectRevision: 1,
resourceBindings: [],
createdReason: 'initial',
createdAt: 1,
},
];
const allResources = projectResourcesFromReadModels(manifest, [], []);
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs([]);
}
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-all-resources',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
const readCountBadge = (selector: string) => {
const badge = document.querySelector<HTMLElement>(
`${selector} .game-resource-book-titlebar-meta small`,
);
const match = /^(\d+) 项$/u.exec(badge?.textContent ?? '');
expect(badge, `${selector} 的计数徽标缺失`).not.toBeNull();
expect(match, `${selector} 的计数徽标不是「N 项」`).not.toBeNull();
return Number(match![1]);
};
await waitFor(() =>
expect(
document.querySelector(
'.game-resource-book-scene-titlebar[data-resource-book-category="all"]',
),
).not.toBeNull(),
);
// 1) 总览里存在「所有资源」卡,并且排在栏目卡之前(第 0 张)。
const overviewGrid = document.querySelector<HTMLElement>(
'.game-resource-book-main-grid',
);
expect(overviewGrid).not.toBeNull();
const overviewTiles = Array.from(
overviewGrid!.querySelectorAll<HTMLElement>(
'.game-resource-book-thumbnail',
),
);
expect(
overviewTiles.map((tile) => tile.dataset.resourceBookCategory),
).toEqual([
'all',
'ui-interaction',
'character',
'scene',
'audio',
'document',
'unclassified',
'version',
]);
expect(screen.getByRole('button', { name: '打开所有资源' })).toBe(
overviewTiles[0],
);
// 2) 它的计数等于全量资源数,并且与各栏目计数之和同一口径(不另算一套)。
const allResourcesCount = readCountBadge(
'.game-resource-book-scene-titlebar[data-resource-book-category="all"]',
);
expect(allResourcesCount).toBe(allResources.length);
const perCategoryCounts = Array.from(
document.querySelectorAll<HTMLElement>(
'.game-resource-book-scene-titlebar[data-resource-book-category]',
),
)
.filter((titlebar) => titlebar.dataset.resourceBookCategory !== 'all')
.map((titlebar) =>
readCountBadge(
`.game-resource-book-scene-titlebar[data-resource-book-category="${titlebar.dataset.resourceBookCategory}"]`,
),
);
expect(perCategoryCounts.reduce((total, count) => total + count, 0)).toBe(
allResourcesCount,
);
// 2b) 「所有资源」卡必须真的铺出预览:与栏目卡同一颗卡片渲染器(`.game-resource-card`),
// 条数按总览摞上限取全量投影的前 N 张;计数徽标与实际喂进去的资源数同源,
// 因此不会出现「标题说 N 项、预览 0 张」。
const allPreviewCards = () =>
Array.from(
document.querySelectorAll<HTMLElement>(
'.game-resource-book-preview-card[data-resource-book-category="all"] .game-resource-card',
),
);
await waitFor(() =>
expect(
allPreviewCards().map((card) => card.dataset.resourceCardId),
).toEqual(
allResources
.slice(0, RESOURCE_BOOK_OVERVIEW_STACK_LIMIT)
.map((resource) => resource.id),
),
);
expect(allPreviewCards()).toHaveLength(
Math.min(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT, allResourcesCount),
);
// 同一张卡的结构也一致:预览摞里是完整卡片(视觉体 + 选中按钮),不是另一套占位。
expect(
allPreviewCards()[0]?.querySelector('.game-resource-card-visual'),
).not.toBeNull();
expect(
allPreviewCards()[0]?.querySelector('.game-resource-card-select'),
).not.toBeNull();
// 预览是只读的第二份宿主:不进无障碍树(否则同一张卡会被读两遍)。
expect(
allPreviewCards()[0]?.closest('[aria-hidden="true"]'),
).not.toBeNull();
// 宿主几何与栏目卡共用同一条规则,视觉上不是另写一套。
const previewStyles = readFileSync(
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const previewHostRule = styleRuleBody(
previewStyles,
'\\.game-resource-book-preview-card',
);
expect(previewHostRule).toMatch(/position:\s*absolute/);
expect(previewHostRule).toMatch(/transform-origin:\s*0 0/);
// 3) 点它进入的是**同一套画本场景**,不是另一条渲染分支:真实栏目的卡片平铺在同一张
// 画布上,卡片宿主是栏目页那一个 `.game-resource-book-scene-card`、
// `data-resource-book-category` 仍是真实栏目;`all` 自己只留钉住的标题栏与全量计数。
fireEvent.click(screen.getByRole('button', { name: '打开所有资源' }));
await waitFor(() =>
expect(
document.querySelector(
'.game-resource-book-scene-titlebar[data-resource-book-category="all"].is-active',
),
).not.toBeNull(),
);
// 「所有资源」展开态必须复用自己的内存视口:缩放按钮不能拿当前栏目页的旧 ref,
// 否则按钮看起来按了但缩放落点/倍率会回到另一份视口状态。
const allSceneWorld = document.querySelector<HTMLElement>(
'[data-resource-book-view="child"] .game-resource-book-scene-world',
);
expect(allSceneWorld).not.toBeNull();
const allTransformBeforeZoom = allSceneWorld?.style.transform;
fireEvent.click(screen.getByRole('button', { name: '放大画布' }));
await waitFor(() =>
expect(allSceneWorld?.style.transform).not.toBe(allTransformBeforeZoom),
);
// 旧实现第二次点击仍可能使用栏目页 ref;连续点击必须继续推进 all 视口。
const allTransformAfterZoom = allSceneWorld?.style.transform;
fireEvent.click(screen.getByRole('button', { name: '放大画布' }));
await waitFor(() =>
expect(allSceneWorld?.style.transform).not.toBe(allTransformAfterZoom),
);
// 旧的分组网格宿主与网格容器一个都不该再存在;改回旧分支时这三条会立刻红。
expect(
document.querySelector('[data-resource-book-all-host="true"]'),
).toBeNull();
expect(
document.querySelector('[data-resource-book-all-page="true"]'),
).toBeNull();
expect(document.querySelector('.game-resource-all-grid')).toBeNull();
expect(document.querySelector('.game-resource-all-section')).toBeNull();
const expectSortedCategories = (categories: string[]) =>
[...categories].sort();
const expectSortedIds = (ids: string[]) => [...ids].sort();
const allCardHosts = Array.from(
document.querySelectorAll<HTMLElement>(
'.game-resource-book-scene-card[data-resource-book-category]',
),
);
// 宿主按真实栏目分组,栏目集合与总览里的那批一致;`all` 自己不出宿主。
const hostCategories = expectSortedCategories(
Array.from(
new Set(allCardHosts.map((host) => host.dataset.resourceBookCategory!)),
),
);
expect(hostCategories).toEqual(
expectSortedCategories(
Array.from(new Set(allResources.map((resource) => resource.category))),
),
);
expect(hostCategories.length).toBeGreaterThan(1);
expect(hostCategories).not.toContain('all');
// 展开态是一张**平铺画布**:真实栏目不出标题栏,整个场景只留 `all` 那一条钉住的。
// 曾经"每条分带配一条标题栏"时,没有可见资源的栏目拿不到带矩形,标题栏会全部回落到
// 世界原点叠成一摞(用户报的破版就是这一摞);下面这条把它钉死。
expect(
document.querySelectorAll(
'.game-resource-book-scene-titlebar:not([data-resource-book-category="all"])',
),
).toHaveLength(0);
// 每张资源恰好一个宿主(展开态没有"第二份宿主"):条数与全量计数同源,
// 因此不会出现「标题说 N 项、画布少几张」。
expect(
expectSortedIds(
allCardHosts.map(
(host) =>
host.querySelector<HTMLElement>('.game-resource-card')?.dataset
.resourceCardId ?? '',
),
),
).toEqual(expectSortedIds(allResources.map((resource) => resource.id)));
// 卡片本体仍是栏目页同一颗渲染器(视觉体 + 选中按钮),不是另一套占位。
for (const host of allCardHosts) {
expect(host.querySelectorAll('.game-resource-card')).toHaveLength(1);
expect(host.querySelector('.game-resource-card-visual')).not.toBeNull();
}
// 展开态的标题栏也是同一颗标题栏组件:`all` 那条钉在视口上并带「资源总览」收起入口,
// 计数用同一份全量计数。
expect(
readCountBadge(
'.game-resource-book-scene-titlebar[data-resource-book-category="all"]',
),
).toBe(allResourcesCount);
expect(
document.querySelector(
'.game-resource-book-scene-titlebar[data-resource-book-category="all"] .game-resource-page-collapse',
),
).not.toBeNull();
// 视觉不是另写一套:那一页没有任何专属选择器,卡与宿主都只用共享规则
// (声明级断言——它验的是"没有平行样式",验不到布局本身)。
const allPageStyles = readFileSync(
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
expect(allPageStyles).not.toMatch(/\.game-resource-all-/);
});
/**
* 空项目下「所有资源」卡不能破版:沿用栏目卡同一条空态(`暂无资源`),
* 预览摞一张都不挂、计数为 0 项,卡片结构与入口行仍在。
*/
it('keeps the all-resources tile on the shared empty state for an empty project', async () => {
const manifest = createGameCreationAppManifest(
'workbench-all-resources-empty',
'空资源项目',
);
manifest.assets = [];
manifest.versions = [];
const allResources = projectResourcesFromReadModels(manifest, [], []);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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}`);
},
);
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-all-resources-empty',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
expect(allResources).toHaveLength(0);
const allTile = await waitFor(() => {
const tile = screen
.getByRole('button', { name: '打开所有资源' })
.closest<HTMLElement>('.game-resource-book-thumbnail');
expect(tile).not.toBeNull();
return tile!;
});
expect(
allTile.querySelector('.game-resource-book-thumbnail-body')?.textContent,
).toBe('暂无资源');
expect(
allTile.querySelector('.game-resource-book-thumbnail-footer')
?.textContent,
).toContain('打开所有资源');
expect(
document.querySelectorAll('.game-resource-book-preview-card'),
).toHaveLength(0);
expect(
document.querySelector(
'.game-resource-book-scene-titlebar[data-resource-book-category="all"] .game-resource-book-titlebar-meta small',
)?.textContent,
).toBe('0 项');
// 空项目进展开态也不破版:一张卡都不挂,空态复用栏目页同一个类与同一句文案
// (「不新增样式」这条红线的可执行判据)。
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(
document.querySelectorAll('.game-resource-book-scene-card'),
).toHaveLength(0);
// 空项目恰恰是那一摞的现场:没有任何带 ⇒ 曾经每个真实栏目的标题栏都回落到世界原点
// 叠在一起(最后画的「项目版本」浮在画布中央)。展开态只允许 `all` 那一条标题栏。
expect(
document.querySelectorAll(
'.game-resource-book-scene-titlebar:not([data-resource-book-category="all"])',
),
).toHaveLength(0);
expect(
document.querySelector('.game-resource-page-empty')?.textContent,
).toBe('没有匹配资源');
});
/**
* 「资源总览」大标题旁边不再有任何副标题。
*
* 原来那颗小字是「N 项正式与候选资源」(空项目时是「从资源栏目开始整理资源」),
* 现在改由总览第 0 张「所有资源」卡的计数徽标承载,标题栏本身只剩标题。
* 卡片内部的说明/装饰文案(「打开 UI 交互」「1 个直接子版本」等)不在本次范围内。
*/
it('keeps the resource overview heading free of a subtitle', async () => {
const manifest = createGameCreationAppManifest(
'workbench-overview-heading',
'总览标题项目',
);
manifest.assets = [
{
id: 'overview-heading-document',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'memory/overview-heading.md',
source: { kind: 'generated', taskId: 'design-foundation' },
category: 'document',
},
];
window.__TAURI__ = {
core: {
invoke: vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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-overview-heading',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
const heading = await waitFor(() => {
const element = document.querySelector<HTMLElement>(
'.game-resource-book-main-heading',
);
expect(element).not.toBeNull();
return element!;
});
// 精确到标题栏整段文本:副标题只要回来一个字,这条断言就会红。
expect(heading.textContent).toBe('资源总览');
expect(heading.querySelectorAll('h2')).toHaveLength(1);
expect(heading.querySelector('h2')?.textContent).toBe('资源总览');
// 卡片里的装饰性文案必须原样保留(同一次改动里最容易误删的东西)。
expect(screen.getByRole('button', { name: '打开所有资源' })).not.toBeNull();
expect(
document.querySelector(
'.game-resource-book-thumbnail[data-resource-book-category="document"] .game-resource-book-thumbnail-footer',
)?.textContent,
).toContain('打开文档');
});
it('renders the column bottom toolbar only inside the matrix columns and hides it on the overview', async () => {
const manifest = createGameCreationAppManifest(
'workbench-bottom-toolbar',
'底部工具栏项目',
);
manifest.assets = [
{
id: 'toolbar-icon-spec',
kind: 'icon-spec',
mediaType: 'image/png',
localPath: 'assets/art-spec.png',
source: { kind: 'canvas', resourceId: 'toolbar-icon-spec-resource' },
},
{
id: 'toolbar-character',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/toolbar-character.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
{
id: 'toolbar-scene',
kind: 'scene',
mediaType: 'image/png',
localPath: 'assets/toolbar-scene.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
{
id: 'toolbar-audio',
kind: 'background-music',
mediaType: 'audio/mpeg',
localPath: 'assets/toolbar-bgm.mp3',
source: { kind: 'generated', taskId: 'audio-plan' },
},
{
id: 'toolbar-document',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'memory/toolbar-notes.md',
source: { kind: 'generated', taskId: 'design-foundation' },
category: 'document',
},
{
id: 'toolbar-unclassified',
kind: 'image',
mediaType: 'image/png',
localPath: 'assets/toolbar-plain.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
];
installResourceBookBottomToolbarInvoke(manifest);
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-bottom-toolbar',
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(document.querySelector('[data-resource-bottom-toolbar]')).toBeNull();
await openResourceBookCategory('角色与对象');
const characterToolbar = document.querySelector<HTMLElement>(
'[data-resource-bottom-toolbar="character"]',
);
expect(characterToolbar).not.toBeNull();
// 工具栏挂在管理区下、与画本场景并列,不在带 scale() 的场景里。
expect(characterToolbar?.closest('.game-resource-book-scene')).toBeNull();
for (const label of ['生成图片', '生成规范', '生成角色形象']) {
expect(
within(characterToolbar!).getByRole('button', { name: label }),
).not.toBeNull();
}
fireEvent.click(
within(characterToolbar!).getByRole('button', { name: '生成规范' }),
);
expect(screen.getByRole('menuitem', { name: '角色规范' })).not.toBeNull();
expect(screen.queryByRole('menuitem', { name: '图标规范' })).toBeNull();
await openResourceBookCategory('音频');
const audioToolbar = document.querySelector<HTMLElement>(
'[data-resource-bottom-toolbar="audio"]',
);
expect(audioToolbar).not.toBeNull();
expect(
within(audioToolbar!).getByRole('button', { name: '生成背景音乐' }),
).not.toBeNull();
expect(
within(audioToolbar!).getByRole('button', { name: '生成音效' }),
).not.toBeNull();
expect(
within(audioToolbar!).queryByRole('button', { name: '生成图片' }),
).toBeNull();
await openResourceBookCategory('UI 交互');
const uiToolbar = document.querySelector<HTMLElement>(
'[data-resource-bottom-toolbar="ui-interaction"]',
);
expect(uiToolbar).not.toBeNull();
for (const label of ['生成图片', '生成图标素材', '生成 UI 设计图']) {
expect(
within(uiToolbar!).getByRole('button', { name: label }),
).not.toBeNull();
}
fireEvent.click(
within(uiToolbar!).getByRole('button', { name: '生成规范' }),
);
expect(screen.getByRole('menuitem', { name: '图标规范' })).not.toBeNull();
expect(screen.getByRole('menuitem', { name: '自定义规范' })).not.toBeNull();
// 文档栏与「所有资源」页都不在矩阵里。
await openResourceBookCategory('文档');
expect(document.querySelector('[data-resource-bottom-toolbar]')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '打开所有资源' }));
await waitFor(() =>
expect(
document.querySelector(
'.game-resource-book-scene-titlebar[data-resource-book-category="all"]',
),
).not.toBeNull(),
);
expect(document.querySelector('[data-resource-bottom-toolbar]')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="main"]'),
).not.toBeNull(),
);
expect(document.querySelector('[data-resource-bottom-toolbar]')).toBeNull();
}, 20_000);
it('submits the UI column toolbar entries with the exact local generation payload', async () => {
const manifest = createGameCreationAppManifest(
'workbench-bottom-toolbar-ui',
'底部工具栏 UI 项目',
);
manifest.assets = [
{
id: 'toolbar-ui-icon',
kind: 'icon',
mediaType: 'image/png',
localPath: 'assets/toolbar-icon.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
];
const { calls } = installResourceBookBottomToolbarInvoke(manifest);
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-bottom-toolbar-ui',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('UI 交互');
// 1) 生成图片:比例 / 尺寸默认 1:1 · 1K。
await submitBottomToolbarPanel('生成图片', {
prompt: '像素月光厨房主角',
});
expect(generateCall(calls, 'image')).toMatchObject({
projectPath: '/tmp/workbench-bottom-toolbar-ui',
kind: 'image',
prompt: '像素月光厨房主角',
aspectRatio: '1:1',
imageSize: '1K',
assetName: 'AI 生成图片',
outputPath: null,
});
// 2) 生成规范 → 图标规范:项目里还没有权威规范图,写进 assets/art-spec.png。
await submitBottomToolbarPanel('图标规范', {
menu: '生成规范',
prompt: '像素月光厨房统一视觉规范',
});
expect(generateCall(calls, 'icon-spec', '图标规范')).toMatchObject({
kind: 'icon-spec',
prompt: '像素月光厨房统一视觉规范',
aspectRatio: '1:1',
imageSize: '1K',
assetName: '图标规范',
outputPath: 'assets/art-spec.png',
});
// 3) 生成规范 → 自定义规范:规格档固定展示 1:1·1K,不带比例选择器;与上面那条
// 共用 `icon-spec` 通道,只有素材名能区分。
await submitBottomToolbarPanel('自定义规范', {
menu: '生成规范',
prompt: '场景与道具的统一色彩规范',
});
expect(generateCall(calls, 'icon-spec', '自定义规范')).toMatchObject({
kind: 'icon-spec',
prompt: '场景与道具的统一色彩规范',
aspectRatio: '1:1',
imageSize: '1K',
assetName: '自定义规范',
outputPath: null,
});
// 生成成功后走既有 manifest 刷新路径(配对读 revision + 清单)。
const commands = calls.map((call) => call.command);
expect(commands).toContain('get_local_game_project_revision');
expect(commands).toContain('get_local_game_manifest');
expect(commands.lastIndexOf('get_local_game_manifest')).toBeGreaterThan(
commands.indexOf('start_local_project_asset_generation'),
);
// 4) 缺权威规范图时,「生成图标素材 / 生成 UI 设计图」保持可点击并说明原因。
const blocked = screen.getByRole('button', { name: '生成图标素材' });
expect((blocked as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(blocked);
expect(screen.getByRole('alert').textContent).toContain(
'assets/art-spec.png',
);
fireEvent.click(screen.getByRole('button', { name: '生成 UI 设计图' }));
expect(screen.getByRole('alert').textContent).toContain(
'assets/art-spec.png',
);
expect(
calls.filter(
(call) => call.command === 'start_local_project_asset_generation',
),
).toHaveLength(3);
}, 20_000);
it('reuses the registered art spec for the derived entries and covers the character column', async () => {
const manifest = createGameCreationAppManifest(
'workbench-bottom-toolbar-derived',
'底部工具栏派生项目',
);
manifest.assets = [
{
id: 'toolbar-art-spec',
kind: 'icon-spec',
mediaType: 'image/png',
localPath: 'assets/art-spec.png',
source: { kind: 'canvas', resourceId: 'toolbar-art-spec-resource' },
},
{
id: 'toolbar-character',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/toolbar-character.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
];
const { calls } = installResourceBookBottomToolbarInvoke(manifest);
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-bottom-toolbar-derived',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('UI 交互');
await submitBottomToolbarPanel('生成图标素材', {
prompt: '各种敌人头像:骷髅 哥布林 强盗',
});
expect(generateCall(calls, 'icon-spritesheet')).toMatchObject({
kind: 'icon-spritesheet',
prompt: '各种敌人头像:骷髅 哥布林 强盗',
aspectRatio: '1:1',
imageSize: '1K',
assetName: 'AI 生成图标素材',
outputPath: null,
});
// 已有权威规范图:UI 设计图不再被前置挡住,默认档沿用 16:9 · 1K。
await submitBottomToolbarPanel('生成 UI 设计图', {
prompt: '横屏单屏界面,含 HUD 与操作控件',
});
expect(generateCall(calls, 'ui-design')).toMatchObject({
kind: 'ui-design',
prompt: '横屏单屏界面,含 HUD 与操作控件',
aspectRatio: '16:9',
imageSize: '1K',
assetName: 'AI 生成 UI 设计图',
outputPath: null,
});
// 已有权威规范图时「图标规范」不再指向那个落点:Rust 侧 replace_existing 固定 false
// 指向已存在文件会被硬拒绝。
await submitBottomToolbarPanel('图标规范', {
menu: '生成规范',
prompt: '第二版图标规范',
});
expect(generateCall(calls, 'icon-spec', '图标规范')).toMatchObject({
kind: 'icon-spec',
assetName: '图标规范',
outputPath: null,
});
await openResourceBookCategory('角色与对象');
await submitBottomToolbarPanel('生成角色形象', {
prompt: '披风猫骑士,正面立绘',
});
expect(generateCall(calls, 'character')).toMatchObject({
kind: 'character',
prompt: '披风猫骑士,正面立绘',
aspectRatio: '1:1',
imageSize: '1K',
assetName: 'AI 生成角色',
outputPath: null,
});
await submitBottomToolbarPanel('角色规范', {
menu: '生成规范',
prompt: '三角色头身比与服装规范',
});
// 本地 IPC 只有一条规范通道(`icon-spec`),没有 specType 入参:角色规范与自定义规范
// 共用它,靠素材名与提示词区分。
expect(generateCall(calls, 'icon-spec', '角色规范')).toMatchObject({
kind: 'icon-spec',
prompt: '三角色头身比与服装规范',
assetName: '角色规范',
outputPath: null,
});
}, 20_000);
it('keeps a submitted generation alive after the panel closes, queues the second submission, and shows backend phases', async () => {
const manifest = createGameCreationAppManifest(
'workbench-asset-generation-tasks',
'生成任务项目',
);
manifest.assets = [
{
id: 'tasks-art-spec',
kind: 'icon-spec',
mediaType: 'image/png',
localPath: 'assets/art-spec.png',
source: { kind: 'canvas', resourceId: 'tasks-art-spec-resource' },
},
];
const calls: BottomToolbarInvokeCall[] = [];
const tasks = new Map<string, Record<string, unknown>>();
let listPolls = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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') {
const kind = invokeStringField(args, 'kind') ?? 'unknown';
const taskId =
invokeStringField(args, 'taskId') ?? `task-${tasks.size}`;
const task = {
taskId,
projectId: String(args?.projectId ?? manifest.projectId),
kind,
assetName: invokeStringField(args, 'assetName') ?? '',
status: 'running',
phaseDetail: '正在生成。',
createdAtMillis: tasks.size + 1,
startedAtMillis: tasks.size + 1,
finishedAtMillis: null,
assetId: null,
error: null,
};
tasks.set(taskId, task);
return task;
}
if (command === 'list_local_project_asset_generations') {
listPolls += 1;
// 第三次轮询(约 4 秒)开始收尾:既让「第一条在途 → 第二条排队」可观测,
// 又让整条链路(补发 + 落卡 + 面板收口)在同一次用例里真的跑完。
if (listPolls >= 3) {
for (const [taskId, task] of tasks) {
if (task.status !== 'completed') {
tasks.set(taskId, {
...task,
status: 'completed',
phaseDetail: '生成已完成。',
assetId: `generated-${String(task.kind)}`,
finishedAtMillis: 2,
});
}
}
}
return [...tasks.values()];
}
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 } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-asset-generation-tasks',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('UI 交互');
expect(screen.getByRole('button', { name: '生成任务' })).not.toBeNull();
// 1) 提交第一条;生成在途时面板能关(关闭 ≠ 取消请求)。
fireEvent.click(
await screen.findByRole('button', { name: '生成 UI 设计图' }),
);
const firstPanel = await screen.findByRole('dialog', {
name: '生成 UI 设计图',
});
fireEvent.change(within(firstPanel).getByLabelText('素材名称'), {
target: { value: '第一条设计图' },
});
await typeGenerationPrompt(firstPanel, '第一条界面');
fireEvent.click(
within(firstPanel).getByRole('button', { name: '生成 UI 设计图' }),
);
// 点击即关闭:同一个事件循环内提交面板就已卸载,画布立刻可用。
expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull();
expect(firstPanel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/);
// 2) 面板关掉之后任务仍在「生成任务」面板里,阶段文案来自后端记录。
const taskPanel = await screen.findByRole('region', { name: '生成任务' });
expect(within(taskPanel).getByText('第一条设计图')).not.toBeNull();
/**
* 阶段文案与徽标要**等**后端记录并进来:刚提交时任务还停在本地队列的「排队中。」,
* 记录合并发生在第一轮账本轮询(间隔 2s)之后。同步断言在这里读到的会是本地阶段,
* 在整套连跑时必然抢跑(隔离单跑时那一拍刚好落在窗口内,所以只在全量里红)。
*/
await waitFor(() => {
expect(within(taskPanel).getByText('正在生成。')).not.toBeNull();
expect(within(taskPanel).getByText('生成中')).not.toBeNull();
});
// 非模态:没有全屏遮罩、没有 aria-modal。
expect(taskPanel.getAttribute('aria-modal')).toBeNull();
expect(document.querySelector('[aria-modal="true"]')).toBeNull();
// 3) 第一条还在途时提交第二条:第二条停在本地队列,生成提交 IPC 仍然只有一次。
fireEvent.click(screen.getByRole('button', { name: '生成 UI 设计图' }));
const secondPanel = await screen.findByRole('dialog', {
name: '生成 UI 设计图',
});
fireEvent.change(within(secondPanel).getByLabelText('素材名称'), {
target: { value: '第二条设计图' },
});
await typeGenerationPrompt(secondPanel, '第二条界面');
fireEvent.click(
within(secondPanel).getByRole('button', { name: '生成 UI 设计图' }),
);
// 第二条提交面板同样点击即关闭;它停在本地队列里(阶段由任务面板呈现),
// 生成提交 IPC 仍然只有一次。
expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull();
await waitFor(() =>
expect(within(taskPanel).getByText('排队中。')).not.toBeNull(),
);
expect(
calls.filter(
(call) => call.command === 'start_local_project_asset_generation',
),
).toHaveLength(1);
await waitFor(() =>
expect(within(taskPanel).getByText('第二条设计图')).not.toBeNull(),
);
// 4) 第一条终态后自动补发第二条,两条都收口为已完成;每条完成各走一次「配对读 + 落卡」。
const manifestReadsBefore = calls.filter(
(call) => call.command === 'get_local_game_manifest',
).length;
await waitFor(
() =>
expect(
calls.filter(
(call) => call.command === 'start_local_project_asset_generation',
),
).toHaveLength(2),
{ timeout: 15_000 },
);
await waitFor(
() =>
expect(
within(screen.getByRole('region', { name: '已完成' })).getAllByRole(
'listitem',
),
).toHaveLength(2),
{ timeout: 15_000 },
);
expect(within(taskPanel).getAllByText('生成已完成。')).toHaveLength(2);
expect(
calls.filter((call) => call.command === 'get_local_game_manifest').length,
).toBeGreaterThanOrEqual(manifestReadsBefore + 2);
/**
* 5) 等轮询真的退出:两个 mock 任务都已收口 completed,再连等两个 poll 周期
* (间隔 2s),账本读计数不再增长才算这条异步链跑完。
*
* 宿主读账本用的是「调用时」的 `window.__TAURI__`,而 afterEach 会把它删掉、下一个用例
* 再装自己的 mock:留下一条在途/在睡的轮询,下一轮醒来就会打进**下一个用例**的 spy
* (实测就是 `does not open a preview before a local project is initialized` 那条变红,
* 且看到的 projectPath 是本案的)。所以排空必须发生在本用例内部,而不是靠全局 harness 掩盖。
*/
const ledgerReads = () =>
calls.filter(
(call) => call.command === 'list_local_project_asset_generations',
).length;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 2_200));
});
const ledgerReadsAfterFirstQuietPeriod = ledgerReads();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 2_200));
});
expect(ledgerReads()).toBe(ledgerReadsAfterFirstQuietPeriod);
}, 30_000);
/**
* 账本读不到时的视图:`listBehavior` 决定这次读取是「返回非数组」还是「直接拒绝」。
*
* 真实场景分别是「旧壳没有这条命令、返回 undefined」与「命令未注册 / 权限拒绝抛错」。
*/
async function renderGenerationLedgerUnavailableView(
listBehavior: () => unknown,
) {
const manifest = createGameCreationAppManifest(
'workbench-asset-generation-tasks-unavailable',
'生成任务账本不可读项目',
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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 === 'list_local_project_asset_generations') {
return listBehavior();
}
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 } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-asset-generation-tasks-unavailable',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('UI 交互');
}
it('survives an unreadable generation task ledger without dropping local state', async () => {
// 旧壳 / 命令未注册:返回 undefined 而不是数组。
await renderGenerationLedgerUnavailableView(() => undefined);
expect(screen.getByRole('button', { name: '生成任务' })).not.toBeNull();
expect(
await screen.findByText('生成任务列表读取失败,暂时无法恢复历史任务'),
).not.toBeNull();
}, 20_000);
it('reports a rejected generation task ledger read instead of failing silently', async () => {
await renderGenerationLedgerUnavailableView(() => {
throw new Error('unexpected invoke list_local_project_asset_generations');
});
expect(screen.getByRole('button', { name: '生成任务' })).not.toBeNull();
expect(
await screen.findByText('生成任务列表读取失败,暂时无法恢复历史任务'),
).not.toBeNull();
}, 20_000);
/**
* 「定位到素材」的公共夹具:项目里有一个 character 资产 + 一条已完成、指向它的生成任务。
*
* `openCategory` 决定先停在哪个栏目:停在别的栏目就能覆盖「素材在另一个栏目」这条分支。
*/
async function renderGenerationLocateView(input: {
projectId: string;
projectPath: string;
openCategory: string;
assetId: string;
ledgerRecords: Record<string, unknown>[];
}) {
const manifest = createGameCreationAppManifest(
input.projectId,
'生成任务定位测试',
);
manifest.assets = [
{
id: input.assetId,
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/locate-target.png',
source: { kind: 'canvas', resourceId: 'locate-target-resource' },
},
];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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: input.projectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'list_local_project_asset_generations') {
return input.ledgerRecords;
}
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 } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: input.projectPath,
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory(input.openCategory);
fireEvent.click(screen.getByRole('button', { name: '生成任务' }));
return screen.findByRole('region', { name: '生成任务' });
}
/**
* 收起「生成任务」侧栏后等它真的走完:收起要先播退场动画(`is-leaving`)再卸载,
* 所以点完关闭按钮不能直接断言 DOM 里已经没有它。
*/
async function waitForTasksSidebarLeaveAnimationToFinish() {
expect(
document.querySelector('.game-resource-generation-tasks-sidebar')
?.className,
).toContain('is-leaving');
await waitFor(() =>
expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(),
);
}
function completedGenerationRecord(input: {
taskId: string;
assetId: string | null;
projectId: string;
}) {
return {
taskId: input.taskId,
projectId: input.projectId,
kind: 'character',
assetName: '定位目标素材',
status: 'completed',
phaseDetail: '生成已完成。',
createdAtMillis: 1,
startedAtMillis: 1,
finishedAtMillis: 2,
assetId: input.assetId,
error: null,
};
}
it('locates a generated asset that lives in another column instead of leaving the notice pending', async () => {
const panel = await renderGenerationLocateView({
projectId: 'workbench-locate-other-column',
projectPath: '/tmp/workbench-locate-other-column',
// 停在 UI 交互栏目:目标素材在 character 栏目。
openCategory: 'UI 交互',
assetId: 'locate-other-column-asset',
ledgerRecords: [
completedGenerationRecord({
taskId: 'task-locate-other-column',
assetId: 'locate-other-column-asset',
projectId: 'workbench-locate-other-column',
}),
],
});
fireEvent.click(
within(panel).getByRole('button', { name: '定位素材 定位目标素材' }),
);
// 悬而未决的中转提示必须消失,并且真的切到目标素材所在栏目。
await waitFor(() =>
expect(screen.queryByText('正在定位生成的素材…')).toBeNull(),
);
await waitFor(() =>
expect(
document.querySelector(
'.game-resource-book-scene-titlebar.is-active[data-resource-book-category="character"]',
),
).not.toBeNull(),
);
}, 20_000);
it('focuses a generated asset that is already in the current column', async () => {
// 这条是「点了没反应」的最小复现:不切栏目、不搜索,画布状态一个都不变,
// 只靠聚焦请求序号让 effect 重跑。
const panel = await renderGenerationLocateView({
projectId: 'workbench-locate-same-column',
projectPath: '/tmp/workbench-locate-same-column',
openCategory: '角色与对象',
assetId: 'locate-same-column-asset',
ledgerRecords: [
completedGenerationRecord({
taskId: 'task-locate-same-column',
assetId: 'locate-same-column-asset',
projectId: 'workbench-locate-same-column',
}),
],
});
fireEvent.click(
within(panel).getByRole('button', { name: '定位素材 定位目标素材' }),
);
await waitFor(() =>
expect(
document.querySelector(
'.game-resource-card-select[data-resource-id="asset:locate-same-column-asset"][aria-pressed="true"]',
),
).not.toBeNull(),
);
expect(screen.queryByText('正在定位生成的素材…')).toBeNull();
}, 20_000);
it('centers the canvas viewport on the located asset so it is actually visible', async () => {
// 「定位到素材」只把卡选中不够:这张画布是 transform 平移的,卡在视口外时
// `scrollIntoView()` 碰不到任何滚动祖先,用户看到的就是"定位过去了但依然见不到素材"。
const panel = await renderGenerationLocateView({
projectId: 'workbench-locate-centers-viewport',
projectPath: '/tmp/workbench-locate-centers-viewport',
openCategory: '角色与对象',
assetId: 'locate-centers-asset',
ledgerRecords: [
completedGenerationRecord({
taskId: 'task-locate-centers',
assetId: 'locate-centers-asset',
projectId: 'workbench-locate-centers-viewport',
}),
],
});
// 先把画布量出尺寸:jsdom 里 clientWidth/Height 恒为 0,不量就会静默走"没尺寸不居中"那条分支。
// 居中使用的是栏目页画布(`.game-resource-page-canvas`),不是外层 `[aria-label]` 容器。
const canvas = (await screen.findByLabelText(
'资源依赖视图',
)) as HTMLDivElement;
const pageCanvas = canvas.querySelector<HTMLElement>(
'.game-resource-page-canvas',
);
expect(pageCanvas).not.toBeNull();
Object.defineProperties(pageCanvas!, {
clientWidth: { configurable: true, get: () => 800 },
clientHeight: { configurable: true, get: () => 600 },
});
fireEvent.click(
within(panel).getByRole('button', { name: '定位素材 定位目标素材' }),
);
const card = await waitFor(() => {
const element = document.querySelector<HTMLElement>(
'.game-resource-card[data-resource-card-id="asset:locate-centers-asset"]',
);
expect(element).not.toBeNull();
return element!;
});
await waitFor(() =>
expect(
document.querySelector(
'.game-resource-card-select[data-resource-id="asset:locate-centers-asset"][aria-pressed="true"]',
),
).not.toBeNull(),
);
const readPixels = (name: string) =>
Number(
/([\d.-]+)px/u.exec(card.style.getPropertyValue(name))?.[1] ?? 'NaN',
);
const centerX =
readPixels('--resource-x') + readPixels('--resource-card-width') / 2;
const centerY =
readPixels('--resource-y') + readPixels('--resource-card-height') / 2;
expect(Number.isFinite(centerX)).toBe(true);
expect(Number.isFinite(centerY)).toBe(true);
const world = document.querySelector<HTMLElement>(
'.game-resource-page-canvas[data-resource-section-scroll="character"] [data-resource-viewport]',
);
const [viewportX, viewportY, viewportScale] = (
world?.getAttribute('data-resource-viewport') ?? ''
)
.split(',')
.map(Number);
expect(viewportScale).toBeGreaterThan(0);
// 视口平移量必须让卡片中心落在画布中心:`viewportX + centerX × scale = 画布宽 / 2`。
const canvasWidth = pageCanvas!.clientWidth;
const canvasHeight = pageCanvas!.clientHeight;
expect(canvasWidth).toBeGreaterThan(0);
expect(canvasHeight).toBeGreaterThan(0);
expect(viewportX! + centerX * viewportScale!).toBeCloseTo(
canvasWidth / 2,
0,
);
expect(viewportY! + centerY * viewportScale!).toBeCloseTo(
canvasHeight / 2,
0,
);
}, 20_000);
it('surfaces the existing clear-search action when the generated asset is filtered out', async () => {
const panel = await renderGenerationLocateView({
projectId: 'workbench-locate-hidden',
projectPath: '/tmp/workbench-locate-hidden',
openCategory: '角色与对象',
assetId: 'locate-hidden-asset',
ledgerRecords: [
completedGenerationRecord({
taskId: 'task-locate-hidden',
assetId: 'locate-hidden-asset',
projectId: 'workbench-locate-hidden',
}),
],
});
// 用搜索条件把目标素材挡掉:筛选面板的关键词就是画布唯一的搜索入口。
fireEvent.keyDown(window, { key: 'f', ctrlKey: true });
fireEvent.change(screen.getByLabelText('查找素材'), {
target: { value: 'zzz-no-such-resource' },
});
fireEvent.keyDown(document, { key: 'Escape' });
fireEvent.click(
within(panel).getByRole('button', { name: '定位素材 定位目标素材' }),
);
await waitFor(() =>
expect(screen.queryByText('正在定位生成的素材…')).toBeNull(),
);
expect(
screen.getByRole('button', { name: '清除搜索并定位' }),
).not.toBeNull();
}, 20_000);
it('settles a locate request whose asset is not in the project at all', async () => {
const panel = await renderGenerationLocateView({
projectId: 'workbench-locate-missing',
projectPath: '/tmp/workbench-locate-missing',
openCategory: '角色与对象',
assetId: 'locate-missing-asset',
ledgerRecords: [
completedGenerationRecord({
taskId: 'task-locate-missing',
assetId: 'asset-that-no-longer-exists',
projectId: 'workbench-locate-missing',
}),
],
});
fireEvent.click(
within(panel).getByRole('button', { name: '定位素材 定位目标素材' }),
);
expect(
await screen.findByText('素材已不在项目里(可能已被删除)'),
).not.toBeNull();
expect(screen.queryByText('正在定位生成的素材…')).toBeNull();
}, 20_000);
/**
* 提交面板的公共夹具:一个带权威规范图的 UI 交互栏目视图,`startLocalAsset` 决定
* `start_local_project_asset_generation` 这一次调用的行为。
*/
async function renderAssetGenerationSubmitView(input: {
projectId: string;
projectPath: string;
startLocalAsset: (args: {
taskId: string;
assetName: string;
}) => Promise<unknown>;
listRecords?: (started: Map<string, Record<string, unknown>>) => unknown;
/** 把首个原型标成已完成,让「运行」页签可切(`runAvailable` 为真)。 */
runnable?: boolean;
}) {
const manifest = createGameCreationAppManifest(
input.projectId,
'生成提交面板测试',
);
if (input.runnable) {
const codePrototype = manifest.tasks.find(
(task) => task.id === 'code-prototype',
);
if (!codePrototype) {
throw new Error('missing code-prototype seed task');
}
codePrototype.status = 'completed';
}
manifest.assets = [
{
id: 'submit-art-spec',
kind: 'icon-spec',
mediaType: 'image/png',
localPath: 'assets/art-spec.png',
source: { kind: 'canvas', resourceId: 'submit-art-spec-resource' },
},
];
const tasks = new Map<string, Record<string, unknown>>();
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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: input.projectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'start_local_project_asset_generation') {
const started = await input.startLocalAsset({
taskId: String(args?.taskId),
assetName: String(args?.assetName),
});
if (started) {
tasks.set(String(args?.taskId), started as Record<string, unknown>);
}
return started;
}
if (command === 'list_local_project_asset_generations') {
if (input.listRecords) {
return input.listRecords(tasks);
}
return [...tasks.values()];
}
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 } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: input.projectPath,
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('UI 交互');
fireEvent.click(
await screen.findByRole('button', { name: '生成 UI 设计图' }),
);
const panel = await screen.findByRole('dialog', {
name: '生成 UI 设计图',
});
fireEvent.change(within(panel).getByLabelText('素材名称'), {
target: { value: '待提交设计图' },
});
await typeGenerationPrompt(panel, '主界面与背包页');
return panel;
}
it('closes the submission panel synchronously on submit and keeps stage text out of it', async () => {
// 提交这一步挂住:面板仍然必须立刻消失(不等受理、不等排队、不等生成)。
const panel = await renderAssetGenerationSubmitView({
projectId: 'workbench-submit-sync-close',
projectPath: '/tmp/workbench-submit-sync-close',
startLocalAsset: () => new Promise(() => undefined),
});
expect(panel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/);
fireEvent.click(
within(panel).getByRole('button', { name: '生成 UI 设计图' }),
);
expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull();
expect(panel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/);
// 阶段文案只出现在任务面板 / 提示条里,不在提交面板里。
expect(screen.getByRole('region', { name: '生成任务' })).not.toBeNull();
}, 20_000);
it('brings the submission panel back with the draft when the backend never accepted the submit', async () => {
const panel = await renderAssetGenerationSubmitView({
projectId: 'workbench-submit-instant-failure',
projectPath: '/tmp/workbench-submit-instant-failure',
startLocalAsset: () =>
Promise.reject(
new Error('项目权限策略拒绝执行:canvas.asset_generate'),
),
});
fireEvent.click(
within(panel).getByRole('button', { name: '生成 UI 设计图' }),
);
expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull();
// 后端从未受理 → 面板连草稿一起带回来,错误可见、可直接改后重试。
const reopened = await screen.findByRole('dialog', {
name: '生成 UI 设计图',
});
await waitFor(() =>
expect(within(reopened).getByRole('alert').textContent).toContain(
'项目权限策略拒绝执行:canvas.asset_generate',
),
);
await waitFor(() =>
expect(generationPromptText(reopened)).toBe('主界面与背包页'),
);
expect(
(within(reopened).getByLabelText('素材名称') as HTMLInputElement).value,
).toBe('待提交设计图');
}, 20_000);
it('does not reopen the submission panel when an accepted task fails later', async () => {
const panel = await renderAssetGenerationSubmitView({
projectId: 'workbench-submit-late-failure',
projectPath: '/tmp/workbench-submit-late-failure',
startLocalAsset: async ({ taskId }) => ({
taskId,
projectId: 'workbench-submit-late-failure',
kind: 'ui-design',
assetName: '待提交设计图',
status: 'running',
phaseDetail: '正在生成。',
createdAtMillis: 1,
startedAtMillis: 1,
finishedAtMillis: null,
assetId: null,
error: null,
}),
// 后端已经受理(start 返回了记录),随后这次生成失败。
listRecords: (started) =>
[...started.values()].map((task) => ({
...task,
status: 'failed',
phaseDetail: '生成失败:远端拒绝',
finishedAtMillis: 2,
error: '远端拒绝',
})),
});
fireEvent.click(
within(panel).getByRole('button', { name: '生成 UI 设计图' }),
);
// 受理之后才失败:面板不回来,只在任务面板收口为失败 + 一次提示条。
await waitFor(() =>
expect(screen.getByText('生成素材失败:远端拒绝')).not.toBeNull(),
);
expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull();
expect(
within(screen.getByRole('region', { name: '生成任务' })).getByText(
'生成失败:远端拒绝',
),
).not.toBeNull();
}, 20_000);
it('keeps a generation progressing while the sidebar is collapsed', async () => {
// 前两轮轮询先保持「在途」,让折叠后的在途计数可观测,之后才收口。
let listPolls = 0;
const panel = await renderAssetGenerationSubmitView({
projectId: 'workbench-sidebar-collapsed',
projectPath: '/tmp/workbench-sidebar-collapsed',
// 运行页签要真的能切,末尾那条「运行态也能重开侧栏」才不是在资源态自证。
runnable: true,
startLocalAsset: async ({ taskId }) => ({
taskId,
projectId: 'workbench-sidebar-collapsed',
kind: 'ui-design',
assetName: '待提交设计图',
status: 'running',
phaseDetail: '正在生成。',
createdAtMillis: 1,
startedAtMillis: 1,
finishedAtMillis: null,
assetId: null,
error: null,
}),
listRecords: (started) => {
listPolls += 1;
return [...started.values()].map((task) =>
listPolls >= 3
? {
...task,
status: 'completed',
phaseDetail: '生成已完成。',
assetId: 'sidebar-collapsed-asset',
finishedAtMillis: 2,
}
: task,
);
},
});
fireEvent.click(
within(panel).getByRole('button', { name: '生成 UI 设计图' }),
);
// 提交后侧栏自动展开(对齐网页端:排队提交后主动弹任务栏)。
const sidebar = await screen.findByRole('region', { name: '生成任务' });
expect(within(sidebar).getByText('待提交设计图')).not.toBeNull();
// 折叠侧栏:折叠只影响这个视图,任务仍在后台推进。
fireEvent.click(
within(sidebar).getByRole('button', { name: '关闭生成任务' }),
);
await waitForTasksSidebarLeaveAnimationToFinish();
// 画布上不留折叠把手:开合口只剩工具条那一枚「生成任务」按钮。
expect(
document.querySelector('.game-resource-generation-tasks-handle'),
).toBeNull();
expect(
screen.getByRole('button', { name: /^生成任务(?: · \d+)?$/ }),
).not.toBeNull();
// 收口后工具条按钮上的在途计数跟着归零 —— 折叠期间进度照常更新;
// 工具条按钮的 aria-label 恒为「生成任务」,所以计数只能查 data 属性,不能查可访问名。
await waitFor(
() =>
expect(
(
screen.getByRole('button', {
name: /^生成任务(?: · \d+)?$/,
}) as HTMLElement
).dataset.resourceGenerationTaskCount,
).toBe('0'),
{ timeout: 15_000 },
);
fireEvent.click(screen.getByRole('button', { name: '生成任务' }));
const reopened = await screen.findByRole('region', { name: '生成任务' });
// 重开后条目内容与在途计数都跟上了收口结果。
await waitFor(
() =>
expect(
within(reopened).getByLabelText('在途生成任务 0').textContent,
).toBe('0'),
{ timeout: 15_000 },
);
expect(within(reopened).getByText('待提交设计图')).not.toBeNull();
// 入口在「运行」页签下同样常驻:侧栏本体在运行态可见,入口若只在资源页签就没法再打开。
fireEvent.click(
within(reopened).getByRole('button', { name: '关闭生成任务' }),
);
await waitForTasksSidebarLeaveAnimationToFinish();
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
// 先钉住真的切到了运行页签,否则下面那条断言只是在资源态自证。
expect(
screen.getByRole('tab', { name: '运行' }).getAttribute('aria-selected'),
).toBe('true');
fireEvent.click(
screen.getByRole('button', { name: /^生成任务(?: · \d+)?$/ }),
);
expect(
await screen.findByRole('region', { name: '生成任务' }),
).not.toBeNull();
}, 20_000);
it('routes the audio column entries to the shared background generation ledger', async () => {
const manifest = createGameCreationAppManifest(
'workbench-bottom-toolbar-audio',
'底部工具栏音频项目',
);
manifest.assets = [
{
id: 'toolbar-audio',
kind: 'background-music',
mediaType: 'audio/mpeg',
localPath: 'assets/toolbar-bgm.mp3',
source: { kind: 'generated', taskId: 'audio-plan' },
},
];
const { calls } = installResourceBookBottomToolbarInvoke(manifest);
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-bottom-toolbar-audio',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('音频');
fireEvent.click(
await screen.findByRole('button', { name: '生成背景音乐' }),
);
const musicPanel = await screen.findByRole('dialog', {
name: '生成背景音乐',
});
// 单类型入口不再渲染类型选择器,音频入口只能在工具栏出现。
expect(
within(musicPanel).queryByRole('button', { name: '视频' }),
).toBeNull();
fireEvent.change(within(musicPanel).getByLabelText('生成提示词'), {
target: { value: '轻快的八音盒' },
});
fireEvent.click(
within(musicPanel).getByRole('button', { name: '生成背景音乐' }),
);
/*
音频与图片类走**同一条命令**:任务 id 就是这次生成的 operation id,另带一枚幂等键。
图片类那套比例 / 尺寸 / 参考 / 精确落点参数一个都不发——音频通道根本不读它们。
*/
await waitFor(() => {
const musicStart = generateCall(calls, 'background-music');
expect(musicStart).toMatchObject({
kind: 'background-music',
prompt: '轻快的八音盒',
assetName: '新背景音乐',
idempotencyKey: expect.any(String),
});
expect(Object.keys(musicStart).sort()).toEqual([
'assetName',
'idempotencyKey',
'kind',
'projectId',
'projectPath',
'prompt',
'taskId',
]);
expect(String(musicStart.taskId)).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
});
// 提交即关闭:面板不留在屏幕上等结果,进度交给「生成任务」侧栏。
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '生成背景音乐' })).toBeNull(),
);
fireEvent.click(await screen.findByRole('button', { name: '生成音效' }));
const soundPanel = await screen.findByRole('dialog', { name: '生成音效' });
fireEvent.change(within(soundPanel).getByLabelText('生成提示词'), {
target: { value: '木门缓慢推开的吱呀声' },
});
fireEvent.click(
within(soundPanel).getByRole('button', { name: '生成音效' }),
);
await waitFor(() =>
expect(generateCall(calls, 'sound-effect')).toMatchObject({
kind: 'sound-effect',
prompt: '木门缓慢推开的吱呀声',
assetName: '新音效',
idempotencyKey: expect.any(String),
}),
);
}, 20_000);
it('uploads toolbar files through the existing upload and paired manifest read chain', async () => {
const manifest = createGameCreationAppManifest(
'workbench-bottom-toolbar-upload',
'底部工具栏上传项目',
);
manifest.assets = [
{
id: 'toolbar-audio',
kind: 'background-music',
mediaType: 'audio/mpeg',
localPath: 'assets/toolbar-bgm.mp3',
source: { kind: 'generated', taskId: 'audio-plan' },
},
];
const { calls } = installResourceBookBottomToolbarInvoke(manifest);
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-bottom-toolbar-upload',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('音频');
const uploadInput = screen.getByLabelText(
'上传素材文件',
) as HTMLInputElement;
const file = new File(['id3'], 'custom-bgm.mp3', { type: 'audio/mpeg' });
// jsdom 的 File 没有 arrayBuffer;宿主上传链路按真实浏览器的能力读字节,这里补上同语义
// 的实现(仓库内其它上传用例同一做法),否则测的是环境缺口而不是上传链路。
if (typeof file.arrayBuffer !== 'function') {
Object.defineProperty(file, 'arrayBuffer', {
value: async () => new Uint8Array([105, 100, 51]).buffer,
});
}
fireEvent.change(uploadInput, { target: { files: [file] } });
await waitFor(() =>
expect(uploadCall(calls, 'custom-bgm.mp3')).toMatchObject({
projectPath: '/tmp/workbench-bottom-toolbar-upload',
fileName: 'custom-bgm.mp3',
mediaType: 'audio/mpeg',
bytes: [105, 100, 51],
}),
);
// 上传后仍走「配对读清单」的既有刷新路径。
const commands = calls.map((call) => call.command);
expect(commands.lastIndexOf('get_local_game_manifest')).toBeGreaterThan(
commands.indexOf('upload_local_asset'),
);
await waitFor(() =>
expect(screen.getByText('已上传 1 个素材')).not.toBeNull(),
);
}, 20_000);
it('keeps the bottom toolbar clear of the zoom dock and above the book scene', () => {
const styles = readFileSync(
repoPath('apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const chromeStyles = readFileSync(
repoPath(
'apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css',
),
'utf8',
);
const sceneZIndex = styleNumber(
styleRuleBody(styles, '\\.game-resource-book-scene'),
'z-index',
);
const toolbar = styleRuleBody(
chromeStyles,
'\\.game-resource-bottom-toolbar',
);
expect(toolbar).toMatch(/position:\s*absolute/u);
expect(styleNumber(toolbar, 'z-index')).toBeGreaterThan(sceneZIndex);
// 左侧锚定 + 右上都不声明:工具栏只占左下角,右下角的缩放 / 撤销 Dock 仍是它自己那一块。
expect(styleNumber(toolbar, 'left')).toBeGreaterThan(0);
expect(styleNumber(toolbar, 'bottom')).toBeGreaterThan(0);
expect(toolbar).not.toMatch(/(?:^|[;\s])right:/u);
expect(toolbar).not.toMatch(/width:\s*100%/u);
const dock = styleRuleBody(styles, '\\.game-resource-book-zoom');
expect(dock).toMatch(/(?:^|[;\s])right:/u);
expect(dock).not.toMatch(/(?:^|[;\s])left:/u);
// 二级菜单不能被工具栏自己的横向滚动裁掉(共享 chrome 默认 overflow-x: auto)。
expect(
styleRuleBody(chromeStyles, '\\.game-resource-bottom-toolbar-strip'),
).toMatch(/overflow:\s*visible/u);
});
}