/** @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 { ProjectResourceCanvasCategory } 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: ProjectResourceCanvasCategory = 'scene', ): 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; overlayRef?: React.Ref; }) { 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, overlayRef?: React.Ref, ) { 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, 'scene'), 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, 'scene'), position('source:three', 0, 120, 'document'), position('target:one', 240, 0, 'scene'), position('target:two', 240, 120, 'scene'), 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, 'scene'), position('art:target', 240, 0, 'scene'), 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-scene', ); 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( '[data-resource-section-scroll="scene"]', ); if (!artViewport) { throw new Error('missing scene 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( '[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( '[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(); 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( '[data-edge-kind="asset-reference"]', ); expect(path).not.toBeNull(); return path?.getAttribute('d'); }); const horizontalReference = overlay.querySelector( '[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': 'scene' }, React.createElement( 'div', { 'data-resource-section-plane': 'scene', '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: 'scene', 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( '[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([ ['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': 'scene' }, React.createElement( 'div', { 'data-resource-section-plane': 'scene', 'data-resource-section-scale': '1', }, React.createElement(ResourceDependencyOverlay, { graph, positions: [ position('source:one', 0, 0), position('target:one', 268, 0), ], section: 'scene', visibleResourceIds: new Set(['source:one', 'target:one']), cardSizeByResourceId, }), ), ), ), ); const overlay = await screen.findByTestId('resource-dependency-overlay'); const reference = await waitFor(() => { const path = overlay.querySelector( '[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 === 'scene') { return rect(0, 100, 600, 240); } if (this.dataset.resourceSectionPlane === 'scene') { const viewport = this.closest( '[data-resource-section-scroll="scene"]', ); 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': 'scene' }, React.createElement( 'div', { 'data-resource-section-plane': 'scene', 'data-resource-section-scale': '1', }, React.createElement(ResourceDependencyOverlay, { graph, positions: [ position('source:one', 0, 0), position('target:one', 0, 320), ], section: 'scene', 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(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( '[data-resource-section-scroll="scene"]', ); if (!viewport) { throw new Error('missing scene 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 === 'scene') { 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': 'scene' }, React.createElement( 'div', { className: 'game-resource-page-world' }, React.createElement(ResourceDependencyOverlay, { graph, positions: [ position('source:one', 0, 0), position('target:one', 0, 320), ], section: 'scene', 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-scene', ); 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(); 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(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(); 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(); 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)/, ); }); });