// Copyright (C) 2024 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 {AggregationNode} from './query_builder/nodes/aggregation_node';
import {ModifyColumnsNode} from './query_builder/nodes/modify_columns_node';
import type {DataExplorerState} from './data_explorer';
import {IntervalIntersectNode} from './query_builder/nodes/interval_intersect_node';
import {SlicesSourceNode} from './query_builder/nodes/sources/slices_source';
import {SqlSourceNode} from './query_builder/nodes/sources/sql_source';
import {TableSourceNode} from './query_builder/nodes/sources/table_source';
import {
  serializeState,
  deserializeState,
  type SerializedNode,
} from './json_handler';
import type {Trace} from '../../public/trace';
import type {
  SqlModules,
  SqlTable,
} from '../../plugins/dev.perfetto.SqlModules/sql_modules';
import {AddColumnsNode} from './query_builder/nodes/add_columns_node';
import {LimitAndOffsetNode} from './query_builder/nodes/limit_and_offset_node';
import {SortNode} from './query_builder/nodes/sort_node';
import {FilterNode} from './query_builder/nodes/filter_node';
import {JoinNode} from './query_builder/nodes/join_node';
import {UnionNode} from './query_builder/nodes/union_node';
import {
  type PerfettoSqlType,
  PerfettoSqlTypes,
} from '../../trace_processor/perfetto_sql_type';
import {NodeType, type QueryNode} from './query_node';
import {
  addConnection,
  removeConnection,
  createGroupFromSelection,
  applyGroupRewiring,
  getAllNodes,
} from './query_builder/graph_utils';
import type {ColumnInfo} from './query_builder/column_info';
import {FilterDuringNode} from './query_builder/nodes/filter_during_node';
import {TimeRangeSourceNode} from './query_builder/nodes/sources/timerange_source';
import {Time} from '../../base/time';
import {registerCoreNodes} from './query_builder/core_nodes';
import {GroupNode} from './query_builder/nodes/group_node';
import {unwrapResult} from '../../base/result';

registerCoreNodes();

