62e0fe94ef
完成资源卡按类型和按依赖分类展现的功能 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>
518 lines
16 KiB
TypeScript
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)/,
|
|
);
|
|
});
|
|
});
|