Files
Genarrative/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx
T
suzmii 46458a8253 修复运行模块版本菜单「点了选项没反应」:portal 菜单被「点外部」判定吃掉 click
665982eb9 的 tauri 事件注销竞态是两个独立原因,用户看到的是同一个症状:
① 切换版本时的重载被事件竞态报错打断(665982eb9,已并入 6618207b5);
② 版本菜单在选项收到 click 之前被「点外部」判定收掉,onSelectVersion 永远不被调用(本提交)。

- 现象:在运行模块点版本菜单里的选项没有任何反应(菜单直接消失、当前版本不切换)。
- 原因:菜单用 createPortal 挂到 document.body(运行画面会缩放,挂 body 才不跟着画布跑偏),DOM 上不在触发按钮 rootRef 的子树里;而「点外部关闭」判定只认 rootRef,落在选项上的那一次按下就被当成点外部,菜单在选项收到 click 之前被卸载,选项的 onClick 永远不会触发。
- 改法:删掉自建的 window mousedown 判定,复用共享 hook useImageCanvasFloatingOptionDismiss(AGC 的 view/project-development/index.tsx:1661 已在用同一份口径,不新造第二套判定),并把 portal 菜单登记成第二个边界:GameRunVersionPicker.tsx:35 新增 menuRef、:37-41 传 boundaryRefs: [rootRef, menuRef]、:69 把 menuRef 挂到菜单容器上。菜单的关闭时机随之从 mousedown 变为 click,与画布浮层口径一致。
- 回归用例(apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx,全部走真实指针序列 mousedown → mouseup → click,不再像旧用例那样只派发 click):
  - does not dismiss the portal version menu when an option is pressed down:mousedown 后菜单必须还在,随后 mouseup+click 必须调用 onSelectVersion('version-root')。
  - selects the version from the run module when the option is clicked after a real press:真实 launcher + 运行模块,走完真实序列后宿主必须收到 onActiveVersionChange('version-root')。
  - keeps the portal menu mounted when the click lands on the menu itself:点 portal 菜单自身(非选项区)不算点外部。
- 变异验证(两条,都先红后绿):
  - 还原旧实现(window mousedown + 只认 rootRef)→ 3 条新用例全红:2 条 AssertionError: expected null not to be null(菜单被关掉),1 条 expected "spy" to be called with arguments: [ 'version-root' ] / Number of calls: 0(onActiveVersionChange 没被调用,正是用户症状)。
  - 只把边界收回 boundaryRefs: [rootRef](仍复用 hook,但不登记 portal 菜单)→ keeps the portal menu mounted when the click lands on the menu itself 变红(expected null not to be null)。注意另两条此时仍绿:共享 hook 的判定在 click 而非 mousedown,所以要复现用户现象必须连旧的 mousedown 判定一起还原,这也是本次把判定整体换成共享 hook 的直接理由。
- 验证:npx vitest run apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx 7 passed(原 4 条 + 新 3 条);npm --prefix apps/ai-game-creator-shell run typecheck exit 0。
2026-09-12 18:02:13 +08:00

384 lines
13 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 { GameRunVersionPicker } from '../src/features/resource-canvas/GameRunVersionPicker';
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);
});
/**
* 用户实测「切换版本点了选项没反应」的直接原因(与事件订阅报错互相独立)。
*
* 菜单 portal 到 `document.body`DOM 上不在触发按钮的子树里。只认 `rootRef` 的
* 「点外部」判定会把落在选项上的 mousedown 当成点外部、当场卸载菜单,随后的 click
* 就落不到选项上,`onSelectVersion` 永远不会被调用。
*
* 这两条用例必须走**真实指针序列**mousedown → mouseup → click):只派发 click
* 会绕过「点外部」判定,正是旧用例没抓住这个 bug 的原因。
*/
test('does not dismiss the portal version menu when an option is pressed down', async () => {
const onSelectVersion = vi.fn();
render(
React.createElement(GameRunVersionPicker, {
versions: createVersionedManifest().versions,
activeVersionId: null,
onSelectVersion,
}),
);
fireEvent.click(await screen.findByLabelText(/^当前版本:/));
const menu = await screen.findByRole('listbox', {
name: '切换游戏版本',
});
const option = within(menu).getByRole('option', { name: /初始版本/ });
fireEvent.mouseDown(option);
// 按下选项时菜单必须还在:被卸载掉就说明这次 mousedown 被判成了「点外部」。
expect(
screen.queryByRole('listbox', { name: '切换游戏版本' }),
).not.toBeNull();
fireEvent.mouseUp(option);
fireEvent.click(option);
expect(onSelectVersion).toHaveBeenCalledWith('version-root');
});
/**
* portal 菜单本身必须登记成「点这里不算外部」:菜单挂在 `document.body` 上,
* 不在触发按钮的子树里,只按 `rootRef` 判定就会把落在菜单自己身上的点击
* 当成点外部把它收掉(选项与菜单之间的空隙、菜单容器的内边距都算)。
*/
test('keeps the portal menu mounted when the click lands on the menu itself', async () => {
render(
React.createElement(GameRunVersionPicker, {
versions: createVersionedManifest().versions,
activeVersionId: null,
onSelectVersion: vi.fn(),
}),
);
fireEvent.click(await screen.findByLabelText(/^当前版本:/));
const menu = await screen.findByRole('listbox', {
name: '切换游戏版本',
});
fireEvent.mouseDown(menu);
fireEvent.click(menu);
expect(
screen.queryByRole('listbox', { name: '切换游戏版本' }),
).not.toBeNull();
});
test('selects the version from the run module when the option is clicked after a real press', 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: '切换游戏版本',
});
const option = within(menu).getByRole('option', { name: /初始版本/ });
// 真实的一次「点选项」:按下 → 抬起 → click。菜单若在按下那一刻被卸载,
// 这次 click 就落不到选项上,宿主收不到任何版本变更(即「点了没反应」)。
fireEvent.mouseDown(option);
fireEvent.mouseUp(option);
fireEvent.click(option);
expect(onActiveVersionChange).toHaveBeenCalledWith('version-root');
});
});