b41b26cab8
- apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts:begin() 顺带按 (栏目, 类型摞列号) 记下同一摞最深那张卡的 First 矩形当"堆锚点",play() 时没有 First 帧的卡片认领它当合成 First(仍是同一套 FLIP,不新增动画系统、不为动画多渲染节点);堆锚点只在本次转场有效(maybeFinish / settle / invalidate 清掉),转场中重基(已在跑动画)不认领锚点,起点仍是"此刻像素"。 - apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts:展开态(allOpen)的卡片补上真实类型摞列号,不再一律 0——否则第 2 摞之后的卡片会全从第 1 摞飞出来。 - apps/ai-game-creator-shell/src/view/project-development/index.tsx:卡片宿主补 data-resource-book-stack-column / data-resource-book-stack-index,把"哪一摞"的摞身份交给转场层。 - apps/ai-game-creator-shell/tests/resourceBookController.test.ts:新增 3 条用例——每一张卡都从自己那一摞位移出来(含第 0/1 摞分别认领各自锚点、总览已渲染的仍用自己 First)、转场中重基从此刻像素而不是被拉回摞上、转场收尾后挂载的卡片不从过期锚点飞出。 - apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts:新增"展开态每张卡带自己那一摞的列号"用例(同栏目两摞 ⇒ [0,0,1])。 - apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts:在既有总览/进栏目用例里钉住宿主上的摞身份属性(总览 3 张 = 下标 0,1,2;进栏目后 5 张 = 下标 0..4、同类型列号 0)。 - docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md:把资源画本转场一节更新为当前状态(堆锚点来源、锚点生命周期、展开态列号)。 - docs/project-memory/shared-memory/pitfalls.md:记录本次排障——现象、已用代码核实的原因(总览每摞只铺 3 张 ⇒ 其余卡片没有节点、没有 First 帧)、"钉住的标题栏移出 world"那笔改动不是原因的证据、处理、变异验证与真机判据。
683 lines
24 KiB
TypeScript
683 lines
24 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
|
||
import {
|
||
buildResourceBookAllLayout,
|
||
buildResourceBookScenePlan,
|
||
groupResourceBookStacks,
|
||
RESOURCE_BOOK_ALL_BAND_GAP,
|
||
RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
|
||
type ResourceBookAllBand,
|
||
resourceBookAllBandLocalPoint,
|
||
type ResourceBookAllLayout,
|
||
resourceBookOverviewCardLayout,
|
||
type ResourceBookOverviewRect,
|
||
resourceBookSceneCardKey,
|
||
selectResourceBookOverviewCards,
|
||
} from '../src/view/project-development/resourceBookLayout';
|
||
import {
|
||
RESOURCE_BOOK_ALL_TARGET,
|
||
type ResourceBookCategory,
|
||
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);
|
||
});
|
||
|
||
/**
|
||
* 展开态(子画布里的「所有资源」)与栏目页共用同一份计划:
|
||
* 每个真实栏目铺在自己的分带上,`all` 只出钉住的标题栏与全量计数。
|
||
*/
|
||
it('spreads every real category on its own band and keeps the all group as a titlebar', () => {
|
||
const sceneResource = resource('code-0', 'scene');
|
||
const allResources = [...resources, sceneResource];
|
||
const allPositions = new Map([
|
||
...positions,
|
||
[sceneResource.id, { x: 40, y: 20 }],
|
||
]);
|
||
const allCardSizes = new Map([
|
||
...cardSizes,
|
||
[sceneResource.id, { width: 180, height: 128 }],
|
||
]);
|
||
const allLayout = buildResourceBookAllLayout({
|
||
categoryOrder: ['unclassified', 'scene'],
|
||
resourcesByCategory: new Map<ResourceBookCategory, ProjectResource[]>([
|
||
['unclassified', resources],
|
||
['scene', [sceneResource]],
|
||
]),
|
||
positions: allPositions,
|
||
cardSizes: allCardSizes,
|
||
});
|
||
const plan = buildResourceBookScenePlan({
|
||
...base,
|
||
visibleCategoryOrder: [RESOURCE_BOOK_ALL_TARGET, 'unclassified', 'scene'],
|
||
resourcesByCategory: new Map<ResourceBookTarget, ProjectResource[]>([
|
||
[RESOURCE_BOOK_ALL_TARGET, allResources],
|
||
['unclassified', resources],
|
||
['scene', [sceneResource]],
|
||
]),
|
||
overviewRects: new Map<ResourceBookTarget, ResourceBookOverviewRect>(),
|
||
positions: allPositions,
|
||
cardSizes: allCardSizes,
|
||
visibleResourceIds: new Set(allResources.map((item) => item.id)),
|
||
state: {
|
||
view: 'child',
|
||
category: RESOURCE_BOOK_ALL_TARGET,
|
||
phase: 'idle',
|
||
token: 1,
|
||
},
|
||
allLayout,
|
||
});
|
||
|
||
const allGroup = plan.find(
|
||
(group) => group.category === RESOURCE_BOOK_ALL_TARGET,
|
||
)!;
|
||
// `all` 一张卡都不挂:卡片由各真实栏目承载,否则同一张卡会挂两份、FLIP 键互相顶掉。
|
||
expect(allGroup.cards).toEqual([]);
|
||
expect(allGroup.titlebar).toBe(true);
|
||
expect(allGroup.titlebarActive).toBe(true);
|
||
expect(allGroup.titlebarOpacity).toBe(1);
|
||
expect(allGroup.resourceCount).toBe(allResources.length);
|
||
|
||
const artGroup = plan.find((group) => group.category === 'unclassified')!;
|
||
const band = allLayout.bandByCategory.get('unclassified')!;
|
||
expect(artGroup.cards).toHaveLength(resources.length);
|
||
expect(artGroup.cards.every((card) => card.presentation === 'child')).toBe(
|
||
true,
|
||
);
|
||
// 展开态的真实栏目**不出标题栏**:那一页是一张平铺画布,标题栏只有 `all` 那一条。
|
||
// 曾经"每条带配一条标题栏"时,没有可见资源的栏目拿不到带矩形,标题栏会集体回落到
|
||
// 世界原点叠成一摞(用户报的正是这个)。
|
||
expect(artGroup.titlebar).toBe(false);
|
||
expect(artGroup.titlebarActive).toBe(false);
|
||
// 画出来的是"栏目内局部坐标 + 带原点",拖动起点与它同系(提交时再减回带原点)。
|
||
const first = artGroup.cards[0]!;
|
||
expect(first.layout.x).toBe(positions.get('art-0')!.x + band.originX);
|
||
expect(first.layout.y).toBe(positions.get('art-0')!.y + band.originY);
|
||
expect(first.layout.dragX).toBe(first.layout.x);
|
||
expect(first.layout.dragY).toBe(first.layout.y);
|
||
|
||
// 各栏目带之间不共享原点:不同栏目的卡不会被画到同一个位置。
|
||
const sceneCard = plan
|
||
.find((group) => group.category === 'scene')!
|
||
.cards.at(0)!;
|
||
expect(sceneCard.layout.y).toBe(
|
||
allPositions.get(sceneResource.id)!.y +
|
||
allLayout.bandByCategory.get('scene')!.originY,
|
||
);
|
||
expect(sceneCard.layout.y).not.toBe(first.layout.y);
|
||
|
||
// 展开态里每张资源在整份计划里只有一个宿主(总览态不是这样:`all` 那一格另有预览摞)。
|
||
const keys = plan.flatMap((group) => group.cards.map((card) => card.key));
|
||
expect(keys).toHaveLength(new Set(keys).size);
|
||
expect(keys).toHaveLength(allResources.length);
|
||
});
|
||
|
||
/**
|
||
* 展开态(进「所有资源」)也要把每张卡认回"它在总览里属于哪一摞"。
|
||
*
|
||
* 总览每摞只铺 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张,展开态铺满全部卡片:没有 First
|
||
* 帧的卡片由转场层按 (栏目, 列号) 认领那一摞的起飞点(见 `resourceBookController` 的堆锚点)。
|
||
* 列号一律写 0 的话,第 2 摞之后的卡片会全都从第 1 摞飞出来。
|
||
*/
|
||
it('keeps every expanded card on its own overview pile column', () => {
|
||
const svg = {
|
||
...resource('svg-0'),
|
||
path: 'assets/svg-0.svg',
|
||
mediaType: 'image/svg+xml',
|
||
};
|
||
// 同一个栏目里两摞:'图片' 一摞(2 张)+ 'SVG' 一摞(1 张)。
|
||
const mixed = [resources[0]!, resources[1]!, svg];
|
||
const mixedPositions = new Map([
|
||
[resources[0]!.id, { x: 0, y: 0 }],
|
||
[resources[1]!.id, { x: 200, y: 0 }],
|
||
[svg.id, { x: 0, y: 300 }],
|
||
]);
|
||
const mixedCardSizes = new Map(
|
||
mixed.map((item) => [item.id, { width: 180, height: 128 }]),
|
||
);
|
||
const allLayout = buildResourceBookAllLayout({
|
||
categoryOrder: ['unclassified'],
|
||
resourcesByCategory: new Map<ResourceBookCategory, ProjectResource[]>([
|
||
['unclassified', mixed],
|
||
]),
|
||
positions: mixedPositions,
|
||
cardSizes: mixedCardSizes,
|
||
});
|
||
const plan = buildResourceBookScenePlan({
|
||
...base,
|
||
visibleCategoryOrder: [RESOURCE_BOOK_ALL_TARGET, 'unclassified'],
|
||
resourcesByCategory: new Map<ResourceBookTarget, ProjectResource[]>([
|
||
[RESOURCE_BOOK_ALL_TARGET, mixed],
|
||
['unclassified', mixed],
|
||
]),
|
||
overviewRects: new Map<ResourceBookTarget, ResourceBookOverviewRect>(),
|
||
positions: mixedPositions,
|
||
cardSizes: mixedCardSizes,
|
||
visibleResourceIds: new Set(mixed.map((item) => item.id)),
|
||
state: {
|
||
view: 'child',
|
||
category: RESOURCE_BOOK_ALL_TARGET,
|
||
phase: 'idle',
|
||
token: 1,
|
||
},
|
||
allLayout,
|
||
});
|
||
|
||
const cards = plan.find((group) => group.category === 'unclassified')!.cards;
|
||
expect(cards.map((card) => card.resource.id)).toEqual(
|
||
mixed.map((item) => item.id),
|
||
);
|
||
expect(cards.map((card) => card.stackColumn)).toEqual([0, 0, 1]);
|
||
});
|
||
|
||
it('fades the leaving all-resources cards from their band positions', () => {
|
||
const allLayout = buildResourceBookAllLayout({
|
||
categoryOrder: ['unclassified'],
|
||
resourcesByCategory: new Map<ResourceBookCategory, ProjectResource[]>([
|
||
['unclassified', resources],
|
||
]),
|
||
positions,
|
||
cardSizes,
|
||
});
|
||
const plan = buildResourceBookScenePlan({
|
||
...base,
|
||
visibleCategoryOrder: [RESOURCE_BOOK_ALL_TARGET, 'unclassified'],
|
||
resourcesByCategory: new Map<ResourceBookTarget, ProjectResource[]>([
|
||
[RESOURCE_BOOK_ALL_TARGET, resources],
|
||
['unclassified', resources],
|
||
]),
|
||
overviewRects: new Map<ResourceBookTarget, ResourceBookOverviewRect>(),
|
||
state: {
|
||
view: 'main',
|
||
category: null,
|
||
phase: 'returning-main',
|
||
token: 2,
|
||
},
|
||
// 返回总览时要按"刚离开的那段子画布"淡出:展开态是分带位置,栏目页是局部坐标。
|
||
exitingCategory: RESOURCE_BOOK_ALL_TARGET,
|
||
allLayout,
|
||
});
|
||
|
||
const band = allLayout.bandByCategory.get('unclassified')!;
|
||
const artGroup = plan.find((group) => group.category === 'unclassified')!;
|
||
const exiting = artGroup.cards.filter(
|
||
(card) => card.presentation === 'exiting',
|
||
);
|
||
expect(exiting).toHaveLength(1);
|
||
expect(exiting[0]!.layout.x).toBe(positions.get('art-3')!.x + band.originX);
|
||
expect(exiting[0]!.layout.y).toBe(positions.get('art-3')!.y + band.originY);
|
||
// 「所有资源」组这一态只出预览摞:它不接 child / exiting 卡,否则同一张卡会在两组里
|
||
// 各挂一份(DOM 查询与 FLIP 键都会撞车)。
|
||
expect(
|
||
plan
|
||
.find((group) => group.category === RESOURCE_BOOK_ALL_TARGET)!
|
||
.cards.every((card) => card.presentation === 'overview'),
|
||
).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('buildResourceBookAllLayout', () => {
|
||
const layout = buildResourceBookAllLayout({
|
||
categoryOrder: ['ui-interaction', 'character', 'audio'],
|
||
resourcesByCategory: new Map<ResourceBookCategory, ProjectResource[]>([
|
||
['ui-interaction', [resource('ui-0', 'ui-interaction')]],
|
||
['character', []],
|
||
['audio', [resource('bgm-0', 'audio'), resource('bgm-1', 'audio')]],
|
||
]),
|
||
positions: new Map([
|
||
['ui-0', { x: 0, y: 0 }],
|
||
['bgm-0', { x: 0, y: 0 }],
|
||
['bgm-1', { x: 200, y: 0 }],
|
||
]),
|
||
cardSizes: new Map([
|
||
['ui-0', { width: 180, height: 128 }],
|
||
['bgm-0', { width: 180, height: 128 }],
|
||
['bgm-1', { width: 180, height: 128 }],
|
||
]),
|
||
});
|
||
|
||
it('keeps the overview category order and drops the categories with nothing to show', () => {
|
||
expect(layout.bands.map((band) => band.category)).toEqual([
|
||
'ui-interaction',
|
||
'audio',
|
||
]);
|
||
});
|
||
|
||
it('stacks the bands downward so no two categories share one origin', () => {
|
||
const [first, second] = layout.bands;
|
||
// 带里只有卡片,所以带宽从原点直接长出 extent 高;两带之间隔一条固定的留白:
|
||
// 否则拖上面一带的卡会压到下面一带的卡上。
|
||
expect(first!.originY).toBe(0);
|
||
expect(first!.height).toBeGreaterThan(0);
|
||
expect(second!.originY).toBe(
|
||
first!.originY + first!.height + RESOURCE_BOOK_ALL_BAND_GAP,
|
||
);
|
||
expect(second!.originY).toBeGreaterThan(first!.originY);
|
||
});
|
||
|
||
it('covers every card box in its bounds', () => {
|
||
const last = layout.bands.at(-1)!;
|
||
expect(layout.bounds.x).toBeLessThanOrEqual(0);
|
||
expect(layout.bounds.y).toBeLessThanOrEqual(0);
|
||
expect(layout.bounds.width).toBeGreaterThanOrEqual(last.width);
|
||
expect(layout.bounds.height).toBeGreaterThanOrEqual(
|
||
last.originY + last.height,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('resourceBookAllBandLocalPoint', () => {
|
||
const band: ResourceBookAllBand = {
|
||
category: 'document',
|
||
originX: 40,
|
||
originY: 100,
|
||
width: 620,
|
||
height: 200,
|
||
};
|
||
const layout: ResourceBookAllLayout = {
|
||
bands: [band],
|
||
bandByCategory: new Map([['document', band]]),
|
||
bounds: { x: 0, y: 0, width: 620, height: 300 },
|
||
};
|
||
|
||
/**
|
||
* 这是本方案唯一会落到布局 sidecar 上的换算:展开态画的是"局部坐标 + 带原点",
|
||
* 落盘只认栏目内局部坐标。换算方向写错 = 把卡写到别处,所以单独钉住。
|
||
*/
|
||
it('subtracts the band origin so the world point lands back on the column-local point', () => {
|
||
expect(
|
||
resourceBookAllBandLocalPoint({
|
||
layout,
|
||
category: 'document',
|
||
x: 140,
|
||
y: 260,
|
||
}),
|
||
).toEqual({ x: 100, y: 160 });
|
||
});
|
||
|
||
it('passes the point through for the column page and for a category without a band', () => {
|
||
// 栏目页没有分带(`layout` 传 null),减 0:与既有"局部坐标直接落盘"逐字等价。
|
||
expect(
|
||
resourceBookAllBandLocalPoint({
|
||
layout: null,
|
||
category: 'document',
|
||
x: 140,
|
||
y: 260,
|
||
}),
|
||
).toEqual({ x: 140, y: 260 });
|
||
expect(
|
||
resourceBookAllBandLocalPoint({
|
||
layout,
|
||
category: 'audio',
|
||
x: 7,
|
||
y: 9,
|
||
}),
|
||
).toEqual({ x: 7, y: 9 });
|
||
});
|
||
});
|