Files
Genarrative/apps/ai-game-creator-shell/tests/ResourceDependencyOverlay.test.ts
T
menghao 62e0fe94ef
Project CI / Repository checks (push) Successful in 1m2s
Project CI / Frontend tests (push) Successful in 3m5s
Project CI / Backend tests (push) Successful in 3m41s
Project CI / Native shell tests (push) Successful in 13m7s
资源卡依赖关系及类型分类预览 (#129)
完成资源卡按类型和按依赖分类展现的功能

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/129
Co-authored-by: menghao <mh18530625731@163.com>
Co-committed-by: menghao <mh18530625731@163.com>
2026-08-05 19:15:46 +08:00

518 lines
16 KiB
TypeScript

/** @vitest-environment jsdom */
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { act, 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 {
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',
null,
...sections.map((section) =>
React.createElement('div', {
key: section,
'data-resource-section-plane': section,
}),
),
React.createElement(ResourceDependencyOverlay, {
ref: overlayRef,
graph,
positions,
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 and one aggregated task trunk without cartesian paths', 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),
);
const taskFlow = overlay.querySelector('[data-edge-kind="task-flow"]');
expect(taskFlow).not.toBeNull();
const taskPaths = Array.from(
taskFlow?.querySelectorAll<SVGPathElement>('path') ?? [],
);
expect(taskPaths).toHaveLength(7);
expect(
taskPaths.every((path) => path.getAttribute('d')?.includes(' C ')),
).toBe(true);
expect(
taskPaths.some((path) => path.getAttribute('d')?.includes(' L ')),
).toBe(false);
expect(taskFlow?.querySelectorAll('path[marker-end]')).toHaveLength(3);
expect(
overlay
.querySelector('marker[id$="-task-flow-arrow"]')
?.getAttribute('markerUnits'),
).toBe('userSpaceOnUse');
});
it('omits cross-section task endpoints and keeps same-section task flow groups', 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');
const taskFlow = await waitFor(() => {
const flows = overlay.querySelectorAll('[data-edge-kind="task-flow"]');
expect(flows).toHaveLength(1);
return flows.item(0);
});
expect(taskFlow.getAttribute('data-resource-section')).toBe('art');
expect(taskFlow.querySelectorAll('path')).toHaveLength(4);
expect(
Array.from(taskFlow.querySelectorAll('[data-resource-id]')).map((node) =>
node.getAttribute('data-resource-id'),
),
).toEqual(['source:two', 'target:one', 'target:two']);
expect(
overlay.querySelectorAll('[data-edge-kind="asset-reference"]'),
).toHaveLength(1);
});
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');
});
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 the section origin when updating a dragged path', 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 320 126',
),
);
act(() =>
overlayRef.current?.updateDragPreview({
resourceId: 'source:one',
x: 80,
y: 40,
}),
);
await waitFor(() =>
expect(overlay.querySelector(selector)?.getAttribute('d')).toContain(
'M 400 166',
),
);
} 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 204 96 C 260 96, 260 60, 204 60',
);
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 264 112 C 320 112, 320 76, 264 76',
),
);
});
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(3),
);
expect(overlay.querySelector('.is-highlighted')).toBeNull();
expect(overlay.querySelector('.is-dimmed')).toBeNull();
});
it('updates only adjacent paths while dragging inside a 4096-resource topology', async () => {
const resourceIds = Array.from(
{ length: 4096 },
(_, index) => `resource:${index}`,
);
const taskFlows = resourceIds.slice(1).map((resourceId, index) => ({
id: `flow:${index}:${index + 1}`,
kind: 'task-flow' as const,
sourceTaskId: `task:${index}`,
targetTaskId: `task:${index + 1}`,
sourceResourceIds: [`resource:${index}`],
targetResourceIds: [resourceId],
cyclic: false,
}));
const graph = normalizeProjectResourceGraph({
resourceIds,
referenceEdges: [],
taskFlows,
connectionIndex: resourceIds.map((resourceId, index) => ({
resourceId,
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: [],
referenceEdgeIds: [],
taskFlowIds: [
...(index > 0 ? [`flow:${index - 1}:${index}`] : []),
...(index < resourceIds.length - 1
? [`flow:${index}:${index + 1}`]
: []),
],
})),
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="task-flow"]'),
).toHaveLength(2),
);
const setAttribute = vi.spyOn(SVGElement.prototype, 'setAttribute');
try {
act(() =>
overlayRef.current?.updateDragPreview({
resourceId: 'resource:2048',
x: 260,
y: 32,
}),
);
const geometryUpdates = setAttribute.mock.calls.filter(
([name]) => name === 'd',
);
expect(geometryUpdates).toHaveLength(6);
} finally {
setAttribute.mockRestore();
}
});
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)/,
);
});
});