Files
Genarrative/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts
T
suzmii 3bf499ddfd
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 2m59s
Project CI / Native shell tests (pull_request) Successful in 19m29s
修复「所有资源」展开态文档卡与图片卡初始自动重叠
- resourceBookLayout:新增分带几何缓存键 resourceBookAllLayoutKey 与取几何的 resolveResourceBookAllLayout,键纳入布局是否就绪 / 卡片真实尺寸 / 排序模式,拖动只改坐标时键保持不变
- resourceBookLayout:抽出 ResourceBookAllLayoutInput,生产组件与回归用例共用同一份「键 + 缓存」口径
- index.tsx:展开态分带几何改走 resolveResourceBookAllLayout,并传入当前布局 hook 的 ready;此前只按项目 / 排序 / 可见资源 id 冻结,依赖模式关系图就绪前算出的单行带(144 高)被永久固化,图片带仍挤在 144 + 48 处,整排压住文档卡
- tests/resourceBookLayout.test.ts:新增混合类型(12 文档 + 12 图片)第一版坐标到位后分带重算、逐对矩形不相交的主用例
- tests/resourceBookLayout.test.ts:新增同一栏混排不同尺寸卡(类型 / 依赖两种模式)不相交、sidecar 手放坐标保留并让开下一带的对照用例
- tests/resourceBookLayout.test.ts:新增缓存键的拖动稳定性、就绪翻转、卡片尺寸与排序模式变化用例
- tests/projectResourceLiveIntegration.test.tsx:新增真宿主用例,依赖模式下打开「所有资源」,读渲染出的卡片世界矩形做逐对相交检查
2026-09-13 17:06:09 +08:00

1136 lines
38 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,
resolveResourceBookAllLayout,
RESOURCE_BOOK_ALL_BAND_GAP,
RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
type ResourceBookAllBand,
resourceBookAllBandLocalPoint,
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,
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 });
});
});
/**
* 「所有资源」展开态里文档卡与图片卡一开始自动重叠的回归夹具(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);
});
});