Skip to content

Commit 729536f

Browse files
committed
test: expose viewport culling correctness gaps
1 parent d6aeca3 commit 729536f

7 files changed

Lines changed: 302 additions & 5 deletions

File tree

270 Bytes
Loading

src/lib/litegraph/src/LGraphCanvas.drawConnections.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import {
99
LiteGraph
1010
} from '@/lib/litegraph/src/litegraph'
1111
import { LLink } from '@/lib/litegraph/src/LLink'
12+
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
13+
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
14+
import type { SlotLayout } from '@/renderer/core/layout/types'
1215
import { toLinkId } from '@/types/linkId'
1316
import { createMockCanvas2DContext } from '@/utils/__tests__/litegraphTestUtils'
1417

@@ -205,4 +208,77 @@ describe('drawConnections widget-input slot positioning', () => {
205208
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
206209
expect(input.pos![1]).toBe(widget.y + offset)
207210
})
211+
212+
it('draws a legacy link whose custom slot positions reach the viewport', () => {
213+
const sourceNode = new LGraphNode('Source')
214+
sourceNode.pos = [-5000, 100]
215+
sourceNode.size = [150, 60]
216+
sourceNode.addOutput('out', '*')
217+
sourceNode.getOutputPos = vi.fn((): [number, number] => [100, 100])
218+
graph.add(sourceNode)
219+
220+
const targetNode = new LGraphNode('Target')
221+
targetNode.pos = [-4000, 100]
222+
targetNode.size = [150, 60]
223+
targetNode.addInput('in', '*')
224+
targetNode.getInputPos = vi.fn((): [number, number] => [200, 100])
225+
graph.add(targetNode)
226+
227+
createTestLink(graph, sourceNode, 0, targetNode, 0)
228+
const renderLink = vi.spyOn(canvas, 'renderLink')
229+
230+
canvas.drawConnections(createMockCtx())
231+
232+
expect(renderLink).toHaveBeenCalled()
233+
})
234+
235+
it('draws a Vue link whose measured slots reach outside node bounds', () => {
236+
const sourceNode = new LGraphNode('Source')
237+
sourceNode.pos = [-5000, 100]
238+
sourceNode.size = [150, 60]
239+
sourceNode.addOutput('out', '*')
240+
graph.add(sourceNode)
241+
242+
const targetNode = new LGraphNode('Target')
243+
targetNode.pos = [-4000, 100]
244+
targetNode.size = [150, 60]
245+
targetNode.addInput('in', '*')
246+
graph.add(targetNode)
247+
248+
createTestLink(graph, sourceNode, 0, targetNode, 0)
249+
250+
const sourceKey = getSlotKey(sourceNode.id, 0, false)
251+
const targetKey = getSlotKey(targetNode.id, 0, true)
252+
const layouts = new Map([
253+
[
254+
sourceKey,
255+
{
256+
nodeId: sourceNode.id,
257+
index: 0,
258+
type: 'output',
259+
position: { x: 100, y: 100 },
260+
bounds: { x: 95, y: 95, width: 10, height: 10 }
261+
} satisfies SlotLayout
262+
],
263+
[
264+
targetKey,
265+
{
266+
nodeId: targetNode.id,
267+
index: 0,
268+
type: 'input',
269+
position: { x: 200, y: 100 },
270+
bounds: { x: 195, y: 95, width: 10, height: 10 }
271+
} satisfies SlotLayout
272+
]
273+
])
274+
vi.mocked(layoutStore.getSlotLayout).mockImplementation(
275+
(key) => layouts.get(key) ?? null
276+
)
277+
LiteGraph.vueNodesMode = true
278+
const renderLink = vi.spyOn(canvas, 'renderLink')
279+
280+
canvas.drawConnections(createMockCtx())
281+
282+
expect(renderLink).toHaveBeenCalled()
283+
})
208284
})

src/renderer/core/layout/store/layoutStore.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import * as Y from 'yjs'
23

34
import { toLinkId } from '@/types/linkId'
45
import { toNodeId } from '@/types/nodeId'
@@ -14,6 +15,8 @@ import type {
1415
NodeLayout,
1516
SlotLayout
1617
} from '@/renderer/core/layout/types'
18+
import { layoutToYNode } from '@/renderer/core/layout/utils/mappers'
19+
import type { NodeLayoutMap } from '@/renderer/core/layout/utils/mappers'
1720

