diff --git a/.github/workflows/graphs.yml b/.github/workflows/graphs.yml new file mode 100644 index 0000000000..3070eb11f4 --- /dev/null +++ b/.github/workflows/graphs.yml @@ -0,0 +1,66 @@ +name: CI + +on: + # Run on PRs and pushes to the default branch. + push: + branches: [ main ] + paths: + - '.github/workflows/graphs.yml' + - 'pkgs/graphs/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/graphs.yml' + - 'pkgs/graphs/**' + schedule: + - cron: "0 0 * * 0" + +env: + PUB_ENVIRONMENT: bot.github + +jobs: + # Check code formatting and static analysis on a single OS (linux) + # against Dart beta. + analyze: + runs-on: ubuntu-latest + defaults: + run: + working-directory: pkgs/graphs/ + strategy: + fail-fast: false + matrix: + sdk: [dev] + steps: + - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab + - uses: dart-lang/setup-dart@d6a63dab3335f427404425de0fbfed4686d93c4f + with: + sdk: ${{ matrix.sdk }} + - id: install + run: dart pub get + - run: dart format --output=none --set-exit-if-changed . + if: always() && steps.install.outcome == 'success' + - run: dart analyze --fatal-infos + if: always() && steps.install.outcome == 'success' + + test: + needs: analyze + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: pkgs/graphs/ + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + sdk: [2.18.0, dev] + steps: + - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab + - uses: dart-lang/setup-dart@d6a63dab3335f427404425de0fbfed4686d93c4f + with: + sdk: ${{ matrix.sdk }} + - id: install + run: dart pub get + - run: dart test --platform vm + if: always() && steps.install.outcome == 'success' + - run: dart test --platform chrome + if: always() && steps.install.outcome == 'success' diff --git a/pkgs/graphs/.gitignore b/pkgs/graphs/.gitignore new file mode 100644 index 0000000000..ddfdca160f --- /dev/null +++ b/pkgs/graphs/.gitignore @@ -0,0 +1,5 @@ +.dart_tool/ +.packages +.pub/ +build/ +pubspec.lock diff --git a/pkgs/graphs/CHANGELOG.md b/pkgs/graphs/CHANGELOG.md new file mode 100644 index 0000000000..d29ca79ce7 --- /dev/null +++ b/pkgs/graphs/CHANGELOG.md @@ -0,0 +1,64 @@ +## 2.3.0 + +- Add a `transitiveClosure` function. +- Make `stronglyConnectedComponents` and `topologicalSort` iterative rather than + recursive to avoid stack overflows on very large graphs. +- Require Dart 2.18 + +## 2.2.0 + +- Add a `secondarySort` parameter to the `topologicalSort()` function which + applies an additional lexical sort where that doesn't break the topological + sort. + +## 2.1.0 + +- Add a `topologicalSort()` function. + +## 2.0.0 + +- **Breaking**: `crawlAsync` will no longer ignore a node from the graph if the + `readNode` callback returns null. + +## 1.0.0 + +- Migrate to null safety. +- **Breaking**: Paths from `shortestPath[s]` are now returned as iterables to + reduce memory consumption of the algorithm to O(n). + +## 0.2.0 + +- **BREAKING** `shortestPath`, `shortestPaths` and `stronglyConnectedComponents` + now have one generic parameter and have replaced the `key` parameter with + optional params: `{bool equals(T key1, T key2), int hashCode(T key)}`. + This follows the pattern used in `dart:collection` classes `HashMap` and + `LinkedHashMap`. It improves the usability and performance of the case where + the source values are directly usable in a hash data structure. + +## 0.1.3+1 + +- Fixed a bug with non-identity `key` in `shortestPath` and `shortestPaths`. + +## 0.1.3 + +- Added `shortestPath` and `shortestPaths` functions. +- Use `HashMap` and `HashSet` from `dart:collection` for + `stronglyConnectedComponents`. Improves runtime performance. + +## 0.1.2+1 + +- Allow using non-dev Dart 2 SDK. + +## 0.1.2 + +- `crawlAsync` surfaces exceptions while crawling through the result stream + rather than as uncaught asynchronous errors. + +## 0.1.1 + +- `crawlAsync` will now ignore nodes that are resolved to `null`. + +## 0.1.0 + +- Initial release with an implementation of `stronglyConnectedComponents` and + `crawlAsync`. diff --git a/pkgs/graphs/CONTRIBUTING.md b/pkgs/graphs/CONTRIBUTING.md new file mode 100644 index 0000000000..286d61c8bc --- /dev/null +++ b/pkgs/graphs/CONTRIBUTING.md @@ -0,0 +1,33 @@ +Want to contribute? Great! First, read this page (including the small print at +the end). + +### Before you contribute +Before we can use your code, you must sign the +[Google Individual Contributor License Agreement](https://cla.developers.google.com/about/google-individual) +(CLA), which you can do online. The CLA is necessary mainly because you own the +copyright to your changes, even after your contribution becomes part of our +codebase, so we need your permission to use and distribute your code. We also +need to be sure of various other things—for instance that you'll tell us if you +know that your code infringes on other people's patents. You don't have to sign +the CLA until after you've submitted your code for review and a member has +approved it, but you must do it before we can put your code into our codebase. + +Before you start working on a larger contribution, you should get in touch with +us first through the issue tracker with your idea so that we can help out and +possibly guide you. Coordinating up front makes it much easier to avoid +frustration later on. + +### Code reviews +All submissions, including submissions by project members, require review. + +### File headers +All files in the project must start with the following header. + + // Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file + // for details. All rights reserved. Use of this source code is governed by a + // BSD-style license that can be found in the LICENSE file. + +### The small print +Contributions made by corporations are covered by a different agreement than the +one above, the +[Software Grant and Corporate Contributor License Agreement](https://developers.google.com/open-source/cla/corporate). diff --git a/pkgs/graphs/LICENSE b/pkgs/graphs/LICENSE new file mode 100644 index 0000000000..03af64abe4 --- /dev/null +++ b/pkgs/graphs/LICENSE @@ -0,0 +1,27 @@ +Copyright 2017, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/graphs/README.md b/pkgs/graphs/README.md new file mode 100644 index 0000000000..2bf0eec6a5 --- /dev/null +++ b/pkgs/graphs/README.md @@ -0,0 +1,42 @@ +[![CI](https://github.com/dart-lang/tools/actions/workflows/graphs.yml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/graphs.yml) +[![pub package](https://img.shields.io/pub/v/graphs.svg)](https://pub.dev/packages/graphs) +[![package publisher](https://img.shields.io/pub/publisher/graphs.svg)](https://pub.dev/packages/graphs/publisher) + +Graph algorithms that do not specify a particular approach for representing a +Graph. + +Functions in this package will take arguments that provide the mechanism for +traversing the graph. For example two common approaches for representing a +graph: + +```dart +class Graph { + Map> nodes; +} +class Node { + // Interesting data +} +``` + +```dart +class Graph { + Node root; +} +class Node { + List edges; + // Interesting data +} +``` + +Any representation can be adapted to the needs of the algorithm: + +- Some algorithms need to associate data with each node in the graph. If the + node type `T` does not correctly or efficiently implement `hashCode` or `==`, + you may provide optional `equals` and/or `hashCode` functions are parameters. +- Algorithms which need to traverse the graph take a `edges` function which provides the reachable nodes. + - `(node) => graph[node]` + - `(node) => node.edges` + + +Graphs that are resolved asynchronously will have similar functions which +return `FutureOr`. diff --git a/pkgs/graphs/analysis_options.yaml b/pkgs/graphs/analysis_options.yaml new file mode 100644 index 0000000000..5e04335ab7 --- /dev/null +++ b/pkgs/graphs/analysis_options.yaml @@ -0,0 +1,56 @@ +# https://dart.dev/guides/language/analysis-options +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + +linter: + rules: + - always_declare_return_types + - avoid_bool_literals_in_conditional_expressions + - avoid_catching_errors + - avoid_classes_with_only_static_members + - avoid_dynamic_calls + - avoid_private_typedef_functions + - avoid_redundant_argument_values + - avoid_returning_null + - avoid_returning_null_for_future + - avoid_returning_this + - avoid_unused_constructor_parameters + - avoid_void_async + - cancel_subscriptions + - comment_references + - directives_ordering + - join_return_with_assignment + - lines_longer_than_80_chars + - literal_only_boolean_expressions + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_runtimeType_toString + - omit_local_variable_types + - only_throw_errors + - package_api_docs + - prefer_asserts_in_initializer_lists + - prefer_const_constructors + - prefer_const_declarations + - prefer_expression_function_bodies + - prefer_final_locals + - prefer_relative_imports + - prefer_single_quotes + - require_trailing_commas + - test_types_in_equals + - throw_in_finally + - type_annotate_public_apis + - unawaited_futures + - unnecessary_await_in_return + - unnecessary_lambdas + - unnecessary_parenthesis + - unnecessary_raw_strings + - unnecessary_statements + - use_if_null_to_convert_nulls_to_bools + - use_raw_strings + - use_string_buffers + - use_super_parameters diff --git a/pkgs/graphs/benchmark/connected_components_benchmark.dart b/pkgs/graphs/benchmark/connected_components_benchmark.dart new file mode 100644 index 0000000000..1f7187922c --- /dev/null +++ b/pkgs/graphs/benchmark/connected_components_benchmark.dart @@ -0,0 +1,50 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:collection'; +import 'dart:math' show Random; + +import 'package:graphs/graphs.dart'; + +void main() { + final rnd = Random(0); + const size = 2000; + final graph = HashMap>(); + + for (var i = 0; i < size * 3; i++) { + final toList = graph.putIfAbsent(rnd.nextInt(size), () => []); + + final toValue = rnd.nextInt(size); + if (!toList.contains(toValue)) { + toList.add(toValue); + } + } + + var maxCount = 0; + var maxIteration = 0; + + const duration = Duration(milliseconds: 100); + + for (var i = 1;; i++) { + var count = 0; + final watch = Stopwatch()..start(); + while (watch.elapsed < duration) { + count++; + final length = + stronglyConnectedComponents(graph.keys, (e) => graph[e] ?? []) + .length; + assert(length == 244, '$length'); + } + + if (count > maxCount) { + maxCount = count; + maxIteration = i; + } + + if (maxIteration == i || (i - maxIteration) % 20 == 0) { + print('max iterations in ${duration.inMilliseconds}ms: $maxCount\t' + 'after $maxIteration of $i iterations'); + } + } +} diff --git a/pkgs/graphs/benchmark/shortest_path_benchmark.dart b/pkgs/graphs/benchmark/shortest_path_benchmark.dart new file mode 100644 index 0000000000..67e7367c5d --- /dev/null +++ b/pkgs/graphs/benchmark/shortest_path_benchmark.dart @@ -0,0 +1,54 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:collection'; +import 'dart:math' show Random; + +import 'package:graphs/graphs.dart'; + +void main() { + final rnd = Random(1); + const size = 1000; + final graph = HashMap>(); + + for (var i = 0; i < size * 5; i++) { + final toList = graph.putIfAbsent(rnd.nextInt(size), () => []); + + final toValue = rnd.nextInt(size); + if (!toList.contains(toValue)) { + toList.add(toValue); + } + } + + int? minTicks; + var maxIteration = 0; + + final testOutput = + shortestPath(0, size - 1, (e) => graph[e] ?? []).toString(); + print(testOutput); + assert(testOutput == '(258, 252, 819, 999)', testOutput); + + final watch = Stopwatch(); + for (var i = 1;; i++) { + watch + ..reset() + ..start(); + final result = shortestPath(0, size - 1, (e) => graph[e] ?? [])!; + final length = result.length; + final first = result.first; + watch.stop(); + assert(length == 4, '$length'); + assert(first == 258, '$first'); + + if (minTicks == null || watch.elapsedTicks < minTicks) { + minTicks = watch.elapsedTicks; + maxIteration = i; + } + + if (maxIteration == i || (i - maxIteration) % 100000 == 0) { + print('min ticks for one run: $minTicks\t' + 'after $maxIteration of $i iterations'); + } + } +} diff --git a/pkgs/graphs/benchmark/shortest_path_worst_case_benchmark.dart b/pkgs/graphs/benchmark/shortest_path_worst_case_benchmark.dart new file mode 100644 index 0000000000..d3117acd2e --- /dev/null +++ b/pkgs/graphs/benchmark/shortest_path_worst_case_benchmark.dart @@ -0,0 +1,58 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:collection'; + +import 'package:graphs/graphs.dart'; + +void main() { + const size = 1000; + final graph = HashMap>(); + + // We create a graph where every subsequent node has an edge to every other + // node before it as well as the next node. This triggers worst case behavior + // in many algorithms as it requires visiting all nodes and edges before + // finding a solution, and there are a maximum number of edges. + for (var i = 0; i < size; i++) { + final toList = graph.putIfAbsent(i, () => []); + for (var t = 0; t < i + 2 && i < size; t++) { + if (i == t) continue; + toList.add(t); + } + } + + int? minTicks; + var maxIteration = 0; + + final testOutput = + shortestPath(0, size - 1, (e) => graph[e] ?? []).toString(); + print(testOutput); + assert( + testOutput == Iterable.generate(size - 1, (i) => i + 1).toString(), + testOutput, + ); + + final watch = Stopwatch(); + for (var i = 1;; i++) { + watch + ..reset() + ..start(); + final result = shortestPath(0, size - 1, (e) => graph[e] ?? [])!; + final length = result.length; + final first = result.first; + watch.stop(); + assert(length == 999, '$length'); + assert(first == 1, '$first'); + + if (minTicks == null || watch.elapsedTicks < minTicks) { + minTicks = watch.elapsedTicks; + maxIteration = i; + } + + if (maxIteration == i || (i - maxIteration) % 100000 == 0) { + print('min ticks for one run: $minTicks\t' + 'after $maxIteration of $i iterations'); + } + } +} diff --git a/pkgs/graphs/example/crawl_async_example.dart b/pkgs/graphs/example/crawl_async_example.dart new file mode 100644 index 0000000000..c74eae901c --- /dev/null +++ b/pkgs/graphs/example/crawl_async_example.dart @@ -0,0 +1,89 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:isolate'; + +import 'package:analyzer/dart/analysis/analysis_context.dart'; +import 'package:analyzer/dart/analysis/context_builder.dart'; +import 'package:analyzer/dart/analysis/context_locator.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:graphs/graphs.dart'; +import 'package:path/path.dart' as p; +import 'package:pool/pool.dart'; + +/// Print a transitive set of imported URIs where libraries are read +/// asynchronously. +Future main() async { + // Limits calls to [findImports]. + final pool = Pool(10); + final allImports = await crawlAsync( + [Uri.parse('package:graphs/graphs.dart')], + read, + (from, source) => pool.withResource(() => findImports(from, source)), + ).toList(); + print(allImports.map((s) => s.uri).toList()); +} + +AnalysisContext? _analysisContext; + +Future get analysisContext async { + var context = _analysisContext; + if (context == null) { + final libUri = Uri.parse('package:graphs/'); + final libPath = await pathForUri(libUri); + final packagePath = p.dirname(libPath); + + final roots = ContextLocator().locateRoots(includedPaths: [packagePath]); + if (roots.length != 1) { + throw StateError('Expected to find exactly one context root, got $roots'); + } + + context = _analysisContext = + ContextBuilder().createContext(contextRoot: roots[0]); + } + + return context; +} + +Future> findImports(Uri from, Source source) async => + source.unit.directives + .whereType() + .map((d) => d.uri.stringValue!) + .where((uri) => !uri.startsWith('dart:')) + .map((import) => resolveImport(import, from)); + +Future parseUri(Uri uri) async { + final path = await pathForUri(uri); + final analysisSession = (await analysisContext).currentSession; + final parseResult = analysisSession.getParsedUnit(path); + return (parseResult as ParsedUnitResult).unit; +} + +Future pathForUri(Uri uri) async { + final fileUri = await Isolate.resolvePackageUri(uri); + if (fileUri == null || !fileUri.isScheme('file')) { + throw StateError('Expected to resolve $uri to a file URI, got $fileUri'); + } + return p.fromUri(fileUri); +} + +Future read(Uri uri) async => Source(uri, await parseUri(uri)); + +Uri resolveImport(String import, Uri from) { + if (import.startsWith('package:')) return Uri.parse(import); + assert(from.scheme == 'package'); + final package = from.pathSegments.first; + final fromPath = p.joinAll(from.pathSegments.skip(1)); + final path = p.normalize(p.join(p.dirname(fromPath), import)); + return Uri.parse('package:${p.join(package, path)}'); +} + +class Source { + final Uri uri; + final CompilationUnit unit; + + Source(this.uri, this.unit); +} diff --git a/pkgs/graphs/example/example.dart b/pkgs/graphs/example/example.dart new file mode 100644 index 0000000000..82cbbcabe7 --- /dev/null +++ b/pkgs/graphs/example/example.dart @@ -0,0 +1,49 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. 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:graphs/graphs.dart'; + +/// A representation of a directed graph. +/// +/// Data is stored on the [Node] class. +class Graph { + final Map> nodes; + + Graph(this.nodes); +} + +class Node { + final String id; + final int data; + + Node(this.id, this.data); + + @override + bool operator ==(Object other) => other is Node && other.id == id; + + @override + int get hashCode => id.hashCode; + + @override + String toString() => '<$id -> $data>'; +} + +void main() { + final nodeA = Node('A', 1); + final nodeB = Node('B', 2); + final nodeC = Node('C', 3); + final nodeD = Node('D', 4); + final graph = Graph({ + nodeA: [nodeB, nodeC], + nodeB: [nodeC, nodeD], + nodeC: [nodeB, nodeD] + }); + + final components = stronglyConnectedComponents( + graph.nodes.keys, + (node) => graph.nodes[node] ?? [], + ); + + print(components); +} diff --git a/pkgs/graphs/lib/graphs.dart b/pkgs/graphs/lib/graphs.dart new file mode 100644 index 0000000000..f10b2e459f --- /dev/null +++ b/pkgs/graphs/lib/graphs.dart @@ -0,0 +1,11 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +export 'src/crawl_async.dart' show crawlAsync; +export 'src/cycle_exception.dart' show CycleException; +export 'src/shortest_path.dart' show shortestPath, shortestPaths; +export 'src/strongly_connected_components.dart' + show stronglyConnectedComponents; +export 'src/topological_sort.dart' show topologicalSort; +export 'src/transitive_closure.dart' show transitiveClosure; diff --git a/pkgs/graphs/lib/src/crawl_async.dart b/pkgs/graphs/lib/src/crawl_async.dart new file mode 100644 index 0000000000..68c0a5b71d --- /dev/null +++ b/pkgs/graphs/lib/src/crawl_async.dart @@ -0,0 +1,85 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:collection'; + +final _empty = Future.value(); + +/// Finds and returns every node in a graph who's nodes and edges are +/// asynchronously resolved. +/// +/// Cycles are allowed. If this is an undirected graph the [edges] function +/// may be symmetric. In this case the [roots] may be any node in each connected +/// graph. +/// +/// [V] is the type of values in the graph nodes. [K] must be a type suitable +/// for using as a Map or Set key. [edges] should return the next reachable +/// nodes. +/// +/// There are no ordering guarantees. This is useful for ensuring some work is +/// performed at every node in an asynchronous graph, but does not give +/// guarantees that the work is done in topological order. +/// +/// If either [readNode] or [edges] throws the error will be forwarded +/// through the result stream and no further nodes will be crawled, though some +/// work may have already been started. +/// +/// Crawling is eager, so calls to [edges] may overlap with other calls that +/// have not completed. If the [edges] callback needs to be limited or throttled +/// that must be done by wrapping it before calling [crawlAsync]. +Stream crawlAsync( + Iterable roots, + FutureOr Function(K) readNode, + FutureOr> Function(K, V) edges, +) { + final crawl = _CrawlAsync(roots, readNode, edges)..run(); + return crawl.result.stream; +} + +class _CrawlAsync { + final result = StreamController(); + + final FutureOr Function(K) readNode; + final FutureOr> Function(K, V) edges; + final Iterable roots; + + final _seen = HashSet(); + + _CrawlAsync(this.roots, this.readNode, this.edges); + + /// Add all nodes in the graph to [result] and return a Future which fires + /// after all nodes have been seen. + Future run() async { + try { + await Future.wait(roots.map(_visit), eagerError: true); + await result.close(); + } catch (e, st) { + result.addError(e, st); + await result.close(); + } + } + + /// Resolve the node at [key] and output it, then start crawling all of it's + /// edges. + Future _crawlFrom(K key) async { + final value = await readNode(key); + if (result.isClosed) return; + result.add(value); + final next = await edges(key, value); + await Future.wait(next.map(_visit), eagerError: true); + } + + /// Synchronously record that [key] is being handled then start work on the + /// node for [key]. + /// + /// The returned Future will complete only after the work for [key] and all + /// transitively reachable nodes has either been finished, or will be finished + /// by some other Future in [_seen]. + Future _visit(K key) { + if (_seen.contains(key)) return _empty; + _seen.add(key); + return _crawlFrom(key); + } +} diff --git a/pkgs/graphs/lib/src/cycle_exception.dart b/pkgs/graphs/lib/src/cycle_exception.dart new file mode 100644 index 0000000000..eb9b433e76 --- /dev/null +++ b/pkgs/graphs/lib/src/cycle_exception.dart @@ -0,0 +1,19 @@ +// Copyright (c) 2021, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// An exception indicating that a cycle was detected in a graph that was +/// expected to be acyclic. +class CycleException implements Exception { + /// The list of nodes comprising the cycle. + /// + /// Each node in this list has an edge to the next node. The final node has an + /// edge to the first node. + final List cycle; + + CycleException(Iterable cycle) : cycle = List.unmodifiable(cycle); + + @override + String toString() => 'A cycle was detected in a graph that must be acyclic:\n' + '${cycle.map((node) => '* $node').join('\n')}'; +} diff --git a/pkgs/graphs/lib/src/shortest_path.dart b/pkgs/graphs/lib/src/shortest_path.dart new file mode 100644 index 0000000000..28f7e51aad --- /dev/null +++ b/pkgs/graphs/lib/src/shortest_path.dart @@ -0,0 +1,144 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:collection'; + +/// Returns the shortest path from [start] to [target] given the directed +/// edges of a graph provided by [edges]. +/// +/// If [start] `==` [target], an empty [List] is returned and [edges] is never +/// called. +/// +/// If [equals] is provided, it is used to compare nodes in the graph. If +/// [equals] is omitted, the node's own [Object.==] is used instead. +/// +/// Similarly, if [hashCode] is provided, it is used to produce a hash value +/// for nodes to efficiently calculate the return value. If it is omitted, the +/// key's own [Object.hashCode] is used. +/// +/// If you supply one of [equals] or [hashCode], you should generally also to +/// supply the other. +Iterable? shortestPath( + T start, + T target, + Iterable Function(T) edges, { + bool Function(T, T)? equals, + int Function(T)? hashCode, +}) => + _shortestPaths( + start, + edges, + target: target, + equals: equals, + hashCode: hashCode, + )[target]; + +/// Returns a [Map] of the shortest paths from [start] to all of the nodes in +/// the directed graph defined by [edges]. +/// +/// All return values will contain the key [start] with an empty [List] value. +/// +/// [start] and all values returned by [edges] must not be `null`. +/// If asserts are enabled, an [AssertionError] is raised if these conditions +/// are not met. If asserts are not enabled, violations result in undefined +/// behavior. +/// +/// If [equals] is provided, it is used to compare nodes in the graph. If +/// [equals] is omitted, the node's own [Object.==] is used instead. +/// +/// Similarly, if [hashCode] is provided, it is used to produce a hash value +/// for nodes to efficiently calculate the return value. If it is omitted, the +/// key's own [Object.hashCode] is used. +/// +/// If you supply one of [equals] or [hashCode], you should generally also to +/// supply the other. +Map> shortestPaths( + T start, + Iterable Function(T) edges, { + bool Function(T, T)? equals, + int Function(T)? hashCode, +}) => + _shortestPaths( + start, + edges, + equals: equals, + hashCode: hashCode, + ); + +Map> _shortestPaths( + T start, + Iterable Function(T) edges, { + T? target, + bool Function(T, T)? equals, + int Function(T)? hashCode, +}) { + final distances = HashMap>(equals: equals, hashCode: hashCode); + distances[start] = _Tail(); + + final nonNullEquals = equals ??= _defaultEquals; + final isTarget = + target == null ? _neverTarget : (T node) => nonNullEquals(node, target); + if (isTarget(start)) { + return distances; + } + + final toVisit = ListQueue()..add(start); + + while (toVisit.isNotEmpty) { + final current = toVisit.removeFirst(); + final currentPath = distances[current]!; + + for (var edge in edges(current)) { + final existingPath = distances[edge]; + + if (existingPath == null) { + distances[edge] = currentPath.append(edge); + if (isTarget(edge)) { + return distances; + } + toVisit.add(edge); + } + } + } + + return distances; +} + +bool _defaultEquals(Object a, Object b) => a == b; +bool _neverTarget(Object _) => false; + +/// An immutable iterable that can efficiently return a copy with a value +/// appended. +/// +/// This implementation has an efficient [length] property. +/// +/// Note that grabbing an [iterator] for the first time is O(n) in time and +/// space because it copies all the values to a new list and uses that +/// iterator in order to avoid stack overflows for large paths. This copy is +/// cached for subsequent calls. +class _Tail extends Iterable { + final T? tail; + final _Tail? head; + @override + final int length; + _Tail() + : tail = null, + head = null, + length = 0; + _Tail._(this.tail, this.head, this.length); + _Tail append(T value) => _Tail._(value, this, length + 1); + + @override + Iterator get iterator => _asIterable.iterator; + + late final _asIterable = () { + _Tail? next = this; + final reversed = List.generate(length, (_) { + final val = next!.tail; + next = next!.head; + return val as T; + }); + return reversed.reversed; + }(); +} diff --git a/pkgs/graphs/lib/src/strongly_connected_components.dart b/pkgs/graphs/lib/src/strongly_connected_components.dart new file mode 100644 index 0000000000..e8a775ceb0 --- /dev/null +++ b/pkgs/graphs/lib/src/strongly_connected_components.dart @@ -0,0 +1,117 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:collection'; +import 'dart:math' show min; + +/// Finds the strongly connected components of a directed graph using Tarjan's +/// algorithm. +/// +/// The result will be a valid reverse topological order ordering of the +/// strongly connected components. Components further from a root will appear in +/// the result before the components which they are connected to. +/// +/// Nodes within a strongly connected component have no ordering guarantees, +/// except that if the first value in [nodes] is a valid root, and is contained +/// in a cycle, it will be the last element of that cycle. +/// +/// [nodes] must contain at least a root of every tree in the graph if there are +/// disjoint subgraphs but it may contain all nodes in the graph if the roots +/// are not known. +/// +/// If [equals] is provided, it is used to compare nodes in the graph. If +/// [equals] is omitted, the node's own [Object.==] is used instead. +/// +/// Similarly, if [hashCode] is provided, it is used to produce a hash value +/// for nodes to efficiently calculate the return value. If it is omitted, the +/// key's own [Object.hashCode] is used. +/// +/// If you supply one of [equals] or [hashCode], you should generally also to +/// supply the other. +List> stronglyConnectedComponents( + Iterable nodes, + Iterable Function(T) edges, { + bool Function(T, T)? equals, + int Function(T)? hashCode, +}) { + final result = >[]; + final lowLinks = HashMap(equals: equals, hashCode: hashCode); + final indexes = HashMap(equals: equals, hashCode: hashCode); + final onStack = HashSet(equals: equals, hashCode: hashCode); + + final nonNullEquals = equals ?? _defaultEquals; + + var index = 0; + final lastVisited = Queue(); + + final stack = [for (final node in nodes) _StackState(node)]; + outer: + while (stack.isNotEmpty) { + final state = stack.removeLast(); + final node = state.node; + var iterator = state.iterator; + + int lowLink; + if (iterator == null) { + if (indexes.containsKey(node)) continue; + indexes[node] = index; + lowLink = lowLinks[node] = index; + index++; + iterator = edges(node).iterator; + + // Nodes with no edges are always in their own component. + if (!iterator.moveNext()) { + result.add([node]); + continue; + } + + lastVisited.addLast(node); + onStack.add(node); + } else { + lowLink = min(lowLinks[node]!, lowLinks[iterator.current]!); + } + + do { + final next = iterator.current; + if (!indexes.containsKey(next)) { + stack.add(_StackState(node, iterator)); + stack.add(_StackState(next)); + continue outer; + } else if (onStack.contains(next)) { + lowLink = lowLinks[node] = min(lowLink, indexes[next]!); + } + } while (iterator.moveNext()); + + if (lowLink == indexes[node]) { + final component = []; + T next; + do { + next = lastVisited.removeLast(); + onStack.remove(next); + component.add(next); + } while (!nonNullEquals(next, node)); + result.add(component); + } + } + + return result; +} + +/// The state of a pass on a single node in Tarjan's Algorithm. +/// +/// This is used to perform the algorithm with an explicit stack rather than +/// recursively, to avoid stack overflow errors for very large graphs. +class _StackState { + /// The node being inspected. + final T node; + + /// The iterator traversing [node]'s edges. + /// + /// This is null if the node hasn't yet begun being traversed. + final Iterator? iterator; + + _StackState(this.node, [this.iterator]); +} + +bool _defaultEquals(Object a, Object b) => a == b; diff --git a/pkgs/graphs/lib/src/topological_sort.dart b/pkgs/graphs/lib/src/topological_sort.dart new file mode 100644 index 0000000000..948e6963be --- /dev/null +++ b/pkgs/graphs/lib/src/topological_sort.dart @@ -0,0 +1,146 @@ +// Copyright (c) 2021, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:collection'; + +import 'package:collection/collection.dart' hide stronglyConnectedComponents; + +import 'cycle_exception.dart'; + +/// Returns a topological sort of the nodes of the directed edges of a graph +/// provided by [nodes] and [edges]. +/// +/// Each element of the returned iterable is guaranteed to appear after all +/// nodes that have edges leading to that node. The result is not guaranteed to +/// be unique, nor is it guaranteed to be stable across releases of this +/// package; however, it will be stable for a given input within a given package +/// version. +/// +/// If [equals] is provided, it is used to compare nodes in the graph. If +/// [equals] is omitted, the node's own [Object.==] is used instead. +/// +/// Similarly, if [hashCode] is provided, it is used to produce a hash value +/// for nodes to efficiently calculate the return value. If it is omitted, the +/// key's own [Object.hashCode] is used. +/// +/// If you supply one of [equals] or [hashCode], you should generally also to +/// supply the other. +/// +/// If you supply [secondarySort], the resulting list will be sorted by that +/// comparison function as much as possible without violating the topological +/// ordering. Note that even with a secondary sort, the result is _still_ not +/// guaranteed to be unique or stable across releases of this package. +/// +/// Note: this requires that [nodes] and each iterable returned by [edges] +/// contain no duplicate entries. +/// +/// Throws a [CycleException] if the graph is cyclical. +List topologicalSort( + Iterable nodes, + Iterable Function(T) edges, { + bool Function(T, T)? equals, + int Function(T)? hashCode, + Comparator? secondarySort, +}) { + if (secondarySort != null) { + return _topologicalSortWithSecondary( + [...nodes], + edges, + secondarySort, + equals, + hashCode, + ); + } + + // https://en.wikipedia.org/wiki/Topological_sorting#Depth-first_search + final result = QueueList(); + final permanentMark = HashSet(equals: equals, hashCode: hashCode); + final temporaryMark = LinkedHashSet(equals: equals, hashCode: hashCode); + final stack = [...nodes]; + while (stack.isNotEmpty) { + final node = stack.removeLast(); + if (permanentMark.contains(node)) continue; + + // If we're visiting this node while it's already marked and not through a + // dependency, that must mean we've traversed all its dependencies and it's + // safe to add it to the result. + if (temporaryMark.contains(node)) { + temporaryMark.remove(node); + permanentMark.add(node); + result.addFirst(node); + } else { + temporaryMark.add(node); + + // Revisit this node once we've visited all its children. + stack.add(node); + for (var child in edges(node)) { + if (temporaryMark.contains(child)) throw CycleException(temporaryMark); + stack.add(child); + } + } + } + + return result; +} + +/// An implementation of [topologicalSort] with a secondary comparison function. +List _topologicalSortWithSecondary( + List nodes, + Iterable Function(T) edges, + Comparator comparator, + bool Function(T, T)? equals, + int Function(T)? hashCode, +) { + // https://en.wikipedia.org/wiki/Topological_sorting#Kahn's_algorithm, + // modified to sort the nodes to traverse. Also documented in + // https://www.algotree.org/algorithms/tree_graph_traversal/lexical_topological_sort_c++/ + + // For each node, the number of incoming edges it has that we haven't yet + // traversed. + final incomingEdges = HashMap(equals: equals, hashCode: hashCode); + for (var node in nodes) { + for (var child in edges(node)) { + incomingEdges[child] = (incomingEdges[child] ?? 0) + 1; + } + } + + // A priority queue of nodes that have no remaining incoming edges. + final nodesToTraverse = PriorityQueue(comparator); + for (var node in nodes) { + if (!incomingEdges.containsKey(node)) nodesToTraverse.add(node); + } + + final result = []; + while (nodesToTraverse.isNotEmpty) { + final node = nodesToTraverse.removeFirst(); + result.add(node); + for (var child in edges(node)) { + var remainingEdges = incomingEdges[child]!; + remainingEdges--; + incomingEdges[child] = remainingEdges; + if (remainingEdges == 0) nodesToTraverse.add(child); + } + } + + if (result.length < nodes.length) { + // This algorithm doesn't automatically produce a cycle list as a side + // effect of sorting, so to throw the appropriate [CycleException] we just + // call the normal [topologicalSort] with a view of this graph that only + // includes nodes that still have edges. + bool nodeIsInCycle(T node) { + final edges = incomingEdges[node]; + return edges != null && edges > 0; + } + + topologicalSort( + nodes.where(nodeIsInCycle), + edges, + equals: equals, + hashCode: hashCode, + ); + assert(false, 'topologicalSort() should throw if the graph has a cycle'); + } + + return result; +} diff --git a/pkgs/graphs/lib/src/transitive_closure.dart b/pkgs/graphs/lib/src/transitive_closure.dart new file mode 100644 index 0000000000..ee19337a84 --- /dev/null +++ b/pkgs/graphs/lib/src/transitive_closure.dart @@ -0,0 +1,143 @@ +// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:collection'; + +import 'cycle_exception.dart'; +import 'strongly_connected_components.dart'; +import 'topological_sort.dart'; + +/// Returns a transitive closure of a directed graph provided by [nodes] and +/// [edges]. +/// +/// The result is a map from [nodes] to the sets of nodes that are transitively +/// reachable through [edges]. No particular ordering is guaranteed. +/// +/// If [equals] is provided, it is used to compare nodes in the graph. If +/// [equals] is omitted, the node's own [Object.==] is used instead. +/// +/// Similarly, if [hashCode] is provided, it is used to produce a hash value +/// for nodes to efficiently calculate the return value. If it is omitted, the +/// key's own [Object.hashCode] is used. +/// +/// If you supply one of [equals] or [hashCode], you should generally also to +/// supply the other. +/// +/// Note: this requires that [nodes] and each iterable returned by [edges] +/// contain no duplicate entries. +/// +/// By default, this can handle either cyclic or acyclic graphs. If [acyclic] is +/// true, this will run more efficiently but throw a [CycleException] if the +/// graph is cyclical. +Map> transitiveClosure( + Iterable nodes, + Iterable Function(T) edges, { + bool Function(T, T)? equals, + int Function(T)? hashCode, + bool acyclic = false, +}) { + if (!acyclic) { + return _cyclicTransitiveClosure( + nodes, + edges, + equals: equals, + hashCode: hashCode, + ); + } + + final topologicalOrder = + topologicalSort(nodes, edges, equals: equals, hashCode: hashCode); + final result = LinkedHashMap>(equals: equals, hashCode: hashCode); + for (final node in topologicalOrder.reversed) { + final closure = LinkedHashSet(equals: equals, hashCode: hashCode); + for (var child in edges(node)) { + closure.add(child); + closure.addAll(result[child]!); + } + + result[node] = closure; + } + + return result; +} + +/// Returns the transitive closure of a cyclic graph using [Purdom's algorithm]. +/// +/// [Purdom's algorithm]: https://algowiki-project.org/en/Purdom%27s_algorithm +/// +/// This first computes the strongly connected components of the graph and finds +/// the transitive closure of those before flattening it out into the transitive +/// closure of the entire graph. +Map> _cyclicTransitiveClosure( + Iterable nodes, + Iterable Function(T) edges, { + bool Function(T, T)? equals, + int Function(T)? hashCode, +}) { + final components = stronglyConnectedComponents( + nodes, + edges, + equals: equals, + hashCode: hashCode, + ); + final nodesToComponents = + HashMap>(equals: equals, hashCode: hashCode); + for (final component in components) { + for (final node in component) { + nodesToComponents[node] = component; + } + } + + // Because [stronglyConnectedComponents] returns the components in reverse + // topological order, we can avoid an additional topological sort here. + // Instead, we directly traverse the component list with the knowledge that + // once we reach a component, everything reachable from it has already been + // registered in [result]. + final result = LinkedHashMap>(equals: equals, hashCode: hashCode); + for (final component in components) { + final closure = LinkedHashSet(equals: equals, hashCode: hashCode); + if (_componentIncludesCycle(component, edges, equals)) { + closure.addAll(component); + } + + // De-duplicate downstream components to avoid adding the same transitive + // children over and over. + final downstreamComponents = { + for (final node in component) + for (final child in edges(node)) nodesToComponents[child]! + }; + for (final childComponent in downstreamComponents) { + if (childComponent == component) continue; + + // This if check is just for efficiency. If [childComponent] has multiple + // nodes, `result[childComponent.first]` will contain all the nodes in + // `childComponent` anyway since it's cyclical. + if (childComponent.length == 1) closure.addAll(childComponent); + closure.addAll(result[childComponent.first]!); + } + + for (final node in component) { + result[node] = closure; + } + } + return result; +} + +/// Returns whether the strongly-connected component [component] of a graph +/// defined by [edges] includes a cycle. +bool _componentIncludesCycle( + List component, + Iterable Function(T) edges, + bool Function(T, T)? equals, +) { + // A strongly-connected component with more than one node always contains a + // cycle, by definition. + if (component.length > 1) return true; + + // A component with only a single node only contains a cycle if that node has + // an edge to itself. + final node = component.single; + return edges(node) + .any((edge) => equals == null ? edge == node : equals(edge, node)); +} diff --git a/pkgs/graphs/pubspec.yaml b/pkgs/graphs/pubspec.yaml new file mode 100644 index 0000000000..d3d9e771de --- /dev/null +++ b/pkgs/graphs/pubspec.yaml @@ -0,0 +1,19 @@ +name: graphs +version: 2.3.0 +description: Graph algorithms that operate on graphs in any representation +repository: https://github.com/dart-lang/tools/tree/main/pkgs/graphs + +environment: + sdk: '>=2.18.0 <3.0.0' + +dependencies: + collection: ^1.1.0 + +dev_dependencies: + lints: ^2.0.0 + test: ^1.16.0 + + # For examples + analyzer: ^5.2.0 + path: ^1.8.0 + pool: ^1.5.0 diff --git a/pkgs/graphs/test/crawl_async_test.dart b/pkgs/graphs/test/crawl_async_test.dart new file mode 100644 index 0000000000..908b5a2ca8 --- /dev/null +++ b/pkgs/graphs/test/crawl_async_test.dart @@ -0,0 +1,113 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. 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:graphs/graphs.dart'; +import 'package:test/test.dart'; + +import 'utils/graph.dart'; + +void main() { + group('asyncCrawl', () { + Future> crawl( + Map?> g, + Iterable roots, + ) { + final graph = AsyncGraph(g); + return crawlAsync(roots, graph.readNode, graph.edges).toList(); + } + + test('empty result for empty graph', () async { + final result = await crawl({}, []); + expect(result, isEmpty); + }); + + test('single item for a single node', () async { + final result = await crawl({'a': []}, ['a']); + expect(result, ['a']); + }); + + test('hits every node in a graph', () async { + final result = await crawl({ + 'a': ['b', 'c'], + 'b': ['c'], + 'c': ['d'], + 'd': [], + }, [ + 'a' + ]); + expect(result, hasLength(4)); + expect( + result, + allOf(contains('a'), contains('b'), contains('c'), contains('d')), + ); + }); + + test('handles cycles', () async { + final result = await crawl({ + 'a': ['b'], + 'b': ['c'], + 'c': ['b'], + }, [ + 'a' + ]); + expect(result, hasLength(3)); + expect(result, allOf(contains('a'), contains('b'), contains('c'))); + }); + + test('handles self cycles', () async { + final result = await crawl({ + 'a': ['b'], + 'b': ['b'], + }, [ + 'a' + ]); + expect(result, hasLength(2)); + expect(result, allOf(contains('a'), contains('b'))); + }); + + test('allows null edges', () async { + final result = await crawl({ + 'a': ['b'], + 'b': null, + }, [ + 'a' + ]); + expect(result, hasLength(2)); + expect(result, allOf(contains('a'), contains('b'))); + }); + + test('allows null nodes', () async { + final result = await crawl({ + 'a': ['b'], + }, [ + 'a' + ]); + expect(result, ['a', null]); + }); + + test('surfaces exceptions for crawling edges', () { + final graph = { + 'a': ['b'], + }; + final nodes = crawlAsync( + ['a'], + (n) => n, + (k, n) => k == 'b' ? throw ArgumentError() : graph[k] ?? [], + ); + expect(nodes, emitsThrough(emitsError(isArgumentError))); + }); + + test('surfaces exceptions for resolving keys', () { + final graph = { + 'a': ['b'], + }; + final nodes = crawlAsync( + ['a'], + (n) => n == 'b' ? throw ArgumentError() : n, + (k, n) => graph[k] ?? [], + ); + expect(nodes, emitsThrough(emitsError(isArgumentError))); + }); + }); +} diff --git a/pkgs/graphs/test/shortest_path_test.dart b/pkgs/graphs/test/shortest_path_test.dart new file mode 100644 index 0000000000..88281d4f6b --- /dev/null +++ b/pkgs/graphs/test/shortest_path_test.dart @@ -0,0 +1,161 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:collection'; +import 'dart:math' show Random; + +import 'package:graphs/graphs.dart'; +import 'package:test/test.dart'; + +import 'utils/utils.dart'; + +void main() { + const graph = >{ + '1': ['2', '5'], + '2': ['3'], + '3': ['4', '5'], + '4': ['1'], + '5': ['8'], + '6': ['7'], + }; + + List readEdges(String key) => graph[key] ?? []; + + List getXValues(X key) => graph[key.value]?.map(X.new).toList() ?? []; + + void singlePathTest(String from, String to, List? expected) { + test('$from -> $to should be $expected (mapped)', () { + expect( + shortestPath( + X(from), + X(to), + getXValues, + equals: xEquals, + hashCode: xHashCode, + )?.map((x) => x.value), + expected, + ); + }); + + test('$from -> $to should be $expected', () { + expect(shortestPath(from, to, readEdges), expected); + }); + } + + void pathsTest( + String from, + Map> expected, + List nullPaths, + ) { + test('paths from $from (mapped)', () { + final result = shortestPaths( + X(from), + getXValues, + equals: xEquals, + hashCode: xHashCode, + ).map((k, v) => MapEntry(k.value, v.map((x) => x.value).toList())); + expect(result, expected); + }); + + test('paths from $from', () { + final result = shortestPaths(from, readEdges); + expect(result, expected); + }); + + for (var entry in expected.entries) { + singlePathTest(from, entry.key, entry.value); + } + + for (var entry in nullPaths) { + singlePathTest(from, entry, null); + } + } + + pathsTest('1', { + '5': ['5'], + '3': ['2', '3'], + '8': ['5', '8'], + '1': [], + '2': ['2'], + '4': ['2', '3', '4'], + }, [ + '6', + '7', + ]); + + pathsTest('6', { + '7': ['7'], + '6': [], + }, [ + '1', + ]); + pathsTest('7', {'7': []}, ['1', '6']); + + pathsTest('42', {'42': []}, ['1', '6']); + + test('integration test', () { + // Be deterministic in the generated graph. This test may have to be updated + // if the behavior of `Random` changes for the provided seed. + final rnd = Random(1); + const size = 1000; + final graph = HashMap>(); + + Iterable? resultForGraph() => + shortestPath(0, size - 1, (e) => graph[e] ?? const []); + + void addRandomEdge() { + final toList = graph.putIfAbsent(rnd.nextInt(size), () => []); + + final toValue = rnd.nextInt(size); + if (!toList.contains(toValue)) { + toList.add(toValue); + } + } + + Iterable? result; + + // Add edges until there is a shortest path between `0` and `size - 1` + do { + addRandomEdge(); + result = resultForGraph(); + } while (result == null); + + expect(result, [313, 547, 91, 481, 74, 64, 439, 388, 660, 275, 999]); + + var count = 0; + // Add edges until the shortest path between `0` and `size - 1` is 2 items + // Adding edges should never increase the length of the shortest path. + // Adding enough edges should reduce the length of the shortest path. + do { + expect(++count, lessThan(size * 5), reason: 'This loop should finish.'); + addRandomEdge(); + final previousResultLength = result!.length; + result = resultForGraph(); + expect(result, hasLength(lessThanOrEqualTo(previousResultLength))); + } while (result!.length > 2); + + expect(result, [275, 999]); + + count = 0; + // Remove edges until there is no shortest path. + // Removing edges should never reduce the length of the shortest path. + // Removing enough edges should increase the length of the shortest path and + // eventually eliminate any path. + do { + expect(++count, lessThan(size * 5), reason: 'This loop should finish.'); + final randomKey = graph.keys.elementAt(rnd.nextInt(graph.length)); + final list = graph[randomKey]!; + expect(list, isNotEmpty); + list.removeAt(rnd.nextInt(list.length)); + if (list.isEmpty) { + graph.remove(randomKey); + } + final previousResultLength = result!.length; + result = resultForGraph(); + if (result != null) { + expect(result, hasLength(greaterThanOrEqualTo(previousResultLength))); + } + } while (result != null); + }); +} diff --git a/pkgs/graphs/test/strongly_connected_components_test.dart b/pkgs/graphs/test/strongly_connected_components_test.dart new file mode 100644 index 0000000000..52ff4fea93 --- /dev/null +++ b/pkgs/graphs/test/strongly_connected_components_test.dart @@ -0,0 +1,304 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. 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:graphs/graphs.dart'; +import 'package:test/test.dart'; + +import 'utils/graph.dart'; +import 'utils/utils.dart'; + +void main() { + group('strongly connected components', () { + /// Run [stronglyConnectedComponents] on [g]. + List> components( + Map?> g, { + Iterable? startNodes, + }) { + final graph = Graph(g); + return stronglyConnectedComponents( + startNodes ?? graph.allNodes, + graph.edges, + ); + } + + test('empty result for empty graph', () { + final result = components({}); + expect(result, isEmpty); + }); + + test('single item for single node', () { + final result = components({'a': []}); + expect(result, [ + ['a'] + ]); + }); + + test('handles non-cycles', () { + final result = components({ + 'a': ['b'], + 'b': ['c'], + 'c': [] + }); + expect(result, [ + ['c'], + ['b'], + ['a'] + ]); + }); + + test('handles entire graph as cycle', () { + final result = components({ + 'a': ['b'], + 'b': ['c'], + 'c': ['d'], + 'd': ['a'], + }); + expect( + result, + [allOf(contains('a'), contains('b'), contains('c'), contains('d'))], + ); + }); + + test('includes the first passed root last in a cycle', () { + // In cases where this is used to find a topological ordering the first + // value in nodes should always come last. + final graph = { + 'a': ['b'], + 'b': ['a'] + }; + final resultFromA = components(graph, startNodes: ['a']); + final resultFromB = components(graph, startNodes: ['b']); + expect(resultFromA.single.last, 'a'); + expect(resultFromB.single.last, 'b'); + }); + + test('handles cycles in the middle', () { + final result = components({ + 'a': ['b', 'c'], + 'b': ['c', 'd'], + 'c': ['b', 'd'], + 'd': [], + }); + expect(result, [ + ['d'], + allOf(contains('b'), contains('c')), + ['a'], + ]); + }); + + test('handles self cycles', () { + final result = components({ + 'a': ['b'], + 'b': ['b'], + }); + expect(result, [ + ['b'], + ['a'], + ]); + }); + + test('valid topological ordering for disjoint subgraphs', () { + final result = components({ + 'a': ['b', 'c'], + 'b': ['b1', 'b2'], + 'c': ['c1', 'c2'], + 'b1': [], + 'b2': [], + 'c1': [], + 'c2': [] + }); + + expect( + result, + containsAllInOrder([ + ['c1'], + ['c'], + ['a'] + ]), + ); + expect( + result, + containsAllInOrder([ + ['c2'], + ['c'], + ['a'] + ]), + ); + expect( + result, + containsAllInOrder([ + ['b1'], + ['b'], + ['a'] + ]), + ); + expect( + result, + containsAllInOrder([ + ['b2'], + ['b'], + ['a'] + ]), + ); + }); + + test('handles getting null for edges', () { + final result = components({ + 'a': ['b'], + 'b': null, + }); + expect(result, [ + ['b'], + ['a'] + ]); + }); + }); + + group('custom hashCode and equals', () { + /// Run [stronglyConnectedComponents] on [g]. + List> components( + Map?> g, { + Iterable? startNodes, + }) { + final graph = BadGraph(g); + + startNodes ??= graph.allNodes.map((n) => n.value); + + return stronglyConnectedComponents( + startNodes.map(X.new), + graph.edges, + equals: xEquals, + hashCode: xHashCode, + ).map((list) => list.map((x) => x.value).toList()).toList(); + } + + test('empty result for empty graph', () { + final result = components({}); + expect(result, isEmpty); + }); + + test('single item for single node', () { + final result = components({'a': []}); + expect(result, [ + ['a'] + ]); + }); + + test('handles non-cycles', () { + final result = components({ + 'a': ['b'], + 'b': ['c'], + 'c': [] + }); + expect(result, [ + ['c'], + ['b'], + ['a'] + ]); + }); + + test('handles entire graph as cycle', () { + final result = components({ + 'a': ['b'], + 'b': ['c'], + 'c': ['a'] + }); + expect(result, [allOf(contains('a'), contains('b'), contains('c'))]); + }); + + test('includes the first passed root last in a cycle', () { + // In cases where this is used to find a topological ordering the first + // value in nodes should always come last. + final graph = { + 'a': ['b'], + 'b': ['a'] + }; + final resultFromA = components(graph, startNodes: ['a']); + final resultFromB = components(graph, startNodes: ['b']); + expect(resultFromA.single.last, 'a'); + expect(resultFromB.single.last, 'b'); + }); + + test('handles cycles in the middle', () { + final result = components({ + 'a': ['b', 'c'], + 'b': ['c', 'd'], + 'c': ['b', 'd'], + 'd': [], + }); + expect(result, [ + ['d'], + allOf(contains('b'), contains('c')), + ['a'], + ]); + }); + + test('handles self cycles', () { + final result = components({ + 'a': ['b'], + 'b': ['b'], + }); + expect(result, [ + ['b'], + ['a'], + ]); + }); + + test('valid topological ordering for disjoint subgraphs', () { + final result = components({ + 'a': ['b', 'c'], + 'b': ['b1', 'b2'], + 'c': ['c1', 'c2'], + 'b1': [], + 'b2': [], + 'c1': [], + 'c2': [] + }); + + expect( + result, + containsAllInOrder([ + ['c1'], + ['c'], + ['a'] + ]), + ); + expect( + result, + containsAllInOrder([ + ['c2'], + ['c'], + ['a'] + ]), + ); + expect( + result, + containsAllInOrder([ + ['b1'], + ['b'], + ['a'] + ]), + ); + expect( + result, + containsAllInOrder([ + ['b2'], + ['b'], + ['a'] + ]), + ); + }); + + test('handles getting null for edges', () { + final result = components({ + 'a': ['b'], + 'b': null, + }); + expect(result, [ + ['b'], + ['a'] + ]); + }); + }); +} diff --git a/pkgs/graphs/test/topological_sort_test.dart b/pkgs/graphs/test/topological_sort_test.dart new file mode 100644 index 0000000000..0cceb45cb5 --- /dev/null +++ b/pkgs/graphs/test/topological_sort_test.dart @@ -0,0 +1,362 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:collection'; + +import 'package:graphs/graphs.dart'; +import 'package:test/test.dart'; + +import 'utils/utils.dart'; + +void main() { + group('without secondarySort', () { + group('topologically sorts a graph', () { + test('with no nodes', () { + expect(_topologicalSort({}), isEmpty); + }); + + test('with only one node', () { + expect(_topologicalSort({1: []}), equals([1])); + }); + + test('with no edges', () { + expect( + _topologicalSort({1: [], 2: [], 3: [], 4: []}), + unorderedEquals([1, 2, 3, 4]), + ); + }); + + test('with single edges', () { + expect( + _topologicalSort({ + 1: [2], + 2: [3], + 3: [4], + 4: [] + }), + equals([1, 2, 3, 4]), + ); + }); + + test('with many edges from one node', () { + final result = _topologicalSort({ + 1: [2, 3, 4], + 2: [], + 3: [], + 4: [] + }); + expect(result.indexOf(1), lessThan(result.indexOf(2))); + expect(result.indexOf(1), lessThan(result.indexOf(3))); + expect(result.indexOf(1), lessThan(result.indexOf(4))); + }); + + test('with transitive edges', () { + final result = _topologicalSort({ + 1: [2, 4], + 2: [], + 3: [], + 4: [3] + }); + expect(result.indexOf(1), lessThan(result.indexOf(2))); + expect(result.indexOf(1), lessThan(result.indexOf(3))); + expect(result.indexOf(1), lessThan(result.indexOf(4))); + expect(result.indexOf(4), lessThan(result.indexOf(3))); + }); + + test('with diamond edges', () { + final result = _topologicalSort({ + 1: [2, 3], + 2: [4], + 3: [4], + 4: [] + }); + expect(result.indexOf(1), lessThan(result.indexOf(2))); + expect(result.indexOf(1), lessThan(result.indexOf(3))); + expect(result.indexOf(1), lessThan(result.indexOf(4))); + expect(result.indexOf(2), lessThan(result.indexOf(4))); + expect(result.indexOf(3), lessThan(result.indexOf(4))); + }); + }); + + test('respects custom equality and hash functions', () { + expect( + _topologicalSort( + { + 0: [2], + 3: [4], + 5: [6], + 7: [] + }, + equals: (i, j) => (i ~/ 2) == (j ~/ 2), + hashCode: (i) => (i ~/ 2).hashCode, + ), + equals([ + 0, + anyOf([2, 3]), + anyOf([4, 5]), + anyOf([6, 7]) + ]), + ); + }); + + group('throws a CycleException for a graph with', () { + test('a one-node cycle', () { + expect( + () => _topologicalSort({ + 1: [1] + }), + throwsCycleException([1]), + ); + }); + + test('a multi-node cycle', () { + expect( + () => _topologicalSort({ + 1: [2], + 2: [3], + 3: [4], + 4: [1] + }), + throwsCycleException([4, 1, 2, 3]), + ); + }); + }); + }); + + group('with secondarySort', () { + group('topologically sorts a graph', () { + test('with no nodes', () { + expect(_topologicalSort({}, secondarySort: true), isEmpty); + }); + + test('with only one node', () { + expect(_topologicalSort({1: []}, secondarySort: true), equals([1])); + }); + + test('with no edges', () { + expect( + _topologicalSort({1: [], 2: [], 3: [], 4: []}, secondarySort: true), + unorderedEquals([1, 2, 3, 4]), + ); + }); + + test('with single edges', () { + expect( + _topologicalSort( + { + 1: [2], + 2: [3], + 3: [4], + 4: [] + }, + secondarySort: true, + ), + equals([1, 2, 3, 4]), + ); + }); + + test('with many edges from one node', () { + final result = _topologicalSort( + { + 1: [2, 3, 4], + 2: [], + 3: [], + 4: [] + }, + secondarySort: true, + ); + expect(result.indexOf(1), lessThan(result.indexOf(2))); + expect(result.indexOf(1), lessThan(result.indexOf(3))); + expect(result.indexOf(1), lessThan(result.indexOf(4))); + }); + + test('with transitive edges', () { + final result = _topologicalSort( + { + 1: [2, 4], + 2: [], + 3: [], + 4: [3] + }, + secondarySort: true, + ); + expect(result.indexOf(1), lessThan(result.indexOf(2))); + expect(result.indexOf(1), lessThan(result.indexOf(3))); + expect(result.indexOf(1), lessThan(result.indexOf(4))); + expect(result.indexOf(4), lessThan(result.indexOf(3))); + }); + + test('with diamond edges', () { + final result = _topologicalSort( + { + 1: [2, 3], + 2: [4], + 3: [4], + 4: [] + }, + secondarySort: true, + ); + expect(result.indexOf(1), lessThan(result.indexOf(2))); + expect(result.indexOf(1), lessThan(result.indexOf(3))); + expect(result.indexOf(1), lessThan(result.indexOf(4))); + expect(result.indexOf(2), lessThan(result.indexOf(4))); + expect(result.indexOf(3), lessThan(result.indexOf(4))); + }); + }); + + group('lexically sorts a graph where possible', () { + test('with no edges', () { + final result = + _topologicalSort({4: [], 3: [], 1: [], 2: []}, secondarySort: true); + expect(result, equals([1, 2, 3, 4])); + }); + + test('with one non-lexical edge', () { + final result = _topologicalSort( + { + 4: [], + 3: [1], + 1: [], + 2: [] + }, + secondarySort: true, + ); + expect( + result, + equals( + anyOf([ + [2, 3, 1, 4], + [3, 1, 2, 4] + ]), + ), + ); + }); + + test('with a non-lexical topolgical order', () { + final result = _topologicalSort( + { + 4: [3], + 3: [2], + 2: [1], + 1: [] + }, + secondarySort: true, + ); + expect(result, equals([4, 3, 2, 1])); + }); + + group('with multiple layers', () { + test('in lexical order', () { + final result = _topologicalSort( + { + 1: [2], + 2: [3], + 3: [], + 4: [5], + 5: [6], + 6: [] + }, + secondarySort: true, + ); + expect(result, equals([1, 2, 3, 4, 5, 6])); + }); + + test('in non-lexical order', () { + final result = _topologicalSort( + { + 1: [3], + 3: [5], + 4: [2], + 2: [6], + 5: [], + 6: [] + }, + secondarySort: true, + ); + expect( + result, + anyOf([ + equals([1, 3, 4, 2, 5, 6]), + equals([1, 4, 2, 3, 5, 6]) + ]), + ); + }); + }); + }); + + test('respects custom equality and hash functions', () { + expect( + _topologicalSort( + { + 0: [2], + 3: [4], + 5: [6], + 7: [] + }, + equals: (i, j) => (i ~/ 2) == (j ~/ 2), + hashCode: (i) => (i ~/ 2).hashCode, + secondarySort: true, + ), + equals([ + 0, + anyOf([2, 3]), + anyOf([4, 5]), + anyOf([6, 7]) + ]), + ); + }); + + group('throws a CycleException for a graph with', () { + test('a one-node cycle', () { + expect( + () => _topologicalSort( + { + 1: [1] + }, + secondarySort: true, + ), + throwsCycleException([1]), + ); + }); + + test('a multi-node cycle', () { + expect( + () => _topologicalSort( + { + 1: [2], + 2: [3], + 3: [4], + 4: [1] + }, + secondarySort: true, + ), + throwsCycleException([4, 1, 2, 3]), + ); + }); + }); + }); +} + +/// Runs a topological sort on a graph represented a map from keys to edges. +List _topologicalSort( + Map> graph, { + bool Function(T, T)? equals, + int Function(T)? hashCode, + bool secondarySort = false, +}) { + if (equals != null) { + graph = LinkedHashMap(equals: equals, hashCode: hashCode)..addAll(graph); + } + return topologicalSort( + graph.keys, + (node) { + expect(graph, contains(node)); + return graph[node]!; + }, + equals: equals, + hashCode: hashCode, + secondarySort: + secondarySort ? (a, b) => (a as Comparable).compareTo(b) : null, + ); +} diff --git a/pkgs/graphs/test/transitive_closure_test.dart b/pkgs/graphs/test/transitive_closure_test.dart new file mode 100644 index 0000000000..65ea3235ef --- /dev/null +++ b/pkgs/graphs/test/transitive_closure_test.dart @@ -0,0 +1,350 @@ +// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:collection'; + +import 'package:graphs/graphs.dart'; +import 'package:test/test.dart'; + +import 'utils/utils.dart'; + +void main() { + group('for an acyclic graph', () { + for (final acyclic in [true, false]) { + group('with acyclic: $acyclic', () { + group('returns the transitive closure for a graph', () { + test('with no nodes', () { + expect(_transitiveClosure({}, acyclic: acyclic), isEmpty); + }); + + test('with only one node', () { + expect( + _transitiveClosure({1: []}, acyclic: acyclic), + equals({1: {}}), + ); + }); + + test('with no edges', () { + expect( + _transitiveClosure( + {1: [], 2: [], 3: [], 4: []}, + acyclic: acyclic, + ), + equals(>{1: {}, 2: {}, 3: {}, 4: {}}), + ); + }); + + test('with single edges', () { + expect( + _transitiveClosure( + { + 1: [2], + 2: [3], + 3: [4], + 4: [] + }, + acyclic: acyclic, + ), + equals({ + 1: {2, 3, 4}, + 2: {3, 4}, + 3: {4}, + 4: {} + }), + ); + }); + + test('with many edges from one node', () { + expect( + _transitiveClosure( + { + 1: [2, 3, 4], + 2: [], + 3: [], + 4: [] + }, + acyclic: acyclic, + ), + equals(>{ + 1: {2, 3, 4}, + 2: {}, + 3: {}, + 4: {} + }), + ); + }); + + test('with transitive edges', () { + expect( + _transitiveClosure( + { + 1: [2, 4], + 2: [], + 3: [], + 4: [3] + }, + acyclic: acyclic, + ), + equals(>{ + 1: {2, 3, 4}, + 2: {}, + 3: {}, + 4: {3} + }), + ); + }); + + test('with diamond edges', () { + expect( + _transitiveClosure( + { + 1: [2, 3], + 2: [4], + 3: [4], + 4: [] + }, + acyclic: acyclic, + ), + equals(>{ + 1: {2, 3, 4}, + 2: {4}, + 3: {4}, + 4: {} + }), + ); + }); + + test('with disjoint subgraphs', () { + expect( + _transitiveClosure( + { + 1: [2], + 2: [3], + 3: [], + 4: [5], + 5: [6], + 6: [], + }, + acyclic: acyclic, + ), + equals(>{ + 1: {2, 3}, + 2: {3}, + 3: {}, + 4: {5, 6}, + 5: {6}, + 6: {}, + }), + ); + }); + }); + + test('respects custom equality and hash functions', () { + final result = _transitiveClosure( + { + 0: [2], + 3: [4], + 5: [6], + 7: [] + }, + equals: (i, j) => (i ~/ 2) == (j ~/ 2), + hashCode: (i) => (i ~/ 2).hashCode, + ); + + expect( + result.keys, + unorderedMatches([ + 0, + anyOf([2, 3]), + anyOf([4, 5]), + anyOf([6, 7]) + ]), + ); + expect( + result[0], + equals({ + anyOf([2, 3]), + anyOf([4, 5]), + anyOf([6, 7]) + }), + ); + expect( + result[2], + equals({ + anyOf([4, 5]), + anyOf([6, 7]) + }), + ); + expect( + result[4], + equals({ + anyOf([6, 7]) + }), + ); + expect(result[6], isEmpty); + }); + }); + } + }); + + group('for a cyclic graph', () { + group('with acyclic: true throws a CycleException for a graph with', () { + test('a one-node cycle', () { + expect( + () => _transitiveClosure( + { + 1: [1] + }, + acyclic: true, + ), + throwsCycleException([1]), + ); + }); + + test('a multi-node cycle', () { + expect( + () => _transitiveClosure( + { + 1: [2], + 2: [3], + 3: [4], + 4: [1] + }, + acyclic: true, + ), + throwsCycleException([4, 1, 2, 3]), + ); + }); + }); + + group('returns the transitive closure for a graph', () { + test('with a single one-node component', () { + expect( + _transitiveClosure({ + 1: [1] + }), + equals({ + 1: {1} + }), + ); + }); + + test('with a single multi-node component', () { + expect( + _transitiveClosure({ + 1: [2], + 2: [3], + 3: [4], + 4: [1] + }), + equals({ + 1: {1, 2, 3, 4}, + 2: {1, 2, 3, 4}, + 3: {1, 2, 3, 4}, + 4: {1, 2, 3, 4} + }), + ); + }); + + test('with a series of multi-node components', () { + expect( + _transitiveClosure({ + 1: [2], + 2: [1, 3], + 3: [4], + 4: [3, 5], + 5: [6], + 6: [5, 7], + 7: [8], + 8: [7], + }), + equals({ + 1: {1, 2, 3, 4, 5, 6, 7, 8}, + 2: {1, 2, 3, 4, 5, 6, 7, 8}, + 3: {3, 4, 5, 6, 7, 8}, + 4: {3, 4, 5, 6, 7, 8}, + 5: {5, 6, 7, 8}, + 6: {5, 6, 7, 8}, + 7: {7, 8}, + 8: {7, 8} + }), + ); + }); + + test('with a diamond of multi-node components', () { + expect( + _transitiveClosure({ + 1: [2], + 2: [1, 3, 5], + 3: [4], + 4: [3, 7], + 5: [6], + 6: [5, 7], + 7: [8], + 8: [7], + }), + equals({ + 1: {1, 2, 3, 4, 5, 6, 7, 8}, + 2: {1, 2, 3, 4, 5, 6, 7, 8}, + 3: {3, 4, 7, 8}, + 4: {3, 4, 7, 8}, + 5: {5, 6, 7, 8}, + 6: {5, 6, 7, 8}, + 7: {7, 8}, + 8: {7, 8} + }), + ); + }); + + test('mixed single- and multi-node components', () { + expect( + _transitiveClosure({ + 1: [2], + 2: [1, 3], + 3: [4], + 4: [5], + 5: [4, 6], + 6: [7], + 7: [8], + 8: [7], + }), + equals({ + 1: {1, 2, 3, 4, 5, 6, 7, 8}, + 2: {1, 2, 3, 4, 5, 6, 7, 8}, + 3: {4, 5, 6, 7, 8}, + 4: {4, 5, 6, 7, 8}, + 5: {4, 5, 6, 7, 8}, + 6: {7, 8}, + 7: {7, 8}, + 8: {7, 8} + }), + ); + }); + }); + }); +} + +/// Returns the transitive closure of a graph represented a map from keys to +/// edges. +Map> _transitiveClosure( + Map> graph, { + bool Function(T, T)? equals, + int Function(T)? hashCode, + bool acyclic = false, +}) { + assert((equals == null) == (hashCode == null)); + if (equals != null) { + graph = LinkedHashMap(equals: equals, hashCode: hashCode)..addAll(graph); + } + return transitiveClosure( + graph.keys, + (node) { + expect(graph, contains(node)); + return graph[node]!; + }, + equals: equals, + hashCode: hashCode, + acyclic: acyclic, + ); +} diff --git a/pkgs/graphs/test/utils/graph.dart b/pkgs/graphs/test/utils/graph.dart new file mode 100644 index 0000000000..ffa24b5e5f --- /dev/null +++ b/pkgs/graphs/test/utils/graph.dart @@ -0,0 +1,47 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:collection'; + +import 'utils.dart'; + +/// A representation of a Graph since none is specified in `lib/`. +class Graph { + final Map?> _graph; + + Graph(this._graph); + + List edges(String node) => _graph[node] ?? []; + + Iterable get allNodes => _graph.keys; +} + +class BadGraph { + final Map?> _graph; + + BadGraph(Map?> values) + : _graph = LinkedHashMap(equals: xEquals, hashCode: xHashCode) + ..addEntries( + values.entries + .map((e) => MapEntry(X(e.key), e.value?.map(X.new).toList())), + ); + + List edges(X node) => _graph[node] ?? []; + + Iterable get allNodes => _graph.keys; +} + +/// A representation of a Graph where keys can asynchronously be resolved to +/// real values or to edges. +class AsyncGraph { + final Map?> graph; + + AsyncGraph(this.graph); + + Future readNode(String node) async => + graph.containsKey(node) ? node : null; + + Future> edges(String key, String? node) async => + graph[key] ?? []; +} diff --git a/pkgs/graphs/test/utils/utils.dart b/pkgs/graphs/test/utils/utils.dart new file mode 100644 index 0000000000..447733ff56 --- /dev/null +++ b/pkgs/graphs/test/utils/utils.dart @@ -0,0 +1,37 @@ +// Copyright (c) 2018, the Dart project authors. Please see the AUTHORS file +// for details. 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:graphs/graphs.dart'; +import 'package:test/test.dart'; + +bool xEquals(X a, X b) => a.value == b.value; + +int xHashCode(X a) => a.value.hashCode; + +/// Returns a matcher that verifies that a function throws a [CycleException] +/// with the given [cycle]. +Matcher throwsCycleException(List cycle) => throwsA( + allOf([ + isA>(), + predicate((exception) { + expect((exception as CycleException).cycle, equals(cycle)); + return true; + }) + ]), + ); + +class X { + final String value; + + X(this.value); + + @override + bool operator ==(Object other) => throw UnimplementedError(); + + @override + int get hashCode => 42; + + @override + String toString() => '($value)'; +}