532c6d51fd
## 背景 本合并请求整合 Game Agent 资源管理的栏目分页自由画布,以及图片持续精修、候选生成和本地恢复事务。 当前远端比较基线: - base:`master` @ `b00a3f80576949567bf3320f0c6c92b4ed0a649d` - head:`codex/game-agent-resource-section-pages` @ `5795d2f9b8797c342dcf2920ccefec2a3ff3f472` - 相对 base:28 commits、33 files(`+3746 / -1333`) ## 主要改动 ### 资源管理分页自由画布 - 固定展示“设计文档 → 美术资源 → 音乐音效 → 游戏代码 → 项目版本”五个栏目;空栏目仍可从悬浮 Dock 打开空画布。 - `按依赖` 与 `按类型` 共用栏目分页画布;每个“排序模式 + 栏目”组合独立保存 viewport。 - 普通滚轮使用有界意图队列切换栏目;Ctrl/Meta + 滚轮以指针为锚点缩放;空白拖拽可沿 x/y 无限平移,不以资源 extent 作为导航边界。 - 资源卡支持拖拽布局与一次 CAS 持久化;切页、排序切换和卸载会统一取消拖拽及 pointer capture。 - 资源详情为非模态独立卡片:打开、切换或关闭详情不卸载背景画布、资源卡或依赖连线,也不重置 viewport、搜索或排序状态。 - 顶部已移除“生成视频 / 生成音效 / 生成背景音乐 / 新增 UI 设计”的手动入口;保留播放、未完成编辑恢复、排序和画布复位,以及资源详情中的既有编辑动作。 ### 图片持续精修与生成事务 - 图片资源可进入唯一活动精修草稿,支持原生批量导入、图片下方快速编辑卡、候选图层、任务侧栏和“设为最终图”。 - 生成成功只合并候选图层和 generation 权威事实,不以全量 hydrate 覆盖生成期间的本地编辑。 - 候选媒体、图层和公开 generation 记录写入并回读成功后,才推进私有 `candidate-ready`。 - 候选确认接入 Surface 保存 FIFO 与重新打开 hydrate;revision-sensitive 操作等待确认屏障。 - 失败归档和正式图提交采用可恢复中间态,避免中断后出现幽灵任务、重复 revision 或不一致的正式资产。 ### 合同与文档 - `acknowledgeCandidateLayers`、`importLocalImages`、`archiveFailedGeneration` 为必选 Host Port;不支持的宿主返回结构化 `unsupported-capability`。 - 同步更新 TypeScript / Rust 合同、Tauri command 可达性、PRD、技术方案与项目共享记忆。 - 明确资源栏目无限画布合同:普通平移不夹取;资源 extent、图片测量、布局变更和 resize 不得重置用户 viewport;仅首次可测量布局与显式复位按真实卡片包围盒适配内容。 ## 验证 CI run [#1290](https://git.genarrative.world/git/GenarrativeAI/Genarrative/actions/runs/1290) 针对当前 head 已完成且四项均成功: - [x] Repository checks - [x] Frontend tests(235 test files、3166 tests passed;App Surface 410 tests) - [x] Backend tests - [x] Native shell tests 另有本地定向检查: - [x] `npm run agc:typecheck` - [x] 资源画布、App Surface、Asset Canvas 与资源实时集成定向回归 - [x] Rust 候选确认、候选中断恢复、失败归档与恢复定向测试 - [x] `cargo fmt --check` - [x] `npm run check:encoding` - [x] `git diff --check` ## 评审 provenance - review #247、#248、#252 都针对旧 head,当前在 Gitea 中均标记为 stale;不将其写作“已处理 #247”或当前 head 的评审结论。 - review #253 针对旧 `5456a1d…` head,提出 PRD 退役入口、共享 pitfalls 的 extent 旧合同和 PR 正文事实更新三项 P2;对应文档已在 `5795d2f…` head 修复并推送。 - 早期由 PR #176 引入的候选持久化、归档、Host Port 与快速编辑卡问题,当前已在 base/head 中具备对应修复,不作为本轮重复阻断项。 - 当前 head 仍需新的、针对最新提交的审批;历史 stale review 不能替代当前 head approval。 ## 影响范围 - 修改 AI 游戏创作 App 的资源管理、图片精修、Tauri 本地持久化与恢复语义,以及共享合同和权威文档。 - 会调整本地资源布局、精修 draft 和私有 generation ledger 的状态机。 - 不修改 SpacetimeDB schema,不新增或变更 `/api/external/v1` 路由。 --------- Co-authored-by: kdletters <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/181 Co-authored-by: suzmii <suzmii@qq.com> Co-committed-by: suzmii <suzmii@qq.com>
853 lines
26 KiB
TypeScript
853 lines
26 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,
|
|
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('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,
|
|
);
|
|
});
|
|
});
|