Files
Genarrative/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts
T
suzmii 30648e6b93 画布修复:重算意图各占队列槽、撤销不跨排序模式、整理判据与落盘同源
- 显式整理与「关系图首次就绪」各占一个队列槽,系统重算不再改写用户已按下的那笔整理
- 资源签名同步覆盖排队中的每一笔资源意图,先落盘的那笔不会把后一笔留成过期签名
- 撤销栈按「项目 + 排序模式」隔离:切模式后不会把另一份 sidecar 的坐标写过去
- 整理的「有没有变化」判据与落盘同源,目标栏目还有在途/排队手动落点时也算一次真实变化
- 删除本次改动退役且已无调用方的 resourceBookAllBandLocalPoint 与布局 dragX/dragY
- 补回归:排队中整理不被覆盖、切模式撤销不跨写、在途拖动后整理照常生效、分类变更后拖动按新栏目落盘
2026-09-17 18:18:32 +08:00

1079 lines
37 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
buildResourceBookAllLayout,
buildResourceBookScenePlan,
groupResourceBookStacks,
resolveResourceBookAllLayout,
RESOURCE_BOOK_ALL_BAND_GAP,
RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
type ResourceBookAllLayout,
resourceBookAllLayoutKey,
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 {
createEmptyResourceCanvasLayout,
reconcileResourceCanvasLayout,
RESOURCE_CANVAS_SECTION_ORDER,
type ResourceCanvasCardSize,
resourceCanvasImageCardSize,
type ResourceCanvasItem,
} from '../src/view/project-development/resourceCanvasLayoutModel';
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,
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,
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);
// 画出来的是"栏目内局部坐标 + 带原点":拖动按同一份世界位移换算成栏目内局部坐标落盘,
// 所以画出来的世界坐标必须等于"sidecar 里的局部坐标 + 带原点"。
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);
// 各栏目带之间不共享原点:不同栏目的卡不会被画到同一个位置。
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,
);
});
});
/**
* 「所有资源」展开态里文档卡与图片卡一开始自动重叠的回归夹具(PR #316 反馈)。
*
* 场景与反馈里的项目同形:12 份 Agent 文本回执落在「文档」栏(180×128 的宽卡),
* 12 张 1:1 小图落在「待归类」栏(探测出像素尺寸后是 180×180 的高卡)。两者尺寸不同,
* 分带几何必须按各自真实尺寸算,且必须在坐标到位后重算 —— 这两件事分别由本组用例钉住。
*/
function documentResource(index: number): ProjectResource {
return {
id: `agent-result:design:run-${index}`,
category: 'document',
subtype: 'agent-result',
label: `测试文档 0${index}`,
path: `专业 Agent 文本回执 · ${index}`,
mediaType: 'Agent 历史文本回执',
sourceLabel: '历史成果',
taskTitle: null,
manifestAssetId: null,
producerTaskId: null,
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
content: `测试文档 ${index} 内容`,
};
}
function imageAssetResource(index: number): ProjectResource {
return {
...resource(`asset:img-0${index}`, 'unclassified'),
label: `img-0${index}.png`,
path: `assets/test-kit/images/img-0${index}.png`,
manifestAssetId: `img-0${index}`,
assetCategory: 'unclassified',
assetTags: [],
};
}
function canvasItemOf(resourceItem: ProjectResource): ResourceCanvasItem {
return {
id: resourceItem.id,
category: resourceItem.category,
subtype: resourceItem.subtype,
label: resourceItem.label,
mediaType: resourceItem.mediaType,
dependencyDepth: resourceItem.dependencyDepth,
};
}
type ResourceCardRect = {
resourceId: string;
category: string;
left: number;
top: number;
right: number;
bottom: number;
};
function resourceCardRectsInAllView({
resources,
resourcesByCategory,
positions,
cardSizes,
allLayout,
}: {
resources: readonly ProjectResource[];
resourcesByCategory: ReadonlyMap<ResourceBookCategory, ProjectResource[]>;
positions: ReadonlyMap<string, { x: number; y: number }>;
cardSizes: ReadonlyMap<string, ResourceCanvasCardSize>;
allLayout: ResourceBookAllLayout;
}): ResourceCardRect[] {
const sceneResourcesByCategory = new Map<
ResourceBookTarget,
ProjectResource[]
>([[RESOURCE_BOOK_ALL_TARGET, [...resources]]]);
for (const [category, items] of resourcesByCategory) {
sceneResourcesByCategory.set(category, items);
}
const plan = buildResourceBookScenePlan({
state: {
view: 'child',
category: RESOURCE_BOOK_ALL_TARGET,
phase: 'idle',
token: 1,
},
visibleCategoryOrder: [
RESOURCE_BOOK_ALL_TARGET,
...RESOURCE_CANVAS_SECTION_ORDER,
],
resourcesByCategory: sceneResourcesByCategory,
overviewRects: new Map(),
positions,
cardSizes,
visibleResourceIds: new Set(resources.map((item) => item.id)),
categoryViewCenters: new Map(),
cardsReady: true,
allLayout,
});
return plan.flatMap((group) =>
group.cards.map((card) => ({
resourceId: card.resource.id,
category: group.category,
left: card.layout.x,
top: card.layout.y,
right: card.layout.x + card.layout.width,
bottom: card.layout.y + card.layout.height,
})),
);
}
/** 逐对矩形相交检查:返回的字符串自带两张卡的矩形数值,红了就能直接看是哪两张撞了。 */
function intersectingResourceCardRects(
rects: readonly ResourceCardRect[],
): string[] {
const intersections: string[] = [];
for (let left = 0; left < rects.length; left += 1) {
for (let right = left + 1; right < rects.length; right += 1) {
const a = rects[left]!;
const b = rects[right]!;
if (
a.left < b.right &&
b.left < a.right &&
a.top < b.bottom &&
b.top < a.bottom
) {
intersections.push(
`${a.resourceId}[${a.left},${a.top},${a.right},${a.bottom}] ∩ ` +
`${b.resourceId}[${b.left},${b.top},${b.right},${b.bottom}]`,
);
}
}
}
return intersections;
}
describe('「所有资源」展开态:混合类型卡片自动布局不重叠', () => {
const documents = Array.from({ length: 12 }, (_, index) =>
documentResource(index + 1),
);
const imageSize = resourceCanvasImageCardSize({
pixelWidth: 512,
pixelHeight: 512,
});
const imageAssets = Array.from({ length: 12 }, (_, index) =>
imageAssetResource(index + 1),
);
const mixedResources = [...documents, ...imageAssets];
const cardSizes = new Map<string, ResourceCanvasCardSize>([
...documents.map((item) => [item.id, { width: 180, height: 128 }] as const),
...imageAssets.map((item) => [item.id, imageSize] as const),
]);
const resourcesByCategory = new Map<ResourceBookCategory, ProjectResource[]>(
RESOURCE_CANVAS_SECTION_ORDER.map((category) => [
category,
mixedResources.filter((item) => item.category === category),
]),
);
const canvasItems = mixedResources.map(canvasItemOf);
const noPositions = new Map<string, { x: number; y: number }>();
/**
* 依赖模式在关系图就绪前的布局是空的:一张坐标都没有。此时按"所有卡都在原点"算出的分带
* 只有单行高 —— 文档带 144(128 + 行间距)、图片带原点 192(144 + 带间距)。坐标到位后
* 文档卡会铺到第 2、3 行(y = 168 / 336),若带几何不跟着重算,图片那一带就压在文档卡上。
*/
function dependencyPositions() {
return new Map(
reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('mixed', 'dependency'),
canvasItems,
undefined,
cardSizes,
).layout.positions.map((position) => [
position.resourceId,
{ x: position.x, y: position.y },
]),
);
}
it('重算第一批坐标到位后的分带,展开态里任意两张卡的矩形都不相交', () => {
const pending = resolveResourceBookAllLayout(null, {
projectId: 'mixed',
mode: 'dependency',
layoutReady: false,
categoryOrder: RESOURCE_CANVAS_SECTION_ORDER,
resourcesByCategory,
positions: noPositions,
cardSizes,
});
// 坐标就绪前的那一份只描述"还没有坐标",卡片此时也不渲染(见 cardsReady)。
expect(pending.layout.bands.map((band) => band.height)).toEqual([144, 196]);
const positions = dependencyPositions();
const settled = resolveResourceBookAllLayout(pending, {
projectId: 'mixed',
mode: 'dependency',
layoutReady: true,
categoryOrder: RESOURCE_CANVAS_SECTION_ORDER,
resourcesByCategory,
positions,
cardSizes,
});
// 硬判据:展开态里逐对矩形都不相交(红了会直接列出撞在一起的那两张卡的矩形)。
const rects = resourceCardRectsInAllView({
resources: mixedResources,
resourcesByCategory,
positions,
cardSizes,
allLayout: settled.layout,
});
expect(intersectingResourceCardRects(rects)).toEqual([]);
expect(rects).toHaveLength(mixedResources.length);
// 几何形状:文档那一带按 3 行真实坐标撑开,图片带被推到它下面,而不是挤在 144 + 48。
const documentBand = settled.layout.bandByCategory.get('document')!;
const imageBand = settled.layout.bandByCategory.get('unclassified')!;
expect(documentBand.height).toBe(480);
expect(imageBand.originY).toBe(
documentBand.originY + documentBand.height + RESOURCE_BOOK_ALL_BAND_GAP,
);
});
it('栏目页里同一栏混排不同尺寸的卡也不相交(类型 / 依赖两种模式)', () => {
const mixedSizes = new Map<string, ResourceCanvasCardSize>([
['variant-doc', { width: 180, height: 128 }],
[
'variant-square',
resourceCanvasImageCardSize({ pixelWidth: 512, pixelHeight: 512 }),
],
[
'variant-tall',
resourceCanvasImageCardSize({ pixelWidth: 256, pixelHeight: 512 }),
],
[
'variant-wide',
resourceCanvasImageCardSize({ pixelWidth: 1024, pixelHeight: 512 }),
],
]);
const variantResources = [
{
...resource('variant-doc'),
label: 'variant-doc.md',
path: 'assets/docs/variant-doc.md',
mediaType: 'text/markdown',
subtype: 'document',
},
{ ...resource('variant-square'), label: 'variant-square.png' },
{ ...resource('variant-tall'), label: 'variant-tall.png' },
{ ...resource('variant-wide'), label: 'variant-wide.png' },
];
const variantItems = variantResources.map(canvasItemOf);
for (const mode of ['type', 'dependency'] as const) {
const positions = new Map(
reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('variants', mode),
variantItems,
undefined,
mixedSizes,
).layout.positions.map((position) => [
position.resourceId,
{ x: position.x, y: position.y },
]),
);
const allLayout = buildResourceBookAllLayout({
categoryOrder: ['unclassified'],
resourcesByCategory: new Map([
['unclassified', variantResources] as [
ResourceBookCategory,
ProjectResource[],
],
]),
positions,
cardSizes: mixedSizes,
});
const rects = resourceCardRectsInAllView({
resources: variantResources,
resourcesByCategory: new Map([
['unclassified', variantResources] as [
ResourceBookCategory,
ProjectResource[],
],
]),
positions,
cardSizes: mixedSizes,
allLayout,
});
expect(rects).toHaveLength(variantResources.length);
expect(intersectingResourceCardRects(rects)).toEqual([]);
}
});
it('已有 sidecar 坐标(含手放)原样保留,并按下一条带让开', () => {
// 用户上一轮在「文档」栏把第一张卡拖到 y = 600(远超自动网格),sidecar 里存的就是它。
const sidecar = createEmptyResourceCanvasLayout('mixed', 'type');
const sidecarPositions = [
...sidecar.positions,
{
resourceId: documents[0]!.id,
section: 'document' as const,
x: 40,
y: 600,
manuallyPlaced: true,
},
];
const positions = new Map(
reconcileResourceCanvasLayout(
{ ...sidecar, positions: sidecarPositions },
canvasItems,
undefined,
cardSizes,
).layout.positions.map((position) => [
position.resourceId,
{ x: position.x, y: position.y },
]),
);
// 手放坐标没有被协调改写。
expect(positions.get(documents[0]!.id)).toEqual({ x: 40, y: 600 });
const resolved = resolveResourceBookAllLayout(null, {
projectId: 'mixed',
mode: 'type',
layoutReady: true,
categoryOrder: RESOURCE_CANVAS_SECTION_ORDER,
resourcesByCategory,
positions,
cardSizes,
});
const documentBand = resolved.layout.bandByCategory.get('document')!;
const imageBand = resolved.layout.bandByCategory.get('unclassified')!;
// 画出来的仍是"sidecar 局部坐标 + 带原点",即手放位置就是用户放下的那一处。
const rects = resourceCardRectsInAllView({
resources: mixedResources,
resourcesByCategory,
positions,
cardSizes,
allLayout: resolved.layout,
});
const manualRect = rects.find(
(rect) => rect.resourceId === documents[0]!.id,
)!;
expect(manualRect.left).toBe(40 + documentBand.originX);
expect(manualRect.top).toBe(600 + documentBand.originY);
// 带高按被拖到 600 的那张卡撑开,图片带整体让到它下面。
expect(manualRect.bottom).toBeLessThanOrEqual(
documentBand.originY + documentBand.height,
);
expect(imageBand.originY).toBeGreaterThanOrEqual(
manualRect.bottom + RESOURCE_BOOK_ALL_BAND_GAP,
);
expect(intersectingResourceCardRects(rects)).toEqual([]);
});
});
describe('resourceBookAllLayoutKey', () => {
const layoutInput = {
projectId: 'key-project',
mode: 'dependency' as const,
categoryOrder: ['document', 'unclassified'] as const,
resourcesByCategory: new Map<ResourceBookCategory, ProjectResource[]>([
['document', [documentResource(1)]],
['unclassified', [imageAssetResource(1, { width: 180, height: 180 })]],
]),
cardSizes: new Map<string, ResourceCanvasCardSize>([
['agent-result:design:run-1', { width: 180, height: 128 }],
['asset:img-01', { width: 180, height: 180 }],
]),
};
const settledPositions = new Map([
['agent-result:design:run-1', { x: 0, y: 0 }],
['asset:img-01', { x: 0, y: 0 }],
]);
it('keeps the key when only the coordinates move, so dragging never re-stacks the bands', () => {
const before = resourceBookAllLayoutKey({
...layoutInput,
layoutReady: true,
positions: settledPositions,
});
const dragged = resourceBookAllLayoutKey({
...layoutInput,
layoutReady: true,
positions: new Map([
['agent-result:design:run-1', { x: 900, y: 1_200 }],
['asset:img-01', { x: 0, y: 0 }],
]),
});
expect(dragged).toBe(before);
});
it('changes the key while the layout is not settled yet, so the first real bands are rebuilt', () => {
const pending = resourceBookAllLayoutKey({
...layoutInput,
layoutReady: false,
positions: new Map(),
});
const settled = resourceBookAllLayoutKey({
...layoutInput,
layoutReady: true,
positions: settledPositions,
});
// 坐标缺一张也仍是 pending:缺坐标时算出来的带高只代表"还没布局",不能当最终几何用。
const partial = resourceBookAllLayoutKey({
...layoutInput,
layoutReady: true,
positions: new Map([['agent-result:design:run-1', { x: 0, y: 0 }]]),
});
expect(pending).not.toBe(settled);
expect(partial).toBe(pending);
});
it('changes the key when real card sizes or the sort mode change', () => {
const base = resourceBookAllLayoutKey({
...layoutInput,
layoutReady: true,
positions: settledPositions,
});
const measuredLater = resourceBookAllLayoutKey({
...layoutInput,
layoutReady: true,
positions: settledPositions,
cardSizes: new Map<string, ResourceCanvasCardSize>([
['agent-result:design:run-1', { width: 180, height: 128 }],
['asset:img-01', { width: 220, height: 180 }],
]),
});
const typeMode = resourceBookAllLayoutKey({
...layoutInput,
mode: 'type',
layoutReady: true,
positions: settledPositions,
});
expect(measuredLater).not.toBe(base);
expect(typeMode).not.toBe(base);
});
});