Files
Genarrative/apps/ai-game-creator-shell/tests/resourceCanvasLayoutModel.test.ts
suzmii 1c2f82e246
Project CI / Backend tests (push) Successful in 9m53s
Project CI / Repository checks (push) Successful in 3m11s
Project CI / Frontend tests (push) Successful in 4m59s
Project CI / Native shell tests (push) Successful in 19m18s
优化 Game Agent 资源画布展示与默认缩放 (#208)
## 变更内容

### 1. 普通资源画布隐藏游戏代码

- 资源画布的大纲、分页和卡片展示不再显示“游戏代码”栏目。
- 保留内部完整的五栏目布局契约,避免已有代码资源坐标被重写。
- 补充回归测试,确认游戏代码不会出现在普通资源画布中,且代码端点不可见时不会渲染对应依赖边。

### 2. 增加资源类型角标

- 资源卡右上角增加类型角标,用于快速识别资源类型。
- 类型从既有资源投影字段推导,覆盖图片、SVG、视频、音频、文档、任务产物、Agent 回执、项目版本等类型,并为未知类型兜底。
- 该能力仅用于前端展示,不新增后端字段或修改资源投影契约。

### 3. 限制默认自动适配缩放

- 画布首次自动适配和显式复位时的默认最大缩放统一限制为 `1.5`,避免内容较少或只有一个资源时卡片被放得过大。
- 该限制对设计文档、美术、音频、游戏代码、项目版本全部资源栏目生效。
- 用户主动缩放仍沿用现有上限,不受默认适配上限影响。
- 用户调整后的画布缩放会按排序模式和栏目保留;切换到其他栏目再返回后,仍恢复用户调整后的 viewport,而不是重新自动放大。

## 兼容性

- 不修改后端 API、SpacetimeDB schema 或 `/api/external/v1`。
- 资源类型角标是展示层推导逻辑,不改变资源数据结构。
- 内部布局仍保留游戏代码栏目契约,已有布局数据不需要迁移。

## 验证

- `resourceCanvasLayoutModel.test.ts`
- `project-development.suite.ts` 相关 App Surface 用例
- `npm run typecheck`
- `npm run check:encoding`
- Prettier 相关文件检查
- `git diff --check`

历史分支检查不替代最新 head CI;PR 更新后将以最新 CI 结果为准。

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/208
Co-authored-by: 董羽秦 <suzmii@qq.com>
Co-committed-by: 董羽秦 <suzmii@qq.com>
2026-08-31 14:36:58 +08:00

872 lines
27 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
isSafeProjectResourceCanvasCoordinate,
} 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_ROW_GAP,
RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
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 === 'art' ? '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', 'art', 0),
resource('b', 'art', 0),
resource('c', 'art', 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('drops a persisted position whose section no longer matches the resource', () => {
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', 'art'),
]);
expect(reconciled.layout.positions).toEqual([
{
resourceId: 'asset-a',
section: 'art',
x: 0,
y: 0,
manuallyPlaced: false,
},
]);
});
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', 'art'),
subtype: 'ui-prototype',
mediaType: 'image/png',
label: '甲界面',
},
{
...resource('art-spritesheet-b', 'art'),
subtype: 'art-spritesheet',
mediaType: 'image/png',
label: '乙图集',
},
{
...resource('art-spritesheet-a', 'art'),
subtype: 'art-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('keeps connected art cards close while placing separate components before isolates', () => {
const layout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-clusters', 'dependency'),
[
resource('art:canvas', 'art', 0),
resource('art:wireframe', 'art', 1),
resource('art:character', 'art', 0),
resource('art:screen', 'art', 1),
resource('art:unrelated', 'art', 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', 'art', 0),
resource('art:b', 'art', 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', 'art', 0),
resource('source:top', 'art', 0),
resource('target:first', 'art', 1),
resource('target:second', 'art', 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', 'art', 0),
resource('middle:upper', 'art', 1),
resource('middle:lower', 'art', 1),
resource('target', 'art', 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', 'art', 2),
resource('art:cycle-b', 'art', 2),
resource('art:after-cycle', 'art', 3),
resource('art:self', 'art', 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', 'art', 2),
resource('art:cycle-b', 'art', 2),
resource('art:sibling', 'art', 2),
resource('art:after', 'art', 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 cycleRows = ['art:cycle-a', 'art:cycle-b']
.map((resourceId) => positionById(layout, resourceId).y)
.sort((left, right) => left - right);
const siblingRow = positionById(layout, 'art:sibling').y;
expect(cycleRows[1] - cycleRows[0]).toBe(
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
);
expect(siblingRow).toBeGreaterThan(cycleRows[1]);
});
it('is deterministic, does not modify type placement, and avoids historical manual positions', () => {
const resources = [
resource('art:source', 'art', 0),
resource('art:target', 'art', 1),
resource('art:other', 'art', 1),
];
const topology = dependencyTopology([
{ sourceResourceId: 'art:source', targetResourceId: 'art:target' },
]);
const source = createEmptyResourceCanvasLayout(
'project-stable',
'dependency',
);
source.positions = [
{
resourceId: 'art:other',
section: 'art',
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', 'art', 4_385),
resource('art:depth-4386-a', 'art', 4_386),
resource('art:depth-4386-b', 'art', 4_386),
resource('art:depth-9000', 'art', 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')}`, 'art'),
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')}`,
'art',
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', 'art'), 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', 'art')],
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', 'art'), resource('wide-b', 'art')];
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', 'art', 0),
resource('source-b', 'art', 0),
resource('target', 'art', 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: 'art',
x: -240,
y: -160,
manuallyPlaced: true,
},
{
resourceId: 'positive-card',
section: 'art',
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: 'art',
x: -240,
y: -160,
manuallyPlaced: true,
},
{
resourceId: 'positive-card',
section: 'art',
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(['document', 'art', 'audio', 'code', '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', 'art', 0),
cardSize: { width: 220, height: 180 },
};
const target = {
...resource('target', 'art', 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,
);
});
});