Files
Genarrative/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts
suzmii 2407295363
Project CI / Repository checks (push) Successful in 2m58s
Project CI / Frontend tests (push) Successful in 3m23s
Project CI / Backend tests (push) Successful in 5m0s
Project CI / Native shell tests (push) Successful in 13m37s
修复资源管理滚轮翻页偶发失效 (#197)
## 变更背景

资源管理画布在连续滚轮翻页时,可能出现偶发失效或中间栏目被逐页渲染,导致后续滚轮输入被资源渲染阻塞;同时,缩放、手动切页、排序和拖拽过程中还可能执行已经过期的翻页任务。

本 PR 将翻页意图与资源渲染提交解耦,并补齐交互取消边界,让连续输入始终基于最新逻辑目标栏目计算。

## 主要修改

- 滚轮分页改为同步维护逻辑目标栏目,可见栏目通过 React transition 异步提交;渲染尚未完成时,新滚轮事件仍基于最新逻辑目标继续计算,不逐页加载中间栏目。
- 待执行翻页队列改为有界合并与延迟 flush,修复旧定时器清理不完整的问题。
- Ctrl/Meta 缩放、触控板缩放手势、手动点击切页、切换排序、开始画布空白拖拽或资源卡拖拽时,取消尚未执行的过期翻页任务。
- viewport 更新显式携带目标栏目,画布拖拽记录发起时的栏目,避免过渡渲染期间把旧栏目 viewport 写入新栏目。
- 每个排序模式和栏目继续保留独立 viewport,复位、首次 fit 与保留用户当前平移缩放的路径统一使用同一套数据流。
- 依赖浮层的 ResizeObserver、scroll 和 resize 监听只在挂载时建立一次;viewport / 几何 revision 变化时通过 ref 合并调度一帧测量,避免连续输入时重复重建观察器。
- 更新资源自由画板技术方案,并补充回归测试覆盖连续滚轮、缩放取消、大步跳转、切页边界和依赖浮层观察器生命周期。

## 验证

- `npm run test -- apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts apps/ai-game-creator-shell/tests/appSurface.test.ts --run`
  - 2 个测试文件通过
  - 393 / 393 tests passed
- `npm run agc:typecheck`
- `npm run lint:eslint -- apps/ai-game-creator-shell/src/view/project-development/index.tsx apps/ai-game-creator-shell/src/view/project-development/ResourceDependencyOverlay.tsx apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`
- `npm run check:encoding`
- `git diff --check`

以上均为合并 `origin/master@2f3c578db` 后在 `b5c98af42` 上执行。

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/197
Co-authored-by: suzmii <suzmii@qq.com>
Co-committed-by: suzmii <suzmii@qq.com>
2026-08-26 15:29:35 +08:00

1062 lines
34 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();
const construct = vi.fn();
class TestResizeObserver {
constructor(_callback: ResizeObserverCallback) {
construct();
}
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(construct).toHaveBeenCalledTimes(1);
expect(observe).toHaveBeenCalledTimes(2);
act(() =>
overlayRef.current?.updateDragPreview({
resourceId: 'source:one',
x: 32,
y: 24,
}),
);
view.rerender(
overlayView(graph, positions, new Set(graph.resourceIds), overlayRef),
);
expect(construct).toHaveBeenCalledTimes(1);
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)/,
);
});
});