blob: c5d8df8e70a94a25d55c5000e03f1f82678544c9 [file]
// 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});
});
});
});