|
| 1 | +/** |
| 2 | + * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates |
| 3 | + * SPDX-License-Identifier: MIT |
| 4 | + */ |
| 5 | + |
| 6 | +import { beforeEach, describe, expect, it, vi } from 'vitest'; |
| 7 | +import type { WorkflowJSON, WorkflowNodeJSON } from '@flowgram.ai/free-layout-editor'; |
| 8 | + |
| 9 | +import { generateUniqueWorkflow } from './unique-workflow'; |
| 10 | + |
| 11 | +const { generateId } = vi.hoisted(() => ({ generateId: vi.fn() })); |
| 12 | + |
| 13 | +vi.mock('nanoid', () => ({ customAlphabet: () => generateId })); |
| 14 | + |
| 15 | +const node = (id: string): WorkflowNodeJSON => ({ |
| 16 | + id, |
| 17 | + type: 'test', |
| 18 | + meta: { position: { x: 0, y: 0 } }, |
| 19 | + data: { title: id }, |
| 20 | +}); |
| 21 | + |
| 22 | +beforeEach(() => { |
| 23 | + generateId.mockReset(); |
| 24 | + generateId.mockImplementation(() => { |
| 25 | + throw new Error('Unexpected ID generation'); |
| 26 | + }); |
| 27 | +}); |
| 28 | + |
| 29 | +describe('generateUniqueWorkflow', () => { |
| 30 | + it('retries IDs already assigned to another copied node and preserves edge endpoints', () => { |
| 31 | + generateId |
| 32 | + .mockReturnValueOnce('111111') |
| 33 | + .mockReturnValueOnce('111111') |
| 34 | + .mockReturnValueOnce('222222'); |
| 35 | + const json: WorkflowJSON = { |
| 36 | + nodes: [node('a'), node('b')], |
| 37 | + edges: [{ sourceNodeID: 'a', targetNodeID: 'b' }], |
| 38 | + }; |
| 39 | + |
| 40 | + generateUniqueWorkflow({ json, isUniqueId: (id) => !['a', 'b'].includes(id) }); |
| 41 | + |
| 42 | + expect(json.nodes.map((item) => item.id)).toEqual(['111111', '222222']); |
| 43 | + expect(json.nodes.map((item) => item.data?.title)).toEqual(['a', 'b']); |
| 44 | + expect(json.edges).toEqual([{ sourceNodeID: '111111', targetNodeID: '222222' }]); |
| 45 | + expect(generateId).toHaveBeenCalledTimes(3); |
| 46 | + }); |
| 47 | + |
| 48 | + it.each([false, true])( |
| 49 | + 'reserves unchanged clipboard IDs before allocation (retained first: %s)', |
| 50 | + (retainedFirst) => { |
| 51 | + generateId.mockReturnValueOnce('111111').mockReturnValueOnce('222222'); |
| 52 | + const nodes = retainedFirst ? [node('111111'), node('a')] : [node('a'), node('111111')]; |
| 53 | + const json: WorkflowJSON = { nodes, edges: [] }; |
| 54 | + |
| 55 | + generateUniqueWorkflow({ json, isUniqueId: (id) => id !== 'a' }); |
| 56 | + |
| 57 | + expect(json.nodes.map((item) => item.id)).toEqual( |
| 58 | + retainedFirst ? ['111111', '222222'] : ['222222', '111111'] |
| 59 | + ); |
| 60 | + expect(generateId).toHaveBeenCalledTimes(2); |
| 61 | + } |
| 62 | + ); |
| 63 | + |
| 64 | + it('keeps IDs and the input object unchanged when they are already unique', () => { |
| 65 | + const json: WorkflowJSON = { |
| 66 | + nodes: [node('a'), node('b')], |
| 67 | + edges: [{ sourceNodeID: 'a', targetNodeID: 'b' }], |
| 68 | + }; |
| 69 | + const original = structuredClone(json); |
| 70 | + |
| 71 | + expect(generateUniqueWorkflow({ json, isUniqueId: () => true })).toBe(json); |
| 72 | + expect(json).toEqual(original); |
| 73 | + expect(generateId).not.toHaveBeenCalled(); |
| 74 | + }); |
| 75 | + |
| 76 | + it('continues to reject IDs occupied by the destination canvas', () => { |
| 77 | + generateId.mockReturnValueOnce('occupied').mockReturnValueOnce('111111'); |
| 78 | + const json: WorkflowJSON = { nodes: [node('a')], edges: [] }; |
| 79 | + |
| 80 | + generateUniqueWorkflow({ json, isUniqueId: (id) => !['a', 'occupied'].includes(id) }); |
| 81 | + |
| 82 | + expect(json.nodes[0].id).toBe('111111'); |
| 83 | + expect(generateId).toHaveBeenCalledTimes(2); |
| 84 | + }); |
| 85 | + |
| 86 | + it('uses distinct IDs across nested blocks while preserving their edges and variable references', () => { |
| 87 | + generateId |
| 88 | + .mockReturnValueOnce('111111') |
| 89 | + .mockReturnValueOnce('111111') |
| 90 | + .mockReturnValueOnce('222222') |
| 91 | + .mockReturnValueOnce('222222') |
| 92 | + .mockReturnValueOnce('333333'); |
| 93 | + const parent = node('parent'); |
| 94 | + parent.blocks = [node('a'), node('b')]; |
| 95 | + parent.edges = [{ sourceNodeID: 'a', targetNodeID: 'b' }]; |
| 96 | + parent.data = { |
| 97 | + internal: { source: 'block-output', blockID: 'a', name: 'value' }, |
| 98 | + external: { source: 'block-output', blockID: 'outside', name: 'value' }, |
| 99 | + }; |
| 100 | + const json: WorkflowJSON = { nodes: [parent], edges: [] }; |
| 101 | + |
| 102 | + generateUniqueWorkflow({ json, isUniqueId: (id) => !['parent', 'a', 'b'].includes(id) }); |
| 103 | + |
| 104 | + expect(parent.id).toBe('111111'); |
| 105 | + expect(parent.blocks.map((item) => item.id)).toEqual(['222222', '333333']); |
| 106 | + expect(parent.edges).toEqual([{ sourceNodeID: '222222', targetNodeID: '333333' }]); |
| 107 | + expect(parent.data.internal.blockID).toBe('222222'); |
| 108 | + expect(parent.data.external.blockID).toBe('outside'); |
| 109 | + expect(generateId).toHaveBeenCalledTimes(5); |
| 110 | + }); |
| 111 | +}); |
0 commit comments