Files
Genarrative/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts
T
suzmii b41b26cab8 修复资源画本进栏目只有少数卡片有位移:没有 First 帧的卡片改从那一摞起飞
- 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"那笔改动不是原因的证据、处理、变异验证与真机判据。
2026-09-12 18:56:43 +08:00

683 lines
24 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.
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 });
});
});