// @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(); 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>) { 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); }); /** * 菜单位置按触发按钮的实时 rect 算:运行画面会缩放 / 滚动,`fixed` 定位的菜单 * 不跟着重算就会漂在旧位置上(离触发按钮越来越远,甚至跑出视口)。 * * 变异验证:把 `scroll` / `resize` 的重算订阅删掉,本用例必须失败。 */ test('recomputes the portal menu position after the window scrolls', async () => { const rect = (bottom: number) => ({ bottom, right: 200, top: bottom - 24, left: 120, width: 80, height: 24, x: 120, y: bottom - 24, toJSON: () => ({}), }) as DOMRect; const rectSpy = vi .spyOn(HTMLElement.prototype, 'getBoundingClientRect') .mockReturnValue(rect(100)); try { render( React.createElement(GameRunVersionPicker, { versions: createVersionedManifest().versions, activeVersionId: null, onSelectVersion: vi.fn(), }), ); fireEvent.click(await screen.findByLabelText(/^当前版本:/)); const menu = await screen.findByRole('listbox', { name: '切换游戏版本', }); expect(menu.style.top).toBe('106px'); // 窗口滚动 / 画布缩放后触发按钮位移,菜单必须跟着走。 rectSpy.mockReturnValue(rect(240)); fireEvent.scroll(window); await waitFor(() => { expect(menu.style.top).toBe('246px'); }); } finally { rectSpy.mockRestore(); } }); /** * 用户实测「切换版本点了选项没反应」的直接原因(与事件订阅报错互相独立)。 * * 菜单 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'); }); });