openpencil/tests/engine/editor/node-preview.test.ts
Danila Poyarkov d16400b3e6 feat(editor): checkpoint live interaction improvements
Unify preview-aware transforms across scene drawing, labels, selection and input. Give live property and creation edits explicit preview ownership, preserve cancellation and one-step undo, and reuse bounded text preparation resources.

Include retained device-grid handling, worktree HMR coverage, and nested/reflected interaction regressions in this cohesive progress checkpoint.

Validation: full check passes; 734 scoped unit tests and 53 targeted browser tests pass. This is NOT merge-ready: the unchanged exact nested filled-section held/released regression still fails with 63 differing pixels (maximum channel delta 5/255). The browser pass count excludes that separately run failure and the previously classified paint-field-width baseline. Raster-origin investigation and broader acceptance remain outstanding.
2026-09-15 11:36:17 +03:00

215 lines
7.9 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
import { SceneGraph, type SceneNode } from '@open-pencil/scene-graph'
describe('owned node previews', () => {
test('publishes layout children with their layout provenance and preserves one-step undo', () => {
const editor = createEditor()
try {
const parent = editor.graph.createNode('FRAME', editor.state.currentPageId, {
width: 200,
height: 100,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED'
})
const child = editor.graph.createNode('RECTANGLE', parent.id, { layoutGrow: 1, height: 20 })
editor.runLayoutForNode(parent.id)
const updates: Array<{ id: string; changes: Partial<SceneNode>; layout: boolean }> = []
editor.onEditorEvent('node:updated', (id, changes) => {
updates.push({ id, changes, layout: editor.graph.isApplyingLayout })
})
const preview = editor.beginNodePreview('Resize')
expect(editor.isInteractiveEditing()).toBe(false)
preview.update(parent.id, { width: 320 })
preview.update(parent.id, { width: 340 })
expect(updates).toEqual([])
expect(editor.isInteractiveEditing()).toBe(true)
preview.commit()
expect(editor.isInteractiveEditing()).toBe(false)
expect(updates.find((update) => update.id === child.id)).toMatchObject({
changes: { width: 340 },
layout: true
})
expect(updates.filter((update) => update.id === child.id)).toHaveLength(1)
editor.undoAction()
expect([parent.width, child.width]).toEqual([200, 200])
expect(editor.undo.canUndo).toBe(false)
editor.redoAction()
expect([parent.width, child.width]).toEqual([340, 340])
} finally {
editor.dispose()
}
})
test('captures implicit style detachment and text-cache invalidation for commit, undo and cancel', () => {
const editor = createEditor()
try {
const node = editor.graph.createNode('TEXT', editor.state.currentPageId, {
text: 'Preview',
fontSize: 16,
textStyleId: 'linked-style',
derivedTextGlyphs: [],
textPicture: new Uint8Array([1, 2, 3])
})
const updates: Partial<SceneNode>[] = []
editor.onEditorEvent('node:updated', (id, changes) => {
if (id === node.id) updates.push(changes)
})
const preview = editor.beginNodePreview('Font size')
preview.update(node.id, { fontSize: 32 })
expect(updates).toEqual([])
expect(node.textStyleId).toBeNull()
preview.commit()
expect(updates[0]).toMatchObject({
fontSize: 32,
textStyleId: null,
derivedTextGlyphs: null,
textPicture: null
})
editor.undoAction()
expect(node.fontSize).toBe(16)
expect(node.textStyleId).toBe('linked-style')
expect(node.derivedTextGlyphs).toEqual([])
expect(node.textPicture).toEqual(new Uint8Array([1, 2, 3]))
const count = updates.length
const cancelled = editor.beginNodePreview()
cancelled.update(node.id, { fontSize: 48 })
cancelled.cancel()
expect(updates).toHaveLength(count)
expect(node.fontSize).toBe(16)
expect(node.textStyleId).toBe('linked-style')
expect(node.derivedTextGlyphs).toEqual([])
} finally {
editor.dispose()
}
})
for (const terminal of ['selection', 'replacement', 'disposal'] as const) {
test(`${terminal} cancels the old edit and ignores its trailing input`, () => {
const editor = createEditor()
try {
const node = editor.graph.createNode('RECTANGLE', editor.state.currentPageId, { x: 10 })
const preview = editor.beginNodePreview()
preview.update(node.id, { x: 50 })
if (terminal === 'selection') editor.select([node.id])
if (terminal === 'replacement') editor.replaceGraph(new SceneGraph())
if (terminal === 'disposal') editor.dispose()
expect(preview.closed).toBe(true)
expect(node.x).toBe(10)
preview.update(node.id, { x: 90 })
preview.commit()
expect(node.x).toBe(10)
expect(editor.undo.canUndo).toBe(false)
expect(editor.isInteractiveEditing()).toBe(false)
} finally {
editor.dispose()
}
})
}
test('deleting an unchanged target cancels the other targets without resurrecting it', () => {
const editor = createEditor()
try {
const first = editor.graph.createNode('RECTANGLE', editor.state.currentPageId, { x: 10 })
const second = editor.graph.createNode('RECTANGLE', editor.state.currentPageId, { x: 20 })
const preview = editor.beginNodePreview()
preview.update(first.id, { x: 10 })
preview.update(second.id, { x: 30 })
editor.graph.deleteNode(first.id)
expect(preview.closed).toBe(true)
expect(editor.graph.getNode(first.id)).toBeUndefined()
expect(second.x).toBe(20)
expect(editor.isInteractiveEditing()).toBe(false)
} finally {
editor.dispose()
}
})
test('preview observers unwind after exceptions and include nested layout updates', () => {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', graph.getPages()[0].id, { x: 10 })
const previous: number[] = []
expect(() =>
graph.runPreviewUpdates(
() => {
graph.runPreviewUpdates(() => graph.updateNode(node.id, { x: 20 }))
throw new Error('Interrupted preview')
},
(before) => {
previous.push(before.x)
}
)
).toThrow('Interrupted preview')
graph.runPreviewUpdates(() => graph.updateNode(node.id, { x: 30 }))
expect(previous).toEqual([10])
})
test('history batches are not live interactions and interaction release is idempotent', () => {
const editor = createEditor()
try {
editor.undo.beginBatch('Programmatic work')
expect(editor.isInteractiveEditing()).toBe(false)
const endFirst = editor.beginInteractiveEdit()
const endSecond = editor.beginInteractiveEdit()
endFirst()
endFirst()
expect(editor.isInteractiveEditing()).toBe(true)
endSecond()
expect(editor.isInteractiveEditing()).toBe(false)
editor.undo.commitBatch()
} finally {
editor.dispose()
}
})
test('rejects library-definition previews without leaving an active edit', () => {
const editor = createEditor()
try {
const node = editor.graph.createNode('COMPONENT', editor.state.currentPageId, {
x: 10,
librarySource: {
identity: { libraryId: 'design-system', assetKey: 'button', revisionId: 'r1' },
sourceNodeId: 'source',
readOnly: true
}
})
const preview = editor.beginNodePreview()
expect(() => preview.update(node.id, { x: 30 })).toThrow('Edit the source library')
expect(preview.closed).toBe(true)
expect(node.x).toBe(10)
expect(editor.isInteractiveEditing()).toBe(false)
} finally {
editor.dispose()
}
})
test('unchanged terminal commits do not add history for non-preview controls', () => {
const editor = createEditor()
try {
const node = editor.graph.createNode('RECTANGLE', editor.state.currentPageId, { opacity: 1 })
editor.commitNodeUpdate(node.id, { opacity: 1 })
expect(editor.undo.canUndo).toBe(false)
} finally {
editor.dispose()
}
})
test('returning to the original value closes without an undo entry', () => {
const editor = createEditor()
try {
const node = editor.graph.createNode('RECTANGLE', editor.state.currentPageId, { x: 10 })
const preview = editor.beginNodePreview()
preview.update(node.id, { x: 40 })
preview.update(node.id, { x: 10 })
preview.commit()
expect(preview.closed).toBe(true)
expect(editor.undo.canUndo).toBe(false)
expect(editor.isInteractiveEditing()).toBe(false)
} finally {
editor.dispose()
}
})
})