Files
Genarrative/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts
T
suzmii 4fb0de2c94 C3 资源画本转场改为真实元素 FLIP
- resourceBookController 重写为真实元素 FLIP:begin 在提交前记录 First,play 测 Last 后给同一节点加反向 transform,sync 按布局签名做可中断重基
- 转场进行中重基用剩余时长(下限 80ms),空闲布局变化用完整 420ms,预览图尺寸晚到的二次布局变化不再瞬移
- 宿主节点几何基准改存 world 局部坐标,平移和缩放不会让上一次的干净矩形过期
- 不可见节点钉在 First 淡出、挂载节点淡入;搜索/排序导致的卸载保留一帧淡出;返回总览时源栏目未回到摞上的卡片按子画布布局淡出
- 新增 buildResourceBookScenePlan 渲染计划,渲染、转场签名与淡出集合共用同一份数据
- 删除 ResourceBookTransitionLayer 与克隆快照逻辑,清理 styles.css 的转场层和 data-book-motion 隐藏规则
- 重写 resourceBookController.test.ts,补 resourceBookLayout/resourceBookModel 用例与 appSurface 同节点断言
- 同步技术方案转场段与 pitfalls 经验条目
2026-09-09 20:24:10 +08:00

275 lines
8.2 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
buildResourceBookScenePlan,
groupResourceBookStacks,
RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
resourceBookOverviewCardLayout,
resourceBookSceneCardKey,
selectResourceBookOverviewCards,
} from '../src/view/project-development/resourceBookLayout';
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
function resource(id: string): ProjectResource {
return {
id,
category: 'art',
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: ['art'] as const,
resourcesByCategory: new Map([['art', resources]]),
overviewRects: new Map([['art', rect]]),
positions,
cardSizes,
visibleResourceIds: new Set(resources.map((item) => item.id)),
categoryViewCenters: new Map([['art', { 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: ['art', 'code'],
resourcesByCategory: new Map([
['art', resources],
['code', [resource('code-0')]],
]),
overviewRects: new Map([
['art', rect],
['code', { ...rect, left: 500 }],
]),
state: { view: 'child', category: 'art', phase: 'entering', token: 1 },
});
const art = plan.find((group) => group.category === 'art')!;
const code = plan.find((group) => group.category === 'code')!;
expect(art.cards.every((card) => card.presentation === 'child')).toBe(true);
expect(art.cards).toHaveLength(4);
expect(art.titlebarActive).toBe(true);
// 其他栏目留在 DOM 里淡出,而不是直接卸载。
expect(code.cards.every((card) => card.presentation === 'overview')).toBe(
true,
);
expect(code.titlebarActive).toBe(false);
expect(code.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: 'art',
});
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: 'art',
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: 'art',
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);
});
});