d1581892d7
- 素材重命名(跨范围契约):`rename_local_project_asset` 现在要求 `expectedProjectId` + `expectedProjectRevision`,前端按删除 / 分类保存同一套 CAS 口径补齐——先 `get_local_game_project_revision` 读当前 revision 再连同项目身份提交,不用可能过期的本地缓存值 - 失败文案统一:新增 `projectAssetCommandErrorMessage`,把 `project-identity-conflict` / `project-revision-conflict` 翻成用户可读中文,重命名 / 删除 / 分类保存三条链路共用一份映射,其余错误原样透出 - index.tsx:资源面板下载按 `resolveDownloadablePanelEntries` 过滤,版本卡这类合成 path 的条目不再进入落盘链路 - index.tsx:`characterAnimationPanel` 纳入画布浮层开关判据(抽 `resolveResourceCanvasFloatingPanelOpen`),「生成动画」面板与快速编辑 / 信息浮层共用同一条「点外部 / Esc 关闭」规则 - index.tsx:去掉 `ResourceBookScene` 上重复的 `onWheel`,滚轮只由 manager 上的原生 `passive:false` 监听处理,平移 / 缩放不再被翻倍 - index.tsx:快速编辑的源资源在层 id 命中不到时回落到已正规化的 `asset:<id>`,重试不再静默什么都不做;取不到时给出可见失败 - ResourceCanvasPanelView.tsx:下载按钮按可下载条数判可用、不再在上传中显示下载转圈;上传中禁掉 × / Esc / 遮罩三条关闭路径;预览占位文案抽 helper - GameRunVersionPicker.tsx:portal 菜单在滚动 / 缩放后重算位置;外部点击判定沿用共享 `useImageCanvasFloatingOptionDismiss` + `menuRef` 边界(review 该条已修,保留现状并补位置用例) - ResourceCanvasGenerationPanelView.tsx:`submitting` 改 `finally` 收回,成功路径不再永久锁住面板 - ResourceAssetDeleteDialog.tsx / ResourceRenameDialog.tsx / ResourceClassificationPanel.tsx:在飞时 `closeOnEscape` / `closeOnBackdrop` 与头部 × 一起挡住 - resourceEditModel.ts / resourceCanvasToolbarModel.ts / useProjectResourceCardPreviews.ts / projectResourceLiveUpdateModel.ts:抽 `resourceBaseName`、`canonicalProjectedResourceMediaType` 参数收窄去掉强转、删未使用参数、补「IO 失败会 reject」的签名说明 - resourceCanvasHistoryModel.ts:`resourceCanvasSnapshotsEqual` 不再比较不可恢复的 `manuallyPlaced`,消除「压进历史但撤销是空操作」 - resourceCanvasSectionMapping.ts:删掉从未接线、且注释与实现相矛盾的 `LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK` - resourceCanvasChrome.css:`.game-resource-panel-grid` 补 `flex: 1 1 auto; min-height: 0`,卡片网格成为真正的滚动区 - 用例:重命名 CAS 载荷与读序、CAS 拒绝文案、版本卡下载门禁、上传中关闭路径、浮层判据、菜单跟随滚动、分区映射
433 lines
15 KiB
TypeScript
433 lines
15 KiB
TypeScript
// @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);
|
||
});
|
||
|
||
/**
|
||
* 菜单位置按触发按钮的实时 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');
|
||
});
|
||
});
|