openpencil/tests/engine/scene-graph/undo/mutation.test.ts
Danila Poyarkov 7f91594d0c refactor: standardize acronym casing
- Rename first-party API, RPC, JSON, CORS, SVG, JSX, and related identifiers to preserve acronym casing
- Keep upstream and serialized boundary names unchanged
- Add a lint guardrail and migration notes for exported APIs
2026-08-10 15:48:16 +03:00

152 lines
5.2 KiB
TypeScript

import { describe, test, expect } from 'bun:test'
import { SceneGraph, UndoManager } from '@open-pencil/core'
import type { JSONObject } from '@open-pencil/scene-graph/primitives'
import { getNodeOrThrow } from '#tests/helpers/assert'
// ---------------------------------------------------------------------------
// SceneGraph + UndoManager — integration (updateNodeWithUndo pattern)
// ---------------------------------------------------------------------------
describe('SceneGraph + UndoManager — updateNode undo integration', () => {
function makeSetup() {
const graph = new SceneGraph()
const undo = new UndoManager()
const pageId = graph.getPages()[0].id
function updateWithUndo(
id: string,
changes: Partial<Parameters<SceneGraph['updateNode']>[1]>,
label: string
) {
const node = getNodeOrThrow(graph, id)
const previous = Object.fromEntries(
(Object.keys(changes) as string[]).map((k) => [k, (node as JSONObject)[k]])
)
graph.updateNode(id, changes)
undo.push({
label,
forward: () => graph.updateNode(id, changes),
inverse: () => graph.updateNode(id, previous as Parameters<SceneGraph['updateNode']>[1])
})
}
return { graph, undo, pageId, updateWithUndo }
}
test('update then undo restores previous value', () => {
const { graph, undo, pageId, updateWithUndo } = makeSetup()
const id = graph.createNode('RECTANGLE', pageId, { x: 0, y: 0, width: 100, height: 100 }).id
updateWithUndo(id, { x: 200 }, 'move')
expect(getNodeOrThrow(graph, id).x).toBe(200)
undo.undo()
expect(getNodeOrThrow(graph, id).x).toBe(0)
})
test('update → undo → redo restores updated value', () => {
const { graph, undo, pageId, updateWithUndo } = makeSetup()
const id = graph.createNode('RECTANGLE', pageId, { x: 0, y: 0, width: 100, height: 100 }).id
updateWithUndo(id, { width: 250 }, 'resize')
undo.undo()
expect(getNodeOrThrow(graph, id).width).toBe(100)
undo.redo()
expect(getNodeOrThrow(graph, id).width).toBe(250)
})
test('multiple updates undo in LIFO order', () => {
const { graph, undo, pageId, updateWithUndo } = makeSetup()
const id = graph.createNode('RECTANGLE', pageId, { x: 0, y: 0, width: 100, height: 100 }).id
updateWithUndo(id, { x: 10 }, 'step1')
updateWithUndo(id, { x: 20 }, 'step2')
updateWithUndo(id, { x: 30 }, 'step3')
undo.undo()
expect(getNodeOrThrow(graph, id).x).toBe(20)
undo.undo()
expect(getNodeOrThrow(graph, id).x).toBe(10)
undo.undo()
expect(getNodeOrThrow(graph, id).x).toBe(0)
})
test('new action after undo clears redo stack', () => {
const { graph, undo, pageId, updateWithUndo } = makeSetup()
const id = graph.createNode('RECTANGLE', pageId, { x: 0, y: 0, width: 100, height: 100 }).id
updateWithUndo(id, { x: 100 }, 'a')
updateWithUndo(id, { x: 200 }, 'b')
undo.undo()
expect(undo.canRedo).toBe(true)
// new action should kill redo
updateWithUndo(id, { x: 300 }, 'c')
expect(undo.canRedo).toBe(false)
expect(getNodeOrThrow(graph, id).x).toBe(300)
})
test('undo does not affect other nodes', () => {
const { graph, undo, pageId, updateWithUndo } = makeSetup()
const a = graph.createNode('RECTANGLE', pageId, { x: 0, y: 0, width: 50, height: 50 }).id
const b = graph.createNode('RECTANGLE', pageId, { x: 0, y: 0, width: 50, height: 50 }).id
updateWithUndo(a, { x: 100 }, 'move a')
updateWithUndo(b, { x: 200 }, 'move b')
undo.undo() // undoes move b
expect(getNodeOrThrow(graph, b).x).toBe(0)
expect(getNodeOrThrow(graph, a).x).toBe(100) // a unaffected
undo.undo() // undoes move a
expect(getNodeOrThrow(graph, a).x).toBe(0)
})
test('multi-field update: all fields restored on undo', () => {
const { graph, undo, pageId, updateWithUndo } = makeSetup()
const id = graph.createNode('RECTANGLE', pageId, { x: 0, y: 0, width: 100, height: 100 }).id
updateWithUndo(id, { x: 50, y: 75, width: 200, height: 300 }, 'big move')
const after = getNodeOrThrow(graph, id)
expect(after.x).toBe(50)
expect(after.y).toBe(75)
expect(after.width).toBe(200)
expect(after.height).toBe(300)
undo.undo()
const restored = getNodeOrThrow(graph, id)
expect(restored.x).toBe(0)
expect(restored.y).toBe(0)
expect(restored.width).toBe(100)
expect(restored.height).toBe(100)
})
test('batch: multiple updates undo as one', () => {
const { graph, undo, pageId } = makeSetup()
const id = graph.createNode('RECTANGLE', pageId, { x: 0, y: 0, width: 100, height: 100 }).id
undo.beginBatch('batch move')
undo.apply({
label: 'x',
forward: () => graph.updateNode(id, { x: 10 }),
inverse: () => graph.updateNode(id, { x: 0 })
})
undo.apply({
label: 'y',
forward: () => graph.updateNode(id, { y: 20 }),
inverse: () => graph.updateNode(id, { y: 0 })
})
undo.commitBatch()
expect(getNodeOrThrow(graph, id).x).toBe(10)
expect(getNodeOrThrow(graph, id).y).toBe(20)
expect(undo.undoLabel).toBe('batch move')
undo.undo()
expect(getNodeOrThrow(graph, id).x).toBe(0)
expect(getNodeOrThrow(graph, id).y).toBe(0)
})
})