1821
function getOperationsAddedBy(action: () => void): LayoutOperation[] {
1922
const operationCount = layoutStore.getOperationsSince(0).length
@@ -530,6 +533,73 @@ describe('layoutStore CRDT operations', () => {
530533
expect(nodesInBounds).toContain('node-c')
531534
})
532535

536+
it('synchronizes the spatial index after remote geometry changes', () => {
537+
const remote = new Y.Doc()
538+
const remoteNodes = remote.getMap<NodeLayoutMap>('nodes')
539+
const nodeId = toNodeId('remote-node')
540+
const layout: NodeLayout = {
541+
...createTestNode(nodeId),
542+
position: { x: 0, y: 0 },
543+
bounds: { x: 0, y: 0, width: 200, height: 100 }
544+
}
545+
546+
remoteNodes.set(nodeId, layoutToYNode(layout))
547+
layoutStore.applyUpdate(Y.encodeStateAsUpdate(remote))
548+
549+
expect(
550+
layoutStore.queryNodesInBounds({
551+
x: -10,
552+
y: -10,
553+
width: 300,
554+
height: 200
555+
})
556+
).toContain(nodeId)
557+
558+
const remoteNode = remoteNodes.get(nodeId)
559+
expect(remoteNode).toBeDefined()
560+
remoteNode?.set('position', { x: 50_000, y: 40_000 })
561+
remoteNode?.set('bounds', {
562+
x: 50_000,
563+
y: 40_000,
564+
width: 200,
565+
height: 100
566+
})
567+
layoutStore.applyUpdate(
568+
Y.encodeStateAsUpdate(remote, Y.encodeStateVector(layoutStore.getYDoc()))
569+
)
570+
571+
expect(
572+
layoutStore.queryNodesInBounds({
573+
x: -10,
574+
y: -10,
575+
width: 300,
576+
height: 200
577+
})
578+
).not.toContain(nodeId)
579+
expect(
580+
layoutStore.queryNodesInBounds({
581+
x: 49_900,
582+
y: 39_900,
583+
width: 400,
584+
height: 300
585+
})
586+
).toContain(nodeId)
587+
588+
remoteNodes.delete(nodeId)
589+
layoutStore.applyUpdate(
590+
Y.encodeStateAsUpdate(remote, Y.encodeStateVector(layoutStore.getYDoc()))
591+
)
592+
593+
expect(
594+
layoutStore.queryNodesInBounds({
595+
x: 49_900,
596+
y: 39_900,
597+
width: 400,
598+
height: 300
599+
})
600+
).not.toContain(nodeId)
601+
})
602+
533603
it('should maintain operation history', () => {
534604
const nodeId = toNodeId('test-node-history')
535605
const layout = createTestNode(nodeId)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
4+
import { SpatialIndexManager } from '@/renderer/core/spatial/SpatialIndex'
5+
6+
const id = (value: string) => value as NodeId
7+
8+
function box(x: number, y: number, size = 100): Bounds {
9+
return { x, y, width: size, height: size }
10+
}
11+
12+
describe('SpatialIndexManager', () => {
13+
it('indexes entries outside the initial root bounds', () => {
14+
const index = new SpatialIndexManager<NodeId>()
15+
const distant = id('distant')
16+
17+
index.insert(distant, box(250_000, -80_000))
18+
19+
expect(index.query(box(249_900, -80_100, 500))).toEqual([distant])
20+
expect(index.size).toBe(1)
21+
})
22+
23+
it('keeps an entry indexed when an update crosses the root boundary', () => {
24+
const index = new SpatialIndexManager<NodeId>()
25+
const moving = id('moving')
26+
27+
index.insert(moving, box(0, 0))
28+
index.update(moving, box(40_000, 40_000))
29+
30+
expect(index.query(box(0, 0, 500))).toEqual([])
31+
expect(index.query(box(39_900, 39_900, 500))).toEqual([moving])
32+
})
33+
34+
it('reports unindexable entries conservatively', () => {
35+
const index = new SpatialIndexManager<NodeId>()
36+
const broken = id('broken')
37+
38+
index.insert(broken, {
39+
x: Number.NaN,
40+
y: 0,
41+
width: 100,
42+
height: 100
43+
})
44+
45+
expect(index.query(box(50_000, 50_000))).toContain(broken)
46+
expect(index.size).toBe(1)
47+
})
48+
49+
it('retains every update when a batch expands the root', () => {
50+
const index = new SpatialIndexManager<NodeId>()
51+
const left = id('left')
52+
const right = id('right')
53+
54+
index.insert(left, box(0, 0))
55+
index.insert(right, box(100, 100))
56+
index.batchUpdate([
57+
{ nodeId: left, bounds: box(-50_000, 0) },
58+
{ nodeId: right, bounds: box(50_000, 0) }
59+
])
60+
61+
expect(index.query(box(-50_100, -100, 500))).toEqual([left])
62+
expect(index.query(box(49_900, -100, 500))).toEqual([right])
63+
expect(index.size).toBe(2)
64+
})
65+
})

src/renderer/extensions/vueNodes/composables/liveNodeState.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
findNodesOptedOutOfCulling,
88
findNodesWithLiveState
99
} from '@/renderer/extensions/vueNodes/composables/liveNodeState'
10+
import * as liveNodeState from '@/renderer/extensions/vueNodes/composables/liveNodeState'
1011

1112
function mountNode(id: string, inner: string): HTMLElement {
1213
const root = document.createElement('div')
@@ -110,4 +111,24 @@ describe('findNodesOptedOutOfCulling', () => {
110111
new Set()
111112
)
112113
})
114+
115+
it('registers a node-type opt-out with lifecycle cleanup', () => {
116+
type RegisterOptOut = (nodeType: string) => () => void
117+
const candidate = Reflect.get(
118+
liveNodeState,
119+
'registerNodeTypeCullingOptOut'
120+
)
121+
expect(candidate).toBeTypeOf('function')
122+
if (typeof candidate !== 'function') return
123+
124+
const cleanup = (candidate as RegisterOptOut)('stateful')
125+
const statefulNode = { ...node('stateful'), type: 'stateful' }
126+
127+
expect(findNodesOptedOutOfCulling([statefulNode])).toEqual(
128+
new Set(['stateful'])
129+
)
130+
131+
cleanup()
132+
expect(findNodesOptedOutOfCulling([statefulNode])).toEqual(new Set())
133+
})
113134
})

