Files
Genarrative/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx
T
k88936 876529e668 删除 Project Supervisor 前端链路并将项目对话收敛到 DirectProject 与立项策划
- 删除 ProjectSupervisorView、SupervisorChatOnlyView、ProjectWorkspaceChatPane、AgentConversationOverlay、DeveloperProjectPanels、DeveloperRuntimePanels 与 features/agent-runtime/panels.tsx
- 删除 Supervisor 独立调试窗口:windows.rs 的 supervisor_chat_window_url / open_project_supervisor_chat_window、main.rs 的 invoke 注册、?supervisor-chat 与 ?agent-chat 前端入口、developer.json capability 和对应 Rust 用例
- 删除工作台壳的开发者 Agent 面板:DeveloperAgentPanel、useDeveloperAgentPanel、useDeveloperAgentState、developerAgentControls
- App.tsx 删除只服务退役面板的 state/ref/effect/handler(agentConversation*、文件/记忆/资产/画板/预览面板处理、commandLog、llmConfigStatus、editorBaseUrl、回放历史与 trace 面板状态、selectedAgent 等)以及由此产生的空分支,并把 requestRuntimeConfigOpen 接回本地 RuntimeConfigDialog
- 立项策划独立成模块:Design Agent 与 Planning V2 的容器和表现移到 view/project-development/planning/(PlanningChatView、PlanningUserInputCard、GddApprovalCard、DesignAgentSurface、PlanningLaneRuntimeStrip、planningLane、planningSessionV2、planningSessionContract)
- 改名到中性概念:ProjectSupervisorComponentProps→ProjectChatComponentProps、WorkspaceLauncherShellProps.ProjectSupervisor→ProjectChat、ProjectDevelopmentView.supervisor→chat、orchestrationMode→agentDockVisible、initialSupervisorMessageClaims→initialTurnClaims、ProjectManifestSnapshotSource 'supervisor'→'chat'、CSS project-supervisor-*/project-planning-*→project-chat-*
- 删除 PROJECT_SUPERVISOR_AGENT_ID 与 PROJECT_SUPERVISOR_PLAN_SOURCE 常量
- 工作台壳自己订阅 game-creator-manifest-invalidated,用 revision→清单→revision 配对读(source: asset-event)并入 currentProjectContext,且不重新检查项目目录;测试夹具改为按监听器集合广播该事件
- 删除只钉住退役界面的 appSurface 用例(旧调试窗口、开发者面板、Agent 对话浮层、运行历史面板)
- 同步文档:ADR、DirectProject 聊天模块抽离实施计划与里程碑、Provider 推理里程碑、策划会话 RuntimeV2、AI 游戏创作实施计划,并在 decision-log 新增 2026-09-19 条
- 删除随退役面板失去调用方的模块与导出:projectSummaryCommands.ts(1054 行旧 Supervisor 斜杠命令处理器)、AGENT_RUN_HISTORY_* 常量、agent-runtime/model.ts 与 project-summary/agentPresentation.ts 里的死导出,以及 agentRunTrace / memoryCommands / projectCommandPolicy 中无调用方的校验与解析函数
- 更新 scripts/check-config.mjs 与 scripts/check-native-shells.mjs 的窗口与命令守卫
2026-09-19 21:59:44 +08:00

433 lines
15 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,
chat: 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,
chat: 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');
});
});