describe('JSON serialization/deserialization', () => {
  let trace: Trace;
  let sqlModules: SqlModules;

  beforeEach(() => {
    trace = {
      traceInfo: {
        traceTitle: 'test_trace',
      },
    } as Trace;

    const stringType: PerfettoSqlType = {kind: 'string'};
    const timestampType: PerfettoSqlType = {
      kind: 'timestamp',
    };

    const tables = new Map<string, SqlTable>();
    tables.set('slice', {
      name: 'slice',
      description: '',
      type: 'table',
      includeKey: '',
      importance: undefined,
      getTableColumns: () => [],
      columns: [
        {
          name: 'name',
          type: stringType,
        },
        {
          name: 'ts',
          type: timestampType,
        },
        {
          name: 'dur',
          type: timestampType,
        },
      ],
    });

    sqlModules = {
      getTable: (name: string) => tables.get(name),
    } as SqlModules;
  });

  test('serializes and deserializes a simple graph', () => {
    const sliceNode = new SlicesSourceNode({}, {});
    const initialState: DataExplorerState = {
      rootNodes: [sliceNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedNode = deserializedState.rootNodes[0];
    expect(deserializedNode).toBeInstanceOf(SlicesSourceNode);
  });

  test('handles multiple nodes and connections', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const modifyNode = new ModifyColumnsNode({selectedColumns: []}, {});
    addConnection(tableNode, modifyNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map([
        [tableNode.nodeId, {x: 10, y: 20}],
        [modifyNode.nodeId, {x: 100, y: 200}],
      ]),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedModifyNode = deserializedTableNode.nextNodes[0];
    expect(
      (deserializedModifyNode as ModifyColumnsNode).primaryInput?.nodeId,
    ).toBe(deserializedTableNode.nodeId);
    // Coordinates are normalized so top-left is at (minX, minY)
    // Original: tableNode (10, 20), modifyNode (100, 200)
    // After normalization: tableNode (0, 0), modifyNode (90, 180)
    expect(
      deserializedState.nodeLayouts.get(deserializedTableNode.nodeId),
    ).toEqual({x: 0, y: 0});
  });

  test('serializes and deserializes aggregation node', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const sliceTable = sqlModules.getTable('slice')!;
    const aggregationNode = new AggregationNode(
      {
        groupByColumns: [
          {
            name: 'name',
            checked: true,
            type: sliceTable.columns[0].type,
          },
        ],
        aggregations: [
          {
            column: {
              name: 'dur',
              type: sliceTable.columns[2].type,
            },
            aggregationOp: 'SUM',
            newColumnName: 'total_dur',
          },
        ],
      },
      {},
    );
    addConnection(tableNode, aggregationNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedAggregationNode = deserializedTableNode
      .nextNodes[0] as AggregationNode;
    expect(deserializedAggregationNode.primaryInput?.nodeId).toBe(
      deserializedTableNode.nodeId,
    );
    expect(deserializedAggregationNode.attrs.groupByColumns[0].name).toBe(
      'name',
    );
    expect(
      deserializedAggregationNode.attrs.aggregations[0].aggregationOp,
    ).toBe('SUM');
    expect(deserializedAggregationNode.attrs.aggregations[0].column?.name).toBe(
      'dur',
    );
  });

  test('serializes and deserializes sql source node', () => {
    const sqlNode = new SqlSourceNode({sql: 'SELECT * FROM slice'}, {trace});
    const initialState: DataExplorerState = {
      rootNodes: [sqlNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedNode = deserializedState.rootNodes[0] as SqlSourceNode;
    expect(deserializedNode.attrs.sql).toBe('SELECT * FROM slice');
  });

  test('serializes and deserializes sql source node with input connections', () => {
    const tableNode1 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const tableNode2 = new TableSourceNode(
      {sqlTable: 'thread'},
      {trace, sqlModules},
    );

    const sqlNode = new SqlSourceNode(
      {sql: 'SELECT * FROM $input_0 JOIN $input_1'},
      {trace},
    );

    // Connect inputs
    tableNode1.nextNodes.push(sqlNode);
    tableNode2.nextNodes.push(sqlNode);
    sqlNode.secondaryInputs.connections.set(0, tableNode1);
    sqlNode.secondaryInputs.connections.set(1, tableNode2);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode1, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(2);

    // Find the sql node in the deserialized graph
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    const deserializedSqlNode = deserializedTableNode1
      .nextNodes[0] as SqlSourceNode;

    expect(deserializedSqlNode.attrs.sql).toBe(
      'SELECT * FROM $input_0 JOIN $input_1',
    );
    expect(deserializedSqlNode.secondaryInputs.connections.size).toBe(2);
    // The order of rootNodes is preserved during deserialization because
    // rootNodeIds are serialized in order and restored in the same order.
    // tableNode1 (slice) was connected to port 0, tableNode2 (thread) to port 1.
    expect(deserializedSqlNode.secondaryInputs.connections.get(0)?.nodeId).toBe(
      deserializedState.rootNodes[0].nodeId,
    );
    expect(deserializedSqlNode.secondaryInputs.connections.get(1)?.nodeId).toBe(
      deserializedState.rootNodes[1].nodeId,
    );
  });

  test('serializes and deserializes interval intersect node', () => {
    const tableNode1 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const tableNode2 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const intervalIntersectNode = new IntervalIntersectNode(
      {
        inputNodes: [tableNode1, tableNode2],
      },
      {},
    );
    tableNode1.nextNodes.push(intervalIntersectNode);
    tableNode2.nextNodes.push(intervalIntersectNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode1, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(2);
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    const deserializedTableNode2 = deserializedState.rootNodes[1];
    expect(deserializedTableNode1.nextNodes.length).toBe(1);
    const deserializedIntervalIntersectNode = deserializedTableNode1
      .nextNodes[0] as IntervalIntersectNode;
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections,
    ).toBeDefined();
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections.size,
    ).toBe(2);
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections.get(0)
        ?.nodeId,
    ).toBe(deserializedTableNode1.nodeId);
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections.get(1)
        ?.nodeId,
    ).toBe(deserializedTableNode2.nodeId);
  });

  test('serializes and deserializes interval intersect node with partition columns and filters', () => {
    const tableNode1 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const tableNode2 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const tableNode3 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const intervalIntersectNode = new IntervalIntersectNode(
      {
        inputNodes: [tableNode1, tableNode2, tableNode3],
        partitionColumns: ['name'],
      },
      {},
    );
    tableNode1.nextNodes.push(intervalIntersectNode);
    tableNode2.nextNodes.push(intervalIntersectNode);
    tableNode3.nextNodes.push(intervalIntersectNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode1, tableNode2, tableNode3],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(3);
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    const deserializedTableNode2 = deserializedState.rootNodes[1];
    const deserializedTableNode3 = deserializedState.rootNodes[2];
    expect(deserializedTableNode1.nextNodes.length).toBe(1);
    const deserializedIntervalIntersectNode = deserializedTableNode1
      .nextNodes[0] as IntervalIntersectNode;

    // Verify secondaryInputs connections
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections,
    ).toBeDefined();
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections.size,
    ).toBe(3);
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections.get(0)
        ?.nodeId,
    ).toBe(deserializedTableNode1.nodeId);
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections.get(1)
        ?.nodeId,
    ).toBe(deserializedTableNode2.nodeId);
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections.get(2)
        ?.nodeId,
    ).toBe(deserializedTableNode3.nodeId);

    // Verify partition columns
    expect(
      (deserializedIntervalIntersectNode as IntervalIntersectNode).attrs
        .partitionColumns,
    ).toBeDefined();
    expect(
      (deserializedIntervalIntersectNode as IntervalIntersectNode).attrs
        .partitionColumns?.length,
    ).toBe(1);
    expect(
      (deserializedIntervalIntersectNode as IntervalIntersectNode).attrs
        .partitionColumns?.[0],
    ).toBe('name');
  });

  test('serializes and deserializes sql source node with reference', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const modifyNode = new ModifyColumnsNode({selectedColumns: []}, {});
    addConnection(tableNode, modifyNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedModifyNode = deserializedTableNode
      .nextNodes[0] as ModifyColumnsNode;
    expect(deserializedModifyNode.primaryInput?.nodeId).toBe(
      deserializedTableNode.nodeId,
    );
  });

  test('serializes and deserializes node with string filters', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const filterNode = new FilterNode(
      {
        filters: [
          {
            column: 'name',
            op: '=',
            value: 'test',
          },
        ],
      },
      {},
    );
    addConnection(tableNode, filterNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedFilterNode = deserializedTableNode
      .nextNodes[0] as FilterNode;
    expect(deserializedFilterNode.attrs.filters?.length).toBe(1);
    const filter = deserializedFilterNode.attrs.filters?.[0];
    expect(filter?.column).toBe('name');
    expect(filter?.op).toBe('=');
    if (filter !== undefined && 'value' in filter) {
      expect(filter.value).toBe('test');
    } else {
      expect.fail('Filter value not found');
    }
  });

  test('serializes and deserializes node with numeric filters', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const filterNode = new FilterNode(
      {
        filters: [
          {
            column: 'dur',
            op: '>',
            value: 1000,
          },
        ],
      },
      {},
    );
    addConnection(tableNode, filterNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedFilterNode = deserializedTableNode
      .nextNodes[0] as FilterNode;
    expect(deserializedFilterNode.attrs.filters?.length).toBe(1);
    const filter = deserializedFilterNode.attrs.filters?.[0];
    expect(filter?.column).toBe('dur');
    expect(filter?.op).toBe('>');
    if (filter !== undefined && 'value' in filter) {
      // Numeric filters should remain as numbers after serialization/deserialization
      expect(typeof filter.value).toBe('number');
      expect(filter.value).toBe(1000);
    } else {
      expect.fail('Filter value not found');
    }
  });

  test('serializes and deserializes node layouts', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const sliceNode = new SlicesSourceNode({}, {});

    const initialState: DataExplorerState = {
      rootNodes: [tableNode, sliceNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map([
        [tableNode.nodeId, {x: 10, y: 20}],
        [sliceNode.nodeId, {x: 100, y: 200}],
      ]),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(2);
    const deserializedTableNode = deserializedState.rootNodes[0];
    const deserializedSliceNode = deserializedState.rootNodes[1];

    // Coordinates are normalized so top-left is at (minX, minY)
    // Original: tableNode (10, 20), sliceNode (100, 200)
    // After normalization: tableNode (0, 0), sliceNode (90, 180)
    expect(
      deserializedState.nodeLayouts.get(deserializedTableNode.nodeId),
    ).toEqual({x: 0, y: 0});
    expect(
      deserializedState.nodeLayouts.get(deserializedSliceNode.nodeId),
    ).toEqual({x: 90, y: 180});
  });

  test('serializes and deserializes node with BigInt filter', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const filterNode = new FilterNode(
      {
        filters: [
          {
            column: 'id',
            op: '=',
            value: 12345678901234567890n,
          },
        ],
      },
      {},
    );
    addConnection(tableNode, filterNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedFilterNode = deserializedTableNode
      .nextNodes[0] as FilterNode;
    expect(deserializedFilterNode.attrs.filters?.length).toBe(1);
    const filter = deserializedFilterNode.attrs.filters?.[0];
    expect(filter?.column).toBe('id');
    expect(filter?.op).toBe('=');
    if (filter !== undefined && 'value' in filter) {
      // BigInt values are serialized as strings and then converted back to numbers.
      // Note: Very large numbers may lose precision due to JavaScript Number limits.
      expect(typeof filter.value).toBe('number');
      expect(filter.value).toBe(Number('12345678901234567890'));
    } else {
      expect.fail('Filter value not found');
    }
  });

  test('serializes and deserializes an empty graph', () => {
    const initialState: DataExplorerState = {
      rootNodes: [],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(0);
    expect(deserializedState.nodeLayouts.size).toBe(0);
  });

  test('throws error on invalid json', () => {
    const invalidJson = '{"invalid": "json"}';
    expect(() => deserializeState(invalidJson, trace, sqlModules)).toThrow();
  });

  test('deserializes graph with primaryInput connections', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const modifyNode = new ModifyColumnsNode({selectedColumns: []}, {});
    addConnection(tableNode, modifyNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    // Test with primaryInput
    const jsonWithPrimaryInput = serializeState(initialState);
    const deserializedStateWithPrimaryInput = deserializeState(
      jsonWithPrimaryInput,
      trace,
      sqlModules,
    );
    const deserializedTableNode1 =
      deserializedStateWithPrimaryInput.rootNodes[0];
    const deserializedModifyNode1 = deserializedTableNode1
      .nextNodes[0] as ModifyColumnsNode;
    expect(deserializedModifyNode1.primaryInput?.nodeId).toBe(
      deserializedTableNode1.nodeId,
    );
  });

  test('serializes and deserializes modify columns node', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const modifyColumnsNode = new ModifyColumnsNode({selectedColumns: []}, {});
    addConnection(tableNode, modifyColumnsNode);

    const filterNode = new FilterNode(
      {
        filters: [
          {
            column: 'new_col',
            op: '=',
            value: '1',
          },
        ],
      },
      {},
    );
    addConnection(modifyColumnsNode, filterNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedModifyNode = deserializedTableNode
      .nextNodes[0] as ModifyColumnsNode;
    expect(deserializedModifyNode.nextNodes.length).toBe(1);
    const deserializedFilterNode = deserializedModifyNode
      .nextNodes[0] as FilterNode;
    expect(deserializedFilterNode.attrs.filters).toBeDefined();
    expect(deserializedFilterNode.attrs.filters?.length).toBe(1);
    expect(deserializedFilterNode.attrs.filters?.[0].column).toBe('new_col');
  });

  test('serializes and deserializes add columns node', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const addColumnsNode = new AddColumnsNode(
      {
        selectedColumns: ['name'],
      },
      {},
    );
    // Manually connect without triggering onPrevNodesUpdated to preserve
    // the test's explicitly provided selectedColumns
    tableNode.nextNodes.push(addColumnsNode);
    addColumnsNode.primaryInput = tableNode;

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedNode = deserializedTableNode
      .nextNodes[0] as AddColumnsNode;
    expect(deserializedNode.attrs.selectedColumns).toEqual(['name']);
    // Note: sqlTable is no longer part of AddColumnsNodeState
  });

  test('serializes and deserializes add columns node with inputNodes connection', () => {
    // Create the main data flow: tableNode1 -> addColumnsNode
    const tableNode1 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const addColumnsNode = new AddColumnsNode(
      {
        selectedColumns: ['name', 'ts'],
        leftColumn: 'id',
        rightColumn: 'id',
      },
      {},
    );
    // Manually connect without triggering onPrevNodesUpdated to preserve
    // the test's explicitly provided selectedColumns
    tableNode1.nextNodes.push(addColumnsNode);
    addColumnsNode.primaryInput = tableNode1;

    // Create the side input: tableNode2 connected to inputNodes[0]
    const tableNode2 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    // Connect tableNode2 to addColumnsNode's secondaryInputs port 0 (left-side port)
    addColumnsNode.secondaryInputs.connections.set(0, tableNode2);
    tableNode2.nextNodes.push(addColumnsNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode1, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map([
        [tableNode1.nodeId, {x: 0, y: 0}],
        [addColumnsNode.nodeId, {x: 0, y: 100}],
        [tableNode2.nodeId, {x: -200, y: 100}],
      ]),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    // Verify root nodes
    expect(deserializedState.rootNodes.length).toBe(2);
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    const deserializedTableNode2 = deserializedState.rootNodes[1];

    // Verify main connection (primaryInput)
    expect(deserializedTableNode1.nextNodes.length).toBe(1);
    const deserializedAddColumnsNode = deserializedTableNode1
      .nextNodes[0] as AddColumnsNode;
    expect(deserializedAddColumnsNode.primaryInput?.nodeId).toBe(
      deserializedTableNode1.nodeId,
    );

    // Verify secondaryInputs connection (THIS IS THE BUG - this will fail before the fix)
    expect(
      deserializedAddColumnsNode.secondaryInputs.connections,
    ).toBeDefined();
    expect(
      deserializedAddColumnsNode.secondaryInputs.connections.size,
    ).toBeGreaterThan(0);
    expect(
      deserializedAddColumnsNode.secondaryInputs.connections.get(0)?.nodeId,
    ).toBe(deserializedTableNode2.nodeId);

    // Verify tableNode2 has the connection back to addColumnsNode
    expect(deserializedTableNode2.nextNodes.length).toBe(1);
    expect(deserializedTableNode2.nextNodes[0].nodeId).toBe(
      deserializedAddColumnsNode.nodeId,
    );

    // Verify node state
    expect(deserializedAddColumnsNode.attrs.selectedColumns).toEqual([
      'name',
      'ts',
    ]);
    expect(deserializedAddColumnsNode.attrs.leftColumn).toBe('id');
    expect(deserializedAddColumnsNode.attrs.rightColumn).toBe('id');

    // Verify layouts are preserved (after normalization)
    // Original: tableNode1 (0, 0), addColumnsNode (0, 100), tableNode2 (-200, 100)
    // After normalization (minX=-200, minY=0):
    // tableNode1 (200, 0), addColumnsNode (200, 100), tableNode2 (0, 100)
    expect(
      deserializedState.nodeLayouts.get(deserializedTableNode1.nodeId),
    ).toEqual({x: 200, y: 0});
    expect(
      deserializedState.nodeLayouts.get(deserializedAddColumnsNode.nodeId),
    ).toEqual({x: 200, y: 100});
    expect(
      deserializedState.nodeLayouts.get(deserializedTableNode2.nodeId),
    ).toEqual({x: 0, y: 100});
  });

  test('add columns node uses renamed columns from modify columns node', () => {
    // Create table source
    const tableNode1 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    // Get all columns from the table to find one to rename
    const allColumns = tableNode1.finalCols;
    // Find a column to rename (let's use the first one)
    const columnToRename = allColumns[0];

    // Create modify columns node that renames a column using alias
    const selectedColumnsWithAlias = [
      {
        ...allColumns[0],
        alias: 'renamed_column',
        checked: true,
      },
      ...allColumns.slice(1, 3).map((col) => ({...col, checked: true})),
    ];

    const modifyColumnsNode = new ModifyColumnsNode(
      {selectedColumns: selectedColumnsWithAlias},
      {},
    );
    addConnection(tableNode1, modifyColumnsNode);

    // Create another table to join with
    const tableNode2 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    // Create add columns node that should see the renamed column 'duration_ns'
    const addColumnsNode = new AddColumnsNode(
      {
        selectedColumns: [],
      },
      {},
    );
    addConnection(tableNode2, addColumnsNode);

    // Connect modifyColumnsNode to addColumnsNode's secondaryInputs port 0
    addColumnsNode.secondaryInputs.connections.set(0, modifyColumnsNode);
    modifyColumnsNode.nextNodes.push(addColumnsNode);

    // Check that addColumnsNode can see the renamed column
    const rightCols = addColumnsNode.rightCols;
    const colNames = rightCols.map((c: ColumnInfo) => c.name);

    // Should see the renamed column
    expect(colNames).toContain('renamed_column');
    // Should NOT see the original column name
    expect(colNames).not.toContain(columnToRename.name);

    // Verify the type is preserved for the renamed column
    const renamedCol = rightCols.find((c) => c.name === 'renamed_column');
    expect(renamedCol).toBeDefined();
    expect(renamedCol?.type).toBe(columnToRename.type);
    expect(renamedCol?.type).toBeDefined(); // Type should be preserved, not undefined

    // Now test serialization/deserialization preserves this
    const initialState: DataExplorerState = {
      rootNodes: [tableNode1, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    // Find the deserialized add columns node
    const deserializedTableNode2 = deserializedState.rootNodes.find(
      (n) => n.nodeId === tableNode2.nodeId,
    );
    expect(deserializedTableNode2).toBeDefined();
    expect(deserializedTableNode2!.nextNodes.length).toBe(1);

    const deserializedAddColumnsNode = deserializedTableNode2!
      .nextNodes[0] as AddColumnsNode;

    // Verify the connection is restored
    expect(
      deserializedAddColumnsNode.secondaryInputs.connections,
    ).toBeDefined();
    expect(
      deserializedAddColumnsNode.secondaryInputs.connections.size,
    ).toBeGreaterThan(0);
    expect(
      deserializedAddColumnsNode.secondaryInputs.connections.get(0),
    ).toBeDefined();

    // Most importantly: verify that renamed columns are still accessible
    const deserializedRightCols = deserializedAddColumnsNode.rightCols;
    const deserializedColNames = deserializedRightCols.map((c) => c.name);

    expect(deserializedColNames).toContain('renamed_column');
    expect(deserializedColNames).not.toContain(columnToRename.name);

    // Verify the type is preserved after serialization/deserialization
    const deserializedRenamedCol = deserializedRightCols.find(
      (c) => c.name === 'renamed_column',
    );
    expect(deserializedRenamedCol).toBeDefined();
    expect(deserializedRenamedCol?.type).toEqual(columnToRename.type);
    expect(deserializedRenamedCol?.type).toBeDefined(); // Type should still be preserved
  });

  test('aggregation node can group by aliased column from modify columns node', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    // Create a ModifyColumnsNode that aliases a column
    const modifyNode = new ModifyColumnsNode(
      {
        selectedColumns: tableNode.finalCols.map((col) => {
          // Alias the 'ts' column to 'timestamp_alias'
          if (col.name === 'ts') {
            return {...col, alias: 'timestamp_alias'};
          }
          return col;
        }),
      },
      {},
    );
    // Manually connect without triggering onPrevNodesUpdated to preserve
    // the test's explicitly provided selectedColumns
    tableNode.nextNodes.push(modifyNode);
    modifyNode.primaryInput = tableNode;

    // Verify that the alias is in finalCols and the original name is not
    const tsColumn = modifyNode.finalCols.find((c) => c.name === 'ts');
    const aliasedColumn = modifyNode.finalCols.find(
      (c) => c.name === 'timestamp_alias',
    );
    expect(tsColumn).toBeUndefined(); // Original name should not be visible
    expect(aliasedColumn).toBeDefined(); // Aliased name should be visible
    expect(aliasedColumn?.name).toBe('timestamp_alias'); // column.name should also use alias

    // Create an AggregationNode that groups by the aliased column
    const aggregationNode = new AggregationNode(
      {groupByColumns: [aliasedColumn!], aggregations: []},
      {},
    );
    // Manually connect without triggering onPrevNodesUpdated to preserve
    // the test's explicitly provided groupByColumns
    modifyNode.nextNodes.push(aggregationNode);
    aggregationNode.primaryInput = modifyNode;

    // Verify the aggregation node sees the aliased column
    expect(aggregationNode.attrs.groupByColumns.length).toBe(1);
    expect(aggregationNode.attrs.groupByColumns[0].name).toBe(
      'timestamp_alias',
    );
    expect(aggregationNode.attrs.groupByColumns[0].name).toBe(
      'timestamp_alias',
    );

    // Serialize and deserialize
    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    // Navigate to the deserialized aggregation node
    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);

    const deserializedModifyNode = deserializedTableNode.nextNodes[0] as
      | ModifyColumnsNode
      | undefined;
    expect(deserializedModifyNode).toBeDefined();
    expect(deserializedModifyNode?.type).toBe(NodeType.kModifyColumns);

    // Verify the alias is still in finalCols after deserialization
    const deserializedTsColumn = deserializedModifyNode?.finalCols.find(
      (c) => c.name === 'ts',
    );
    const deserializedAliasedColumn = deserializedModifyNode?.finalCols.find(
      (c) => c.name === 'timestamp_alias',
    );
    expect(deserializedTsColumn).toBeUndefined(); // Original name should not be visible
    expect(deserializedAliasedColumn).toBeDefined(); // Aliased name should be visible
    expect(deserializedAliasedColumn?.name).toBe('timestamp_alias');

    // Verify the aggregation node still sees the aliased column
    expect(deserializedModifyNode?.nextNodes.length).toBe(1);
    const deserializedAggNode = deserializedModifyNode?.nextNodes[0] as
      | AggregationNode
      | undefined;
    expect(deserializedAggNode).toBeDefined();
    expect(deserializedAggNode?.type).toBe(NodeType.kAggregation);
    expect(deserializedAggNode?.attrs.groupByColumns.length).toBe(1);
    expect(deserializedAggNode?.attrs.groupByColumns[0].name).toBe(
      'timestamp_alias',
    );
    expect(deserializedAggNode?.attrs.groupByColumns[0].name).toBe(
      'timestamp_alias',
    );
  });

  test('serializes and deserializes limit and offset node', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const limitAndOffsetNode = new LimitAndOffsetNode(
      {
        limit: 100,
        offset: 20,
      },
      {},
    );
    addConnection(tableNode, limitAndOffsetNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedNode = deserializedTableNode
      .nextNodes[0] as LimitAndOffsetNode;
    expect(deserializedNode.attrs.limit).toEqual(100);
    expect(deserializedNode.attrs.offset).toEqual(20);
  });

  test('serializes and deserializes sort node', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const sortNode = new SortNode(
      {
        sortCriteria: [
          {colName: 'name', direction: 'ASC'},
          {colName: 'ts', direction: 'DESC'},
        ],
      },
      {},
    );
    addConnection(tableNode, sortNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedNode = deserializedTableNode.nextNodes[0] as SortNode;
    expect(deserializedNode.attrs.sortCriteria).toEqual([
      {colName: 'name', direction: 'ASC'},
      {colName: 'ts', direction: 'DESC'},
    ]);
  });

  test('serializes and deserializes filter node', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const filterNode = new FilterNode(
      {
        filters: [
          {
            column: 'name',
            op: '=',
            value: 'test',
          },
          {
            column: 'dur',
            op: '>',
            value: 1000,
          },
        ],
        filterOperator: 'AND',
      },
      {},
    );
    addConnection(tableNode, filterNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedNode = deserializedTableNode.nextNodes[0] as FilterNode;

    // Verify filters
    expect(deserializedNode.attrs.filters).toBeDefined();
    expect(deserializedNode.attrs.filters?.length).toBe(2);
    expect(deserializedNode.attrs.filters?.[0].column).toBe('name');
    expect(deserializedNode.attrs.filters?.[0].op).toBe('=');
    if (
      deserializedNode.attrs.filters?.[0] &&
      'value' in deserializedNode.attrs.filters[0]
    ) {
      expect(deserializedNode.attrs.filters[0].value).toBe('test');
    }
    expect(deserializedNode.attrs.filters?.[1].column).toBe('dur');
    expect(deserializedNode.attrs.filters?.[1].op).toBe('>');
    if (
      deserializedNode.attrs.filters?.[1] &&
      'value' in deserializedNode.attrs.filters[1]
    ) {
      expect(deserializedNode.attrs.filters[1].value).toBe(1000);
    }

    // Verify filter operator
    expect(deserializedNode.attrs.filterOperator).toBe('AND');

    // Verify primaryInput connection
    expect(deserializedNode.primaryInput?.nodeId).toBe(
      deserializedTableNode.nodeId,
    );
  });

  test('serializes and deserializes filter node with null filter', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const filterNode = new FilterNode(
      {
        filters: [
          {
            column: 'name',
            op: 'is null',
          },
        ],
      },
      {},
    );
    addConnection(tableNode, filterNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedNode = deserializedTableNode.nextNodes[0] as FilterNode;

    // Verify filter
    expect(deserializedNode.attrs.filters).toBeDefined();
    expect(deserializedNode.attrs.filters?.length).toBe(1);
    expect(deserializedNode.attrs.filters?.[0].column).toBe('name');
    expect(deserializedNode.attrs.filters?.[0].op).toBe('is null');
    // Null filters don't have a value property
    expect('value' in deserializedNode.attrs.filters![0]).toBe(false);
  });

  test('serializes and deserializes merge node with equality condition', () => {
    const tableNode1 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const tableNode2 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const joinNode = new JoinNode(
      {
        leftQueryAlias: 'left',
        rightQueryAlias: 'right',
        conditionType: 'equality',
        joinType: 'INNER',
        leftColumn: 'name',
        rightColumn: 'name',
        sqlExpression: '',
        leftColumns: undefined,
        rightColumns: undefined,
      },
      {},
    );
    joinNode.secondaryInputs.connections.set(0, tableNode1);
    tableNode1.nextNodes.push(joinNode);
    joinNode.secondaryInputs.connections.set(1, tableNode2);
    tableNode2.nextNodes.push(joinNode);
    joinNode.onPrevNodesUpdated?.();

    const initialState: DataExplorerState = {
      rootNodes: [tableNode1, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(2);
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    const deserializedTableNode2 = deserializedState.rootNodes[1];
    expect(deserializedTableNode1.nextNodes.length).toBe(1);
    const deserializedJoinNode = deserializedTableNode1
      .nextNodes[0] as JoinNode;
    expect(deserializedJoinNode.secondaryInputs.connections).toBeDefined();
    expect(deserializedJoinNode.secondaryInputs.connections.size).toBe(2);
    expect(
      deserializedJoinNode.secondaryInputs.connections.get(0)?.nodeId,
    ).toBe(deserializedTableNode1.nodeId);
    expect(
      deserializedJoinNode.secondaryInputs.connections.get(1)?.nodeId,
    ).toBe(deserializedTableNode2.nodeId);
    expect(deserializedJoinNode.attrs.leftQueryAlias).toBe('left');
    expect(deserializedJoinNode.attrs.rightQueryAlias).toBe('right');
    expect(deserializedJoinNode.attrs.conditionType).toBe('equality');
    expect(deserializedJoinNode.attrs.leftColumn).toBe('name');
    expect(deserializedJoinNode.attrs.rightColumn).toBe('name');
  });

  test('serializes and deserializes merge node with freeform condition', () => {
    const tableNode1 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const tableNode2 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const joinNode = new JoinNode(
      {
        leftQueryAlias: 't1',
        rightQueryAlias: 't2',
        conditionType: 'freeform',
        joinType: 'INNER',
        leftColumn: '',
        rightColumn: '',
        sqlExpression: 't1.id = t2.parent_id',
        leftColumns: undefined,
        rightColumns: undefined,
      },
      {},
    );
    joinNode.secondaryInputs.connections.set(0, tableNode1);
    tableNode1.nextNodes.push(joinNode);
    joinNode.secondaryInputs.connections.set(1, tableNode2);
    tableNode2.nextNodes.push(joinNode);
    joinNode.onPrevNodesUpdated?.();

    const initialState: DataExplorerState = {
      rootNodes: [tableNode1, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(2);
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    expect(deserializedTableNode1.nextNodes.length).toBe(1);
    const deserializedJoinNode = deserializedTableNode1
      .nextNodes[0] as JoinNode;
    expect(deserializedJoinNode.attrs.leftQueryAlias).toBe('t1');
    expect(deserializedJoinNode.attrs.rightQueryAlias).toBe('t2');
    expect(deserializedJoinNode.attrs.conditionType).toBe('freeform');
    expect(deserializedJoinNode.attrs.sqlExpression).toBe(
      't1.id = t2.parent_id',
    );
  });

  test('deserializes old JSON with NodeType.kMerge as JoinNode (backward compatibility)', () => {
    // Simulate an old saved JSON that used NodeType.kMerge
    // Since kMerge is an alias for kJoin, this should deserialize correctly
    const oldJson = `{
      "nodes": [
        {
          "nodeId": "0",
          "type": "${NodeType.kMerge}",
          "state": {
            "leftQueryAlias": "left",
            "rightQueryAlias": "right",
            "conditionType": "equality",
            "leftColumn": "id",
            "rightColumn": "id",
            "sqlExpression": "",
            "leftNodeId": "1",
            "rightNodeId": "2"
          },
          "nextNodes": []
        },
        {
          "nodeId": "1",
          "type": "${NodeType.kTable}",
          "state": {
            "tableName": "slice",
            "columnNames": ["id", "ts", "dur", "name"]
          },
          "nextNodes": ["0"]
        },
        {
          "nodeId": "2",
          "type": "${NodeType.kTable}",
          "state": {
            "tableName": "slice",
            "columnNames": ["id", "ts", "dur", "name"]
          },
          "nextNodes": ["0"]
        }
      ],
      "rootNodeIds": ["1", "2"]
    }`;

    const deserializedState = deserializeState(oldJson, trace, sqlModules);

    // Verify the graph structure
    expect(deserializedState.rootNodes.length).toBe(2);

    // Find the join node
    const table1 = deserializedState.rootNodes[0];
    const table2 = deserializedState.rootNodes[1];
    expect(table1.nextNodes.length).toBe(1);
    expect(table2.nextNodes.length).toBe(1);
    expect(table1.nextNodes[0]).toBe(table2.nextNodes[0]); // Same join node

    // Verify it's actually a JoinNode (not just any node)
    const joinNode = table1.nextNodes[0] as JoinNode;
    expect(joinNode).toBeInstanceOf(JoinNode);
    expect(joinNode.type).toBe(NodeType.kJoin); // kMerge should equal kJoin

    // Verify the state was preserved
    expect(joinNode.attrs.leftQueryAlias).toBe('left');
    expect(joinNode.attrs.rightQueryAlias).toBe('right');
    expect(joinNode.attrs.conditionType).toBe('equality');
    expect(joinNode.attrs.leftColumn).toBe('id');
    expect(joinNode.attrs.rightColumn).toBe('id');

    // Verify the connections are correct
    expect(joinNode.secondaryInputs.connections.size).toBe(2);
    expect(joinNode.secondaryInputs.connections.get(0)).toBe(table1);
    expect(joinNode.secondaryInputs.connections.get(1)).toBe(table2);
  });

  test('serializes and deserializes union node', () => {
    const tableNode1 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const tableNode2 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const tableNode3 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const unionNode = new UnionNode(
      {
        inputNodes: [tableNode1, tableNode2, tableNode3],
        selectedColumns: [
          {
            name: 'name',
            checked: true,
          },
          {
            name: 'ts',
            checked: true,
          },
        ],
      },
      {},
    );
    tableNode1.nextNodes.push(unionNode);
    tableNode2.nextNodes.push(unionNode);
    tableNode3.nextNodes.push(unionNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode1, tableNode2, tableNode3],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(3);
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    const deserializedTableNode2 = deserializedState.rootNodes[1];
    const deserializedTableNode3 = deserializedState.rootNodes[2];
    expect(deserializedTableNode1.nextNodes.length).toBe(1);
    const deserializedUnionNode = deserializedTableNode1
      .nextNodes[0] as UnionNode;
    expect(deserializedUnionNode.secondaryInputs.connections).toBeDefined();
    expect(deserializedUnionNode.secondaryInputs.connections.size).toBe(3);
    expect(
      deserializedUnionNode.secondaryInputs.connections.get(0)?.nodeId,
    ).toBe(deserializedTableNode1.nodeId);
    expect(
      deserializedUnionNode.secondaryInputs.connections.get(1)?.nodeId,
    ).toBe(deserializedTableNode2.nodeId);
    expect(
      deserializedUnionNode.secondaryInputs.connections.get(2)?.nodeId,
    ).toBe(deserializedTableNode3.nodeId);
    expect(
      (deserializedUnionNode as UnionNode).attrs.selectedColumns.length,
    ).toBe(2);
    expect(
      (deserializedUnionNode as UnionNode).attrs.selectedColumns[0].name,
    ).toBe('name');
    expect(
      (deserializedUnionNode as UnionNode).attrs.selectedColumns[0].checked,
    ).toBe(true);
    expect(
      (deserializedUnionNode as UnionNode).attrs.selectedColumns[1].name,
    ).toBe('ts');
    expect(
      (deserializedUnionNode as UnionNode).attrs.selectedColumns[1].checked,
    ).toBe(true);
  });

  test('serializes and deserializes merge node with filters and comment', () => {
    const tableNode1 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const tableNode2 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const joinNode = new JoinNode(
      {
        leftQueryAlias: 'left',
        rightQueryAlias: 'right',
        conditionType: 'equality',
        joinType: 'INNER',
        leftColumn: 'name',
        rightColumn: 'name',
        sqlExpression: '',
        leftColumns: undefined,
        rightColumns: undefined,
      },
      {},
    );
    joinNode.secondaryInputs.connections.set(0, tableNode1);
    tableNode1.nextNodes.push(joinNode);
    joinNode.secondaryInputs.connections.set(1, tableNode2);
    tableNode2.nextNodes.push(joinNode);
    joinNode.onPrevNodesUpdated?.();

    const filterNode = new FilterNode(
      {
        filters: [
          {
            column: 'dur',
            op: '>',
            value: '1000',
          },
        ],
      },
      {},
    );
    addConnection(joinNode, filterNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode1, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(2);
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    expect(deserializedTableNode1.nextNodes.length).toBe(1);
    const deserializedJoinNode = deserializedTableNode1
      .nextNodes[0] as JoinNode;
    expect(deserializedJoinNode.nextNodes.length).toBe(1);
    const deserializedFilterNode = deserializedJoinNode
      .nextNodes[0] as FilterNode;
    expect(deserializedFilterNode.attrs.filters).toBeDefined();
    expect(deserializedFilterNode.attrs.filters?.length).toBe(1);
    expect(deserializedFilterNode.attrs.filters?.[0].column).toBe('dur');
    expect(deserializedFilterNode.attrs.filters?.[0].op).toBe('>');
  });

  test('serializes and deserializes union node', () => {
    const tableNode1 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const tableNode2 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const unionNode = new UnionNode(
      {
        inputNodes: [tableNode1, tableNode2],
        selectedColumns: [
          {
            name: 'name',
            checked: true,
          },
          {
            name: 'ts',
            checked: false,
          },
        ],
      },
      {},
    );
    tableNode1.nextNodes.push(unionNode);
    tableNode2.nextNodes.push(unionNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode1, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(2);
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    expect(deserializedTableNode1.nextNodes.length).toBe(1);
    const deserializedUnionNode = deserializedTableNode1
      .nextNodes[0] as UnionNode;
    // Verify unchecked column is preserved
    expect(
      (deserializedUnionNode as UnionNode).attrs.selectedColumns[1].checked,
    ).toBe(false);
  });

  test('join node requires explicit column selection', () => {
    const tableNode1 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const tableNode2 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    // Both tables have the same columns
    // When leftColumns/rightColumns are undefined, columns default to unchecked
    const joinNode = new JoinNode(
      {
        leftQueryAlias: 'left',
        rightQueryAlias: 'right',
        conditionType: 'equality',
        joinType: 'INNER',
        leftColumn: 'name',
        rightColumn: 'name',
        sqlExpression: '',
        leftColumns: undefined,
        rightColumns: undefined,
      },
      {},
    );
    joinNode.secondaryInputs.connections.set(0, tableNode1);
    tableNode1.nextNodes.push(joinNode);
    joinNode.secondaryInputs.connections.set(1, tableNode2);
    tableNode2.nextNodes.push(joinNode);
    joinNode.onPrevNodesUpdated?.();

    // With no columns checked, finalCols should be empty
    expect(joinNode.finalCols).toEqual([]);

    // Now explicitly check some columns
    expect(joinNode.attrs.leftColumns).toBeDefined();
    // Find and check the 'name' column from left
    const nameCol = joinNode.attrs.leftColumns!.find((c) => c.name === 'name');
    expect(nameCol).toBeDefined();
    nameCol!.checked = true;

    expect(joinNode.attrs.rightColumns).toBeDefined();
    // Find and check 'ts' column from right (to show we can select any column)
    const tsCol = joinNode.attrs.rightColumns!.find((c) => c.name === 'ts');
    expect(tsCol).toBeDefined();
    tsCol!.checked = true;

    // Verify finalCols now includes only checked columns
    const finalCols = joinNode.finalCols;
    const colNames = finalCols.map((c: ColumnInfo) => c.name);

    // Should include 'name' from left and 'ts' from right
    expect(colNames).toContain('name');
    expect(colNames).toContain('ts');
    expect(colNames.length).toBe(2);

    // Verify the structured query includes select_columns
    const sq = joinNode.getStructuredQuery();
    expect(sq).toBeDefined();
    expect(sq?.selectColumns).toBeDefined();
    expect(sq?.selectColumns?.length).toBe(finalCols.length);
    expect(sq?.selectColumns?.map((c) => c.columnNameOrExpression)).toEqual(
      colNames,
    );
  });

  test('serializes and deserializes modify columns node with selected columns', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const sliceTable = sqlModules.getTable('slice')!;
    const modifyColumnsNode = new ModifyColumnsNode(
      {
        selectedColumns: [
          {
            name: 'name',
            checked: true,
            type: sliceTable.columns[0].type,
          },
          {
            name: 'ts',
            checked: true,
            type: sliceTable.columns[1].type,
          },
        ],
      },
      {},
    );
    // Manually connect without triggering onPrevNodesUpdated to preserve
    // the test's explicitly provided selectedColumns
    tableNode.nextNodes.push(modifyColumnsNode);
    modifyColumnsNode.primaryInput = tableNode;

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedNode = deserializedTableNode
      .nextNodes[0] as ModifyColumnsNode;
    expect(deserializedNode.attrs.selectedColumns.length).toBe(2);
    expect(deserializedNode.attrs.selectedColumns[0].name).toBe('name');
    expect(deserializedNode.attrs.selectedColumns[1].name).toBe('ts');
  });

  test('serializes modify columns node without primaryInput', () => {
    // Create a modify columns node without a primaryInput (edge case)
    const modifyColumnsNode = new ModifyColumnsNode({selectedColumns: []}, {});

    const initialState: DataExplorerState = {
      rootNodes: [modifyColumnsNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    // Should be able to serialize without throwing
    const json = serializeState(initialState);
    const serialized = JSON.parse(json);

    // Verify primaryInputId is undefined in serialized state
    const serializedNode = serialized.nodes.find(
      (n: SerializedNode) => n.nodeId === modifyColumnsNode.nodeId,
    );
    expect(serializedNode).toBeDefined();
    expect(serializedNode.state.primaryInputId).toBeUndefined();
  });

  test('serializes and deserializes aggregation node with multiple aggregations', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const sliceTable = sqlModules.getTable('slice')!;
    const aggregationNode = new AggregationNode(
      {
        groupByColumns: [
          {
            name: 'name',
            checked: true,
            type: sliceTable.columns[0].type,
          },
          {
            name: 'ts',
            checked: true,
            type: sliceTable.columns[1].type,
          },
        ],
        aggregations: [
          {
            column: {
              name: 'dur',
              type: sliceTable.columns[2].type,
            },
            aggregationOp: 'SUM',
            newColumnName: 'total_dur',
          },
          {
            column: {
              name: 'dur',
              type: sliceTable.columns[2].type,
            },
            aggregationOp: 'AVG',
            newColumnName: 'avg_dur',
          },
          {
            column: {
              name: 'dur',
              type: sliceTable.columns[2].type,
            },
            aggregationOp: 'COUNT',
            newColumnName: 'count',
          },
        ],
      },
      {},
    );
    // Manually connect without triggering onPrevNodesUpdated to preserve
    // the test's explicitly provided groupByColumns
    tableNode.nextNodes.push(aggregationNode);
    aggregationNode.primaryInput = tableNode;

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);
    const deserializedAggregationNode = deserializedTableNode
      .nextNodes[0] as AggregationNode;
    expect(deserializedAggregationNode.attrs.groupByColumns.length).toBe(2);
    expect(deserializedAggregationNode.attrs.groupByColumns[1].name).toBe('ts');
    expect(deserializedAggregationNode.attrs.aggregations.length).toBe(3);
    expect(
      deserializedAggregationNode.attrs.aggregations[0].aggregationOp,
    ).toBe('SUM');
    expect(
      deserializedAggregationNode.attrs.aggregations[0].newColumnName,
    ).toBe('total_dur');
    expect(
      deserializedAggregationNode.attrs.aggregations[1].aggregationOp,
    ).toBe('AVG');
    expect(
      deserializedAggregationNode.attrs.aggregations[1].newColumnName,
    ).toBe('avg_dur');
    expect(
      deserializedAggregationNode.attrs.aggregations[2].aggregationOp,
    ).toBe('COUNT');
  });

  test('serializes and deserializes complex multi-node chain', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const sliceTable = sqlModules.getTable('slice')!;

    const filterNode = new FilterNode(
      {
        filters: [
          {
            column: 'name',
            op: '!=',
            value: 'idle',
          },
        ],
      },
      {},
    );
    addConnection(tableNode, filterNode);
    const modifyNode = new ModifyColumnsNode(
      {
        selectedColumns: [
          {
            name: 'name',
            checked: true,
            type: sliceTable.columns[0].type,
          },
        ],
      },
      {},
    );
    addConnection(filterNode, modifyNode);

    const aggregationNode = new AggregationNode(
      {
        groupByColumns: [
          {
            name: 'name',
            checked: true,
            type: sliceTable.columns[0].type,
          },
        ],
        aggregations: [
          {
            column: {
              name: 'dur_ms',
              type: sliceTable.columns[2].type,
            },
            aggregationOp: 'SUM',
            newColumnName: 'total_dur_ms',
          },
        ],
      },
      {},
    );
    addConnection(modifyNode, aggregationNode);

    const sortNode = new SortNode(
      {
        sortCriteria: [{colName: 'total_dur_ms', direction: 'ASC'}],
      },
      {},
    );
    addConnection(aggregationNode, sortNode);

    const limitNode = new LimitAndOffsetNode(
      {
        limit: 10,
        offset: 0,
      },
      {},
    );
    addConnection(sortNode, limitNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map([
        [tableNode.nodeId, {x: 0, y: 0}],
        [filterNode.nodeId, {x: 0, y: 50}],
        [modifyNode.nodeId, {x: 0, y: 100}],
        [aggregationNode.nodeId, {x: 0, y: 200}],
        [sortNode.nodeId, {x: 0, y: 300}],
        [limitNode.nodeId, {x: 0, y: 400}],
      ]),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    // Verify chain structure
    expect(deserializedState.rootNodes.length).toBe(1);
    const node1 = deserializedState.rootNodes[0] as TableSourceNode;
    expect(node1.nextNodes.length).toBe(1);

    const node2 = node1.nextNodes[0] as FilterNode;
    expect(node2.primaryInput?.nodeId).toBe(node1.nodeId);
    expect(node2.attrs.filters?.length).toBe(1);
    expect(node2.nextNodes.length).toBe(1);

    const node3 = node2.nextNodes[0] as ModifyColumnsNode;
    expect(node3.primaryInput?.nodeId).toBe(node2.nodeId);
    expect(node3.nextNodes.length).toBe(1);

    const node4 = node3.nextNodes[0] as AggregationNode;
    expect(node4.primaryInput?.nodeId).toBe(node3.nodeId);
    expect(node4.nextNodes.length).toBe(1);

    const node5 = node4.nextNodes[0] as SortNode;
    expect(node5.primaryInput?.nodeId).toBe(node4.nodeId);
    expect(node5.nextNodes.length).toBe(1);

    const node6 = node5.nextNodes[0] as LimitAndOffsetNode;
    expect(node6.primaryInput?.nodeId).toBe(node5.nodeId);
    expect(node6.attrs.limit).toBe(10);

    // Verify all layouts preserved
    expect(deserializedState.nodeLayouts.size).toBe(6);
  });

  test('serializes and deserializes branching graph', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const sliceTable = sqlModules.getTable('slice')!;
    const modifyNode1 = new ModifyColumnsNode(
      {
        selectedColumns: [
          {
            name: 'name',
            checked: true,
            type: sliceTable.columns[0].type,
          },
        ],
      },
      {},
    );
    addConnection(tableNode, modifyNode1);

    const modifyNode2 = new ModifyColumnsNode(
      {
        selectedColumns: [
          {
            name: 'ts',
            checked: true,
            type: sliceTable.columns[1].type,
          },
        ],
      },
      {},
    );
    addConnection(tableNode, modifyNode2);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(2);

    const branch1 = deserializedTableNode.nextNodes[0] as ModifyColumnsNode;
    const branch2 = deserializedTableNode.nextNodes[1] as ModifyColumnsNode;

    expect(branch1.primaryInput?.nodeId).toBe(deserializedTableNode.nodeId);
    expect(branch2.primaryInput?.nodeId).toBe(deserializedTableNode.nodeId);
  });

  test('deserializes graph without nodeLayouts field (auto-layout)', () => {
    // Create a real node and serialize it
    const sliceNode = new SlicesSourceNode({}, {});
    const initialState: DataExplorerState = {
      rootNodes: [sliceNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };
    const serialized = serializeState(initialState);

    // Parse the JSON and remove the nodeLayouts field
    const parsed = JSON.parse(serialized);
    delete parsed.nodeLayouts;
    const jsonWithoutLayouts = JSON.stringify(parsed);

    const deserializedState = deserializeState(
      jsonWithoutLayouts,
      trace,
      sqlModules,
    );

    expect(deserializedState.rootNodes.length).toBe(1);
    expect(deserializedState.nodeLayouts.size).toBe(0);
    const deserializedNode = deserializedState.rootNodes[0] as SlicesSourceNode;
    expect(deserializedNode).toBeInstanceOf(SlicesSourceNode);
  });

  // ========================================
  // FilterDuringNode Serialization Tests
  // ========================================

  test('serializes and deserializes filter during node with single secondary input', () => {
    const slicesNode = new SlicesSourceNode({}, {});
    const timeRangeNode = new TimeRangeSourceNode({}, {trace});

    const filterDuringNode = new FilterDuringNode(
      {
        partitionColumns: ['utid'],
        clipToIntervals: false,
      },
      {},
    );

    // Connect slicesNode as primaryInput (from above)
    slicesNode.nextNodes.push(filterDuringNode);
    filterDuringNode.primaryInput = slicesNode;

    // Connect timeRangeNode as secondaryInput (from left)
    timeRangeNode.nextNodes.push(filterDuringNode);
    filterDuringNode.secondaryInputs.connections.set(0, timeRangeNode);

    const initialState: DataExplorerState = {
      rootNodes: [slicesNode, timeRangeNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(2);

    // Find the deserialized slices node (first root)
    const deserializedSlicesNode = deserializedState.rootNodes[0];
    expect(deserializedSlicesNode).toBeInstanceOf(SlicesSourceNode);
    expect(deserializedSlicesNode.nextNodes.length).toBe(1);

    // Find the deserialized filter during node
    const deserializedFilterDuringNode = deserializedSlicesNode
      .nextNodes[0] as FilterDuringNode;
    expect(deserializedFilterDuringNode).toBeInstanceOf(FilterDuringNode);

    // Verify primaryInput connection
    expect(deserializedFilterDuringNode.primaryInput?.nodeId).toBe(
      deserializedSlicesNode.nodeId,
    );

    // Verify secondaryInput connection
    expect(deserializedFilterDuringNode.secondaryInputs.connections.size).toBe(
      1,
    );

    // Verify state preserved
    expect(
      (deserializedFilterDuringNode as FilterDuringNode).attrs.partitionColumns,
    ).toEqual(['utid']);
    expect(
      (deserializedFilterDuringNode as FilterDuringNode).attrs.clipToIntervals,
    ).toBe(false);
  });

  // ========================================
  // Complex Connection Scenarios
  // ========================================

  test('serializes and deserializes complex graph with diamond pattern', () => {
    // Diamond pattern: table -> filter -> |
    //                  table -> sort   -> | -> union
    const tableNode1 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const tableNode2 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const filterNode = new FilterNode(
      {
        filters: [{column: 'name', op: '=', value: 'foo'}],
      },
      {},
    );
    addConnection(tableNode1, filterNode);

    const sortNode = new SortNode(
      {
        sortCriteria: [{colName: 'ts', direction: 'ASC'}],
      },
      {},
    );
    addConnection(tableNode2, sortNode);
    const unionNode = new UnionNode(
      {
        inputNodes: [filterNode, sortNode],
        selectedColumns: [
          {
            name: 'name',
            checked: true,
          },
        ],
      },
      {},
    );
    filterNode.nextNodes.push(unionNode);
    sortNode.nextNodes.push(unionNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode1, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(2);

    // Verify the diamond converges at union
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    const deserializedTableNode2 = deserializedState.rootNodes[1];

    const deserializedFilterNode = deserializedTableNode1
      .nextNodes[0] as FilterNode;
    const deserializedSortNode = deserializedTableNode2
      .nextNodes[0] as SortNode;

    expect(deserializedFilterNode.nextNodes.length).toBe(1);
    expect(deserializedSortNode.nextNodes.length).toBe(1);

    // Both should converge to the same union node
    const deserializedUnionNode1 = deserializedFilterNode.nextNodes[0];
    const deserializedUnionNode2 = deserializedSortNode.nextNodes[0];
    expect(deserializedUnionNode1.nodeId).toBe(deserializedUnionNode2.nodeId);
  });

  test('serializes and deserializes deep chain with 6+ nodes', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const sliceTable = sqlModules.getTable('slice')!;

    const filterNode1 = new FilterNode(
      {
        filters: [{column: 'name', op: '!=', value: 'idle'}],
      },
      {},
    );
    addConnection(tableNode, filterNode1);

    const filterNode2 = new FilterNode(
      {
        filters: [{column: 'dur', op: '>', value: 1000}],
      },
      {},
    );
    addConnection(filterNode1, filterNode2);

    const sortNode = new SortNode(
      {
        sortCriteria: [{colName: 'ts', direction: 'ASC'}],
      },
      {},
    );
    addConnection(filterNode2, sortNode);
    const aggregationNode = new AggregationNode(
      {
        groupByColumns: [
          {
            name: 'name',
            checked: true,
            type: sliceTable.columns[0].type,
          },
        ],
        aggregations: [
          {
            column: {
              name: 'dur',
              type: sliceTable.columns[2].type,
            },
            aggregationOp: 'SUM',
            newColumnName: 'total_dur',
          },
        ],
      },
      {},
    );
    addConnection(sortNode, aggregationNode);

    const limitNode = new LimitAndOffsetNode({limit: 100, offset: 0}, {});
    addConnection(aggregationNode, limitNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);

    // Walk the chain and verify all connections
    let currentNode = deserializedState.rootNodes[0];
    expect(currentNode).toBeInstanceOf(TableSourceNode);

    currentNode = currentNode.nextNodes[0];
    expect(currentNode).toBeInstanceOf(FilterNode);
    expect((currentNode as FilterNode).primaryInput).toBeDefined();

    currentNode = currentNode.nextNodes[0];
    expect(currentNode).toBeInstanceOf(FilterNode);
    expect((currentNode as FilterNode).primaryInput).toBeDefined();

    currentNode = currentNode.nextNodes[0];
    expect(currentNode).toBeInstanceOf(SortNode);
    expect((currentNode as SortNode).primaryInput).toBeDefined();

    currentNode = currentNode.nextNodes[0];
    expect(currentNode).toBeInstanceOf(AggregationNode);
    expect((currentNode as AggregationNode).primaryInput).toBeDefined();

    currentNode = currentNode.nextNodes[0];
    expect(currentNode).toBeInstanceOf(LimitAndOffsetNode);
    expect((currentNode as LimitAndOffsetNode).primaryInput).toBeDefined();
    expect(currentNode.nextNodes.length).toBe(0); // End of chain
  });

  test('serializes and deserializes graph with interval intersect and union combined', () => {
    const tableNode1 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const tableNode2 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const tableNode3 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    // Create interval intersect with first two tables
    const intervalIntersectNode = new IntervalIntersectNode(
      {
        inputNodes: [tableNode1, tableNode2],
      },
      {},
    );
    tableNode1.nextNodes.push(intervalIntersectNode);
    tableNode2.nextNodes.push(intervalIntersectNode);

    // Create union with interval intersect result and third table
    const unionNode = new UnionNode(
      {
        inputNodes: [intervalIntersectNode, tableNode3],
        selectedColumns: [
          {
            name: 'name',
            checked: true,
          },
        ],
      },
      {},
    );
    intervalIntersectNode.nextNodes.push(unionNode);
    tableNode3.nextNodes.push(unionNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode1, tableNode2, tableNode3],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(3);

    // Find interval intersect node
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    const deserializedIntervalIntersectNode = deserializedTableNode1
      .nextNodes[0] as IntervalIntersectNode;
    expect(deserializedIntervalIntersectNode).toBeInstanceOf(
      IntervalIntersectNode,
    );

    // Verify interval intersect has 2 secondary inputs
    expect(
      deserializedIntervalIntersectNode.secondaryInputs.connections.size,
    ).toBe(2);

    // Find union node
    expect(deserializedIntervalIntersectNode.nextNodes.length).toBe(1);
    const deserializedUnionNode = deserializedIntervalIntersectNode
      .nextNodes[0] as UnionNode;
    expect(deserializedUnionNode).toBeInstanceOf(UnionNode);

    // Union should have 2 inputs: interval intersect and tableNode3
    expect(deserializedUnionNode.secondaryInputs.connections.size).toBe(2);
  });

  // ========================================
  // Connection Removal Tests
  // ========================================

  test('handles removing connection from middle of chain', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const filterNode = new FilterNode(
      {
        filters: [{column: 'name', op: '=', value: 'foo'}],
      },
      {},
    );
    addConnection(tableNode, filterNode);

    const sortNode = new SortNode(
      {
        sortCriteria: [{colName: 'ts', direction: 'ASC'}],
      },
      {},
    );
    addConnection(filterNode, sortNode);

    const limitNode = new LimitAndOffsetNode({limit: 10, offset: 0}, {});
    addConnection(sortNode, limitNode);

    // Remove the filter node from the chain
    removeConnection(tableNode, filterNode);
    removeConnection(filterNode, sortNode);

    // Reconnect directly
    addConnection(tableNode, sortNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    // Table -> Sort -> Limit (filter should be orphaned)
    const deserializedTableNode = deserializedState.rootNodes[0];
    expect(deserializedTableNode.nextNodes.length).toBe(1);

    const deserializedSortNode = deserializedTableNode.nextNodes[0] as SortNode;
    expect(deserializedSortNode).toBeInstanceOf(SortNode);
    expect(deserializedSortNode.primaryInput?.nodeId).toBe(
      deserializedTableNode.nodeId,
    );

    expect(deserializedSortNode.nextNodes.length).toBe(1);
    const deserializedLimitNode = deserializedSortNode
      .nextNodes[0] as LimitAndOffsetNode;
    expect(deserializedLimitNode).toBeInstanceOf(LimitAndOffsetNode);
  });

  test('handles removing one input from multi-input node', () => {
    const tableNode1 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const tableNode2 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const tableNode3 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const unionNode = new UnionNode(
      {
        inputNodes: [tableNode1, tableNode2, tableNode3],
        selectedColumns: [
          {
            name: 'name',
            checked: true,
          },
        ],
      },
      {},
    );
    tableNode1.nextNodes.push(unionNode);
    tableNode2.nextNodes.push(unionNode);
    tableNode3.nextNodes.push(unionNode);

    // Remove the second table from union
    removeConnection(tableNode2, unionNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode1, tableNode2, tableNode3],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    // Find union node
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    const deserializedUnionNode = deserializedTableNode1
      .nextNodes[0] as UnionNode;

    // Union should only have 2 inputs now
    expect(deserializedUnionNode.secondaryInputs.connections.size).toBe(2);

    // tableNode2 should have no nextNodes
    const deserializedTableNode2 = deserializedState.rootNodes[1];
    expect(deserializedTableNode2.nextNodes.length).toBe(0);
  });

  test('handles removing all inputs from multi-input node', () => {
    const tableNode1 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const tableNode2 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const unionNode = new UnionNode(
      {
        inputNodes: [tableNode1, tableNode2],
        selectedColumns: [
          {
            name: 'name',
            checked: true,
          },
        ],
      },
      {},
    );
    tableNode1.nextNodes.push(unionNode);
    tableNode2.nextNodes.push(unionNode);

    // Remove all inputs
    removeConnection(tableNode1, unionNode);
    removeConnection(tableNode2, unionNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode1, tableNode2, unionNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    // Union should be a root node now with no inputs
    expect(deserializedState.rootNodes.length).toBe(3);

    // Find the union node (should be the third root)
    const deserializedUnionNode = deserializedState.rootNodes.find(
      (n) => n.type === NodeType.kUnion,
    ) as UnionNode;
    expect(deserializedUnionNode).toBeDefined();
    expect(deserializedUnionNode.secondaryInputs.connections.size).toBe(0);
  });

  test('handles graph with multiple disconnected subgraphs', () => {
    // First subgraph: table1 -> filter
    const tableNode1 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const filterNode = new FilterNode(
      {
        filters: [{column: 'name', op: '=', value: 'foo'}],
      },
      {},
    );
    addConnection(tableNode1, filterNode);

    // Second subgraph: table2 -> sort -> limit
    const tableNode2 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const sortNode = new SortNode(
      {
        sortCriteria: [{colName: 'ts', direction: 'ASC'}],
      },
      {},
    );
    addConnection(tableNode2, sortNode);
    const limitNode = new LimitAndOffsetNode({limit: 100, offset: 0}, {});
    addConnection(sortNode, limitNode);

    // Third subgraph: standalone slices source
    const slicesNode = new SlicesSourceNode({}, {});

    const initialState: DataExplorerState = {
      rootNodes: [tableNode1, tableNode2, slicesNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(3);

    // Verify first subgraph
    const deserializedTableNode1 = deserializedState.rootNodes[0];
    expect(deserializedTableNode1.nextNodes.length).toBe(1);
    expect(deserializedTableNode1.nextNodes[0]).toBeInstanceOf(FilterNode);

    // Verify second subgraph
    const deserializedTableNode2 = deserializedState.rootNodes[1];
    expect(deserializedTableNode2.nextNodes.length).toBe(1);
    const deserializedSortNode = deserializedTableNode2.nextNodes[0];
    expect(deserializedSortNode).toBeInstanceOf(SortNode);
    expect(deserializedSortNode.nextNodes.length).toBe(1);
    expect(deserializedSortNode.nextNodes[0]).toBeInstanceOf(
      LimitAndOffsetNode,
    );

    // Verify third subgraph
    const deserializedSlicesNode = deserializedState.rootNodes[2];
    expect(deserializedSlicesNode).toBeInstanceOf(SlicesSourceNode);
    expect(deserializedSlicesNode.nextNodes.length).toBe(0);
  });

  test('serializes and deserializes time range source node', () => {
    const timeRangeNode = new TimeRangeSourceNode(
      {start: '1000', end: '2000'},
      {trace},
    );

    const initialState: DataExplorerState = {
      rootNodes: [timeRangeNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.rootNodes.length).toBe(1);
    const deserializedNode = deserializedState
      .rootNodes[0] as TimeRangeSourceNode;
    expect(deserializedNode).toBeInstanceOf(TimeRangeSourceNode);
    expect(deserializedNode.start).toEqual(Time.fromRaw(1000n));
    expect(deserializedNode.end).toEqual(Time.fromRaw(2000n));
  });

  test('serializes and deserializes labels', () => {
    const sliceNode = new SlicesSourceNode({}, {});
    const labels = [
      {
        id: 'label-1',
        x: 100,
        y: 200,
        width: 300,
        text: 'First label',
      },
      {
        id: 'label-2',
        x: 400,
        y: 500,
        width: 250,
        text: 'Second label',
      },
    ];

    const initialState: DataExplorerState = {
      rootNodes: [sliceNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels,
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    // Labels are normalized so top-left is at (minX, minY)
    // Original: label-1 (100, 200), label-2 (400, 500)
    // After normalization: label-1 (0, 0), label-2 (300, 300)
    expect(deserializedState.labels).toEqual([
      {
        id: 'label-1',
        x: 0,
        y: 0,
        width: 300,
        text: 'First label',
      },
      {
        id: 'label-2',
        x: 300,
        y: 300,
        width: 250,
        text: 'Second label',
      },
    ]);
  });

  test('handles state without labels', () => {
    const sliceNode = new SlicesSourceNode({}, {});
    const initialState: DataExplorerState = {
      rootNodes: [sliceNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.labels).toEqual([]);
  });

  test('handles empty labels array', () => {
    const sliceNode = new SlicesSourceNode({}, {});
    const initialState: DataExplorerState = {
      rootNodes: [sliceNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.labels).toEqual([]);
  });

  test('labels survive JSON round-trip with special characters', () => {
    const sliceNode = new SlicesSourceNode({}, {});
    const labels = [
      {
        id: 'label-1',
        x: 0,
        y: 0,
        width: 200,
        text: 'Label with "quotes" and \n newlines \t tabs',
      },
      {
        id: 'label-2',
        x: 0,
        y: 0,
        width: 200,
        text: 'Unicode: 你好 🎉',
      },
    ];

    const initialState: DataExplorerState = {
      rootNodes: [sliceNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels,
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    expect(deserializedState.labels).toEqual(labels);
  });

  test('normalizes coordinates so top-left corner starts at minimum coordinates', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const sliceNode = new SlicesSourceNode({}, {});

    const initialState: DataExplorerState = {
      rootNodes: [tableNode, sliceNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map([
        [tableNode.nodeId, {x: 500, y: 300}],
        [sliceNode.nodeId, {x: 800, y: 600}],
      ]),
      labels: [
        {
          id: 'label-1',
          x: 400,
          y: 250,
          width: 100,
          text: 'Test label',
        },
      ],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    // After normalization, the minimum coordinates (400, 250) become (0, 0)
    // All other coordinates are shifted accordingly
    const tableNodeId = deserializedState.rootNodes[0].nodeId;
    const sliceNodeId = deserializedState.rootNodes[1].nodeId;

    expect(deserializedState.nodeLayouts.get(tableNodeId)).toEqual({
      x: 100,
      y: 50,
    }); // (500-400, 300-250)
    expect(deserializedState.nodeLayouts.get(sliceNodeId)).toEqual({
      x: 400,
      y: 350,
    }); // (800-400, 600-250)
    expect(deserializedState.labels[0]).toEqual({
      id: 'label-1',
      x: 0,
      y: 0,
      width: 100,
      text: 'Test label',
    }); // (400-400, 250-250)
  });

  test('does not modify coordinates when already normalized at (0, 0)', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map([[tableNode.nodeId, {x: 0, y: 0}]]),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserializedState = deserializeState(json, trace, sqlModules);

    const tableNodeId = deserializedState.rootNodes[0].nodeId;
    expect(deserializedState.nodeLayouts.get(tableNodeId)).toEqual({
      x: 0,
      y: 0,
    });
  });

  test('serializeState normalizes coordinates in the exported JSON', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const sliceNode = new SlicesSourceNode({}, {});

    // State with non-zero minimum coordinates
    const initialState: DataExplorerState = {
      rootNodes: [tableNode, sliceNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map([
        [tableNode.nodeId, {x: 500, y: 300}],
        [sliceNode.nodeId, {x: 800, y: 600}],
      ]),
      labels: [{id: 'label-1', x: 400, y: 250, width: 100, text: 'Test label'}],
    };

    const json = serializeState(initialState);
    const parsed = JSON.parse(json);

    // The exported JSON should have normalized coordinates
    // Minimum is (400, 250) from the label, so all coordinates shift by that
    expect(parsed.nodeLayouts[tableNode.nodeId]).toEqual({
      x: 100,
      y: 50,
    }); // (500-400, 300-250)
    expect(parsed.nodeLayouts[sliceNode.nodeId]).toEqual({
      x: 400,
      y: 350,
    }); // (800-400, 600-250)
    expect(parsed.labels[0]).toEqual({
      id: 'label-1',
      x: 0,
      y: 0,
      width: 100,
      text: 'Test label',
    }); // (400-400, 250-250)
  });

  test('serializes and deserializes a group node', () => {
    // Build a chain: tableNode → modifyNode, then group them.
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const modifyNode = new ModifyColumnsNode({selectedColumns: []}, {});
    addConnection(tableNode, modifyNode);

    // Create the group
    const group = unwrapResult(
      createGroupFromSelection(new Set([tableNode.nodeId, modifyNode.nodeId]), [
        tableNode,
        modifyNode,
      ]),
    );
    applyGroupRewiring(group);

    group.attrs.name = 'My Test Group';

    // rootNodes includes only the group (inner nodes are discovered
    // via GroupNode.innerNodes traversal inside getAllNodes).
    const initialState: DataExplorerState = {
      rootNodes: [group],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserialized = deserializeState(json, trace, sqlModules);

    // Find the deserialized group node
    const allNodes = getAllNodes(deserialized.rootNodes);
    const deserializedGroup = allNodes.find((n) => n.type === NodeType.kGroup);
    expect(deserializedGroup).toBeDefined();
    expect(deserializedGroup).toBeInstanceOf(GroupNode);

    if (!(deserializedGroup instanceof GroupNode)) return;
    expect(deserializedGroup.attrs.name).toBe('My Test Group');
    expect(deserializedGroup.innerNodes).toHaveLength(2);
    expect(deserializedGroup.endNode).toBeDefined();

    // Inner nodes should be accessible through the group
    for (const inner of deserializedGroup.innerNodes) {
      expect(inner.nodeId).toBeDefined();
    }
  });

  test('group node serialization preserves inner connections', () => {
    // Build: tableNode → modifyNode → filterNode, group modify+filter
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const modifyNode = new ModifyColumnsNode({selectedColumns: []}, {});
    const filterNode = new FilterNode({filters: []}, {});
    addConnection(tableNode, modifyNode);
    addConnection(modifyNode, filterNode);

    // Group modify+filter (tableNode is external)
    const group = unwrapResult(
      createGroupFromSelection(
        new Set([modifyNode.nodeId, filterNode.nodeId]),
        [tableNode, modifyNode, filterNode],
      ),
    );
    applyGroupRewiring(group);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode, group],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserialized = deserializeState(json, trace, sqlModules);

    const allNodes = getAllNodes(deserialized.rootNodes);
    const deserializedGroup = allNodes.find((n) => n.type === NodeType.kGroup);

    expect(deserializedGroup).toBeDefined();
    expect(deserializedGroup).toBeInstanceOf(GroupNode);
    if (!(deserializedGroup instanceof GroupNode)) return;

    // Group should have secondary inputs from tableNode (external source)
    expect(deserializedGroup.secondaryInputs.connections.size).toBeGreaterThan(
      0,
    );

    // End node should be defined after deserialization
    expect(deserializedGroup.endNode).toBeDefined();

    // Inner nodes should be modification nodes, not source nodes
    expect(deserializedGroup.innerNodes).toHaveLength(2);
  });

  // =========================================================================
  // Tests for graph-level connection serialization
  // =========================================================================

  test('serializes primaryInput at graph level, not in node state', () => {
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const filterNode = new FilterNode(
      {
        filters: [{column: 'name', op: '=', value: 'test'}],
      },
      {},
    );
    addConnection(tableNode, filterNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const parsed = JSON.parse(json);

    const serializedFilter = parsed.nodes.find(
      (n: SerializedNode) => n.nodeId === filterNode.nodeId,
    );

    expect(serializedFilter.primaryInputId).toBe(tableNode.nodeId);
    expect(serializedFilter.state.primaryInputId).toBeUndefined();
  });

  test('serializes secondaryInputs at graph level, not in node state', () => {
    const tableNode1 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const tableNode2 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const intervalNode = new IntervalIntersectNode(
      {
        inputNodes: [tableNode1, tableNode2],
      },
      {},
    );
    tableNode1.nextNodes.push(intervalNode);
    tableNode2.nextNodes.push(intervalNode);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode1, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const parsed = JSON.parse(json);

    const serializedInterval = parsed.nodes.find(
      (n: SerializedNode) => n.nodeId === intervalNode.nodeId,
    );

    expect(serializedInterval.secondaryInputIds).toEqual({
      '0': tableNode1.nodeId,
      '1': tableNode2.nodeId,
    });
    expect(serializedInterval.state.intervalNodes).toBeUndefined();
  });

  test('deserializes connections from graph-level fields', () => {
    // Build a graph: table -> filter (primaryInput) and table1, table2 -> union (secondaryInputs)
    const tableNode = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const filterNode = new FilterNode(
      {
        filters: [{column: 'name', op: '=', value: 'x'}],
      },
      {},
    );
    addConnection(tableNode, filterNode);

    const tableNode2 = new TableSourceNode(
      {sqlTable: 'slice'},
      {trace, sqlModules},
    );
    const unionNode = new UnionNode(
      {
        inputNodes: [],
        selectedColumns: [],
      },
      {},
    );
    tableNode.nextNodes.push(unionNode);
    tableNode2.nextNodes.push(unionNode);
    unionNode.secondaryInputs.connections.set(0, tableNode);
    unionNode.secondaryInputs.connections.set(1, tableNode2);

    const initialState: DataExplorerState = {
      rootNodes: [tableNode, tableNode2],
      selectedNodes: new Set(),
      nodeLayouts: new Map(),
      labels: [],
    };

    const json = serializeState(initialState);
    const deserialized = deserializeState(json, trace, sqlModules);

    const allNodes = new Map<string, QueryNode>();
    const queue = [...deserialized.rootNodes];
    while (queue.length > 0) {
      const node = queue.shift()!;
      if (allNodes.has(node.nodeId)) continue;
      allNodes.set(node.nodeId, node);
      queue.push(...node.nextNodes);
    }

    const deserializedUnion = [...allNodes.values()].find(
      (n) => n.type === NodeType.kUnion,
    ) as UnionNode;
    expect(deserializedUnion).toBeDefined();
    expect(deserializedUnion.secondaryInputs.connections.size).toBe(2);

    const deserializedFilter = [...allNodes.values()].find(
      (n) => n.type === NodeType.kFilter,
    ) as FilterNode;
    expect(deserializedFilter).toBeDefined();
    expect(deserializedFilter.primaryInput).toBeDefined();
  });

  describe('legacy string type migration', () => {
    function deserializeRaw(serializedGraph: object): DataExplorerState {
      return deserializeState(
        JSON.stringify(serializedGraph),
        trace,
        sqlModules,
      );
    }

    it('migrates legacy columnName field in JoinNode columns, preserving checked state', () => {
      // columnName→name migration matters because buildDescriptors() looks up
      // existing columns by name to preserve user customizations (checked, alias).
      // Without migration, the lookup fails and checked resets to false.
      //
      // The test uses connected TableSourceNodes (slice table: name, ts, dur)
      // so that postDeserializeLate/updateColumnArrays() can actually run.
      const graph = {
        nodes: [
          {
            nodeId: 'src1',
            type: NodeType.kTable,
            nextNodes: ['join1'],
            state: {sqlTable: 'slice'},
          },
          {
            nodeId: 'src2',
            type: NodeType.kTable,
            nextNodes: ['join1'],
            state: {sqlTable: 'slice'},
          },
          {
            nodeId: 'join1',
            type: NodeType.kJoin,
            nextNodes: [],
            secondaryInputIds: {'0': 'src1', '1': 'src2'},
            state: {
              leftQueryAlias: 'left',
              rightQueryAlias: 'right',
              conditionType: 'equality',
              joinType: 'INNER',
              leftColumn: 'name',
              rightColumn: 'name',
              sqlExpression: '',
              // Legacy format: 'columnName' instead of 'name'. After migration,
              // buildDescriptors can match this to the 'name' column from src1
              // and preserve checked: true.
              leftColumns: [
                {columnName: 'name', type: {kind: 'string'}, checked: true},
              ],
              rightColumns: [],
            },
          },
        ],
        rootNodeIds: ['src1', 'src2'],
        nodeLayouts: {},
        labels: [],
      };
      const state = deserializeRaw(graph);
      const src1 = state.rootNodes[0];
      const joinNode = src1.nextNodes[0] as JoinNode;
      // After migration, 'name' column should be found and checked: true preserved.
      const nameCol = joinNode.attrs.leftColumns?.find(
        (c) => c.name === 'name',
      );
      expect(nameCol).toBeDefined();
      expect(nameCol?.checked).toBe(true);
    });

    it('applies JoinNode defaults for absent conditionType/joinType/column/sqlExpression fields', () => {
      const graph = {
        nodes: [
          {
            nodeId: 'join1',
            type: NodeType.kJoin,
            nextNodes: [],
            state: {
              leftQueryAlias: 'left',
              rightQueryAlias: 'right',
              // conditionType, joinType, leftColumn, rightColumn, sqlExpression
              // are all absent — must default to their canonical values.
            },
          },
        ],
        rootNodeIds: ['join1'],
        nodeLayouts: {},
        labels: [],
      };
      const state = deserializeRaw(graph);
      const joinNode = state.rootNodes[0] as JoinNode;
      expect(joinNode.attrs.conditionType).toBe('equality');
      expect(joinNode.attrs.joinType).toBe('INNER');
      expect(joinNode.attrs.leftColumn).toBe('');
      expect(joinNode.attrs.rightColumn).toBe('');
      expect(joinNode.attrs.sqlExpression).toBe('');
    });

    it('migrates legacy string types in ModifyColumnsNode selectedColumns', () => {
      const graph = {
        nodes: [
          {
            nodeId: 'mod1',
            type: NodeType.kModifyColumns,
            nextNodes: [],
            state: {
              selectedColumns: [
                {name: 'id', type: 'INT', checked: true},
                {name: 'name', type: 'STRING', checked: true},
                {name: 'ts', type: 'UNKNOWN_LEGACY', checked: false},
              ],
            },
          },
        ],
        rootNodeIds: ['mod1'],
        nodeLayouts: {},
        labels: [],
      };
      const state = deserializeRaw(graph);
      const modNode = state.rootNodes[0] as ModifyColumnsNode;
      expect(modNode.attrs.selectedColumns[0].type).toEqual(
        PerfettoSqlTypes.INT,
      );
      expect(modNode.attrs.selectedColumns[1].type).toEqual(
        PerfettoSqlTypes.STRING,
      );
      expect(modNode.attrs.selectedColumns[2].type).toBeUndefined();
    });

    it('migrates legacy string types in AggregationNode groupByColumns', () => {
      // groupByColumns flow directly into finalCols, so unmigrated string types
      // would crash downstream nodes (e.g. renderTypeSelector).
      const graph = {
        nodes: [
          {
            nodeId: 'agg1',
            type: NodeType.kAggregation,
            nextNodes: [],
            state: {
              groupByColumns: [
                {name: 'ts', type: 'TIMESTAMP', checked: true},
                {name: 'name', type: 'STRING', checked: true},
              ],
              aggregations: [],
            },
          },
        ],
        rootNodeIds: ['agg1'],
        nodeLayouts: {},
        labels: [],
      };
      const state = deserializeRaw(graph);
      const aggNode = state.rootNodes[0] as AggregationNode;
      expect(aggNode.attrs.groupByColumns[0].type).toEqual(
        PerfettoSqlTypes.TIMESTAMP,
      );
      expect(aggNode.attrs.groupByColumns[1].type).toEqual(
        PerfettoSqlTypes.STRING,
      );
    });

    it('migrates legacy string types in AggregationNode aggregations[].column.type', () => {
      // aggregations[].column.type flows into finalCols via getAggregationResultType
      // for SUM/MIN/MAX ops, so a string type there would crash renderTypeSelector.
      const graph = {
        nodes: [
          {
            nodeId: 'agg1',
            type: NodeType.kAggregation,
            nextNodes: [],
            state: {
              groupByColumns: [],
              aggregations: [
                {
                  column: {name: 'dur', type: 'DURATION'},
                  aggregationOp: 'SUM',
                  newColumnName: 'total_dur',
                },
              ],
            },
          },
        ],
        rootNodeIds: ['agg1'],
        nodeLayouts: {},
        labels: [],
      };
      const state = deserializeRaw(graph);
      const aggNode = state.rootNodes[0] as AggregationNode;
      expect(aggNode.attrs.aggregations[0].column?.type).toEqual(
        PerfettoSqlTypes.DURATION,
      );
    });

    it('migrates legacy string types in UnionNode selectedColumns', () => {
      // selectedColumns flow directly into finalCols, so unmigrated string types
      // would crash downstream nodes (e.g. renderTypeSelector).
      const graph = {
        nodes: [
          {
            nodeId: 'union1',
            type: NodeType.kUnion,
            nextNodes: [],
            state: {
              selectedColumns: [
                {name: 'id', type: 'INT', checked: true},
                {name: 'ts', type: 'TIMESTAMP', checked: true},
              ],
            },
          },
        ],
        rootNodeIds: ['union1'],
        nodeLayouts: {},
        labels: [],
      };
      const state = deserializeRaw(graph);
      const unionNode = state.rootNodes[0] as UnionNode;
      expect(unionNode.attrs.selectedColumns[0].type).toEqual(
        PerfettoSqlTypes.INT,
      );
      expect(unionNode.attrs.selectedColumns[1].type).toEqual(
        PerfettoSqlTypes.TIMESTAMP,
      );
    });

    it('migrates legacy string types in AddColumnsNode columnTypes', () => {
      const graph = {
        nodes: [
          {
            nodeId: 'add1',
            type: NodeType.kAddColumns,
            nextNodes: [],
            state: {
              sql: 'SELECT 1 AS val',
              columnTypes: {val: 'DOUBLE', count: 'INT'},
            },
          },
        ],
        rootNodeIds: ['add1'],
        nodeLayouts: {},
        labels: [],
      };
      const state = deserializeRaw(graph);
      const addNode = state.rootNodes[0] as AddColumnsNode;
      expect(addNode.attrs.columnTypes?.['val']).toEqual(
        PerfettoSqlTypes.DOUBLE,
      );
      expect(addNode.attrs.columnTypes?.['count']).toEqual(
        PerfettoSqlTypes.INT,
      );
    });
  });
});
