openpencil/tests/engine/vue/editor/selected-nodes.test.ts
Danila Poyarkov 9eea1beb2a fix(vue): detach nested selection projection values
Top-level selection copies were safe, but nested fill and child arrays still aliased graph data. Clone initial node values and only the changed preview fields so mutable consumers cannot bypass graph tracking or cancellation. Preserve field-granular updates and paused subscriptions.
2026-09-16 10:05:44 +03:00

270 lines
9.4 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { computed, createApp, effectScope, ref, shallowReactive } from 'vue'
import { createDefaultEditorState, createEditor } from '@open-pencil/core/editor'
import { getSharedStyles, SceneGraph, type Fill } from '@open-pencil/scene-graph'
import { EDITOR_KEY } from '#vue/editor/context'
import { createSelectedNodeState, useSelectedNodeState } from '#vue/editor/selection-state/nodes'
import { useSceneComputed } from '#vue/internal/scene-computed/use'
function setup() {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const state = shallowReactive(createDefaultEditorState(page.id))
const editor = createEditor({ graph, state })
const first = graph.createNode('RECTANGLE', page.id, { x: 10, width: 100 })
const second = graph.createNode('RECTANGLE', page.id, { x: 30, width: 200 })
editor.select([first.id])
const selection = createSelectedNodeState(editor)
return {
editor,
first,
second,
selection,
dispose() {
selection.dispose()
editor.dispose()
}
}
}
describe('selected-node preview projections', () => {
for (const phase of ['initial', 'preview'] as const) {
test(`detaches nested ${phase} values from the graph`, () => {
const fixture = setup()
const { editor, first, selection } = fixture
const fill: Fill = {
type: 'SOLID',
color: { r: 0.2, g: 0.3, b: 0.4, a: 1 },
opacity: 1,
visible: true
}
try {
editor.updateNode(first.id, { fills: [fill] })
const projected = selection.node.value
if (!projected) throw new Error('Missing projection')
const preview = editor.beginNodePreview('Paint')
if (phase === 'preview') preview.update(first.id, { fills: [{ ...fill, opacity: 0.5 }] })
projected.fills[0].color.r = 0.9
projected.childIds.push('projection-only')
expect(first.fills[0].color.r).toBe(0.2)
expect(first.childIds).toEqual([])
preview.cancel()
expect(first.fills[0]).toEqual(fill)
expect(editor.undo.canUndo).toBe(false)
} finally {
fixture.dispose()
}
})
}
test('tracks committed changes and selection with a plain editor state', () => {
const editor = createEditor()
const active = ref(true)
const selection = createSelectedNodeState(editor, active)
try {
expect(selection.node.value).toBeNull()
const first = editor.graph.createNode('RECTANGLE', editor.state.currentPageId, { x: 10 })
editor.select([first.id])
expect(selection.node.value?.x).toBe(10)
editor.updateNodeWithUndo(first.id, { x: 40 })
expect(selection.node.value?.x).toBe(40)
editor.undoAction()
expect(selection.node.value?.x).toBe(10)
active.value = false
editor.updateNode(first.id, { x: 70 })
expect(selection.node.value?.x).toBe(10)
active.value = true
expect(selection.node.value?.x).toBe(70)
editor.graph.deleteNode(first.id)
expect(selection.node.value).toBeNull()
} finally {
selection.dispose()
editor.dispose()
}
})
test('projection writes stay isolated and preview cancellation restores the graph without history', () => {
const fixture = setup()
const { editor, first, selection } = fixture
try {
expect(selection.node.value?.x).toBe(10)
const preview = editor.beginNodePreview('Move')
preview.update(first.id, { x: 80 })
const projected = selection.node.value
if (!projected) throw new Error('Missing selected node')
expect(projected.x).toBe(80)
projected.x = 99
expect(first.x).toBe(80)
preview.cancel()
expect(selection.node.value?.x).toBe(10)
expect(first.x).toBe(10)
expect(editor.undo.canUndo).toBe(false)
} finally {
fixture.dispose()
}
})
test('freezes inactive projections, refreshes on activation and disposes subscriptions', () => {
const fixture = setup()
const { editor, first, second } = fixture
const active = ref(true)
const selection = createSelectedNodeState(editor, active)
try {
const initial = selection.nodes.value
expect(selection.node.value?.x).toBe(10)
active.value = false
editor.graph.updateNodePreview(first.id, { x: 80 })
editor.select([second.id])
editor.graph.updateNodePreview(second.id, { x: 130 })
expect(selection.nodes.value).toBe(initial)
expect(selection.node.value?.x).toBe(10)
active.value = true
expect(selection.node.value?.id).toBe(second.id)
expect(selection.node.value?.x).toBe(130)
editor.graph.updateNodePreview(second.id, { x: 160 })
expect(selection.node.value?.x).toBe(160)
const last = selection.node.value
selection.dispose()
active.value = false
active.value = true
editor.graph.updateNodePreview(second.id, { x: 200 })
expect(last?.x).toBe(160)
} finally {
selection.dispose()
fixture.dispose()
}
})
test('updates only consumers of changed properties, not scene catalogs or the graph object', () => {
const fixture = setup()
const { editor, first, second, selection } = fixture
try {
const calls = { x: 0, opacity: 0, styles: 0 }
const x = computed(() => {
calls.x++
return selection.node.value?.x
})
const opacity = computed(() => {
calls.opacity++
return selection.node.value?.opacity
})
const app = createApp({ render: () => null })
app.provide(EDITOR_KEY, editor)
const styles = app.runWithContext(() =>
useSceneComputed(() => {
calls.styles++
return getSharedStyles(editor.graph, 'fill')
})
)
const selected = selection.node.value
const selectedNodes = selection.nodes.value
expect(selected).not.toBe(first)
expect(x.value).toBe(10)
expect(opacity.value).toBe(1)
expect(styles.value).toEqual([])
editor.graph.updateNodePreview(first.id, { x: 60 })
expect(x.value).toBe(60)
expect(opacity.value).toBe(1)
expect(styles.value).toEqual([])
expect(selection.node.value).toBe(selected)
expect(selection.nodes.value).toBe(selectedNodes)
expect(calls).toEqual({ x: 2, opacity: 1, styles: 1 })
editor.graph.updateNodePreview(second.id, { x: 90 })
editor.pan(10, 10)
expect(x.value).toBe(60)
expect(opacity.value).toBe(1)
expect(styles.value).toEqual([])
expect(calls).toEqual({ x: 2, opacity: 1, styles: 1 })
editor.graph.updateNodePreview(first.id, { opacity: 0.5 })
expect(opacity.value).toBe(0.5)
expect(x.value).toBe(60)
expect(calls).toEqual({ x: 2, opacity: 2, styles: 1 })
} finally {
fixture.dispose()
}
})
test('tracks multi-selection, selection changes and preview cancellation', () => {
const fixture = setup()
const { editor, first, second, selection } = fixture
try {
editor.select([first.id, second.id])
const widths = computed(() => selection.nodes.value.map((node) => node.width))
expect(selection.node.value).toBeNull()
expect(widths.value).toEqual([100, 200])
editor.graph.updateNodePreview(second.id, { width: 250 })
expect(widths.value).toEqual([100, 250])
editor.graph.updateNodePreview(second.id, { width: 200 })
expect(widths.value).toEqual([100, 200])
editor.select([second.id])
expect(selection.node.value?.id).toBe(second.id)
editor.graph.updateNodePreview(first.id, { width: 300 })
expect(widths.value).toEqual([200])
editor.clearSelection()
expect(selection.nodes.value).toEqual([])
expect(selection.node.value).toBeNull()
} finally {
fixture.dispose()
}
})
test('refreshes committed edits, undo and deletion', () => {
const fixture = setup()
const { editor, first, selection } = fixture
try {
expect(selection.node.value?.x).toBe(10)
editor.updateNodeWithUndo(first.id, { x: 40 }, 'Move')
expect(selection.node.value?.x).toBe(40)
editor.undoAction()
expect(selection.node.value?.x).toBe(10)
editor.graph.deleteNode(first.id)
expect(selection.nodes.value).toEqual([])
} finally {
fixture.dispose()
}
})
test('follows graph replacement without receiving previews from the discarded graph', () => {
const fixture = setup()
const { editor, first, selection } = fixture
try {
const previousGraph = editor.graph
expect(selection.node.value?.x).toBe(10)
const graph = new SceneGraph()
const replacement = graph.createNode('RECTANGLE', graph.getPages()[0].id, { x: 50 })
editor.replaceGraph(graph)
editor.select([replacement.id])
expect(selection.node.value?.x).toBe(50)
previousGraph.updateNodePreview(first.id, { x: 90 })
expect(selection.node.value?.x).toBe(50)
graph.updateNodePreview(replacement.id, { x: 60 })
expect(selection.node.value?.x).toBe(60)
} finally {
fixture.dispose()
}
})
test('releases preview subscriptions with the consuming scope', () => {
const fixture = setup()
const { editor, first } = fixture
const scope = effectScope()
try {
const selection = scope.run(() => useSelectedNodeState(editor))
expect(selection?.node.value?.x).toBe(10)
scope.stop()
editor.graph.updateNodePreview(first.id, { x: 80 })
expect(selection?.node.value?.x).toBe(10)
} finally {
scope.stop()
fixture.dispose()
}
})
})