import { describe, it, expect, vi } from 'vitest' import { flushSync } from 'svelte' import { createFlowDiffManager } from './flowDiffManager.svelte' import { DUPLICATE_MODULE_PREFIX, NEW_MODULE_PREFIX } from './flowDiff' import type { FlowValue, RawScript, ForloopFlow, BranchOne, BranchAll } from '$lib/gen' import { SPECIAL_MODULE_IDS } from '../copilot/chat/shared' import { createRawScriptModule, createForloopModule, createBranchOneModule, createBranchAllModule, createExtendedOpenFlow, createFlowStore, createFlowWithSpecialModules, deepClone, expectModuleOrder, createIdentityModule, createAiAgentModule, createFlowModuleTool } from './flowDiff.testUtils' vi.mock('../copilot/chat/shared', () => ({ SPECIAL_MODULE_IDS: { INPUT: 'Input', PREPROCESSOR: 'preprocessor', FAILURE: 'failure' } })) describe('FlowDiffManager', () => { describe('effect auto-computation', () => { it('auto-computes moduleActions when flows differ', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'before') const beforeFlow = createExtendedOpenFlow({ modules: [moduleA] }) const moduleB = createRawScriptModule('b', 'new') const afterFlow: FlowValue = { modules: [moduleA, moduleB] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() expect(manager.moduleActions).toHaveProperty('b') expect(manager.moduleActions['b']).toEqual({ action: 'added', pending: true }) }) cleanup() }) }) describe('acceptModule', () => { it('accepts added module - inserts into beforeFlow', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'content-a') const moduleB = createRawScriptModule('b', 'content-b') const beforeFlow = createExtendedOpenFlow({ modules: [moduleA] }) const afterFlow: FlowValue = { modules: [moduleA, moduleB] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() // Verify 'b' is detected as added expect(manager.moduleActions['b']).toEqual({ action: 'added', pending: true }) // Accept the added module manager.acceptModule('b') flushSync() // After accepting, beforeFlow should now contain module 'b' const beforeModules = manager.beforeFlow?.value.modules ?? [] expect(beforeModules.map((m) => m.id)).toContain('b') // The action should be cleared since beforeFlow now matches currentFlow expect(manager.moduleActions['b']).toBeUndefined() }) cleanup() }) it('accepts removed module - deletes from beforeFlow', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'content-a') const moduleB = createRawScriptModule('b', 'content-b') const beforeFlow = createExtendedOpenFlow({ modules: [moduleA, moduleB] }) const afterFlow: FlowValue = { modules: [moduleA] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() // Verify 'b' is detected as removed expect(manager.moduleActions['b']).toEqual({ action: 'removed', pending: true }) // Accept the removal manager.acceptModule('b') flushSync() // After accepting, beforeFlow should no longer contain module 'b' const beforeModules = manager.beforeFlow?.value.modules ?? [] expect(beforeModules.map((m) => m.id)).not.toContain('b') expect(manager.moduleActions['b']).toBeUndefined() }) cleanup() }) it('accepts modified module - updates beforeFlow content', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleBeforeA = createRawScriptModule('a', 'original-content') const moduleAfterA = createRawScriptModule('a', 'modified-content') const beforeFlow = createExtendedOpenFlow({ modules: [moduleBeforeA] }) const afterFlow: FlowValue = { modules: [moduleAfterA] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() // Verify 'a' is detected as modified expect(manager.moduleActions['a']).toEqual({ action: 'modified', pending: true }) // Accept the modification manager.acceptModule('a') flushSync() // After accepting, beforeFlow module should have the new content const beforeModules = manager.beforeFlow?.value.modules ?? [] const moduleA = beforeModules.find((m) => m.id === 'a') expect((moduleA?.value as RawScript).content).toBe('modified-content') expect(manager.moduleActions['a']).toBeUndefined() }) cleanup() }) it('accepts added nested module - parent accepted as skeleton first', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) // beforeFlow: empty const beforeFlow = createExtendedOpenFlow({ modules: [] }) // afterFlow: forloop containing a nested module const nestedModule = createRawScriptModule('nested', 'nested-content') const forloop = createForloopModule('loop', [nestedModule]) const afterFlow: FlowValue = { modules: [forloop] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() // Both 'loop' and 'nested' should be detected as added expect(manager.moduleActions['loop']).toEqual({ action: 'added', pending: true }) expect(manager.moduleActions['nested']).toEqual({ action: 'added', pending: true }) // Accept the nested module first - should auto-accept parent as skeleton manager.acceptModule('nested') flushSync() // After accepting nested, the loop should also be in beforeFlow (as skeleton initially) const beforeModules = manager.beforeFlow?.value.modules ?? [] expect(beforeModules.map((m) => m.id)).toContain('loop') // The nested module should be in the loop const loopModule = beforeModules.find((m) => m.id === 'loop') const loopValue = loopModule?.value as ForloopFlow expect(loopValue.modules.map((m) => m.id)).toContain('nested') }) cleanup() }) it('accepts input schema changes', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const beforeSchema = { properties: { x: { type: 'string' } } } const afterSchema = { properties: { x: { type: 'string' }, y: { type: 'number' } } } const beforeFlow = createExtendedOpenFlow({ modules: [] }, beforeSchema) const afterFlow: FlowValue = { modules: [] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) manager.setCurrentInputSchema(afterSchema) flushSync() // Input schema change should be detected expect(manager.moduleActions[SPECIAL_MODULE_IDS.INPUT]).toEqual({ action: 'modified', pending: true }) // Accept the input schema change manager.acceptModule(SPECIAL_MODULE_IDS.INPUT) flushSync() // beforeFlow schema should now match currentInputSchema expect(manager.beforeFlow?.schema).toEqual(afterSchema) }) cleanup() }) it('accepts added preprocessor module - stores it on beforeFlow', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const preprocessor = createRawScriptModule(SPECIAL_MODULE_IDS.PREPROCESSOR, 'prep') const beforeFlow = createExtendedOpenFlow({ modules: [] }) const afterFlow = createFlowWithSpecialModules({ preprocessor_module: deepClone(preprocessor) }) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() expect(manager.moduleActions[SPECIAL_MODULE_IDS.PREPROCESSOR]).toEqual({ action: 'added', pending: true }) manager.acceptModule(SPECIAL_MODULE_IDS.PREPROCESSOR) flushSync() expect(manager.beforeFlow?.value.preprocessor_module?.id).toBe( SPECIAL_MODULE_IDS.PREPROCESSOR ) expect( (manager.beforeFlow?.value.preprocessor_module?.value as RawScript | undefined) ?.content ).toBe('prep') }) cleanup() }) it('handles duplicate ID prefix (old__) for type changes', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) // Create a real type change: rawscript -> identity const moduleBeforeA = createRawScriptModule('a', 'content') const moduleAfterA = createIdentityModule('a') const beforeFlow = createExtendedOpenFlow({ modules: [moduleBeforeA] }) const afterFlow: FlowValue = { modules: [moduleAfterA] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() // Type change produces: 'a' (added) and 'old__a' (removed) expect(manager.moduleActions['a']).toEqual({ action: 'added', pending: true }) expect(manager.moduleActions[`${DUPLICATE_MODULE_PREFIX}a`]).toEqual({ action: 'removed', pending: true }) // Accept the removal (old__a) - should remove original module from beforeFlow manager.acceptModule(`${DUPLICATE_MODULE_PREFIX}a`) flushSync() // The module 'a' (original rawscript) should be removed from beforeFlow const beforeModules = manager.beforeFlow?.value.modules ?? [] expect(beforeModules.map((m) => m.id)).not.toContain('a') }) cleanup() }) }) describe('rejectModule', () => { it('rejects added module - removes from flowStore', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'content-a') const moduleB = createRawScriptModule('b', 'content-b') const beforeFlow = createExtendedOpenFlow({ modules: [deepClone(moduleA)] }) const currentFlowValue: FlowValue = { modules: [deepClone(moduleA), deepClone(moduleB)] } const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() expect(manager.moduleActions['b']).toEqual({ action: 'added', pending: true }) // Reject the added module manager.rejectModule('b', flowStore) flushSync() // After rejecting, flowStore should no longer contain module 'b' const currentModules = flowStore.val.value.modules expect(currentModules.map((m) => m.id)).not.toContain('b') expect(currentModules.map((m) => m.id)).toContain('a') }) cleanup() }) it('rejects removed module - restores to flowStore', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'content-a') const moduleB = createRawScriptModule('b', 'content-b') const beforeFlow = createExtendedOpenFlow({ modules: [deepClone(moduleA), deepClone(moduleB)] }) const currentFlowValue: FlowValue = { modules: [deepClone(moduleA)] } const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() expect(manager.moduleActions['b']).toEqual({ action: 'removed', pending: true }) // Reject the removal - should restore module 'b' manager.rejectModule('b', flowStore) flushSync() // After rejecting, flowStore should contain module 'b' again const currentModules = flowStore.val.value.modules expect(currentModules.map((m) => m.id)).toContain('b') }) cleanup() }) it('rejects modified module - reverts flowStore to beforeFlow state', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleBeforeA = createRawScriptModule('a', 'original-content') const moduleAfterA = createRawScriptModule('a', 'modified-content') const beforeFlow = createExtendedOpenFlow({ modules: [deepClone(moduleBeforeA)] }) const currentFlowValue: FlowValue = { modules: [deepClone(moduleAfterA)] } const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() expect(manager.moduleActions['a']).toEqual({ action: 'modified', pending: true }) // Reject the modification manager.rejectModule('a', flowStore) flushSync() // After rejecting, flowStore should have the original content const moduleA = flowStore.val.value.modules.find((m) => m.id === 'a') expect((moduleA?.value as RawScript).content).toBe('original-content') }) cleanup() }) it('rejects input schema changes - reverts flowStore schema', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const beforeSchema = { properties: { x: { type: 'string' } } } const afterSchema = { properties: { x: { type: 'string' }, y: { type: 'number' } } } const beforeFlow = createExtendedOpenFlow({ modules: [] }, beforeSchema) const flowStore = createFlowStore(createExtendedOpenFlow({ modules: [] }, afterSchema)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) manager.setCurrentInputSchema(afterSchema) flushSync() expect(manager.moduleActions[SPECIAL_MODULE_IDS.INPUT]).toEqual({ action: 'modified', pending: true }) // Reject the schema change manager.rejectModule(SPECIAL_MODULE_IDS.INPUT, flowStore) flushSync() // After rejecting, flowStore schema should match beforeFlow expect(flowStore.val.schema).toEqual(beforeSchema) }) cleanup() }) it('rejects removed failure module - restores it on flowStore', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const failure = createRawScriptModule(SPECIAL_MODULE_IDS.FAILURE, 'fail') const beforeFlow = createExtendedOpenFlow( createFlowWithSpecialModules({ failure_module: deepClone(failure) }) ) const flowStore = createFlowStore(createExtendedOpenFlow({ modules: [] })) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() expect(manager.moduleActions[SPECIAL_MODULE_IDS.FAILURE]).toEqual({ action: 'removed', pending: true }) manager.rejectModule(SPECIAL_MODULE_IDS.FAILURE, flowStore) flushSync() expect(flowStore.val.value.failure_module?.id).toBe(SPECIAL_MODULE_IDS.FAILURE) expect((flowStore.val.value.failure_module?.value as RawScript | undefined)?.content).toBe( 'fail' ) }) cleanup() }) it('does not crash without flowStore', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'content') const moduleB = createRawScriptModule('b', 'content') const beforeFlow = createExtendedOpenFlow({ modules: [moduleA] }) const afterFlow: FlowValue = { modules: [moduleA, moduleB] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() // Should not throw when flowStore is not provided expect(() => manager.rejectModule('b')).not.toThrow() }) cleanup() }) it('does nothing for module not in actions', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'content') const beforeFlow = createExtendedOpenFlow({ modules: [moduleA] }) const afterFlow: FlowValue = { modules: [moduleA] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() // No actions should exist expect(Object.keys(manager.moduleActions)).toHaveLength(0) // Should not throw for non-existent module expect(() => manager.rejectModule('nonexistent')).not.toThrow() }) cleanup() }) it('rejects duplicate ID prefix (old__) for type changes - restores original and renames new', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) // Create a real type change: rawscript -> identity const moduleBeforeA = createRawScriptModule('a', 'original-content') const moduleAfterA = createIdentityModule('a') const beforeFlow = createExtendedOpenFlow({ modules: [deepClone(moduleBeforeA)] }) const currentFlowValue: FlowValue = { modules: [deepClone(moduleAfterA)] } const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() // Type change produces: 'a' (added) and 'old__a' (removed) expect(manager.moduleActions['a']).toEqual({ action: 'added', pending: true }) expect(manager.moduleActions[`${DUPLICATE_MODULE_PREFIX}a`]).toEqual({ action: 'removed', pending: true }) // Reject the removal (old__a) - should restore original and rename the new one manager.rejectModule(`${DUPLICATE_MODULE_PREFIX}a`, flowStore) flushSync() const currentModules = flowStore.val.value.modules // The original module 'a' (rawscript) should be restored const restoredOriginal = currentModules.find((m) => m.id === 'a') expect(restoredOriginal).toBeDefined() expect(restoredOriginal?.value.type).toBe('rawscript') // The new module should be renamed to 'new__a' so user can still accept/reject it const renamedNew = currentModules.find((m) => m.id === `${NEW_MODULE_PREFIX}a`) expect(renamedNew).toBeDefined() expect(renamedNew?.value.type).toBe('identity') }) cleanup() }) }) describe('batch operations', () => { it('acceptAll accepts all pending modules', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'content-a') const moduleB = createRawScriptModule('b', 'content-b') const moduleC = createRawScriptModule('c', 'content-c') const beforeFlow = createExtendedOpenFlow({ modules: [moduleA] }) const afterFlow: FlowValue = { modules: [moduleA, moduleB, moduleC] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() expect(manager.moduleActions['b']).toBeDefined() expect(manager.moduleActions['c']).toBeDefined() // Accept all manager.acceptAll() flushSync() // All modules should now be in beforeFlow const beforeModules = manager.beforeFlow?.value.modules ?? [] expect(beforeModules.map((m) => m.id)).toContain('b') expect(beforeModules.map((m) => m.id)).toContain('c') }) cleanup() }) it('acceptAll skips non-pending actions', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'content') const moduleB = createRawScriptModule('b', 'content') const beforeFlow = createExtendedOpenFlow({ modules: [moduleA] }) const afterFlow: FlowValue = { modules: [moduleA, moduleB] } // Set editMode to false so actions are not pending manager.setEditMode(false) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() // Action should exist but not be pending expect(manager.moduleActions['b']?.pending).toBe(false) // acceptAll should skip non-pending manager.acceptAll() flushSync() // 'b' should still not be in beforeFlow const beforeModules = manager.beforeFlow?.value.modules ?? [] expect(beforeModules.map((m) => m.id)).not.toContain('b') }) cleanup() }) it('rejectAll rejects all pending modules', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'content-a') const moduleB = createRawScriptModule('b', 'content-b') const moduleC = createRawScriptModule('c', 'content-c') const beforeFlow = createExtendedOpenFlow({ modules: [deepClone(moduleA)] }) const currentFlowValue: FlowValue = { modules: [deepClone(moduleA), deepClone(moduleB), deepClone(moduleC)] } const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() expect(manager.moduleActions['b']).toBeDefined() expect(manager.moduleActions['c']).toBeDefined() // Reject all manager.rejectAll(flowStore) flushSync() // Both added modules should be removed from flowStore const currentModules = flowStore.val.value.modules expect(currentModules.map((m) => m.id)).not.toContain('b') expect(currentModules.map((m) => m.id)).not.toContain('c') }) cleanup() }) }) describe('edge cases', () => { it('clearSnapshot clears all state', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'content') const moduleB = createRawScriptModule('b', 'content') const beforeFlow = createExtendedOpenFlow({ modules: [moduleA] }) const afterFlow: FlowValue = { modules: [moduleA, moduleB] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() expect(manager.beforeFlow).toBeDefined() expect(manager.currentFlow).toBeDefined() expect(Object.keys(manager.moduleActions).length).toBeGreaterThan(0) manager.clearSnapshot() flushSync() expect(manager.beforeFlow).toBeUndefined() expect(manager.currentFlow).toBeUndefined() expect(Object.keys(manager.moduleActions)).toHaveLength(0) }) cleanup() }) it('revertToSnapshot restores entire flow', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'original') const moduleB = createRawScriptModule('b', 'modified') const beforeFlow = createExtendedOpenFlow({ modules: [deepClone(moduleA)] }) const currentFlowValue: FlowValue = { modules: [deepClone(moduleB)] } const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() // Revert to snapshot manager.revertToSnapshot(flowStore) flushSync() // flowStore should now be the beforeFlow expect(flowStore.val.value.modules.map((m) => m.id)).toEqual(['a']) expect((flowStore.val.value.modules[0].value as RawScript).content).toBe('original') }) cleanup() }) }) describe('module positioning', () => { it('accept added module at beginning - inserts at correct position', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'content-a') const moduleB = createRawScriptModule('b', 'content-b') const moduleNew = createRawScriptModule('new', 'new at beginning') // beforeFlow: [a, b] // afterFlow: [new, a, b] const beforeFlow = createExtendedOpenFlow({ modules: [deepClone(moduleA), deepClone(moduleB)] }) const afterFlow: FlowValue = { modules: [deepClone(moduleNew), deepClone(moduleA), deepClone(moduleB)] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() expect(manager.moduleActions['new']).toEqual({ action: 'added', pending: true }) // Accept the added module manager.acceptModule('new') flushSync() // Verify 'new' is inserted at the beginning const beforeModules = manager.beforeFlow?.value.modules ?? [] expectModuleOrder(beforeModules, ['new', 'a', 'b']) }) cleanup() }) it('accept added module in middle - inserts at correct position', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'content-a') const moduleB = createRawScriptModule('b', 'content-b') const moduleC = createRawScriptModule('c', 'content-c') const moduleNew = createRawScriptModule('new', 'new in middle') // beforeFlow: [a, b, c] // afterFlow: [a, new, b, c] const beforeFlow = createExtendedOpenFlow({ modules: [deepClone(moduleA), deepClone(moduleB), deepClone(moduleC)] }) const afterFlow: FlowValue = { modules: [ deepClone(moduleA), deepClone(moduleNew), deepClone(moduleB), deepClone(moduleC) ] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() expect(manager.moduleActions['new']).toEqual({ action: 'added', pending: true }) // Accept the added module manager.acceptModule('new') flushSync() // Verify 'new' is inserted at the correct middle position const beforeModules = manager.beforeFlow?.value.modules ?? [] expectModuleOrder(beforeModules, ['a', 'new', 'b', 'c']) }) cleanup() }) it('reject removed module - restores at correct position', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'content-a') const moduleB = createRawScriptModule('b', 'content-b') const moduleC = createRawScriptModule('c', 'content-c') // beforeFlow: [a, b, c] // afterFlow (currentFlow): [a, c] - 'b' removed const beforeFlow = createExtendedOpenFlow({ modules: [deepClone(moduleA), deepClone(moduleB), deepClone(moduleC)] }) const currentFlowValue: FlowValue = { modules: [deepClone(moduleA), deepClone(moduleC)] } const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() expect(manager.moduleActions['b']).toEqual({ action: 'removed', pending: true }) // Reject the removal - should restore 'b' at original position manager.rejectModule('b', flowStore) flushSync() // Verify 'b' is restored at the middle position const currentModules = flowStore.val.value.modules expectModuleOrder(currentModules, ['a', 'b', 'c']) }) cleanup() }) it('accept added module inside loop - inserts at correct nested position', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const innerA = createRawScriptModule('inner_a', 'inner a') const innerB = createRawScriptModule('inner_b', 'inner b') const innerNew = createRawScriptModule('inner_new', 'new at beginning of loop') // beforeFlow: loop with [inner_a, inner_b] // afterFlow: loop with [inner_new, inner_a, inner_b] const beforeLoop = createForloopModule('loop1', [deepClone(innerA), deepClone(innerB)]) const afterLoop = createForloopModule('loop1', [ deepClone(innerNew), deepClone(innerA), deepClone(innerB) ]) const beforeFlow = createExtendedOpenFlow({ modules: [beforeLoop] }) const afterFlow: FlowValue = { modules: [afterLoop] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() expect(manager.moduleActions['inner_new']).toEqual({ action: 'added', pending: true }) // Accept the added nested module manager.acceptModule('inner_new') flushSync() // Verify 'inner_new' is inserted at beginning inside the loop const beforeModules = manager.beforeFlow?.value.modules ?? [] expect(beforeModules).toHaveLength(1) const loopModule = beforeModules[0] const loopModules = (loopModule.value as ForloopFlow).modules expectModuleOrder(loopModules, ['inner_new', 'inner_a', 'inner_b']) }) cleanup() }) it('reject removed module inside loop - restores at correct nested position', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const innerA = createRawScriptModule('inner_a', 'inner a') const innerB = createRawScriptModule('inner_b', 'inner b') const innerC = createRawScriptModule('inner_c', 'inner c') // beforeFlow: loop with [inner_a, inner_b, inner_c] // afterFlow: loop with [inner_a, inner_c] - inner_b removed const beforeLoop = createForloopModule('loop1', [ deepClone(innerA), deepClone(innerB), deepClone(innerC) ]) const afterLoop = createForloopModule('loop1', [deepClone(innerA), deepClone(innerC)]) const beforeFlow = createExtendedOpenFlow({ modules: [beforeLoop] }) const currentFlowValue: FlowValue = { modules: [afterLoop] } const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() expect(manager.moduleActions['inner_b']).toEqual({ action: 'removed', pending: true }) // Reject the removal - should restore 'inner_b' at original nested position manager.rejectModule('inner_b', flowStore) flushSync() // Verify 'inner_b' is restored at the middle position inside the loop const currentModules = flowStore.val.value.modules expect(currentModules).toHaveLength(1) const loopModule = currentModules[0] const loopModules = (loopModule.value as ForloopFlow).modules expectModuleOrder(loopModules, ['inner_a', 'inner_b', 'inner_c']) }) cleanup() }) it('accept multiple added modules at different positions', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'content-a') const moduleB = createRawScriptModule('b', 'content-b') const moduleNew1 = createRawScriptModule('new1', 'new at start') const moduleNew2 = createRawScriptModule('new2', 'new in middle') const moduleNew3 = createRawScriptModule('new3', 'new at end') // beforeFlow: [a, b] // afterFlow: [new1, a, new2, b, new3] const beforeFlow = createExtendedOpenFlow({ modules: [deepClone(moduleA), deepClone(moduleB)] }) const afterFlow: FlowValue = { modules: [ deepClone(moduleNew1), deepClone(moduleA), deepClone(moduleNew2), deepClone(moduleB), deepClone(moduleNew3) ] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() // Accept all added modules one by one manager.acceptModule('new1') flushSync() manager.acceptModule('new2') flushSync() manager.acceptModule('new3') flushSync() // Verify all modules are at correct positions const beforeModules = manager.beforeFlow?.value.modules ?? [] expectModuleOrder(beforeModules, ['new1', 'a', 'new2', 'b', 'new3']) }) cleanup() }) it('reject multiple removed modules - restores all at correct positions', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'first') const moduleB = createRawScriptModule('b', 'second') const moduleC = createRawScriptModule('c', 'third') const moduleD = createRawScriptModule('d', 'fourth') const moduleE = createRawScriptModule('e', 'fifth') // beforeFlow: [a, b, c, d, e] // afterFlow: [b, d] - a, c, e removed const beforeFlow = createExtendedOpenFlow({ modules: [ deepClone(moduleA), deepClone(moduleB), deepClone(moduleC), deepClone(moduleD), deepClone(moduleE) ] }) const currentFlowValue: FlowValue = { modules: [deepClone(moduleB), deepClone(moduleD)] } const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() // Reject removals one by one manager.rejectModule('a', flowStore) flushSync() manager.rejectModule('c', flowStore) flushSync() manager.rejectModule('e', flowStore) flushSync() // Verify all modules are restored at correct positions const currentModules = flowStore.val.value.modules expectModuleOrder(currentModules, ['a', 'b', 'c', 'd', 'e']) }) cleanup() }) }) describe('branch operations', () => { describe('branchone', () => { it('accept added module in default branch - inserts at correct position', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const defaultA = createRawScriptModule('default_a', 'default step a') const defaultB = createRawScriptModule('default_b', 'default step b') // beforeFlow: branch with [default_a] in default // afterFlow: branch with [default_a, default_b] in default const beforeBranch = createBranchOneModule('branch1', [deepClone(defaultA)], []) const afterBranch = createBranchOneModule( 'branch1', [deepClone(defaultA), deepClone(defaultB)], [] ) const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranch] }) const afterFlow: FlowValue = { modules: [afterBranch] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() expect(manager.moduleActions['default_b']).toEqual({ action: 'added', pending: true }) // Accept the added module manager.acceptModule('default_b') flushSync() // Verify 'default_b' is inserted at correct position in default branch const beforeModules = manager.beforeFlow?.value.modules ?? [] expect(beforeModules).toHaveLength(1) const branchModule = beforeModules[0] const branchDefault = (branchModule.value as BranchOne).default expectModuleOrder(branchDefault, ['default_a', 'default_b']) }) cleanup() }) it('reject removed module from default branch - restores at correct position', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const defaultA = createRawScriptModule('default_a', 'default step a') const defaultB = createRawScriptModule('default_b', 'default step b') const defaultC = createRawScriptModule('default_c', 'default step c') // beforeFlow: branch with [default_a, default_b, default_c] in default // afterFlow: branch with [default_a, default_c] in default - default_b removed const beforeBranch = createBranchOneModule( 'branch1', [deepClone(defaultA), deepClone(defaultB), deepClone(defaultC)], [] ) const afterBranch = createBranchOneModule( 'branch1', [deepClone(defaultA), deepClone(defaultC)], [] ) const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranch] }) const currentFlowValue: FlowValue = { modules: [afterBranch] } const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() expect(manager.moduleActions['default_b']).toEqual({ action: 'removed', pending: true }) // Reject the removal - should restore 'default_b' at original position manager.rejectModule('default_b', flowStore) flushSync() // Verify 'default_b' is restored at the middle position in default branch const currentModules = flowStore.val.value.modules expect(currentModules).toHaveLength(1) const branchModule = currentModules[0] const branchDefault = (branchModule.value as BranchOne).default expectModuleOrder(branchDefault, ['default_a', 'default_b', 'default_c']) }) cleanup() }) it('accept added module in conditional branch - inserts at correct position', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const branchModuleA = createRawScriptModule('branch_a', 'branch step a') const branchModuleB = createRawScriptModule('branch_b', 'branch step b') // beforeFlow: branch with [branch_a] in conditional branch // afterFlow: branch with [branch_a, branch_b] in conditional branch const beforeBranch = createBranchOneModule( 'branch1', [], [{ expr: 'x > 0', modules: [deepClone(branchModuleA)] }] ) const afterBranch = createBranchOneModule( 'branch1', [], [{ expr: 'x > 0', modules: [deepClone(branchModuleA), deepClone(branchModuleB)] }] ) const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranch] }) const afterFlow: FlowValue = { modules: [afterBranch] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() expect(manager.moduleActions['branch_b']).toEqual({ action: 'added', pending: true }) // Accept the added module manager.acceptModule('branch_b') flushSync() // Verify 'branch_b' is inserted in the conditional branch const beforeModules = manager.beforeFlow?.value.modules ?? [] expect(beforeModules).toHaveLength(1) const branchModule = beforeModules[0] const branches = (branchModule.value as BranchOne).branches expect(branches).toHaveLength(1) expectModuleOrder(branches[0].modules, ['branch_a', 'branch_b']) }) cleanup() }) it('reject removed module from conditional branch - restores at correct position', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const branchModuleA = createRawScriptModule('branch_a', 'branch step a') const branchModuleB = createRawScriptModule('branch_b', 'branch step b') // beforeFlow: branch with [branch_a, branch_b] in conditional branch // afterFlow: branch with [branch_a] in conditional branch - branch_b removed const beforeBranch = createBranchOneModule( 'branch1', [], [{ expr: 'x > 0', modules: [deepClone(branchModuleA), deepClone(branchModuleB)] }] ) const afterBranch = createBranchOneModule( 'branch1', [], [{ expr: 'x > 0', modules: [deepClone(branchModuleA)] }] ) const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranch] }) const currentFlowValue: FlowValue = { modules: [afterBranch] } const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() expect(manager.moduleActions['branch_b']).toEqual({ action: 'removed', pending: true }) // Reject the removal manager.rejectModule('branch_b', flowStore) flushSync() // Verify 'branch_b' is restored in the conditional branch const currentModules = flowStore.val.value.modules expect(currentModules).toHaveLength(1) const branchModule = currentModules[0] const branches = (branchModule.value as BranchOne).branches expect(branches).toHaveLength(1) expectModuleOrder(branches[0].modules, ['branch_a', 'branch_b']) }) cleanup() }) it('accept modified module in branch - updates content correctly', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const branchModuleBefore = createRawScriptModule('branch_a', 'original content') const branchModuleAfter = createRawScriptModule('branch_a', 'modified content') // beforeFlow: branch with original module // afterFlow: branch with modified module const beforeBranch = createBranchOneModule('branch1', [deepClone(branchModuleBefore)], []) const afterBranch = createBranchOneModule('branch1', [deepClone(branchModuleAfter)], []) const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranch] }) const afterFlow: FlowValue = { modules: [afterBranch] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() expect(manager.moduleActions['branch_a']).toEqual({ action: 'modified', pending: true }) // Accept the modification manager.acceptModule('branch_a') flushSync() // Verify content is updated const beforeModules = manager.beforeFlow?.value.modules ?? [] const branchModule = beforeModules[0] const branchDefault = (branchModule.value as BranchOne).default const moduleA = branchDefault.find((m) => m.id === 'branch_a') expect((moduleA?.value as RawScript).content).toBe('modified content') }) cleanup() }) it('reject entire conditional branch removal - restores the branch with its modules', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const defaultModule = createRawScriptModule('default_mod', 'default') const branch1ModuleA = createRawScriptModule('b1_a', 'branch 1 a') const branch2ModuleA = createRawScriptModule('b2_a', 'branch 2 a') const branch2ModuleB = createRawScriptModule('b2_b', 'branch 2 b') // beforeFlow: branch with 2 conditional branches // afterFlow: branch with only 1 conditional branch - second branch removed const beforeBranch = createBranchOneModule( 'branch1', [deepClone(defaultModule)], [ { expr: 'x > 0', modules: [deepClone(branch1ModuleA)] }, { expr: 'x < 0', modules: [deepClone(branch2ModuleA), deepClone(branch2ModuleB)] } ] ) const afterBranch = createBranchOneModule( 'branch1', [deepClone(defaultModule)], [{ expr: 'x > 0', modules: [deepClone(branch1ModuleA)] }] ) const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranch] }) const currentFlowValue: FlowValue = { modules: [afterBranch] } const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() // Both modules from the removed branch should be marked as removed expect(manager.moduleActions['b2_a']).toEqual({ action: 'removed', pending: true }) expect(manager.moduleActions['b2_b']).toEqual({ action: 'removed', pending: true }) // Reject both removals to restore the entire branch manager.rejectModule('b2_a', flowStore) flushSync() manager.rejectModule('b2_b', flowStore) flushSync() // Verify the second branch is restored with both modules const currentModules = flowStore.val.value.modules expect(currentModules).toHaveLength(1) const branchModule = currentModules[0] const branches = (branchModule.value as BranchOne).branches // Should have 2 branches again expect(branches).toHaveLength(2) expectModuleOrder(branches[0].modules, ['b1_a']) expectModuleOrder(branches[1].modules, ['b2_a', 'b2_b']) }) cleanup() }) }) describe('branchall', () => { it('accept added module in parallel branch - inserts at correct position', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const parallel1A = createRawScriptModule('p1_a', 'parallel 1 a') const parallel2A = createRawScriptModule('p2_a', 'parallel 2 a') const parallel2B = createRawScriptModule('p2_b', 'parallel 2 b - new') // beforeFlow: branchall with [p1_a] and [p2_a] // afterFlow: branchall with [p1_a] and [p2_a, p2_b] const beforeBranchAll = createBranchAllModule('branchall1', [ { modules: [deepClone(parallel1A)] }, { modules: [deepClone(parallel2A)] } ]) const afterBranchAll = createBranchAllModule('branchall1', [ { modules: [deepClone(parallel1A)] }, { modules: [deepClone(parallel2A), deepClone(parallel2B)] } ]) const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranchAll] }) const afterFlow: FlowValue = { modules: [afterBranchAll] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() expect(manager.moduleActions['p2_b']).toEqual({ action: 'added', pending: true }) // Accept the added module manager.acceptModule('p2_b') flushSync() // Verify 'p2_b' is inserted in the second parallel branch const beforeModules = manager.beforeFlow?.value.modules ?? [] expect(beforeModules).toHaveLength(1) const branchAllModule = beforeModules[0] const branches = (branchAllModule.value as BranchAll).branches expect(branches).toHaveLength(2) expectModuleOrder(branches[0].modules, ['p1_a']) expectModuleOrder(branches[1].modules, ['p2_a', 'p2_b']) }) cleanup() }) it('reject removed module from parallel branch - restores at correct position', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const parallel1A = createRawScriptModule('p1_a', 'parallel 1 a') const parallel1B = createRawScriptModule('p1_b', 'parallel 1 b') const parallel2A = createRawScriptModule('p2_a', 'parallel 2 a') // beforeFlow: branchall with [p1_a, p1_b] and [p2_a] // afterFlow: branchall with [p1_a] and [p2_a] - p1_b removed const beforeBranchAll = createBranchAllModule('branchall1', [ { modules: [deepClone(parallel1A), deepClone(parallel1B)] }, { modules: [deepClone(parallel2A)] } ]) const afterBranchAll = createBranchAllModule('branchall1', [ { modules: [deepClone(parallel1A)] }, { modules: [deepClone(parallel2A)] } ]) const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranchAll] }) const currentFlowValue: FlowValue = { modules: [afterBranchAll] } const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() expect(manager.moduleActions['p1_b']).toEqual({ action: 'removed', pending: true }) // Reject the removal manager.rejectModule('p1_b', flowStore) flushSync() // Verify 'p1_b' is restored in the first parallel branch const currentModules = flowStore.val.value.modules expect(currentModules).toHaveLength(1) const branchAllModule = currentModules[0] const branches = (branchAllModule.value as BranchAll).branches expect(branches).toHaveLength(2) expectModuleOrder(branches[0].modules, ['p1_a', 'p1_b']) expectModuleOrder(branches[1].modules, ['p2_a']) }) cleanup() }) it('reject entire parallel branch removal - restores the branch with its modules', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const parallel1A = createRawScriptModule('p1_a', 'parallel 1 a') const parallel1B = createRawScriptModule('p1_b', 'parallel 1 b') const parallel2A = createRawScriptModule('p2_a', 'parallel 2 a') // beforeFlow: branchall with 2 parallel branches // afterFlow: branchall with only 1 parallel branch - second branch removed const beforeBranchAll = createBranchAllModule('branchall1', [ { modules: [deepClone(parallel1A), deepClone(parallel1B)] }, { modules: [deepClone(parallel2A)] } ]) const afterBranchAll = createBranchAllModule('branchall1', [ { modules: [deepClone(parallel1A), deepClone(parallel1B)] } ]) const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranchAll] }) const currentFlowValue: FlowValue = { modules: [afterBranchAll] } const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() // Module from the removed branch should be marked as removed expect(manager.moduleActions['p2_a']).toEqual({ action: 'removed', pending: true }) // Reject the removal to restore the entire branch manager.rejectModule('p2_a', flowStore) flushSync() // Verify the second parallel branch is restored const currentModules = flowStore.val.value.modules expect(currentModules).toHaveLength(1) const branchAllModule = currentModules[0] const branches = (branchAllModule.value as BranchAll).branches // Should have 2 branches again expect(branches).toHaveLength(2) expectModuleOrder(branches[0].modules, ['p1_a', 'p1_b']) expectModuleOrder(branches[1].modules, ['p2_a']) }) cleanup() }) }) }) describe('module movement', () => { it('accept module moved from root to loop - accepts the addition in loop', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'content-a') const moduleB = createRawScriptModule('b', 'content-b') const emptyLoop = createForloopModule('loop1', []) const loopWithB = createForloopModule('loop1', [deepClone(moduleB)]) // beforeFlow: [a, b, loop(empty)] // afterFlow: [a, loop(b)] const beforeFlow = createExtendedOpenFlow({ modules: [deepClone(moduleA), deepClone(moduleB), deepClone(emptyLoop)] }) const afterFlow: FlowValue = { modules: [deepClone(moduleA), deepClone(loopWithB)] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() // 'b' should be detected as moved (removed from root, added in loop) expect(manager.moduleActions['b']).toEqual({ action: 'added', pending: true }) expect(manager.moduleActions['old__b']).toEqual({ action: 'removed', pending: true }) // Accept the added 'b' in loop manager.acceptModule('b') flushSync() // After accepting, beforeFlow should have 'b' inside the loop const beforeModules = manager.beforeFlow?.value.modules ?? [] const loopModule = beforeModules.find((m) => m.id === 'loop1') const loopModules = (loopModule?.value as ForloopFlow).modules expect(loopModules.some((m) => m.id === 'b')).toBe(true) }) cleanup() }) it('accept module moved from root to loop - accepts the removal at root', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'content-a') const moduleB = createRawScriptModule('b', 'content-b') const emptyLoop = createForloopModule('loop1', []) const loopWithB = createForloopModule('loop1', [deepClone(moduleB)]) // beforeFlow: [a, b, loop(empty)] // afterFlow: [a, loop(b)] const beforeFlow = createExtendedOpenFlow({ modules: [deepClone(moduleA), deepClone(moduleB), deepClone(emptyLoop)] }) const afterFlow: FlowValue = { modules: [deepClone(moduleA), deepClone(loopWithB)] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() // Accept the removed 'old__b' at root manager.acceptModule('old__b') flushSync() // After accepting removal, 'b' should no longer be at root in beforeFlow const beforeModules = manager.beforeFlow?.value.modules ?? [] const rootIds = beforeModules.map((m) => m.id) expect(rootIds).not.toContain('b') }) cleanup() }) it('reject module moved from root to loop - rejects the addition', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'content-a') const moduleB = createRawScriptModule('b', 'content-b') const emptyLoop = createForloopModule('loop1', []) const loopWithB = createForloopModule('loop1', [deepClone(moduleB)]) // beforeFlow: [a, b, loop(empty)] const beforeFlow = createExtendedOpenFlow({ modules: [deepClone(moduleA), deepClone(moduleB), deepClone(emptyLoop)] }) // currentFlow (flowStore): [a, loop(b)] const currentFlowValue: FlowValue = { modules: [deepClone(moduleA), deepClone(loopWithB)] } const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() // Reject the added 'b' in loop - should remove it from flowStore manager.rejectModule('b', flowStore) flushSync() // 'b' should no longer be in the loop in flowStore const currentModules = flowStore.val.value.modules const loopModule = currentModules.find((m) => m.id === 'loop1') const loopModules = (loopModule?.value as ForloopFlow).modules expect(loopModules.some((m) => m.id === 'b')).toBe(false) }) cleanup() }) it('reject module moved from root to loop - rejects the removal (restores at root)', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'content-a') const moduleB = createRawScriptModule('b', 'content-b') const emptyLoop = createForloopModule('loop1', []) const loopWithB = createForloopModule('loop1', [deepClone(moduleB)]) // beforeFlow: [a, b, loop(empty)] const beforeFlow = createExtendedOpenFlow({ modules: [deepClone(moduleA), deepClone(moduleB), deepClone(emptyLoop)] }) // currentFlow (flowStore): [a, loop(b)] const currentFlowValue: FlowValue = { modules: [deepClone(moduleA), deepClone(loopWithB)] } const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() // Reject the removed 'old__b' - restores original at root, renames new to 'new__b' manager.rejectModule('old__b', flowStore) flushSync() // 'b' should be restored at root level in flowStore const currentModules = flowStore.val.value.modules const rootIds = currentModules.map((m) => m.id) expect(rootIds).toContain('b') // The module in the loop should now be 'new__b' const loopModule = currentModules.find((m) => m.id === 'loop1') const loopModules = (loopModule?.value as ForloopFlow).modules expect(loopModules.some((m) => m.id === `${NEW_MODULE_PREFIX}b`)).toBe(true) }) cleanup() }) it('accept module moved between branches', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'step a') // beforeFlow: branch with 'a' in first conditional branch const beforeBranch = createBranchOneModule( 'branch1', [], [ { expr: 'x > 0', modules: [deepClone(moduleA)] }, { expr: 'x < 0', modules: [] } ] ) // afterFlow: branch with 'a' in second conditional branch const afterBranch = createBranchOneModule( 'branch1', [], [ { expr: 'x > 0', modules: [] }, { expr: 'x < 0', modules: [deepClone(moduleA)] } ] ) const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranch] }) const afterFlow: FlowValue = { modules: [afterBranch] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(afterFlow) flushSync() // 'a' is moved - should be removed from branch 0 and added to branch 1 expect(manager.moduleActions['a']).toEqual({ action: 'added', pending: true }) expect(manager.moduleActions['old__a']).toEqual({ action: 'removed', pending: true }) // Accept both the addition and removal manager.acceptModule('a') flushSync() manager.acceptModule('old__a') flushSync() // beforeFlow should now have 'a' in branch 1, not in branch 0 const beforeModules = manager.beforeFlow?.value.modules ?? [] const branchModule = beforeModules.find((m) => m.id === 'branch1') const branches = (branchModule?.value as BranchOne).branches expect(branches[0].modules.some((m) => m.id === 'a')).toBe(false) expect(branches[1].modules.some((m) => m.id === 'a')).toBe(true) }) cleanup() }) it('reject module moved between branches - restores original position', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleA = createRawScriptModule('a', 'step a') // beforeFlow: branch with 'a' in first conditional branch const beforeBranch = createBranchOneModule( 'branch1', [], [ { expr: 'x > 0', modules: [deepClone(moduleA)] }, { expr: 'x < 0', modules: [] } ] ) // afterFlow: branch with 'a' in second conditional branch const afterBranch = createBranchOneModule( 'branch1', [], [ { expr: 'x > 0', modules: [] }, { expr: 'x < 0', modules: [deepClone(moduleA)] } ] ) const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranch] }) const currentFlowValue: FlowValue = { modules: [afterBranch] } const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() // Reject the removal from branch 0 - restores original 'a', renames new to 'new__a' manager.rejectModule('old__a', flowStore) flushSync() // Accept the addition in branch 1 manager.acceptModule('a') flushSync() // flowStore should now have 'a' in branch 0 (original), 'new__a' in branch 1 (renamed new) const currentModules = flowStore.val.value.modules const branchModule = currentModules.find((m) => m.id === 'branch1') const branches = (branchModule?.value as BranchOne).branches expect(branches[0].modules.some((m) => m.id === 'a')).toBe(true) expect(branches[1].modules.some((m) => m.id === `${NEW_MODULE_PREFIX}a`)).toBe(true) }) cleanup() }) it('prevents bug when rejecting moved module removal (branchall scenario)', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const moduleR = createRawScriptModule('r', 'module r content') // beforeFlow: 'r' in branch 2 const beforeBranchAll = createBranchAllModule('branchall1', [ { modules: [] }, // branch 0 { modules: [] }, // branch 1 { modules: [deepClone(moduleR)] } // branch 2 ]) // currentFlow: 'r' moved to branch 0 const afterBranchAll = createBranchAllModule('branchall1', [ { modules: [deepClone(moduleR)] }, // branch 0 { modules: [] }, // branch 1 { modules: [] } // branch 2 ]) const beforeFlow = createExtendedOpenFlow({ modules: [beforeBranchAll] }) const currentFlowValue: FlowValue = { modules: [afterBranchAll] } const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() // Verify initial state: 'r' is added at branch 0, 'old__r' is removed from branch 2 expect(manager.moduleActions['r']).toEqual({ action: 'added', pending: true }) expect(manager.moduleActions['old__r']).toEqual({ action: 'removed', pending: true }) // User rejects the removal - wants to keep module at original location (branch 2) manager.rejectModule('old__r', flowStore) flushSync() // After rejection, verify the current behavior: // 1. Original module should be restored at branch 2 with id 'r' // 2. New module at branch 0 should be renamed to 'new__r' const currentModules = flowStore.val.value.modules const branchAllModule = currentModules.find((m) => m.id === 'branchall1') const branches = (branchAllModule?.value as BranchAll).branches // Branch 0 should have 'new__r' (renamed new module) expect(branches[0].modules.some((m) => m.id === `${NEW_MODULE_PREFIX}r`)).toBe(true) // Branch 2 should have 'r' (restored original) expect(branches[2].modules.some((m) => m.id === 'r')).toBe(true) }) cleanup() }) it('accepts an added ai agent tool into beforeFlow', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const lookupTool = createFlowModuleTool(createRawScriptModule('lookup_user', 'lookup')) const sumTool = createFlowModuleTool(createRawScriptModule('sum', 'sum')) const beforeFlow = createExtendedOpenFlow({ modules: [createAiAgentModule('agent', [deepClone(lookupTool)])] }) const currentFlowValue: FlowValue = { modules: [createAiAgentModule('agent', [deepClone(lookupTool), deepClone(sumTool)])] } manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(currentFlowValue) flushSync() expect(manager.moduleActions['sum']).toEqual({ action: 'added', pending: true }) manager.acceptModule('sum') flushSync() const tools = (((manager.beforeFlow?.value.modules ?? [])[0]?.value as any).tools ?? []) as any[] expect(tools.map((tool) => tool.id)).toEqual(['lookup_user', 'sum']) }) cleanup() }) it('rejects a removed ai agent tool back into the live flow', () => { const cleanup = $effect.root(() => { const manager = createFlowDiffManager({ testMode: true }) const lookupTool = createFlowModuleTool(createRawScriptModule('lookup_user', 'lookup')) const sumTool = createFlowModuleTool(createRawScriptModule('sum', 'sum')) const beforeFlow = createExtendedOpenFlow({ modules: [createAiAgentModule('agent', [deepClone(lookupTool), deepClone(sumTool)])] }) const currentFlowValue: FlowValue = { modules: [createAiAgentModule('agent', [deepClone(lookupTool)])] } const flowStore = createFlowStore(createExtendedOpenFlow(currentFlowValue)) manager.setEditMode(true) manager.setBeforeFlow(beforeFlow) manager.setCurrentFlow(flowStore.val.value) flushSync() expect(manager.moduleActions['sum']).toEqual({ action: 'removed', pending: true }) manager.rejectModule('sum', flowStore) flushSync() const tools = (((flowStore.val.value.modules ?? [])[0]?.value as any).tools ?? []) as any[] expect(tools.map((tool) => tool.id)).toEqual(['lookup_user', 'sum']) }) cleanup() }) }) })