Files
Genarrative/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx
T
suzmii 29b99f213d 删除资源画布左侧的栏目大纲导航:切栏目改由总览卡片、所有资源入口与下一页承担
- index.tsx:整条删除 `ResourceBookOutlineNav` 组件(`.game-resource-outline` / `aria-label="资源栏目大纲"` / 栏目图标 + 栏目文字 + 未读红点)以及栏目页与「所有资源」页两处挂载点;`ResourceBookAllResourcesPage` 不再接收只服务导航的 `unreadCategories` / `onOpen`。
- index.tsx:一并清掉只服务这条导航的「未读红点」状态链(`unreadResourceCategoryState`、`resourceCategorySnapshotRef`、`resourceIdsByCategory`、`viewedResourceCategory` 与对应 effect),不留死代码、不留墓碑注释。
- styles.css:删除全部 `.game-resource-outline*` 规则(悬浮 Dock、常态/悬停态、图标列宽、`.game-resource-outline-label`、`.game-resource-outline-unread`,共 106 行)。
- 保留未动:分区栏目本身、栏目标题栏、资源卡、总览缩略卡片、第 0 张「所有资源」入口、「所有资源」汇总页、搜索、总览、快速编辑与选中工具条。
- 测试:切栏目 helper(appSurface/project-development、appSurface/home、resourceRename、resourceVersionSwitch)改为走「资源总览」缩略卡片(需要时先点「收起资源」回总览),不再点导航;栏目顺序断言改钉在总览缩略卡片的 `aria-label` 序列上(第 0 张「所有资源」+ 固定七栏目),断言内容未放宽。
- 测试:按「四不写」删除只守这条导航的断言——大纲常态可读性的 CSS 声明用例、大纲条目图标/文字的 DOM 用例(该用例保留其中与搜索浮层相关的断言并改名)、未读红点用例(该用例保留"只登记游戏代码的项目照常分页并落在待归类"的部分并改名)、空项目与「所有资源」页里的导航存在性断言。
- 测试:新增反向守卫 + 切栏目链路用例(`has no outline nav and keeps every section reachable through the remaining entries`):断言 `资源栏目大纲` 标签、`.game-resource-outline` 类名与对应 `<nav>` 都不再渲染,并逐条走通总览缩略卡片(含空栏目)→ 下一页 → 标题栏「资源总览」→ 「所有资源」入口往返。
- 测试:`preserves independent art viewports across sort and workbench mode switches` 保留全部原断言,只去掉排序模式切换后那些"点当前栏目"的空操作往返(旧导航点在同栏目上是提前返回,改 helper 后变成真实往返,会在 jsdom 里触发重新 fit)。
2026-09-11 13:54:12 +08:00

256 lines
7.8 KiB
TypeScript

// @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('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);
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,
);
// 选中的版本已经不存在时按空态处理(与 `@` 面板同一口径),不残留旧高亮;
// 工作台壳会在版本消失时把选择清回「最新版本」。
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);
});
});