src/renderer/extensions/vueNodes/composables/useSlotElementTracking.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { render } from '@testing-library/vue'
22
import { beforeEach, describe, expect, it, vi } from 'vitest'
33

44
import { toNodeId } from '@/types/nodeId'
5+
import type { NodeId } from '@/types/nodeId'
56
import { defineComponent, nextTick, ref } from 'vue'
67

78
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
@@ -17,6 +18,7 @@ import {
1718
requestSlotLayoutSyncForAllNodes,
1819
useSlotElementTracking
1920
} from './useSlotElementTracking'
21+
import * as slotTracking from './useSlotElementTracking'
2022

2123
const mockGraph = vi.hoisted(() => ({ _nodes: [] as unknown[] }))
2224
const mockCanvasState = vi.hoisted(() => ({
@@ -181,6 +183,46 @@ describe('useSlotElementTracking', () => {
181183
expect(layoutStore.pendingSlotSync).toBe(true)
182184
})
183185

186+
it('completes only for a resolved empty rendered-node set', () => {
187+
type BeginSync = () => void
188+
type IsPending = () => boolean
189+
type SetExpected = (nodeIds: ReadonlySet<NodeId> | null) => void
190+
191+
const beginCandidate = Reflect.get(slotTracking, 'beginVueNodeSlotSync')
192+
const pendingCandidate = Reflect.get(
193+
slotTracking,
194+
'isVueNodeSlotSyncPending'
195+
)
196+
const expectedCandidate = Reflect.get(
197+
slotTracking,
198+
'setExpectedRenderedNodeIds'
199+
)
200+
201+
expect(beginCandidate).toBeTypeOf('function')
202+
expect(pendingCandidate).toBeTypeOf('function')
203+
expect(expectedCandidate).toBeTypeOf('function')
204+
if (
205+
typeof beginCandidate !== 'function' ||
206+
typeof pendingCandidate !== 'function' ||
207+
typeof expectedCandidate !== 'function'
208+
) {
209+
return
210+
}
211+
212+
const beginSync = beginCandidate as BeginSync
213+
const isPending = pendingCandidate as IsPending
214+
const setExpected = expectedCandidate as SetExpected
215+
216+
beginSync()
217+
setExpected(null)
218+
flushScheduledSlotLayoutSync()
219+
expect(isPending()).toBe(true)
220+
221+
setExpected(new Set())
222+
flushScheduledSlotLayoutSync()
223+
expect(isPending()).toBe(false)
224+
})
225+
184226
it('keeps pendingSlotSync when all registered slots are hidden', () => {
185227
const slotKey = getSlotKey(NODE_ID, SLOT_INDEX, true)
186228
const hiddenSlot = document.createElement('div')

0 commit comments

Comments
 (0)