Files
sim/apps/sim/stores/undo-redo/store.test.ts
Vikhyath Mondreti bf5d0a5573 feat(copy-paste): allow cross workflow selection, paste, move for blocks (#2649)
* feat(copy-paste): allow cross workflow selection, paste, move for blocks

* fix drag options

* add keyboard and mouse controls into docs

* refactor sockets and undo/redo for batch additions and removals

* fix tests

* cleanup more code

* fix perms issue

* fix subflow copy/paste

* remove log file

* fit paste in viewport bounds

* fix deselection
2025-12-31 02:47:06 -08:00

761 lines
23 KiB
TypeScript

/**
* Tests for the undo/redo store.
*
* These tests cover:
* - Basic push/undo/redo operations
* - Stack capacity limits
* - Move operation coalescing
* - Recording suspension
* - Stack pruning
* - Multi-workflow/user isolation
*/
import {
createAddBlockEntry,
createAddEdgeEntry,
createBlock,
createMockStorage,
createMoveBlockEntry,
createRemoveBlockEntry,
createRemoveEdgeEntry,
createUpdateParentEntry,
} from '@sim/testing'
import { beforeEach, describe, expect, it } from 'vitest'
import { runWithUndoRedoRecordingSuspended, useUndoRedoStore } from '@/stores/undo-redo/store'
import type { UpdateParentOperation } from '@/stores/undo-redo/types'
describe('useUndoRedoStore', () => {
const workflowId = 'wf-test'
const userId = 'user-test'
beforeEach(() => {
global.localStorage = createMockStorage()
useUndoRedoStore.setState({
stacks: {},
capacity: 100,
})
})
describe('push', () => {
it('should add an operation to the undo stack', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
const entry = createAddBlockEntry('block-1', { workflowId, userId })
push(workflowId, userId, entry)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 1,
redoSize: 0,
})
})
it('should clear redo stack when pushing new operation', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
undo(workflowId, userId)
expect(getStackSizes(workflowId, userId).redoSize).toBe(1)
push(workflowId, userId, createAddBlockEntry('block-3', { workflowId, userId }))
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 2,
redoSize: 0,
})
})
it('should respect capacity limit', () => {
useUndoRedoStore.setState({ capacity: 3 })
const { push, getStackSizes } = useUndoRedoStore.getState()
for (let i = 0; i < 5; i++) {
push(workflowId, userId, createAddBlockEntry(`block-${i}`, { workflowId, userId }))
}
expect(getStackSizes(workflowId, userId).undoSize).toBe(3)
})
it('should limit number of stacks to 5', () => {
const { push } = useUndoRedoStore.getState()
// Create 6 different workflow/user combinations
for (let i = 0; i < 6; i++) {
const wfId = `wf-${i}`
const uId = `user-${i}`
push(wfId, uId, createAddBlockEntry(`block-${i}`, { workflowId: wfId, userId: uId }))
}
const { stacks } = useUndoRedoStore.getState()
expect(Object.keys(stacks).length).toBe(5)
})
it('should remove oldest stack when limit exceeded', () => {
const { push } = useUndoRedoStore.getState()
// Create stacks with varying timestamps
for (let i = 0; i < 5; i++) {
push(`wf-${i}`, `user-${i}`, createAddBlockEntry(`block-${i}`))
}
// Add a 6th stack - should remove the oldest
push('wf-new', 'user-new', createAddBlockEntry('block-new'))
const { stacks } = useUndoRedoStore.getState()
expect(Object.keys(stacks).length).toBe(5)
expect(stacks['wf-new:user-new']).toBeDefined()
})
})
describe('undo', () => {
it('should return the last operation and move it to redo', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
const entry = createAddBlockEntry('block-1', { workflowId, userId })
push(workflowId, userId, entry)
const result = undo(workflowId, userId)
expect(result).toEqual(entry)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 0,
redoSize: 1,
})
})
it('should return null when undo stack is empty', () => {
const { undo } = useUndoRedoStore.getState()
const result = undo(workflowId, userId)
expect(result).toBeNull()
})
it('should undo operations in LIFO order', () => {
const { push, undo } = useUndoRedoStore.getState()
const entry1 = createAddBlockEntry('block-1', { workflowId, userId })
const entry2 = createAddBlockEntry('block-2', { workflowId, userId })
const entry3 = createAddBlockEntry('block-3', { workflowId, userId })
push(workflowId, userId, entry1)
push(workflowId, userId, entry2)
push(workflowId, userId, entry3)
expect(undo(workflowId, userId)).toEqual(entry3)
expect(undo(workflowId, userId)).toEqual(entry2)
expect(undo(workflowId, userId)).toEqual(entry1)
})
})
describe('redo', () => {
it('should return the last undone operation and move it back to undo', () => {
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
const entry = createAddBlockEntry('block-1', { workflowId, userId })
push(workflowId, userId, entry)
undo(workflowId, userId)
const result = redo(workflowId, userId)
expect(result).toEqual(entry)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 1,
redoSize: 0,
})
})
it('should return null when redo stack is empty', () => {
const { redo } = useUndoRedoStore.getState()
const result = redo(workflowId, userId)
expect(result).toBeNull()
})
it('should redo operations in LIFO order', () => {
const { push, undo, redo } = useUndoRedoStore.getState()
const entry1 = createAddBlockEntry('block-1', { workflowId, userId })
const entry2 = createAddBlockEntry('block-2', { workflowId, userId })
push(workflowId, userId, entry1)
push(workflowId, userId, entry2)
undo(workflowId, userId)
undo(workflowId, userId)
expect(redo(workflowId, userId)).toEqual(entry1)
expect(redo(workflowId, userId)).toEqual(entry2)
})
})
describe('clear', () => {
it('should clear both undo and redo stacks', () => {
const { push, undo, clear, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
undo(workflowId, userId)
clear(workflowId, userId)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 0,
redoSize: 0,
})
})
it('should only clear stacks for specified workflow/user', () => {
const { push, clear, getStackSizes } = useUndoRedoStore.getState()
push(
'wf-1',
'user-1',
createAddBlockEntry('block-1', { workflowId: 'wf-1', userId: 'user-1' })
)
push(
'wf-2',
'user-2',
createAddBlockEntry('block-2', { workflowId: 'wf-2', userId: 'user-2' })
)
clear('wf-1', 'user-1')
expect(getStackSizes('wf-1', 'user-1').undoSize).toBe(0)
expect(getStackSizes('wf-2', 'user-2').undoSize).toBe(1)
})
})
describe('clearRedo', () => {
it('should only clear the redo stack', () => {
const { push, undo, clearRedo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
undo(workflowId, userId)
clearRedo(workflowId, userId)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 1,
redoSize: 0,
})
})
})
describe('getStackSizes', () => {
it('should return zero sizes for non-existent stack', () => {
const { getStackSizes } = useUndoRedoStore.getState()
expect(getStackSizes('non-existent', 'user')).toEqual({
undoSize: 0,
redoSize: 0,
})
})
it('should return correct sizes', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
push(workflowId, userId, createAddBlockEntry('block-3', { workflowId, userId }))
undo(workflowId, userId)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 2,
redoSize: 1,
})
})
})
describe('setCapacity', () => {
it('should update capacity', () => {
const { setCapacity } = useUndoRedoStore.getState()
setCapacity(50)
expect(useUndoRedoStore.getState().capacity).toBe(50)
})
it('should truncate existing stacks to new capacity', () => {
const { push, setCapacity, getStackSizes } = useUndoRedoStore.getState()
for (let i = 0; i < 10; i++) {
push(workflowId, userId, createAddBlockEntry(`block-${i}`, { workflowId, userId }))
}
expect(getStackSizes(workflowId, userId).undoSize).toBe(10)
setCapacity(5)
expect(getStackSizes(workflowId, userId).undoSize).toBe(5)
})
})
describe('move-block coalescing', () => {
it('should coalesce consecutive moves of the same block', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 10, y: 10 },
})
)
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 10, y: 10 },
after: { x: 20, y: 20 },
})
)
// Should coalesce into a single operation
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
})
it('should not coalesce moves of different blocks', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 10, y: 10 },
})
)
push(
workflowId,
userId,
createMoveBlockEntry('block-2', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 20, y: 20 },
})
)
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
})
it('should skip no-op moves', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 100, y: 100 },
after: { x: 100, y: 100 },
})
)
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
})
it('should preserve original position when coalescing results in no-op', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
// Move block from (0,0) to (10,10)
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 10, y: 10 },
})
)
// Move block back to (0,0) - coalesces to a no-op
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 10, y: 10 },
after: { x: 0, y: 0 },
})
)
// Should result in no operations since it's a round-trip
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
})
})
describe('recording suspension', () => {
it('should skip operations when recording is suspended', async () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
await runWithUndoRedoRecordingSuspended(() => {
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
})
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
})
it('should resume recording after suspension ends', async () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
await runWithUndoRedoRecordingSuspended(() => {
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
})
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
})
it('should handle nested suspension correctly', async () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
await runWithUndoRedoRecordingSuspended(async () => {
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
await runWithUndoRedoRecordingSuspended(() => {
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
})
push(workflowId, userId, createAddBlockEntry('block-3', { workflowId, userId }))
})
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
push(workflowId, userId, createAddBlockEntry('block-4', { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
})
})
describe('pruneInvalidEntries', () => {
it('should remove entries for non-existent blocks', () => {
const { push, pruneInvalidEntries, getStackSizes } = useUndoRedoStore.getState()
// Add entries for blocks
push(workflowId, userId, createRemoveBlockEntry('block-1', null, { workflowId, userId }))
push(workflowId, userId, createRemoveBlockEntry('block-2', null, { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
// Prune with only block-1 existing
const graph = {
blocksById: {
'block-1': createBlock({ id: 'block-1' }),
},
edgesById: {},
}
pruneInvalidEntries(workflowId, userId, graph)
// Only the entry for block-1 should remain (inverse is add-block which requires block NOT exist)
// Actually, remove-block inverse is add-block, which is applicable when block doesn't exist
// Let me reconsider: the pruneInvalidEntries checks if the INVERSE is applicable
// For remove-block, inverse is add-block, which is applicable when block doesn't exist
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
})
it('should remove redo entries with non-applicable operations', () => {
const { push, undo, pruneInvalidEntries, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createRemoveBlockEntry('block-1', null, { workflowId, userId }))
undo(workflowId, userId)
expect(getStackSizes(workflowId, userId).redoSize).toBe(1)
// Prune - block-1 doesn't exist, so remove-block is not applicable
pruneInvalidEntries(workflowId, userId, { blocksById: {}, edgesById: {} })
expect(getStackSizes(workflowId, userId).redoSize).toBe(0)
})
})
describe('workflow/user isolation', () => {
it('should keep stacks isolated by workflow and user', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
push(
'wf-1',
'user-1',
createAddBlockEntry('block-1', { workflowId: 'wf-1', userId: 'user-1' })
)
push(
'wf-1',
'user-2',
createAddBlockEntry('block-2', { workflowId: 'wf-1', userId: 'user-2' })
)
push(
'wf-2',
'user-1',
createAddBlockEntry('block-3', { workflowId: 'wf-2', userId: 'user-1' })
)
expect(getStackSizes('wf-1', 'user-1').undoSize).toBe(1)
expect(getStackSizes('wf-1', 'user-2').undoSize).toBe(1)
expect(getStackSizes('wf-2', 'user-1').undoSize).toBe(1)
})
it('should not affect other stacks when undoing', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
push(
'wf-1',
'user-1',
createAddBlockEntry('block-1', { workflowId: 'wf-1', userId: 'user-1' })
)
push(
'wf-2',
'user-1',
createAddBlockEntry('block-2', { workflowId: 'wf-2', userId: 'user-1' })
)
undo('wf-1', 'user-1')
expect(getStackSizes('wf-1', 'user-1').undoSize).toBe(0)
expect(getStackSizes('wf-2', 'user-1').undoSize).toBe(1)
})
})
describe('edge cases', () => {
it('should handle rapid consecutive operations', () => {
const { push, getStackSizes } = useUndoRedoStore.getState()
for (let i = 0; i < 50; i++) {
push(workflowId, userId, createAddBlockEntry(`block-${i}`, { workflowId, userId }))
}
expect(getStackSizes(workflowId, userId).undoSize).toBe(50)
})
it('should handle multiple undo/redo cycles', () => {
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
for (let i = 0; i < 10; i++) {
undo(workflowId, userId)
redo(workflowId, userId)
}
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 1,
redoSize: 0,
})
})
it('should handle mixed operation types', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
push(workflowId, userId, createAddEdgeEntry('edge-1', { workflowId, userId }))
push(
workflowId,
userId,
createMoveBlockEntry('block-1', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 100, y: 100 },
})
)
push(workflowId, userId, createRemoveBlockEntry('block-2', null, { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(4)
undo(workflowId, userId)
undo(workflowId, userId)
expect(getStackSizes(workflowId, userId)).toEqual({
undoSize: 2,
redoSize: 2,
})
})
})
describe('edge operations', () => {
it('should handle add-edge operations', () => {
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddEdgeEntry('edge-1', { workflowId, userId }))
push(workflowId, userId, createAddEdgeEntry('edge-2', { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
const entry = undo(workflowId, userId)
expect(entry?.operation.type).toBe('add-edge')
expect(getStackSizes(workflowId, userId).redoSize).toBe(1)
redo(workflowId, userId)
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
})
it('should handle remove-edge operations', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createRemoveEdgeEntry('edge-1', null, { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
const entry = undo(workflowId, userId)
expect(entry?.operation.type).toBe('remove-edge')
expect(entry?.inverse.type).toBe('add-edge')
})
})
describe('update-parent operations', () => {
it('should handle update-parent operations', () => {
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
push(
workflowId,
userId,
createUpdateParentEntry('block-1', {
workflowId,
userId,
oldParentId: undefined,
newParentId: 'loop-1',
oldPosition: { x: 100, y: 100 },
newPosition: { x: 50, y: 50 },
})
)
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
const entry = undo(workflowId, userId)
expect(entry?.operation.type).toBe('update-parent')
expect(entry?.inverse.type).toBe('update-parent')
redo(workflowId, userId)
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
})
it('should correctly swap parent IDs in inverse operation', () => {
const { push, undo } = useUndoRedoStore.getState()
push(
workflowId,
userId,
createUpdateParentEntry('block-1', {
workflowId,
userId,
oldParentId: 'loop-1',
newParentId: 'loop-2',
oldPosition: { x: 0, y: 0 },
newPosition: { x: 100, y: 100 },
})
)
const entry = undo(workflowId, userId)
const inverse = entry?.inverse as UpdateParentOperation
expect(inverse.data.oldParentId).toBe('loop-2')
expect(inverse.data.newParentId).toBe('loop-1')
expect(inverse.data.oldPosition).toEqual({ x: 100, y: 100 })
expect(inverse.data.newPosition).toEqual({ x: 0, y: 0 })
})
})
describe('pruneInvalidEntries with edges', () => {
it('should remove entries for non-existent edges', () => {
const { push, pruneInvalidEntries, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createRemoveEdgeEntry('edge-1', null, { workflowId, userId }))
push(workflowId, userId, createRemoveEdgeEntry('edge-2', null, { workflowId, userId }))
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
const graph = {
blocksById: {},
edgesById: {
'edge-1': { id: 'edge-1', source: 'a', target: 'b' },
},
}
pruneInvalidEntries(workflowId, userId, graph as any)
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
})
})
describe('complex scenarios', () => {
it('should handle a complete workflow creation scenario', () => {
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('starter', { workflowId, userId }))
push(workflowId, userId, createAddBlockEntry('agent-1', { workflowId, userId }))
push(workflowId, userId, createAddEdgeEntry('edge-1', { workflowId, userId }))
push(
workflowId,
userId,
createMoveBlockEntry('agent-1', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 200, y: 100 },
})
)
expect(getStackSizes(workflowId, userId).undoSize).toBe(4)
undo(workflowId, userId)
undo(workflowId, userId)
expect(getStackSizes(workflowId, userId)).toEqual({ undoSize: 2, redoSize: 2 })
redo(workflowId, userId)
expect(getStackSizes(workflowId, userId)).toEqual({ undoSize: 3, redoSize: 1 })
push(workflowId, userId, createAddBlockEntry('agent-2', { workflowId, userId }))
expect(getStackSizes(workflowId, userId)).toEqual({ undoSize: 4, redoSize: 0 })
})
it('should handle loop workflow with child blocks', () => {
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
push(workflowId, userId, createAddBlockEntry('loop-1', { workflowId, userId }))
push(
workflowId,
userId,
createUpdateParentEntry('child-1', {
workflowId,
userId,
oldParentId: undefined,
newParentId: 'loop-1',
})
)
push(
workflowId,
userId,
createMoveBlockEntry('child-1', {
workflowId,
userId,
before: { x: 0, y: 0 },
after: { x: 50, y: 50 },
})
)
expect(getStackSizes(workflowId, userId).undoSize).toBe(3)
const moveEntry = undo(workflowId, userId)
expect(moveEntry?.operation.type).toBe('move-block')
const parentEntry = undo(workflowId, userId)
expect(parentEntry?.operation.type).toBe('update-parent')
})
})
})