Files
Genarrative/apps/ai-game-creator-shell/tests/resourceVersionSwitch.test.tsx
T
suzmii e088ede217 让「当前使用」的资源卡真的发光,并修掉版本高亮的两处既有缺陷
- .game-resource-card.is-current-version 的外向扩散层从 0 0 0 2px /14%(那是描边)改成 0 0 0 3px /32% + 0 0 20px /34% 的真发光,边框口径仍是 1px
- 新增 game-resource-card-current-version-breathe 关键帧与 ::after 光环(只呼吸 opacity,照本页 .ui-editor-status-attention 的既有写法):声明档位 38%,呼吸区间 0.58 ↔ 1,视觉上约 22% ↔ 38%
- prefers-reduced-motion 下关掉动画但保留可见档位(opacity 0.75),收敛动画不收敛可见性
- 缺陷①:.game-resource-card.is-relation-version-binding 原本只写 border-color,而卡片本体 border: 0,规则从未生效(「点击版本卡后高亮绑定资源」这条 PRD 语义一直看不见);改为真实的 1px 边框 + ring,并把该规则移到 .is-current-version 之前,让常驻光环在两者同时命中时仍然赢
- 缺陷②:当前版本规则与 hover/focus/selected 同权重且在后,导致当前版本卡几乎看不出被选中;新增 .is-current-version.is-selected / :hover / :focus-within / .is-dragging 变体,显式提权到 (0,3,0) 并带上选中态自己那层阴影
- 测试:新增 resourceCardCurrentVersionStyle.test.ts(外向扩散层档位、呼吸实现方式与关键帧区间、选中态变体权重更高、reduced-motion 下仍可见、版本聚焦规则不再是死规则)
- 测试:resourceVersionSwitch 补 data-used-by-current-version 的 DOM 断言(命中为 "true"、未命中不含该属性、切版本后跟着换手)
- 测试夹具修正:project-development.suite 的版本夹具把 slotId 从 'player' 改成恒等映射 'asset:asset-player'('historical' → 'asset:asset-removed'),否则「当前版本」判定永远不命中、照它写断言必成假守卫;并补一条自证断言钉住它真的命中
2026-09-11 16:42:58 +08:00

277 lines
8.6 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 {
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);
// 同一件事的 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);
});
});