3cca4fb99c
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
- 根因:版本投影的显示顺序走依赖视图、同层按 opaque 资源 id 做字符串排序,而 `version:initial-*` 的字符串序大于 `version:agent-*`,于是初始版本被排到最后(4 个版本读作 2/3/4/1,既与卡面「版本 N」序号冲突,也与「最新版本 = 发布标签」冲突;已落盘坐标 agent-22(0,0) agent-42(228,0) agent-46(0,168) initial-13(228,168) 为证) - 改法:`ProjectResource` / `ResourceCanvasItem` 增加 `orderIndex`(= 内部版本序数,仅项目版本资源携带),新增 `compareResourceOrder`(先比序键、再回落 opaque id)并替换依赖视图 4 处 `compareStableIdentifier`;类型视图在 mediaType 之后插入序键比较(顺带修掉 ≥10 个版本时「版本 10」被字符串序排到「版本 2」之前的错位) - 兼容性:不带 `orderIndex` 的其它资源排序结果与落盘坐标逐值不变 - 验证:`npx vitest run apps/ai-game-creator-shell/tests` 199 文件 / 1935 passed / 0 failed;`npm run agc:typecheck` exit 0;`npm run check:encoding` passed;`git diff --check` 干净;真机 CDP 用 Vite 现网真实模块 + 真实 manifest 复算落位:修复前 2→3→4→5→1,修复后 1→2→3→4→5
1247 lines
40 KiB
TypeScript
1247 lines
40 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
|
||
import {
|
||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
|
||
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
|
||
isSafeProjectResourceCanvasCoordinate,
|
||
type ProjectResourceCanvasPosition,
|
||
} from '../../../packages/shared/src/contracts/gameCreationApp';
|
||
import {
|
||
createEmptyResourceCanvasLayout,
|
||
createResourceCanvasCardSizeByResourceId,
|
||
DEFAULT_RESOURCE_CANVAS_CARD_SIZE,
|
||
fitResourceCanvasViewportToContent,
|
||
moveResourceCanvasPosition,
|
||
normalizeInfiniteResourceCanvasViewport,
|
||
reconcileResourceCanvasLayout,
|
||
RESOURCE_CANVAS_CARD_HEIGHT,
|
||
RESOURCE_CANVAS_CARD_WIDTH,
|
||
RESOURCE_CANVAS_COLUMN_GAP,
|
||
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
|
||
RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS,
|
||
RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
|
||
RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
|
||
RESOURCE_CANVAS_SECTION_ORDER,
|
||
type ResourceCanvasCardSize,
|
||
resourceCanvasContentBounds,
|
||
resourceCanvasImageCardSize,
|
||
type ResourceCanvasItem,
|
||
type ResourceCanvasLayoutTopology,
|
||
resourceCanvasSectionExtent,
|
||
} from '../src/view/project-development/resourceCanvasLayoutModel';
|
||
|
||
function resource(
|
||
id: string,
|
||
category: ResourceCanvasItem['category'],
|
||
dependencyDepth = 0,
|
||
): ResourceCanvasItem {
|
||
return {
|
||
id,
|
||
category,
|
||
subtype: 'default',
|
||
dependencyDepth,
|
||
label: id,
|
||
mediaType: category === 'scene' ? 'image/png' : 'text/markdown',
|
||
};
|
||
}
|
||
|
||
function dependencyTopology(
|
||
referenceEdges: ResourceCanvasLayoutTopology['referenceEdges'] = [],
|
||
taskFlows: ResourceCanvasLayoutTopology['taskFlows'] = [],
|
||
): ResourceCanvasLayoutTopology {
|
||
return { referenceEdges, taskFlows };
|
||
}
|
||
|
||
function positionById(
|
||
layout: ReturnType<typeof reconcileResourceCanvasLayout>['layout'],
|
||
resourceId: string,
|
||
) {
|
||
const position = layout.positions.find(
|
||
(candidate) => candidate.resourceId === resourceId,
|
||
);
|
||
expect(position).toBeDefined();
|
||
return position!;
|
||
}
|
||
|
||
describe('resource canvas layout model', () => {
|
||
it('allows infinite background panning while keeping zoom within shared limits', () => {
|
||
expect(
|
||
normalizeInfiniteResourceCanvasViewport({
|
||
x: 2_000,
|
||
y: 2_000,
|
||
scale: 1,
|
||
}),
|
||
).toEqual({ x: 2_000, y: 2_000, scale: 1 });
|
||
expect(
|
||
normalizeInfiniteResourceCanvasViewport({
|
||
x: -2_000,
|
||
y: -2_000,
|
||
scale: 0.001,
|
||
}),
|
||
).toEqual({ x: -2_000, y: -2_000, scale: 0.025 });
|
||
});
|
||
|
||
it('does not pull the viewport back when resource extents change', () => {
|
||
const before = normalizeInfiniteResourceCanvasViewport({
|
||
x: 12_000,
|
||
y: -9_000,
|
||
scale: 0.75,
|
||
});
|
||
const after = normalizeInfiniteResourceCanvasViewport(before);
|
||
|
||
expect(after).toEqual(before);
|
||
});
|
||
|
||
it('repairs invalid viewport coordinates without adding a canvas boundary', () => {
|
||
expect(
|
||
normalizeInfiniteResourceCanvasViewport({
|
||
x: Number.NaN,
|
||
y: Number.POSITIVE_INFINITY,
|
||
scale: Number.NaN,
|
||
}),
|
||
).toEqual({ x: 0, y: 0, scale: 1 });
|
||
});
|
||
|
||
it('creates non-overlapping defaults and keeps the two modes independent', () => {
|
||
const resources = [
|
||
resource('a', 'scene', 0),
|
||
resource('b', 'scene', 0),
|
||
resource('c', 'scene', 1),
|
||
];
|
||
const dependency = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-1', 'dependency'),
|
||
resources,
|
||
).layout;
|
||
const type = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-1', 'type'),
|
||
resources,
|
||
).layout;
|
||
|
||
expect(dependency.positions).toMatchObject([
|
||
{ resourceId: 'a', x: 0, y: 0, manuallyPlaced: false },
|
||
{
|
||
resourceId: 'c',
|
||
x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
|
||
y: 0,
|
||
manuallyPlaced: false,
|
||
},
|
||
{
|
||
resourceId: 'b',
|
||
x: 0,
|
||
y: RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
|
||
manuallyPlaced: false,
|
||
},
|
||
]);
|
||
expect(type.positions).toMatchObject([
|
||
{ resourceId: 'a', x: 0, y: 0 },
|
||
{
|
||
resourceId: 'b',
|
||
x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
|
||
y: 0,
|
||
},
|
||
{
|
||
resourceId: 'c',
|
||
x: 2 * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP),
|
||
y: 0,
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('preserves existing positions, appends new resources, and removes stale ids', () => {
|
||
const base = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-1', 'type'),
|
||
[resource('a', 'document'), resource('stale', 'document')],
|
||
).layout;
|
||
const moved = moveResourceCanvasPosition(
|
||
base,
|
||
'a',
|
||
'document',
|
||
333.4,
|
||
41.6,
|
||
);
|
||
const reconciled = reconcileResourceCanvasLayout(moved, [
|
||
resource('a', 'document'),
|
||
resource('new', 'document'),
|
||
]);
|
||
|
||
expect(reconciled.changed).toBe(true);
|
||
expect(reconciled.layout.positions).toContainEqual({
|
||
resourceId: 'a',
|
||
section: 'document',
|
||
x: 333,
|
||
y: 42,
|
||
manuallyPlaced: true,
|
||
});
|
||
expect(
|
||
reconciled.layout.positions.some(
|
||
(position) => position.resourceId === 'stale',
|
||
),
|
||
).toBe(false);
|
||
expect(
|
||
reconciled.layout.positions.find(
|
||
(position) => position.resourceId === 'new',
|
||
)?.manuallyPlaced,
|
||
).toBe(false);
|
||
});
|
||
|
||
it('preserves negative manual positions within the symmetric canvas range', () => {
|
||
const base = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-negative', 'type'),
|
||
[resource('a', 'document')],
|
||
).layout;
|
||
const moved = moveResourceCanvasPosition(
|
||
base,
|
||
'a',
|
||
'document',
|
||
-123.4,
|
||
-45.6,
|
||
);
|
||
|
||
expect(moved.positions[0]).toMatchObject({ x: -123, y: -46 });
|
||
expect(
|
||
isSafeProjectResourceCanvasCoordinate(
|
||
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
|
||
),
|
||
).toBe(true);
|
||
expect(
|
||
isSafeProjectResourceCanvasCoordinate(
|
||
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE - 1,
|
||
),
|
||
).toBe(false);
|
||
});
|
||
|
||
it('rewrites a persisted position whose section no longer matches, keeping the coordinates', () => {
|
||
// 旧行为是丢弃这条坐标、让资源按现行分区自动重排 —— 那等于把用户手摆的位置抹掉。
|
||
// 资源分类本来就会变(`kind:"UI"` 的 8 条资产从「待归类」被修到「UI 交互」就是真机一例),
|
||
// 所以现在改成改写 section、原样保留 x / y / manuallyPlaced。
|
||
const layout = createEmptyResourceCanvasLayout('project-1', 'dependency');
|
||
layout.positions = [
|
||
{
|
||
resourceId: 'asset-a',
|
||
section: 'audio',
|
||
x: 10,
|
||
y: 10,
|
||
manuallyPlaced: true,
|
||
},
|
||
];
|
||
const reconciled = reconcileResourceCanvasLayout(layout, [
|
||
resource('asset-a', 'scene'),
|
||
]);
|
||
expect(reconciled.changed).toBe(true);
|
||
expect(reconciled.layout.positions).toEqual([
|
||
{
|
||
resourceId: 'asset-a',
|
||
section: 'scene',
|
||
x: 10,
|
||
y: 10,
|
||
manuallyPlaced: true,
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('栏目顺序固定为 6 类资产分类加末尾的项目版本栏目', () => {
|
||
expect(RESOURCE_CANVAS_SECTION_ORDER).toEqual([
|
||
'ui-interaction',
|
||
'character',
|
||
'scene',
|
||
'audio',
|
||
'document',
|
||
'unclassified',
|
||
'version',
|
||
]);
|
||
});
|
||
|
||
it('读时归并旧栏目 sidecar:坐标与手动标记原样保留、section 改写并判定为变化', () => {
|
||
const source = createEmptyResourceCanvasLayout('project-legacy', 'type');
|
||
source.positions = [
|
||
{
|
||
resourceId: 'legacy-art',
|
||
section: 'art',
|
||
x: -32,
|
||
y: 64,
|
||
manuallyPlaced: true,
|
||
},
|
||
{
|
||
resourceId: 'legacy-code',
|
||
section: 'code',
|
||
x: 180,
|
||
y: 0,
|
||
manuallyPlaced: false,
|
||
},
|
||
{
|
||
resourceId: 'legacy-version',
|
||
section: 'version',
|
||
x: 12,
|
||
y: 34,
|
||
manuallyPlaced: true,
|
||
},
|
||
];
|
||
|
||
const reconciled = reconcileResourceCanvasLayout(source, [
|
||
{ ...resource('legacy-art', 'ui-interaction'), subtype: 'icon' },
|
||
{ ...resource('legacy-code', 'unclassified'), subtype: 'code' },
|
||
{ ...resource('legacy-version', 'version'), subtype: 'project-version' },
|
||
]);
|
||
|
||
// 改写后的布局与 sidecar 逐项不同:既有协调路径据此写回一次新分区。
|
||
expect(reconciled.changed).toBe(true);
|
||
expect(reconciled.layout.positions).toEqual([
|
||
{
|
||
resourceId: 'legacy-art',
|
||
section: 'ui-interaction',
|
||
x: -32,
|
||
y: 64,
|
||
manuallyPlaced: true,
|
||
},
|
||
{
|
||
resourceId: 'legacy-code',
|
||
section: 'unclassified',
|
||
x: 180,
|
||
y: 0,
|
||
manuallyPlaced: false,
|
||
},
|
||
{
|
||
resourceId: 'legacy-version',
|
||
section: 'version',
|
||
x: 12,
|
||
y: 34,
|
||
manuallyPlaced: true,
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('只登记游戏代码的项目落在待归类栏目而不是被分区轴排除', () => {
|
||
const layout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-code-only', 'type'),
|
||
[
|
||
resource('asset:game-entry', 'unclassified'),
|
||
resource('task:code-prototype:game/main.js', 'unclassified'),
|
||
],
|
||
).layout;
|
||
|
||
expect(RESOURCE_CANVAS_SECTION_ORDER).toContain('unclassified');
|
||
expect(layout.positions).toHaveLength(2);
|
||
expect(
|
||
new Set(layout.positions.map((position) => position.section)),
|
||
).toEqual(new Set(['unclassified']));
|
||
});
|
||
|
||
it('sorts type defaults by subtype before media type and label with an id fallback', () => {
|
||
const typeLayout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-type-order', 'type'),
|
||
[
|
||
{
|
||
...resource('ui-prototype', 'scene'),
|
||
subtype: 'ui-design',
|
||
mediaType: 'image/png',
|
||
label: '甲界面',
|
||
},
|
||
{
|
||
...resource('art-spritesheet-b', 'scene'),
|
||
subtype: 'icon-spritesheet',
|
||
mediaType: 'image/png',
|
||
label: '乙图集',
|
||
},
|
||
{
|
||
...resource('art-spritesheet-a', 'scene'),
|
||
subtype: 'icon-spritesheet',
|
||
mediaType: 'image/png',
|
||
label: '乙图集',
|
||
},
|
||
],
|
||
).layout;
|
||
|
||
expect(
|
||
typeLayout.positions.map(({ resourceId, x, y }) => ({
|
||
resourceId,
|
||
x,
|
||
y,
|
||
})),
|
||
).toEqual([
|
||
{ resourceId: 'art-spritesheet-a', x: 0, y: 0 },
|
||
{
|
||
resourceId: 'art-spritesheet-b',
|
||
x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
|
||
y: 0,
|
||
},
|
||
{
|
||
resourceId: 'ui-prototype',
|
||
x: 2 * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP),
|
||
y: 0,
|
||
},
|
||
]);
|
||
});
|
||
|
||
it('项目版本卡按内部版本序数落位,而不是按 opaque 资源 id', () => {
|
||
// 版本资源 id 里的 versionId 是 opaque 标识:`initial-13` 的字符串序大于 `agent-22`。
|
||
// 没有显式次序键时,依赖视图会把初始版本排到最后 —— 卡面 `版本 1` 落在最右,
|
||
// 与「最新内部版本 = 版本 N」以及发布面板取的标签(`versions.length`)都对不上。
|
||
const rows = [
|
||
['initial-13', 0],
|
||
['agent-22', 1],
|
||
['agent-42', 2],
|
||
['agent-46', 3],
|
||
['agent-69', 4],
|
||
] as const;
|
||
|
||
const withOrderIndex = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-version-order', 'dependency'),
|
||
rows.map(([versionId, orderIndex]) => ({
|
||
...resource(`version:${versionId}`, 'version'),
|
||
subtype: 'project-version',
|
||
mediaType: '正式项目版本',
|
||
label: `版本 ${orderIndex + 1}`,
|
||
orderIndex,
|
||
})),
|
||
).layout;
|
||
|
||
// 阅读次序 = 先上后下、先左后右;它必须与 `版本 N` 的序号一致。
|
||
expect(
|
||
[...withOrderIndex.positions]
|
||
.sort((left, right) => left.y - right.y || left.x - right.x)
|
||
.map((position) => position.resourceId),
|
||
).toEqual(rows.map(([versionId]) => `version:${versionId}`));
|
||
|
||
// 对照组:不带显式次序键时落位退回 opaque id,`initial-*` 排到最后 —— 这就是要修的观感。
|
||
const withoutOrderIndex = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-version-order', 'dependency'),
|
||
rows.map(([versionId, orderIndex]) => ({
|
||
...resource(`version:${versionId}`, 'version'),
|
||
subtype: 'project-version',
|
||
mediaType: '正式项目版本',
|
||
label: `版本 ${orderIndex + 1}`,
|
||
})),
|
||
).layout;
|
||
|
||
expect(
|
||
[...withoutOrderIndex.positions]
|
||
.sort((left, right) => left.y - right.y || left.x - right.x)
|
||
.map((position) => position.resourceId),
|
||
).toEqual([
|
||
'version:agent-22',
|
||
'version:agent-42',
|
||
'version:agent-46',
|
||
'version:agent-69',
|
||
'version:initial-13',
|
||
]);
|
||
});
|
||
|
||
it('类型视图的项目版本也按序数排,不按 `版本 N` 的字符串序', () => {
|
||
// 10 版之后 `版本 10` 的字符串序小于 `版本 2`,按 label 比会错位;显式序键不会。
|
||
const indexes = Array.from({ length: 10 }, (_unused, index) => index);
|
||
|
||
const typeLayout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-version-type-order', 'type'),
|
||
indexes.map((index) => ({
|
||
...resource(`version:agent-${index + 1}`, 'version'),
|
||
subtype: 'project-version',
|
||
mediaType: '正式项目版本',
|
||
label: `版本 ${index + 1}`,
|
||
orderIndex: index,
|
||
})),
|
||
).layout;
|
||
|
||
expect(
|
||
[...typeLayout.positions]
|
||
.sort((left, right) => left.y - right.y || left.x - right.x)
|
||
.map((position) => position.resourceId),
|
||
).toEqual(indexes.map((index) => `version:agent-${index + 1}`));
|
||
});
|
||
|
||
it('keeps connected art cards close while placing separate components before isolates', () => {
|
||
const layout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-clusters', 'dependency'),
|
||
[
|
||
resource('art:canvas', 'scene', 0),
|
||
resource('art:wireframe', 'scene', 1),
|
||
resource('art:character', 'scene', 0),
|
||
resource('art:screen', 'scene', 1),
|
||
resource('art:unrelated', 'scene', 0),
|
||
],
|
||
dependencyTopology([
|
||
{
|
||
sourceResourceId: 'art:canvas',
|
||
targetResourceId: 'art:wireframe',
|
||
},
|
||
{
|
||
sourceResourceId: 'art:character',
|
||
targetResourceId: 'art:screen',
|
||
},
|
||
]),
|
||
).layout;
|
||
|
||
const canvas = positionById(layout, 'art:canvas');
|
||
const wireframe = positionById(layout, 'art:wireframe');
|
||
const character = positionById(layout, 'art:character');
|
||
const screen = positionById(layout, 'art:screen');
|
||
const unrelated = positionById(layout, 'art:unrelated');
|
||
|
||
expect(wireframe.y).toBe(canvas.y);
|
||
expect(screen.y).toBe(character.y);
|
||
expect(character.y - canvas.y).toBeGreaterThanOrEqual(
|
||
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
|
||
);
|
||
expect(unrelated.y).toBeGreaterThan(screen.y);
|
||
});
|
||
|
||
it('ignores cross-category references and task flows when forming layout clusters', () => {
|
||
const layout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout(
|
||
'project-no-cross-category',
|
||
'dependency',
|
||
),
|
||
[
|
||
resource('art:a', 'scene', 0),
|
||
resource('art:b', 'scene', 0),
|
||
resource('document:a', 'document', 0),
|
||
],
|
||
dependencyTopology(
|
||
[
|
||
{
|
||
sourceResourceId: 'document:a',
|
||
targetResourceId: 'art:a',
|
||
},
|
||
],
|
||
[
|
||
{
|
||
sourceResourceIds: ['document:a'],
|
||
targetResourceIds: ['art:a'],
|
||
},
|
||
],
|
||
),
|
||
).layout;
|
||
|
||
expect(positionById(layout, 'art:a').y).toBe(0);
|
||
expect(positionById(layout, 'art:b').y).toBe(
|
||
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
|
||
);
|
||
expect(positionById(layout, 'document:a')).toMatchObject({
|
||
section: 'document',
|
||
y: 0,
|
||
});
|
||
});
|
||
|
||
it('uses bounded layer scans to align multi-edge targets with their upstream neighbors', () => {
|
||
const layout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-crossings', 'dependency'),
|
||
[
|
||
resource('source:bottom', 'scene', 0),
|
||
resource('source:top', 'scene', 0),
|
||
resource('target:first', 'scene', 1),
|
||
resource('target:second', 'scene', 1),
|
||
],
|
||
dependencyTopology(
|
||
[
|
||
{
|
||
sourceResourceId: 'source:bottom',
|
||
targetResourceId: 'target:second',
|
||
},
|
||
{
|
||
sourceResourceId: 'source:top',
|
||
targetResourceId: 'target:first',
|
||
},
|
||
],
|
||
[
|
||
{
|
||
sourceResourceIds: ['source:bottom', 'source:top'],
|
||
targetResourceIds: ['target:first', 'target:second'],
|
||
},
|
||
],
|
||
),
|
||
).layout;
|
||
|
||
expect(positionById(layout, 'source:bottom').y).toBeLessThan(
|
||
positionById(layout, 'source:top').y,
|
||
);
|
||
expect(positionById(layout, 'target:second').y).toBeLessThan(
|
||
positionById(layout, 'target:first').y,
|
||
);
|
||
});
|
||
|
||
it('centers narrow dependency layers so diamond edges have balanced lengths', () => {
|
||
const layout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-balanced-diamond', 'dependency'),
|
||
[
|
||
resource('source', 'scene', 0),
|
||
resource('middle:upper', 'scene', 1),
|
||
resource('middle:lower', 'scene', 1),
|
||
resource('target', 'scene', 2),
|
||
],
|
||
dependencyTopology([
|
||
{ sourceResourceId: 'source', targetResourceId: 'middle:upper' },
|
||
{ sourceResourceId: 'source', targetResourceId: 'middle:lower' },
|
||
{ sourceResourceId: 'middle:upper', targetResourceId: 'target' },
|
||
{ sourceResourceId: 'middle:lower', targetResourceId: 'target' },
|
||
]),
|
||
).layout;
|
||
const dependencySlotHeight =
|
||
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP;
|
||
const dependencySlotWidth =
|
||
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP;
|
||
|
||
expect(positionById(layout, 'middle:lower')).toMatchObject({
|
||
x: dependencySlotWidth,
|
||
y: 0,
|
||
});
|
||
expect(positionById(layout, 'middle:upper')).toMatchObject({
|
||
x: dependencySlotWidth,
|
||
y: dependencySlotHeight,
|
||
});
|
||
expect(positionById(layout, 'source')).toMatchObject({
|
||
x: 0,
|
||
y: dependencySlotHeight / 2,
|
||
});
|
||
expect(positionById(layout, 'target')).toMatchObject({
|
||
x: dependencySlotWidth * 2,
|
||
y: dependencySlotHeight / 2,
|
||
});
|
||
});
|
||
|
||
it('keeps cycles contiguous, preserves their Rust depth, and leaves cross-section cards in place', () => {
|
||
const layout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-cycles', 'dependency'),
|
||
[
|
||
resource('art:cycle-a', 'scene', 2),
|
||
resource('art:cycle-b', 'scene', 2),
|
||
resource('art:after-cycle', 'scene', 3),
|
||
resource('art:self', 'scene', 0),
|
||
resource('document:brief', 'document', 0),
|
||
],
|
||
dependencyTopology([
|
||
{ sourceResourceId: 'art:cycle-a', targetResourceId: 'art:cycle-b' },
|
||
{ sourceResourceId: 'art:cycle-b', targetResourceId: 'art:cycle-a' },
|
||
{
|
||
sourceResourceId: 'art:cycle-b',
|
||
targetResourceId: 'art:after-cycle',
|
||
},
|
||
{ sourceResourceId: 'art:self', targetResourceId: 'art:self' },
|
||
{
|
||
sourceResourceId: 'document:brief',
|
||
targetResourceId: 'art:cycle-a',
|
||
},
|
||
]),
|
||
).layout;
|
||
|
||
const cycleA = positionById(layout, 'art:cycle-a');
|
||
const cycleB = positionById(layout, 'art:cycle-b');
|
||
const afterCycle = positionById(layout, 'art:after-cycle');
|
||
expect(cycleA.x).toBe(
|
||
2 * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP),
|
||
);
|
||
expect(cycleB.x).toBe(cycleA.x);
|
||
expect(Math.abs(cycleA.y - cycleB.y)).toBe(
|
||
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
|
||
);
|
||
expect(afterCycle.x).toBe(
|
||
3 * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP),
|
||
);
|
||
expect(positionById(layout, 'document:brief').section).toBe('document');
|
||
});
|
||
|
||
it('keeps same-depth cycle members consecutive when another related card shares the layer', () => {
|
||
const layout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-cycle-contiguity', 'dependency'),
|
||
[
|
||
resource('art:cycle-a', 'scene', 2),
|
||
resource('art:cycle-b', 'scene', 2),
|
||
resource('art:sibling', 'scene', 2),
|
||
resource('art:after', 'scene', 3),
|
||
],
|
||
dependencyTopology([
|
||
{ sourceResourceId: 'art:cycle-a', targetResourceId: 'art:cycle-b' },
|
||
{ sourceResourceId: 'art:cycle-b', targetResourceId: 'art:cycle-a' },
|
||
{ sourceResourceId: 'art:cycle-b', targetResourceId: 'art:after' },
|
||
{ sourceResourceId: 'art:sibling', targetResourceId: 'art:after' },
|
||
]),
|
||
).layout;
|
||
|
||
const cycleA = positionById(layout, 'art:cycle-a');
|
||
const cycleB = positionById(layout, 'art:cycle-b');
|
||
const sibling = positionById(layout, 'art:sibling');
|
||
const dependencySlotWidth =
|
||
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP;
|
||
const dependencySlotHeight =
|
||
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP;
|
||
|
||
// 同层 3 张卡按层内网格展开成 2 列,环成员仍占相邻两格(同排相邻列),
|
||
// 另一张共享该层的相关卡落在它们之后的下一行。
|
||
expect(cycleB.y).toBe(cycleA.y);
|
||
expect(cycleB.x - cycleA.x).toBe(dependencySlotWidth);
|
||
expect(sibling.x).toBe(cycleA.x);
|
||
expect(sibling.y).toBe(cycleA.y + dependencySlotHeight);
|
||
});
|
||
|
||
it('is deterministic, does not modify type placement, and avoids historical manual positions', () => {
|
||
const resources = [
|
||
resource('art:source', 'scene', 0),
|
||
resource('art:target', 'scene', 1),
|
||
resource('art:other', 'scene', 1),
|
||
];
|
||
const topology = dependencyTopology([
|
||
{ sourceResourceId: 'art:source', targetResourceId: 'art:target' },
|
||
]);
|
||
const source = createEmptyResourceCanvasLayout(
|
||
'project-stable',
|
||
'dependency',
|
||
);
|
||
source.positions = [
|
||
{
|
||
resourceId: 'art:other',
|
||
section: 'scene',
|
||
x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
|
||
y: 0,
|
||
manuallyPlaced: true,
|
||
},
|
||
];
|
||
const first = reconcileResourceCanvasLayout(
|
||
source,
|
||
resources,
|
||
topology,
|
||
).layout;
|
||
const second = reconcileResourceCanvasLayout(
|
||
source,
|
||
resources,
|
||
topology,
|
||
).layout;
|
||
const type = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-stable', 'type'),
|
||
resources,
|
||
topology,
|
||
).layout;
|
||
|
||
expect(second.positions).toEqual(first.positions);
|
||
expect(positionById(first, 'art:other')).toMatchObject({
|
||
x: RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP,
|
||
y: 0,
|
||
manuallyPlaced: true,
|
||
});
|
||
expect(positionById(first, 'art:target').y).toBe(
|
||
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
|
||
);
|
||
expect(positionById(type, 'art:other')).toMatchObject({ x: 0, y: 0 });
|
||
});
|
||
|
||
it('saturates super-deep dependency columns and avoids collisions within the coordinate contract', () => {
|
||
const resources = [
|
||
resource('art:depth-4385', 'scene', 4_385),
|
||
resource('art:depth-4386-a', 'scene', 4_386),
|
||
resource('art:depth-4386-b', 'scene', 4_386),
|
||
resource('art:depth-9000', 'scene', 9_000),
|
||
];
|
||
const layout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-deep-dependency', 'dependency'),
|
||
resources,
|
||
).layout;
|
||
const repeatedLayout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-deep-dependency', 'dependency'),
|
||
resources,
|
||
).layout;
|
||
const lastLegalColumn =
|
||
Math.floor(
|
||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE /
|
||
(RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP),
|
||
) *
|
||
(RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP);
|
||
const deepPositions = [
|
||
positionById(layout, 'art:depth-4386-a'),
|
||
positionById(layout, 'art:depth-4386-b'),
|
||
positionById(layout, 'art:depth-9000'),
|
||
];
|
||
|
||
expect(lastLegalColumn).toBe(999_780);
|
||
expect(
|
||
isSafeProjectResourceCanvasCoordinate(
|
||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
|
||
),
|
||
).toBe(true);
|
||
expect(
|
||
isSafeProjectResourceCanvasCoordinate(
|
||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE + 1,
|
||
),
|
||
).toBe(false);
|
||
expect(
|
||
isSafeProjectResourceCanvasCoordinate(
|
||
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
|
||
),
|
||
).toBe(true);
|
||
expect(
|
||
isSafeProjectResourceCanvasCoordinate(
|
||
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE - 1,
|
||
),
|
||
).toBe(false);
|
||
expect(deepPositions.map((position) => position.x)).toEqual([
|
||
lastLegalColumn,
|
||
lastLegalColumn,
|
||
lastLegalColumn,
|
||
]);
|
||
expect(new Set(deepPositions.map((position) => position.y)).size).toBe(3);
|
||
expect(repeatedLayout.positions).toEqual(layout.positions);
|
||
expect(
|
||
layout.positions.every(
|
||
(position) =>
|
||
isSafeProjectResourceCanvasCoordinate(position.x) &&
|
||
isSafeProjectResourceCanvasCoordinate(position.y),
|
||
),
|
||
).toBe(true);
|
||
expect(resources.map((resource) => resource.dependencyDepth)).toEqual([
|
||
4_385, 4_386, 4_386, 9_000,
|
||
]);
|
||
});
|
||
|
||
it.each(['dependency', 'type'] as const)(
|
||
'reconciles 4096 resources in %s mode within the bounded layout budget',
|
||
(mode) => {
|
||
const resources = Array.from({ length: 4096 }, (_, index) => ({
|
||
...resource(`resource-${index.toString().padStart(4, '0')}`, 'scene'),
|
||
dependencyDepth: index % 64,
|
||
mediaType: `image/type-${index % 16}`,
|
||
}));
|
||
|
||
const startedAt = performance.now();
|
||
const layout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-performance', mode),
|
||
resources,
|
||
).layout;
|
||
const elapsedMs = performance.now() - startedAt;
|
||
|
||
expect(layout.positions).toHaveLength(4096);
|
||
expect(
|
||
new Set(
|
||
layout.positions.map((position) => `${position.x}:${position.y}`),
|
||
).size,
|
||
).toBe(4096);
|
||
expect(elapsedMs).toBeLessThan(2000);
|
||
},
|
||
);
|
||
|
||
it('keeps dependency clustering within the 4096-resource layout budget', () => {
|
||
const resources = Array.from({ length: 4096 }, (_, index) =>
|
||
resource(
|
||
`resource-${index.toString().padStart(4, '0')}`,
|
||
'scene',
|
||
index % 64,
|
||
),
|
||
);
|
||
const topology = dependencyTopology(
|
||
resources.slice(1).map((target, index) => ({
|
||
sourceResourceId: resources[index]!.id,
|
||
targetResourceId: target.id,
|
||
})),
|
||
);
|
||
const startedAt = performance.now();
|
||
const layout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout(
|
||
'project-cluster-performance',
|
||
'dependency',
|
||
),
|
||
resources,
|
||
topology,
|
||
).layout;
|
||
|
||
expect(layout.positions).toHaveLength(4096);
|
||
expect(performance.now() - startedAt).toBeLessThan(2000);
|
||
});
|
||
});
|
||
|
||
describe('resource canvas variable card geometry', () => {
|
||
it.each([
|
||
[1, 1, { width: 180, height: 180 }],
|
||
[16, 9, { width: 220, height: 124 }],
|
||
[9, 16, { width: 101, height: 180 }],
|
||
[2, 3, { width: 120, height: 180 }],
|
||
[3, 2, { width: 220, height: 147 }],
|
||
[100, 1, { width: 220, height: 110 }],
|
||
[1, 100, { width: 96, height: 180 }],
|
||
])(
|
||
'bounds a %d:%d image inside the shared card limits',
|
||
(pixelWidth, pixelHeight, expected) => {
|
||
expect(resourceCanvasImageCardSize({ pixelWidth, pixelHeight })).toEqual(
|
||
expected,
|
||
);
|
||
},
|
||
);
|
||
|
||
it('keeps non-images and images without metadata at the fixed fallback size', () => {
|
||
const sizes = createResourceCanvasCardSizeByResourceId(
|
||
[resource('image', 'scene'), resource('document', 'document')],
|
||
new Map([
|
||
['image', { pixelWidth: 1920, pixelHeight: 1080 }],
|
||
['document', { pixelWidth: 1920, pixelHeight: 1080 }],
|
||
]),
|
||
);
|
||
|
||
expect(sizes.get('image')).toEqual({ width: 220, height: 124 });
|
||
expect(sizes.get('document')).toEqual(DEFAULT_RESOURCE_CANVAS_CARD_SIZE);
|
||
expect(
|
||
createResourceCanvasCardSizeByResourceId(
|
||
[resource('pending-image', 'scene')],
|
||
new Map(),
|
||
).get('pending-image'),
|
||
).toEqual(DEFAULT_RESOURCE_CANVAS_CARD_SIZE);
|
||
});
|
||
|
||
it('uses actual widths and heights for type placement collision checks', () => {
|
||
const resources = [
|
||
resource('wide-a', 'scene'),
|
||
resource('wide-b', 'scene'),
|
||
];
|
||
const cardSizes = new Map<string, ResourceCanvasCardSize>([
|
||
['wide-a', { width: 220, height: 180 }],
|
||
['wide-b', { width: 220, height: 180 }],
|
||
]);
|
||
const layout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('variable-type', 'type'),
|
||
resources,
|
||
undefined,
|
||
cardSizes,
|
||
).layout;
|
||
|
||
expect(positionById(layout, 'wide-a')).toMatchObject({ x: 0, y: 0 });
|
||
expect(positionById(layout, 'wide-b')).toMatchObject({
|
||
x: 2 * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP),
|
||
y: 0,
|
||
});
|
||
});
|
||
|
||
it('uses actual dimensions for dependency columns, rows, and section extent', () => {
|
||
const resources = [
|
||
resource('source-a', 'scene', 0),
|
||
resource('source-b', 'scene', 0),
|
||
resource('target', 'scene', 1),
|
||
];
|
||
const cardSizes = new Map<string, ResourceCanvasCardSize>([
|
||
['source-a', { width: 220, height: 180 }],
|
||
['source-b', { width: 96, height: 180 }],
|
||
['target', { width: 180, height: 128 }],
|
||
]);
|
||
const layout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('variable-dependency', 'dependency'),
|
||
resources,
|
||
undefined,
|
||
cardSizes,
|
||
).layout;
|
||
|
||
expect(positionById(layout, 'source-a')).toMatchObject({ x: 0, y: 0 });
|
||
expect(positionById(layout, 'source-b')).toMatchObject({
|
||
x: 0,
|
||
y: 180 + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
|
||
});
|
||
expect(positionById(layout, 'target')).toMatchObject({
|
||
x: 220 + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
|
||
y: 0,
|
||
});
|
||
expect(resourceCanvasSectionExtent(layout.positions, cardSizes)).toEqual({
|
||
x: 0,
|
||
y: 0,
|
||
width: 620,
|
||
height: 416,
|
||
});
|
||
});
|
||
|
||
it('includes negative manual coordinates in the fit bounds', () => {
|
||
const positions: ProjectResourceCanvasPosition[] = [
|
||
{
|
||
resourceId: 'negative-card',
|
||
section: 'scene',
|
||
x: -240,
|
||
y: -160,
|
||
manuallyPlaced: true,
|
||
},
|
||
{
|
||
resourceId: 'positive-card',
|
||
section: 'scene',
|
||
x: 480,
|
||
y: 320,
|
||
manuallyPlaced: true,
|
||
},
|
||
];
|
||
const extent = resourceCanvasSectionExtent(
|
||
positions,
|
||
new Map([
|
||
['negative-card', { width: 180, height: 128 }],
|
||
['positive-card', { width: 220, height: 180 }],
|
||
]),
|
||
);
|
||
|
||
expect(extent).toEqual({
|
||
x: -256,
|
||
y: -176,
|
||
width: 972,
|
||
height: 692,
|
||
});
|
||
});
|
||
|
||
it('fits the exact resource card bounds to the reset viewport', () => {
|
||
const positions: ProjectResourceCanvasPosition[] = [
|
||
{
|
||
resourceId: 'negative-card',
|
||
section: 'scene',
|
||
x: -240,
|
||
y: -160,
|
||
manuallyPlaced: true,
|
||
},
|
||
{
|
||
resourceId: 'positive-card',
|
||
section: 'scene',
|
||
x: 480,
|
||
y: 320,
|
||
manuallyPlaced: true,
|
||
},
|
||
];
|
||
const bounds = resourceCanvasContentBounds(
|
||
positions,
|
||
new Map([
|
||
['negative-card', { width: 180, height: 128 }],
|
||
['positive-card', { width: 220, height: 180 }],
|
||
]),
|
||
);
|
||
expect(bounds).toEqual({
|
||
x: -240,
|
||
y: -160,
|
||
width: 940,
|
||
height: 660,
|
||
});
|
||
|
||
const viewport = fitResourceCanvasViewportToContent({
|
||
bounds,
|
||
canvasSize: { width: 800, height: 600 },
|
||
});
|
||
expect(viewport.scale).toBeCloseTo(768 / 940, 8);
|
||
expect(bounds.width * viewport.scale).toBeCloseTo(768, 8);
|
||
expect(viewport.x + bounds.x * viewport.scale).toBeCloseTo(16, 8);
|
||
});
|
||
|
||
it.each([
|
||
'ui-interaction',
|
||
'character',
|
||
'scene',
|
||
'audio',
|
||
'document',
|
||
'unclassified',
|
||
'version',
|
||
])(
|
||
'caps the initial fit for every resource category (%s) while keeping explicit zoom overrides available',
|
||
() => {
|
||
const viewport = fitResourceCanvasViewportToContent({
|
||
bounds: { x: 0, y: 0, width: 180, height: 128 },
|
||
canvasSize: { width: 800, height: 600 },
|
||
});
|
||
const explicitViewport = fitResourceCanvasViewportToContent({
|
||
bounds: { x: 0, y: 0, width: 180, height: 128 },
|
||
canvasSize: { width: 800, height: 600 },
|
||
maxScale: 2.5,
|
||
});
|
||
|
||
expect(viewport.scale).toBe(RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE);
|
||
expect(explicitViewport.scale).toBe(2.5);
|
||
},
|
||
);
|
||
|
||
it('accepts card sizes directly on resource items for hook integration', () => {
|
||
const wide = {
|
||
...resource('wide', 'scene', 0),
|
||
cardSize: { width: 220, height: 180 },
|
||
};
|
||
const target = {
|
||
...resource('target', 'scene', 1),
|
||
cardSize: { width: 180, height: 128 },
|
||
};
|
||
const layout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('item-size', 'dependency'),
|
||
[wide, target],
|
||
).layout;
|
||
|
||
expect(positionById(layout, 'target').x).toBe(
|
||
wide.cardSize.width + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('resource canvas dependency layer grid', () => {
|
||
const dependencySlotWidth =
|
||
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP;
|
||
const dependencySlotHeight =
|
||
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP;
|
||
|
||
function gridResources(count: number, dependencyDepth = 0) {
|
||
return Array.from({ length: count }, (_, index) =>
|
||
resource(
|
||
`art:grid-${index.toString().padStart(2, '0')}`,
|
||
'scene',
|
||
dependencyDepth,
|
||
),
|
||
);
|
||
}
|
||
|
||
function expectNoOverlappingCards(
|
||
layout: ReturnType<typeof reconcileResourceCanvasLayout>['layout'],
|
||
) {
|
||
const rects = layout.positions.map((position) => ({
|
||
position,
|
||
size: DEFAULT_RESOURCE_CANVAS_CARD_SIZE,
|
||
}));
|
||
rects.forEach((left, index) => {
|
||
rects.slice(index + 1).forEach((right) => {
|
||
expect(
|
||
left.position.x <
|
||
right.position.x +
|
||
right.size.width +
|
||
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP &&
|
||
left.position.x +
|
||
left.size.width +
|
||
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP >
|
||
right.position.x &&
|
||
left.position.y <
|
||
right.position.y +
|
||
right.size.height +
|
||
RESOURCE_CANVAS_DEPENDENCY_ROW_GAP &&
|
||
left.position.y +
|
||
left.size.height +
|
||
RESOURCE_CANVAS_DEPENDENCY_ROW_GAP >
|
||
right.position.y,
|
||
).toBe(false);
|
||
});
|
||
});
|
||
}
|
||
|
||
it('lays a single-depth section out as a grid plane instead of one column', () => {
|
||
const resources = gridResources(12);
|
||
const layout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-layer-grid', 'dependency'),
|
||
resources,
|
||
).layout;
|
||
|
||
// 12 张同深度卡:4 列 × 3 行,横向展开成平面,而不是 12 张摞成一列。
|
||
expect(
|
||
Array.from(new Set(layout.positions.map((position) => position.x))),
|
||
).toEqual([
|
||
0,
|
||
dependencySlotWidth,
|
||
2 * dependencySlotWidth,
|
||
3 * dependencySlotWidth,
|
||
]);
|
||
expect(
|
||
Array.from(new Set(layout.positions.map((position) => position.y))),
|
||
).toEqual([0, dependencySlotHeight, 2 * dependencySlotHeight]);
|
||
expect(layout.positions).toHaveLength(12);
|
||
expectNoOverlappingCards(layout);
|
||
});
|
||
|
||
it('is stable for repeated runs and independent of the resource input order', () => {
|
||
const resources = gridResources(9);
|
||
const layout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout(
|
||
'project-layer-grid-stable',
|
||
'dependency',
|
||
),
|
||
resources,
|
||
).layout;
|
||
const repeated = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout(
|
||
'project-layer-grid-stable',
|
||
'dependency',
|
||
),
|
||
resources,
|
||
).layout;
|
||
const reversed = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout(
|
||
'project-layer-grid-stable',
|
||
'dependency',
|
||
),
|
||
[...resources].reverse(),
|
||
).layout;
|
||
|
||
expect(repeated.positions).toEqual(layout.positions);
|
||
expect(reversed.positions).toEqual(layout.positions);
|
||
});
|
||
|
||
it('keeps depth bands monotone and clear of the wrapped layer', () => {
|
||
const shallow = gridResources(9);
|
||
const deep = resource('art:deep', 'scene', 1);
|
||
const layout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-layer-band', 'dependency'),
|
||
[...shallow, deep],
|
||
).layout;
|
||
const shallowPositions = shallow.map((item) =>
|
||
positionById(layout, item.id),
|
||
);
|
||
const deepPosition = positionById(layout, deep.id);
|
||
|
||
// 9 张同深度铺成 3 列,层带按 3 列预留;深度 1 的层带整体让开,仍随深度向右推进。
|
||
expect(
|
||
Array.from(new Set(shallowPositions.map((position) => position.x))).sort(
|
||
(left, right) => left - right,
|
||
),
|
||
).toEqual([0, dependencySlotWidth, 2 * dependencySlotWidth]);
|
||
expect(deepPosition.x).toBe(3 * dependencySlotWidth);
|
||
expect(
|
||
Math.max(...shallowPositions.map((position) => position.x)) +
|
||
RESOURCE_CANVAS_CARD_WIDTH +
|
||
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
|
||
).toBeLessThanOrEqual(deepPosition.x);
|
||
});
|
||
|
||
it('caps the wrapped columns so a large layer stays readable', () => {
|
||
const resources = gridResources(100);
|
||
const layout = reconcileResourceCanvasLayout(
|
||
createEmptyResourceCanvasLayout('project-layer-cap', 'dependency'),
|
||
resources,
|
||
).layout;
|
||
const xValues = Array.from(
|
||
new Set(layout.positions.map((position) => position.x)),
|
||
);
|
||
|
||
// 100 张同深度卡铺成封顶的 6 列,而不是一条 100 张的超高竖带或超长横带。
|
||
expect(xValues).toHaveLength(RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS);
|
||
expect(xValues).toHaveLength(6);
|
||
expect(Math.max(...xValues)).toBe(
|
||
(RESOURCE_CANVAS_DEPENDENCY_LAYER_MAX_COLUMNS - 1) * dependencySlotWidth,
|
||
);
|
||
expectNoOverlappingCards(layout);
|
||
});
|
||
|
||
it('keeps manual coordinates identical while the automatic grid re-derives', () => {
|
||
const resources = gridResources(9);
|
||
const source = createEmptyResourceCanvasLayout(
|
||
'project-layer-grid-manual',
|
||
'dependency',
|
||
);
|
||
source.positions = [
|
||
{
|
||
resourceId: 'art:grid-00',
|
||
section: 'scene',
|
||
x: 900,
|
||
y: 640,
|
||
manuallyPlaced: true,
|
||
},
|
||
];
|
||
const layout = reconcileResourceCanvasLayout(source, resources).layout;
|
||
// 重派生路径只保留手动坐标后重算自动坐标,手动坐标必须逐项不变。
|
||
const redriven = reconcileResourceCanvasLayout(
|
||
{
|
||
...layout,
|
||
positions: layout.positions.filter(
|
||
(position) => position.manuallyPlaced,
|
||
),
|
||
},
|
||
resources,
|
||
).layout;
|
||
|
||
expect(positionById(layout, 'art:grid-00')).toMatchObject({
|
||
x: 900,
|
||
y: 640,
|
||
manuallyPlaced: true,
|
||
});
|
||
expect(positionById(redriven, 'art:grid-00')).toMatchObject({
|
||
x: 900,
|
||
y: 640,
|
||
manuallyPlaced: true,
|
||
});
|
||
expectNoOverlappingCards(layout);
|
||
expectNoOverlappingCards(redriven);
|
||
expect(
|
||
redriven.positions.filter((position) => !position.manuallyPlaced),
|
||
).toEqual(layout.positions.filter((position) => !position.manuallyPlaced));
|
||
});
|
||
});
|