diff --git a/src/app/utilities/rete-editor.spec.ts b/src/app/utilities/rete-editor.spec.ts index 3b37cfe..d806eba 100644 --- a/src/app/utilities/rete-editor.spec.ts +++ b/src/app/utilities/rete-editor.spec.ts @@ -6,7 +6,7 @@ import { DEFAULT_NODE_CAPABILITIES, RESOURCE_NODE_CAPABILITIES } from '@models/f import { HFNode } from '@models/nodes'; import { vi } from 'vitest'; -import { isResourceNode, resolveNodeCapabilities, ReteRuntimeContext, syncResourceLinkPresentation } from './rete-editor'; +import { isResourceNode, refreshLoopMarkers, resolveNodeCapabilities, ReteRuntimeContext, syncResourceLinkPresentation } from './rete-editor'; function makeNode(typeName: string, nodeFamily: 'block' | 'container' | 'resource'): HFNode { return { data: { typeName, nodeFamily } } as unknown as HFNode; @@ -87,6 +87,39 @@ describe('isResourceNode', () => { }); }); +describe('loop route placement', () => { + it('starts 48px above its highest endpoint, irrespective of a higher node between them', async () => { + const start = { id: 'start', data: { typeName: 'Start', nodeFamily: 'block' } } as any; + const router = { id: 'router', data: { typeName: 'Router', nodeFamily: 'block' } } as any; + const intermediate = { id: 'intermediate', data: { typeName: 'Step', nodeFamily: 'block' } } as any; + const forward = { id: 'forward', source: 'start', sourceOutput: 'out', target: 'router', targetInput: 'in' } as any; + const loop = { id: 'loop', source: 'router', sourceOutput: 'out', target: 'start', targetInput: 'in', loop: {} } as any; + const editor = { + getNodes: () => [start, router, intermediate], + getConnections: () => [forward, loop] + } as any; + const area = { + nodeViews: new Map([ + ['start', { position: { x: 0, y: 500 }, element: { offsetWidth: 100 } }], + ['router', { position: { x: 400, y: 400 }, element: { offsetWidth: 100 } }], + ['intermediate', { position: { x: 200, y: 100 }, element: { offsetWidth: 100 } }] + ]), + update: vi.fn().mockResolvedValue(undefined) + } as any; + const runtime = makeRuntime({ + blocksService: { + peekBlockType: vi.fn().mockImplementation((type: string) => + type === 'Router' ? { capabilities: { ...DEFAULT_NODE_CAPABILITIES, routesExclusively: true } } : null) + } as any + }); + + await refreshLoopMarkers(editor, area, runtime); + + expect(loop.__loopBack).toBe(true); + expect(loop.__loopTop).toBe(352); + }); +}); + describe('the link between a resource and a step that uses it', () => { it('replaces an S3 operation choice with PostgreSQL choices when its resource changes', async () => { const { NodeEditor, ClassicPreset } = await import('rete'); diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 648bce6..7db4779 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -977,21 +977,15 @@ export async function refreshLoopMarkers( } /** - * The initial route of a loop: above the nodes it has to avoid when the loop is first established. + * The initial route of a loop: 48px above its highest endpoint. Nodes in between are deliberately + * ignored, since they may subsequently be moved across this fixed route. */ function loopTop(area: AreaPlugin, connection: LoopAwareConnection): number | undefined { const ends = [connection.source, connection.target] .map((id) => area.nodeViews.get(id)) .filter((view): view is NonNullable => view != null); if (!ends.length) return undefined; - // The way back runs across everything between its two ends, so it has to clear the tallest of - // those nodes too - the loop's own steps usually sit there - not only the two it connects. - const left = Math.min(...ends.map((view) => view.position.x)); - const right = Math.max(...ends.map((view) => view.position.x + view.element.offsetWidth)); - const tops = [...area.nodeViews.values()] - .filter((view) => view.position.x + view.element.offsetWidth >= left && view.position.x <= right) - .map((view) => view.position.y); - return Math.min(...tops, ...ends.map((view) => view.position.y)) - 48; + return Math.min(...ends.map((view) => view.position.y)) - 48; } /**