e15456ea14
- readReport 增加 droppedMissingResource(资源已不在 manifest)/ droppedSectionMismatch(分区与资源分类不匹配)/ dropped(两者之和),判据仍是 normalizeResourceCanvasPosition,未改 resourceCanvasLayoutModel 的丢弃语义。 - 文案补上跳过条数与两类原因:归并与丢弃同时发生时用「另有 N 条坐标无法对齐已跳过(…)」;只有丢弃时用「打开项目时有 N 条坐标无法对齐已跳过(…)」。 - 提示条暴露 data-resource-canvas-layout-normalized / -dropped / -dropped-missing-resource / -dropped-section-mismatch,供排障直接读 DOM;完全没丢时四个值都是 0。 - 读盘触发条件放宽为「有归并或有丢弃」,避免只有丢弃时什么都不提示。 - 断言:读盘统计用例覆盖三种情况分离;文案用例覆盖四形态(含真机 112 / 66 / 16 口径);appSurface 用例断言四个 data-* 与完整文案。 - 变异验证:把 dropped 计数改成恒 0 → 统计用例、读盘报告用例、提示条用例三处变红;去掉 data-resource-canvas-layout-dropped → 提示条用例变红。
9753 lines
343 KiB
TypeScript
9753 lines
343 KiB
TypeScript
import userEvent from '@testing-library/user-event';
|
||
|
||
import type {
|
||
ProjectResourceCanvasLayout,
|
||
ProjectResourceCanvasPosition,
|
||
} from '../../../../packages/shared/src/contracts/gameCreationApp';
|
||
import { RESOURCE_REFERENCE_INSERT_EVENT } from '../../src/features/project-workspace/resourceReferences';
|
||
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 {
|
||
act,
|
||
agentRuntimeUserInputRequest,
|
||
App,
|
||
cleanup,
|
||
composerDisabled,
|
||
composerText,
|
||
composerValue,
|
||
createGameCreationAppManifest,
|
||
createGameCreationAppSeedTasks,
|
||
createProjectSupervisorRuntimeHarness,
|
||
emptyProjectPolicy,
|
||
expect,
|
||
findResourceSelectButton,
|
||
fireEvent,
|
||
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
|
||
type GameCreationAgentRunTrace,
|
||
getResourceSelectButton,
|
||
it,
|
||
mockRoleAgentReply,
|
||
openResourceSearch,
|
||
pickProjectFromLauncher,
|
||
ProjectDevelopmentView,
|
||
projectSupervisorResponseStream,
|
||
queryResourceSelectButton,
|
||
React,
|
||
readFileSync,
|
||
render,
|
||
renderAppAt,
|
||
renderLauncherProjectsAt,
|
||
resolve,
|
||
screen,
|
||
setComposerText,
|
||
submitChat,
|
||
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();
|
||
}
|
||
});
|
||
}
|
||
|
||
function createDeferred<T>() {
|
||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||
let reject!: (reason?: unknown) => void;
|
||
const promise = new Promise<T>((nextResolve, nextReject) => {
|
||
resolve = nextResolve;
|
||
reject = nextReject;
|
||
});
|
||
return { promise, reject, resolve };
|
||
}
|
||
|
||
/** 取 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,
|
||
supervisor: 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();
|
||
|
||
fireEvent.click(
|
||
screen.getByRole('button', {
|
||
name: '审批配置,当前严格审批',
|
||
}),
|
||
);
|
||
expect(
|
||
screen.getByRole('dialog', { name: '陶泥儿的操作权限' }),
|
||
).not.toBeNull();
|
||
const riskApproval = screen.getByRole('radio', { name: /风险审批/ });
|
||
fireEvent.click(riskApproval);
|
||
expect(riskApproval.getAttribute('aria-checked')).toBe('false');
|
||
expect(riskApproval.getAttribute('data-unavailable')).toBe('true');
|
||
expect(screen.getAllByText('Rank 规则待定,当前暂不可用')).toHaveLength(2);
|
||
fireEvent.click(screen.getByRole('button', { name: '完成' }));
|
||
expect(
|
||
screen.getByRole('button', {
|
||
name: '审批配置,当前严格审批',
|
||
}),
|
||
).not.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('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,
|
||
supervisor: 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,
|
||
supervisor: 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"]'),
|
||
);
|
||
expect(documentTitlebarBefore!.isConnected).toBe(true);
|
||
expect(documentTitlebarBefore!.classList.contains('is-active')).toBe(true);
|
||
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?.style.transform).toContain('scale(');
|
||
const childScene = childCard?.closest<HTMLElement>(
|
||
'.game-resource-book-scene',
|
||
);
|
||
const childSceneWorld = childScene?.querySelector<HTMLElement>(
|
||
'.game-resource-book-scene-world',
|
||
);
|
||
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,
|
||
supervisor: 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)!);
|
||
|
||
await openResourceBookCategory('角色与对象');
|
||
await waitFor(() =>
|
||
expect(
|
||
document.querySelectorAll(
|
||
'.game-resource-book-scene-card.is-expanded[data-resource-book-category="character"]',
|
||
),
|
||
).toHaveLength(5),
|
||
);
|
||
|
||
fireEvent.change(openResourceSearch(), {
|
||
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 search as a transient overlay that never lands on the titlebar band', () => {
|
||
const styles = readFileSync(
|
||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||
'utf8',
|
||
);
|
||
const tsxSource = readFileSync(
|
||
resolve(
|
||
process.cwd(),
|
||
'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);
|
||
|
||
const search = styleRuleBody(styles, '\\.game-resource-search');
|
||
expect(search).not.toMatch(/display:\s*none/u);
|
||
expect(search).toMatch(/display:\s*flex/u);
|
||
expect(search).toMatch(/position:\s*absolute/u);
|
||
expect(styleNumber(search, '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(search, 'bottom')).toBeGreaterThanOrEqual(
|
||
dockBottom + dockHeight,
|
||
);
|
||
// 浮层右边缘与 Dock 右边缘对齐。
|
||
expect(styleNumber(search, 'right')).toBe(styleNumber(dock, 'right'));
|
||
// 浮层只按底边 + 右边锚定:一旦补上 top,它会重新浮回管理区顶部那条标题栏带上。
|
||
expect(search).not.toMatch(/(?:^|[;\s])top:/u);
|
||
const titlebar = styleRuleBody(
|
||
styles,
|
||
'\\.game-resource-book-scene-titlebar',
|
||
);
|
||
const titlebarHeight = styleNumber(titlebar, 'min-height');
|
||
expect(titlebarHeight).toBeGreaterThan(0);
|
||
|
||
// 结构上搜索框只在浮层打开时渲染,而且挂在管理区下(与画本场景并列),不是常驻节点。
|
||
const searchTag = tsxSource.indexOf('className="game-resource-search"');
|
||
expect(searchTag).toBeGreaterThan(-1);
|
||
expect(tsxSource).toMatch(
|
||
/\{resourceSearchOpen \? \(\s*<label\s+ref=\{resourceSearchPopoverRef\}\s+className="game-resource-search"/s,
|
||
);
|
||
|
||
// 浮层提示层是绝对定位的浮层,容器不吃点击:否则这条横跨管理区的空盒子会把
|
||
// 「资源总览」(回到资源总览)这类按钮的命中区域整段吃掉。
|
||
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('renders the resource search overlay 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,
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
onHomeOpen: vi.fn(),
|
||
onProjectsOpen: vi.fn(),
|
||
}),
|
||
);
|
||
|
||
// 搜索浮层与栏目标题栏分属两条排布带,这不是 CSS 声明能表达的:画本场景是管理区里
|
||
// 铺满整块的绝对定位层,搜索框只要落在场景内部,就会跟着场景走、和栏目标题栏共享
|
||
// 同一条水平带。所以这里钉住渲染出来的 DOM 归属关系:浮层是管理区的直接子节点,
|
||
// 与画本场景并列,而且只在打开时存在。
|
||
expect(screen.queryByLabelText('搜索项目资源')).toBeNull();
|
||
const searchLabel = openResourceSearch().closest('label');
|
||
expect(searchLabel?.className).toContain('game-resource-search');
|
||
|
||
const manager = searchLabel?.parentElement ?? null;
|
||
expect(manager?.className).toContain('game-resource-manager');
|
||
|
||
// 入场动画会替换场景节点,所以这里用祖先链判断归属关系,不依赖某一个具体节点的
|
||
// 身份,也不受节点替换影响。从父节点起算:label 自己就带 `game-resource-search`。
|
||
const searchAncestors = searchLabel?.parentElement ?? null;
|
||
expect(searchAncestors?.closest('.game-resource-book-scene')).toBeNull();
|
||
expect(searchAncestors?.closest('.game-resource-book-main')).toBeNull();
|
||
expect(searchAncestors).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(
|
||
resolve(process.cwd(), 'packages/image-canvas-react/src/styles.css'),
|
||
'utf8',
|
||
);
|
||
// 消费端 `border/background: var(...)` 没有 fallback:token 缺失或被写透明,框选就没有颜色。
|
||
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,
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
onHomeOpen: vi.fn(),
|
||
onProjectsOpen: vi.fn(),
|
||
}),
|
||
);
|
||
|
||
// 搜索框不再常驻:常态只有右下角 Dock 里的搜索按钮,浮层由按钮或 Ctrl/Cmd+F 叫出。
|
||
expect(screen.queryByLabelText('搜索项目资源')).toBeNull();
|
||
const searchInput = openResourceSearch();
|
||
expect(searchInput.type).toBe('search');
|
||
expect(searchInput.closest('label')?.className).toContain(
|
||
'game-resource-search',
|
||
);
|
||
|
||
const overviewCards = () =>
|
||
document.querySelectorAll(
|
||
'.game-resource-book-scene-card[data-resource-book-category="character"]',
|
||
);
|
||
await waitFor(() => expect(overviewCards()).toHaveLength(2));
|
||
|
||
// 总览态:输入即过滤总览摞上的卡片。
|
||
fireEvent.change(searchInput, { 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(openResourceSearch(), { target: { value: '' } });
|
||
await waitFor(() =>
|
||
expect(queryResourceSelectButton('beta-enemy.png')).not.toBeNull(),
|
||
);
|
||
});
|
||
|
||
it('calls out the resource search overlay 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',
|
||
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,
|
||
supervisor: 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 searchTrigger = () =>
|
||
screen.getByRole('button', { name: '搜索资源' });
|
||
expect(searchTrigger().getAttribute('aria-expanded')).toBe('false');
|
||
|
||
// Ctrl/Cmd+F 叫出浮层,焦点自动落到搜索框(PRD「焦点落到资源搜索框」的入口)。
|
||
fireEvent.keyDown(window, { key: 'f', ctrlKey: true });
|
||
const searchInput = screen.getByLabelText(
|
||
'搜索项目资源',
|
||
) as HTMLInputElement;
|
||
expect(searchTrigger().getAttribute('aria-expanded')).toBe('true');
|
||
expect(document.activeElement).toBe(searchInput);
|
||
|
||
fireEvent.change(searchInput, { target: { value: 'alpha-hero' } });
|
||
await waitFor(() => expect(overviewCards()).toHaveLength(1));
|
||
// 有搜索条件时 Dock 上的搜索按钮保持高亮:浮层收起后条件仍在,按钮必须看得出来。
|
||
expect(searchTrigger().className).toContain('is-active');
|
||
|
||
// Esc 只收起浮层:搜索条件不跟着被清掉(关闭不等于清除搜索)。
|
||
fireEvent.keyDown(document, { key: 'Escape' });
|
||
expect(screen.queryByLabelText('搜索项目资源')).toBeNull();
|
||
expect(overviewCards()).toHaveLength(1);
|
||
expect(searchTrigger().getAttribute('aria-expanded')).toBe('false');
|
||
|
||
// 点搜索按钮同样能开合,且不会被「点外部关闭」抢在前面。
|
||
fireEvent.click(searchTrigger());
|
||
expect(screen.getByLabelText('搜索项目资源')).not.toBeNull();
|
||
fireEvent.click(searchTrigger());
|
||
expect(screen.queryByLabelText('搜索项目资源')).toBeNull();
|
||
|
||
// 点浮层外部关闭,条件继续保留。
|
||
fireEvent.click(searchTrigger());
|
||
expect(screen.getByLabelText('搜索项目资源')).not.toBeNull();
|
||
fireEvent.click(document.body);
|
||
expect(screen.queryByLabelText('搜索项目资源')).toBeNull();
|
||
expect(overviewCards()).toHaveLength(1);
|
||
|
||
// 「清除搜索并定位」那条链路的入口还在:清空搜索后过滤恢复。
|
||
fireEvent.click(searchTrigger());
|
||
fireEvent.change(screen.getByLabelText('搜索项目资源'), {
|
||
target: { value: '' },
|
||
});
|
||
await waitFor(() => expect(overviewCards()).toHaveLength(2));
|
||
expect(searchTrigger().className).not.toContain('is-active');
|
||
});
|
||
|
||
it('keeps the overview-return button out of the floating notice layer hit region', () => {
|
||
const styles = readFileSync(
|
||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||
'utf8',
|
||
);
|
||
const tsxSource = readFileSync(
|
||
resolve(
|
||
process.cwd(),
|
||
'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,
|
||
supervisor: 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;
|
||
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;
|
||
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,
|
||
supervisor: 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(
|
||
'已把 1 条旧分区坐标对齐到新分区并写回,坐标位置未变;另有 2 条坐标无法对齐已跳过(1 条资源已不在项目中,1 条分区与资源分类不匹配)',
|
||
);
|
||
// 排障口径:完全没丢是 0 / 0,丢的是哪一类由两个子计数分开。
|
||
expect(notice.dataset.resourceCanvasLayoutNormalized).toBe('1');
|
||
expect(notice.dataset.resourceCanvasLayoutDropped).toBe('2');
|
||
expect(notice.dataset.resourceCanvasLayoutDroppedMissingResource).toBe('1');
|
||
expect(notice.dataset.resourceCanvasLayoutDroppedSectionMismatch).toBe('1');
|
||
// 读时归并会当场写回一次 sidecar,提示说的就是这次写回。
|
||
await waitFor(() => expect(layoutRevision).toBeGreaterThan(0));
|
||
|
||
// 一次性提示,不是常驻说明:关掉即从 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,
|
||
};
|
||
}
|
||
// 没有归并、没有丢弃就不该写回:这里失败关闭,避免「悄悄写了一次」被漏掉。
|
||
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,
|
||
supervisor: 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,
|
||
supervisor: 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,
|
||
},
|
||
supervisor: 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,
|
||
supervisor: 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,
|
||
supervisor: 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,
|
||
supervisor: 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('[data-resource-book-all-host="true"]'),
|
||
).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,
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
onHomeOpen: vi.fn(),
|
||
onProjectsOpen: vi.fn(),
|
||
};
|
||
const rendered = render(
|
||
React.createElement(ProjectDevelopmentView, viewProps),
|
||
);
|
||
|
||
const refreshedManifest = {
|
||
...manifest,
|
||
assets: [
|
||
{
|
||
id: 'imported-image',
|
||
kind: 'ui',
|
||
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,
|
||
supervisor: 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 判为视频。
|
||
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,
|
||
supervisor: 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');
|
||
expect(heroCard?.textContent).not.toContain('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 documentSummary = await screen.findByText(/这是安全的卡片正文摘要。/);
|
||
expect(
|
||
documentSummary
|
||
.closest('.game-resource-card')
|
||
?.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 = openResourceSearch();
|
||
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,
|
||
supervisor: 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(() => {
|
||
expect(
|
||
document.querySelectorAll('.game-resource-card-visual > img'),
|
||
).toHaveLength(48);
|
||
});
|
||
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,
|
||
);
|
||
});
|
||
|
||
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,
|
||
supervisor: 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(
|
||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||
'utf8',
|
||
);
|
||
// 卡片本体不再自带描边;只有被当前版本绑定的素材才有边框。
|
||
expect(styles).toMatch(/\.game-resource-card\s*\{[^}]*border:\s*0;/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('资源卡的 @ 引用入口只留在选中工具条里', () => {
|
||
const styles = readFileSync(
|
||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||
'utf8',
|
||
);
|
||
// 卡片右上角那个圆钮连同它撑出来的 44×44 热区一起退役:卡片本体只剩
|
||
// 「选中资源」与媒体播放钮,卡片 chrome 用例(上一条)继续钉这条口径。
|
||
expect(styles).not.toContain('.game-resource-card-reference');
|
||
|
||
const source = readFileSync(
|
||
resolve(
|
||
process.cwd(),
|
||
'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}`}',
|
||
);
|
||
expect(extraActions).toContain('title="@引用"');
|
||
expect(extraActions).toContain('<span>@引用</span>');
|
||
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*selectedResource\.subtype \|\|\s*selectedResource\.category,\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,
|
||
supervisor: 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 序;标题沿用「@引用」,与卡片入口时期的说法一致。
|
||
expect(referenceButton.tagName).toBe('BUTTON');
|
||
expect(referenceButton.getAttribute('type')).toBe('button');
|
||
expect(referenceButton.getAttribute('title')).toBe('@引用');
|
||
|
||
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,
|
||
supervisor: React.createElement('div', null, '项目总控'),
|
||
onHomeOpen: vi.fn(),
|
||
onProjectsOpen: vi.fn(),
|
||
}),
|
||
);
|
||
|
||
// 只读字段与运行页签同源:名称 / 路径 / 类型 + 只读的分类与标签,
|
||
// 不含「来源任务」「资产 ID」这类 manifest 内部标识。
|
||
const expectedRows = [
|
||
['名称', 'hero.png'],
|
||
['路径', 'assets/hero.png'],
|
||
['类型', 'image/png'],
|
||
['分类', '角色与对象'],
|
||
['标签', '主角、待定稿'],
|
||
];
|
||
|
||
await openResourceBookCategory('角色与对象');
|
||
fireEvent.click(await findResourceSelectButton('hero.png'));
|
||
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
|
||
const toolbarLabels = within(toolbar)
|
||
.getAllByRole('button')
|
||
.map((button) => button.getAttribute('aria-label') ?? '');
|
||
const infoButton = within(toolbar).getByRole('button', { name: '信息' });
|
||
// 位置固定在「@引用」之后、「编辑标签」之前:工具条上的顺序即功能顺序。
|
||
expect(toolbarLabels.indexOf('信息')).toBeGreaterThan(
|
||
toolbarLabels.indexOf('引用资源 hero.png'),
|
||
);
|
||
expect(toolbarLabels.indexOf('信息')).toBeLessThan(
|
||
toolbarLabels.indexOf('编辑标签'),
|
||
);
|
||
expect(infoButton.getAttribute('aria-pressed')).toBe('false');
|
||
|
||
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'));
|
||
const reopenedToolbar = await screen.findByRole('toolbar', {
|
||
name: '图片工具栏',
|
||
});
|
||
fireEvent.click(
|
||
within(reopenedToolbar).getByRole('button', { name: '信息' }),
|
||
);
|
||
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: '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\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,
|
||
supervisor: 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);
|
||
|
||
// 视频(animation → character-animation)落在「角色与对象」,
|
||
// 音频(bgm 不在 canonical 目录里)落在「待归类」。
|
||
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「素材工具栏」),
|
||
// 并且只渲染宿主编排层真实接通的动作:「@引用」(从卡片挪进工具条的引用入口,
|
||
// 资源卡上的圆钮已删除)「信息」(只读信息浮层)「编辑标签」(面板只编辑 manifest
|
||
// `assets[].tags`,分类不再有手动入口)「重命名」已接面板,「下载按钮」复用资源
|
||
// 面板同一条落盘链路,「改造」在宿主编排层仍是空回调,不能再渲染成点了没反应的按钮。
|
||
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,
|
||
supervisor: 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(
|
||
screen
|
||
.queryAllByTitle('选中资源')
|
||
.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,
|
||
supervisor: 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 = openResourceSearch();
|
||
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,
|
||
supervisor: 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,
|
||
},
|
||
];
|
||
let observerCount = 0;
|
||
let observerDisconnected = false;
|
||
class TestResizeObserver {
|
||
constructor(readonly callback: ResizeObserverCallback) {
|
||
observerCount += 1;
|
||
}
|
||
|
||
observe() {}
|
||
|
||
unobserve() {}
|
||
|
||
disconnect() {
|
||
observerDisconnected = true;
|
||
}
|
||
}
|
||
Object.defineProperty(window, 'ResizeObserver', {
|
||
configurable: true,
|
||
value: TestResizeObserver,
|
||
});
|
||
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(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(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,
|
||
supervisor: 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,
|
||
supervisor: 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: [],
|
||
supervisor: 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,
|
||
supervisor: 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: [],
|
||
supervisor: 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,
|
||
supervisor: 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-prototype-first-by-label',
|
||
kind: 'ui-prototype',
|
||
mediaType: 'image/png',
|
||
localPath: 'assets/a-ui-prototype.png',
|
||
source: { kind: 'generated', taskId: 'design-foundation' },
|
||
},
|
||
{
|
||
id: 'art-spritesheet-last-by-label',
|
||
kind: 'art-spritesheet',
|
||
mediaType: 'image/png',
|
||
localPath: 'assets/z-art-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: [],
|
||
supervisor: 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:art-spritesheet-last-by-label',
|
||
x: 0,
|
||
y: 0,
|
||
}),
|
||
expect.objectContaining({
|
||
resourceId: 'asset:ui-prototype-first-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,
|
||
},
|
||
],
|
||
supervisor: 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(
|
||
resolve(process.cwd(), '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-actions \.game-workbench-play-button\s*\{[^}]*position:\s*absolute[^}]*left:\s*50%[^}]*transform:\s*translateX\(-50%\)/s,
|
||
);
|
||
expect(styles).toMatch(
|
||
/@media \(max-width: 1000px\)[\s\S]*?\.game-workbench-view-actions \.game-workbench-play-button\s*\{[^}]*position:\s*static[^}]*transform:\s*none/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-supervisor-conversation\s*\{[^}]*position:\s*relative[^}]*display:\s*block[^}]*height:\s*100%[^}]*min-height:\s*0[^}]*overflow:\s*hidden/s,
|
||
);
|
||
expect(styles).toMatch(
|
||
/\.game-workbench-chat \.project-supervisor-message-list\s*\{[^}]*height:\s*100%[^}]*min-height:\s*96px[^}]*overflow-y:\s*auto[^}]*padding-bottom:\s*196px[^}]*scroll-padding-bottom:\s*196px/s,
|
||
);
|
||
expect(styles).toMatch(
|
||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-message-list\s*\{[^}]*position:\s*absolute[^}]*bottom:\s*0;[^}]*left:\s*0/s,
|
||
);
|
||
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,
|
||
);
|
||
expect(styles).toMatch(
|
||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\) 82px[^}]*z-index:\s*2[^}]*padding-top:\s*8px[^}]*background:\s*transparent/s,
|
||
);
|
||
const composerRule = styles.match(
|
||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\s*\{([^}]*)\}/s,
|
||
);
|
||
expect(composerRule?.[1]).not.toContain('border-top:');
|
||
expect(styles).toMatch(
|
||
/\.supervisor-chat-only-runtime-controls \.pending-command\s*\{[^}]*display:\s*grid[^}]*grid-template-columns:\s*minmax\(0, 1fr\) auto/s,
|
||
);
|
||
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-supervisor-submit-button`:以前是 composer 下所有
|
||
// `button`,会把绝对定位广播到输入区里的「AI 润色」上,把它浮到文本区中间。
|
||
expect(styles).toMatch(
|
||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\s+\.project-supervisor-submit-button\s*\{[^}]*min-height:\s*72px[^}]*white-space:\s*nowrap/s,
|
||
);
|
||
expect(styles).not.toMatch(
|
||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-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-supervisor-surface\.is-direct-codex\s+\.project-supervisor-message-list\s*\{[^}]*position:\s*static[^}]*height:\s*52vh[^}]*flex:\s*1 1 auto/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(
|
||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||
'utf8',
|
||
);
|
||
const projectDevelopmentSource = readFileSync(
|
||
resolve(
|
||
process.cwd(),
|
||
'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]*?\/>/,
|
||
);
|
||
expect(projectDevelopmentSource).toMatch(
|
||
/<div className="game-workbench-chat-wallet">\{walletEntry\}<\/div>/,
|
||
);
|
||
expect(projectDevelopmentSource).toMatch(
|
||
/const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*selectedResourceIds\.length === 0\s*&&\s*!uiEditorRoute/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('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,
|
||
supervisor: 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(
|
||
resolve(process.cwd(), '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(
|
||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||
'utf8',
|
||
);
|
||
// 对话框就是消息列表那只铺满会话区的盒子;输入区是它内部的一块,不再是贴在它下边、
|
||
// 四边描边与它连成"两只盒子紧挨着"的第二只盒子。
|
||
const messageListRule =
|
||
styles.match(
|
||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-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-supervisor-surface\.is-direct-codex\s+\.project-supervisor-composer\.is-direct-codex\s*\{([^}]*)\}/s,
|
||
)?.[1] ?? '';
|
||
expect(composerRule).not.toBe('');
|
||
// 输入区四边内缩:左右下三边各 12px 显式钉住,上边由 `max-height: calc(100% - 24px)`
|
||
// 兜底,四条边都不与外框描边重合,底边还留出 12px 边距。
|
||
const inset = styleNumber(composerRule, 'right');
|
||
expect(inset).toBeGreaterThan(0);
|
||
expect(styleNumber(composerRule, 'left')).toBe(inset);
|
||
expect(styleNumber(composerRule, 'bottom')).toBe(inset);
|
||
expect(composerRule).toContain('position: absolute;');
|
||
// 输入区自己那只盒子还在(拆掉边框/底色会让输入区变成没有边界的裸文本)。
|
||
expect(composerRule).toContain(
|
||
'border: 1px solid var(--platform-surface-border);',
|
||
);
|
||
expect(composerRule).toContain('background: var(--platform-input-fill);');
|
||
|
||
// 消息列表给输入区留出位置:底边留白 = 内缩 + 输入区最高高度 + 间距。
|
||
// 输入区最高高度 = 编辑器 max-height(140) + 输入框行距(8) + 操作排 28px 方钮(28)
|
||
// + 输入框下内边距(4) + 输入区自己上下内边距(8 × 2) + 操作条(2 + 30 方钮) = 228;
|
||
// 256 = 12px 内缩 + 228 + 16px 间距。这里按层叠"第一条"取值,改动后务必同时跑
|
||
// tests/chatDialogFrameLayout.test.ts——那条用例按层叠生效值验同一组几何(含窄屏),
|
||
// 后面再写一条同选择器的规则顶掉这里,只有那条会失败。
|
||
const composerClearance = styleNumber(
|
||
messageListRule,
|
||
'scroll-padding-bottom',
|
||
);
|
||
expect(composerClearance).toBeGreaterThanOrEqual(inset + 228);
|
||
expect(composerClearance).toBe(inset + 228 + 16);
|
||
const messageListPadding =
|
||
/padding:\s*18px 20px (\d+)px;/u.exec(messageListRule)?.[1] ?? '';
|
||
expect(Number(messageListPadding)).toBe(composerClearance);
|
||
|
||
// 编辑器高度上限就是上面那个 228 的来源之一;改这里就必须同步改留白。
|
||
const editorRule =
|
||
styles.match(
|
||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-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(
|
||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||
'utf8',
|
||
);
|
||
const messageListRule =
|
||
styles.match(
|
||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-message-list\s*\{([^}]*)\}/s,
|
||
)?.[1] ?? '';
|
||
const messageRule =
|
||
styles.match(
|
||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-message-list\s+\.message\s*\{([^}]*)\}/s,
|
||
)?.[1] ?? '';
|
||
const userMessageRule =
|
||
styles.match(
|
||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-message-list\s+\.message--user\s*\{([^}]*)\}/s,
|
||
)?.[1] ?? '';
|
||
const processCardRules = Array.from(
|
||
styles.matchAll(
|
||
/\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-message-list\s*>\s*\.project-supervisor-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);');
|
||
expect(processCardRules).toHaveLength(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,
|
||
supervisor: 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(openResourceSearch(), {
|
||
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: 'ui-prototype',
|
||
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,
|
||
},
|
||
],
|
||
supervisor: 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('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,
|
||
supervisor: 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 registerUserSurfaceBoundaryTests() {
|
||
it('keeps the user surface to chat, upload, config and command confirmation', async () => {
|
||
renderAppAt('/?main');
|
||
|
||
expect(screen.getByLabelText('聊天')).not.toBeNull();
|
||
expect(screen.getByLabelText('Agent 状态')).not.toBeNull();
|
||
const composerInput = screen.getByLabelText('创作想法');
|
||
expect(composerInput).not.toBeNull();
|
||
expect(screen.getByText('上传')).not.toBeNull();
|
||
expect(screen.getByRole('button', { name: '命令' })).not.toBeNull();
|
||
expect(screen.getByRole('button', { name: '能力' })).not.toBeNull();
|
||
expect(screen.getByRole('button', { name: '配置' })).not.toBeNull();
|
||
expect(screen.getByRole('button', { name: 'LLM状态' })).not.toBeNull();
|
||
expect(screen.getByRole('button', { name: '显示目录' })).not.toBeNull();
|
||
expect(screen.queryByLabelText('项目摘要')).toBeNull();
|
||
expect(
|
||
(screen.getByRole('button', { name: '灵感草稿' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(false);
|
||
expect(
|
||
(screen.getByRole('button', { name: '项目状态' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '权限' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '审计' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '运行' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '资产' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '打开画板' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(false);
|
||
expect(
|
||
(
|
||
screen.getByRole('button', {
|
||
name: '导入画板资产',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '任务' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '索引确认' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(
|
||
screen.getByRole('button', {
|
||
name: '资产登记确认',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(
|
||
screen.getByRole('button', {
|
||
name: '记忆写入确认',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '预览确认' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(
|
||
screen.getByRole('button', {
|
||
name: '打开预览确认',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(
|
||
screen.getByRole('button', {
|
||
name: '停止预览确认',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: 'Agent确认' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(
|
||
screen.getByRole('button', {
|
||
name: '读对话确认',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(
|
||
screen.getByRole('button', {
|
||
name: '存对话确认',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: 'Trace' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '文件' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '索引' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '记忆' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '短期记忆' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '黑板' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '记到黑板' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '覆盖黑板' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '清空黑板' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '快照' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '快照列表' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '历史' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '白名单' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(false);
|
||
expect(
|
||
(screen.getByRole('button', { name: '静态自检' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '启动预览' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '打开预览' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '预览状态' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '停止预览' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '刷新状态' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '刷新 Agent' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '状态' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '终止' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '重试' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '继续' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '继续说明' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '输出' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '活动' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(screen.getByRole('button', { name: '上下文包' }) as HTMLButtonElement)
|
||
.disabled,
|
||
).toBe(true);
|
||
expect(
|
||
(
|
||
screen.getByRole('button', {
|
||
name: /拆解创作方向/,
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(true);
|
||
fireEvent.click(screen.getByRole('button', { name: '打开画板' }));
|
||
expect(await composerValue(composerInput)).toBe('/canvas ');
|
||
fireEvent.click(screen.getByRole('button', { name: '灵感草稿' }));
|
||
expect(await composerValue(composerInput)).toBe(
|
||
'像素风厨房弹幕小游戏:玩家用方向键躲避飞来的食材,收集调料加分,60 秒内尽量高分,失败后可一键重开。',
|
||
);
|
||
expect(screen.queryByText('game.generate_draft')).toBeNull();
|
||
fireEvent.click(screen.getByRole('button', { name: '白名单' }));
|
||
expect(await screen.findByText(/可运行受限命令:/)).not.toBeNull();
|
||
expect(screen.getByRole('button', { name: '切换项目' })).not.toBeNull();
|
||
expect(screen.getByText('想做什么游戏?')).not.toBeNull();
|
||
expect(screen.getAllByText('暂无最近运行证据').length).toBeGreaterThan(0);
|
||
expect(screen.queryByLabelText('工作区管理')).toBeNull();
|
||
expect(screen.queryByLabelText('开发环境')).toBeNull();
|
||
expect(screen.queryByLabelText('运行时配置')).toBeNull();
|
||
expect(screen.queryByText('Agent 能力')).toBeNull();
|
||
expect(screen.queryByText('编排 Trace')).toBeNull();
|
||
});
|
||
}
|
||
|
||
export function registerProjectSupervisorSurfaceTests() {
|
||
it('allows selecting the model on the first direct-project entry', async () => {
|
||
const projectPath = '/tmp/first-entry-model-select';
|
||
const manifest = createGameCreationAppManifest(
|
||
'first-entry-model-select',
|
||
'首次进入模型选择',
|
||
);
|
||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
initialSessionExists: false,
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
return supervisorHarness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: supervisorHarness.listen },
|
||
};
|
||
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
initialProjectManifest: manifest,
|
||
projectSupervisorOnly: true,
|
||
}),
|
||
);
|
||
|
||
const surface = await screen.findByLabelText('陶泥儿项目对话');
|
||
const trigger = within(surface).getByRole('button', { name: '对话模型' });
|
||
await waitFor(() => expect(trigger.hasAttribute('disabled')).toBe(false));
|
||
fireEvent.click(trigger);
|
||
await waitFor(() =>
|
||
expect(
|
||
within(surface).getByRole('option', { name: '快速' }),
|
||
).not.toBeNull(),
|
||
);
|
||
fireEvent.click(within(surface).getByRole('option', { name: '快速' }));
|
||
await waitFor(() => expect(trigger.textContent).toContain('快速'));
|
||
});
|
||
|
||
it('anchors the model dropdown to its own trigger instead of the composer box', () => {
|
||
const styles = readFileSync(
|
||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||
'utf8',
|
||
);
|
||
|
||
// 弹层自己不参与父容器流式布局:菜单与模型提示都是 absolute,打开它不会给控制排
|
||
// 添一个流式子节点("弹层撑高容器 / 把同排元素挤开"这一类问题的前提)。
|
||
const menu = styleRuleBody(styles, '\\.conversation-model-menu');
|
||
expect(menu).toMatch(/position:\s*absolute/u);
|
||
expect(menu).toMatch(/bottom:\s*calc\(100%\s*\+\s*\d+px\)/u);
|
||
expect(menu).toMatch(/right:\s*0/u);
|
||
const actionNotice = styleRuleBody(
|
||
styles,
|
||
"\\.conversation-model-select \\[role='status'\\]",
|
||
);
|
||
expect(actionNotice).toMatch(/position:\s*absolute/u);
|
||
|
||
// 锚点:direct-codex 控制排里的模型选择钮容器必须是**有定位**的,否则上面两个
|
||
// absolute 弹层的包含块会回落到 `position: absolute` 的 composer 盒子——菜单就会
|
||
// 跑到整个 composer 上方,和触发钮之间隔着输入区,输入区一变高菜单跟着往上飘。
|
||
const anchor = styleRuleBody(
|
||
styles,
|
||
'\\.project-supervisor-composer-controls\\s+\\n?\\s*\\.conversation-model-select',
|
||
);
|
||
expect(anchor).toMatch(/position:\s*relative\s*!important/u);
|
||
expect(anchor).not.toMatch(/position:\s*static/u);
|
||
// 补锚点不能把它移出控制排:仍在原位(right/bottom auto)、仍可被压缩换行。
|
||
expect(anchor).toMatch(/right:\s*auto/u);
|
||
expect(anchor).toMatch(/bottom:\s*auto/u);
|
||
expect(anchor).toMatch(/flex:\s*0 1 auto/u);
|
||
expect(anchor).toMatch(/min-width:\s*0/u);
|
||
|
||
// 输入区高度变化不得推动同排操作元素:composer 是底边锚定的两行网格,输入区占第一
|
||
// 行、控制排占第二行;输入区从 96px 长到 140px 只把 composer 顶边抬高,控制排仍钉在
|
||
// composer 底边(`bottom: 12px`),发送钮与模型选择钮不动。
|
||
const composer = styleRuleBody(
|
||
styles,
|
||
'\\.game-workbench-chat\\s+\\.project-supervisor-surface\\.is-direct-codex\\s+\\.project-supervisor-composer\\.is-direct-codex',
|
||
);
|
||
expect(composer).toMatch(/position:\s*absolute/u);
|
||
expect(styleNumber(composer, 'bottom')).toBeGreaterThan(0);
|
||
expect(composer).toMatch(/grid-template-rows:\s*auto auto/u);
|
||
// 控制排是单行 flex、不换行:窄屏下靠可压缩的模型选择钮(flex 0 1 auto + min-width 0)
|
||
// 收窄,而不是把模型选择钮/发送钮挤到第二行。
|
||
const controls = styleRuleBody(
|
||
styles,
|
||
'\\.project-supervisor-composer-controls',
|
||
);
|
||
expect(controls).toMatch(/display:\s*flex/u);
|
||
expect(controls).not.toMatch(/flex-wrap/u);
|
||
// `@` 引用钮与发送钮共用一条尺寸规则(同一规则体里两个选择器),都必须不可压缩,
|
||
// 这样窄屏下被压的是可收缩的模型选择钮,而不是把这两个方钮挤出这一行。
|
||
const controlsButtons = styleRuleBody(
|
||
styles,
|
||
'\\.project-supervisor-composer-controls\\s+\\n?\\s*\\.project-supervisor-submit-button',
|
||
);
|
||
expect(controlsButtons).toMatch(/flex:\s*0 0 30px/u);
|
||
});
|
||
|
||
it('bounds the model dropdown height so a long catalog cannot cover the composer', () => {
|
||
const styles = readFileSync(
|
||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||
'utf8',
|
||
);
|
||
const menu = styleRuleBody(styles, '\\.conversation-model-menu');
|
||
// 条目多时菜单不能无限长:240px 与视口 40vh 取小者,超出部分在菜单内滚动
|
||
// (窄屏 / 移动端优先下 40vh 更稳)。高度有界不改锚点与流式归属,见上一条用例。
|
||
expect(menu).toMatch(/max-height:\s*min\(\s*240px\s*,\s*40vh\s*\)/u);
|
||
expect(menu).toMatch(/overflow:\s*auto/u);
|
||
// 菜单内滚动不传染给背后的消息列表,与 `.resource-reference-menu` 同一口径。
|
||
expect(menu).toMatch(/overscroll-behavior:\s*contain/u);
|
||
// 依旧不参与父容器流式布局。
|
||
expect(menu).toMatch(/position:\s*absolute/u);
|
||
});
|
||
|
||
it('runs a top workbench play request without a second confirmation', async () => {
|
||
const projectPath = '/tmp/top-play-request';
|
||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: '顶部播放请求',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'start_local_game_preview') {
|
||
return {
|
||
url: 'http://127.0.0.1:43127/',
|
||
port: 43127,
|
||
root: projectPath,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return createGameCreationAppManifest(
|
||
'top-play-request',
|
||
'顶部播放请求',
|
||
);
|
||
}
|
||
return supervisorHarness.invoke(command, args);
|
||
},
|
||
);
|
||
const handled = vi.fn();
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: supervisorHarness.listen },
|
||
};
|
||
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
playRequest: { projectPath, requestId: 7 },
|
||
onPlayRequestHandled: handled,
|
||
}),
|
||
);
|
||
|
||
expect(
|
||
await screen.findByText(
|
||
/运行通过,已载入客户端运行视图/,
|
||
{},
|
||
{ timeout: 3_000 },
|
||
),
|
||
).not.toBeNull();
|
||
expect(handled).toHaveBeenCalledWith(7);
|
||
expect(invoke).toHaveBeenCalledWith('start_local_game_preview', {
|
||
projectPath,
|
||
});
|
||
expect(screen.queryByText('game.run_local')).toBeNull();
|
||
});
|
||
|
||
it('starts a direct product preview without the legacy Canvas-only static smoke', async () => {
|
||
const projectPath = '/tmp/direct-dom-preview';
|
||
const manifest = createGameCreationAppManifest(
|
||
'direct-dom-preview',
|
||
'DOM 游戏预览',
|
||
);
|
||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
if (command === 'start_local_game_preview') {
|
||
return {
|
||
url: 'http://127.0.0.1:43126/',
|
||
port: 43126,
|
||
root: projectPath,
|
||
};
|
||
}
|
||
return supervisorHarness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: supervisorHarness.listen },
|
||
};
|
||
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
initialProjectManifest: manifest,
|
||
projectSupervisorOnly: true,
|
||
playRequest: { projectPath, requestId: 8 },
|
||
}),
|
||
);
|
||
|
||
expect(
|
||
await screen.findByText(
|
||
'运行通过,已载入客户端运行视图:http://127.0.0.1:43126/',
|
||
),
|
||
).not.toBeNull();
|
||
expect(invoke).toHaveBeenCalledWith('start_local_game_preview', {
|
||
projectPath,
|
||
});
|
||
expect(invoke).not.toHaveBeenCalledWith('run_limited_local_command', {
|
||
projectPath,
|
||
commandId: 'game.static_smoke',
|
||
});
|
||
});
|
||
|
||
it('keeps the direct Codex welcome surface free of legacy Supervisor status before the first message', async () => {
|
||
const projectPath = '/tmp/launcher-empty-supervisor-game';
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'launcher-empty-supervisor-game',
|
||
);
|
||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
initialSessionExists: false,
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'launcher-empty-supervisor-game',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
return supervisorHarness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: supervisorHarness.listen },
|
||
};
|
||
renderLauncherProjectsAt('/?launcher');
|
||
|
||
pickProjectFromLauncher(projectPath);
|
||
|
||
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
|
||
const messageList = within(supervisorSurface).getByLabelText('陶泥儿消息');
|
||
expect(
|
||
await within(messageList).findByText('想做什么游戏?'),
|
||
).not.toBeNull();
|
||
expect(
|
||
within(supervisorSurface).queryByLabelText('项目总控 Agent 状态'),
|
||
).toBeNull();
|
||
expect(
|
||
within(supervisorSurface).getByText(
|
||
'告诉陶泥儿接下来要做什么,或输入 @ 选择资源',
|
||
),
|
||
).not.toBeNull();
|
||
expect(
|
||
within(supervisorSurface).getByRole('button', { name: '发送' }),
|
||
).toHaveProperty('disabled', false);
|
||
expect(screen.queryByLabelText('项目总控对话')).toBeNull();
|
||
});
|
||
|
||
it('keeps a persisted legacy Supervisor failure out of the direct product chat', async () => {
|
||
const projectPath = '/tmp/launcher-legacy-failure-supervisor-game';
|
||
const legacyFailure =
|
||
'后台任务失败:kind=codex-app-server-context-window-exceeded ' +
|
||
`fingerprint=${'a'.repeat(64)} chars=2048`;
|
||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
supervisorMessages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'assistant',
|
||
content: legacyFailure,
|
||
agentId: 'project-supervisor',
|
||
messageId: 'legacy-failure-message',
|
||
updatedAt: 2000,
|
||
},
|
||
],
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'launcher-legacy-failure-supervisor-game',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'launcher-legacy-failure-supervisor-game',
|
||
);
|
||
}
|
||
return supervisorHarness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: supervisorHarness.listen },
|
||
};
|
||
renderLauncherProjectsAt('/?launcher');
|
||
|
||
pickProjectFromLauncher(projectPath);
|
||
|
||
const messageList = await screen.findByLabelText('陶泥儿消息');
|
||
expect(
|
||
within(messageList).queryByText(
|
||
'项目总控 Agent 模型上下文已超限,请缩小任务范围后重试',
|
||
),
|
||
).toBeNull();
|
||
expect(messageList.textContent).not.toContain('fingerprint');
|
||
expect(messageList.textContent).not.toContain('chars=');
|
||
});
|
||
|
||
it('keeps a persisted needs-reconciliation Supervisor runtime out of direct product chat', async () => {
|
||
const projectPath = '/tmp/launcher-reconciliation-supervisor-game';
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'launcher-reconciliation-supervisor-game',
|
||
);
|
||
const reconciliationRuntime = {
|
||
schemaVersion: 'game-creator-agent-runtime.v1',
|
||
agentId: 'project-supervisor',
|
||
taskId: 'project-supervisor',
|
||
sessionId: 'persisted-supervisor-session',
|
||
runId: 'persisted-reconciliation-run',
|
||
source: 'project-supervisor',
|
||
status: 'needs-reconciliation',
|
||
phase: 'needs-reconciliation',
|
||
currentTask: '帮我生成一个贪吃蛇',
|
||
currentGoal: '完成贪吃蛇原型',
|
||
currentAction: '等待核对 Provider 回复交接',
|
||
waitingOn: '人工核对',
|
||
nextStep: '核对后继续或取消',
|
||
plan: [],
|
||
observations: [],
|
||
allowedTools: [],
|
||
pendingToolAction: null,
|
||
lastResponse: null,
|
||
error: 'tool-plan-unknown',
|
||
updatedAt: 7000,
|
||
};
|
||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
initialSessionExists: false,
|
||
initialRuntime: reconciliationRuntime,
|
||
runtimeMapLoader: async () => [reconciliationRuntime],
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'launcher-reconciliation-supervisor-game',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
return supervisorHarness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: supervisorHarness.listen },
|
||
};
|
||
renderLauncherProjectsAt('/?launcher');
|
||
|
||
pickProjectFromLauncher(projectPath);
|
||
|
||
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
|
||
expect(
|
||
within(supervisorSurface).queryByLabelText('项目总控 Agent 状态'),
|
||
).toBeNull();
|
||
expect(
|
||
within(supervisorSurface).queryByText('项目总控 Agent · 待核对'),
|
||
).toBeNull();
|
||
expect(
|
||
within(supervisorSurface).queryByText('tool-plan-unknown'),
|
||
).toBeNull();
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'read_game_creator_agent_runtimes',
|
||
expect.anything(),
|
||
);
|
||
expect(
|
||
within(supervisorSurface).queryByRole('button', {
|
||
name: '已核对,结束旧任务',
|
||
}),
|
||
).toBeNull();
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'resume_game_creator_agent_runtime_tasks',
|
||
expect.anything(),
|
||
);
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'cancel_game_creator_agent_runtime_task',
|
||
expect.anything(),
|
||
);
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'confirm_retry_game_creator_agent_runtime_task',
|
||
expect.anything(),
|
||
);
|
||
expect(
|
||
within(supervisorSurface).queryByRole('button', {
|
||
name: '在当前项目重试总控',
|
||
}),
|
||
).toBeNull();
|
||
});
|
||
|
||
it('does not hydrate a reconciled legacy Supervisor while allowing a new direct Codex turn', async () => {
|
||
const projectPath = '/tmp/launcher-reconciliation-empty-queue';
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'launcher-reconciliation-empty-queue',
|
||
);
|
||
const runId = 'reconciliation-empty-queue-run';
|
||
const reconciliationRuntime = {
|
||
schemaVersion: 'game-creator-agent-runtime.v1',
|
||
agentId: 'project-supervisor',
|
||
taskId: 'project-supervisor',
|
||
sessionId: 'reconciliation-empty-queue-session',
|
||
runId,
|
||
source: 'project-supervisor',
|
||
status: 'needs-reconciliation',
|
||
phase: 'needs-reconciliation',
|
||
currentTask: '生成贪吃蛇原型',
|
||
currentGoal: '完成可玩原型',
|
||
currentAction: '等待核对 Provider 回复交接',
|
||
waitingOn: '人工核对',
|
||
nextStep: '核对后结束旧任务',
|
||
plan: [],
|
||
observations: [],
|
||
allowedTools: [],
|
||
pendingToolAction: null,
|
||
lastResponse: null,
|
||
error: 'tool-plan-unknown',
|
||
updatedAt: 7000,
|
||
};
|
||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
sessionId: 'reconciliation-empty-queue-session',
|
||
initialSessionExists: false,
|
||
initialRuntime: reconciliationRuntime,
|
||
runtimeMapLoader: async () => [reconciliationRuntime],
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'launcher-reconciliation-empty-queue',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
if (command === 'chat_with_game_creator_direct_codex') {
|
||
return 'DIRECT_AFTER_RECONCILIATION_OK';
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return { status: 'stopped', url: null, port: null, root: null };
|
||
}
|
||
if (command === 'start_local_game_preview') {
|
||
return {
|
||
url: 'http://127.0.0.1:43123/game/index.html',
|
||
port: 43123,
|
||
root: projectPath,
|
||
};
|
||
}
|
||
return supervisorHarness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: supervisorHarness.listen },
|
||
};
|
||
renderLauncherProjectsAt('/?launcher');
|
||
|
||
pickProjectFromLauncher(projectPath);
|
||
|
||
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
|
||
expect(
|
||
within(supervisorSurface).queryByLabelText('项目总控 Agent 状态'),
|
||
).toBeNull();
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'read_game_creator_agent_runtimes',
|
||
expect.anything(),
|
||
);
|
||
const input = within(supervisorSurface).getByLabelText('陶泥儿对话内容');
|
||
await setComposerText(input, '从旧任务状态继续生成,但使用 direct Codex');
|
||
fireEvent.submit(input.closest('form') as HTMLFormElement);
|
||
|
||
expect(
|
||
await within(supervisorSurface).findByText(
|
||
'DIRECT_AFTER_RECONCILIATION_OK',
|
||
),
|
||
).not.toBeNull();
|
||
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', {
|
||
projectPath,
|
||
prompt: '从旧任务状态继续生成,但使用 direct Codex',
|
||
clientTurnId: expect.any(String),
|
||
});
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'cancel_game_creator_agent_runtime_task',
|
||
expect.anything(),
|
||
);
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'confirm_retry_game_creator_agent_runtime_task',
|
||
expect.anything(),
|
||
);
|
||
});
|
||
|
||
it('loads and continues the active Project Supervisor Session in the standalone chat surface', async () => {
|
||
const projectPath = '/tmp/supervisor-chat-only-game';
|
||
const historyMessage = '已持久化的项目总控历史';
|
||
const harness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
expectedRunProfile: 'standard',
|
||
supervisorMessages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'assistant',
|
||
content: historyMessage,
|
||
agentId: 'project-supervisor',
|
||
messageId: 'supervisor-chat-only-history',
|
||
updatedAt: 2000,
|
||
},
|
||
],
|
||
});
|
||
window.__TAURI__ = {
|
||
core: { invoke: harness.invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
supervisorChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
|
||
const messageList = within(surface).getByLabelText('项目总控消息');
|
||
expect(await within(messageList).findByText(historyMessage)).not.toBeNull();
|
||
expect(harness.invoke).toHaveBeenCalledWith(
|
||
'list_game_creator_agent_sessions',
|
||
{
|
||
projectPath,
|
||
agentId: 'project-supervisor',
|
||
},
|
||
);
|
||
expect(harness.invoke).toHaveBeenCalledWith('read_local_conversation', {
|
||
projectPath,
|
||
agentId: 'project-supervisor',
|
||
sessionId: harness.sessionId,
|
||
});
|
||
expect(
|
||
within(surface).getByRole('button', { name: '设置' }),
|
||
).not.toBeNull();
|
||
expect(screen.queryByLabelText('选择 Agent')).toBeNull();
|
||
expect(screen.queryByLabelText('项目总控 Agent 状态')).toBeNull();
|
||
expect(screen.queryByLabelText('专业 Agent 协作状态')).toBeNull();
|
||
|
||
await setComposerText(
|
||
within(surface).getByLabelText('项目总控对话内容'),
|
||
'继续完成可玩原型',
|
||
);
|
||
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
|
||
|
||
await waitFor(() => {
|
||
expect(harness.invoke).toHaveBeenCalledWith(
|
||
'start_game_creator_supervisor_runtime_task',
|
||
{
|
||
projectPath,
|
||
sessionId: harness.sessionId,
|
||
task: '继续完成可玩原型',
|
||
runId: expect.stringMatching(/^project-supervisor-task-/),
|
||
runProfile: 'standard',
|
||
source: 'project-supervisor-gui',
|
||
},
|
||
);
|
||
});
|
||
expect(harness.invoke).not.toHaveBeenCalledWith(
|
||
'create_game_creator_agent_session',
|
||
expect.anything(),
|
||
);
|
||
expect(harness.invoke).not.toHaveBeenCalledWith(
|
||
'chat_with_game_creator_agent',
|
||
expect.anything(),
|
||
);
|
||
});
|
||
|
||
it('keeps an unsent standalone Project Supervisor draft across window navigation reloads', async () => {
|
||
const projectPath = '/tmp/supervisor-chat-only-draft';
|
||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||
window.__TAURI__ = {
|
||
core: { invoke: harness.invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
const renderSupervisorChat = () =>
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
supervisorChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
renderSupervisorChat();
|
||
const firstSurface = await screen.findByLabelText('项目总控 Agent 纯聊天');
|
||
await within(firstSurface).findByLabelText('项目总控消息');
|
||
await setComposerText(
|
||
within(firstSurface).getByLabelText('项目总控对话内容'),
|
||
'这条草稿还没有发送',
|
||
);
|
||
|
||
cleanup();
|
||
renderSupervisorChat();
|
||
|
||
const restoredInput = await screen.findByLabelText('项目总控对话内容');
|
||
expect(await composerValue(restoredInput)).toBe('这条草稿还没有发送');
|
||
});
|
||
|
||
it('shows and handles runtime recovery confirmation in the standalone Project Supervisor chat', async () => {
|
||
const projectPath = '/tmp/supervisor-chat-only-resume';
|
||
const harness = createProjectSupervisorRuntimeHarness({ projectPath });
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
throw new Error('项目权限策略要求用户确认:agent.resume');
|
||
}
|
||
if (command === 'confirm_resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
return harness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
supervisorChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
|
||
const detail = await within(surface).findByText(
|
||
`恢复 ${projectPath} 中未完成的 Agent Runtime 任务`,
|
||
);
|
||
expect(within(surface).queryByText(/项目总控 Agent 恢复失败/)).toBeNull();
|
||
const confirmation = detail.closest('.pending-command');
|
||
expect(confirmation).not.toBeNull();
|
||
|
||
fireEvent.click(
|
||
within(confirmation as HTMLElement).getByRole('button', {
|
||
name: '确认',
|
||
}),
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'confirm_resume_game_creator_agent_runtime_tasks',
|
||
{ projectPath },
|
||
);
|
||
expect(
|
||
within(surface).queryByText(
|
||
`恢复 ${projectPath} 中未完成的 Agent Runtime 任务`,
|
||
),
|
||
).toBeNull();
|
||
});
|
||
});
|
||
|
||
it('confirms and rejects pending actions in the standalone Project Supervisor chat', async () => {
|
||
const projectPath = '/tmp/supervisor-chat-only-confirmation';
|
||
const runId = 'supervisor-chat-only-confirmation-run';
|
||
const harness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
initialRuntime: {
|
||
runId,
|
||
status: 'waiting-for-confirmation',
|
||
phase: 'waiting-for-confirmation',
|
||
pendingToolAction: {
|
||
actionId: 'standalone-action-confirm',
|
||
actionFingerprint: 'standalone-fingerprint-confirm',
|
||
tool: 'file.write',
|
||
inputSummary: 'game/index.html',
|
||
reason: null,
|
||
requestedAt: 3000,
|
||
},
|
||
},
|
||
});
|
||
harness.setConfirmRuntime(
|
||
harness.runtimeState({
|
||
runId,
|
||
status: 'waiting-for-confirmation',
|
||
phase: 'waiting-for-confirmation',
|
||
pendingToolAction: {
|
||
actionId: 'standalone-action-reject',
|
||
actionFingerprint: 'standalone-fingerprint-reject',
|
||
tool: 'command.exec',
|
||
inputSummary: 'npm test',
|
||
reason: null,
|
||
requestedAt: 4000,
|
||
},
|
||
}),
|
||
);
|
||
harness.setRejectRuntime(
|
||
harness.runtimeState({
|
||
runId,
|
||
status: 'running',
|
||
phase: 'planning',
|
||
pendingToolAction: null,
|
||
}),
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke: harness.invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
supervisorChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
|
||
let pendingAction =
|
||
await within(surface).findByLabelText('项目总控 Agent 待确认动作');
|
||
expect(within(pendingAction).getByText('file.write')).not.toBeNull();
|
||
expect(within(pendingAction).getByText('game/index.html')).not.toBeNull();
|
||
fireEvent.click(
|
||
within(pendingAction).getByRole('button', { name: '确认' }),
|
||
);
|
||
await waitFor(() => {
|
||
expect(harness.invoke).toHaveBeenCalledWith(
|
||
'confirm_game_creator_agent_runtime_task',
|
||
{
|
||
projectPath,
|
||
agentId: 'project-supervisor',
|
||
runId,
|
||
actionId: 'standalone-action-confirm',
|
||
note: '用户已确认待执行工具动作',
|
||
},
|
||
);
|
||
});
|
||
|
||
pendingAction =
|
||
await within(surface).findByLabelText('项目总控 Agent 待确认动作');
|
||
expect(within(pendingAction).getByText('command.exec')).not.toBeNull();
|
||
expect(within(pendingAction).getByText('npm test')).not.toBeNull();
|
||
fireEvent.click(
|
||
within(pendingAction).getByRole('button', { name: '拒绝' }),
|
||
);
|
||
await waitFor(() => {
|
||
expect(harness.invoke).toHaveBeenCalledWith(
|
||
'reject_game_creator_agent_runtime_task',
|
||
{
|
||
projectPath,
|
||
agentId: 'project-supervisor',
|
||
runId,
|
||
actionId: 'standalone-action-reject',
|
||
note: '用户已拒绝待执行工具动作',
|
||
},
|
||
);
|
||
});
|
||
});
|
||
|
||
it('answers structured questions in the standalone Project Supervisor chat', async () => {
|
||
const projectPath = '/tmp/supervisor-chat-only-user-input';
|
||
const sessionId = 'supervisor-chat-only-user-input-session';
|
||
const runId = 'supervisor-chat-only-user-input-run';
|
||
const request = agentRuntimeUserInputRequest({
|
||
agentId: 'project-supervisor',
|
||
sessionId,
|
||
runId,
|
||
});
|
||
const harness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
sessionId,
|
||
initialRuntime: {
|
||
runId,
|
||
status: 'waiting-for-user-input',
|
||
phase: 'waiting-for-user-input',
|
||
currentTask: '准备首版角色规范图',
|
||
currentAction: '等待用户补充关键信息',
|
||
waitingOn: '你的澄清回答',
|
||
nextStep: '提交全部回答后继续同一 Run',
|
||
userInputRequest: request,
|
||
updatedAt: 6000,
|
||
},
|
||
});
|
||
window.__TAURI__ = {
|
||
core: { invoke: harness.invoke },
|
||
event: { listen: harness.listen },
|
||
};
|
||
render(
|
||
React.createElement(App, {
|
||
initialProjectPath: projectPath,
|
||
projectSupervisorOnly: true,
|
||
supervisorChatOnly: true,
|
||
}),
|
||
);
|
||
|
||
const surface = await screen.findByLabelText('项目总控 Agent 纯聊天');
|
||
const card = await within(surface).findByLabelText('Needs input');
|
||
expect(within(card).getByText('1. 美术方向')).not.toBeNull();
|
||
expect(
|
||
within(card).getByText('首版角色规范图采用哪种美术方向?'),
|
||
).not.toBeNull();
|
||
expect(within(card).getByText('优先验证轮廓与动作可读性。')).not.toBeNull();
|
||
expect(await composerDisabled('项目总控对话内容', within(surface))).toBe(
|
||
true,
|
||
);
|
||
|
||
fireEvent.click(within(card).getByRole('button', { name: /像素风/ }));
|
||
fireEvent.click(within(card).getByRole('button', { name: '提交回答' }));
|
||
await waitFor(() => {
|
||
expect(harness.invoke).toHaveBeenCalledWith(
|
||
'answer_game_creator_agent_runtime_user_input',
|
||
{
|
||
projectPath,
|
||
agentId: 'project-supervisor',
|
||
runId,
|
||
actionId: request.actionId,
|
||
requestId: request.requestId,
|
||
responseId: expect.stringMatching(/^app-user-input-/),
|
||
answers: { visual_direction: '像素风' },
|
||
},
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(within(surface).queryByLabelText('Needs input')).toBeNull();
|
||
});
|
||
});
|
||
|
||
it('opens an existing project without hydrating legacy Supervisor history, then sends consecutive direct Codex turns', async () => {
|
||
const projectPath = '/tmp/launcher-supervisor-game';
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'launcher-supervisor-game',
|
||
);
|
||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
});
|
||
let directTurnUpdateHandler:
|
||
| ((event: { payload: Record<string, unknown> }) => void)
|
||
| null = null;
|
||
const listen = vi.fn(
|
||
async (
|
||
eventName: string,
|
||
handler: (event: { payload: Record<string, unknown> }) => void,
|
||
) => {
|
||
if (eventName === 'game-creator-direct-turn-update') {
|
||
directTurnUpdateHandler = handler;
|
||
return () => {
|
||
if (directTurnUpdateHandler === handler) {
|
||
directTurnUpdateHandler = null;
|
||
}
|
||
};
|
||
}
|
||
return supervisorHarness.listen(
|
||
eventName,
|
||
handler as Parameters<typeof supervisorHarness.listen>[1],
|
||
);
|
||
},
|
||
);
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'launcher-supervisor-game',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
if (command === 'chat_with_game_creator_direct_codex') {
|
||
return `DIRECT_REPLY:${String(args?.prompt ?? '')}`;
|
||
}
|
||
return supervisorHarness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen },
|
||
};
|
||
renderLauncherProjectsAt('/?launcher');
|
||
|
||
pickProjectFromLauncher(projectPath);
|
||
|
||
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
|
||
expect(
|
||
within(supervisorSurface).queryByText('已恢复的项目总控历史'),
|
||
).toBeNull();
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'list_game_creator_agent_sessions',
|
||
expect.anything(),
|
||
);
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command, args]) =>
|
||
command === 'read_local_conversation' &&
|
||
(args as Record<string, unknown> | undefined)?.agentId ===
|
||
'project-supervisor',
|
||
),
|
||
).toBe(false);
|
||
expect(screen.queryByLabelText('专业 Agent 协作状态')).toBeNull();
|
||
expect(screen.queryByRole('status', { name: '自动执行' })).toBeNull();
|
||
expect(screen.queryByLabelText('子 Agent 状态栏')).toBeNull();
|
||
expect(screen.queryByText('严格审批')).toBeNull();
|
||
expect(screen.queryByRole('button', { name: /审批配置/ })).toBeNull();
|
||
expect(screen.queryByLabelText('选择 Agent')).toBeNull();
|
||
expect(screen.queryByRole('dialog', { name: 'Agent 对话' })).toBeNull();
|
||
expect(supervisorHarness.listen).not.toHaveBeenCalledWith(
|
||
'game-creator-agent-runtime-update',
|
||
expect.any(Function),
|
||
);
|
||
const policyReadCountBeforeChat = invoke.mock.calls.filter(
|
||
([command]) => command === 'read_project_permission_policy',
|
||
).length;
|
||
|
||
const firstDirectReply = createDeferred<string>();
|
||
const secondDirectReply = createDeferred<string>();
|
||
let directReplyCount = 0;
|
||
invoke.mockImplementation(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'chat_with_game_creator_direct_codex') {
|
||
directReplyCount += 1;
|
||
return directReplyCount === 1
|
||
? firstDirectReply.promise
|
||
: secondDirectReply.promise;
|
||
}
|
||
return supervisorHarness.invoke(command, args);
|
||
},
|
||
);
|
||
|
||
await setComposerText(
|
||
screen.getByLabelText('陶泥儿对话内容'),
|
||
'先完成正式客户端玩法拆解',
|
||
);
|
||
fireEvent.click(
|
||
within(supervisorSurface).getByRole('button', { name: '发送' }),
|
||
);
|
||
await waitFor(() =>
|
||
expect(
|
||
within(
|
||
within(supervisorSurface).getByLabelText('陶泥儿执行过程'),
|
||
).getByText('需求已接收'),
|
||
).not.toBeNull(),
|
||
);
|
||
const directMessageList =
|
||
within(supervisorSurface).getByLabelText('陶泥儿消息');
|
||
Object.defineProperties(directMessageList, {
|
||
clientHeight: { configurable: true, value: 180 },
|
||
scrollHeight: { configurable: true, value: 640 },
|
||
scrollTop: { configurable: true, value: 0, writable: true },
|
||
});
|
||
const waitingProcessCard =
|
||
within(directMessageList).getByLabelText('陶泥儿执行过程');
|
||
expect(waitingProcessCard.parentElement).toBe(directMessageList);
|
||
expect(
|
||
within(waitingProcessCard).getByText('正在等待陶泥儿开始'),
|
||
).not.toBeNull();
|
||
expect(
|
||
within(waitingProcessCard).getByLabelText('陶泥儿正在执行的内容')
|
||
.textContent,
|
||
).toBe('正在等待陶泥儿开始');
|
||
const firstDirectCall = invoke.mock.calls.find(
|
||
([command]) => command === 'chat_with_game_creator_direct_codex',
|
||
);
|
||
const firstTurnId = String(
|
||
(firstDirectCall?.[1] as Record<string, unknown> | undefined)
|
||
?.clientTurnId ?? '',
|
||
);
|
||
expect(firstTurnId).not.toBe('');
|
||
expect(firstDirectCall?.[1]).toEqual({
|
||
projectPath,
|
||
prompt: '先完成正式客户端玩法拆解',
|
||
clientTurnId: firstTurnId,
|
||
});
|
||
|
||
await act(async () => {
|
||
supervisorHarness.emitProgress('direct', '正在准备安全阶段');
|
||
directTurnUpdateHandler?.({
|
||
payload: {
|
||
projectPath,
|
||
turnId: firstTurnId,
|
||
sequence: 0,
|
||
status: 'accepted',
|
||
activity: 'request-accepted',
|
||
updatedAt: 1000,
|
||
},
|
||
});
|
||
directTurnUpdateHandler?.({
|
||
payload: {
|
||
projectPath,
|
||
turnId: firstTurnId,
|
||
sequence: 1,
|
||
status: 'running',
|
||
activity: 'preparing',
|
||
updatedAt: 1200,
|
||
},
|
||
});
|
||
});
|
||
const thinkingProcessCard =
|
||
within(directMessageList).getByLabelText('陶泥儿执行过程');
|
||
expect(within(thinkingProcessCard).getByText('任务执行中')).not.toBeNull();
|
||
expect(
|
||
within(thinkingProcessCard).getByLabelText('陶泥儿正在执行的内容')
|
||
.textContent,
|
||
).toBe('正在思考中');
|
||
await act(async () => {
|
||
directTurnUpdateHandler?.({
|
||
payload: {
|
||
projectPath,
|
||
turnId: firstTurnId,
|
||
sequence: 2,
|
||
status: 'running',
|
||
activity: 'file-read',
|
||
accumulatedText: `正在读取 game/index.html,${'这是一段非常长的执行内容。'.repeat(12)}用于验证执行详情展开状态不会因为后续事件更新而被重置。`,
|
||
updatedAt: 1500,
|
||
},
|
||
});
|
||
});
|
||
const runningProcessCard =
|
||
within(directMessageList).getByLabelText('陶泥儿执行过程');
|
||
expect(within(runningProcessCard).getByText('任务执行中')).not.toBeNull();
|
||
expect(
|
||
within(runningProcessCard).getByRole('button', { name: '展开' }),
|
||
).not.toBeNull();
|
||
fireEvent.click(
|
||
within(runningProcessCard).getByRole('button', { name: '展开' }),
|
||
);
|
||
await act(async () => {
|
||
directTurnUpdateHandler?.({
|
||
payload: {
|
||
projectPath,
|
||
turnId: firstTurnId,
|
||
sequence: 3,
|
||
status: 'streaming',
|
||
activity: 'controlled-tool',
|
||
accumulatedText: 'DIRECT_STREAM:先完成正式客户端玩法拆解',
|
||
updatedAt: 2000,
|
||
},
|
||
});
|
||
directTurnUpdateHandler?.({
|
||
payload: {
|
||
projectPath,
|
||
turnId: firstTurnId,
|
||
sequence: 1,
|
||
status: 'streaming',
|
||
activity: 'file-write',
|
||
accumulatedText: '乱序事件不能回退正文',
|
||
updatedAt: 1500,
|
||
},
|
||
});
|
||
directTurnUpdateHandler?.({
|
||
payload: {
|
||
projectPath,
|
||
turnId: 'old-direct-turn',
|
||
sequence: 99,
|
||
status: 'streaming',
|
||
activity: 'validation',
|
||
accumulatedText: '旧回合不能覆盖正文',
|
||
updatedAt: 3000,
|
||
},
|
||
});
|
||
directTurnUpdateHandler?.({
|
||
payload: {
|
||
projectPath: '/tmp/wrong-direct-project',
|
||
turnId: firstTurnId,
|
||
sequence: 100,
|
||
status: 'streaming',
|
||
activity: 'response-finalization',
|
||
accumulatedText: '错误项目不能覆盖正文',
|
||
updatedAt: 4000,
|
||
},
|
||
});
|
||
supervisorHarness.emitProgress('direct', '旧 fallback 不能覆盖精确事件');
|
||
});
|
||
const streamingProcessCard =
|
||
within(directMessageList).getByLabelText('陶泥儿执行过程');
|
||
expect(within(streamingProcessCard).getByText('回复生成中')).not.toBeNull();
|
||
expect(
|
||
within(streamingProcessCard).getByLabelText('陶泥儿正在执行的内容')
|
||
.textContent,
|
||
).toBe('正在生成回复');
|
||
expect(
|
||
within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent,
|
||
).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解');
|
||
expect(directMessageList.scrollTop).toBe(640);
|
||
expect(within(supervisorSurface).queryByText(/不能覆盖正文/u)).toBeNull();
|
||
expect(
|
||
within(supervisorSurface).queryByText('旧 fallback 不能覆盖精确事件'),
|
||
).toBeNull();
|
||
directMessageList.scrollTop = 100;
|
||
fireEvent.scroll(directMessageList);
|
||
await act(async () => {
|
||
directTurnUpdateHandler?.({
|
||
payload: {
|
||
projectPath,
|
||
turnId: firstTurnId,
|
||
sequence: 4,
|
||
status: 'running',
|
||
accumulatedText: `正在执行 npm test,${'工具输出很长时需要保持展开状态。'.repeat(10)}`,
|
||
updatedAt: 4500,
|
||
},
|
||
});
|
||
});
|
||
expect(directMessageList.scrollTop).toBe(100);
|
||
expect(within(streamingProcessCard).getByText('任务执行中')).not.toBeNull();
|
||
expect(
|
||
within(streamingProcessCard).getByText(/正在执行 npm test/u),
|
||
).not.toBeNull();
|
||
await act(async () => {
|
||
directTurnUpdateHandler?.({
|
||
payload: {
|
||
projectPath,
|
||
turnId: firstTurnId,
|
||
sequence: 5,
|
||
status: 'running',
|
||
activity: 'command-exec',
|
||
accumulatedText: `正在执行命令:npm run smoke,${'heartbeat 不得覆盖具体命令。'.repeat(12)}`,
|
||
updatedAt: 5000,
|
||
},
|
||
});
|
||
directTurnUpdateHandler?.({
|
||
payload: {
|
||
projectPath,
|
||
turnId: firstTurnId,
|
||
sequence: 6,
|
||
status: 'running',
|
||
activity: 'command-exec',
|
||
updatedAt: 5100,
|
||
},
|
||
});
|
||
});
|
||
expect(
|
||
within(streamingProcessCard).getByText(/heartbeat 不得覆盖具体命令/u),
|
||
).not.toBeNull();
|
||
expect(within(streamingProcessCard).queryByText('正在执行命令')).toBeNull();
|
||
await act(async () => {
|
||
directTurnUpdateHandler?.({
|
||
payload: {
|
||
projectPath,
|
||
turnId: firstTurnId,
|
||
sequence: 7,
|
||
status: 'running',
|
||
activity: 'controlled-tool',
|
||
accumulatedText: `正在写入文件:game/index.html,${'写入详情需要保持展开状态。'.repeat(12)}`,
|
||
updatedAt: 5200,
|
||
},
|
||
});
|
||
directTurnUpdateHandler?.({
|
||
payload: {
|
||
projectPath,
|
||
turnId: firstTurnId,
|
||
sequence: 8,
|
||
status: 'running',
|
||
activity: 'controlled-tool',
|
||
updatedAt: 5300,
|
||
},
|
||
});
|
||
});
|
||
expect(
|
||
within(streamingProcessCard).getByText(
|
||
/正在写入文件:game\/index\.html/u,
|
||
),
|
||
).not.toBeNull();
|
||
expect(within(streamingProcessCard).queryByText('正在调用工具')).toBeNull();
|
||
expect(
|
||
within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent,
|
||
).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解');
|
||
expect(
|
||
(
|
||
within(streamingProcessCard).getByRole('button', {
|
||
name: '收起',
|
||
}) as HTMLButtonElement
|
||
).getAttribute('aria-expanded'),
|
||
).toBe('true');
|
||
await act(async () => {
|
||
firstDirectReply.resolve('DIRECT_REPLY:先完成正式客户端玩法拆解');
|
||
});
|
||
await waitFor(() => {
|
||
expect(
|
||
within(supervisorSurface).getAllByText(
|
||
'DIRECT_REPLY:先完成正式客户端玩法拆解',
|
||
),
|
||
).toHaveLength(1);
|
||
expect(
|
||
within(supervisorSurface).queryByLabelText('陶泥儿正在执行的内容'),
|
||
).toBeNull();
|
||
expect(
|
||
within(supervisorSurface).queryByText(
|
||
'DIRECT_STREAM:先完成正式客户端玩法拆解',
|
||
),
|
||
).toBeNull();
|
||
expect(
|
||
within(directMessageList).queryByLabelText('陶泥儿执行过程'),
|
||
).toBeNull();
|
||
});
|
||
await waitFor(() => {
|
||
expect(
|
||
(
|
||
within(supervisorSurface).getByRole('button', {
|
||
name: '发送',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(false);
|
||
});
|
||
|
||
await setComposerText(
|
||
screen.getByLabelText('陶泥儿对话内容'),
|
||
'补充:优先复用现有素材',
|
||
);
|
||
fireEvent.click(
|
||
within(supervisorSurface).getByRole('button', { name: '发送' }),
|
||
);
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'chat_with_game_creator_direct_codex',
|
||
{
|
||
projectPath,
|
||
prompt: '补充:优先复用现有素材',
|
||
clientTurnId: expect.any(String),
|
||
},
|
||
);
|
||
});
|
||
const directCalls = invoke.mock.calls.filter(
|
||
([command]) => command === 'chat_with_game_creator_direct_codex',
|
||
);
|
||
const secondTurnId = String(
|
||
(directCalls[1]?.[1] as Record<string, unknown> | undefined)
|
||
?.clientTurnId ?? '',
|
||
);
|
||
expect(secondTurnId).not.toBe(firstTurnId);
|
||
await act(async () => {
|
||
directTurnUpdateHandler?.({
|
||
payload: {
|
||
projectPath,
|
||
turnId: secondTurnId,
|
||
sequence: 0,
|
||
status: 'streaming',
|
||
activity: 'not-a-public-activity',
|
||
accumulatedText: '即将失败的临时正文',
|
||
updatedAt: 5000,
|
||
},
|
||
});
|
||
});
|
||
const failedTurnProcessCard =
|
||
within(directMessageList).getByLabelText('陶泥儿执行过程');
|
||
expect(
|
||
within(failedTurnProcessCard).getByText('回复生成中'),
|
||
).not.toBeNull();
|
||
expect(
|
||
within(failedTurnProcessCard).getByLabelText('陶泥儿正在执行的内容')
|
||
.textContent,
|
||
).toBe('正在生成回复');
|
||
expect(
|
||
within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent,
|
||
).toBe('即将失败的临时正文');
|
||
await act(async () => {
|
||
directTurnUpdateHandler?.({
|
||
payload: {
|
||
projectPath,
|
||
turnId: secondTurnId,
|
||
sequence: 1,
|
||
status: 'failed',
|
||
activity: 'none',
|
||
accumulatedText: '失败事件不得保留这段正文',
|
||
updatedAt: 5100,
|
||
},
|
||
});
|
||
});
|
||
expect(
|
||
within(supervisorSurface).queryByLabelText('陶泥儿实时回复'),
|
||
).toBeNull();
|
||
const failedStatusProcessCard =
|
||
within(directMessageList).getByLabelText('陶泥儿执行过程');
|
||
expect(
|
||
within(failedStatusProcessCard).getByText('处理失败'),
|
||
).not.toBeNull();
|
||
expect(
|
||
within(failedStatusProcessCard).getByLabelText('陶泥儿正在执行的内容')
|
||
.textContent,
|
||
).toBe('正在记录失败原因');
|
||
await act(async () => {
|
||
secondDirectReply.reject(new Error('模拟 direct 失败'));
|
||
});
|
||
await waitFor(() => {
|
||
expect(
|
||
within(supervisorSurface).queryByLabelText('陶泥儿实时回复'),
|
||
).toBeNull();
|
||
expect(
|
||
within(directMessageList).queryByLabelText('陶泥儿执行过程'),
|
||
).toBeNull();
|
||
expect(
|
||
(
|
||
within(supervisorSurface).getByRole('button', {
|
||
name: '发送',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(false);
|
||
});
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_game_creator_supervisor_runtime_task',
|
||
),
|
||
).toHaveLength(0);
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'steer_game_creator_agent_runtime_task',
|
||
),
|
||
).toHaveLength(0);
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'chat_with_game_creator_direct_codex',
|
||
),
|
||
).toHaveLength(2);
|
||
// 进入项目时按内存 registry 核对一次预览活体(有活体才作为会话预览)。
|
||
// 这里只核验、不启动:开始预览仍然只能由用户动作或 Agent 触发。
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'get_local_game_preview_status',
|
||
),
|
||
).toHaveLength(1);
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'start_local_game_preview',
|
||
),
|
||
).toHaveLength(0);
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'read_project_permission_policy',
|
||
),
|
||
).toHaveLength(policyReadCountBeforeChat + 1);
|
||
});
|
||
|
||
it('does not poll or render legacy professional Agent runtime state in direct product chat', async () => {
|
||
const projectPath = '/tmp/launcher-runtime-status-game';
|
||
let professionalRuntimeReadCount = 0;
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'launcher-runtime-status-game',
|
||
);
|
||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
runtimeMapLoader: async () => {
|
||
professionalRuntimeReadCount += 1;
|
||
return [];
|
||
},
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'launcher-runtime-status-game',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
return supervisorHarness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: supervisorHarness.listen },
|
||
};
|
||
renderLauncherProjectsAt('/?launcher');
|
||
|
||
pickProjectFromLauncher(projectPath);
|
||
|
||
expect(await screen.findByLabelText('陶泥儿项目对话')).not.toBeNull();
|
||
await act(async () => {
|
||
await Promise.resolve();
|
||
});
|
||
expect(professionalRuntimeReadCount).toBe(0);
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'read_game_creator_agent_runtimes',
|
||
expect.anything(),
|
||
);
|
||
expect(screen.queryByLabelText('专业 Agent 协作状态')).toBeNull();
|
||
expect(screen.queryByLabelText('专业 Agent 实时状态')).toBeNull();
|
||
expect(screen.queryByLabelText('项目总控 Agent 状态')).toBeNull();
|
||
expect(screen.queryByLabelText('子 Agent 状态栏')).toBeNull();
|
||
});
|
||
|
||
it('does not hydrate a ready legacy Supervisor stream into direct product chat', async () => {
|
||
const projectPath = '/tmp/launcher-supervisor-finalizing';
|
||
const reply = '已经落盘的唯一项目总控回复';
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'launcher-supervisor-finalizing',
|
||
);
|
||
const runId = 'supervisor-finalizing-run';
|
||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
supervisorMessages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'assistant',
|
||
content: reply,
|
||
agentId: 'project-supervisor',
|
||
messageId: 'launcher-supervisor-final-assistant',
|
||
updatedAt: 7000,
|
||
},
|
||
],
|
||
initialRuntime: {
|
||
runId,
|
||
status: 'running',
|
||
phase: 'finalizing',
|
||
loopIteration: 1,
|
||
appliedSteerCursor: 0,
|
||
updatedAt: 7000,
|
||
},
|
||
initialResponseStream: projectSupervisorResponseStream({
|
||
runId,
|
||
sequence: 8,
|
||
accumulatedText: reply,
|
||
status: 'ready',
|
||
}),
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: true,
|
||
projectName: 'launcher-supervisor-finalizing',
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
return supervisorHarness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: supervisorHarness.listen },
|
||
};
|
||
renderLauncherProjectsAt('/?launcher');
|
||
|
||
pickProjectFromLauncher(projectPath);
|
||
|
||
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
|
||
expect(within(supervisorSurface).queryByText(reply)).toBeNull();
|
||
expect(
|
||
within(supervisorSurface).queryByLabelText('项目总控 Agent 实时回复'),
|
||
).toBeNull();
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'read_game_creator_agent_runtimes',
|
||
expect.anything(),
|
||
);
|
||
expect(supervisorHarness.listen).not.toHaveBeenCalledWith(
|
||
'game-creator-agent-runtime-update',
|
||
expect.any(Function),
|
||
);
|
||
});
|
||
|
||
it('rejects picker project paths with control characters before project calls', async () => {
|
||
const invoke = vi.fn(async (command: string) => {
|
||
if (command === 'pick_local_project_directory') {
|
||
return '/tmp/bad\u0007path';
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
});
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderLauncherProjectsAt('/?launcher');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
|
||
|
||
expect(await screen.findByText('项目目录不能包含控制字符')).not.toBeNull();
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'is_local_project_directory_non_empty',
|
||
expect.anything(),
|
||
);
|
||
});
|
||
|
||
it('opens a selected Godot project as the active root and keeps direct Codex composer keyboard semantics', async () => {
|
||
const projectPath = '/tmp/existing-godot-project';
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'existing-godot-project',
|
||
);
|
||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||
projectPath,
|
||
expectedRunProfile: 'standard',
|
||
});
|
||
const invoke = vi.fn(
|
||
async (command: string, args?: Record<string, unknown>) => {
|
||
if (command === 'pick_local_project_directory') {
|
||
return projectPath;
|
||
}
|
||
if (command === 'inspect_local_project_directory') {
|
||
return {
|
||
projectPath,
|
||
exists: true,
|
||
isDirectory: true,
|
||
isGameCreatorProject: false,
|
||
isGodotProject: true,
|
||
godotProjectRoot: 'game-source',
|
||
projectName: null,
|
||
recentRunStatus: null,
|
||
recentRunStopReason: null,
|
||
};
|
||
}
|
||
if (command === 'import_local_godot_project') {
|
||
return {
|
||
projectPath,
|
||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||
manifest,
|
||
};
|
||
}
|
||
if (command === 'chat_with_game_creator_direct_codex') {
|
||
return 'DIRECT_GODOT_OK';
|
||
}
|
||
if (command === 'get_local_game_preview_status') {
|
||
return { status: 'stopped', url: null, port: null, root: null };
|
||
}
|
||
if (command === 'start_local_game_preview') {
|
||
return {
|
||
url: 'http://127.0.0.1:43125/game/index.html',
|
||
port: 43125,
|
||
root: projectPath,
|
||
};
|
||
}
|
||
return supervisorHarness.invoke(command, args);
|
||
},
|
||
);
|
||
window.__TAURI__ = {
|
||
core: { invoke },
|
||
event: { listen: supervisorHarness.listen },
|
||
};
|
||
renderLauncherProjectsAt('/?launcher');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '打开项目' }));
|
||
|
||
const surface = await screen.findByLabelText('陶泥儿项目对话');
|
||
expect(invoke).toHaveBeenCalledWith('import_local_godot_project', {
|
||
projectPath,
|
||
projectId: expect.stringMatching(/^local-project-/),
|
||
name: 'existing-godot-project',
|
||
});
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command]) => command === 'init_local_game_project',
|
||
),
|
||
).toBe(false);
|
||
|
||
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
||
await setComposerText(composer, '修改玩家移动脚本');
|
||
// 输入区已从原生 textarea 换成 Lexical 富文本:Shift+Enter 由编辑器自己插入换行
|
||
// (按键被编辑器消化,因此 fireEvent 返回 false),语义仍是「换行而不是发送」。
|
||
expect(fireEvent.keyDown(composer, { key: 'Enter', shiftKey: true })).toBe(
|
||
false,
|
||
);
|
||
// composerValue 会先等编辑器提交这一帧,再校验草稿保留且确实插入了换行。
|
||
expect(await composerValue(composer)).toBe('修改玩家移动脚本');
|
||
expect(
|
||
composer.querySelector('br:not([data-lexical-managed-linebreak])'),
|
||
).not.toBeNull();
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command]) => command === 'chat_with_game_creator_direct_codex',
|
||
),
|
||
).toBe(false);
|
||
|
||
// 组合态 Enter 同样不发送:按键被编辑器消化,草稿原样保留。
|
||
expect(
|
||
fireEvent.keyDown(composer, { key: 'Enter', isComposing: true }),
|
||
).toBe(false);
|
||
expect(await composerValue(composer)).toBe('修改玩家移动脚本');
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command]) => command === 'chat_with_game_creator_direct_codex',
|
||
),
|
||
).toBe(false);
|
||
|
||
expect(fireEvent.keyDown(composer, { key: 'Enter' })).toBe(false);
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith(
|
||
'chat_with_game_creator_direct_codex',
|
||
{
|
||
projectPath,
|
||
prompt: '修改玩家移动脚本',
|
||
clientTurnId: expect.any(String),
|
||
},
|
||
);
|
||
});
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'start_game_creator_supervisor_runtime_task',
|
||
expect.anything(),
|
||
);
|
||
});
|
||
}
|
||
|
||
export function registerProjectWorkbenchNavigationTests() {
|
||
it('keeps project switching inside the single-window client flow', async () => {
|
||
const invoke = vi.fn(async () => undefined);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?main');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '切换项目' }));
|
||
|
||
expect(screen.getByText('请回到首页的项目组切换项目。')).not.toBeNull();
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'open_game_creator_launcher_window',
|
||
);
|
||
|
||
await submitChat('/switch-project');
|
||
|
||
expect(
|
||
screen.getAllByText('请回到首页的项目组切换项目。').length,
|
||
).toBeGreaterThanOrEqual(2);
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'open_game_creator_launcher_window',
|
||
);
|
||
});
|
||
|
||
it('opens the current project directory from the main project 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 === 'read_project_permission_policy') {
|
||
return emptyProjectPolicy();
|
||
}
|
||
if (command === 'chat_with_game_creator_agent') {
|
||
return {
|
||
replyText: `主聊天回复:${String(args?.prompt ?? '')}`,
|
||
};
|
||
}
|
||
if (command === 'read_local_conversation') {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (command === 'chat_with_game_creator_role_agent') {
|
||
return {
|
||
replyText: mockRoleAgentReply(),
|
||
};
|
||
}
|
||
if (command === 'append_local_conversation_message') {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (command === 'open_local_project_directory') {
|
||
return undefined;
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
await screen.findByText('已打开:authorized-game');
|
||
const revealButtons = screen.getAllByRole('button', { name: '显示目录' });
|
||
fireEvent.click(revealButtons[revealButtons.length - 1]);
|
||
|
||
await waitFor(() => {
|
||
expect(invoke).toHaveBeenCalledWith('open_local_project_directory', {
|
||
projectPath: '/tmp/authorized-game',
|
||
});
|
||
});
|
||
expect(await screen.findByText('已打开项目目录。')).not.toBeNull();
|
||
|
||
await submitChat('/open-project');
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'open_local_project_directory',
|
||
),
|
||
).toHaveLength(2);
|
||
});
|
||
expect(
|
||
screen.getAllByText('已打开项目目录。').length,
|
||
).toBeGreaterThanOrEqual(2);
|
||
});
|
||
|
||
it('fills an asset registration draft from recent project files without registering immediately', 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 === 'read_project_permission_policy') {
|
||
return emptyProjectPolicy();
|
||
}
|
||
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_local_project_file') {
|
||
throw new Error('missing trace');
|
||
}
|
||
if (command === 'list_local_project_files') {
|
||
return {
|
||
projectPath: String(args?.projectPath ?? ''),
|
||
files: [
|
||
{
|
||
path: 'assets/uploads/hero.png',
|
||
kind: 'file',
|
||
size: 4,
|
||
modifiedAt: 1700000001,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
if (command === 'register_local_asset') {
|
||
throw new Error('should only fill the chat draft');
|
||
}
|
||
throw new Error(`unexpected invoke ${command}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke } };
|
||
await act(async () => {
|
||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||
});
|
||
|
||
await screen.findByText('已打开:authorized-game');
|
||
invoke.mockClear();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '文件' }));
|
||
|
||
expect(await screen.findByText(/本地项目文件:/)).not.toBeNull();
|
||
fireEvent.click(
|
||
within(screen.getByLabelText('最近项目文件')).getByRole('button', {
|
||
name: '登记资产 assets/uploads/hero.png',
|
||
}),
|
||
);
|
||
await waitFor(() =>
|
||
expect(document.activeElement).toBe(screen.getByLabelText('创作想法')),
|
||
);
|
||
|
||
expect(await composerText()).toBe(
|
||
'/asset-register assets/uploads/hero.png image image/png',
|
||
);
|
||
expect(
|
||
screen.queryByText('asset.register · assets/uploads/hero.png'),
|
||
).toBeNull();
|
||
expect(invoke).not.toHaveBeenCalledWith(
|
||
'register_local_asset',
|
||
expect.anything(),
|
||
);
|
||
});
|
||
}
|
||
|
||
export function registerProjectAgentStatusTests() {
|
||
it('shows runtime status and recent tasks in the main agent status list', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
const runtimeTask = {
|
||
schemaVersion: 'game-creator-agent-runtime-task.v1',
|
||
agentId: 'design-director',
|
||
taskId: 'design-director',
|
||
sessionId: 'agent-session-design-director',
|
||
runId: 'runtime-design-director-1',
|
||
source: 'agent-background-task',
|
||
task: '排队补齐世界观拆解',
|
||
status: 'pending',
|
||
phase: 'queued',
|
||
currentAction: '等待当前任务完成',
|
||
error: null,
|
||
updatedAt: 10,
|
||
};
|
||
const runtimeState = {
|
||
schemaVersion: 'game-creator-agent-runtime.v1',
|
||
agentId: 'design-director',
|
||
taskId: 'design-director',
|
||
sessionId: 'agent-session-design-director',
|
||
runId: 'runtime-design-director-1',
|
||
source: 'agent-background-task',
|
||
status: 'running',
|
||
phase: 'planning',
|
||
currentTask: '拆解关卡节奏',
|
||
currentGoal: '补齐第一关节奏目标',
|
||
currentAction: '整理目标和约束',
|
||
waitingOn: 'Agent 输出计划或回复',
|
||
loopIteration: 2,
|
||
maxLoopIterations: 3,
|
||
toolActionBudget: 3,
|
||
plan: ['读取项目上下文'],
|
||
planSteps: [
|
||
{
|
||
index: 0,
|
||
title: '读取项目上下文',
|
||
status: 'active',
|
||
detail: '正在整理目标和约束',
|
||
updatedAt: 11,
|
||
},
|
||
],
|
||
activePlanStepIndex: 0,
|
||
observations: ['已创建本轮 Agent Runtime run。'],
|
||
taskQueue: {
|
||
total: 2,
|
||
pending: 1,
|
||
running: 1,
|
||
completed: 0,
|
||
failed: 0,
|
||
latestRunId: 'runtime-design-director-1',
|
||
updatedAt: 10,
|
||
},
|
||
allowedTools: ['conversation.read'],
|
||
lastResponse: null,
|
||
error: null,
|
||
updatedAt: 10,
|
||
};
|
||
const completedRuntimeState = {
|
||
...runtimeState,
|
||
status: 'idle',
|
||
phase: 'completed',
|
||
currentAction: '等待下一轮输入',
|
||
waitingOn: '开发者下一轮输入',
|
||
nextStep: '等待下一轮输入',
|
||
taskQueue: {
|
||
total: 2,
|
||
pending: 1,
|
||
running: 0,
|
||
completed: 1,
|
||
failed: 0,
|
||
latestRunId: 'runtime-design-director-1',
|
||
updatedAt: 12,
|
||
},
|
||
lastResponse: '已补齐第一关节奏目标。',
|
||
updatedAt: 12,
|
||
};
|
||
const completedRuntimeTask = {
|
||
...runtimeTask,
|
||
status: 'completed',
|
||
phase: 'completed',
|
||
currentAction: '等待下一轮输入',
|
||
updatedAt: 12,
|
||
};
|
||
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 [
|
||
{
|
||
state: runtimeState,
|
||
sessionPath:
|
||
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
|
||
eventPath:
|
||
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
|
||
taskPath:
|
||
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
|
||
taskQueue: runtimeState.taskQueue,
|
||
recentEvents: [],
|
||
recentTasks: [runtimeTask],
|
||
},
|
||
];
|
||
}
|
||
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}`);
|
||
},
|
||
);
|
||
let runtimeUpdateHandler:
|
||
| ((event: { payload: Record<string, unknown> }) => void)
|
||
| null = null;
|
||
const listen = vi.fn(
|
||
async (
|
||
eventName: string,
|
||
handler: (event: { payload: Record<string, unknown> }) => void,
|
||
) => {
|
||
if (eventName === 'game-creator-agent-progress') {
|
||
return () => {};
|
||
}
|
||
if (eventName === 'game-creator-agent-runtime-update') {
|
||
runtimeUpdateHandler = handler;
|
||
return () => {
|
||
if (runtimeUpdateHandler === handler) {
|
||
runtimeUpdateHandler = null;
|
||
}
|
||
};
|
||
}
|
||
throw new Error(`unexpected listen ${eventName}`);
|
||
},
|
||
);
|
||
window.__TAURI__ = { core: { invoke }, event: { listen } };
|
||
|
||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
await screen.findByText('想做什么游戏?');
|
||
const agentStatusList = screen.getByLabelText('Agent 状态列表');
|
||
await waitFor(() => {
|
||
const designCard = within(agentStatusList).getByRole('button', {
|
||
name: /拆解创作方向/,
|
||
});
|
||
expect(designCard.textContent).toContain(
|
||
'Runtime:running / planning · Loop 2/3 · 整理目标和约束 · 等待 Agent 输出计划或回复 · 下一步 等待 Agent 输出计划或回复 · run runtime-design-director-1',
|
||
);
|
||
expect(designCard.textContent).toContain('当前目标:补齐第一关节奏目标');
|
||
expect(designCard.textContent).toContain('当前任务:拆解关卡节奏');
|
||
expect(designCard.textContent).toContain(
|
||
'当前计划步骤:#1 active · 读取项目上下文 · 正在整理目标和约束',
|
||
);
|
||
expect(designCard.textContent).toContain(
|
||
'任务队列:pending 1 · running 1 · waiting 0 · needsInput 0 · cancelled 0 · completed 0 · failed 0 · total 2 · latest runtime-design-director-1',
|
||
);
|
||
expect(designCard.textContent).toContain(
|
||
'最近任务:pending / queued · 排队补齐世界观拆解',
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.filter(
|
||
([command]) => command === 'resume_game_creator_agent_runtime_tasks',
|
||
),
|
||
).toHaveLength(1);
|
||
});
|
||
|
||
await act(async () => {
|
||
runtimeUpdateHandler?.({
|
||
payload: {
|
||
projectPath: '/tmp/authorized-game',
|
||
agentId: 'design-director',
|
||
runId: 'runtime-design-director-1',
|
||
status: 'idle',
|
||
phase: 'completed',
|
||
runtime: {
|
||
state: completedRuntimeState,
|
||
sessionPath:
|
||
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
|
||
eventPath:
|
||
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
|
||
taskPath:
|
||
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
|
||
taskQueue: completedRuntimeState.taskQueue,
|
||
recentEvents: [],
|
||
recentTasks: [completedRuntimeTask],
|
||
},
|
||
},
|
||
});
|
||
});
|
||
|
||
await waitFor(() => {
|
||
const designCard = within(agentStatusList).getByRole('button', {
|
||
name: /拆解创作方向/,
|
||
});
|
||
expect(designCard.textContent).toContain(
|
||
'Runtime:idle / completed · Loop 2/3 · 等待下一轮输入 · 等待 开发者下一轮输入 · 下一步 等待下一轮输入 · run runtime-design-director-1',
|
||
);
|
||
expect(designCard.textContent).toContain(
|
||
'任务队列:pending 1 · running 0 · waiting 0 · needsInput 0 · cancelled 0 · completed 1 · failed 0 · total 2 · latest runtime-design-director-1',
|
||
);
|
||
expect(designCard.textContent).toContain(
|
||
'最近任务:completed / completed · 排队补齐世界观拆解',
|
||
);
|
||
});
|
||
});
|
||
|
||
it('lets the developer schedule ready manifest tasks into agent runtimes', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
const runtimeTask = {
|
||
schemaVersion: 'game-creator-agent-runtime-task.v1',
|
||
agentId: 'design-director',
|
||
taskId: 'design-director',
|
||
sessionId: 'agent-session-design-director',
|
||
runId: 'ready-design-director-1',
|
||
source: 'agent-ready-task-scheduler',
|
||
task: '执行 Ready 任务:拆解创作方向',
|
||
status: 'running',
|
||
phase: 'planning',
|
||
currentAction: '准备读取项目上下文',
|
||
error: null,
|
||
updatedAt: 20,
|
||
};
|
||
const runtimeState = {
|
||
schemaVersion: 'game-creator-agent-runtime.v1',
|
||
agentId: 'design-director',
|
||
taskId: 'design-director',
|
||
sessionId: 'agent-session-design-director',
|
||
runId: 'ready-design-director-1',
|
||
source: 'agent-ready-task-scheduler',
|
||
status: 'running',
|
||
phase: 'planning',
|
||
currentTask: '执行 Ready 任务:拆解创作方向',
|
||
currentGoal: '把 ready 任务投递给对应 Agent',
|
||
currentAction: '准备读取项目上下文',
|
||
waitingOn: 'Agent 输出计划或回复',
|
||
loopIteration: 1,
|
||
maxLoopIterations: 3,
|
||
toolActionBudget: 3,
|
||
plan: ['读取项目上下文'],
|
||
planSteps: [],
|
||
activePlanStepIndex: null,
|
||
observations: [],
|
||
taskQueue: {
|
||
total: 1,
|
||
pending: 0,
|
||
running: 1,
|
||
completed: 0,
|
||
failed: 0,
|
||
latestRunId: 'ready-design-director-1',
|
||
updatedAt: 20,
|
||
},
|
||
allowedTools: ['conversation.read'],
|
||
lastResponse: null,
|
||
error: null,
|
||
updatedAt: 20,
|
||
};
|
||
const runtimeResult = {
|
||
state: runtimeState,
|
||
sessionPath:
|
||
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
|
||
eventPath:
|
||
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
|
||
taskPath:
|
||
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
|
||
taskQueue: runtimeState.taskQueue,
|
||
recentEvents: [],
|
||
recentTasks: [runtimeTask],
|
||
};
|
||
let scheduled = false;
|
||
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_project_permission_policy') {
|
||
return emptyProjectPolicy();
|
||
}
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
if (command === 'read_game_creator_agent_runtimes') {
|
||
return scheduled ? [runtimeResult] : [];
|
||
}
|
||
if (command === 'schedule_game_creator_agent_ready_tasks') {
|
||
scheduled = true;
|
||
return [runtimeResult];
|
||
}
|
||
if (command === 'append_local_conversation_message') {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (command === 'get_local_game_manifest') {
|
||
return manifest;
|
||
}
|
||
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('/?dev&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
await screen.findByText('想做什么游戏?');
|
||
const scheduleButton = await screen.findByRole('button', {
|
||
name: '调度 Ready',
|
||
});
|
||
await waitFor(() => {
|
||
expect((scheduleButton as HTMLButtonElement).disabled).toBe(false);
|
||
});
|
||
|
||
fireEvent.click(scheduleButton);
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command, args]) =>
|
||
command === 'schedule_game_creator_agent_ready_tasks' &&
|
||
args?.projectPath === '/tmp/authorized-game' &&
|
||
args?.limit === 16,
|
||
),
|
||
).toBe(true);
|
||
});
|
||
expect(await screen.findAllByText('已调度 1 个 Ready 任务。')).toHaveLength(
|
||
2,
|
||
);
|
||
const agentStatusList = screen.getByLabelText('Agent 状态列表');
|
||
await waitFor(() => {
|
||
const designCard = within(agentStatusList).getByRole('button', {
|
||
name: /拆解创作方向/,
|
||
});
|
||
expect(designCard.textContent).toContain('Runtime:running / planning');
|
||
expect(designCard.textContent).toContain(
|
||
'当前目标:把 ready 任务投递给对应 Agent',
|
||
);
|
||
expect(designCard.textContent).toContain(
|
||
'最近任务:running / planning · 执行 Ready 任务:拆解创作方向',
|
||
);
|
||
});
|
||
});
|
||
|
||
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');
|
||
|
||
await screen.findByText('想做什么游戏?');
|
||
|
||
expect(screen.queryByRole('button', { name: '调度 Ready' })).toBeNull();
|
||
expect(
|
||
invoke.mock.calls.some(
|
||
([command]) => command === 'schedule_game_creator_agent_ready_tasks',
|
||
),
|
||
).toBe(false);
|
||
});
|
||
|
||
it('ignores stale agent conversation reads after switching agents', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
let releaseOldConversation: (() => void) | null = null;
|
||
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_project_permission_policy') {
|
||
return emptyProjectPolicy();
|
||
}
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
if (command === 'read_game_creator_agent_runtimes') {
|
||
return [];
|
||
}
|
||
if (command === 'read_local_conversation') {
|
||
if (args?.agentId === null) {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (args?.agentId === 'design-director') {
|
||
return await new Promise((resolve) => {
|
||
releaseOldConversation = () =>
|
||
resolve({
|
||
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
|
||
agentId: 'design-director',
|
||
messages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'user',
|
||
content: '旧 Agent 慢速消息',
|
||
agentId: 'design-director',
|
||
updatedAt: 1,
|
||
},
|
||
],
|
||
});
|
||
});
|
||
}
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/agents/art-director.jsonl',
|
||
agentId: args?.agentId,
|
||
messages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'user',
|
||
content: '新 Agent 历史消息',
|
||
agentId: String(args?.agentId ?? ''),
|
||
updatedAt: 2,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
if (command === 'read_local_agent_memory') {
|
||
return {
|
||
taskId: args?.taskId,
|
||
path: `/tmp/authorized-game/memory/agents/${String(
|
||
args?.taskId ?? '',
|
||
)}.md`,
|
||
content:
|
||
args?.taskId === 'art-director'
|
||
? '新 Agent 私有记忆'
|
||
: '旧 Agent 私有记忆',
|
||
exists: true,
|
||
};
|
||
}
|
||
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');
|
||
|
||
await screen.findByText('想做什么游戏?');
|
||
const agentStatusList = screen.getByLabelText('Agent 状态列表');
|
||
const designAgentButton = within(agentStatusList).getByRole('button', {
|
||
name: /拆解创作方向/,
|
||
});
|
||
await waitFor(() => {
|
||
expect((designAgentButton as HTMLButtonElement).disabled).toBe(false);
|
||
});
|
||
fireEvent.click(designAgentButton);
|
||
await waitFor(() => {
|
||
expect(releaseOldConversation).not.toBeNull();
|
||
});
|
||
fireEvent.click(
|
||
within(agentStatusList).getByRole('button', { name: /确定视觉方向/ }),
|
||
);
|
||
|
||
expect(await screen.findByText('新 Agent 历史消息')).not.toBeNull();
|
||
expect(screen.getByLabelText('Agent 私有记忆').textContent).toContain(
|
||
'新 Agent 私有记忆',
|
||
);
|
||
await act(async () => {
|
||
releaseOldConversation?.();
|
||
});
|
||
|
||
expect(screen.queryByText('旧 Agent 慢速消息')).toBeNull();
|
||
expect(screen.getByText('新 Agent 历史消息')).not.toBeNull();
|
||
expect(screen.getByLabelText('Agent 私有记忆').textContent).not.toContain(
|
||
'旧 Agent 私有记忆',
|
||
);
|
||
});
|
||
|
||
it('ignores stale agent conversation saves after switching agents', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
let releaseOldSave: (() => void) | null = null;
|
||
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_project_permission_policy') {
|
||
return emptyProjectPolicy();
|
||
}
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
if (command === 'read_game_creator_agent_runtimes') {
|
||
return [];
|
||
}
|
||
if (command === 'read_local_conversation') {
|
||
if (args?.agentId === null) {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (args?.agentId === 'art-director') {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/agents/art-director.jsonl',
|
||
agentId: 'art-director',
|
||
messages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'user',
|
||
content: '新 Agent 留存消息',
|
||
agentId: 'art-director',
|
||
updatedAt: 2,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
|
||
agentId: 'design-director',
|
||
messages: [],
|
||
};
|
||
}
|
||
if (command === 'read_local_agent_memory') {
|
||
return {
|
||
taskId: args?.taskId,
|
||
path: `/tmp/authorized-game/memory/agents/${String(
|
||
args?.taskId ?? '',
|
||
)}.md`,
|
||
content: '',
|
||
exists: false,
|
||
};
|
||
}
|
||
if (command === 'append_local_conversation_message') {
|
||
const message = args?.message as {
|
||
role: 'user' | 'assistant';
|
||
content: string;
|
||
agentId: string | null;
|
||
};
|
||
return await new Promise((resolve) => {
|
||
releaseOldSave = () =>
|
||
resolve({
|
||
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
|
||
agentId: 'design-director',
|
||
messages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: message.role,
|
||
content: message.content,
|
||
agentId: message.agentId,
|
||
updatedAt: 1,
|
||
},
|
||
],
|
||
});
|
||
});
|
||
}
|
||
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');
|
||
|
||
await screen.findByText('想做什么游戏?');
|
||
const agentStatusList = screen.getByLabelText('Agent 状态列表');
|
||
const designAgentButton = within(agentStatusList).getByRole('button', {
|
||
name: /拆解创作方向/,
|
||
});
|
||
await waitFor(() => {
|
||
expect((designAgentButton as HTMLButtonElement).disabled).toBe(false);
|
||
});
|
||
fireEvent.click(designAgentButton);
|
||
const input = await screen.findByLabelText('Agent 对话内容');
|
||
fireEvent.change(input, { target: { value: '旧 Agent 保存回包' } });
|
||
fireEvent.submit(input.closest('form') as HTMLFormElement);
|
||
await screen.findByText('正在保存用户消息');
|
||
await waitFor(() => {
|
||
expect(releaseOldSave).not.toBeNull();
|
||
});
|
||
fireEvent.click(
|
||
within(agentStatusList).getByRole('button', { name: /确定视觉方向/ }),
|
||
);
|
||
|
||
expect(await screen.findByText('新 Agent 留存消息')).not.toBeNull();
|
||
await act(async () => {
|
||
releaseOldSave?.();
|
||
});
|
||
|
||
expect(screen.getByText('新 Agent 留存消息')).not.toBeNull();
|
||
expect(screen.queryByText('旧 Agent 保存回包')).toBeNull();
|
||
expect(screen.queryByText(/已保存 1 条/)).toBeNull();
|
||
});
|
||
|
||
it('ignores stale agent reads after closing the agent dialog', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
let releaseConversation: (() => void) | null = null;
|
||
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_project_permission_policy') {
|
||
return emptyProjectPolicy();
|
||
}
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
if (command === 'read_game_creator_agent_runtimes') {
|
||
return [];
|
||
}
|
||
if (command === 'read_local_conversation') {
|
||
if (args?.agentId === null) {
|
||
return {
|
||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: null,
|
||
messages: [],
|
||
};
|
||
}
|
||
return await new Promise((resolve) => {
|
||
releaseConversation = () =>
|
||
resolve({
|
||
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
|
||
agentId: 'design-director',
|
||
messages: [
|
||
{
|
||
schemaVersion: 'game-creator-conversation.v1',
|
||
role: 'user',
|
||
content: '关闭后不该写入界面状态',
|
||
agentId: 'design-director',
|
||
updatedAt: 1,
|
||
},
|
||
],
|
||
});
|
||
});
|
||
}
|
||
if (command === 'read_local_agent_memory') {
|
||
return {
|
||
taskId: args?.taskId,
|
||
path: '/tmp/authorized-game/memory/agents/design/director.md',
|
||
content: '关闭后不该读取私有记忆',
|
||
exists: true,
|
||
};
|
||
}
|
||
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('/?dev&projectPath=%2Ftmp%2Fauthorized-game');
|
||
|
||
await screen.findByText('想做什么游戏?');
|
||
await waitFor(() => {
|
||
expect(
|
||
(
|
||
screen.getByRole('button', {
|
||
name: '刷新 Agent',
|
||
}) as HTMLButtonElement
|
||
).disabled,
|
||
).toBe(false);
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ }));
|
||
await waitFor(() => {
|
||
expect(releaseConversation).not.toBeNull();
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
|
||
expect(screen.queryByLabelText('Agent 对话')).toBeNull();
|
||
|
||
await act(async () => {
|
||
releaseConversation?.();
|
||
});
|
||
|
||
expect(screen.queryByText('关闭后不该写入界面状态')).toBeNull();
|
||
expect(screen.queryByText('conversation.read')).toBeNull();
|
||
expect(screen.queryByText('memory.agent.read')).toBeNull();
|
||
});
|
||
|
||
it('updates the open agent dialog when agent status is refreshed', async () => {
|
||
const manifest = createGameCreationAppManifest(
|
||
'local-project-draft',
|
||
'未命名游戏原型',
|
||
);
|
||
let runReadCount = 0;
|
||
const makeTrace = (withAgentStep: boolean) =>
|
||
({
|
||
schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
|
||
runId: 'run-open-agent-refresh',
|
||
commandId: 'game.generate_draft',
|
||
status: 'running',
|
||
passes: 1,
|
||
maxPasses: 3,
|
||
toolCallCount: 1,
|
||
maxToolCalls: 128,
|
||
stopReason: 'running',
|
||
goal: '做一个厨房弹幕游戏',
|
||
coordination: 'Planner',
|
||
steps: withAgentStep
|
||
? [
|
||
{
|
||
pass: 1,
|
||
agent: 'Planner',
|
||
phase: 'plan',
|
||
taskId: 'design-director',
|
||
group: 'design',
|
||
role: 'Director',
|
||
status: 'running',
|
||
inputPaths: ['memory/session.md'],
|
||
outputPaths: ['.agent/spec.md'],
|
||
summary: '刷新后的拆解方向',
|
||
toolCalls: [
|
||
{
|
||
toolId: 'llm.planner.refresh',
|
||
status: 'ok',
|
||
inputPaths: ['memory/session.md'],
|
||
outputPaths: ['.agent/spec.md'],
|
||
summary: '刷新后工具调用',
|
||
},
|
||
...Array.from({ length: 5 }, (_, index) => ({
|
||
toolId: `llm.extra.${index + 1}`,
|
||
status: 'ok',
|
||
inputPaths: [],
|
||
outputPaths: [],
|
||
summary: `额外工具调用 ${index + 1}`,
|
||
})),
|
||
],
|
||
},
|
||
]
|
||
: [],
|
||
artifacts: [],
|
||
taskGraph: {
|
||
goal: '做一个厨房弹幕游戏',
|
||
readyTaskIds: [],
|
||
activeTaskIds: withAgentStep ? ['design-director'] : [],
|
||
carriedTaskIds: [],
|
||
repairFocus: [],
|
||
repairRoutes: [],
|
||
tasks: createGameCreationAppSeedTasks(),
|
||
},
|
||
passPlans: [],
|
||
nextStep: 'continue',
|
||
error: null,
|
||
updatedAt: runReadCount,
|
||
}) satisfies GameCreationAgentRunTrace;
|
||
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: args?.agentId
|
||
? '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl'
|
||
: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||
agentId: args?.agentId ?? null,
|
||
messages: [],
|
||
};
|
||
}
|
||
if (command === 'read_local_agent_memory') {
|
||
return {
|
||
taskId: args?.taskId,
|
||
path: '/tmp/authorized-game/memory/agents/design/director.md',
|
||
content: '',
|
||
exists: false,
|
||
};
|
||
}
|
||
if (command === 'read_project_permission_policy') {
|
||
return emptyProjectPolicy();
|
||
}
|
||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||
return [];
|
||
}
|
||
if (command === 'read_game_creator_agent_runtimes') {
|
||
return [];
|
||
}
|
||
if (command === 'read_local_project_file') {
|
||
runReadCount += 1;
|
||
return {
|
||
path: String(args?.relativePath ?? ''),
|
||
absolutePath: `${String(args?.projectPath ?? '')}/${String(
|
||
args?.relativePath ?? '',
|
||
)}`,
|
||
content: JSON.stringify(makeTrace(runReadCount > 1)),
|
||
};
|
||
}
|
||
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');
|
||
|
||
await screen.findByText('已打开:authorized-game');
|
||
fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ }));
|
||
expect(await screen.findByLabelText('Agent 对话')).not.toBeNull();
|
||
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
|
||
'暂无最近运行证据',
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '刷新 Agent' }));
|
||
|
||
expect(await screen.findByText('刷新后的拆解方向')).not.toBeNull();
|
||
await waitFor(() => {
|
||
expect(screen.getByLabelText('Agent 对话').textContent).toContain(
|
||
'刷新后的拆解方向',
|
||
);
|
||
});
|
||
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
|
||
'in: memory/session.md',
|
||
);
|
||
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
|
||
'out: .agent/spec.md',
|
||
);
|
||
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
|
||
'tool: llm.planner.refresh · ok · 刷新后工具调用',
|
||
);
|
||
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
|
||
'tool: llm.planner.refresh · ok · 刷新后工具调用 · in memory/session.md · out .agent/spec.md',
|
||
);
|
||
expect(screen.getByLabelText('Agent 最近证据').textContent).toContain(
|
||
'还有 1 个工具调用',
|
||
);
|
||
});
|
||
|
||
it('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');
|
||
|
||
expect(await screen.findByText('想做什么游戏?')).not.toBeNull();
|
||
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('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');
|
||
|
||
expect(await screen.findByText('想做什么游戏?')).not.toBeNull();
|
||
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,
|
||
supervisor: 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,
|
||
supervisor: 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(
|
||
resolve(process.cwd(), '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) 点它进入的视图包含来自多个栏目的资源,而不是某一个栏目。
|
||
fireEvent.click(screen.getByRole('button', { name: '打开所有资源' }));
|
||
await waitFor(() =>
|
||
expect(
|
||
document.querySelector('[data-resource-book-all-host="true"]'),
|
||
).not.toBeNull(),
|
||
);
|
||
|
||
const expectSortedCategories = (categories: string[]) =>
|
||
[...categories].sort();
|
||
const allSections = Array.from(
|
||
document.querySelectorAll<HTMLElement>(
|
||
'.game-resource-all-section[data-resource-book-category]',
|
||
),
|
||
);
|
||
const expectedSections = expectSortedCategories(
|
||
Array.from(new Set(allResources.map((resource) => resource.category))),
|
||
);
|
||
expect(
|
||
expectSortedCategories(
|
||
allSections.map((section) => section.dataset.resourceBookCategory!),
|
||
),
|
||
).toEqual(expectedSections);
|
||
expect(allSections.length).toBeGreaterThan(1);
|
||
for (const section of allSections) {
|
||
expect(
|
||
section.querySelectorAll('.game-resource-card').length,
|
||
).toBeGreaterThan(0);
|
||
}
|
||
// 汇总页自己的标题栏也用同一份全量计数。
|
||
expect(readCountBadge('.game-resource-all-page')).toBe(allResourcesCount);
|
||
});
|
||
|
||
/**
|
||
* 空项目下「所有资源」卡不能破版:沿用栏目卡同一条空态(`暂无资源`),
|
||
* 预览摞一张都不挂、计数为 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,
|
||
supervisor: 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 项');
|
||
});
|
||
|
||
/**
|
||
* 「资源总览」大标题旁边不再有任何副标题。
|
||
*
|
||
* 原来那颗小字是「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,
|
||
supervisor: 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('打开文档');
|
||
});
|
||
}
|