Add UI and data classes for CPU method table (#5303)

diff --git a/packages/devtools_app/lib/src/screens/profiler/cpu_profile_columns.dart b/packages/devtools_app/lib/src/screens/profiler/cpu_profile_columns.dart
index dab888c..d491808 100644
--- a/packages/devtools_app/lib/src/screens/profiler/cpu_profile_columns.dart
+++ b/packages/devtools_app/lib/src/screens/profiler/cpu_profile_columns.dart
@@ -17,6 +17,7 @@
 
 const timeColumnWidthPx = 180.0;
 
+// TODO(kenz): clean up to use TimeAndPercentageColumn.
 class SelfTimeColumn extends ColumnData<CpuStackFrame> {
   SelfTimeColumn({String? titleTooltip})
       : super(
@@ -50,6 +51,7 @@
   String getTooltip(CpuStackFrame dataObject) => '';
 }
 
+// TODO(kenz): clean up to use TimeAndPercentageColumn.
 class TotalTimeColumn extends ColumnData<CpuStackFrame> {
   TotalTimeColumn({String? titleTooltip})
       : super(
diff --git a/packages/devtools_app/lib/src/screens/profiler/cpu_profile_controller.dart b/packages/devtools_app/lib/src/screens/profiler/cpu_profile_controller.dart
index befaa29..f7d3d5f 100644
--- a/packages/devtools_app/lib/src/screens/profiler/cpu_profile_controller.dart
+++ b/packages/devtools_app/lib/src/screens/profiler/cpu_profile_controller.dart
@@ -19,6 +19,7 @@
 import 'cpu_profile_model.dart';
 import 'cpu_profile_service.dart';
 import 'cpu_profile_transformer.dart';
+import 'panes/method_table/method_table_controller.dart';
 
 enum CpuProfilerViewType {
   function,
@@ -89,6 +90,8 @@
   /// Store of cached CPU profiles for each isolate.
   final _cpuProfileStoreByIsolateId = <String, CpuProfileStore>{};
 
+  final methodTableController = MethodTableController();
+
   /// Notifies that new cpu profile data is available.
   ValueListenable<CpuProfileData?> get dataNotifier => _dataNotifier;
   final _dataNotifier = ValueNotifier<CpuProfileData?>(baseStateCpuProfileData);
@@ -528,6 +531,7 @@
     _userTagFilter.value = userTagNone;
     transformer.reset();
     cpuProfileStore.clear();
+    methodTableController.reset();
     resetSearch();
   }
 
diff --git a/packages/devtools_app/lib/src/screens/profiler/cpu_profiler.dart b/packages/devtools_app/lib/src/screens/profiler/cpu_profiler.dart
index 0e37561..094f34e 100644
--- a/packages/devtools_app/lib/src/screens/profiler/cpu_profiler.dart
+++ b/packages/devtools_app/lib/src/screens/profiler/cpu_profiler.dart
@@ -25,7 +25,7 @@
 import 'panes/bottom_up.dart';
 import 'panes/call_tree.dart';
 import 'panes/cpu_flame_chart.dart';
-import 'panes/method_table.dart';
+import 'panes/method_table/method_table.dart';
 
 // TODO(kenz): provide useful UI upon selecting a CPU stack frame.
 
@@ -321,8 +321,10 @@
         },
       ),
     );
-    const methodTable = KeepAliveWrapper(
-      child: CpuMethodTable(),
+    final methodTable = KeepAliveWrapper(
+      child: CpuMethodTable(
+        methodTableController: widget.controller.methodTableController,
+      ),
     );
     final cpuFlameChart = KeepAliveWrapper(
       child: LayoutBuilder(
diff --git a/packages/devtools_app/lib/src/screens/profiler/panes/method_table.dart b/packages/devtools_app/lib/src/screens/profiler/panes/method_table.dart
deleted file mode 100644
index 57c3d6a..0000000
--- a/packages/devtools_app/lib/src/screens/profiler/panes/method_table.dart
+++ /dev/null
@@ -1,16 +0,0 @@
-// Copyright 2023 The Chromium Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-import 'package:flutter/material.dart';
-
-class CpuMethodTable extends StatelessWidget {
-  const CpuMethodTable({super.key});
-
-  @override
-  Widget build(BuildContext context) {
-    return const Center(
-      child: Text('TODO implement Method Table'),
-    );
-  }
-}
diff --git a/packages/devtools_app/lib/src/screens/profiler/panes/method_table/method_table.dart b/packages/devtools_app/lib/src/screens/profiler/panes/method_table/method_table.dart
new file mode 100644
index 0000000..af3ce52
--- /dev/null
+++ b/packages/devtools_app/lib/src/screens/profiler/panes/method_table/method_table.dart
@@ -0,0 +1,292 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+
+import '../../../../shared/common_widgets.dart';
+import '../../../../shared/primitives/auto_dispose.dart';
+import '../../../../shared/primitives/utils.dart';
+import '../../../../shared/split.dart';
+import '../../../../shared/table/table.dart';
+import '../../../../shared/table/table_data.dart';
+import '../../../../shared/theme.dart';
+import 'method_table_controller.dart';
+import 'method_table_model.dart';
+
+/// Widget that displays a method table for a CPU profile.
+class CpuMethodTable extends StatelessWidget {
+  const CpuMethodTable({required this.methodTableController});
+
+  final MethodTableController methodTableController;
+
+  @override
+  Widget build(BuildContext context) {
+    return ValueListenableBuilder<List<MethodTableGraphNode>>(
+      valueListenable: methodTableController.methods,
+      builder: (context, methods, _) {
+        return Split(
+          axis: Axis.horizontal,
+          initialFractions: const [0.5, 0.5],
+          children: [
+            _MethodTable(methodTableController, methods),
+            _MethodGraph(methodTableController),
+          ],
+        );
+      },
+    );
+  }
+}
+
+/// A table of methods and their timing information for a CPU profile.
+class _MethodTable extends StatelessWidget {
+  const _MethodTable(this._methodTableController, this._methods);
+
+  static final methodColumn = _MethodColumn();
+  static final selfTimeColumn = _SelfTimeColumn();
+  static final totalTimeColumn = _TotalTimeColumn();
+  static final columns = List<ColumnData<MethodTableGraphNode>>.unmodifiable([
+    selfTimeColumn,
+    totalTimeColumn,
+    methodColumn,
+  ]);
+
+  final MethodTableController _methodTableController;
+
+  final List<MethodTableGraphNode> _methods;
+
+  @override
+  Widget build(BuildContext context) {
+    return OutlineDecoration.onlyRight(
+      child: FlatTable<MethodTableGraphNode>(
+        keyFactory: (node) => ValueKey(node.id),
+        data: _methods,
+        dataKey: 'cpu-profile-methods',
+        columns: columns,
+        defaultSortColumn: selfTimeColumn,
+        defaultSortDirection: SortDirection.descending,
+        selectionNotifier: _methodTableController.selectedNode,
+      ),
+    );
+  }
+}
+
+/// A graph for a single method that shows its predecessors (callers) and
+/// successors (callees) as well as timing information for each of those nodes.
+class _MethodGraph extends StatefulWidget {
+  const _MethodGraph(this.methodTableController);
+
+  final MethodTableController methodTableController;
+
+  @override
+  State<_MethodGraph> createState() => _MethodGraphState();
+}
+
+class _MethodGraphState extends State<_MethodGraph> with AutoDisposeMixin {
+  MethodTableGraphNode? _selectedGraphNode;
+
+  List<MethodTableGraphNode> _callers = [];
+
+  List<MethodTableGraphNode> _callees = [];
+
+  @override
+  void initState() {
+    super.initState();
+
+    _selectedGraphNode = widget.methodTableController.selectedNode.value;
+    addAutoDisposeListener(widget.methodTableController.selectedNode, () {
+      setState(() {
+        _selectedGraphNode = widget.methodTableController.selectedNode.value;
+        _callers = _selectedGraphNode!.predecessors
+            .cast<MethodTableGraphNode>()
+            .toList();
+        _callees = _selectedGraphNode!.successors
+            .cast<MethodTableGraphNode>()
+            .toList();
+      });
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    if (_selectedGraphNode == null) {
+      return OutlineDecoration.onlyLeft(
+        child: const Center(
+          child: Text('Select a method to view its call graph.'),
+        ),
+      );
+    }
+    return OutlineDecoration.onlyLeft(
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Flexible(
+            child: OutlineDecoration.onlyBottom(
+              child: _CallersTable(
+                widget.methodTableController,
+                _callers,
+              ),
+            ),
+          ),
+          Padding(
+            padding: const EdgeInsets.all(denseSpacing),
+            child: Row(
+              children: [
+                Text(_selectedGraphNode!.display),
+              ],
+            ),
+          ),
+          Flexible(
+            child: OutlineDecoration.onlyTop(
+              child: _CalleesTable(
+                widget.methodTableController,
+                _callees,
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+/// A table of predecessors (callers) for a single method in a method table.
+class _CallersTable extends StatelessWidget {
+  _CallersTable(this._methodTableController, this._callers) {
+    _callerTimeColumn =
+        _CallerTimeColumn(methodTableController: _methodTableController);
+    columns = List<ColumnData<MethodTableGraphNode>>.unmodifiable([
+      _callerTimeColumn,
+      methodColumn,
+    ]);
+  }
+
+  static final methodColumn = _MethodColumn();
+
+  final MethodTableController _methodTableController;
+
+  final List<MethodTableGraphNode> _callers;
+
+  late final List<ColumnData<MethodTableGraphNode>> columns;
+
+  late final _CallerTimeColumn _callerTimeColumn;
+
+  @override
+  Widget build(BuildContext context) {
+    return FlatTable<MethodTableGraphNode>(
+      keyFactory: (node) => ValueKey(node.id),
+      data: _callers,
+      dataKey: 'cpu-profile-method-callers',
+      columns: columns,
+      defaultSortColumn: _callerTimeColumn,
+      defaultSortDirection: SortDirection.descending,
+      selectionNotifier: _methodTableController.selectedNode,
+    );
+  }
+}
+
+/// A table of successors (callees) for a single method in a method table.
+class _CalleesTable extends StatelessWidget {
+  _CalleesTable(this._methodTableController, this._callees) {
+    _calleeTimeColumn =
+        _CalleeTimeColumn(methodTableController: _methodTableController);
+    _columns = List<ColumnData<MethodTableGraphNode>>.unmodifiable([
+      _calleeTimeColumn,
+      _methodColumn,
+    ]);
+  }
+
+  static final _methodColumn = _MethodColumn();
+
+  final MethodTableController _methodTableController;
+
+  final List<MethodTableGraphNode> _callees;
+
+  late final List<ColumnData<MethodTableGraphNode>> _columns;
+
+  late final _CalleeTimeColumn _calleeTimeColumn;
+
+  @override
+  Widget build(BuildContext context) {
+    return FlatTable<MethodTableGraphNode>(
+      keyFactory: (node) => ValueKey(node.id),
+      data: _callees,
+      dataKey: 'cpu-profile-method-callees',
+      columns: _columns,
+      defaultSortColumn: _calleeTimeColumn,
+      defaultSortDirection: SortDirection.descending,
+      selectionNotifier: _methodTableController.selectedNode,
+    );
+  }
+}
+
+class _MethodColumn extends ColumnData<MethodTableGraphNode> {
+  _MethodColumn() : super.wide('Method');
+
+  static const _separator = ' - ';
+
+  @override
+  bool get supportsSorting => true;
+
+  @override
+  String getValue(MethodTableGraphNode dataObject) => dataObject.name;
+
+  @override
+  String getTooltip(MethodTableGraphNode dataObject) {
+    return '${dataObject.name}$_separator(${dataObject.packageUri})';
+  }
+}
+
+class _SelfTimeColumn extends TimeAndPercentageColumn<MethodTableGraphNode> {
+  _SelfTimeColumn({String? titleTooltip})
+      : super(
+          title: 'Self Time',
+          titleTooltip: titleTooltip,
+          timeProvider: (node) => node.selfTime,
+          percentAsDoubleProvider: (node) => node.selfTimeRatio,
+          secondaryCompare: (node) => node.name,
+        );
+}
+
+class _TotalTimeColumn extends TimeAndPercentageColumn<MethodTableGraphNode> {
+  _TotalTimeColumn({String? titleTooltip})
+      : super(
+          title: 'Total Time',
+          titleTooltip: titleTooltip,
+          timeProvider: (node) => node.totalTime,
+          percentAsDoubleProvider: (node) => node.totalTimeRatio,
+          secondaryCompare: (node) => node.name,
+        );
+}
+
+const _percentageOnlyWidth = 100.0;
+
+class _CallerTimeColumn extends TimeAndPercentageColumn<MethodTableGraphNode> {
+  _CallerTimeColumn({
+    required MethodTableController methodTableController,
+    String? titleTooltip,
+  }) : super(
+          title: 'Caller Time',
+          titleTooltip: titleTooltip,
+          percentageOnly: true,
+          percentAsDoubleProvider: (node) =>
+              methodTableController.callerPercentageFor(node),
+          secondaryCompare: (node) => node.name,
+          columnWidth: _percentageOnlyWidth,
+        );
+}
+
+class _CalleeTimeColumn extends TimeAndPercentageColumn<MethodTableGraphNode> {
+  _CalleeTimeColumn({
+    required MethodTableController methodTableController,
+    String? titleTooltip,
+  }) : super(
+          title: 'Callee Time',
+          titleTooltip: titleTooltip,
+          percentageOnly: true,
+          percentAsDoubleProvider: (node) =>
+              methodTableController.calleePercentageFor(node),
+          secondaryCompare: (node) => node.name,
+          columnWidth: _percentageOnlyWidth,
+        );
+}
diff --git a/packages/devtools_app/lib/src/screens/profiler/panes/method_table/method_table_controller.dart b/packages/devtools_app/lib/src/screens/profiler/panes/method_table/method_table_controller.dart
new file mode 100644
index 0000000..9477081
--- /dev/null
+++ b/packages/devtools_app/lib/src/screens/profiler/panes/method_table/method_table_controller.dart
@@ -0,0 +1,35 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/foundation.dart';
+
+import '../../cpu_profile_model.dart';
+import 'method_table_model.dart';
+
+/// Controller for the CPU profile method table.
+///
+/// This controller is responsible for managing state of the Method table UI
+/// and for providing utility methods to interact with the active data.
+class MethodTableController {
+  final selectedNode = ValueNotifier<MethodTableGraphNode?>(null);
+
+  final methods = ValueNotifier<List<MethodTableGraphNode>>([]);
+
+  void createMethodTableForProfile(CpuProfileData cpuProfileData) {
+    // TODO(kenz): generate the method table graph.
+  }
+
+  double callerPercentageFor(MethodTableGraphNode node) {
+    return selectedNode.value?.predecessorEdgePercentage(node) ?? 0.0;
+  }
+
+  double calleePercentageFor(MethodTableGraphNode node) {
+    return selectedNode.value?.successorEdgePercentage(node) ?? 0.0;
+  }
+
+  void reset() {
+    selectedNode.value = null;
+    methods.value = <MethodTableGraphNode>[];
+  }
+}
diff --git a/packages/devtools_app/lib/src/screens/profiler/panes/method_table/method_table_model.dart b/packages/devtools_app/lib/src/screens/profiler/panes/method_table/method_table_model.dart
new file mode 100644
index 0000000..1cfa3e6
--- /dev/null
+++ b/packages/devtools_app/lib/src/screens/profiler/panes/method_table/method_table_model.dart
@@ -0,0 +1,74 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import '../../../../shared/primitives/graph.dart';
+import '../../../../shared/primitives/utils.dart';
+import '../../../../shared/profiler_utils.dart';
+import '../../cpu_profile_model.dart';
+
+/// Represents a graph node for a method in a CPU profile method table.
+class MethodTableGraphNode extends GraphNode {
+  MethodTableGraphNode({
+    required this.name,
+    required this.packageUri,
+    required this.profileMetaData,
+  });
+
+  factory MethodTableGraphNode.fromStackFrame(CpuStackFrame frame) {
+    return MethodTableGraphNode(
+      name: frame.name,
+      packageUri: frame.packageUri,
+      profileMetaData: frame.profileMetaData,
+    );
+  }
+
+  final String name;
+
+  final String packageUri;
+
+  final ProfileMetaData profileMetaData;
+
+  String get id => '$name-$packageUri';
+
+  String get display => '$name ($packageUri)';
+
+  // TODO(kenz): implement the calculation for exclusive and inclusive count.
+
+  /// The number of cpu samples where this frame is on top of the stack.
+  ///
+  /// This count is used to calculate self time.
+  int exclusiveSampleCount = 0;
+
+  /// The number of cpu samples where this frame is anywhere on the stack.
+  ///
+  /// This count is used to calculate total time.
+  int inclusiveSampleCount = 0;
+
+  late double totalTimeRatio =
+      safeDivide(inclusiveSampleCount, profileMetaData.sampleCount);
+
+  late Duration totalTime = Duration(
+    microseconds:
+        (totalTimeRatio * profileMetaData.time!.duration.inMicroseconds)
+            .round(),
+  );
+
+  late double selfTimeRatio =
+      safeDivide(exclusiveSampleCount, profileMetaData.sampleCount);
+
+  late Duration selfTime = Duration(
+    microseconds:
+        (selfTimeRatio * profileMetaData.time!.duration.inMicroseconds).round(),
+  );
+
+  double get inclusiveSampleRatio => safeDivide(
+        inclusiveSampleCount,
+        profileMetaData.sampleCount,
+      );
+
+  double get exclusiveSampleRatio => safeDivide(
+        exclusiveSampleCount,
+        profileMetaData.sampleCount,
+      );
+}
diff --git a/packages/devtools_app/lib/src/shared/primitives/graph.dart b/packages/devtools_app/lib/src/shared/primitives/graph.dart
new file mode 100644
index 0000000..c938ebc
--- /dev/null
+++ b/packages/devtools_app/lib/src/shared/primitives/graph.dart
@@ -0,0 +1,81 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:collection/collection.dart';
+
+/// A dart object that represents a graph.
+///
+/// Each [GraphNode] has a set of [predecessors] and [successors], which
+/// represent incoming and outgoing edges, respectively. A [GraphNode] in
+/// [predecessors] or [successors] may have multiple edges to this [GraphNode].
+///
+/// For each predecessor [GraphNode] in [predecessors], the edge count to this
+/// [GraphNode] from the predecessor is stored in [predecessorEdgeCounts].
+///
+/// For each successor [GraphNode] in [successors], the edge count from this
+/// [GraphNode] to the successor is stored in [successorEdgeCounts].
+class GraphNode {
+  /// Predecessors of this node.
+  final predecessors = <GraphNode>{};
+
+  /// Successors of this node.
+  final successors = <GraphNode>{};
+
+  /// Maps predecessor [GraphNode]s from [predecessors] to the number of
+  /// outgoing edges going to [this] node.
+  ///
+  /// For example:
+  ///        A (predecessor node)
+  ///      /  \
+  ///     |    |
+  ///      \  /
+  ///       B (this node)
+  ///
+  /// ==> successorEdgeCounts[A] = 2
+  final predecessorEdgeCounts = <GraphNode, int>{};
+
+  /// Maps successor [GraphNode]s from [successors] to the number of incoming
+  /// edges coming from [this] node.
+  ///
+  /// For example:
+  ///        A (this node)
+  ///      /  \
+  ///     |    |
+  ///      \  /
+  ///       B (successor node)
+  ///
+  /// ==> successorEdgeCounts[B] = 2
+  final successorEdgeCounts = <GraphNode, int>{};
+
+  /// Returns the percentage of this node's predecessor edges that connect to
+  /// [node].
+  double predecessorEdgePercentage(GraphNode node) {
+    if (predecessorEdgeCounts.keys.contains(node)) {
+      final totalEdgeCount = predecessorEdgeCounts.values.sum;
+      return predecessorEdgeCounts[node]! / totalEdgeCount;
+    }
+    return 0.0;
+  }
+
+  /// Returns the percentage of this node's sucessor edges that connect to
+  /// [node].
+  double successorEdgePercentage(GraphNode node) {
+    if (successorEdgeCounts.keys.contains(node)) {
+      final totalEdgeCount = successorEdgeCounts.values.sum;
+      return successorEdgeCounts[node]! / totalEdgeCount;
+    }
+    return 0.0;
+  }
+
+  /// Create outgoing edge from [this] node to the given node [n].
+  void outgoingEdge(GraphNode n, {int edgeCount = 1}) {
+    n.predecessors.add(this);
+    final predEdgeCount = n.predecessorEdgeCounts[this] ?? 0;
+    n.predecessorEdgeCounts[this] = predEdgeCount + edgeCount;
+
+    successors.add(n);
+    final succEdgeCount = successorEdgeCounts[n] ?? 0;
+    successorEdgeCounts[n] = succEdgeCount + edgeCount;
+  }
+}
diff --git a/packages/devtools_app/lib/src/shared/table/table_data.dart b/packages/devtools_app/lib/src/shared/table/table_data.dart
index bb88e17..bb2d98d 100644
--- a/packages/devtools_app/lib/src/shared/table/table_data.dart
+++ b/packages/devtools_app/lib/src/shared/table/table_data.dart
@@ -160,3 +160,66 @@
     }
   }
 }
+
+/// Column that, for each row, shows a time value in milliseconds and the
+/// percentage that the time value is of the total time for this data set.
+///
+/// Both time and percentage are provided through callbacks [timeProvider] and
+/// [percentAsDoubleProvider], respectively.
+///
+/// When [percentageOnly] is true, the time value will be omitted, and only the
+/// percentage will be displayed.
+abstract class TimeAndPercentageColumn<T> extends ColumnData<T> {
+  TimeAndPercentageColumn({
+    required String title,
+    required this.percentAsDoubleProvider,
+    this.timeProvider,
+    this.secondaryCompare,
+    this.percentageOnly = false,
+    double columnWidth = _defaultTimeColumnWidth,
+    super.titleTooltip,
+  })  : assert(percentageOnly == (timeProvider == null)),
+        super(
+          title,
+          fixedWidthPx: scaleByFontFactor(columnWidth),
+        );
+
+  static const _defaultTimeColumnWidth = 180.0;
+
+  Duration Function(T)? timeProvider;
+
+  double Function(T) percentAsDoubleProvider;
+
+  Comparable Function(T)? secondaryCompare;
+
+  final bool percentageOnly;
+
+  @override
+  bool get numeric => true;
+
+  @override
+  int compare(T a, T b) {
+    final int result = super.compare(a, b);
+    if (result == 0 && secondaryCompare != null) {
+      return secondaryCompare!(a).compareTo(secondaryCompare!(b));
+    }
+    return result;
+  }
+
+  @override
+  double getValue(T dataObject) => percentageOnly
+      ? percentAsDoubleProvider(dataObject)
+      : timeProvider!(dataObject).inMicroseconds.toDouble();
+
+  @override
+  String getDisplayValue(T dataObject) {
+    final percentDisplay = '${percent2(percentAsDoubleProvider(dataObject))}';
+    if (percentageOnly) {
+      return percentDisplay;
+    }
+    return '${msText(timeProvider!(dataObject), fractionDigits: 2)} ($percentDisplay)';
+  }
+
+  @override
+  String getTooltip(T dataObject) => '';
+}
diff --git a/packages/devtools_app/test/cpu_profiler/cpu_profiler_test.dart b/packages/devtools_app/test/cpu_profiler/cpu_profiler_test.dart
index 00c4cb5..ed8e607 100644
--- a/packages/devtools_app/test/cpu_profiler/cpu_profiler_test.dart
+++ b/packages/devtools_app/test/cpu_profiler/cpu_profiler_test.dart
@@ -9,7 +9,7 @@
 import 'package:devtools_app/src/screens/profiler/panes/bottom_up.dart';
 import 'package:devtools_app/src/screens/profiler/panes/call_tree.dart';
 import 'package:devtools_app/src/screens/profiler/panes/cpu_flame_chart.dart';
-import 'package:devtools_app/src/screens/profiler/panes/method_table.dart';
+import 'package:devtools_app/src/screens/profiler/panes/method_table/method_table.dart';
 import 'package:devtools_app/src/shared/charts/flame_chart.dart';
 import 'package:devtools_app/src/shared/config_specific/import_export/import_export.dart';
 import 'package:devtools_app/src/shared/feature_flags.dart';
diff --git a/packages/devtools_app/test/primitives/graph_test.dart b/packages/devtools_app/test/primitives/graph_test.dart
new file mode 100644
index 0000000..0537053
--- /dev/null
+++ b/packages/devtools_app/test/primitives/graph_test.dart
@@ -0,0 +1,104 @@
+// Copyright 2023 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:devtools_app/src/shared/primitives/graph.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  late GraphNode testNodeA;
+  late GraphNode testNodeB;
+  late GraphNode testNodeC;
+  late GraphNode testNodeD;
+  late GraphNode testNodeE;
+  late GraphNode testNodeF;
+
+  group('GraphNode', () {
+    setUp(() {
+      testNodeA = GraphNode();
+      testNodeB = GraphNode();
+      testNodeC = GraphNode();
+      testNodeD = GraphNode();
+      testNodeE = GraphNode();
+      testNodeF = GraphNode();
+      testNodeA
+        ..outgoingEdge(testNodeB)
+        ..outgoingEdge(testNodeC)
+        ..outgoingEdge(testNodeD);
+      testNodeB
+        ..outgoingEdge(testNodeC)
+        ..outgoingEdge(testNodeD);
+      testNodeD
+        ..outgoingEdge(testNodeE)
+        ..outgoingEdge(testNodeF);
+      testNodeF.outgoingEdge(testNodeB);
+
+      // Extra edges to make the edge counts interesting.
+      testNodeA
+        ..outgoingEdge(testNodeB)
+        ..outgoingEdge(testNodeC)
+        ..outgoingEdge(testNodeC);
+
+      testNodeB.outgoingEdge(testNodeC);
+
+      testNodeD
+        ..outgoingEdge(testNodeE)
+        ..outgoingEdge(testNodeE);
+    });
+
+    test('predecessor and successor lists are accurate', () {
+      expect(testNodeA.predecessors, isEmpty);
+      expect(testNodeA.successors, {testNodeB, testNodeC, testNodeD});
+
+      expect(testNodeB.predecessors, {testNodeA, testNodeF});
+      expect(testNodeB.successors, {testNodeC, testNodeD});
+
+      expect(testNodeC.predecessors, {testNodeA, testNodeB});
+      expect(testNodeC.successors, isEmpty);
+
+      expect(testNodeD.predecessors, {testNodeA, testNodeB});
+      expect(testNodeD.successors, {testNodeE, testNodeF});
+
+      expect(testNodeE.predecessors, {testNodeD});
+      expect(testNodeE.successors, isEmpty);
+
+      expect(testNodeF.predecessors, {testNodeD});
+      expect(testNodeF.successors, {testNodeB});
+    });
+
+    test('predecessor and successor edge counts are accurate', () {
+      expect(testNodeA.predecessorEdgeCounts.keys, isEmpty);
+      expect(testNodeA.predecessorEdgeCounts.values, isEmpty);
+      expect(
+        testNodeA.successorEdgeCounts.keys,
+        [testNodeB, testNodeC, testNodeD],
+      );
+      expect(testNodeA.successorEdgeCounts.values, [2, 3, 1]);
+
+      expect(testNodeB.predecessorEdgeCounts.keys, [testNodeA, testNodeF]);
+      expect(testNodeB.predecessorEdgeCounts.values, [2, 1]);
+      expect(testNodeB.successorEdgeCounts.keys, [testNodeC, testNodeD]);
+      expect(testNodeB.successorEdgeCounts.values, [2, 1]);
+
+      expect(testNodeC.predecessorEdgeCounts.keys, [testNodeA, testNodeB]);
+      expect(testNodeC.predecessorEdgeCounts.values, [3, 2]);
+      expect(testNodeC.successorEdgeCounts.keys, isEmpty);
+      expect(testNodeC.successorEdgeCounts.values, isEmpty);
+
+      expect(testNodeD.predecessorEdgeCounts.keys, [testNodeA, testNodeB]);
+      expect(testNodeD.predecessorEdgeCounts.values, [1, 1]);
+      expect(testNodeD.successorEdgeCounts.keys, [testNodeE, testNodeF]);
+      expect(testNodeD.successorEdgeCounts.values, [3, 1]);
+
+      expect(testNodeE.predecessorEdgeCounts.keys, [testNodeD]);
+      expect(testNodeE.predecessorEdgeCounts.values, [3]);
+      expect(testNodeE.successorEdgeCounts.keys, isEmpty);
+      expect(testNodeE.successorEdgeCounts.values, isEmpty);
+
+      expect(testNodeF.predecessorEdgeCounts.keys, [testNodeD]);
+      expect(testNodeF.predecessorEdgeCounts.values, [1]);
+      expect(testNodeF.successorEdgeCounts.keys, [testNodeB]);
+      expect(testNodeF.successorEdgeCounts.values, [1]);
+    });
+  });
+}