| // Copyright (C) 2026 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| import {NodeType} from './query_node'; |
| import { |
| copySelectedNodes, |
| pasteClipboardNodes, |
| type ClipboardEntry, |
| type ClipboardConnection, |
| } from './clipboard_operations'; |
| import {createMockNode, connectNodes} from './query_builder/testing/test_utils'; |
| import {TableSourceNode} from './query_builder/nodes/sources/table_source'; |
| import type {Trace} from '../../public/trace'; |
| import type {SqlModules} from '../dev.perfetto.SqlModules/sql_modules'; |
| |
| describe('clipboard_operations', () => { |
| describe('copySelectedNodes', () => { |
| it('should return undefined when no nodes are selected', () => { |
| const node = createMockNode({nodeId: 'n1'}); |
| const result = copySelectedNodes({ |
| rootNodes: [node], |
| selectedNodes: new Set(), |
| nodeLayouts: new Map(), |
| }); |
| expect(result).toBeUndefined(); |
| }); |
| |
| it('should return undefined when selected IDs do not match any nodes', () => { |
| const node = createMockNode({nodeId: 'n1'}); |
| const result = copySelectedNodes({ |
| rootNodes: [node], |
| selectedNodes: new Set(['nonexistent']), |
| nodeLayouts: new Map(), |
| }); |
| expect(result).toBeUndefined(); |
| }); |
| |
| it('should copy a single selected node with relative position (0,0)', () => { |
| const node = createMockNode({nodeId: 'n1'}); |
| const result = copySelectedNodes({ |
| rootNodes: [node], |
| selectedNodes: new Set(['n1']), |
| nodeLayouts: new Map([['n1', {x: 100, y: 200}]]), |
| }); |
| |
| expect(result).toBeDefined(); |
| expect(result?.clipboardNodes).toHaveLength(1); |
| expect(result?.clipboardNodes[0].relativeX).toBe(0); |
| expect(result?.clipboardNodes[0].relativeY).toBe(0); |
| expect(result?.clipboardNodes[0].isDocked).toBe(false); |
| }); |
| |
| it('should compute relative positions for multiple nodes', () => { |
| const node1 = createMockNode({nodeId: 'n1'}); |
| const node2 = createMockNode({nodeId: 'n2'}); |
| const result = copySelectedNodes({ |
| rootNodes: [node1, node2], |
| selectedNodes: new Set(['n1', 'n2']), |
| nodeLayouts: new Map([ |
| ['n1', {x: 50, y: 100}], |
| ['n2', {x: 250, y: 300}], |
| ]), |
| }); |
| |
| expect(result).toBeDefined(); |
| expect(result?.clipboardNodes).toHaveLength(2); |
| // First node is at origin (min x/y) |
| expect(result?.clipboardNodes[0].relativeX).toBe(0); |
| expect(result?.clipboardNodes[0].relativeY).toBe(0); |
| // Second node is offset |
| expect(result?.clipboardNodes[1].relativeX).toBe(200); |
| expect(result?.clipboardNodes[1].relativeY).toBe(200); |
| }); |
| |
| it('should mark nodes without layout as docked', () => { |
| const node1 = createMockNode({nodeId: 'n1'}); |
| const node2 = createMockNode({nodeId: 'n2'}); |
| const result = copySelectedNodes({ |
| rootNodes: [node1, node2], |
| selectedNodes: new Set(['n1', 'n2']), |
| nodeLayouts: new Map([['n1', {x: 100, y: 100}]]), |
| // n2 has no layout → docked |
| }); |
| |
| expect(result).toBeDefined(); |
| expect(result?.clipboardNodes[0].isDocked).toBe(false); |
| expect(result?.clipboardNodes[1].isDocked).toBe(true); |
| }); |
| |
| it('should clone nodes for the clipboard', () => { |
| const node = createMockNode({nodeId: 'n1'}); |
| const result = copySelectedNodes({ |
| rootNodes: [node], |
| selectedNodes: new Set(['n1']), |
| nodeLayouts: new Map([['n1', {x: 0, y: 0}]]), |
| }); |
| |
| expect(result).toBeDefined(); |
| // Cloned node should not be the same object reference |
| expect(result?.clipboardNodes[0].node).not.toBe(node); |
| }); |
| |
| it('should capture primaryInput connections between selected nodes', () => { |
| const parent = createMockNode({nodeId: 'p', type: NodeType.kTable}); |
| const child = createMockNode({nodeId: 'c', type: NodeType.kFilter}); |
| connectNodes(parent, child); |
| |
| const result = copySelectedNodes({ |
| rootNodes: [parent], |
| selectedNodes: new Set(['p', 'c']), |
| nodeLayouts: new Map([ |
| ['p', {x: 0, y: 0}], |
| ['c', {x: 0, y: 100}], |
| ]), |
| }); |
| |
| expect(result).toBeDefined(); |
| expect(result?.clipboardConnections).toHaveLength(1); |
| expect(result?.clipboardConnections[0].fromIndex).toBe(0); |
| expect(result?.clipboardConnections[0].toIndex).toBe(1); |
| expect(result?.clipboardConnections[0].portIndex).toBeUndefined(); |
| }); |
| |
| it('should not capture connections to nodes outside the selection', () => { |
| const parent = createMockNode({nodeId: 'p', type: NodeType.kTable}); |
| const child = createMockNode({nodeId: 'c', type: NodeType.kFilter}); |
| connectNodes(parent, child); |
| |
| // Only select the child, not the parent |
| const result = copySelectedNodes({ |
| rootNodes: [parent], |
| selectedNodes: new Set(['c']), |
| nodeLayouts: new Map([['c', {x: 0, y: 100}]]), |
| }); |
| |
| expect(result).toBeDefined(); |
| expect(result?.clipboardConnections).toHaveLength(0); |
| }); |
| |
| it('should capture secondaryInput connections between selected nodes', () => { |
| const source = createMockNode({nodeId: 's', type: NodeType.kTable}); |
| const multi = createMockNode({nodeId: 'm', type: NodeType.kJoin}); |
| multi.secondaryInputs = { |
| connections: new Map([[0, source]]), |
| min: 2, |
| max: 2, |
| portNames: ['Left', 'Right'], |
| }; |
| source.nextNodes = [multi]; |
| |
| const result = copySelectedNodes({ |
| rootNodes: [source, multi], |
| selectedNodes: new Set(['s', 'm']), |
| nodeLayouts: new Map([ |
| ['s', {x: 0, y: 0}], |
| ['m', {x: 100, y: 0}], |
| ]), |
| }); |
| |
| expect(result).toBeDefined(); |
| expect(result?.clipboardConnections).toHaveLength(1); |
| expect(result?.clipboardConnections[0].portIndex).toBe(0); |
| }); |
| }); |
| |
| describe('pasteClipboardNodes', () => { |
| it('should return undefined when clipboard is undefined', () => { |
| const result = pasteClipboardNodes( |
| {rootNodes: [], nodeLayouts: new Map()}, |
| undefined, |
| ); |
| expect(result).toBeUndefined(); |
| }); |
| |
| it('should return undefined when clipboard has zero entries', () => { |
| const result = pasteClipboardNodes( |
| {rootNodes: [], nodeLayouts: new Map()}, |
| {clipboardNodes: [], clipboardConnections: []}, |
| ); |
| expect(result).toBeUndefined(); |
| }); |
| |
| it('should append cloned nodes to rootNodes', () => { |
| const existing = createMockNode({nodeId: 'existing'}); |
| const clipNode = createMockNode({nodeId: 'clip'}); |
| const clipboard = { |
| clipboardNodes: [ |
| {node: clipNode, relativeX: 0, relativeY: 0, isDocked: false}, |
| ] as ClipboardEntry[], |
| clipboardConnections: [] as ClipboardConnection[], |
| }; |
| |
| const result = pasteClipboardNodes( |
| {rootNodes: [existing], nodeLayouts: new Map()}, |
| clipboard, |
| ); |
| |
| expect(result).toBeDefined(); |
| // Original node + pasted node |
| expect(result?.rootNodes).toHaveLength(2); |
| expect(result?.rootNodes[0]).toBe(existing); |
| // Pasted node is cloned (not the same reference) |
| expect(result?.rootNodes[1]).not.toBe(clipNode); |
| }); |
| |
| it('should select only the newly pasted nodes', () => { |
| const clipNode = createMockNode({nodeId: 'clip'}); |
| const clipboard = { |
| clipboardNodes: [ |
| {node: clipNode, relativeX: 0, relativeY: 0, isDocked: false}, |
| ] as ClipboardEntry[], |
| clipboardConnections: [] as ClipboardConnection[], |
| }; |
| |
| const result = pasteClipboardNodes( |
| {rootNodes: [], nodeLayouts: new Map()}, |
| clipboard, |
| ); |
| |
| expect(result).toBeDefined(); |
| expect(result?.selectedNodes.size).toBe(1); |
| // The selected ID should be the NEW (cloned) node's ID, not the clipboard node |
| const pastedNodeId = result?.rootNodes[0].nodeId; |
| expect(result?.selectedNodes.has(pastedNodeId ?? '')).toBe(true); |
| }); |
| |
| it('should add layout positions for undocked nodes with offset', () => { |
| const clipNode = createMockNode({nodeId: 'clip'}); |
| const clipboard = { |
| clipboardNodes: [ |
| {node: clipNode, relativeX: 100, relativeY: 200, isDocked: false}, |
| ] as ClipboardEntry[], |
| clipboardConnections: [] as ClipboardConnection[], |
| }; |
| |
| const result = pasteClipboardNodes( |
| {rootNodes: [], nodeLayouts: new Map()}, |
| clipboard, |
| ); |
| |
| expect(result).toBeDefined(); |
| const pastedNodeId = result?.rootNodes[0].nodeId ?? ''; |
| const layout = result?.nodeLayouts.get(pastedNodeId); |
| expect(layout).toBeDefined(); |
| // relativeX + pasteOffsetX (50), relativeY + pasteOffsetY (50) |
| expect(layout?.x).toBe(150); |
| expect(layout?.y).toBe(250); |
| }); |
| |
| it('should not add layout for docked nodes', () => { |
| const clipNode = createMockNode({nodeId: 'clip'}); |
| const clipboard = { |
| clipboardNodes: [ |
| {node: clipNode, relativeX: 0, relativeY: 0, isDocked: true}, |
| ] as ClipboardEntry[], |
| clipboardConnections: [] as ClipboardConnection[], |
| }; |
| |
| const result = pasteClipboardNodes( |
| {rootNodes: [], nodeLayouts: new Map()}, |
| clipboard, |
| ); |
| |
| expect(result).toBeDefined(); |
| const pastedNodeId = result?.rootNodes[0].nodeId ?? ''; |
| expect(result?.nodeLayouts.has(pastedNodeId)).toBe(false); |
| }); |
| |
| it('should allow multiple pastes from the same clipboard', () => { |
| // Use real TableSourceNode since its clone() generates new IDs |
| const mockTrace = {traceInfo: {traceTitle: 'test'}} as Trace; |
| const mockSqlModules = { |
| listTables: () => [], |
| getTable: () => undefined, |
| } as unknown as SqlModules; |
| const realNode = new TableSourceNode( |
| {}, |
| {trace: mockTrace, sqlModules: mockSqlModules}, |
| ); |
| |
| const clipboard = { |
| clipboardNodes: [ |
| {node: realNode, relativeX: 0, relativeY: 0, isDocked: false}, |
| ] as ClipboardEntry[], |
| clipboardConnections: [] as ClipboardConnection[], |
| }; |
| |
| const result1 = pasteClipboardNodes( |
| {rootNodes: [], nodeLayouts: new Map()}, |
| clipboard, |
| ); |
| |
| const result2 = pasteClipboardNodes( |
| { |
| rootNodes: result1?.rootNodes ?? [], |
| nodeLayouts: result1?.nodeLayouts ?? new Map(), |
| }, |
| clipboard, |
| ); |
| |
| expect(result2).toBeDefined(); |
| expect(result2?.rootNodes).toHaveLength(2); |
| // The two pasted nodes should have different IDs |
| expect(result2?.rootNodes[0].nodeId).not.toBe( |
| result2?.rootNodes[1].nodeId, |
| ); |
| }); |
| |
| it('should restore connections between pasted nodes', () => { |
| const parent = createMockNode({nodeId: 'p', type: NodeType.kTable}); |
| const child = createMockNode({nodeId: 'c', type: NodeType.kFilter}); |
| const clipboard = { |
| clipboardNodes: [ |
| {node: parent, relativeX: 0, relativeY: 0, isDocked: false}, |
| {node: child, relativeX: 0, relativeY: 100, isDocked: false}, |
| ] as ClipboardEntry[], |
| clipboardConnections: [{fromIndex: 0, toIndex: 1}], |
| }; |
| |
| const result = pasteClipboardNodes( |
| {rootNodes: [], nodeLayouts: new Map()}, |
| clipboard, |
| ); |
| |
| expect(result).toBeDefined(); |
| expect(result?.rootNodes).toHaveLength(2); |
| const pastedParent = result?.rootNodes[0]; |
| const pastedChild = result?.rootNodes[1]; |
| // addConnection should have connected them |
| expect(pastedParent?.nextNodes).toContain(pastedChild); |
| }); |
| |
| it('should preserve existing nodeLayouts on paste', () => { |
| const existing = createMockNode({nodeId: 'existing'}); |
| const clipNode = createMockNode({nodeId: 'clip'}); |
| const existingLayouts = new Map([['existing', {x: 500, y: 600}]]); |
| const clipboard = { |
| clipboardNodes: [ |
| {node: clipNode, relativeX: 0, relativeY: 0, isDocked: false}, |
| ] as ClipboardEntry[], |
| clipboardConnections: [] as ClipboardConnection[], |
| }; |
| |
| const result = pasteClipboardNodes( |
| {rootNodes: [existing], nodeLayouts: existingLayouts}, |
| clipboard, |
| ); |
| |
| expect(result).toBeDefined(); |
| expect(result?.nodeLayouts.get('existing')).toEqual({x: 500, y: 600}); |
| }); |
| }); |
| }); |