From 7bcdf010838ea37c990420bdc4ee62e86af9df7a Mon Sep 17 00:00:00 2001 From: Matheus Baumgarten Date: Fri, 24 Mar 2023 22:38:46 -0300 Subject: [PATCH] `TreeController` improvements (#45) * add `TreeController.breadthFirstSearch()` & tests * organize file and add some tests * fix isTree(Expanded/Collapsed) & add tests * add tests for (expand/collapse)All * fix typo * add doc about the behavior of (expand/collapse)All --- lib/src/tree_controller.dart | 165 ++++++++++---- test/tree_controller_test.dart | 401 +++++++++++++++++++++++++++++++-- 2 files changed, 498 insertions(+), 68 deletions(-) diff --git a/lib/src/tree_controller.dart b/lib/src/tree_controller.dart index dc488d8c..3bcbf30f 100644 --- a/lib/src/tree_controller.dart +++ b/lib/src/tree_controller.dart @@ -22,6 +22,12 @@ typedef Visitor = void Function(T node); /// traversed or skipped. typedef DescendCondition = bool Function(T node); +/// Signature of a function that takes a `T` node and returns a `bool`. +/// +/// Used when traversing the tree in breadth first order to decide whether the +/// traversal should stop. +typedef ReturnCondition = bool Function(T node); + /// A controller used to dynamically manage the state of a tree. /// /// Whenever this controller notifies its listeners any attached tree views @@ -197,54 +203,6 @@ class TreeController with ChangeNotifier { rebuild(); } - /// Expands all nodes of this tree consequently. - void expandAll() => expandCascading(roots); - - /// Collapses all nodes of this tree consequently. - void collapseAll() => collapseCascading(roots); - - /// Whether all root nodes of this tree are expanded. - /// - /// To know this it is required to know if root nodes contained in - /// the cache of expanded nodes. - bool get areAllRootsExpanded => roots.every(getExpansionState); - - /// Whether all root nodes of this tree are collapsed. - /// - /// To know this it is required to know if root nodes does not contained in - /// the cache of expanded nodes. - bool get areAllRootsCollapsed => !roots.any(getExpansionState); - - /// Whether all nodes of this tree are expanded. - bool get isTreeExpanded { - final totalNodes = [], expandedNodes = []; - depthFirstTraversal( - onTraverse: (entry) { - totalNodes.add(entry); - if (entry.isExpanded) { - expandedNodes.add(entry); - } - }, - descendCondition: (node) => true, - ); - return totalNodes.length == expandedNodes.length; - } - - /// Whether all nodes of this tree are collapsed. - bool get isTreeCollapsed { - final totalNodes = [], collapsedNodes = []; - depthFirstTraversal( - onTraverse: (entry) { - totalNodes.add(entry); - if (!entry.isExpanded) { - collapsedNodes.add(entry); - } - }, - descendCondition: (node) => true, - ); - return totalNodes.length == collapsedNodes.length; - } - void _applyCascadingAction(Iterable nodes, Visitor action) { for (final T node in nodes) { action(node); @@ -268,6 +226,18 @@ class TreeController with ChangeNotifier { rebuild(); } + /// Expands all nodes of this tree recursively. + /// + /// This method delegates its call to [expandCascading] passing in [roots] + /// as the nodes to be expanded. + void expandAll() => expandCascading(roots); + + /// Collapses all nodes of this tree recursively. + /// + /// This method delegates its call to [collapseCascading] passing in [roots] + /// as the nodes to be collapsed. + void collapseAll() => collapseCascading(roots); + /// Walks up the ancestors of [node] setting their expansion state to `true`. /// Note: [node] is not expanded by this method. /// @@ -290,6 +260,95 @@ class TreeController with ChangeNotifier { rebuild(); } + /// Whether all root nodes of this tree are expanded. + bool get areAllRootsExpanded => roots.every(getExpansionState); + + /// Whether all root nodes of this tree are collapsed. + bool get areAllRootsCollapsed => !roots.any(getExpansionState); + + /// Whether **all** nodes of this tree are expanded. + /// + /// Traverses the tree in breadth first order checking the expansion state of + /// each visited node. The traversal will return early if it finds a collapsed + /// node. + bool get isTreeExpanded { + bool allNodesExpanded = false; + + breadthFirstSearch( + returnCondition: (T node) { + final bool isExpanded = getExpansionState(node); + allNodesExpanded = isExpanded; + // Stop the traversal if [node] is not expanded + return !isExpanded; + }, + ); + + return allNodesExpanded; + } + + /// Whether **all** nodes of this tree are collapsed. + /// + /// Traverses the tree in breadth first order checking the expansion state of + /// each visited node. The traversal will return early if it finds an expanded + /// node. + bool get isTreeCollapsed { + bool allNodesCollapsed = true; + + breadthFirstSearch( + returnCondition: (T node) { + final bool isExpanded = getExpansionState(node); + allNodesCollapsed = !isExpanded; + // Stop the traversal if [node] is expanded + return isExpanded; + }, + ); + + return allNodesCollapsed; + } + + /// Traverses the subtrees of [startingNodes] in breadth first order. If + /// [startingNodes] is not provided, [roots] will be used instead. + /// + /// [descendCondition] is used to determine if the descendants of the node + /// passed to it should be traversed. When not provided, defaults to + /// [alwaysReturnsTrue], a function that always returns `true` which leads + /// to every node on the tree being visited by this traversal. + /// + /// [returnCondition] is used as a predicate to decide if the iteration should + /// be stopped. If this callback returns `true` the node that was passed to + /// it is returned from this method. When not provided, defaults to + /// [alwaysReturnsFalse], a function that always returns `false` which leads + /// to every node on the tree being visited by this traversal. + /// + /// An optional [onTraverse] callback can be provided to apply an action to + /// each visited node. This callback is called prior to [returnCondition] and + /// [descendCondition] making it possible to update a node before checking + /// its properties. + T? breadthFirstSearch({ + Iterable? startingNodes, + DescendCondition descendCondition = alwaysReturnsTrue, + ReturnCondition returnCondition = alwaysReturnsFalse, + Visitor? onTraverse, + }) { + final List nodes = List.of(startingNodes ?? roots); + + while (nodes.isNotEmpty) { + final T node = nodes.removeAt(0); + + onTraverse?.call(node); + + if (returnCondition(node)) { + return node; + } + + if (descendCondition(node)) { + nodes.addAll(childrenProvider(node)); + } + } + + return null; + } + /// Traverses the subtrees of [roots] creating [TreeEntry] instances for /// each visited node. /// @@ -375,6 +434,16 @@ class TreeController with ChangeNotifier { } } +/// A function that can take a nullable [Object] and will always return `true`. +/// +/// Used in other function declarations as a constant default parameter. +bool alwaysReturnsTrue([Object? _]) => true; + +/// A function that can take a nullable [Object] and will always return `false`. +/// +/// Used in other function declarations as a constant default parameter. +bool alwaysReturnsFalse([Object? _]) => false; + /// Used to store useful information about [node] in a tree. /// /// Instances of this class are short lived, created by [TreeController] diff --git a/test/tree_controller_test.dart b/test/tree_controller_test.dart index 3cc66e34..de5b8005 100644 --- a/test/tree_controller_test.dart +++ b/test/tree_controller_test.dart @@ -32,6 +32,40 @@ bool visitAllNodes(TreeEntry entry) => true; class TestTree { static const String root = '0'; + TestTree.depthFirst() + : flatTree = List.unmodifiable([ + '1', + '1.1', + '1.1.1', + '1.1.2', + '1.2', + '1.2.1', + '1.2.2', + '2', + '2.1', + '2.1.1', + '2.1.1.1', + '2.1.1.1.1', + '3', + ]); + + TestTree.breadthFirst() + : flatTree = List.unmodifiable([ + '1', + '2', + '3', + '1.1', + '1.2', + '2.1', + '1.1.1', + '1.1.2', + '1.2.1', + '1.2.2', + '2.1.1', + '2.1.1.1', + '2.1.1.1.1', + ]); + final childrenOf = Map>.unmodifiable({ root: ['1', '2', '3'], '1': ['1.1', '1.2'], @@ -56,21 +90,7 @@ class TestTree { '2.1.1.1.1': '2.1.1.1', }); - final flatTree = List.unmodifiable([ - '1', - '1.1', - '1.1.1', - '1.1.2', - '1.2', - '1.2.1', - '1.2.2', - '2', - '2.1', - '2.1.1', - '2.1.1.1', - '2.1.1.1.1', - '3', - ]); + final List flatTree; int get totalNodeCount => flatTree.length; @@ -290,6 +310,42 @@ void main() { }); }); + test('expandAll() properly expands every node', () { + final tree = TestTree.breadthFirst(); + final controller = TreeController( + roots: tree.roots, + childrenProvider: tree.childrenProvider, + ); + + controller.breadthFirstSearch(onTraverse: (String node) { + expect(controller.getExpansionState(node), isFalse); + }); + + controller.expandAll(); + + controller.breadthFirstSearch(onTraverse: (String node) { + expect(controller.getExpansionState(node), isTrue); + }); + }); + + test('collapseAll() properly collapses every node', () { + final tree = TestTree.breadthFirst(); + final controller = TreeController( + roots: tree.roots, + childrenProvider: tree.childrenProvider, + )..expandAll(); + + controller.breadthFirstSearch(onTraverse: (String node) { + expect(controller.getExpansionState(node), isTrue); + }); + + controller.collapseAll(); + + controller.breadthFirstSearch(onTraverse: (String node) { + expect(controller.getExpansionState(node), isFalse); + }); + }); + group('expandAncestors()', () { const root = 1; const target = 7; @@ -334,12 +390,317 @@ void main() { }); }); + test('areAllRootsExpanded', () { + final controller = TestTreeController.create(roots: const [1, 2, 3]); + expect(controller.areAllRootsExpanded, isFalse); + + for (final root in controller.roots) { + expect(controller.areAllRootsExpanded, isFalse); + controller.setExpansionState(root, true); + } + + expect(controller.areAllRootsExpanded, isTrue); + + for (final root in controller.roots) { + controller.setExpansionState(root, false); + } + + expect(controller.areAllRootsExpanded, isFalse); + }); + + test('areAllRootsCollapsed', () { + final controller = TestTreeController.create(roots: const [1, 2, 3]); + expect(controller.areAllRootsCollapsed, isTrue); + + controller.setExpansionState(2, true); + expect(controller.areAllRootsCollapsed, isFalse); + + controller.setExpansionState(2, false); + expect(controller.areAllRootsCollapsed, isTrue); + + for (final root in controller.roots) { + controller.setExpansionState(root, true); + } + + expect(controller.areAllRootsCollapsed, isFalse); + }); + + group('isTreeExpanded', () { + late TestTree tree; + late TestTreeController controller; + + setUp(() { + tree = TestTree.breadthFirst(); + controller = TestTreeController( + roots: tree.roots, + childrenProvider: tree.childrenProvider, + ); + }); + + test('only returns true when all tree nodes are expanded', () { + controller.collapseAll(); + expect(controller.isTreeExpanded, isFalse); + + controller.expand('3'); + expect(controller.isTreeExpanded, isFalse); + + controller.expandCascading(const ['2']); + expect(controller.isTreeExpanded, isFalse); + + controller.expand('1'); + expect( + controller.isTreeExpanded, + isFalse, + reason: 'All root nodes have been expanded, but there are still ' + 'some collapsed nodes in the subtree of node "1"', + ); + + controller.expandAll(); + expect(controller.isTreeExpanded, isTrue); + }); + }); + + group('isTreeCollapsed', () { + late TestTree tree; + late TestTreeController controller; + + setUp(() { + tree = TestTree.breadthFirst(); + controller = TestTreeController( + roots: tree.roots, + childrenProvider: tree.childrenProvider, + ); + }); + + test('only returns true when all tree nodes are collapsed', () { + controller.expandAll(); + expect(controller.isTreeCollapsed, isFalse); + + controller.collapse('3'); + expect(controller.isTreeCollapsed, isFalse); + + controller.collapseCascading(const ['2']); + expect(controller.isTreeCollapsed, isFalse); + + controller.collapse('1'); + expect( + controller.isTreeCollapsed, + isFalse, + reason: 'All root nodes have been collapsed, but there are still ' + 'some expanded nodes in the subtree of node "1"', + ); + + controller.collapseAll(); + expect(controller.isTreeCollapsed, isTrue); + }); + }); + + group('breadthFirstSearch()', () { + late TestTree tree; + late TestTreeController controller; + + setUp(() { + tree = TestTree.breadthFirst(); + controller = TestTreeController( + roots: tree.roots, + childrenProvider: tree.childrenProvider, + ); + }); + + test('uses roots when startingNodes is not provided', () { + final visitedNodes = []; + + controller.breadthFirstSearch( + startingNodes: null, + onTraverse: visitedNodes.add, + ); + + expect(visitedNodes, containsAll(controller.roots)); + }); + + test('uses startingNodes when provided', () { + const flatSubtree = [ + '1', + '1.1', + '1.2', + '1.1.1', + '1.1.2', + '1.2.1', + '1.2.2', + ]; + final visitedNodes = []; + + controller.breadthFirstSearch( + startingNodes: ['1'], + onTraverse: (String node) { + expect(node, startsWith('1')); + visitedNodes.add(node); + }, + ); + + expect(visitedNodes, isNot(containsAll(const ['2', '3']))); + + expect(visitedNodes.length, equals(flatSubtree.length)); + expect(visitedNodes, equals(flatSubtree)); + }); + + test('traverses the tree in the right order', () { + final flatTree = List.of(tree.flatTree); + + controller.breadthFirstSearch( + onTraverse: (String node) { + expect(node, equals(flatTree.removeAt(0))); + }, + ); + + expect(flatTree, isEmpty); + }); + + test('calls onTraverse for every visited node', () { + // Traverse all nodes + int index = 0; + controller.breadthFirstSearch( + onTraverse: (String node) { + expect(node, equals(tree.flatTree[index])); + index++; + }, + ); + + // Traverse expanded nodes only + for (final root in tree.roots) { + controller.setExpansionState(root, true); + } + + const flatTree = ['1', '2', '3', '1.1', '1.2', '2.1']; + final visitedNodes = []; + + controller.breadthFirstSearch( + descendCondition: controller.getExpansionState, + onTraverse: visitedNodes.add, + ); + + expect(visitedNodes.length, equals(flatTree.length)); + for (int index = 0; index < flatTree.length; ++index) { + expect(visitedNodes[index], equals(flatTree[index])); + } + }); + + test('calls descendCondition for every visited node', () { + int visitedNodesCount = 0; + + controller.breadthFirstSearch( + descendCondition: (_) { + visitedNodesCount++; + return true; // visit all nodes + }, + ); + + expect(visitedNodesCount, equals(tree.totalNodeCount)); + }); + + test('respects descendCondition', () { + final result = ['1', '2', '3', '2.1', '2.1.1', '2.1.1.1', '2.1.1.1.1']; + + controller.breadthFirstSearch( + descendCondition: (String node) => node.startsWith('2'), + onTraverse: (String node) { + expect(node, equals(result.removeAt(0))); + }, + ); + + expect(result, isEmpty); + }); + + test( + 'does not call descendCondition for the node matched by returnCondition', + () { + const target = '2.1'; + + controller.breadthFirstSearch( + returnCondition: (String node) => node == target, + descendCondition: (String node) { + expect(node, isNot(equals(target))); + return true; + }, + ); + }, + ); + + test('respects returnCondition', () { + final visitedNodes = []; + + controller.breadthFirstSearch( + returnCondition: (String node) => node == '3', + onTraverse: visitedNodes.add, + ); + + expect(visitedNodes.length, equals(3)); + expect(visitedNodes, equals(controller.roots)); + }); + + test('onTraverse is called for the node matched in returnCondition', () { + const target = '1'; + final visitedNodes = []; + + controller.breadthFirstSearch( + onTraverse: visitedNodes.add, + returnCondition: (String node) => node == target, + ); + + expect(visitedNodes, contains(target)); + }); + + test('returns the node that matches the returnCondition', () { + const target = '2.1.1.1'; + + final String? result = controller.breadthFirstSearch( + returnCondition: (String node) => node == target, + ); + + expect(result, isNotNull); + expect(result, equals(target)); + }); + + test('returns null if the returnCondition is never met', () { + final String? result = controller.breadthFirstSearch( + returnCondition: (String node) => node == 'not a node', + ); + + expect(result, isNull); + }); + + test('completes the traversal if returnCondition is not provided', () { + int visitedNodesCount = 0; + controller.breadthFirstSearch(onTraverse: (_) => visitedNodesCount++); + expect(visitedNodesCount, equals(tree.totalNodeCount)); + }); + + test('stops the traversal when returnCondition is met', () { + const amountOfNodesToTraverse = 3; + + int nodeCount = 0; + final visitedNodes = []; + + controller.breadthFirstSearch( + returnCondition: (_) => nodeCount == amountOfNodesToTraverse, + onTraverse: (String node) { + nodeCount++; + visitedNodes.add(node); + }, + ); + + expect(nodeCount, equals(amountOfNodesToTraverse)); + expect(visitedNodes.length, equals(amountOfNodesToTraverse)); + expect(visitedNodes, equals(controller.roots)); + }); + }); + group('depthFirstTraversal()', () { late TestTree tree; late TestTreeController controller; setUp(() { - tree = TestTree(); + tree = TestTree.depthFirst(); controller = TestTreeController( roots: tree.roots, childrenProvider: tree.childrenProvider, @@ -365,7 +726,7 @@ void main() { } const flatTree = ['1', '1.1', '1.2', '2', '2.1', '3']; - final visitedNodes = []; + final visitedNodes = []; controller.depthFirstTraversal( onTraverse: (TreeEntry entry) { @@ -571,7 +932,7 @@ void main() { '1.2.2', '3' ]; - int visitedNodeCount = 0; + int visitedNodesCount = 0; controller.depthFirstTraversal( rootEntry: rootEntry, @@ -580,10 +941,10 @@ void main() { expect(entry.node, startsWith('2.1.1')); expect(subtree.contains(entry.node), isTrue); expect(unreachable.contains(entry.node), isFalse); - ++visitedNodeCount; + ++visitedNodesCount; }, ); - expect(visitedNodeCount, equals(subtree.length)); + expect(visitedNodesCount, equals(subtree.length)); }); test('is used as the parent of root nodes', () {