Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts
T
suzmii 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
2026-10-04 13:11:37 +08:00

1247 lines
40 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 {
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));
});
});