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>
1054 lines
33 KiB
TypeScript
1054 lines
33 KiB
TypeScript
/** @vitest-environment jsdom */
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
|
|
import {
|
|
act,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
} from '@testing-library/react';
|
|
import React from 'react';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { ProjectResourceCanvasPosition } from '../../../packages/shared/src/contracts/gameCreationApp';
|
|
import type { ProjectResourceCanvasSection } from '../../../packages/shared/src/contracts/gameCreationApp';
|
|
import {
|
|
RESOURCE_CANVAS_CARD_WIDTH,
|
|
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
|
|
type ResourceCanvasCardSize,
|
|
} from '../src/view/project-development/resourceCanvasLayoutModel';
|
|
import {
|
|
normalizeProjectResourceGraph,
|
|
type ProjectResourceGraph,
|
|
type ProjectResourceGraphReadModel,
|
|
} from '../src/view/project-development/resourceDependencyGraphModel';
|
|
import {
|
|
ResourceDependencyOverlay,
|
|
type ResourceDependencyOverlayHandle,
|
|
} from '../src/view/project-development/ResourceDependencyOverlay';
|
|
|
|
function position(
|
|
resourceId: string,
|
|
x: number,
|
|
y: number,
|
|
section: ProjectResourceCanvasSection = 'art',
|
|
): ProjectResourceCanvasPosition {
|
|
return {
|
|
resourceId,
|
|
section,
|
|
x,
|
|
y,
|
|
manuallyPlaced: false,
|
|
};
|
|
}
|
|
|
|
function graphFixture() {
|
|
const referenceId = 'asset-reference:["source:one","target:one"]';
|
|
const selfReferenceId = 'asset-reference:["unrelated","unrelated"]';
|
|
const flowId = 'task-flow:["source-task","target-task"]';
|
|
const resourceIds = [
|
|
'source:one',
|
|
'source:two',
|
|
'source:three',
|
|
'target:one',
|
|
'target:two',
|
|
'target:three',
|
|
'unrelated',
|
|
];
|
|
const readModel: ProjectResourceGraphReadModel = {
|
|
resourceIds,
|
|
referenceEdges: [
|
|
{
|
|
id: referenceId,
|
|
kind: 'asset-reference',
|
|
sourceResourceId: 'source:one',
|
|
targetResourceId: 'target:one',
|
|
cyclic: false,
|
|
},
|
|
{
|
|
id: selfReferenceId,
|
|
kind: 'asset-reference',
|
|
sourceResourceId: 'unrelated',
|
|
targetResourceId: 'unrelated',
|
|
cyclic: true,
|
|
},
|
|
],
|
|
taskFlows: [
|
|
{
|
|
id: flowId,
|
|
kind: 'task-flow',
|
|
sourceTaskId: 'source-task',
|
|
targetTaskId: 'target-task',
|
|
sourceResourceIds: ['source:one', 'source:two', 'source:three'],
|
|
targetResourceIds: ['target:one', 'target:two', 'target:three'],
|
|
cyclic: false,
|
|
},
|
|
],
|
|
connectionIndex: resourceIds.map((resourceId) => ({
|
|
resourceId,
|
|
upstreamReferenceResourceIds:
|
|
resourceId === 'target:one'
|
|
? ['source:one']
|
|
: resourceId === 'unrelated'
|
|
? ['unrelated']
|
|
: [],
|
|
downstreamReferenceResourceIds:
|
|
resourceId === 'source:one'
|
|
? ['target:one']
|
|
: resourceId === 'unrelated'
|
|
? ['unrelated']
|
|
: [],
|
|
referenceEdgeIds:
|
|
resourceId === 'source:one' || resourceId === 'target:one'
|
|
? [referenceId]
|
|
: resourceId === 'unrelated'
|
|
? [selfReferenceId]
|
|
: [],
|
|
taskFlowIds: resourceId === 'unrelated' ? [] : [flowId],
|
|
})),
|
|
producerAssignments: [],
|
|
dependencyDepths: [],
|
|
unresolvedReferenceResourceIds: [],
|
|
cyclicResourceIds: ['unrelated'],
|
|
cyclicTaskIds: [],
|
|
producerMappingTruncated: false,
|
|
};
|
|
return normalizeProjectResourceGraph(readModel);
|
|
}
|
|
|
|
function OverlayHarness({
|
|
graph,
|
|
positions,
|
|
visibleResourceIds,
|
|
overlayRef,
|
|
}: {
|
|
graph: ProjectResourceGraph;
|
|
positions: ProjectResourceCanvasPosition[];
|
|
visibleResourceIds: ReadonlySet<string>;
|
|
overlayRef?: React.Ref<ResourceDependencyOverlayHandle>;
|
|
}) {
|
|
const sections = Array.from(
|
|
new Set(positions.map((position) => position.section)),
|
|
);
|
|
return React.createElement(
|
|
'div',
|
|
{ 'data-testid': 'resource-dependency-overlay' },
|
|
...sections.map((section) =>
|
|
React.createElement(
|
|
'div',
|
|
{
|
|
key: section,
|
|
'data-resource-section-scroll': section,
|
|
},
|
|
React.createElement(
|
|
'div',
|
|
{
|
|
'data-resource-section-plane': section,
|
|
'data-resource-section-scale': '1',
|
|
},
|
|
React.createElement(ResourceDependencyOverlay, {
|
|
ref: section === sections[0] ? overlayRef : undefined,
|
|
graph,
|
|
positions,
|
|
section,
|
|
visibleResourceIds,
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
function overlayView(
|
|
graph: ProjectResourceGraph,
|
|
positions: ProjectResourceCanvasPosition[],
|
|
visibleResourceIds: ReadonlySet<string>,
|
|
overlayRef?: React.Ref<ResourceDependencyOverlayHandle>,
|
|
) {
|
|
return React.createElement(OverlayHarness, {
|
|
graph,
|
|
positions,
|
|
visibleResourceIds,
|
|
overlayRef,
|
|
});
|
|
}
|
|
|
|
describe('ResourceDependencyOverlay', () => {
|
|
it('renders exact references without visualizing aggregated task flows', async () => {
|
|
const graph = graphFixture();
|
|
const positions = Array.from(graph.resourceIds).map((resourceId, index) =>
|
|
position(resourceId, (index % 3) * 220, Math.floor(index / 3) * 120),
|
|
);
|
|
render(overlayView(graph, positions, new Set(graph.resourceIds)));
|
|
|
|
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
|
await waitFor(() =>
|
|
expect(
|
|
overlay.querySelectorAll('[data-edge-kind="asset-reference"]'),
|
|
).toHaveLength(2),
|
|
);
|
|
expect(overlay.querySelector('[data-edge-kind="task-flow"]')).toBeNull();
|
|
expect(overlay.querySelector('marker[id$="-task-flow-arrow"]')).toBeNull();
|
|
});
|
|
|
|
it('在统一 world 模式下渲染跨分类引用,并使用稳定的 world 标识', async () => {
|
|
const graph = graphFixture();
|
|
const positions = [
|
|
position('source:one', 0, 0, 'art'),
|
|
position('target:one', 220, 0, 'document'),
|
|
];
|
|
render(
|
|
React.createElement(
|
|
'div',
|
|
null,
|
|
React.createElement(ResourceDependencyOverlay, {
|
|
graph,
|
|
positions,
|
|
visibleResourceIds: new Set(
|
|
positions.map(({ resourceId }) => resourceId),
|
|
),
|
|
}),
|
|
),
|
|
);
|
|
|
|
const overlay = await screen.findByTestId(
|
|
'resource-dependency-overlay-world',
|
|
);
|
|
expect(overlay.getAttribute('data-resource-section')).toBeNull();
|
|
expect(
|
|
overlay.querySelector(
|
|
'[data-edge-kind="asset-reference"][data-source-resource-id="source:one"][data-target-resource-id="target:one"]',
|
|
),
|
|
).not.toBeNull();
|
|
});
|
|
it('omits task flows and cross-section exact references', async () => {
|
|
const graph = graphFixture();
|
|
const positions = [
|
|
position('source:one', 0, 0, 'document'),
|
|
position('source:two', 0, 0, 'art'),
|
|
position('source:three', 0, 120, 'document'),
|
|
position('target:one', 240, 0, 'art'),
|
|
position('target:two', 240, 120, 'art'),
|
|
position('target:three', 240, 0, 'audio'),
|
|
];
|
|
render(
|
|
overlayView(
|
|
graph,
|
|
positions,
|
|
new Set(positions.map((position) => position.resourceId)),
|
|
),
|
|
);
|
|
|
|
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
|
await waitFor(() =>
|
|
expect(
|
|
overlay.querySelectorAll('[data-edge-kind="task-flow"]'),
|
|
).toHaveLength(0),
|
|
);
|
|
expect(
|
|
overlay.querySelectorAll('[data-edge-kind="asset-reference"]'),
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
it('owns each relationship in exactly one section plane and isolates section scrolling', async () => {
|
|
const resourceIds = [
|
|
'art:source',
|
|
'art:target',
|
|
'version:source',
|
|
'version:target',
|
|
];
|
|
const artReferenceId = 'reference:art';
|
|
const versionReferenceId = 'reference:version';
|
|
const graph = normalizeProjectResourceGraph({
|
|
resourceIds,
|
|
referenceEdges: [
|
|
{
|
|
id: artReferenceId,
|
|
kind: 'asset-reference',
|
|
sourceResourceId: 'art:source',
|
|
targetResourceId: 'art:target',
|
|
cyclic: false,
|
|
},
|
|
{
|
|
id: versionReferenceId,
|
|
kind: 'asset-reference',
|
|
sourceResourceId: 'version:source',
|
|
targetResourceId: 'version:target',
|
|
cyclic: false,
|
|
},
|
|
],
|
|
taskFlows: [],
|
|
connectionIndex: resourceIds.map((resourceId) => ({
|
|
resourceId,
|
|
upstreamReferenceResourceIds: resourceId.endsWith(':target')
|
|
? [resourceId.replace(':target', ':source')]
|
|
: [],
|
|
downstreamReferenceResourceIds: resourceId.endsWith(':source')
|
|
? [resourceId.replace(':source', ':target')]
|
|
: [],
|
|
referenceEdgeIds: [
|
|
resourceId.startsWith('art:') ? artReferenceId : versionReferenceId,
|
|
],
|
|
taskFlowIds: [],
|
|
})),
|
|
producerAssignments: [],
|
|
dependencyDepths: [],
|
|
unresolvedReferenceResourceIds: [],
|
|
cyclicResourceIds: [],
|
|
cyclicTaskIds: [],
|
|
producerMappingTruncated: false,
|
|
});
|
|
const positions = [
|
|
position('art:source', 0, 0, 'art'),
|
|
position('art:target', 240, 0, 'art'),
|
|
position('version:source', 0, 0, 'version'),
|
|
position('version:target', 240, 0, 'version'),
|
|
];
|
|
const originalGetBoundingClientRect =
|
|
HTMLElement.prototype.getBoundingClientRect;
|
|
const getBoundingClientRect = vi
|
|
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
|
.mockImplementation(function getSectionRect(this: HTMLElement) {
|
|
if (this.hasAttribute('data-resource-section-scroll')) {
|
|
return {
|
|
x: 0,
|
|
y: 0,
|
|
left: 0,
|
|
top: 0,
|
|
right: 600,
|
|
bottom: 200,
|
|
width: 600,
|
|
height: 200,
|
|
toJSON: () => ({}),
|
|
} as DOMRect;
|
|
}
|
|
return originalGetBoundingClientRect.call(this);
|
|
});
|
|
try {
|
|
const rendered = render(
|
|
overlayView(graph, positions, new Set(resourceIds)),
|
|
);
|
|
const artOverlay = await screen.findByTestId(
|
|
'resource-dependency-overlay-art',
|
|
);
|
|
const versionOverlay = await screen.findByTestId(
|
|
'resource-dependency-overlay-version',
|
|
);
|
|
await waitFor(() => {
|
|
expect(
|
|
artOverlay.querySelector(`[data-edge-id='${artReferenceId}']`),
|
|
).not.toBeNull();
|
|
expect(
|
|
versionOverlay.querySelector(
|
|
`[data-edge-id='${versionReferenceId}']`,
|
|
),
|
|
).not.toBeNull();
|
|
});
|
|
expect(
|
|
artOverlay.querySelector(`[data-edge-id='${versionReferenceId}']`),
|
|
).toBeNull();
|
|
expect(
|
|
versionOverlay.querySelector(`[data-edge-id='${artReferenceId}']`),
|
|
).toBeNull();
|
|
const versionPathBeforeScroll = versionOverlay
|
|
.querySelector(`[data-edge-id='${versionReferenceId}']`)
|
|
?.getAttribute('d');
|
|
|
|
const artViewport = rendered.container.querySelector<HTMLElement>(
|
|
'[data-resource-section-scroll="art"]',
|
|
);
|
|
if (!artViewport) {
|
|
throw new Error('missing art viewport');
|
|
}
|
|
artViewport.scrollTop = 80;
|
|
fireEvent.scroll(artViewport);
|
|
await waitFor(() =>
|
|
expect(artOverlay.getAttribute('data-logical-viewport')).toBe(
|
|
'0,80,600,200',
|
|
),
|
|
);
|
|
expect(versionOverlay.getAttribute('data-logical-viewport')).toBe(
|
|
'0,0,600,200',
|
|
);
|
|
expect(
|
|
versionOverlay
|
|
.querySelector(`[data-edge-id='${versionReferenceId}']`)
|
|
?.getAttribute('d'),
|
|
).toBe(versionPathBeforeScroll);
|
|
} finally {
|
|
getBoundingClientRect.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('assigns stable separate ports to same-section reference edges', async () => {
|
|
const resourceIds = ['source', 'target:upper', 'target:lower'];
|
|
const graph = normalizeProjectResourceGraph({
|
|
resourceIds,
|
|
referenceEdges: [
|
|
{
|
|
id: 'reference:upper',
|
|
kind: 'asset-reference',
|
|
sourceResourceId: 'source',
|
|
targetResourceId: 'target:upper',
|
|
cyclic: false,
|
|
},
|
|
{
|
|
id: 'reference:lower',
|
|
kind: 'asset-reference',
|
|
sourceResourceId: 'source',
|
|
targetResourceId: 'target:lower',
|
|
cyclic: false,
|
|
},
|
|
],
|
|
taskFlows: [],
|
|
connectionIndex: resourceIds.map((resourceId) => ({
|
|
resourceId,
|
|
upstreamReferenceResourceIds: resourceId === 'source' ? [] : ['source'],
|
|
downstreamReferenceResourceIds:
|
|
resourceId === 'source' ? ['target:upper', 'target:lower'] : [],
|
|
referenceEdgeIds:
|
|
resourceId === 'source'
|
|
? ['reference:upper', 'reference:lower']
|
|
: [
|
|
resourceId === 'target:upper'
|
|
? 'reference:upper'
|
|
: 'reference:lower',
|
|
],
|
|
taskFlowIds: [],
|
|
})),
|
|
producerAssignments: [],
|
|
dependencyDepths: [],
|
|
unresolvedReferenceResourceIds: [],
|
|
cyclicResourceIds: [],
|
|
cyclicTaskIds: [],
|
|
producerMappingTruncated: false,
|
|
});
|
|
const positions = [
|
|
position('source', 0, 72),
|
|
position('target:upper', 240, 0),
|
|
position('target:lower', 240, 144),
|
|
];
|
|
const view = render(
|
|
overlayView(graph, positions, new Set(graph.resourceIds)),
|
|
);
|
|
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
|
const paths = await waitFor(() => {
|
|
const result = Array.from(
|
|
overlay.querySelectorAll<SVGPathElement>(
|
|
'[data-edge-kind="asset-reference"]',
|
|
),
|
|
);
|
|
expect(result).toHaveLength(2);
|
|
return result;
|
|
});
|
|
expect(
|
|
paths.map((path) => path.getAttribute('data-source-port-offset')),
|
|
).toEqual(['7', '-7']);
|
|
expect(new Set(paths.map((path) => path.getAttribute('d'))).size).toBe(2);
|
|
|
|
const firstPaths = paths.map((path) => path.getAttribute('d'));
|
|
view.rerender(overlayView(graph, positions, new Set(graph.resourceIds)));
|
|
await waitFor(() =>
|
|
expect(
|
|
Array.from(
|
|
overlay.querySelectorAll<SVGPathElement>(
|
|
'[data-edge-kind="asset-reference"]',
|
|
),
|
|
(path) => path.getAttribute('d'),
|
|
),
|
|
).toEqual(firstPaths),
|
|
);
|
|
});
|
|
|
|
it('filters hidden endpoints and updates path geometry when positions change', async () => {
|
|
const graph = graphFixture();
|
|
const positions = [
|
|
position('source:one', 0, 0),
|
|
position('target:one', 240, 0),
|
|
];
|
|
const overlayRef = React.createRef<ResourceDependencyOverlayHandle>();
|
|
const view = render(
|
|
overlayView(
|
|
graph,
|
|
positions,
|
|
new Set(['source:one', 'target:one']),
|
|
overlayRef,
|
|
),
|
|
);
|
|
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
|
const firstPath = await waitFor(() => {
|
|
const path = overlay.querySelector<SVGPathElement>(
|
|
'[data-edge-kind="asset-reference"]',
|
|
);
|
|
expect(path).not.toBeNull();
|
|
return path?.getAttribute('d');
|
|
});
|
|
const horizontalReference = overlay.querySelector<SVGPathElement>(
|
|
'[data-edge-kind="asset-reference"]',
|
|
);
|
|
expect(horizontalReference?.getAttribute('data-route-axis')).toBe(
|
|
'horizontal',
|
|
);
|
|
expect(horizontalReference?.getAttribute('marker-end')).toContain(
|
|
'asset-reference-arrow',
|
|
);
|
|
expect(firstPath).toContain('230 64');
|
|
|
|
act(() =>
|
|
overlayRef.current?.updateDragPreview({
|
|
resourceId: 'source:one',
|
|
x: 80,
|
|
y: 40,
|
|
}),
|
|
);
|
|
await waitFor(() =>
|
|
expect(
|
|
overlay
|
|
.querySelector('[data-edge-kind="asset-reference"]')
|
|
?.getAttribute('d'),
|
|
).not.toBe(firstPath),
|
|
);
|
|
|
|
view.rerender(overlayView(graph, positions, new Set(['target:one'])));
|
|
await waitFor(() =>
|
|
expect(
|
|
overlay.querySelector('[data-edge-kind="asset-reference"]'),
|
|
).toBeNull(),
|
|
);
|
|
});
|
|
|
|
it('keeps reference endpoints in the same logical plane that owns scaling', async () => {
|
|
const graph = graphFixture();
|
|
render(
|
|
React.createElement(
|
|
'div',
|
|
{ 'data-testid': 'resource-dependency-overlay' },
|
|
React.createElement(
|
|
'div',
|
|
{ 'data-resource-section-scroll': 'art' },
|
|
React.createElement(
|
|
'div',
|
|
{
|
|
'data-resource-section-plane': 'art',
|
|
'data-resource-section-scale': '1.5',
|
|
},
|
|
React.createElement(ResourceDependencyOverlay, {
|
|
graph,
|
|
positions: [
|
|
position('source:one', 0, 0),
|
|
position(
|
|
'target:one',
|
|
RESOURCE_CANVAS_CARD_WIDTH +
|
|
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
|
|
0,
|
|
),
|
|
],
|
|
section: 'art',
|
|
visibleResourceIds: new Set(['source:one', 'target:one']),
|
|
geometryRevision: '340@1.5',
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
|
const reference = await waitFor(() => {
|
|
const path = overlay.querySelector<SVGPathElement>(
|
|
'[data-edge-kind="asset-reference"]',
|
|
);
|
|
expect(path).not.toBeNull();
|
|
return path as SVGPathElement;
|
|
});
|
|
expect(reference.getAttribute('data-route-axis')).toBe('horizontal');
|
|
expect(reference.getAttribute('d')).toBe('M 180 64 L 218 64');
|
|
expect(reference.closest('[data-resource-section-scale="1.5"]')).not.toBe(
|
|
null,
|
|
);
|
|
});
|
|
|
|
it('routes references from each resource actual card rectangle', async () => {
|
|
const graph = graphFixture();
|
|
const cardSizeByResourceId = new Map<string, ResourceCanvasCardSize>([
|
|
['source:one', { width: 220, height: 180 }],
|
|
['target:one', { width: 96, height: 180 }],
|
|
]);
|
|
render(
|
|
React.createElement(
|
|
'div',
|
|
{ 'data-testid': 'resource-dependency-overlay' },
|
|
React.createElement(
|
|
'div',
|
|
{ 'data-resource-section-scroll': 'art' },
|
|
React.createElement(
|
|
'div',
|
|
{
|
|
'data-resource-section-plane': 'art',
|
|
'data-resource-section-scale': '1',
|
|
},
|
|
React.createElement(ResourceDependencyOverlay, {
|
|
graph,
|
|
positions: [
|
|
position('source:one', 0, 0),
|
|
position('target:one', 268, 0),
|
|
],
|
|
section: 'art',
|
|
visibleResourceIds: new Set(['source:one', 'target:one']),
|
|
cardSizeByResourceId,
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
|
const reference = await waitFor(() => {
|
|
const path = overlay.querySelector<SVGPathElement>(
|
|
'[data-edge-kind="asset-reference"]',
|
|
);
|
|
expect(path).not.toBeNull();
|
|
return path as SVGPathElement;
|
|
});
|
|
expect(reference.getAttribute('data-route-axis')).toBe('horizontal');
|
|
expect(reference.getAttribute('d')).toBe('M 220 90 L 258 90');
|
|
});
|
|
|
|
it('keeps a relationship mounted while scrolling partial cards and marks an offscreen continuation', async () => {
|
|
const graph = graphFixture();
|
|
const originalGetBoundingClientRect =
|
|
HTMLElement.prototype.getBoundingClientRect;
|
|
const getBoundingClientRect = vi
|
|
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
|
.mockImplementation(function getSectionRect(this: HTMLElement) {
|
|
const rect = (
|
|
left: number,
|
|
top: number,
|
|
width: number,
|
|
height: number,
|
|
) =>
|
|
({
|
|
x: left,
|
|
y: top,
|
|
left,
|
|
top,
|
|
right: left + width,
|
|
bottom: top + height,
|
|
width,
|
|
height,
|
|
toJSON: () => ({}),
|
|
}) as DOMRect;
|
|
if (this.classList.contains('test-resource-content')) {
|
|
return rect(0, 0, 600, 600);
|
|
}
|
|
if (this.dataset.resourceSectionScroll === 'art') {
|
|
return rect(0, 100, 600, 240);
|
|
}
|
|
if (this.dataset.resourceSectionPlane === 'art') {
|
|
const viewport = this.closest<HTMLElement>(
|
|
'[data-resource-section-scroll="art"]',
|
|
);
|
|
return rect(0, 100 - (viewport?.scrollTop ?? 0), 600, 600);
|
|
}
|
|
return originalGetBoundingClientRect.call(this);
|
|
});
|
|
try {
|
|
const rendered = render(
|
|
React.createElement(
|
|
'div',
|
|
{
|
|
className: 'test-resource-content',
|
|
'data-testid': 'resource-dependency-overlay',
|
|
},
|
|
React.createElement(
|
|
'div',
|
|
{ 'data-resource-section-scroll': 'art' },
|
|
React.createElement(
|
|
'div',
|
|
{
|
|
'data-resource-section-plane': 'art',
|
|
'data-resource-section-scale': '1',
|
|
},
|
|
React.createElement(ResourceDependencyOverlay, {
|
|
graph,
|
|
positions: [
|
|
position('source:one', 0, 0),
|
|
position('target:one', 0, 320),
|
|
],
|
|
section: 'art',
|
|
visibleResourceIds: new Set(['source:one', 'target:one']),
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
|
const selector = '[data-edge-kind="asset-reference"]';
|
|
const reference = await waitFor(() => {
|
|
const path = overlay.querySelector<SVGPathElement>(selector);
|
|
expect(path).not.toBeNull();
|
|
expect(path?.getAttribute('data-route-axis')).toBe('vertical');
|
|
return path as SVGPathElement;
|
|
});
|
|
expect(reference.getAttribute('marker-end')).toContain(
|
|
'asset-reference-arrow',
|
|
);
|
|
expect(reference.getAttribute('data-edge-continuation')).toBe('outgoing');
|
|
expect(reference.getAttribute('data-offscreen-target')).toBe('true');
|
|
|
|
const viewport = rendered.container.querySelector<HTMLElement>(
|
|
'[data-resource-section-scroll="art"]',
|
|
);
|
|
if (!viewport) {
|
|
throw new Error('missing art viewport');
|
|
}
|
|
viewport.scrollTop = 80;
|
|
fireEvent.scroll(viewport);
|
|
await waitFor(() =>
|
|
expect(overlay.querySelector(selector)).toBe(reference),
|
|
);
|
|
viewport.scrollTop = 180;
|
|
fireEvent.scroll(viewport);
|
|
await waitFor(() => {
|
|
expect(overlay.querySelector(selector)).toBe(reference);
|
|
expect(reference.getAttribute('data-edge-continuation')).toBe(
|
|
'incoming',
|
|
);
|
|
});
|
|
viewport.scrollTop = 500;
|
|
fireEvent.scroll(viewport);
|
|
await waitFor(() =>
|
|
expect(Boolean(overlay.querySelector(selector))).toBe(false),
|
|
);
|
|
} finally {
|
|
getBoundingClientRect.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('culls paged dependency edges from the transformed canvas viewport', async () => {
|
|
const graph = graphFixture();
|
|
const originalGetBoundingClientRect =
|
|
HTMLElement.prototype.getBoundingClientRect;
|
|
const getBoundingClientRect = vi
|
|
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
|
.mockImplementation(function getPagedCanvasRect(this: HTMLElement) {
|
|
if (this.dataset.resourceSectionScroll === 'art') {
|
|
return {
|
|
x: 0,
|
|
y: 0,
|
|
left: 0,
|
|
top: 0,
|
|
right: 600,
|
|
bottom: 240,
|
|
width: 600,
|
|
height: 240,
|
|
toJSON: () => ({}),
|
|
} as DOMRect;
|
|
}
|
|
return originalGetBoundingClientRect.call(this);
|
|
});
|
|
const view = (canvasViewport: { x: number; y: number; scale: number }) =>
|
|
React.createElement(
|
|
'div',
|
|
{ className: 'game-resource-canvas--dependency' },
|
|
React.createElement(
|
|
'div',
|
|
{ 'data-resource-section-scroll': 'art' },
|
|
React.createElement(
|
|
'div',
|
|
{ className: 'game-resource-page-world' },
|
|
React.createElement(ResourceDependencyOverlay, {
|
|
graph,
|
|
positions: [
|
|
position('source:one', 0, 0),
|
|
position('target:one', 0, 320),
|
|
],
|
|
section: 'art',
|
|
visibleResourceIds: new Set(['source:one', 'target:one']),
|
|
canvasViewport,
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
try {
|
|
const rendered = render(view({ x: 0, y: 0, scale: 1 }));
|
|
const overlay = await screen.findByTestId(
|
|
'resource-dependency-overlay-art',
|
|
);
|
|
const selector = '[data-edge-kind="asset-reference"]';
|
|
await waitFor(() => {
|
|
expect(overlay.dataset.logicalViewport).toBe('0,0,600,240');
|
|
expect(
|
|
overlay
|
|
.querySelector(selector)
|
|
?.getAttribute('data-edge-continuation'),
|
|
).toBe('outgoing');
|
|
});
|
|
|
|
rendered.rerender(view({ x: 0, y: -180, scale: 1 }));
|
|
await waitFor(() => {
|
|
expect(overlay.dataset.logicalViewport).toBe('0,180,600,240');
|
|
expect(
|
|
overlay
|
|
.querySelector(selector)
|
|
?.getAttribute('data-edge-continuation'),
|
|
).toBe('incoming');
|
|
});
|
|
|
|
rendered.rerender(view({ x: 0, y: -1_000, scale: 2 }));
|
|
await waitFor(() => {
|
|
expect(overlay.dataset.logicalViewport).toBe('0,500,300,120');
|
|
expect(overlay.querySelector(selector)).toBeNull();
|
|
});
|
|
} finally {
|
|
getBoundingClientRect.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('keeps drag previews in plane-local coordinates', async () => {
|
|
const getBoundingClientRect = vi
|
|
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
|
.mockImplementation(function () {
|
|
const isPlane = this.hasAttribute('data-resource-section-plane');
|
|
return {
|
|
x: isPlane ? 160 : 20,
|
|
y: isPlane ? 90 : 10,
|
|
left: isPlane ? 160 : 20,
|
|
top: isPlane ? 90 : 10,
|
|
right: 0,
|
|
bottom: 0,
|
|
width: 0,
|
|
height: 0,
|
|
toJSON: () => ({}),
|
|
};
|
|
});
|
|
try {
|
|
const graph = graphFixture();
|
|
const positions = [
|
|
position('source:one', 0, 0),
|
|
position('target:one', 240, 0),
|
|
];
|
|
const visible = new Set(['source:one', 'target:one']);
|
|
const overlayRef = React.createRef<ResourceDependencyOverlayHandle>();
|
|
render(overlayView(graph, positions, visible, overlayRef));
|
|
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
|
const selector = '[data-edge-kind="asset-reference"]';
|
|
await waitFor(() =>
|
|
expect(overlay.querySelector(selector)?.getAttribute('d')).toContain(
|
|
'M 180 64 L 230 64',
|
|
),
|
|
);
|
|
|
|
act(() =>
|
|
overlayRef.current?.updateDragPreview({
|
|
resourceId: 'source:one',
|
|
x: 80,
|
|
y: 40,
|
|
}),
|
|
);
|
|
await waitFor(() =>
|
|
expect(
|
|
overlay.querySelector(selector)?.getAttribute('data-route-axis'),
|
|
).toBe('vertical'),
|
|
);
|
|
expect(overlay.querySelector(selector)?.getAttribute('d')).toContain(
|
|
'M 170 40',
|
|
);
|
|
} finally {
|
|
getBoundingClientRect.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('routes a cyclic self-reference outside the resource card and moves it with the card', async () => {
|
|
const graph = graphFixture();
|
|
const view = render(
|
|
overlayView(
|
|
graph,
|
|
[position('unrelated', 24, 32)],
|
|
new Set(['unrelated']),
|
|
),
|
|
);
|
|
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
|
const selfLoopSelector =
|
|
'[data-edge-kind="asset-reference"]' +
|
|
'[data-source-resource-id="unrelated"]' +
|
|
'[data-target-resource-id="unrelated"]';
|
|
const selfLoop = await waitFor(() => {
|
|
const path = overlay.querySelector<SVGPathElement>(selfLoopSelector);
|
|
expect(path).not.toBeNull();
|
|
return path as SVGPathElement;
|
|
});
|
|
|
|
expect(selfLoop.getAttribute('data-cyclic')).toBe('true');
|
|
expect(selfLoop.getAttribute('data-self-loop')).toBe('true');
|
|
expect(selfLoop.getAttribute('d')).toBe(
|
|
'M 214 114 C 270 114, 270 78, 214 78',
|
|
);
|
|
expect(selfLoop.getAttribute('marker-end')).toContain(
|
|
'asset-reference-arrow',
|
|
);
|
|
|
|
view.rerender(
|
|
overlayView(
|
|
graph,
|
|
[position('unrelated', 84, 48)],
|
|
new Set(['unrelated']),
|
|
),
|
|
);
|
|
await waitFor(() =>
|
|
expect(selfLoop.getAttribute('d')).toBe(
|
|
'M 274 130 C 330 130, 330 94, 274 94',
|
|
),
|
|
);
|
|
});
|
|
|
|
it('keeps every rendered relationship at its default visual state', async () => {
|
|
const graph = graphFixture();
|
|
const positions = Array.from(graph.resourceIds).map((resourceId, index) =>
|
|
position(resourceId, index * 200, 0),
|
|
);
|
|
render(overlayView(graph, positions, new Set(graph.resourceIds)));
|
|
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
|
await waitFor(() =>
|
|
expect(overlay.querySelectorAll('[data-edge-kind]')).toHaveLength(2),
|
|
);
|
|
expect(overlay.querySelector('.is-highlighted')).toBeNull();
|
|
expect(overlay.querySelector('.is-dimmed')).toBeNull();
|
|
});
|
|
|
|
it('keeps 4096-resource exact-reference output bounded to positioned members', async () => {
|
|
const resourceIds = Array.from(
|
|
{ length: 4096 },
|
|
(_, index) => `resource:${index}`,
|
|
);
|
|
const referenceEdges = resourceIds.slice(1).map((resourceId, index) => ({
|
|
id: `reference:${index}:${index + 1}`,
|
|
kind: 'asset-reference' as const,
|
|
sourceResourceId: `resource:${index}`,
|
|
targetResourceId: resourceId,
|
|
cyclic: false,
|
|
}));
|
|
const graph = normalizeProjectResourceGraph({
|
|
resourceIds,
|
|
referenceEdges,
|
|
taskFlows: [],
|
|
connectionIndex: resourceIds.map((resourceId, index) => ({
|
|
resourceId,
|
|
upstreamReferenceResourceIds:
|
|
index > 0 ? [`resource:${index - 1}`] : [],
|
|
downstreamReferenceResourceIds:
|
|
index < resourceIds.length - 1 ? [`resource:${index + 1}`] : [],
|
|
referenceEdgeIds: [
|
|
...(index > 0 ? [`reference:${index - 1}:${index}`] : []),
|
|
...(index < resourceIds.length - 1
|
|
? [`reference:${index}:${index + 1}`]
|
|
: []),
|
|
],
|
|
taskFlowIds: [],
|
|
})),
|
|
producerAssignments: [],
|
|
dependencyDepths: [],
|
|
unresolvedReferenceResourceIds: [],
|
|
cyclicResourceIds: [],
|
|
cyclicTaskIds: [],
|
|
producerMappingTruncated: false,
|
|
});
|
|
const positions = [
|
|
position('resource:2047', 0, 0),
|
|
position('resource:2048', 220, 0),
|
|
position('resource:2049', 440, 0),
|
|
];
|
|
const visible = new Set(positions.map(({ resourceId }) => resourceId));
|
|
const overlayRef = React.createRef<ResourceDependencyOverlayHandle>();
|
|
render(overlayView(graph, positions, visible, overlayRef));
|
|
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
|
await waitFor(() =>
|
|
expect(
|
|
overlay.querySelectorAll('[data-edge-kind="asset-reference"]'),
|
|
).toHaveLength(2),
|
|
);
|
|
expect(overlay.querySelector('[data-edge-kind="task-flow"]')).toBeNull();
|
|
act(() =>
|
|
overlayRef.current?.updateDragPreview({
|
|
resourceId: 'resource:2048',
|
|
x: 260,
|
|
y: 32,
|
|
}),
|
|
);
|
|
await waitFor(() =>
|
|
expect(
|
|
overlay.querySelectorAll('[data-edge-kind="asset-reference"]'),
|
|
).toHaveLength(2),
|
|
);
|
|
});
|
|
|
|
it('disconnects layout observers when the SVG layer is destroyed', () => {
|
|
const observe = vi.fn();
|
|
const disconnect = vi.fn();
|
|
class TestResizeObserver {
|
|
constructor(_callback: ResizeObserverCallback) {}
|
|
|
|
observe = observe;
|
|
unobserve = vi.fn();
|
|
disconnect = disconnect;
|
|
}
|
|
vi.stubGlobal('ResizeObserver', TestResizeObserver);
|
|
try {
|
|
const graph = graphFixture();
|
|
const positions = Array.from(graph.resourceIds).map((resourceId, index) =>
|
|
position(resourceId, index * 200, 0),
|
|
);
|
|
const overlayRef = React.createRef<ResourceDependencyOverlayHandle>();
|
|
const view = render(
|
|
overlayView(graph, positions, new Set(graph.resourceIds), overlayRef),
|
|
);
|
|
|
|
expect(observe).toHaveBeenCalledTimes(2);
|
|
act(() =>
|
|
overlayRef.current?.updateDragPreview({
|
|
resourceId: 'source:one',
|
|
x: 32,
|
|
y: 24,
|
|
}),
|
|
);
|
|
expect(observe).toHaveBeenCalledTimes(2);
|
|
view.unmount();
|
|
expect(disconnect).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
}
|
|
});
|
|
|
|
it('uses a persistent orange with at least 3:1 canvas contrast', () => {
|
|
const styles = readFileSync(
|
|
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
|
'utf8',
|
|
);
|
|
expect(styles).toMatch(
|
|
/\.game-resource-dependency-edge--reference\s*\{[^}]*stroke:\s*#c45f20[^}]*opacity:\s*1/s,
|
|
);
|
|
expect(styles).toMatch(
|
|
/\.game-resource-dependency-marker--reference path\s*\{[^}]*fill:\s*#c45f20/s,
|
|
);
|
|
const luminance = (hex: string) => {
|
|
const channels = hex
|
|
.match(/[a-f\d]{2}/giu)!
|
|
.map((value) => Number.parseInt(value, 16) / 255)
|
|
.map((value) =>
|
|
value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4,
|
|
);
|
|
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
|
};
|
|
const lineLuminance = luminance('c45f20');
|
|
const canvasLuminance = luminance('fffdfa');
|
|
const contrast =
|
|
(Math.max(lineLuminance, canvasLuminance) + 0.05) /
|
|
(Math.min(lineLuminance, canvasLuminance) + 0.05);
|
|
expect(contrast).toBeGreaterThanOrEqual(3);
|
|
expect(styles).not.toMatch(
|
|
/\.game-resource-dependency-edge\.is-(?:highlighted|dimmed)/,
|
|
);
|
|
});
|
|
});
|