Skip to content

Commit 82f024a

Browse files
test: delete a pasted subgraph in both orders
Copy/paste a subgraph instance with a promoted widget and a rerouted external link, then delete the original and the copy in both orders. Order-dependence was the bug class this targets. It is not there: both orders converge on a byte-identical terminal state, and in each the survivor keeps its definition, its promoted value and its rerouted link. Test 4 asserts the two terminal states against each other and then against explicit expected values, so the equality cannot pass vacuously. Test 1 is a precondition guard for the same reason — paste clones the definition under a fresh UUID rather than sharing it, the promoted value survives into a new widget id, and both instances thread through one shared reroute. Without it the delete tests could pass on an empty setup. Two order-independent leaks are pinned as current behaviour: - Removing a SubgraphNode never releases the widgetValueStore entries its promoted inputs registered, so the deleted instance's value survives under its old widget id. - A reroute created on an ordinary link is seeded with that link's id in floatingLinkIds as well as linkIds (LGraph.ts:1538-1539), so totalLinks never reaches 0 and the reroute outlives its links. Asserted pre-deletion so it cannot be misread as delete damage. The second corrects an earlier reading that blamed target-node removal. The removal only exposes it; createReroute causes it. 4 tests, all passing.
1 parent ed0ec69 commit 82f024a

1 file changed

Lines changed: 387 additions & 0 deletions

File tree

