Files
Genarrative/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts
T
suzmii ea9668e4a9 资源总览「所有资源」卡补上资源预览
- 现象与归因:「所有资源」卡的预览区一直是空的,原因是我在 1cc9a2381 里加的闸 `!isResourceBookAllTarget(category)`(当时为避开"同一张资源卡挂两份")让它一张卡片计划都不生成;预览卡从来只由画本场景注入,占位摞本身不含卡片
- 改为真正生成卡片计划:喂进去的仍是全量投影(与标题栏计数徽标同一份口径),但当成一摞交给栏目卡同一个选取函数 `selectResourceBookOverviewCards`——没有视图中心时按投影顺序取前 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT`(=3) 张,与栏目卡每摞上限同口径;不分类型列,避免 66 项一口气铺出十几列
- 预览用同一个 `renderCard` 与同一套 `resourceBookOverviewCardLayout` 几何渲染,唯一差别是宿主类名换成 `.game-resource-book-preview-card`:转场层按 `.game-resource-book-scene-card` 收集 FLIP 节点并以 `card:${resourceId}` 为键,同一张卡两个宿主会让两条记录互相顶掉,因此预览宿主不进收集范围(`resourceBookController` 一行未改)
- 预览宿主带 `aria-hidden` + `pointer-events: none`:无障碍树与 `getByRole` 里每张资源卡仍然只有栏目卡那一份,不会重复命中;点预览区仍走进「所有资源」页,栏目卡预览卡仍可选中
- 转场淡出集合(`resourceBookPlanKeysRef` 那段)跳过「所有资源」组,避免预览节点被当成"离开视图的卡片"按子画布坐标重新贴到画布别处
- `ResourceBookThumbnail`:该卡正文的占位摞从最多 3 摞改为对齐真实摞数的 1 摞,卡片高度回到与栏目卡同一条口径
- `styles.css`:把 `.game-resource-book-preview-card` 并进 `.game-resource-book-scene-card` 的两条既有规则(宿主几何、主态卡片圆角/阴影)与 `prefers-reduced-motion`;没有新写一套预览样式
- 断言(`project-development.suite.ts`):预览卡的 `data-resource-card-id` 序列恰等于全量投影前 3 个、条数 == `min(3, 计数徽标)`、预览里是完整卡片(视觉体 + 选中按钮)、宿主在 `aria-hidden` 子树里、宿主几何规则与栏目卡共用同一条
- 断言(新增用例):空项目下沿用既有空态「暂无资源」、预览节点 0 个、计数 `0 项`、入口行仍在,卡片不破版
- 单测(`resourceBookLayout.test.ts`):把已被新行为取代的旧断言(`all` 组不出卡片、全计划无重复卡片键)删掉,换成新合同(预览 = 全量投影前 3 张、只铺一列、各栏目摞不变),并补空投影 → 0 张
- 变异验证(两条都跑过并已还原):① 喂进去的资源改回空数组 → `AssertionError: expected +0 to be 4`;② 恢复旧闸(计数仍正确、预览为 0)→ `AssertionError: expected [] to deeply equal [ Array(3) ]`
2026-09-11 15:37:17 +08:00

406 lines
13 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
buildResourceBookAllResourcesSections,
buildResourceBookScenePlan,
groupResourceBookStacks,
RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
resourceBookOverviewCardLayout,
type ResourceBookOverviewRect,
resourceBookSceneCardKey,
selectResourceBookOverviewCards,
} from '../src/view/project-development/resourceBookLayout';
import {
RESOURCE_BOOK_ALL_TARGET,
type ResourceBookTarget,
} from '../src/view/project-development/resourceBookModel';
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
function resource(
id: string,
category: ProjectResource['category'] = 'unclassified',
): ProjectResource {
return {
id,
category,
subtype: 'image',
label: id,
path: `assets/${id}.png`,
mediaType: 'image/png',
sourceLabel: '测试',
taskTitle: null,
manifestAssetId: id,
producerTaskId: null,
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
};
}
const rect = { left: 100, top: 200, width: 280, height: 210 };
describe('resourceBookOverviewCardLayout', () => {
it('keeps the stack tilt bounded however many resources share one type', () => {
const rotations = [0, 1, 2, 3, 12, 59].map(
(stackIndex) =>
resourceBookOverviewCardLayout({ rect, stackIndex, stackColumn: 0 })
.rotation,
);
for (const rotation of rotations) {
expect(Math.abs(rotation)).toBeLessThanOrEqual(3);
}
expect(rotations[0]).toBe(0.5);
expect(rotations.at(-1)).toBe(rotations[2]);
});
it('stops offsetting deeper cards once the visible stack depth is reached', () => {
const positions = [0, 1, 2, 3, 59].map(
(stackIndex) =>
resourceBookOverviewCardLayout({ rect, stackIndex, stackColumn: 0 }).y,
);
expect(positions[0]).toBe(rect.top + 58);
expect(positions[1]).toBe(rect.top + 63);
expect(positions[2]).toBe(rect.top + 68);
expect(positions.at(-1)).toBe(positions[2]);
});
it('limits how many cards the overview renders per type stack', () => {
expect(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT).toBe(3);
});
describe('selectResourceBookOverviewCards', () => {
const cards = ['a', 'b', 'c', 'd'].map(resource);
const positions = new Map([
['a', { x: 0, y: 0 }],
['b', { x: 1_000, y: 0 }],
['c', { x: 2_000, y: 0 }],
['d', { x: 3_000, y: 0 }],
]);
const cardSizes = new Map<string, { width: number; height: number }>();
it('keeps the first visible cards when the view center is unknown', () => {
expect(
selectResourceBookOverviewCards({
resources: cards,
visibleResourceIds: new Set(['a', 'b', 'c', 'd']),
positions,
cardSizes,
viewCenter: undefined,
}).map((item) => item.id),
).toEqual(['a', 'b', 'c']);
});
it('prefers the cards nearest the current view center', () => {
expect(
selectResourceBookOverviewCards({
resources: cards,
visibleResourceIds: new Set(['a', 'b', 'c', 'd']),
positions,
cardSizes,
viewCenter: { x: 3_000, y: 64 },
}).map((item) => item.id),
).toEqual(['d', 'c', 'b']);
});
it('never surfaces cards hidden by the current filter', () => {
expect(
selectResourceBookOverviewCards({
resources: cards,
visibleResourceIds: new Set(['a', 'c', 'd']),
positions,
cardSizes,
viewCenter: { x: 1_000, y: 64 },
}).map((item) => item.id),
).toEqual(['a', 'c', 'd']);
});
});
});
describe('groupResourceBookStacks', () => {
it('keeps resources of one type in a single stack', () => {
const stacks = groupResourceBookStacks([
resource('overview-art-0'),
resource('overview-art-1'),
resource('overview-art-2'),
resource('overview-art-3'),
]);
expect(stacks).toHaveLength(1);
expect(stacks[0]?.[1]).toHaveLength(4);
});
});
describe('buildResourceBookScenePlan', () => {
const resources = ['art-0', 'art-1', 'art-2', 'art-3'].map(resource);
const positions = new Map(
resources.map((item, index) => [item.id, { x: index * 200, y: 0 }]),
);
const cardSizes = new Map(
resources.map((item) => [item.id, { width: 180, height: 128 }]),
);
const base = {
visibleCategoryOrder: ['unclassified'] as const,
resourcesByCategory: new Map([['unclassified', resources]]),
overviewRects: new Map([['unclassified', rect]]),
positions,
cardSizes,
visibleResourceIds: new Set(resources.map((item) => item.id)),
categoryViewCenters: new Map([['unclassified', { x: 0, y: 64 }]]),
cardsReady: true,
};
it('renders the bounded overview stack on the steady main canvas', () => {
const plan = buildResourceBookScenePlan({
...base,
state: {
view: 'main',
category: null,
phase: 'idle',
token: 0,
},
});
expect(plan).toHaveLength(1);
expect(plan[0]!.cards).toHaveLength(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT);
expect(plan[0]!.cards[0]!.presentation).toBe('overview');
expect(plan[0]!.cards[0]!.stackCount).toBe(
RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
);
expect(plan[0]!.cards[0]!.layout).toMatchObject({
x: rect.left + 18,
y: rect.top + 58,
width: 92,
height: 64,
});
expect(plan[0]!.titlebarActive).toBe(false);
expect(plan[0]!.titlebarOpacity).toBe(1);
expect(plan[0]!.resourceCount).toBe(4);
});
it('keeps other categories mounted for a fade-out while entering', () => {
const plan = buildResourceBookScenePlan({
...base,
visibleCategoryOrder: ['unclassified', 'scene'],
resourcesByCategory: new Map([
['unclassified', resources],
['scene', [resource('code-0')]],
]),
overviewRects: new Map([
['unclassified', rect],
['scene', { ...rect, left: 500 }],
]),
state: {
view: 'child',
category: 'unclassified',
phase: 'entering',
token: 1,
},
});
const artGroup = plan.find((group) => group.category === 'unclassified')!;
const codeGroup = plan.find((group) => group.category === 'scene')!;
expect(artGroup.cards.every((card) => card.presentation === 'child')).toBe(
true,
);
expect(artGroup.cards).toHaveLength(4);
expect(artGroup.titlebarActive).toBe(true);
// 其他栏目留在 DOM 里淡出,而不是直接卸载。
expect(
codeGroup.cards.every((card) => card.presentation === 'overview'),
).toBe(true);
expect(codeGroup.titlebarActive).toBe(false);
expect(codeGroup.titlebarOpacity).toBe(0);
});
it('marks cards that do not return to the stack as exiting', () => {
const plan = buildResourceBookScenePlan({
...base,
state: {
view: 'main',
category: null,
phase: 'returning-main',
token: 2,
},
exitingCategory: 'unclassified',
});
const cards = plan[0]!.cards;
expect(
cards.filter((card) => card.presentation === 'overview'),
).toHaveLength(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT);
const exiting = cards.filter((card) => card.presentation === 'exiting');
expect(exiting).toHaveLength(1);
// 淡出节点按子画布布局渲染,位置由 FLIP 钉在 First。
expect(exiting[0]!.layout.x).toBe(positions.get('art-3')!.x);
});
it('keeps search-driven removals mounted without duplicating keys', () => {
const plan = buildResourceBookScenePlan({
...base,
state: { view: 'main', category: null, phase: 'idle', token: 0 },
exitingCards: [
{
key: resourceBookSceneCardKey('art-3'),
category: 'unclassified',
resource: resources[3]!,
presentation: 'exiting',
stackIndex: 0,
stackColumn: 0,
stackCount: 1,
layout: {
x: 0,
y: 0,
width: 180,
height: 128,
dragX: 0,
dragY: 0,
rotation: 0,
},
},
{
key: resourceBookSceneCardKey('art-0'),
category: 'unclassified',
resource: resources[0]!,
presentation: 'exiting',
stackIndex: 0,
stackColumn: 0,
stackCount: 1,
layout: {
x: 0,
y: 0,
width: 180,
height: 128,
dragX: 0,
dragY: 0,
rotation: 0,
},
},
],
});
const keys = plan[0]!.cards.map((card) => card.key);
expect(keys).toContain(resourceBookSceneCardKey('art-3'));
expect(
keys.filter((key) => key === resourceBookSceneCardKey('art-0')),
).toHaveLength(1);
});
/**
* 资源总览第 0 张「所有资源」卡走同一条渲染管线:标题栏的计数是全量资源数,
* 预览摞则是全量投影按稳定顺序的前 N 张,且只铺一列(跨类型的一摞)。
*
* 它是同一张资源卡的第二份宿主(各栏目摞里仍各有一份),所以这里既钉预览的来源与
* 上限,也钉各栏目摞没有被改动。
*/
it('puts the first overview-stack slice of the full projection on the all-resources tile', () => {
const documentResource = resource('design-0', 'document');
const allResources = [...resources, documentResource];
const allPositions = new Map([
...positions,
[documentResource.id, { x: 900, y: 0 }],
]);
const allCardSizes = new Map([
...cardSizes,
[documentResource.id, { width: 180, height: 128 }],
]);
const plan = buildResourceBookScenePlan({
...base,
visibleCategoryOrder: [
RESOURCE_BOOK_ALL_TARGET,
'unclassified',
'document',
],
resourcesByCategory: new Map<ResourceBookTarget, ProjectResource[]>([
[RESOURCE_BOOK_ALL_TARGET, allResources],
['unclassified', resources],
['document', [documentResource]],
]),
overviewRects: new Map<ResourceBookTarget, ResourceBookOverviewRect>([
[RESOURCE_BOOK_ALL_TARGET, { ...rect, left: 0, top: 0 }],
['unclassified', rect],
['document', { ...rect, left: 500 }],
]),
positions: allPositions,
cardSizes: allCardSizes,
visibleResourceIds: new Set(allResources.map((item) => item.id)),
categoryViewCenters: new Map([
['unclassified', { x: 0, y: 64 }],
['document', { x: 900, y: 64 }],
]),
state: { view: 'main', category: null, phase: 'idle', token: 0 },
});
const allGroup = plan.find(
(group) => group.category === RESOURCE_BOOK_ALL_TARGET,
)!;
expect(allGroup.resourceCount).toBe(allResources.length);
expect(allGroup.titlebarActive).toBe(false);
expect(allGroup.titlebarOpacity).toBe(1);
expect(allGroup.cards.map((card) => card.resource.id)).toEqual(
allResources
.slice(0, RESOURCE_BOOK_OVERVIEW_STACK_LIMIT)
.map((item) => item.id),
);
// 跨类型也只铺一列:不然 66 项会一口气铺出十几列。
expect(
Array.from(new Set(allGroup.cards.map((card) => card.stackColumn))),
).toEqual([0]);
expect(
allGroup.cards.every((card) => card.presentation === 'overview'),
).toBe(true);
// 各栏目自己的摞照旧:预览是叠加的一份,不改其它栏目卡。
expect(
plan
.find((group) => group.category === 'document')!
.cards.map((card) => card.resource.id),
).toEqual(['design-0']);
expect(
plan.find((group) => group.category === 'unclassified')!.cards.length,
).toBeGreaterThan(0);
});
it('keeps the all-resources tile preview empty for an empty projection', () => {
const plan = buildResourceBookScenePlan({
...base,
visibleCategoryOrder: [RESOURCE_BOOK_ALL_TARGET],
resourcesByCategory: new Map<ResourceBookTarget, ProjectResource[]>([
[RESOURCE_BOOK_ALL_TARGET, []],
]),
overviewRects: new Map<ResourceBookTarget, ResourceBookOverviewRect>([
[RESOURCE_BOOK_ALL_TARGET, rect],
]),
visibleResourceIds: new Set<string>(),
categoryViewCenters: new Map(),
state: { view: 'main', category: null, phase: 'idle', token: 0 },
});
expect(plan).toHaveLength(1);
expect(plan[0]!.resourceCount).toBe(0);
expect(plan[0]!.cards).toHaveLength(0);
});
});
describe('buildResourceBookAllResourcesSections', () => {
it('keeps the overview section order and drops empty sections', () => {
const sections = buildResourceBookAllResourcesSections({
categoryOrder: ['ui-interaction', 'character', 'audio'],
resourcesByCategory: new Map([
['ui-interaction', [resource('ui-0', 'ui-interaction')]],
['character', []],
['audio', [resource('bgm-0', 'audio'), resource('bgm-1', 'audio')]],
]),
});
expect(
sections.map((section) => [section.category, section.resources.length]),
).toEqual([
['ui-interaction', 1],
['audio', 2],
]);
});
});