Files
Genarrative/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx
T
suzmii 3015370286 补 C7 版本切换「重载当前预览」验收测试
- resourceVersionSwitch 新增用例:运行模块内切换版本必须既上报宿主又触发 onPlay 重载预览
- 覆盖验收用例 S16 的「并重载当前预览」条款(此前版本入口只测了记录层与高亮,重载没有守卫)
- 变异验证:摘掉 selectActiveVersion 里的 onPlay 调用后,该用例以 expected "spy" to be called at least once 失败
- 定向验证:resourceVersionSwitch 4 passed
2026-09-12 13:34:32 +08:00

297 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
cleanup,
createGameCreationAppManifest,
createGameCreationAppSeedTasks,
fireEvent,
ProjectDevelopmentView,
React,
render,
screen,
waitFor,
within,
} from './appSurface/harness';
const PROJECT_PATH = '/tmp/workbench-version-switch';
function installResourceCardIntersectionObserver() {
class ResourceCardIntersectionObserver {
readonly root = null;
readonly rootMargin = '160px';
readonly thresholds = [0];
readonly observed = new Set<Element>();
constructor(readonly callback: IntersectionObserverCallback) {}
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,
});
}
function createVersionedManifest(): GameCreationAppManifest {
const manifest = createGameCreationAppManifest(
'workbench-version-switch',
'版本切换测试',
);
// 运行模块的可用性与版本入口无关,这里用一条已完成原型任务把它打开。
manifest.tasks = createGameCreationAppSeedTasks().map((task) =>
task.id === 'code-prototype'
? { ...task, status: 'completed' as const }
: task,
);
// 两件资产必须落在同一栏目才能同屏比较版本高亮,因此都用 `character` 分类。
manifest.assets = [
{
id: 'asset-player',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/player.png',
source: { kind: 'generated' },
},
{
id: 'asset-town',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/town.png',
source: { kind: 'generated' },
},
];
// createdAt 是 Unix 秒(写入侧 manifest.rs 用 unix_timestamp())。
manifest.versions = [
{
versionId: 'version-root',
parentVersionId: null,
projectRevision: 3,
resourceBindings: [
{ slotId: 'asset:asset-player', resourceId: 'asset-player' },
],
createdReason: 'initial',
createdAt: 1_788_075_047,
},
{
versionId: 'version-child',
parentVersionId: 'version-root',
projectRevision: 4,
resourceBindings: [
{ slotId: 'asset:asset-town', resourceId: 'asset-town' },
],
createdReason: 'agent-revision',
createdAt: 1_788_075_104,
},
];
return manifest;
}
function renderWorkbench(
manifest: GameCreationAppManifest,
props: {
activeVersionId?: string | null;
onActiveVersionChange?: (versionId: string) => void;
} = {},
) {
const { rerender } = render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: PROJECT_PATH,
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
onPlay: vi.fn(),
...props,
}),
);
return {
rerenderWith(next: Partial<Record<string, unknown>>) {
rerender(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: PROJECT_PATH,
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
onPlay: vi.fn(),
...props,
...next,
}),
);
},
};
}
async function openArtCategory() {
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
// 左侧栏目大纲导航已按用户要求删除:切栏目走「资源总览」的栏目缩略卡片。
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: '打开角色与对象' }),
);
await waitFor(() => {
expect(
document.querySelector('[data-resource-book-view="child"]'),
).not.toBeNull();
});
}
function cardFor(label: string) {
return screen
.getByRole('button', { name: `选中资源:角色与对象 ${label}` })
.closest('.game-resource-card');
}
afterEach(() => {
cleanup();
});
describe('C7 运行模块版本切换', () => {
test('keeps the version entry hidden without versions and shows the latest one by default', async () => {
installResourceCardIntersectionObserver();
const manifest = createVersionedManifest();
manifest.versions = [];
const withoutVersions = renderWorkbench(manifest);
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
expect(screen.queryByLabelText(/^当前版本:/)).toBeNull();
withoutVersions.rerenderWith({ manifest: createVersionedManifest() });
const trigger = await screen.findByLabelText(/^当前版本:/);
expect(trigger.getAttribute('aria-label')).toContain('智能体修订');
});
test('switches the current version through the entry and reports it to the host', async () => {
installResourceCardIntersectionObserver();
const manifest = createVersionedManifest();
const onActiveVersionChange = vi.fn();
renderWorkbench(manifest, { onActiveVersionChange });
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
fireEvent.click(await screen.findByLabelText(/^当前版本:/));
const menu = await screen.findByRole('listbox', {
name: '切换游戏版本',
});
expect(within(menu).getAllByRole('option')).toHaveLength(2);
// 当前版本(最新的那个)已在菜单里标记为选中。
expect(
within(menu)
.getByRole('option', { name: /智能体修订/ })
.getAttribute('aria-selected'),
).toBe('true');
fireEvent.click(within(menu).getByRole('option', { name: /初始版本/ }));
expect(onActiveVersionChange).toHaveBeenCalledWith('version-root');
});
test('reloads the current preview when the version changes in the run module', async () => {
installResourceCardIntersectionObserver();
const manifest = createVersionedManifest();
const onPlay = vi.fn();
const onActiveVersionChange = vi.fn();
renderWorkbench(manifest, { onPlay, onActiveVersionChange });
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
fireEvent.click(await screen.findByLabelText(/^当前版本:/));
const menu = await screen.findByRole('listbox', {
name: '切换游戏版本',
});
fireEvent.click(within(menu).getByRole('option', { name: /初始版本/ }));
expect(onActiveVersionChange).toHaveBeenCalledWith('version-root');
// 验收用例 S16 的「并重载当前预览」:只改记录层而不重载,用户看到的仍是上一个版本的
// 画面(素材不可变,画面靠重载预览回到该版本对应的资源),切换就成了"点了没反应"。
expect(onPlay).toHaveBeenCalled();
});
test('drives the "current use" card highlight from the active version', async () => {
installResourceCardIntersectionObserver();
const manifest = createVersionedManifest();
const rendered = renderWorkbench(manifest);
await openArtCategory();
// 默认当前版本是 manifest 里最新的那个:只有它绑定的资源算「当前使用」。
expect(cardFor('town.png')?.classList.contains('is-current-version')).toBe(
true,
);
expect(
cardFor('player.png')?.classList.contains('is-current-version'),
).toBe(false);
// 同一件事的 DOM 合同:命中的卡片带 `data-used-by-current-version="true"`,
// 未命中的卡片**根本没有这个属性**(不是 `"false"`),排障与验收都读它。
expect(
cardFor('town.png')?.getAttribute('data-used-by-current-version'),
).toBe('true');
expect(
cardFor('player.png')?.hasAttribute('data-used-by-current-version'),
).toBe(false);
rendered.rerenderWith({ activeVersionId: 'version-root' });
await waitFor(() => {
expect(
cardFor('player.png')?.classList.contains('is-current-version'),
).toBe(true);
});
expect(cardFor('town.png')?.classList.contains('is-current-version')).toBe(
false,
);
// 切版本后属性跟着换手,而不是两件资产都亮着。
expect(
cardFor('player.png')?.getAttribute('data-used-by-current-version'),
).toBe('true');
expect(
cardFor('town.png')?.hasAttribute('data-used-by-current-version'),
).toBe(false);
// 选中的版本已经不存在时按空态处理(与 `@` 面板同一口径),不残留旧高亮;
// 工作台壳会在版本消失时把选择清回「最新版本」。
rendered.rerenderWith({ activeVersionId: 'version-removed' });
await waitFor(() => {
expect(
cardFor('town.png')?.classList.contains('is-current-version'),
).toBe(false);
});
expect(
cardFor('player.png')?.classList.contains('is-current-version'),
).toBe(false);
expect(
cardFor('town.png')?.hasAttribute('data-used-by-current-version'),
).toBe(false);
expect(
cardFor('player.png')?.hasAttribute('data-used-by-current-version'),
).toBe(false);
});
});