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]); + }); + }); +}