Lines changed: 387 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
4+
import type { Reroute } from '@/lib/litegraph/src/litegraph'
5+
import type { LinkId } from '@/types/linkId'
6+
import {
7+
LGraph,
8+
LGraphCanvas,
9+
LGraphNode,
10+
LLink,
11+
LiteGraph,
12+
SubgraphNode,
13+
createUuidv4
14+
} from '@/lib/litegraph/src/litegraph'
15+
import type { ClipboardItems } from '@/lib/litegraph/src/types/serialisation'
16+
import { useWidgetValueStore } from '@/stores/widgetValueStore'
17+
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
18+
19+
import {
20+
createTestSubgraphData,
21+
registerTestSubgraphNodeTypes
22+
} from './subgraph/__fixtures__/subgraphHelpers'
23+
24+
/**
25+
* QA-4 (invariant I4, no ambiguous ownership across graphs): copy/paste a
26+
* subgraph instance that carries a promoted widget and a rerouted external
27+
* link, then delete the two instances in **both** orders.
28+
*
29+
* Order-dependence is the bug class. A cleanup path that works when the copy
30+
* goes first and corrupts the survivor when the original goes first is
31+
* invisible to any test that only tries one order, so both orders are asserted
32+
* separately and then compared.
33+
*
34+
* `LGraphCanvas.clipboard.test.ts` already covers the paste-time half —
35+
* interior id remapping, `proxyWidgets` / `previewExposures` reference
36+
* rewriting, input reconnection when clipboard ids are strings, legacy
37+
* `proxyWidgets` clearing and preview auto-exposure. None of that outlives the
38+
* paste. This file starts where that one stops: what the *deletions* do.
39+
*
40+
* These assertions describe `main` as it behaves today. Two of them pin
41+
* behaviour that is arguably wrong; they are asserted as-is and called out.
42+
*/
43+
44+
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
45+
useCanvasStore: () => ({})
46+
}))
47+
vi.mock('@/services/litegraphService', () => ({
48+
useLitegraphService: () => ({ updatePreviews: () => ({}) })
49+
}))
50+
51+
const INTERIOR_TYPE = 'test/qa4-interior'
52+
const PLAIN_TYPE = 'test/qa4-plain'
53+
54+
const ORIGINAL_SEED = 4242
55+
const COPY_SEED = 99
56+
57+
/** Interior node whose `seed` widget is backed by an input slot, so it is promotable. */
58+
class InteriorNode extends LGraphNode {
59+
constructor() {
60+
super('QA4 Interior')
61+
this.serialize_widgets = true
62+
const seedSlot = this.addInput('seed', 'NUMBER')
63+
seedSlot.widget = { name: 'seed' }
64+
this.addInput('image', 'IMAGE')
65+
this.addOutput('out', 'NUMBER')
66+
this.addWidget('number', 'seed', 20, () => {})
67+
}
68+
}
69+
70+
class PlainNode extends LGraphNode {
71+
constructor() {
72+
super('QA4 Plain')
73+
this.addInput('in', '*')
74+
this.addOutput('out', '*')
75+
}
76+
}
77+
78+
const canvasElements: HTMLCanvasElement[] = []
79+
80+
function createCanvas(graph: LGraph): LGraphCanvas {
81+
const el = document.createElement('canvas')
82+
el.width = 800
83+
el.height = 600
84+
el.getContext = vi.fn().mockReturnValue(createMockCanvasRenderingContext2D())
85+
el.getBoundingClientRect = vi
86+
.fn()
87+
.mockReturnValue({ left: 0, top: 0, width: 800, height: 600 })
88+
// LGraph.remove -> checkPanels dereferences canvas.parentNode.
89+
document.body.append(el)
90+
canvasElements.push(el)
91+
return new LGraphCanvas(el, graph, { skip_render: true })
92+
}
93+
94+
interface Fixture {
95+
rootGraph: LGraph
96+
original: SubgraphNode
97+
copy: SubgraphNode
98+
upstream: LGraphNode
99+
downstream: LGraphNode
100+
/** Shared by the original's and the copy's inbound external link. */
101+
reroute: Reroute
102+
originalDefId: string
103+
copyDefId: string
104+
originalInLinkId: LinkId
105+
originalOutLinkId: LinkId
106+
copyInLinkId: LinkId
107+
}
108+
109+
/**
110+
* Root graph:
111+
*
112+
* ```
113+
* upstream ──┬─(reroute 1)─► original[image] original[result] ─► downstream
114+
* └─(reroute 1)─► copy[image]
115+
* ```
116+
*
117+
* Both instances also carry a promoted `seed` widget projected from their own
118+
* interior node. `connectInputs` is the Ctrl+Shift+V paste, the only paste that
119+
* reattaches a copy to the upstream node it was copied from — and therefore the
120+
* only one that threads two links through a single reroute.
121+
*/
122+
function buildFixture(): Fixture {
123+
const rootGraph = new LGraph()
124+
rootGraph.id = createUuidv4()
125+
registerTestSubgraphNodeTypes(rootGraph)
126+
const canvas = createCanvas(rootGraph)
127+
128+
const subgraph = rootGraph.createSubgraph(
129+
createTestSubgraphData({ name: 'QA4 Subgraph' })
130+
)
131+
132+
const interior = LiteGraph.createNode(INTERIOR_TYPE)!
133+
subgraph.add(interior)
134+
subgraph.addInput('image', 'IMAGE').connect(interior.inputs[1], interior)
135+
subgraph.addOutput('result', 'NUMBER')
136+
subgraph.outputNode.slots[0].connect(interior.outputs[0], interior)
137+
138+
const original = LiteGraph.createNode(subgraph.id)
139+
if (!(original instanceof SubgraphNode))
140+
throw new Error('Expected the subgraph type to build a SubgraphNode')
141+
rootGraph.add(original)
142+
143+
expect(
144+
promoteValueWidgetViaSubgraphInput(original, interior, interior.widgets![0])
145+
).toStrictEqual({ ok: true })
146+
147+
const upstream = LiteGraph.createNode(PLAIN_TYPE)!
148+
rootGraph.add(upstream)
149+
const downstream = LiteGraph.createNode(PLAIN_TYPE)!
150+
rootGraph.add(downstream)
151+
152+
const inbound = upstream.connect(0, original, 0)!
153+
const reroute = rootGraph.createReroute([200, 200], inbound)!
154+
const outbound = original.connect(0, downstream, 0)!
155+
156+
useWidgetValueStore().setValue(original.inputs[1].widgetId!, ORIGINAL_SEED)
157+
158+
const parsed = JSON.parse(
159+
JSON.stringify(canvas._serializeItems([original]))
160+
) as ClipboardItems
161+
const pasted = canvas._deserializeItems(parsed, {
162+
connectInputs: true,
163+
position: [700, 700]
164+
})
165+
const copy = pasted?.created.find(
166+
(item): item is SubgraphNode => item instanceof SubgraphNode
167+
)
168+
if (!copy) throw new Error('Expected a pasted SubgraphNode')
169+
170+
return {
171+
rootGraph,
172+
original,
173+
copy,
174+
upstream,
175+
downstream,
176+
reroute,
177+
originalDefId: subgraph.id,
178+
copyDefId: String(copy.type),
179+
originalInLinkId: inbound.id,
180+
originalOutLinkId: outbound.id,
181+
copyInLinkId: copy.inputs[0].link!
182+
}
183+
}
184+
185+
/** The promoted `seed` value as the host projects it to the canvas. */
186+
function promotedSeed(host: SubgraphNode): unknown {
187+
return host.getWidgetFromSlot(host.inputs[1])?.value
188+
}
189+
190+
/** Widget states still keyed to this host's node id, as `{ name, value }` pairs. */
191+
function storedWidgets(graph: LGraph, host: SubgraphNode) {
192+
return useWidgetValueStore()
193+
.getNodeWidgets(graph.id, host.id)
194+
.map(({ name, value }) => ({ name, value }))
195+
}
196+
197+
/** Structural facts that must not depend on the order the instances were deleted in. */
198+
function terminalState(f: Fixture) {
199+
return {
200+
remainingNodes: f.rootGraph.nodes.map((node) => String(node.id)),
201+
remainingDefinitions: f.rootGraph.subgraphs.size,
202+
remainingLinks: [...f.rootGraph._links.keys()],
203+
remainingFloatingLinks: [...f.rootGraph.floatingLinks.keys()],
204+
remainingReroutes: [...f.rootGraph.reroutes.values()].map((reroute) => ({
205+
linkIds: [...reroute.linkIds],
206+
floatingLinkIds: [...reroute.floatingLinkIds]
207+
})),
208+
originalWidgetStates: storedWidgets(f.rootGraph, f.original),
209+
copyWidgetStates: storedWidgets(f.rootGraph, f.copy)
210+
}
211+
}
212+
213+
describe('subgraph copy/paste then delete in both orders', () => {
214+
beforeEach(() => {
215+
LiteGraph.registerNodeType(INTERIOR_TYPE, InteriorNode)
216+
LiteGraph.registerNodeType(PLAIN_TYPE, PlainNode)
217+
})
218+
219+
afterEach(() => {
220+
LiteGraph.unregisterNodeType(INTERIOR_TYPE)
221+
LiteGraph.unregisterNodeType(PLAIN_TYPE)
222+
for (const el of canvasElements.splice(0)) el.remove()
223+
})
224+
225+
it('pastes an independent instance that shares the original reroute', () => {
226+
const f = buildFixture()
227+
228+
// Paste always clones the definition rather than sharing it, so the two
229+
// instances are entangled only through the root graph — never through
230+
// `rootGraph.subgraphs`.
231+
expect(f.copyDefId).not.toBe(f.originalDefId)
232+
expect(f.rootGraph.subgraphs.size).toBe(2)
233+
expect(f.copy.subgraph).not.toBe(f.original.subgraph)
234+
expect(f.copy.subgraph.nodes).toHaveLength(1)
235+
236+
// The promoted value rides across the copy/paste...
237+
expect(promotedSeed(f.copy)).toBe(ORIGINAL_SEED)
238+
// ...into a widget id of the copy's own, keyed by the pasted node id.
239+
expect(f.copy.inputs[1].widgetId).not.toBe(f.original.inputs[1].widgetId)
240+
241+
// Where they *are* entangled: one reroute now carries both inbound links.
242+
expect([...f.reroute.linkIds]).toStrictEqual([
243+
f.originalInLinkId,
244+
f.copyInLinkId
245+
])
246+
expect(
247+
LLink.getReroutes(f.rootGraph, f.rootGraph.getLink(f.copyInLinkId)!)
248+
).toStrictEqual([f.reroute])
249+
250+
// Only inbound links are serialised, so the copy has no outbound link.
251+
expect(f.copy.outputs[0].links ?? []).toHaveLength(0)
252+
253+
// `createReroute` seeds `floatingLinkIds` from a non-floating link, so the
254+
// reroute claims a floating link the graph has never had. This predates any
255+
// deletion; asserting it here stops the terminal state below from being
256+
// misread as deletion damage.
257+
expect([...f.reroute.floatingLinkIds]).toStrictEqual([f.originalInLinkId])
258+
expect(f.rootGraph.floatingLinks.size).toBe(0)
259+
})
260+
261+
it('leaves the copy whole when the original is deleted first', () => {
262+
const f = buildFixture()
263+
useWidgetValueStore().setValue(f.copy.inputs[1].widgetId!, COPY_SEED)
264+
265+
f.rootGraph.remove(f.original)
266+
267+
// Ownership: only the original's definition is released.
268+
expect(f.rootGraph.subgraphs.has(f.originalDefId)).toBe(false)
269+
expect(f.rootGraph.subgraphs.get(f.copyDefId)).toBe(f.copy.subgraph)
270+
expect(f.copy.isDetached).toBe(false)
271+
expect(f.original.isDetached).toBe(true)
272+
expect(f.rootGraph.nodes).toContain(f.copy)
273+
expect(f.rootGraph.nodes).not.toContain(f.original)
274+
// `delete`d from the index, so the lookup is undefined rather than null.
275+
expect(f.rootGraph.getNodeById(f.original.id)).toBeUndefined()
276+
277+
// The copy's promoted widget value is untouched by its sibling's removal.
278+
expect(promotedSeed(f.copy)).toBe(COPY_SEED)
279+
280+
// Topology: the shared reroute survives holding only the copy's link, and
281+
// that link still resolves end to end through it.
282+
expect([...f.reroute.linkIds]).toStrictEqual([f.copyInLinkId])
283+
const copyInLink = f.rootGraph.getLink(f.copyInLinkId)
284+
expect(copyInLink).toMatchObject({
285+
origin_id: f.upstream.id,
286+
target_id: f.copy.id,
287+
parentId: f.reroute.id
288+
})
289+
expect(LLink.getReroutes(f.rootGraph, copyInLink!)).toStrictEqual([
290+
f.reroute
291+
])
292+
expect(f.upstream.outputs[0].links).toStrictEqual([f.copyInLinkId])
293+
294+
// The original's own links are gone, including the outbound one.
295+
expect(f.rootGraph.getLink(f.originalInLinkId)).toBeUndefined()
296+
expect(f.rootGraph.getLink(f.originalOutLinkId)).toBeUndefined()
297+
expect(f.downstream.inputs[0].link).toBeNull()
298+
299+
// Store leak on `main`: removing a SubgraphNode never deletes the widget
300+
// states its promoted inputs registered, so the deleted instance's value
301+
// outlives the instance.
302+
expect(storedWidgets(f.rootGraph, f.original)).toStrictEqual([
303+
{ name: 'seed', value: ORIGINAL_SEED }
304+
])
305+
})
306+
307+
it('leaves the original whole when the copy is deleted first', () => {
308+
const f = buildFixture()
309+
useWidgetValueStore().setValue(f.copy.inputs[1].widgetId!, COPY_SEED)
310+
311+
f.rootGraph.remove(f.copy)
312+
313+
expect(f.rootGraph.subgraphs.has(f.copyDefId)).toBe(false)
314+
expect(f.rootGraph.subgraphs.get(f.originalDefId)).toBe(f.original.subgraph)
315+
expect(f.original.isDetached).toBe(false)
316+
expect(f.copy.isDetached).toBe(true)
317+
expect(f.rootGraph.nodes).toContain(f.original)
318+
expect(f.rootGraph.nodes).not.toContain(f.copy)
319+
expect(f.rootGraph.getNodeById(f.copy.id)).toBeUndefined()
320+
321+
expect(promotedSeed(f.original)).toBe(ORIGINAL_SEED)
322+
323+
expect([...f.reroute.linkIds]).toStrictEqual([f.originalInLinkId])
324+
const originalInLink = f.rootGraph.getLink(f.originalInLinkId)
325+
expect(originalInLink).toMatchObject({
326+
origin_id: f.upstream.id,
327+
target_id: f.original.id,
328+
parentId: f.reroute.id
329+
})
330+
expect(LLink.getReroutes(f.rootGraph, originalInLink!)).toStrictEqual([
331+
f.reroute
332+
])
333+
expect(f.upstream.outputs[0].links).toStrictEqual([f.originalInLinkId])
334+
335+
// The original's outbound link is unrelated to the copy and stays put.
336+
expect(f.rootGraph.getLink(f.originalOutLinkId)).toMatchObject({
337+
origin_id: f.original.id,
338+
target_id: f.downstream.id
339+
})
340+
expect(f.downstream.inputs[0].link).toBe(f.originalOutLinkId)
341+
342+
expect(f.rootGraph.getLink(f.copyInLinkId)).toBeUndefined()
343+
344+
expect(storedWidgets(f.rootGraph, f.copy)).toStrictEqual([
345+
{ name: 'seed', value: COPY_SEED }
346+
])
347+
})
348+
349+
it('reaches the same terminal state whichever instance is deleted first', () => {
350+
const originalFirst = buildFixture()
351+
useWidgetValueStore().setValue(
352+
originalFirst.copy.inputs[1].widgetId!,
353+
COPY_SEED
354+
)
355+
originalFirst.rootGraph.remove(originalFirst.original)
356+
originalFirst.rootGraph.remove(originalFirst.copy)
357+
358+
const copyFirst = buildFixture()
359+
useWidgetValueStore().setValue(
360+
copyFirst.copy.inputs[1].widgetId!,
361+
COPY_SEED
362+
)
363+
copyFirst.rootGraph.remove(copyFirst.copy)
364+
copyFirst.rootGraph.remove(copyFirst.original)
365+
366+
expect(terminalState(copyFirst)).toStrictEqual(terminalState(originalFirst))
367+
368+
// That shared state is: both definitions released, both instances gone,
369+
// every link gone — but the reroute survives carrying no links at all,
370+
// because the phantom `floatingLinkIds` entry keeps `totalLinks` above
371+
// zero, and both promoted widget states are still in the store.
372+
expect(terminalState(originalFirst)).toStrictEqual({
373+
remainingNodes: [
374+
String(originalFirst.upstream.id),
375+
String(originalFirst.downstream.id)
376+
],
377+
remainingDefinitions: 0,
378+
remainingLinks: [],
379+
remainingFloatingLinks: [],
380+
remainingReroutes: [
381+
{ linkIds: [], floatingLinkIds: [originalFirst.originalInLinkId] }
382+
],
383+
originalWidgetStates: [{ name: 'seed', value: ORIGINAL_SEED }],
384+
copyWidgetStates: [{ name: 'seed', value: COPY_SEED }]
385+
})
386+
})
387+
})

0 commit comments

Comments
 (0)