openpencil/tests/engine/undo-redo-sequence.test.ts
Danila Poyarkov b3053601f7 Add arrow key nudge and fix undo/redo bugs
Nudge: Arrow keys move selection by 1px, Shift+Arrow by 10px.
Consecutive nudges within 300ms are batched into a single undo
entry so Cmd+Z undoes the entire sequence at once.

Fix duplicate redo creating nodes with new IDs, breaking
subsequent undo entries that reference the original IDs. Now
snapshotSubtree/restoreSubtree preserve node IDs across redo.

Fix delete undo not restoring children — deleteSelected only
snapshotted top-level nodes, so undoing a frame delete lost all
its children. Now snapshots the full subtree.

Fix draw tool not committing resize to undo — shapes created via
draw had their final dimensions lost on redo because handleDrawUp
never recorded the resize. Now uses beginBatch/commitBatch to
combine create+resize into a single undo entry.

Clean up use-keyboard.ts — remove shouldPreventDefault and opaque
PREVENT_* sets, move tool switch and nudge to dedicated keydown
listener, add shift() helper.

Closes #182
2026-04-10 00:26:13 +03:00

154 lines
4.4 KiB
TypeScript

import { describe, test, expect } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
describe('undo/redo multi-step sequences', () => {
function setup() {
const editor = createEditor()
const pageId = editor.graph.getPages()[0].id
return { editor, pageId }
}
test('create → move → duplicate → move copy → undo all → redo all', () => {
const { editor, pageId } = setup()
const frame = editor.graph.createNode('FRAME', pageId, {
name: 'Card',
x: 100,
y: 100,
width: 200,
height: 150,
})
editor.select([frame.id])
const createSnapshot = structuredClone(editor.graph.getNode(frame.id)!)
editor.pushUndoEntry({
label: 'Create',
forward: () => {
const { parentId: _p, childIds: _c, ...rest } = createSnapshot
editor.graph.createNode('FRAME', pageId, rest)
},
inverse: () => editor.graph.deleteNode(frame.id),
})
editor.graph.updateNode(frame.id, { x: 300, y: 50 })
editor.commitMove(new Map([[frame.id, { x: 100, y: 100 }]]))
expect(editor.graph.getNode(frame.id)!.x).toBe(300)
expect(editor.graph.getNode(frame.id)!.y).toBe(50)
editor.duplicateSelected()
const dupIds = [...editor.state.selectedIds]
expect(dupIds).toHaveLength(1)
const dupId = dupIds[0]
expect(dupId).not.toBe(frame.id)
editor.graph.updateNode(dupId, { x: 500, y: 200 })
editor.commitMove(new Map([[dupId, { x: 320, y: 70 }]]))
// Undo move copy
editor.undo.undo()
expect(editor.graph.getNode(dupId)!.x).toBe(320)
// Undo duplicate
editor.undo.undo()
expect(editor.graph.getNode(dupId)).toBeUndefined()
// Undo move
editor.undo.undo()
expect(editor.graph.getNode(frame.id)!.x).toBe(100)
// Undo create
editor.undo.undo()
expect(editor.graph.getNode(frame.id)).toBeUndefined()
// Redo create
editor.undo.redo()
expect(editor.graph.getNode(frame.id)).not.toBeUndefined()
expect(editor.graph.getNode(frame.id)!.x).toBe(100)
// Redo move
editor.undo.redo()
expect(editor.graph.getNode(frame.id)!.x).toBe(300)
// Redo duplicate — must recreate with SAME ID
editor.undo.redo()
expect(editor.graph.getNode(dupId)).not.toBeUndefined()
// Redo move copy — must find the node by same ID
editor.undo.redo()
expect(editor.graph.getNode(dupId)!.x).toBe(500)
expect(editor.graph.getNode(dupId)!.y).toBe(200)
})
test('duplicate with children preserves subtree on redo', () => {
const { editor, pageId } = setup()
const frame = editor.graph.createNode('FRAME', pageId, {
name: 'Card',
x: 50,
y: 50,
width: 200,
height: 150,
})
editor.graph.createNode('TEXT', frame.id, {
name: 'Title',
text: 'Hello',
x: 10,
y: 10,
width: 100,
height: 20,
})
editor.select([frame.id])
editor.duplicateSelected()
const dupFrameId = [...editor.state.selectedIds][0]
const dupChildren = editor.graph.getNode(dupFrameId)!.childIds
expect(dupChildren).toHaveLength(1)
const dupTextId = dupChildren[0]
expect(editor.graph.getNode(dupTextId)!.text).toBe('Hello')
// Undo
editor.undo.undo()
expect(editor.graph.getNode(dupFrameId)).toBeUndefined()
expect(editor.graph.getNode(dupTextId)).toBeUndefined()
// Redo — must recreate with same IDs
editor.undo.redo()
expect(editor.graph.getNode(dupFrameId)).not.toBeUndefined()
expect(editor.graph.getNode(dupTextId)).not.toBeUndefined()
expect(editor.graph.getNode(dupFrameId)!.childIds).toContain(dupTextId)
expect(editor.graph.getNode(dupTextId)!.text).toBe('Hello')
})
test('delete frame with children → undo restores subtree', () => {
const { editor, pageId } = setup()
const frame = editor.graph.createNode('FRAME', pageId, {
name: 'Card',
x: 0,
y: 0,
width: 200,
height: 150,
})
const child = editor.graph.createNode('RECTANGLE', frame.id, {
name: 'Bg',
x: 0,
y: 0,
width: 200,
height: 150,
})
editor.select([frame.id])
editor.deleteSelected()
expect(editor.graph.getNode(frame.id)).toBeUndefined()
expect(editor.graph.getNode(child.id)).toBeUndefined()
editor.undo.undo()
expect(editor.graph.getNode(frame.id)).not.toBeUndefined()
expect(editor.graph.getNode(child.id)).not.toBeUndefined()
expect(editor.graph.getNode(frame.id)!.childIds).toContain(child.id)
})
})