diff --git a/CHANGELOG.md b/CHANGELOG.md index c4e2893..68f7778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## 0.3.0 + +Internals rewritten; the window class is now `RebuildWindow` and print mode +is `--dart-define=PRINT_REBUILDS=true`. The rebuild tracking core +(the window, the matchers, the failure tables and baseline files) was +written again from scratch. The guard (`package:rebuildcop/guard.dart` and +`dart run rebuildcop:check`) is unchanged. + +### Breaking changes + +- The counting window class is renamed to `RebuildWindow`. Its members are + the same: `RebuildWindow.open`, `RebuildWindow.record`, `close()`, + `countOf`, `countNamed`, `countWhere`, `countUnder`, `mountsOf`, + `presentTypes`, `countsByType`, `describeBusiest`, `counterDelta`, + `scrollDelta`, `RebuildWindow.printCounts` and + `RebuildWindow.updateBaselines`. There is no alias for the old name: + replace it with `RebuildWindow` in tests, including return types such as + `Future` in test helpers. +- Print mode is turned on with `--dart-define=PRINT_REBUILDS=true`; the + 0.2.0 define is no longer read. Printed lines start with `[rebuildcop]`. +- The entries of `rebuilds` and `mounts` are `BuildRecord`s, and their + `ancestors` are `AncestorSnapshot`s, both exported. Code that only read + their fields (`widget`, `widgetType`, `typeName`, `isMount`, `ancestors`) + needs no change. +- A matcher given something other than a window says + `is not a RebuildWindow`. + +### Unchanged + +- Every matcher (`rebuiltAtMost`, `mountedAtMost`, `subtreeRebuiltAtMost`, + `interactionHappened`, `matchesRebuildBaseline`) and `allowAbsent`, with + the failure messages shown in the README and FAQ. +- Baseline files: the same JSON format (one object per key, one whole-number + budget per type name, keys sorted, two-space indent) and the same paths, + so files written by 0.2.0 and 0.1.x are read as they are. +- `UPDATE_REBUILD_BUDGETS`, `expectRebuildBudgetIsTestOnly` and + `dart run rebuildcop:check`. + ## 0.2.0 Renamed from `imaji_rebuild_budget`. The API is the same; only the package @@ -42,7 +80,7 @@ widget type was never built now fail by name, rejects two tracked entries with the same name, and does not write a baseline in update mode while one of its types is absent. - `interactionHappened(rebuilt:)` says when the type was never there at all. -- `RebuildCounter.open` and `RebuildCounter.record` throw a `StateError` +- Opening a window (`open` and `record`) throws a `StateError` unless the widgets binding is a `TestWidgetsFlutterBinding`, so a window can no longer be opened in a debug run of the app, for example from a debug menu. `LiveTestWidgetsFlutterBinding` and `integration_test` are @@ -53,7 +91,7 @@ widget type was never built now fail - `allowAbsent` on `rebuiltAtMost`, `mountedAtMost`, `subtreeRebuiltAtMost` and `matchesRebuildBaseline`. -- `RebuildCounter.presentTypes`: the widget types that had an element in the +- `presentTypes` on the window: the widget types that had an element in the tree during the window, collected by the walks that already run at `open()` and `close()`. - `package:rebuildcop/guard.dart` with @@ -88,17 +126,17 @@ Documentation only; no API or behaviour change. First release. -- `RebuildCounter`: a counting window over Flutter's +- The counting window over Flutter's `debugOnRebuildDirtyWidget` hook. Windows can overlap, keep and restore a previously installed hook, report a hook replaced mid-window, and fail a test that leaves one open. Mounts and rebuilds are counted apart. - `RebuildCounter.record` wraps an interaction in a window. + `record` wraps an interaction in a window. - Matchers: `rebuiltAtMost` (by type, by name, or total), `subtreeRebuiltAtMost` (by finder), `mountedAtMost`. - `interactionHappened`: proof that a window's interaction ran (a scroll offset moved, a fetch count or named counter changed, a type rebuilt). - Failure output lists the busiest widget types with the budget beside each. -- Print mode (`--dart-define=REBUILD_COUNTS=true`) prints every window's +- Print mode (a `--dart-define` flag) prints every window's per-type counts and every measurement beside its budget. - `matchesRebuildBaseline`: golden-style JSON budget files, rewritten with `--dart-define=UPDATE_REBUILD_BUDGETS=true`. diff --git a/README.md b/README.md index 6f98db0..4af774e 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,11 @@ void main() => ### Coming from imaji_rebuild_budget -This package was renamed from `imaji_rebuild_budget`; the API is the same. -Replace the dev dependency with `rebuildcop`, change -`package:imaji_rebuild_budget/` imports to `package:rebuildcop/`, and run -`dart run rebuildcop:check`. See the +This package was renamed from `imaji_rebuild_budget`. Replace the dev +dependency with `rebuildcop`, change `package:imaji_rebuild_budget/` imports +to `package:rebuildcop/`, and run `dart run rebuildcop:check`. Since 0.3.0 +the window class is `RebuildWindow` and print mode is `PRINT_REBUILDS`. See +the [CHANGELOG](https://github.com/saimskywalker/rebuildcop/blob/main/CHANGELOG.md). ## Quick start @@ -76,7 +77,7 @@ void main() { testWidgets('scrolling the feed never rebuilds the tab bar', (tester) async { await tester.pumpWidget(const MyApp()); - final window = RebuildCounter.open(label: 'scroll the feed'); + final window = RebuildWindow.open(label: 'scroll the feed'); await tester.drag(find.byType(ListView), const Offset(0, -600)); await tester.pumpAndSettle(); window.close(); @@ -88,7 +89,7 @@ void main() { ``` Run `flutter test`. To pick a budget, measure first: run with -`--dart-define=REBUILD_COUNTS=true` and every window prints its per-type +`--dart-define=PRINT_REBUILDS=true` and every window prints its per-type counts. ## Example output @@ -98,7 +99,7 @@ product list once a second. Three ticks, budget zero: ```text Expected: ProductTile rebuilt at most 0 times in the window - Actual: RebuildCounter: + Actual: RebuildWindow: Which: rebuilt 15 times, 15 over budget. 42 rebuilds across 5 widget types, busiest first: 18 Text @@ -132,7 +133,7 @@ counted, a framework limitation; budget the widget that builds them. ## Documentation -- [API: `RebuildCounter` and every matcher](https://github.com/saimskywalker/rebuildcop/blob/main/doc/api.md) +- [API: `RebuildWindow` and every matcher](https://github.com/saimskywalker/rebuildcop/blob/main/doc/api.md) - [Measuring budgets (print mode) and baseline files](https://github.com/saimskywalker/rebuildcop/blob/main/doc/budgets.md) - [Examples: setState, keys, Riverpod, Provider, Bloc](https://github.com/saimskywalker/rebuildcop/blob/main/doc/examples.md) - [FAQ, known gaps, supported versions and comparison](https://github.com/saimskywalker/rebuildcop/blob/main/doc/faq.md) diff --git a/doc/api.md b/doc/api.md index 8541df1..55782d3 100644 --- a/doc/api.md +++ b/doc/api.md @@ -4,21 +4,22 @@ Everything is exported from `package:rebuildcop/rebuildcop.dart`, except the [guard](#guard-keeping-the-package-out-of-the-app), which has a library of its own. -## `RebuildCounter`, the counting window +## `RebuildWindow`, the counting window | Member | What it does | |---|---| -| `RebuildCounter.open({label, fetchCount, counters})` | Starts counting. Call it after the widget is pumped, right before the interaction. Throws a `StateError` outside `flutter test` (no `TestWidgetsFlutterBinding`). | -| `RebuildCounter.record(() async { ... }, {...})` | Opens, runs the interaction, closes, even when it throws. | +| `RebuildWindow.open({label, fetchCount, counters})` | Starts counting. Call it after the widget is pumped, right before the interaction. Throws a `StateError` outside `flutter test` (no `TestWidgetsFlutterBinding`). | +| `RebuildWindow.record(() async { ... }, {...})` | Opens, runs the interaction, closes, even when it throws. | | `close()` | Stops counting and snapshots scroll offsets and counters. | | `countOf(Type)` / `countNamed('_Private')` / `countWhere(test)` | Rebuilds of an exact type, a type by name, or widgets matching a predicate. | | `countUnder(Finder, {of})` | Rebuilds of a subtree, optionally of one type inside it. | | `presentTypes` | Widget types that had an element in the tree during the window. A type budget for anything else fails. | | `mountsOf(Type)`, `mounts`, `rebuilds`, `total` | Raw records. A *mount* is an element built for the first time inside the window; a *rebuild* is an element that already existed building again. | +| `BuildRecord` | One entry of `mounts` or `rebuilds`: `widget`, `widgetType`, `typeName`, `isMount`, `element`, and `ancestors` (the enclosing widgets at build time, nearest first, as `AncestorSnapshot`s). | | `countsByType()`, `describeBusiest({top, budgets, under})` | Per-type counts and the failure table, for your own `reason:` strings. | | `counterDelta(name)`, `scrollDelta(Finder)` | How much a counter or a scroll offset changed between `open` and `close`. | -| `RebuildCounter.printCounts` | Print mode; defaults to `--dart-define=REBUILD_COUNTS=true`. | -| `RebuildCounter.updateBaselines` | Baseline update mode; defaults to `--dart-define=UPDATE_REBUILD_BUDGETS=true`. | +| `RebuildWindow.printCounts` | Print mode; defaults to `--dart-define=PRINT_REBUILDS=true`. | +| `RebuildWindow.updateBaselines` | Baseline update mode; defaults to `--dart-define=UPDATE_REBUILD_BUDGETS=true`. | Windows may overlap and close in any order. A hook installed before the first window opens keeps receiving every call and is put back afterwards. If @@ -62,7 +63,7 @@ when the window opens and when it closes: ```dart final api = FakeInboxApi(); -final window = RebuildCounter.open(fetchCount: () => api.calls); +final window = RebuildWindow.open(fetchCount: () => api.calls); for (var i = 0; i < 5; i++) { await tester.pump(const Duration(seconds: 15)); // the poll interval } diff --git a/doc/budgets.md b/doc/budgets.md index ce8dd77..abd2a34 100644 --- a/doc/budgets.md +++ b/doc/budgets.md @@ -6,17 +6,17 @@ Never guess a budget. Run the interaction, read the count, write that number down: ```sh -flutter test --dart-define=REBUILD_COUNTS=true test/feed_test.dart +flutter test --dart-define=PRINT_REBUILDS=true test/feed_test.dart ``` ``` -[rebuild counts] window "three clock ticks": 42 rebuilds, 0 mounts +[rebuildcop] window "three clock ticks": 42 rebuilds, 0 mounts 18 Text 15 ProductTile 3 ClockText 3 ProductList 3 TickingShopSlow -[rebuild counts] ProductTile rebuilt in "three clock ticks": measured 15, budget 0 (OVER) +[rebuildcop] ProductTile rebuilt in "three clock ticks": measured 15, budget 0 (OVER) ``` Every window prints its per-type counts when it closes, and every budget diff --git a/doc/faq.md b/doc/faq.md index 17ca2f3..d47dc44 100644 --- a/doc/faq.md +++ b/doc/faq.md @@ -15,7 +15,7 @@ or Windows runner. No device, emulator, simulator or `integration_test`. The hook only fires in debug builds, which is what `flutter test` always runs. **Can I show rebuild counts in a debug build of the app?** -No. `RebuildCounter.open` throws a `StateError` unless the widgets binding is +No. `RebuildWindow.open` throws a `StateError` unless the widgets binding is a `TestWidgetsFlutterBinding`, the binding `testWidgets` sets up: ```text @@ -114,7 +114,7 @@ proof that the interaction ran. **Why do I have to call `close()`?** So the counts cover exactly the interaction. Frames pumped later (settling -animations, teardown) cannot change them. Use `RebuildCounter.record` if you +animations, teardown) cannot change them. Use `RebuildWindow.record` if you prefer a closure. **Does it work with `flutter test --platform chrome`?** @@ -142,7 +142,7 @@ By hand in `pubspec.yaml`: ```yaml dev_dependencies: - rebuildcop: ^0.2.0 + rebuildcop: ^0.3.0 ``` To try unreleased changes from `main` instead of a published version (it can diff --git a/example/README.md b/example/README.md index 8e8d9dd..8954108 100644 --- a/example/README.md +++ b/example/README.md @@ -7,7 +7,7 @@ telling the two apart. Run them with: ```sh cd example flutter test -flutter test --dart-define=REBUILD_COUNTS=true # print every measured count +flutter test --dart-define=PRINT_REBUILDS=true # print every measured count ``` | File | Over-rebuild | Fix | @@ -25,7 +25,7 @@ testWidgets('AFTER: ref.watch(cartProvider.select(...)) leaves it alone', (tester) async { await tester.pumpWidget(/* the screen */); - final window = RebuildCounter.open(label: 'apply a coupon'); + final window = RebuildWindow.open(label: 'apply a coupon'); container.read(cartProvider.notifier).applyCoupon('SPRING'); await tester.pump(); window.close(); diff --git a/example/test/bloc_test.dart b/example/test/bloc_test.dart index 9c3c14e..c55a8ac 100644 --- a/example/test/bloc_test.dart +++ b/example/test/bloc_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rebuildcop/rebuildcop.dart'; import 'package:rebuildcop_example/bloc/download.dart'; -Future fiveProgressTicks( +Future fiveProgressTicks( WidgetTester tester, { required bool selective, }) async { @@ -25,7 +25,7 @@ Future fiveProgressTicks( ); var ticks = 0; - final window = RebuildCounter.open( + final window = RebuildWindow.open( label: 'five progress ticks', counters: {'ticks': () => ticks}, ); diff --git a/example/test/provider_test.dart b/example/test/provider_test.dart index 66a9027..54e1c8d 100644 --- a/example/test/provider_test.dart +++ b/example/test/provider_test.dart @@ -4,7 +4,7 @@ import 'package:rebuildcop/rebuildcop.dart'; import 'package:rebuildcop_example/provider/reader_settings.dart'; import 'package:provider/provider.dart'; -Future toggleTheme(WidgetTester tester, Widget label) async { +Future toggleTheme(WidgetTester tester, Widget label) async { final settings = ReaderSettings(); addTearDown(settings.dispose); await tester.pumpWidget( @@ -17,7 +17,7 @@ Future toggleTheme(WidgetTester tester, Widget label) async { ), ); - final window = RebuildCounter.open(label: 'toggle dark mode'); + final window = RebuildWindow.open(label: 'toggle dark mode'); settings.toggleDarkMode(); await tester.pump(); window.close(); diff --git a/example/test/riverpod_test.dart b/example/test/riverpod_test.dart index 0643e64..ecaea4d 100644 --- a/example/test/riverpod_test.dart +++ b/example/test/riverpod_test.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:rebuildcop/rebuildcop.dart'; import 'package:rebuildcop_example/riverpod/cart.dart'; -Future applyCoupon(WidgetTester tester, Widget badge) async { +Future applyCoupon(WidgetTester tester, Widget badge) async { await tester.pumpWidget( ProviderScope( child: Directionality( @@ -17,7 +17,7 @@ Future applyCoupon(WidgetTester tester, Widget badge) async { tester.element(find.byWidget(badge)), ); - final window = RebuildCounter.open(label: 'apply a coupon'); + final window = RebuildWindow.open(label: 'apply a coupon'); container.read(cartProvider.notifier).applyCoupon('SPRING'); await tester.pump(); window.close(); @@ -57,7 +57,7 @@ void main() { tester.element(find.byType(CartBadgeFast)), ); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); container.read(cartProvider.notifier).add('Desk'); await tester.pump(); window.close(); diff --git a/example/test/set_state_test.dart b/example/test/set_state_test.dart index 27c2f56..656ece9 100644 --- a/example/test/set_state_test.dart +++ b/example/test/set_state_test.dart @@ -8,8 +8,8 @@ Widget _host(Widget child) => Directionality(textDirection: TextDirection.ltr, child: child); /// Lets the clock tick three times, one frame per tick. -Future _threeTicks(WidgetTester tester) async { - final window = RebuildCounter.open(label: 'three clock ticks'); +Future _threeTicks(WidgetTester tester) async { + final window = RebuildWindow.open(label: 'three clock ticks'); for (var i = 0; i < 3; i++) { await tester.pump(const Duration(seconds: 1)); } @@ -43,16 +43,16 @@ void main() { expect(window, interactionHappened(rebuilt: ClockText)); expect(window, rebuiltAtMost(0, of: ProductTile)); expect(window, rebuiltAtMost(0, of: ProductList)); - // Measured with --dart-define=REBUILD_COUNTS=true: per tick, the + // Measured with --dart-define=PRINT_REBUILDS=true: per tick, the // clock, its ClockText and that label's Text. expect(window, subtreeRebuiltAtMost(9, under: find.byType(LiveClock))); }); }); group('re-sorting a keyed list', () { - Future reverse(WidgetTester tester, bool stable) async { + Future reverse(WidgetTester tester, bool stable) async { await tester.pumpWidget(_host(SortableResults(stableKeys: stable))); - final window = RebuildCounter.open(label: 'reverse the results'); + final window = RebuildWindow.open(label: 'reverse the results'); tester .state(find.byType(SortableResults)) .reverse(); diff --git a/lib/rebuildcop.dart b/lib/rebuildcop.dart index b0c62f5..16e11ef 100644 --- a/lib/rebuildcop.dart +++ b/lib/rebuildcop.dart @@ -1,30 +1,15 @@ /// Rebuild budgets for Flutter widget tests. /// -/// Open a [RebuildCounter] window around an interaction, close it, and -/// assert that a widget type or a whole subtree rebuilt no more than a -/// measured budget: +/// Open a `RebuildWindow` around an interaction, close it, and assert on it +/// with `rebuiltAtMost`, `subtreeRebuiltAtMost`, `mountedAtMost`, +/// `interactionHappened` and `matchesRebuildBaseline`. /// -/// ```dart -/// final window = RebuildCounter.open(); -/// await tester.tap(find.byIcon(Icons.add)); -/// await tester.pump(); -/// window.close(); -/// -/// expect(window, interactionHappened(rebuilt: CounterText)); -/// expect(window, rebuiltAtMost(0, of: ProductList)); -/// ``` -/// -/// It listens to Flutter's own [debugOnRebuildDirtyWidget] hook, so it runs -/// headless in plain `flutter test` and the code under test carries no -/// probes. +/// Import this library from tests only: it depends on `flutter_test`. library; -import 'package:flutter/widgets.dart' show debugOnRebuildDirtyWidget; - -import 'src/rebuild_counter.dart'; - -export 'src/baseline.dart' show matchesRebuildBaseline; -export 'src/interaction.dart' show interactionHappened; -export 'src/matchers.dart' +export 'src/tally/build_record.dart' show AncestorSnapshot, BuildRecord; +export 'src/tally/rebuild_window.dart' show RebuildWindow; +export 'src/verdict/baseline/baseline_matcher.dart' show matchesRebuildBaseline; +export 'src/verdict/budgets.dart' show mountedAtMost, rebuiltAtMost, subtreeRebuiltAtMost; -export 'src/rebuild_counter.dart' show RebuildCounter, RebuildRecord; +export 'src/verdict/proof.dart' show interactionHappened; diff --git a/lib/src/baseline.dart b/lib/src/baseline.dart deleted file mode 100644 index 840ae37..0000000 --- a/lib/src/baseline.dart +++ /dev/null @@ -1,211 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'baseline_store.dart'; -import 'matchers.dart' show defaultTopTypes; -import 'rebuild_counter.dart'; -import 'report.dart'; - -/// Matches a closed [RebuildCounter] against budgets stored in a JSON file, -/// the way `matchesGoldenFile` matches pixels. -/// -/// [path] is resolved against the directory of the test file, like a golden -/// file. The file maps each [key] to the budget of every tracked widget -/// type: -/// -/// ```json -/// { -/// "scroll the feed": { -/// "FeedTile": 12, -/// "TabBar": 0 -/// } -/// } -/// ``` -/// -/// [types] and [named] list the widget types this window tracks (`named` -/// is for private types such as `'_Header'`). Each must have rebuilt no more -/// than its stored budget. A type that is tracked but missing from the -/// file, or stored but no longer tracked, is a failure too, so the file -/// always describes exactly what the test checks. -/// -/// A tracked type that had no element anywhere in the window fails, in -/// update mode as well, because its budget could never fail (see -/// `rebuiltAtMost`); pass [allowAbsent] when one may legitimately be -/// missing. So does a [named] entry that two different types rebuilt under. -/// The file keys each type by its name, so two tracked entries with the -/// same name are rejected. -/// -/// Run once with `--dart-define=UPDATE_REBUILD_BUDGETS=true` to write the -/// measured counts as the budgets. Commit the file; from then on a raised -/// budget shows up as a reviewed diff instead of a silently edited number. -/// -/// Keep one baseline file per test file: test files run in parallel, and -/// two of them rewriting one file in update mode would race. -Matcher matchesRebuildBaseline( - String path, { - required String key, - List types = const [], - List named = const [], - bool allowAbsent = false, - int top = defaultTopTypes, -}) { - // Each tracked name, with its exact type when it came from `types`. - final byName = {}; - void track(String name, Type? type) { - if (byName.containsKey(name) && byName[name] != type) { - throw ArgumentError( - 'matchesRebuildBaseline tracks two widget types named $name; the ' - 'baseline file keys budgets by name, so it cannot tell them apart.', - ); - } - byName[name] = type; - } - - for (final type in types) { - track('$type', type); - } - for (final name in named) { - track(name, null); - } - if (byName.isEmpty) { - throw ArgumentError( - 'matchesRebuildBaseline needs at least one widget type in `types` or ' - '`named`. Track the few types that matter, not the whole tree.', - ); - } - final tracked = byName.keys.toList()..sort(); - return _BaselineMatcher(path, key, tracked, byName, allowAbsent, top); -} - -class _BaselineMatcher extends Matcher { - const _BaselineMatcher( - this.path, - this.key, - this.tracked, - this.types, - this.allowAbsent, - this.top, - ); - - final String path; - final String key; - final List tracked; - final Map types; - final bool allowAbsent; - final int top; - - static const String _problemsKey = 'problems'; - static const String _tableKey = 'table'; - - @override - bool matches(Object? item, Map matchState) { - final problems = []; - matchState[_problemsKey] = problems; - if (item is! RebuildCounter) { - problems.add('is not a RebuildCounter'); - return false; - } - if (item.isOpen) { - problems.add('is still open; call close() before asserting'); - return false; - } - - final measured = {}; - for (final name in tracked) { - final type = types[name]; - bool counts(RebuildRecord r) => - type != null ? r.widgetType == type : r.typeName == name; - final counted = item.rebuilds.where(counts).toList(); - measured[name] = counted.length; - final problem = typeBudgetProblem( - item, - type: type, - named: type == null ? name : null, - counted: counted, - allowAbsent: allowAbsent, - ); - if (problem != null) problems.add(problem); - } - final resolved = resolveBaselinePath(path); - if (problems.isNotEmpty) { - if (RebuildCounter.updateBaselines) { - problems.add('nothing was written to $resolved'); - } - return false; - } - - if (RebuildCounter.updateBaselines) { - final baseline = readBaseline(resolved) ?? {}; - baseline[key] = measured; - writeBaseline(resolved, baseline); - if (RebuildCounter.printCounts) { - // ignore: avoid_print - print('[rebuild counts] wrote "$key" to $resolved: $measured'); - } - return true; - } - - const hint = 'record it with --dart-define=UPDATE_REBUILD_BUDGETS=true'; - final baseline = readBaseline(resolved); - if (baseline == null) { - problems.add('baseline file $resolved does not exist; $hint'); - return false; - } - final budgets = baseline[key]; - if (budgets == null) { - problems.add('baseline file $resolved has no entry "$key"; $hint'); - return false; - } - - for (final type in tracked) { - final budget = budgets[type]; - final count = measured[type]!; - if (RebuildCounter.printCounts && budget != null) { - // ignore: avoid_print - print(printMeasurement('$type in "$key"', count, budget)); - } - if (budget == null) { - problems.add('$type is tracked but has no budget in "$key"; $hint'); - } else if (count > budget) { - problems.add('$type rebuilt $count times, budget $budget ' - '(over by ${count - budget})'); - } - } - for (final type in budgets.keys) { - if (!tracked.contains(type)) { - problems.add('"$key" stores a budget for $type, which this test no ' - 'longer tracks; $hint'); - } - } - if (problems.isNotEmpty) { - matchState[_tableKey] = busiestTable( - item.rebuilds, - top: top, - budgets: { - for (final type in tracked) - if (budgets[type] != null) type: budgets[type]!, - }, - ); - } - return problems.isEmpty; - } - - @override - Description describe(Description description) => description.add( - 'rebuilds of ${tracked.join(', ')} within the budgets stored under ' - '"$key" in $path', - ); - - @override - Description describeMismatch( - Object? item, - Description mismatchDescription, - Map matchState, - bool verbose, - ) { - final problems = matchState[_problemsKey] as List; - final table = matchState[_tableKey] as String?; - mismatchDescription.add(problems.map((p) => '- $p').join('\n')); - if (table != null) mismatchDescription.add('\n$table'); - return mismatchDescription; - } -} diff --git a/lib/src/baseline_store.dart b/lib/src/baseline_store.dart deleted file mode 100644 index ec29f79..0000000 --- a/lib/src/baseline_store.dart +++ /dev/null @@ -1,7 +0,0 @@ -/// Where a baseline file lives and how it is read and written. -/// -/// The `dart:io` version is used wherever files exist; on the web there is -/// no file system, so every operation throws [UnsupportedError]. -library; - -export 'baseline_store_stub.dart' if (dart.library.io) 'baseline_store_io.dart'; diff --git a/lib/src/baseline_store_io.dart b/lib/src/baseline_store_io.dart deleted file mode 100644 index 11a72c4..0000000 --- a/lib/src/baseline_store_io.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; - -/// Resolves [path] against the directory of the running test file, the way -/// golden files are, falling back to the working directory. -String resolveBaselinePath(String path) { - final uri = Uri.file(path); - if (uri.isAbsolute || File(path).isAbsolute) return path; - final comparator = goldenFileComparator; - final base = comparator is LocalFileComparator - ? comparator.basedir - : Directory.current.uri.resolve('./'); - return base.resolveUri(uri).toFilePath(); -} - -/// Reads the baseline at [resolvedPath], or `null` if it does not exist. -/// -/// Throws a [FormatException] when the file is not a JSON object of -/// objects of integers. -Map>? readBaseline(String resolvedPath) { - final file = File(resolvedPath); - if (!file.existsSync()) return null; - final Object? decoded; - try { - decoded = jsonDecode(file.readAsStringSync()); - } on FormatException catch (error) { - throw FormatException( - 'Rebuild baseline $resolvedPath is not valid JSON: ${error.message}', - ); - } - FormatException shape(String detail) => FormatException( - 'Rebuild baseline $resolvedPath must map each key to an object of ' - 'widget type names and integer budgets; $detail.', - ); - if (decoded is! Map) throw shape('the top level is not'); - return { - for (final entry in decoded.entries) - entry.key: switch (entry.value) { - final Map budgets => { - for (final budget in budgets.entries) - budget.key: switch (budget.value) { - final int n when n >= 0 => n, - _ => throw shape('"${entry.key}" -> "${budget.key}" is not'), - }, - }, - _ => throw shape('"${entry.key}" is not'), - }, - }; -} - -/// Writes [baseline] to [resolvedPath] with sorted keys and two-space -/// indentation, so a changed budget is a one-line diff. -void writeBaseline( - String resolvedPath, - Map> baseline, -) { - final sorted = { - for (final key in baseline.keys.toList()..sort()) - key: { - for (final type in baseline[key]!.keys.toList()..sort()) - type: baseline[key]![type], - }, - }; - final file = File(resolvedPath); - file.parent.createSync(recursive: true); - file.writeAsStringSync( - '${const JsonEncoder.withIndent(' ').convert(sorted)}\n', - ); -} diff --git a/lib/src/baseline_store_stub.dart b/lib/src/baseline_store_stub.dart deleted file mode 100644 index 0591b74..0000000 --- a/lib/src/baseline_store_stub.dart +++ /dev/null @@ -1,18 +0,0 @@ -Never _unsupported() => throw UnsupportedError( - 'Rebuild baselines need a file system (dart:io). Run these tests on ' - 'the Dart VM, which is what plain `flutter test` does.', - ); - -/// Resolves [path] against the directory of the running test file. -String resolveBaselinePath(String path) => _unsupported(); - -/// Reads the baseline at [resolvedPath], or `null` if it does not exist. -Map>? readBaseline(String resolvedPath) => - _unsupported(); - -/// Writes [baseline] to [resolvedPath]. -void writeBaseline( - String resolvedPath, - Map> baseline, -) => - _unsupported(); diff --git a/lib/src/interaction.dart b/lib/src/interaction.dart deleted file mode 100644 index 56c6c67..0000000 --- a/lib/src/interaction.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'rebuild_counter.dart'; - -/// Matches a closed [RebuildCounter] whose interaction provably ran. -/// -/// A budget of zero passes just as happily when the interaction did nothing -/// at all: a drag that missed its target, a timer that never fired, a fake -/// that was never called. Pair every budget with this matcher so a zero -/// means "nothing rebuilt" and not "nothing happened". -/// -/// Every given condition must hold: -/// -/// * [scrollOffsetMoved] finds a [Scrollable], a scroll view that builds one -/// (such as [ListView]), or a widget inside one, whose offset changed -/// between [RebuildCounter.open] and [RebuildCounter.close]. -/// * [fetchCount] is compared with how much the window's `fetchCount` -/// callback changed. An `int` must match exactly; any [Matcher] (such as -/// `greaterThan(0)`) is applied as is. -/// * [counters] does the same for named counters given to -/// [RebuildCounter.open]. -/// * [rebuilt] is a widget type that must have rebuilt at least once, for -/// showing that the screen reacted at all. -/// -/// ```dart -/// final window = RebuildCounter.open(fetchCount: () => inbox.fetches); -/// for (var i = 0; i < 5; i++) { -/// await tester.pump(const Duration(seconds: 15)); -/// } -/// window.close(); -/// -/// expect(window, interactionHappened(fetchCount: 5, rebuilt: UnreadBadge)); -/// expect(window, rebuiltAtMost(0, of: NavigationBar)); -/// ``` -/// -/// Throws an [ArgumentError] when no condition is given. -Matcher interactionHappened({ - Finder? scrollOffsetMoved, - Object? fetchCount, - Map counters = const {}, - Type? rebuilt, -}) { - if (counters.containsKey(fetchCountName) && fetchCount != null) { - throw ArgumentError( - 'Pass the fetch count either as `fetchCount` or in `counters`, ' - 'not both.', - ); - } - final expectedCounters = { - for (final entry in counters.entries) entry.key: wrapMatcher(entry.value), - if (fetchCount != null) fetchCountName: wrapMatcher(fetchCount), - }; - if (scrollOffsetMoved == null && - expectedCounters.isEmpty && - rebuilt == null) { - throw ArgumentError( - 'interactionHappened needs at least one condition: scrollOffsetMoved, ' - 'fetchCount, counters or rebuilt.', - ); - } - return _InteractionMatcher(scrollOffsetMoved, expectedCounters, rebuilt); -} - -class _InteractionMatcher extends Matcher { - const _InteractionMatcher(this.scroll, this.counters, this.rebuilt); - - final Finder? scroll; - final Map counters; - final Type? rebuilt; - - static const String _problemsKey = 'problems'; - - @override - bool matches(Object? item, Map matchState) { - final problems = []; - matchState[_problemsKey] = problems; - if (item is! RebuildCounter) { - problems.add('is not a RebuildCounter'); - return false; - } - if (item.isOpen) { - problems.add('is still open; call close() before asserting'); - return false; - } - - final finder = scroll; - if (finder != null) { - final what = finder.describeMatch(Plurality.many); - if (finder.evaluate().isEmpty) { - problems.add('scrollOffsetMoved: found no $what'); - } else { - final delta = item.scrollDelta(finder); - if (delta == null) { - problems.add('scrollOffsetMoved: no scrollable for $what was on ' - 'screen both when the window opened and when it closed'); - } else if (delta.abs() < _minimumScroll) { - problems.add('scrollOffsetMoved: the offset of $what did not ' - 'change'); - } - } - } - - for (final entry in counters.entries) { - final name = entry.key; - if (!item.counterNames.contains(name)) { - problems.add('$name: the window was opened without ' - '${name == fetchCountName ? 'a fetchCount callback' : 'a counter named "$name"'}'); - continue; - } - final delta = item.counterDelta(name); - final state = {}; - if (!entry.value.matches(delta, state)) { - final expected = entry.value.describe(StringDescription()); - problems.add('$name changed by $delta, expected $expected'); - } - } - - final type = rebuilt; - if (type != null && item.countOf(type) == 0) { - final absent = typeBudgetProblem(item, type: type, budget: false); - problems.add('rebuilt: ${absent ?? '$type never rebuilt in the window'}'); - } - return problems.isEmpty; - } - - @override - Description describe(Description description) { - final parts = [ - if (scroll != null) - 'the scroll offset of ${scroll!.describeMatch(Plurality.one)} moved', - for (final entry in counters.entries) - '${entry.key} changed by ' - '${entry.value.describe(StringDescription())}', - if (rebuilt != null) '$rebuilt rebuilt at least once', - ]; - return description.add('an interaction where ${parts.join(', and ')}'); - } - - @override - Description describeMismatch( - Object? item, - Description mismatchDescription, - Map matchState, - bool verbose, - ) { - final problems = matchState[_problemsKey] as List; - return mismatchDescription.add( - 'gives no proof the interaction ran:\n' - '${problems.map((p) => ' - $p').join('\n')}', - ); - } -} - -/// The smallest offset change, in logical pixels, that counts as a scroll. -const double _minimumScroll = 1e-6; diff --git a/lib/src/matchers.dart b/lib/src/matchers.dart deleted file mode 100644 index ee52ff7..0000000 --- a/lib/src/matchers.dart +++ /dev/null @@ -1,265 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'rebuild_counter.dart'; -import 'report.dart'; - -/// How many busy widget types a failure lists by default. -const int defaultTopTypes = 10; - -/// Matches a closed [RebuildCounter] whose rebuilds stayed within [budget]. -/// -/// With [of], only widgets whose runtime type is exactly that type count. -/// With [named], only widgets whose runtime type has that name count, for a -/// type that is private to the code under test. With neither, every rebuild -/// in the window counts. -/// -/// A failure lists the [top] busiest widget types in the window with the -/// budget beside the one that was checked. -/// -/// ```dart -/// expect(window, rebuiltAtMost(0, of: NavigationBar)); -/// expect(window, rebuiltAtMost(3, named: '_Header')); -/// ``` -/// -/// A budget [of] or [named] a type that had no element anywhere in the -/// window fails, because it could never fail: the widget was renamed or -/// replaced, or the test imports a same-named widget from a different -/// library than the app (`package:flutter/material.dart` and -/// `package:material_ui/material_ui.dart`, for example). To assert that a -/// widget never appears, use `expect(find.byType(T), findsNothing)`. Pass -/// [allowAbsent] to keep a budget whose type may legitimately be missing: -/// -/// ```dart -/// expect(window, rebuiltAtMost(0, of: ErrorBanner, allowAbsent: true)); -/// ``` -/// -/// A budget [named] a name that two different types rebuilt under fails -/// too, rather than add their counts together. -/// -/// The budget should be the count measured when the test was written -/// (see [RebuildCounter.printCounts]). Raising it is a decision to accept -/// more rebuilds, not a way to make a red test green. -/// -/// A budget [of] a render-object widget such as `ColoredBox` or `Padding` -/// passes even when a parent hands it a new widget every frame: Flutter -/// does not report that update to `debugOnRebuildDirtyWidget` -/// ([flutter/flutter#71626](https://github.com/flutter/flutter/issues/71626)). -/// Budget the widget that builds them instead. -Matcher rebuiltAtMost( - int budget, { - Type? of, - String? named, - bool allowAbsent = false, - int top = defaultTopTypes, -}) { - _checkBudget(budget); - if (of != null && named != null) { - throw ArgumentError('Pass either `of` or `named`, not both.'); - } - return _BudgetMatcher( - budget: budget, - top: top, - subject: of != null ? '$of' : named ?? 'all widgets', - budgetKey: of != null ? '$of' : named, - type: of, - named: named, - allowAbsent: allowAbsent, - select: (record) => of != null - ? record.widgetType == of - : named == null || record.typeName == named, - pool: (window) => window.rebuilds, - ); -} - -/// Matches a closed [RebuildCounter] in which the subtree found by [under] -/// rebuilt at most [budget] times. -/// -/// The subtree is the elements [under] finds plus every element below them. -/// It catches the busy inside of a widget whose own root never rebuilt, such -/// as a `ValueListenableBuilder` or a state-library consumer deep in a -/// screen. With [of], only widgets of exactly that type inside the subtree -/// count, for a type that also appears elsewhere on screen. -/// -/// [under] is evaluated when the matcher runs, against the current tree, so -/// assert before the subtree is torn down. A finder that matches nothing is -/// a failure, never a pass, because a subtree that is not there cannot -/// rebuild. Pass `skipOffstage: false` to the finder for an offstage -/// subtree. -/// -/// Like [rebuiltAtMost], a budget [of] a type that had no element anywhere -/// in the window fails unless [allowAbsent] is true. -/// -/// ```dart -/// expect(window, subtreeRebuiltAtMost(0, under: find.byType(BottomNavigationBar))); -/// ``` -Matcher subtreeRebuiltAtMost( - int budget, { - required Finder under, - Type? of, - bool allowAbsent = false, - int top = defaultTopTypes, -}) { - _checkBudget(budget); - return _BudgetMatcher( - budget: budget, - top: top, - subject: of == null - ? 'the subtree under the ${under.describeMatch(Plurality.one)}' - : '$of under the ${under.describeMatch(Plurality.one)}', - budgetKey: of == null ? null : '$of', - type: of, - allowAbsent: allowAbsent, - under: under, - select: (record) => of == null || record.widgetType == of, - pool: (window) => window.rebuilds, - ); -} - -/// Matches a closed [RebuildCounter] in which at most [budget] new elements -/// were mounted, optionally only those of exactly type [of]. -/// -/// Mounts are not rebuilds: a new element means a new `State`, a fresh -/// `initState`, lost scroll positions and text selections. A widget that -/// gets a `UniqueKey()` in `build`, or a subtree whose type changes on -/// every frame, shows up here. -/// -/// New render-object elements (`ColoredBox`, `Padding`, ...) are not seen, -/// because Flutter does not call `debugOnRebuildDirtyWidget` when one is -/// mounted; count the widget that builds them instead. -/// -/// Like [rebuiltAtMost], a budget [of] a type that had no element anywhere -/// in the window fails unless [allowAbsent] is true. -Matcher mountedAtMost( - int budget, { - Type? of, - bool allowAbsent = false, - int top = defaultTopTypes, -}) { - _checkBudget(budget); - return _BudgetMatcher( - budget: budget, - top: top, - subject: of == null ? 'new elements' : 'new $of elements', - verb: 'mounted', - budgetKey: of == null ? null : '$of', - type: of, - allowAbsent: allowAbsent, - select: (record) => of == null || record.widgetType == of, - pool: (window) => window.mounts, - ); -} - -void _checkBudget(int budget) { - if (budget < 0) { - throw ArgumentError.value(budget, 'budget', 'must not be negative'); - } -} - -class _BudgetMatcher extends Matcher { - const _BudgetMatcher({ - required this.budget, - required this.top, - required this.subject, - required this.select, - required this.pool, - this.budgetKey, - this.type, - this.named, - this.allowAbsent = false, - this.under, - this.verb = 'rebuilt', - }); - - final int budget; - final int top; - final String subject; - final String verb; - final String? budgetKey; - final Type? type; - final String? named; - final bool allowAbsent; - final Finder? under; - final bool Function(RebuildRecord record) select; - final List Function(RebuildCounter window) pool; - - static const String _countKey = 'count'; - static const String _problemKey = 'problem'; - static const String _scopeKey = 'scope'; - - @override - bool matches(Object? item, Map matchState) { - if (item is! RebuildCounter) { - matchState[_problemKey] = 'is not a RebuildCounter'; - return false; - } - if (item.isOpen) { - matchState[_problemKey] = 'is still open; call close() before ' - 'asserting, so frames pumped later cannot change the count'; - return false; - } - var records = pool(item); - if (under != null) { - final Set roots; - try { - roots = resolveRoots(under!); - } on StateError catch (error) { - matchState[_problemKey] = error.message; - return false; - } - records = records.where((r) => r.isWithin(roots)).toList(); - } - final counted = records.where(select).toList(); - final count = counted.length; - matchState[_countKey] = count; - matchState[_scopeKey] = records; - if (RebuildCounter.printCounts) { - final window = item.label == null ? '' : ' in "${item.label}"'; - // ignore: avoid_print - print(printMeasurement('$subject $verb$window', count, budget)); - } - if (type != null || named != null) { - final problem = typeBudgetProblem( - item, - type: type, - named: named, - counted: counted, - allowAbsent: allowAbsent, - ); - if (problem != null) { - matchState[_problemKey] = problem; - return false; - } - } - return count <= budget; - } - - @override - Description describe(Description description) => description.add( - '$subject $verb at most $budget ' - '${budget == 1 ? 'time' : 'times'} in the window', - ); - - @override - Description describeMismatch( - Object? item, - Description mismatchDescription, - Map matchState, - bool verbose, - ) { - final problem = matchState[_problemKey]; - if (problem != null) return mismatchDescription.add('$problem'); - final count = matchState[_countKey] as int; - final scope = matchState[_scopeKey] as List; - final key = budgetKey; - return mismatchDescription - .add('$verb $count ${count == 1 ? 'time' : 'times'}, ' - '${count - budget} over budget.\n') - .add(busiestTable( - scope, - top: top, - budgets: key == null ? const {} : {key: budget}, - noun: verb == 'mounted' ? 'mount' : 'rebuild', - )); - } -} diff --git a/lib/src/output/type_table.dart b/lib/src/output/type_table.dart new file mode 100644 index 0000000..e5f487b --- /dev/null +++ b/lib/src/output/type_table.dart @@ -0,0 +1,81 @@ +import '../tally/build_record.dart'; + +/// The tag in front of every line print mode writes. +const String printTag = '[rebuildcop]'; + +/// `1 time`, `2 times`. +String times(int n) => n == 1 ? '1 time' : '$n times'; + +/// [noun] with an `s` unless [n] is 1. +String plural(int n, String noun) => n == 1 ? noun : '${noun}s'; + +/// Counts [records] per type name, busiest first, ties in name order. +List> tallyByName(Iterable records) { + final tally = {}; + for (final record in records) { + tally.update(record.typeName, (n) => n + 1, ifAbsent: () => 1); + } + return tally.entries.toList() + ..sort((a, b) { + final byCount = b.value.compareTo(a.value); + return byCount != 0 ? byCount : a.key.compareTo(b.key); + }); +} + +/// A table of [records] per widget type, busiest first. +/// +/// At most [top] types are listed, plus every type in [budgets] (keyed by +/// type name) that has records, each with its budget beside it. [noun] +/// names what a record is in the header (`rebuild` or `mount`). +String formatTypeTable( + Iterable records, { + int top = 10, + Map budgets = const {}, + String noun = 'rebuild', +}) { + final rows = tallyByName(records); + if (rows.isEmpty) { + return 'no ${noun}s were recorded'; + } + final total = rows.fold(0, (sum, row) => sum + row.value); + final shown = >[]; + for (var i = 0; i < rows.length; i++) { + if (i < top || budgets.containsKey(rows[i].key)) { + shown.add(rows[i]); + } + } + var countWidth = 0; + var nameWidth = 0; + for (final row in shown) { + final digits = '${row.value}'.length; + if (digits > countWidth) countWidth = digits; + if (row.key.length > nameWidth) nameWidth = row.key.length; + } + + final out = StringBuffer( + '$total ${plural(total, noun)} across ${rows.length} ' + '${plural(rows.length, 'widget type')}, busiest first:', + ); + for (final row in shown) { + out.write('\n ${'${row.value}'.padLeft(countWidth)} '); + final budget = budgets[row.key]; + if (budget == null) { + out.write(row.key); + } else { + out.write('${row.key.padRight(nameWidth)} budget $budget'); + if (row.value > budget) { + out.write(', over by ${row.value - budget}'); + } + } + } + final hidden = rows.length - shown.length; + if (hidden > 0) { + out.write('\n ... and $hidden more ${plural(hidden, 'type')}'); + } + return out.toString(); +} + +/// The print-mode line for one measurement against its budget. +String measurementLine(String subject, int measured, int budget) => + '$printTag $subject: measured $measured, budget $budget' + '${measured > budget ? ' (OVER)' : ''}'; diff --git a/lib/src/rebuild_counter.dart b/lib/src/rebuild_counter.dart deleted file mode 100644 index 4d7d2ee..0000000 --- a/lib/src/rebuild_counter.dart +++ /dev/null @@ -1,610 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'report.dart'; - -/// The counter name that [RebuildCounter.open]'s `fetchCount` callback is -/// stored under. Read it back with [RebuildCounter.counterDelta]. -const String fetchCountName = 'fetchCount'; - -/// One build that Flutter reported while a [RebuildCounter] window was open. -/// -/// A record is either a *rebuild* (an element that already existed built -/// again) or a *mount* (an element built for the first time inside the -/// window). Budgets are about rebuilds; mounts are kept separately because -/// a list that scrolls new rows into view is expected to mount them. -@immutable -class RebuildRecord { - const RebuildRecord._({ - required this.element, - required this.widget, - required this.ancestors, - required this.isMount, - }); - - /// The element that built. - final Element element; - - /// The widget the element built with, captured at the moment it built. - final Widget widget; - - /// The element's ancestors at the moment it built, nearest first. - /// - /// Captured eagerly because an element that is later unmounted can no - /// longer be walked, while a subtree question may be asked after it was. - final List ancestors; - - /// Whether this was the element's first build (a mount) rather than a - /// rebuild. - final bool isMount; - - /// The runtime type of [widget]. - Type get widgetType => widget.runtimeType; - - /// The name of [widgetType], as used in reports and baseline files. - String get typeName => widget.runtimeType.toString(); - - /// Whether this record's element is one of [roots] or sits below one. - bool isWithin(Set roots) { - if (roots.contains(element)) return true; - for (final ancestor in ancestors) { - if (roots.contains(ancestor)) return true; - } - return false; - } -} - -/// A counting window over Flutter's widget builds. -/// -/// Open a window, run one interaction, close it, then assert on what was -/// rebuilt in between: -/// -/// ```dart -/// final window = RebuildCounter.open(label: 'scroll the feed'); -/// await tester.drag(find.byType(ListView), const Offset(0, -600)); -/// await tester.pumpAndSettle(); -/// window.close(); -/// -/// expect(window, interactionHappened(scrollOffsetMoved: find.byType(ListView))); -/// expect(window, rebuiltAtMost(2, of: TabBar)); -/// ``` -/// -/// The window listens to [debugOnRebuildDirtyWidget], the framework hook -/// behind DevTools' "Track widget rebuilds", so the code under test needs no -/// keys, probes or counters. The hook only fires in debug builds, which is -/// what `flutter test` runs. -/// -/// Every element already in the tree when the window opens is remembered, so -/// a later build of one of them counts as a rebuild and a build of anything -/// new counts as a mount. -/// -/// Render-object widgets (`ColoredBox`, `Padding`, `SizedBox`, `RichText`, -/// ...) are mostly not counted. Flutter does not call the hook when a -/// parent hands a `RenderObjectElement` a new widget (its `update` calls -/// `updateRenderObject` without going through `Element.rebuild`), nor when -/// one is first mounted; only a changed inherited dependency reaches it. See -/// [flutter/flutter#71626](https://github.com/flutter/flutter/issues/71626). -/// Budget the widget that builds them instead. -/// -/// Windows can overlap and nest. A hook that was installed before the first -/// window opened keeps receiving every call and is put back when the last -/// window closes. A window that is still open when its test ends is closed -/// and the test fails, so a forgotten [close] cannot leak the hook into the -/// next test. -class RebuildCounter { - RebuildCounter._(this.label, Map counters) - : _counters = Map.unmodifiable(counters); - - /// Opens a window and starts counting. - /// - /// Call it inside a test body (or `setUp`), after the widget under test - /// has been pumped, and right before the interaction being measured. - /// - /// [label] names the window in failure messages, print-mode output and - /// baseline files. - /// - /// [fetchCount] and [counters] are read when the window opens and again - /// when it closes, so `interactionHappened(fetchCount: ...)` can prove - /// that the interaction reached the code it was meant to reach. They are - /// plain callbacks into test doubles; nothing in production code changes. - /// - /// Throws a [StateError] unless the widgets binding is a - /// [TestWidgetsFlutterBinding], the one `testWidgets` sets up. That - /// includes `LiveTestWidgetsFlutterBinding` and `integration_test`'s - /// binding, but not the binding of a running app, so a window cannot be - /// opened from app code such as a debug menu. This package belongs in - /// `dev_dependencies` and must not be imported from `lib/`; - /// `expectRebuildBudgetIsTestOnly` in `guard.dart` checks both. - static RebuildCounter open({ - String? label, - num Function()? fetchCount, - Map counters = const {}, - }) { - if (!kDebugMode) { - throw UnsupportedError( - 'RebuildCounter needs a debug build: Flutter only calls ' - 'debugOnRebuildDirtyWidget when assertions are enabled.', - ); - } - _requireTestBinding(); - if (fetchCount != null && counters.containsKey(fetchCountName)) { - throw ArgumentError.value( - counters, - 'counters', - 'must not contain "$fetchCountName" when fetchCount is also given', - ); - } - final window = RebuildCounter._(label, { - ...counters, - if (fetchCount != null) fetchCountName: fetchCount, - }); - window._start(); - addTearDown(window._failIfLeftOpen); - return window; - } - - /// Opens a window, runs [interaction], and closes the window, even when - /// [interaction] throws. - /// - /// The arguments mean the same as for [open], and the returned future - /// fails with the same errors, before [interaction] runs. - static Future record( - Future Function() interaction, { - String? label, - num Function()? fetchCount, - Map counters = const {}, - }) async { - final window = - open(label: label, fetchCount: fetchCount, counters: counters); - try { - await interaction(); - } finally { - if (window.isOpen) window.close(); - } - return window; - } - - /// Whether print mode is on. - /// - /// In print mode every window prints its per-type counts when it closes, - /// and every budget matcher prints the measured count beside its budget, - /// so a budget can be set from a measurement instead of a guess. It - /// defaults to `--dart-define=REBUILD_COUNTS=true`; a test file may also - /// set it directly while a budget is being measured. - static bool printCounts = const bool.fromEnvironment('REBUILD_COUNTS'); - - /// Whether `matchesRebuildBaseline` rewrites baseline files with the - /// measured counts instead of checking against them. - /// - /// Defaults to `--dart-define=UPDATE_REBUILD_BUDGETS=true`, the rebuild - /// counterpart of `flutter test --update-goldens`. Never turn it on in - /// CI: a baseline that rewrites itself can never fail. - static bool updateBaselines = - const bool.fromEnvironment('UPDATE_REBUILD_BUDGETS'); - - /// The name given to [open], if any. - final String? label; - - final Map _counters; - final Map _countersAtOpen = {}; - final Map _countersAtClose = {}; - - final Set _known = Set.identity(); - final Set _present = {}; - final List _rebuilds = []; - final List _mounts = []; - - final Map _scrollAtOpen = Map.identity(); - final Map _scrollAtClose = Map.identity(); - - bool _open = false; - bool _closedOnce = false; - - /// Whether the window is still counting. - bool get isOpen => _open; - - /// Every rebuild recorded in the window, in the order Flutter reported - /// them. - List get rebuilds => List.unmodifiable(_rebuilds); - - /// Every mount (first build of a new element) recorded in the window. - List get mounts => List.unmodifiable(_mounts); - - /// The total number of rebuilds in the window. - int get total => _rebuilds.length; - - /// Rebuilds of widgets whose runtime type is exactly [type]. - /// - /// Subtypes are not included: a budget for `MyCard` says nothing about - /// `MyFancyCard`. - int countOf(Type type) => _rebuilds.where((r) => r.widgetType == type).length; - - /// Rebuilds of widgets whose runtime type is named [typeName]. - /// - /// Use this for a type that is private to the library under test, such as - /// `'_Header'`. - int countNamed(String typeName) => - _rebuilds.where((r) => r.typeName == typeName).length; - - /// Rebuilds of widgets that satisfy [test], for telling instances of one - /// type apart by a field. - int countWhere(bool Function(Widget widget) test) => - _rebuilds.where((r) => test(r.widget)).length; - - /// Rebuilds of the elements found by [under] and of everything below - /// them, optionally restricted to widgets of type [of]. - /// - /// [under] is evaluated against the current tree, so ask before the - /// subtree is unmounted. Throws a [StateError] when it finds nothing, - /// because a subtree that is not there would always count zero. - int countUnder(Finder under, {Type? of}) { - final roots = resolveRoots(under); - return _rebuilds - .where((r) => (of == null || r.widgetType == of) && r.isWithin(roots)) - .length; - } - - /// Mounts of widgets whose runtime type is exactly [type]. - int mountsOf(Type type) => _mounts.where((r) => r.widgetType == type).length; - - /// The widget types that had an element in the tree at some point in the - /// window: when it opened, when it closed, or when one of them built in - /// between. - /// - /// Type budgets check it so that a budget for a type that never appeared - /// fails instead of passing with a count of zero. The walks that already - /// run at [open] and [close] collect it; there is no extra walk. - Set get presentTypes => Set.unmodifiable(_present); - - /// Rebuild counts grouped by widget type name, busiest first. - /// - /// Ties are broken by name so the order is stable between runs. - Map countsByType() => groupByType(_rebuilds); - - /// How much the counter called [name] changed between [open] and - /// [close]. - /// - /// Throws a [StateError] while the window is open, and an [ArgumentError] - /// when no such counter was given to [open]. - num counterDelta(String name) { - _requireClosed('read a counter'); - if (!_counters.containsKey(name)) { - throw ArgumentError.value( - name, - 'name', - 'no counter with this name was given to RebuildCounter.open', - ); - } - return _countersAtClose[name]! - _countersAtOpen[name]!; - } - - /// The names of the counters given to [open], including `fetchCount`. - Iterable get counterNames => _counters.keys; - - /// How far the [ScrollableState] that [scrollable] resolves to moved - /// between [open] and [close], in logical pixels. - /// - /// [scrollable] may find a [Scrollable] itself, a scroll view that builds - /// one (such as [ListView]), or any widget inside one. Returns `null` when - /// no scrollable can be resolved, or when it was not on screen at both - /// ends of the window. - double? scrollDelta(Finder scrollable) { - _requireClosed('read a scroll offset'); - for (final element in scrollable.evaluate()) { - final state = scrollableFor(element); - if (state == null) continue; - final before = _scrollAtOpen[state]; - final after = _scrollAtClose[state]; - if (before == null || after == null) return null; - return after - before; - } - return null; - } - - /// A table of the busiest widget types in the window. - /// - /// Lists the [top] types by rebuild count, plus any type named in - /// [budgets] even when it is not among them, with its budget beside it. - /// [under] narrows the table to one subtree. - String describeBusiest({ - int top = 10, - Map budgets = const {}, - Finder? under, - }) { - final records = under == null - ? _rebuilds - : _rebuilds.where((r) => r.isWithin(resolveRoots(under))); - return busiestTable(records.toList(), top: top, budgets: budgets); - } - - /// Stops counting and snapshots scroll offsets and counters. - /// - /// Throws a [StateError] if the window was already closed, or if - /// [debugOnRebuildDirtyWidget] was replaced by someone else while the - /// window was open, which means some builds were never reported and the - /// counts cannot be trusted. - void close() { - if (!_open) { - throw StateError( - _closedOnce - ? '${_name()} was already closed.' - : '${_name()} is not open.', - ); - } - _finish(); - final intact = _Hook.detach(this); - if (printCounts) { - // ignore: avoid_print - print(printReport(this)); - } - if (!intact) { - throw StateError( - 'debugOnRebuildDirtyWidget was replaced while ${_name()} was open, ' - 'so its counts are incomplete. Do not set the hook directly while a ' - 'RebuildCounter is counting.', - ); - } - } - - @override - String toString() { - final state = _open ? 'open' : 'closed'; - final name = label == null ? '' : '"$label", '; - return 'RebuildCounter($name$state, ${_rebuilds.length} rebuilds, ' - '${_mounts.length} mounts)'; - } - - /// Throws unless the widgets binding is a [TestWidgetsFlutterBinding]. - /// - /// [BindingBase.debugBindingType] is checked first because - /// [WidgetsBinding.instance] throws before any binding exists; it is only - /// reliable in debug builds, which [open] has already required. - static void _requireTestBinding() { - final bindingType = BindingBase.debugBindingType(); - if (bindingType != null && - WidgetsBinding.instance is TestWidgetsFlutterBinding) { - return; - } - final why = bindingType == null - ? 'No binding is initialized: open windows inside testWidgets, which ' - 'sets up the test binding.' - : 'The binding is $bindingType, not a TestWidgetsFlutterBinding.'; - throw StateError( - 'rebuildcop only runs under flutter test. It must be a ' - 'dev_dependency and must not be imported from lib/. $why', - ); - } - - void _requireClosed(String action) { - if (_open) { - throw StateError( - 'Cannot $action while ${_name()} is still open. Call close() first, ' - 'so frames pumped after the interaction cannot change the result.', - ); - } - } - - String _name() => label == null ? 'the rebuild window' : 'window "$label"'; - - void _start() { - final root = WidgetsBinding.instance.rootElement; - if (root != null) { - _known.add(root); - _present.add(root.widget.runtimeType); - _walk(root, (element) { - _known.add(element); - _present.add(element.widget.runtimeType); - _snapshotScroll(element, _scrollAtOpen); - }); - } - _counters.forEach((name, read) => _countersAtOpen[name] = read()); - _open = true; - _Hook.attach(this); - } - - void _finish() { - _open = false; - _closedOnce = true; - final root = WidgetsBinding.instance.rootElement; - if (root != null) { - _walk(root, (element) { - _present.add(element.widget.runtimeType); - _snapshotScroll(element, _scrollAtClose); - }); - } - _counters.forEach((name, read) => _countersAtClose[name] = read()); - } - - void _onBuild(Element element) { - if (!_open) return; - final isMount = _known.add(element); - _present.add(element.widget.runtimeType); - final ancestors = []; - element.visitAncestorElements((ancestor) { - ancestors.add(ancestor); - return true; - }); - final record = RebuildRecord._( - element: element, - widget: element.widget, - ancestors: List.unmodifiable(ancestors), - isMount: isMount, - ); - (isMount ? _mounts : _rebuilds).add(record); - } - - String? _closeIfLeftOpen() { - if (!_open) return null; - _finish(); - _Hook.detach(this); - return '${_name()} was opened but never closed. Call close() after the ' - 'interaction (or use RebuildCounter.record) so the counting hook is ' - 'released before the next test.'; - } - - void _failIfLeftOpen() { - final message = _closeIfLeftOpen(); - if (message != null) fail(message); - } - - static void _walk(Element parent, void Function(Element) visit) { - parent.visitChildren((child) { - visit(child); - _walk(child, visit); - }); - } - - static void _snapshotScroll( - Element element, - Map into, - ) { - if (element is StatefulElement && element.state is ScrollableState) { - final state = element.state as ScrollableState; - final position = state.position; - if (position.hasPixels) into[state] = position.pixels; - } - } -} - -/// The elements that [finder] currently finds, as an identity set. -/// -/// Throws a [StateError] when it finds none. -Set resolveRoots(Finder finder) { - final roots = Set.identity()..addAll(finder.evaluate()); - if (roots.isEmpty) { - throw StateError( - 'Found no ${finder.describeMatch(Plurality.zero)}, so there is no ' - 'subtree to ' - 'count. A subtree budget over nothing would always pass.', - ); - } - return roots; -} - -/// Why a budget over [type] or [named] in [window] could never fail, or -/// `null` when it can. -/// -/// A budget over a type with no element in the window counts zero whatever -/// the code does, which is how a renamed widget, or a test that imports a -/// same-named widget from a different library than the app, hides. Pass -/// [allowAbsent] when that is intended. A budget by name also fails when -/// [counted], the records it would add up, belong to more than one type. -/// -/// With [budget] false the message is worded for a type that had to -/// rebuild rather than one with a budget. -String? typeBudgetProblem( - RebuildCounter window, { - Type? type, - String? named, - Iterable counted = const [], - bool allowAbsent = false, - bool budget = true, -}) { - final name = named ?? '$type'; - final present = type != null - ? window._present.contains(type) - : window._present.any((t) => '$t' == name); - if (!present && !allowAbsent) { - final what = type != null ? '$type element' : 'widget type named $name'; - final cause = 'no $what existed during ${window._name()}'; - final sameName = type != null && window._present.any((t) => '$t' == name); - if (sameName) { - return '$cause${budget ? ', so this budget could never fail' : ''}. ' - 'A different type with the same name, $name, was present: the ' - 'test and the app probably import it from different libraries ' - '(for example package:flutter/material.dart and ' - 'package:material_ui/material_ui.dart).'; - } - if (!budget) return '$cause. Was it renamed or replaced?'; - return '$cause, so this budget could never fail. If it was renamed or ' - 'replaced, budget the new ${type != null ? 'type' : 'name'}. To ' - 'assert that it never appears, use findsNothing; to keep this ' - 'budget anyway, pass allowAbsent: true.'; - } - if (named != null) { - final types = counted.map((r) => r.widgetType).toSet(); - if (types.length > 1) { - return '${types.length} different types named $name rebuilt during ' - '${window._name()}, so a budget by name would add them up. Budget ' - 'each by type, or narrow the budget to one subtree.'; - } - } - return null; -} - -/// The [ScrollableState] that [element] stands for: its own, the first one -/// below it, or the nearest one above it. -ScrollableState? scrollableFor(Element element) { - ScrollableState? own(Element e) => - e is StatefulElement && e.state is ScrollableState - ? e.state as ScrollableState - : null; - - final self = own(element); - if (self != null) return self; - - final queue = [element]; - while (queue.isNotEmpty) { - final current = queue.removeAt(0); - final found = own(current); - if (found != null) return found; - current.visitChildren(queue.add); - } - return element.findAncestorStateOfType(); -} - -/// Closes [window] if its test left it open, and returns the failure message -/// the test would report; `null` when it was already closed. -/// -/// Not exported: the package's own tests use it to check the end-of-test -/// guard without failing a test on purpose. -String? closeLeftOpenWindow(RebuildCounter window) => window._closeIfLeftOpen(); - -/// Fans [debugOnRebuildDirtyWidget] out to every open window. -/// -/// One dispatcher is installed while at least one window is open, so -/// windows may open and close in any order. Whatever hook was installed -/// before the first window opened keeps receiving every call, and is put -/// back when the last window closes. -abstract final class _Hook { - static final List _windows = []; - static RebuildDirtyWidgetCallback? _previous; - static bool _replaced = false; - - static void _dispatch(Element element, bool builtOnce) { - _previous?.call(element, builtOnce); - for (final window in List.of(_windows)) { - window._onBuild(element); - } - } - - static final RebuildDirtyWidgetCallback _installed = _dispatch; - - static bool get _intact => identical(debugOnRebuildDirtyWidget, _installed); - - static void attach(RebuildCounter window) { - if (_windows.isEmpty) { - _previous = debugOnRebuildDirtyWidget; - _replaced = false; - debugOnRebuildDirtyWidget = _installed; - } else if (!_intact) { - _replaced = true; - } - _windows.add(window); - } - - /// Removes [window]; returns whether the dispatcher stayed installed for - /// the whole time any window was open. - static bool detach(RebuildCounter window) { - _windows.remove(window); - if (!_intact) _replaced = true; - final intact = !_replaced; - if (_windows.isEmpty) { - if (_intact) debugOnRebuildDirtyWidget = _previous; - _previous = null; - _replaced = false; - } - return intact; - } -} diff --git a/lib/src/report.dart b/lib/src/report.dart deleted file mode 100644 index dcb233c..0000000 --- a/lib/src/report.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'rebuild_counter.dart'; - -/// Counts [records] by widget type name, busiest first, ties by name. -Map groupByType(Iterable records) { - final counts = {}; - for (final record in records) { - counts.update(record.typeName, (n) => n + 1, ifAbsent: () => 1); - } - final entries = counts.entries.toList() - ..sort((a, b) { - final byCount = b.value.compareTo(a.value); - return byCount != 0 ? byCount : a.key.compareTo(b.key); - }); - return Map.fromEntries(entries); -} - -/// Renders the busiest widget types in [records] as an aligned table. -/// -/// The [top] busiest types are listed, followed by any type in [budgets] -/// that did not make the cut, so the type a budget was set on is always -/// visible. A budgeted row shows its budget and, when exceeded, by how much. -String busiestTable( - List records, { - int top = 10, - Map budgets = const {}, - String noun = 'rebuild', -}) { - if (records.isEmpty) return 'no ${noun}s were recorded'; - - final counts = groupByType(records); - final rows = [ - ...counts.keys.take(top), - for (final name in budgets.keys) - if (!counts.keys.take(top).contains(name)) name, - ]; - final countWidth = rows - .map((name) => '${counts[name] ?? 0}'.length) - .fold(1, (a, b) => a > b ? a : b); - final nameWidth = - rows.map((name) => name.length).fold(1, (a, b) => a > b ? a : b); - - final hidden = counts.length - rows.where(counts.containsKey).length; - final buffer = StringBuffer( - '${counted(records.length, noun)} across ' - '${counted(counts.length, 'widget type')}, busiest first:', - ); - for (final name in rows) { - final count = counts[name] ?? 0; - final line = StringBuffer('\n ${'$count'.padLeft(countWidth)} '); - final budget = budgets[name]; - if (budget == null) { - line.write(name); - } else { - line - ..write(name.padRight(nameWidth)) - ..write(' budget $budget'); - if (count > budget) line.write(', over by ${count - budget}'); - } - buffer.write(line.toString().trimRight()); - } - if (hidden > 0) { - buffer.write('\n ... and $hidden more ${hidden == 1 ? 'type' : 'types'}'); - } - return buffer.toString(); -} - -/// The print-mode summary of a closed [window]. -String printReport(RebuildCounter window) { - final name = window.label == null ? 'window' : 'window "${window.label}"'; - final counts = window.countsByType(); - final buffer = StringBuffer( - '[rebuild counts] $name: ${counted(window.total, 'rebuild')}, ' - '${counted(window.mounts.length, 'mount')}', - ); - final width = counts.values - .map((n) => '$n'.length) - .fold(1, (a, b) => a > b ? a : b); - counts.forEach((type, count) { - buffer.write('\n ${'$count'.padLeft(width)} $type'); - }); - for (final counter in window.counterNames) { - buffer.write('\n counter $counter changed by ' - '${window.counterDelta(counter)}'); - } - return buffer.toString(); -} - -/// The print-mode line for one budget check. -String printMeasurement(String what, int measured, int budget) => - '[rebuild counts] $what: measured $measured, budget $budget' - '${measured > budget ? ' (OVER)' : ''}'; - -/// [count] followed by [noun], with an `s` unless [count] is one. -String counted(int count, String noun) => - '$count $noun${count == 1 ? '' : 's'}'; diff --git a/lib/src/tally/build_record.dart b/lib/src/tally/build_record.dart new file mode 100644 index 0000000..0888f4d --- /dev/null +++ b/lib/src/tally/build_record.dart @@ -0,0 +1,71 @@ +import 'package:flutter/widgets.dart'; + +/// A widget that enclosed a [BuildRecord] when it was built. +/// +/// The [widget] is captured at build time, so it stays readable after the +/// element has been unmounted. +@immutable +class AncestorSnapshot { + /// Captures [element] and its current widget. + AncestorSnapshot(this.element) : widget = element.widget; + + /// The ancestor element. It may have been unmounted since. + final Element element; + + /// The ancestor's widget at the time of the build. + final Widget widget; + + @override + String toString() => 'AncestorSnapshot(${widget.runtimeType})'; +} + +/// One call of [debugOnRebuildDirtyWidget] seen by a rebuild window. +@immutable +class BuildRecord { + /// Captures [element] as it builds. + /// + /// [isMount] is true for the element's first build inside the window. + BuildRecord(this.element, {required this.isMount}) + : widget = element.widget, + ancestors = _ancestorsOf(element); + + /// The element that built. It may have been unmounted since. + final Element element; + + /// The widget the element built from. + final Widget widget; + + /// True for an element built for the first time inside the window; false + /// for an element that already existed building again. + final bool isMount; + + /// The enclosing widgets at the time of the build, nearest first. + final List ancestors; + + /// The exact runtime type of [widget]. + Type get widgetType => widget.runtimeType; + + /// The name of [widgetType], as shown in failure messages. + String get typeName => typeLabel(widgetType); + + /// Whether this build happened at [root] or anywhere below it. + bool isWithin(Element root) => + identical(element, root) || + ancestors.any((ancestor) => identical(ancestor.element, root)); + + @override + String toString() => + 'BuildRecord($typeName, ${isMount ? 'mount' : 'rebuild'})'; + + static List _ancestorsOf(Element element) { + final chain = []; + element.visitAncestorElements((ancestor) { + chain.add(AncestorSnapshot(ancestor)); + return true; + }); + return List.unmodifiable(chain); + } +} + +/// The name a widget type is reported under. +String typeLabel(Type type) => '$type'; diff --git a/lib/src/tally/hook_relay.dart b/lib/src/tally/hook_relay.dart new file mode 100644 index 0000000..f7e9e7c --- /dev/null +++ b/lib/src/tally/hook_relay.dart @@ -0,0 +1,63 @@ +import 'package:flutter/widgets.dart'; + +/// Receives every element that builds while it is attached to [HookRelay]. +typedef BuildListener = void Function(Element element); + +/// Shares Flutter's single [debugOnRebuildDirtyWidget] slot between any +/// number of open windows. +/// +/// The relay takes the slot when the first listener attaches and hands it +/// back when the last one detaches. A callback that was in the slot before +/// keeps receiving every call in between and is put back afterwards. +class HookRelay { + HookRelay._(); + + /// The relay for this isolate. + static final HookRelay shared = HookRelay._(); + + final List _listeners = []; + + /// The callback that was in the slot when the relay took it. + RebuildDirtyWidgetCallback? _displaced; + + /// The relay's own callback, kept as one object so the slot can be + /// compared with it by identity. + late final RebuildDirtyWidgetCallback _entry = _dispatch; + + bool _holdsSlot = false; + + /// Starts sending builds to [listener]. + void attach(BuildListener listener) { + if (!_holdsSlot) { + _displaced = debugOnRebuildDirtyWidget; + debugOnRebuildDirtyWidget = _entry; + _holdsSlot = true; + } + _listeners.add(listener); + } + + /// Stops sending builds to [listener]. + /// + /// Returns false when something else took the slot while [listener] was + /// attached, which means [listener] may have missed builds. The relay then + /// leaves the slot alone, because the callback in it is not its own. + bool detach(BuildListener listener) { + _listeners.remove(listener); + final intact = _holdsSlot && identical(debugOnRebuildDirtyWidget, _entry); + if (_listeners.isEmpty && _holdsSlot) { + if (intact) { + debugOnRebuildDirtyWidget = _displaced; + } + _displaced = null; + _holdsSlot = false; + } + return intact; + } + + void _dispatch(Element element, bool builtOnce) { + for (final listener in List.of(_listeners)) { + listener(element); + } + _displaced?.call(element, builtOnce); + } +} diff --git a/lib/src/tally/rebuild_window.dart b/lib/src/tally/rebuild_window.dart new file mode 100644 index 0000000..0ad3c5b --- /dev/null +++ b/lib/src/tally/rebuild_window.dart @@ -0,0 +1,370 @@ +import 'dart:collection'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../output/type_table.dart'; +import 'build_record.dart'; +import 'hook_relay.dart'; +import 'tree_survey.dart'; + +/// The counter name [RebuildWindow.open] gives its `fetchCount` callback. +const String fetchCountName = 'fetchCount'; + +/// A counting window over Flutter's [debugOnRebuildDirtyWidget] hook. +/// +/// Open one right before an interaction and close it right after; every +/// widget built in between is recorded, mounts apart from rebuilds: +/// +/// ```dart +/// final window = RebuildWindow.open(label: 'scroll the feed'); +/// await tester.drag(find.byType(ListView), const Offset(0, -600)); +/// await tester.pumpAndSettle(); +/// window.close(); +/// +/// expect(window, rebuiltAtMost(0, of: TabBar)); +/// ``` +/// +/// Windows may overlap and close in any order. A window still open when its +/// test ends is closed and fails the test. +class RebuildWindow { + RebuildWindow._(this.label, this._counters, this._before) + : _known = HashSet.identity()..addAll(_before.elements); + + /// Starts counting. + /// + /// Call it after the widget is pumped, right before the interaction. + /// [label] names the window in failure messages. [fetchCount] and + /// [counters] are read now and again at [close]; see [counterDelta]. + /// + /// Throws a [StateError] unless the widgets binding is a + /// [TestWidgetsFlutterBinding], so a window can only open under + /// `flutter test`. + static RebuildWindow open({ + String? label, + int Function()? fetchCount, + Map counters = const {}, + }) { + if (fetchCount != null && counters.containsKey(fetchCountName)) { + throw ArgumentError.value( + counters, + 'counters', + 'must not contain "$fetchCountName" when fetchCount is passed', + ); + } + _requireTestBinding(); + final readers = { + if (fetchCount != null) fetchCountName: fetchCount, + ...counters, + }; + final window = RebuildWindow._(label, readers, TreeSurvey.now()); + window._start(); + return window; + } + + /// Opens a window, runs [interaction] and closes the window, also when + /// [interaction] throws. + static Future record( + Future Function() interaction, { + String? label, + int Function()? fetchCount, + Map counters = const {}, + }) async { + final window = + open(label: label, fetchCount: fetchCount, counters: counters); + try { + await interaction(); + } finally { + if (window.isOpen) { + window.close(); + } + } + return window; + } + + /// Print mode: when true, every window prints its per-type counts when it + /// closes and every budget matcher prints its measurement. + /// + /// Defaults to `--dart-define=PRINT_REBUILDS=true`. + static bool printCounts = const bool.fromEnvironment('PRINT_REBUILDS'); + + /// Baseline update mode: when true, `matchesRebuildBaseline` writes the + /// measured counts instead of comparing against them. + /// + /// Defaults to `--dart-define=UPDATE_REBUILD_BUDGETS=true`. + static bool updateBaselines = + const bool.fromEnvironment('UPDATE_REBUILD_BUDGETS'); + + /// The name of this window in messages, if it was given one. + final String? label; + + final Map _counters; + final Map _countersAtOpen = {}; + final Map _countersAtClose = {}; + final TreeSurvey _before; + TreeSurvey? _after; + final Set _known; + final List _records = []; + final Set _builtTypes = {}; + bool _open = false; + + void _start() { + for (final entry in _counters.entries) { + _countersAtOpen[entry.key] = entry.value(); + } + HookRelay.shared.attach(_onBuild); + _open = true; + addTearDown(() { + final message = closeAbandonedWindow(this); + if (message != null) { + fail(message); + } + }); + } + + void _onBuild(Element element) { + final isMount = _known.add(element); + _records.add(BuildRecord(element, isMount: isMount)); + _builtTypes.add(element.widget.runtimeType); + } + + /// Whether the window is still counting. + bool get isOpen => _open; + + /// Stops counting and snapshots scroll offsets and counters. + /// + /// Throws a [StateError] if the window was already closed, or if + /// something replaced [debugOnRebuildDirtyWidget] while it was open, which + /// means the counts are incomplete. + void close() { + if (!_open) { + throw StateError( + 'close() was called twice: ${windowPhrase(this)} was already closed.', + ); + } + _open = false; + final intact = HookRelay.shared.detach(_onBuild); + _after = TreeSurvey.now(); + for (final entry in _counters.entries) { + _countersAtClose[entry.key] = entry.value(); + } + if (!intact) { + throw StateError( + 'debugOnRebuildDirtyWidget was replaced while ${windowPhrase(this)} ' + 'was open, so its counts are incomplete. Do not set that hook, or ' + 'turn on rebuild tracking in DevTools, while a window is open.', + ); + } + if (printCounts) { + // ignore: avoid_print + print(_closingSummary()); + } + } + + String _closingSummary() { + final rebuildCount = rebuilds.length; + final mountCount = mounts.length; + final out = StringBuffer( + '$printTag ${label == null ? 'rebuild window' : 'window "$label"'}: ' + '$rebuildCount ${plural(rebuildCount, 'rebuild')}, ' + '$mountCount ${plural(mountCount, 'mount')}', + ); + final rows = tallyByName(rebuilds); + final width = rows.isEmpty ? 0 : '${rows.first.value}'.length; + for (final row in rows) { + out.write('\n ${'${row.value}'.padLeft(width)} ${row.key}'); + } + for (final name in counterNames) { + out.write('\n counter $name changed by ${counterDelta(name)}'); + } + return out.toString(); + } + + /// Every build of an element that already existed: it was in the tree + /// when the window opened, or was mounted earlier in the window. + List get rebuilds => + List.unmodifiable(_records.where((r) => !r.isMount)); + + /// Every first build of an element inside the window. + List get mounts => + List.unmodifiable(_records.where((r) => r.isMount)); + + /// The number of [rebuilds]. + int get total => _records.where((r) => !r.isMount).length; + + /// Rebuilds of widgets whose runtime type is exactly [type]. + int countOf(Type type) => countWhere((w) => w.runtimeType == type); + + /// Rebuilds of widgets whose type is called [typeName], for private types. + int countNamed(String typeName) => + _records.where((r) => !r.isMount && r.typeName == typeName).length; + + /// Rebuilds of widgets for which [test] returns true. + int countWhere(bool Function(Widget widget) test) => + _records.where((r) => !r.isMount && test(r.widget)).length; + + /// Mounts of widgets whose runtime type is exactly [type]. + int mountsOf(Type type) => + _records.where((r) => r.isMount && r.widgetType == type).length; + + /// Rebuilds at and below every widget [under] finds, or only those of + /// type [of] when it is given. + /// + /// Throws a [StateError] when [under] finds nothing. + int countUnder(Finder under, {Type? of}) { + final found = buildsUnder(this, under); + if (found == null) { + throw StateError( + 'countUnder: found no ${under.describeMatch(Plurality.many)}.', + ); + } + return found.where((r) => of == null || r.widgetType == of).length; + } + + /// The widget types that had an element in the tree while the window was + /// open: when it opened, when it closed, or built in between. + Set get presentTypes => Set.unmodifiable({ + ..._before.types, + ..._builtTypes, + ...?_after?.types, + }); + + /// Rebuild counts per type name, busiest first, ties in name order. + Map countsByType() => Map.fromEntries(tallyByName(rebuilds)); + + /// The busiest-types table used in failure messages. + /// + /// [budgets] maps type names to a budget shown beside that type. [under] + /// narrows the table to one subtree. + String describeBusiest({ + int top = 10, + Map budgets = const {}, + Finder? under, + }) { + var records = rebuilds; + if (under != null) { + records = buildsUnder(this, under) ?? const []; + } + return formatTypeTable(records, top: top, budgets: budgets); + } + + /// The names of the counters given to [open], `fetchCount` included. + Iterable get counterNames => _counters.keys; + + /// How much counter [name] changed between [open] and [close]. + /// + /// Throws a [StateError] while the window is open and an [ArgumentError] + /// for a counter [open] was not given. + int counterDelta(String name) { + if (!_counters.containsKey(name)) { + throw ArgumentError.value(name, 'name', 'no counter with this name'); + } + _requireClosed('counterDelta'); + return _countersAtClose[name]! - _countersAtOpen[name]!; + } + + /// How far the scrollable at, inside or around what [finder] finds moved + /// between [open] and [close]. + /// + /// Null when [finder] finds nothing, there is no scrollable there, or the + /// scrollable was not in the tree at both ends of the window. + double? scrollDelta(Finder finder) { + _requireClosed('scrollDelta'); + final target = scrollableFor(finder).scrollable; + if (target == null) { + return null; + } + return scrollMovement(this, target); + } + + void _requireClosed(String member) { + if (_open) { + throw StateError( + '$member is only known once ${windowPhrase(this)} is closed.', + ); + } + } + + @override + String toString() { + final parts = [ + if (label != null) '"$label"', + _open ? 'open' : 'closed', + '$total ${plural(total, 'rebuild')}', + '${mounts.length} ${plural(mounts.length, 'mount')}', + ]; + return 'RebuildWindow(${parts.join(', ')})'; + } +} + +/// `window "label"`, or `the rebuild window` without a label. +String windowPhrase(RebuildWindow window) => + window.label == null ? 'the rebuild window' : 'window "${window.label}"'; + +/// The builds at and below every element [under] finds, or null when it +/// finds nothing. +List? buildsUnder( + RebuildWindow window, + Finder under, { + bool mounts = false, +}) { + final roots = under.evaluate().toList(); + if (roots.isEmpty) { + return null; + } + return [ + for (final record in window._records) + if (record.isMount == mounts && roots.any(record.isWithin)) record, + ]; +} + +/// How far [scrollable] moved during [window], or null when it was not in +/// the tree both when the window opened and when it closed. +double? scrollMovement(RebuildWindow window, ScrollableState scrollable) { + final start = window._before.scrollOffsets[scrollable]; + final end = window._after?.scrollOffsets[scrollable]; + if (start == null || end == null) { + return null; + } + return end - start; +} + +/// Closes [window] if it is still open and returns the failure for a window +/// its test never closed; returns null for a closed window. +String? closeAbandonedWindow(RebuildWindow window) { + if (!window.isOpen) { + return null; + } + final phrase = windowPhrase(window); + try { + window.close(); + } on StateError { + // Closing is all that matters here; the failure below says why. + } + return '$phrase was opened but never closed, so it was closed when its ' + 'test ended. Call close() right after the interaction, or use ' + 'RebuildWindow.record.'; +} + +void _requireTestBinding() { + const lead = 'rebuildcop only runs under flutter test. It must be a ' + 'dev_dependency and must not be imported from lib/.'; + WidgetsBinding? binding; + try { + binding = WidgetsBinding.instance; + } catch (_) { + binding = null; + } + if (binding == null) { + throw StateError( + '$lead No widgets binding is initialized: open windows inside ' + 'testWidgets, which sets up a TestWidgetsFlutterBinding.', + ); + } + if (binding is! TestWidgetsFlutterBinding) { + throw StateError( + '$lead The binding is ${binding.runtimeType}, not a ' + 'TestWidgetsFlutterBinding.', + ); + } +} diff --git a/lib/src/tally/tree_survey.dart b/lib/src/tally/tree_survey.dart new file mode 100644 index 0000000..16c585c --- /dev/null +++ b/lib/src/tally/tree_survey.dart @@ -0,0 +1,67 @@ +import 'dart:collection'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// What a walk of the element tree found at one moment. +class TreeSurvey { + TreeSurvey._(this.elements, this.types, this.scrollOffsets); + + /// Walks the tree under the binding's root element. + factory TreeSurvey.now() { + final elements = HashSet.identity(); + final types = {}; + final offsets = HashMap.identity(); + final root = WidgetsBinding.instance.rootElement; + if (root != null) { + final pending = [root]; + while (pending.isNotEmpty) { + final element = pending.removeLast(); + elements.add(element); + types.add(element.widget.runtimeType); + if (element is StatefulElement) { + final state = element.state; + if (state is ScrollableState && state.position.hasPixels) { + offsets[state] = state.position.pixels; + } + } + element.visitChildren(pending.add); + } + } + return TreeSurvey._(elements, types, offsets); + } + + /// Every element in the tree. + final Set elements; + + /// The runtime type of every element's widget. + final Set types; + + /// The scroll offset of every scrollable in the tree. + final Map scrollOffsets; +} + +/// The scrollable a finder points at: the found widget itself, the first +/// scrollable inside it, or the one around it. +/// +/// `found` is false when the finder finds nothing; `scrollable` is null +/// then, and also when there is no scrollable in or around the widget. +({bool found, ScrollableState? scrollable}) scrollableFor(Finder finder) { + final hits = finder.evaluate(); + if (hits.isEmpty) { + return (found: false, scrollable: null); + } + final start = hits.first; + final pending = ListQueue()..add(start); + while (pending.isNotEmpty) { + final element = pending.removeFirst(); + if (element is StatefulElement && element.state is ScrollableState) { + return (found: true, scrollable: element.state as ScrollableState); + } + element.visitChildren(pending.add); + } + return ( + found: true, + scrollable: start.findAncestorStateOfType(), + ); +} diff --git a/lib/src/verdict/baseline/baseline_file.dart b/lib/src/verdict/baseline/baseline_file.dart new file mode 100644 index 0000000..981da28 --- /dev/null +++ b/lib/src/verdict/baseline/baseline_file.dart @@ -0,0 +1,3 @@ +// The file access behind matchesRebuildBaseline: dart:io on the VM, an +// UnsupportedError on the web. +export 'baseline_file_web.dart' if (dart.library.io) 'baseline_file_vm.dart'; diff --git a/lib/src/verdict/baseline/baseline_file_vm.dart b/lib/src/verdict/baseline/baseline_file_vm.dart new file mode 100644 index 0000000..233eedc --- /dev/null +++ b/lib/src/verdict/baseline/baseline_file_vm.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Where the baseline at [path] lives. +/// +/// An absolute path is used as is. A relative one resolves next to the test +/// file, like a golden file. +String locateBaselineFile(String path) { + if (File(path).isAbsolute) { + return path; + } + final comparator = goldenFileComparator; + final base = comparator is LocalFileComparator + ? comparator.basedir + : Directory.current.uri; + return base.resolve(path).toFilePath(); +} + +/// The contents of the file at [path], or null when there is none. +String? readBaselineFile(String path) { + final file = File(path); + return file.existsSync() ? file.readAsStringSync() : null; +} + +/// Writes [contents] to [path], creating its directory if needed. +void writeBaselineFile(String path, String contents) { + File(path) + ..parent.createSync(recursive: true) + ..writeAsStringSync(contents); +} diff --git a/lib/src/verdict/baseline/baseline_file_web.dart b/lib/src/verdict/baseline/baseline_file_web.dart new file mode 100644 index 0000000..1dfa275 --- /dev/null +++ b/lib/src/verdict/baseline/baseline_file_web.dart @@ -0,0 +1,13 @@ +Never _unsupported() => throw UnsupportedError( + 'matchesRebuildBaseline needs a file system, which flutter test ' + '--platform chrome does not have.', + ); + +/// Where [path] points: unsupported without a file system. +String locateBaselineFile(String path) => _unsupported(); + +/// The contents of [path]: unsupported without a file system. +String? readBaselineFile(String path) => _unsupported(); + +/// Writes [path]: unsupported without a file system. +void writeBaselineFile(String path, String contents) => _unsupported(); diff --git a/lib/src/verdict/baseline/baseline_matcher.dart b/lib/src/verdict/baseline/baseline_matcher.dart new file mode 100644 index 0000000..2a51d69 --- /dev/null +++ b/lib/src/verdict/baseline/baseline_matcher.dart @@ -0,0 +1,220 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../../output/type_table.dart'; +import '../../tally/build_record.dart'; +import '../../tally/rebuild_window.dart'; +import '../presence.dart'; +import '../window_matcher.dart'; +import 'baseline_file.dart'; + +/// Matches a closed [RebuildWindow] against budgets stored in a JSON file, +/// like a golden file. +/// +/// The file at [path] (relative to the test file, or absolute) maps each +/// [key] to one budget per tracked entry: every type in [types], counted by +/// exact type, and every type name in [named]: +/// +/// ```json +/// { +/// "scroll the feed": {"FeedTile": 12, "TabBar": 0, "_FeedHeader": 1} +/// } +/// ``` +/// +/// With [RebuildWindow.updateBaselines] on, the measured counts are written +/// under [key] instead and the matcher passes. A missing file, key or +/// budget fails, and so does a stored budget for an entry the test no +/// longer tracks. A tracked type that had no element in the window fails, +/// in update mode too, unless [allowAbsent] is set. A malformed file throws +/// a [FormatException]. +/// +/// Needs a file system: on the web it throws an [UnsupportedError]. +Matcher matchesRebuildBaseline( + String path, { + required String key, + List types = const [], + List named = const [], + bool allowAbsent = false, + int top = 10, +}) { + final entries = {}; + void track(String name, _Tracked tracked) { + final existing = entries[name]; + if (existing != null) { + if (existing.type != null && existing.type == tracked.type) { + return; + } + throw ArgumentError( + 'Two tracked entries are named $name; a baseline file stores one ' + 'budget per name. Track one of them by name in a separate baseline.', + ); + } + entries[name] = tracked; + } + + for (final type in types) { + track(typeLabel(type), _Tracked(type)); + } + for (final name in named) { + track(name, const _Tracked(null)); + } + if (entries.isEmpty) { + throw ArgumentError('matchesRebuildBaseline needs at least one entry in ' + 'types or named.'); + } + return _Baseline(path, key, entries, allowAbsent, top); +} + +class _Tracked { + const _Tracked(this.type); + + /// Null for an entry tracked by name. + final Type? type; +} + +const String _recordHint = + 'record it with flutter test --dart-define=UPDATE_REBUILD_BUDGETS=true'; + +class _Baseline extends WindowMatcher { + const _Baseline( + this.path, this.key, this.entries, this.allowAbsent, this.top); + + final String path; + final String key; + final Map entries; + final bool allowAbsent; + final int top; + + @override + String? judge(RebuildWindow window) { + final file = locateBaselineFile(path); + final problems = []; + final measured = {}; + for (final MapEntry(key: name, value: tracked) in entries.entries) { + final type = tracked.type; + if (type != null) { + final absent = allowAbsent ? null : absentTypeForBudget(window, type); + if (absent != null) { + problems.add(absent); + continue; + } + measured[name] = window.countOf(type); + } else { + final absent = allowAbsent ? null : absentNameForBudget(window, name); + final ambiguous = ambiguousName(window, name); + if (absent != null || ambiguous != null) { + problems.add((absent ?? ambiguous)!); + continue; + } + measured[name] = window.countNamed(name); + } + } + + final stored = _read(file); + if (RebuildWindow.updateBaselines) { + if (problems.isNotEmpty) { + return 'cannot record the rebuild baseline "$key":' + '${_bullets(problems)}\nnothing was written to $file.'; + } + final all = stored ?? >{}; + all[key] = measured; + writeBaselineFile(file, _encode(all)); + return null; + } + + if (stored == null) { + return '$file does not exist; $_recordHint.'; + } + final budgets = stored[key]; + if (budgets == null) { + return '$file has no entry "$key"; $_recordHint.'; + } + for (final MapEntry(key: name, value: count) in measured.entries) { + final budget = budgets[name]; + if (RebuildWindow.printCounts && budget != null) { + final where = window.label == null ? '' : ' in "${window.label}"'; + // ignore: avoid_print + print(measurementLine('$name rebuilt$where', count, budget)); + } + if (budget == null) { + problems.add('$name is tracked but has no budget in $file; ' + '$_recordHint.'); + } else if (count > budget) { + problems.add('$name rebuilt ${times(count)}, budget $budget ' + '(over by ${count - budget})'); + } + } + for (final name in budgets.keys) { + if (!entries.containsKey(name)) { + problems.add('$file stores a budget for $name, which this test no ' + 'longer tracks. Remove it, or record the baseline again.'); + } + } + if (problems.isEmpty) { + return null; + } + final table = formatTypeTable( + window.rebuilds, + top: top, + budgets: { + for (final name in entries.keys) + if (budgets[name] != null) name: budgets[name]!, + }, + ); + return 'does not match the rebuild baseline "$key" in $file:' + '${_bullets(problems)}\n$table'; + } + + static String _bullets(List lines) => + lines.map((line) => '\n - $line').join(); + + /// The parsed file, or null when it does not exist. + static Map>? _read(String file) { + final text = readBaselineFile(file); + if (text == null) { + return null; + } + final decoded = jsonDecode(text); + if (decoded is! Map) { + throw FormatException('$file must hold a JSON object.', text); + } + final result = >{}; + for (final MapEntry(key: entryKey, value: budgets) in decoded.entries) { + if (budgets is! Map) { + throw FormatException( + 'Entry "$entryKey" in $file must be an object of budgets.', + text, + ); + } + final parsed = {}; + for (final MapEntry(key: name, value: budget) in budgets.entries) { + if (budget is! int || budget < 0) { + throw FormatException( + 'The budget for $name in entry "$entryKey" of $file must be a ' + 'whole number of zero or more, not $budget.', + text, + ); + } + parsed[name] = budget; + } + result[entryKey] = parsed; + } + return result; + } + + static String _encode(Map> all) { + final sorted = { + for (final entryKey in all.keys.toList()..sort()) + entryKey: { + for (final name in all[entryKey]!.keys.toList()..sort()) + name: all[entryKey]![name], + }, + }; + return '${const JsonEncoder.withIndent(' ').convert(sorted)}\n'; + } + + @override + Description describe(Description description) => + description.add('matches the rebuild baseline "$key" in $path'); +} diff --git a/lib/src/verdict/budgets.dart b/lib/src/verdict/budgets.dart new file mode 100644 index 0000000..4a9102b --- /dev/null +++ b/lib/src/verdict/budgets.dart @@ -0,0 +1,248 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../output/type_table.dart'; +import '../tally/build_record.dart'; +import '../tally/rebuild_window.dart'; +import 'presence.dart'; +import 'window_matcher.dart'; + +/// Matches a closed [RebuildWindow] in which widgets rebuilt at most +/// [budget] times. +/// +/// Counts widgets of exactly type [of], widgets whose type is called +/// [named] (for private types), or, with neither, every rebuild. +/// +/// A budget for a type that had no element anywhere in the window fails, +/// because it could never fail; pass [allowAbsent] to keep it anyway. A +/// budget by [named] also fails when two different types with that name +/// rebuilt. [top] limits the busiest-types table in the failure. +Matcher rebuiltAtMost( + int budget, { + Type? of, + String? named, + bool allowAbsent = false, + int top = 10, +}) { + _checkBudget(budget); + if (of != null && named != null) { + throw ArgumentError('Pass at most one of `of` and `named`.'); + } + return _RebuildBudget(budget, of, named, allowAbsent, top); +} + +/// Matches a closed [RebuildWindow] in which at most [budget] elements of +/// exactly type [of] (or of any type, without [of]) were mounted. +/// +/// A mount is an element built for the first time inside the window: a +/// `UniqueKey()` in `build`, or a subtree whose type flips, remounts and +/// loses its `State`. +Matcher mountedAtMost( + int budget, { + Type? of, + bool allowAbsent = false, + int top = 10, +}) { + _checkBudget(budget); + return _MountBudget(budget, of, allowAbsent, top); +} + +/// Matches a closed [RebuildWindow] in which the subtree at every widget +/// [under] finds rebuilt at most [budget] times, counting only type [of] +/// when it is given. +/// +/// A finder that finds nothing fails rather than passes. +Matcher subtreeRebuiltAtMost( + int budget, { + required Finder under, + Type? of, + bool allowAbsent = false, + int top = 10, +}) { + _checkBudget(budget); + return _SubtreeBudget(budget, under, of, allowAbsent, top); +} + +void _checkBudget(int budget) { + if (budget < 0) { + throw ArgumentError.value(budget, 'budget', 'must not be negative'); + } +} + +/// `in "label"`, or `in the rebuild window` without a label. +String _where(RebuildWindow window) => + window.label == null ? 'in the rebuild window' : 'in "${window.label}"'; + +String _overrun(String verb, int measured, int budget) => + '$verb ${times(measured)}, ${measured - budget} over budget.\n'; + +class _RebuildBudget extends WindowMatcher { + const _RebuildBudget( + this.budget, + this.type, + this.name, + this.allowAbsent, + this.top, + ); + + final int budget; + final Type? type; + final String? name; + final bool allowAbsent; + final int top; + + String? get _subject => type != null ? typeLabel(type!) : name; + + @override + String? judge(RebuildWindow window) { + final type = this.type; + final name = this.name; + final int measured; + if (type != null) { + if (!allowAbsent) { + final absent = absentTypeForBudget(window, type); + if (absent != null) return absent; + } + measured = window.countOf(type); + } else if (name != null) { + if (!allowAbsent) { + final absent = absentNameForBudget(window, name); + if (absent != null) return absent; + } + final ambiguous = ambiguousName(window, name); + if (ambiguous != null) return ambiguous; + measured = window.countNamed(name); + } else { + measured = window.total; + } + + if (RebuildWindow.printCounts) { + // ignore: avoid_print + print(measurementLine( + '${_subject ?? 'all widgets'} rebuilt ${_where(window)}', + measured, + budget, + )); + } + if (measured <= budget) { + return null; + } + final subject = _subject; + return _overrun('rebuilt', measured, budget) + + formatTypeTable( + window.rebuilds, + top: top, + budgets: {if (subject != null) subject: budget}, + ); + } + + @override + Description describe(Description description) => description.add( + '${_subject ?? 'all widgets'} rebuilt at most ${times(budget)} ' + 'in the window', + ); +} + +class _MountBudget extends WindowMatcher { + const _MountBudget(this.budget, this.type, this.allowAbsent, this.top); + + final int budget; + final Type? type; + final bool allowAbsent; + final int top; + + String get _subject => type == null ? 'all widgets' : typeLabel(type!); + + @override + String? judge(RebuildWindow window) { + final type = this.type; + if (type != null && !allowAbsent) { + final absent = absentTypeForBudget(window, type); + if (absent != null) return absent; + } + final measured = + type == null ? window.mounts.length : window.mountsOf(type); + if (RebuildWindow.printCounts) { + // ignore: avoid_print + print(measurementLine( + '$_subject mounted ${_where(window)}', + measured, + budget, + )); + } + if (measured <= budget) { + return null; + } + return _overrun('mounted', measured, budget) + + formatTypeTable( + window.mounts, + top: top, + noun: 'mount', + budgets: {if (type != null) _subject: budget}, + ); + } + + @override + Description describe(Description description) => description + .add('$_subject mounted at most ${times(budget)} in the window'); +} + +class _SubtreeBudget extends WindowMatcher { + const _SubtreeBudget( + this.budget, + this.under, + this.type, + this.allowAbsent, + this.top, + ); + + final int budget; + final Finder under; + final Type? type; + final bool allowAbsent; + final int top; + + String get _root => under.describeMatch(Plurality.one); + + String get _subject => type == null + ? 'the subtree of the $_root' + : '${typeLabel(type!)} under the $_root'; + + @override + String? judge(RebuildWindow window) { + final type = this.type; + if (type != null && !allowAbsent) { + final absent = absentTypeForBudget(window, type); + if (absent != null) return absent; + } + final records = buildsUnder(window, under); + if (records == null) { + return 'found no ${under.describeMatch(Plurality.many)}, so there is ' + 'no subtree to count. A budget on nothing could never fail.'; + } + final measured = type == null + ? records.length + : records.where((r) => r.widgetType == type).length; + if (RebuildWindow.printCounts) { + // ignore: avoid_print + print(measurementLine( + '$_subject rebuilt ${_where(window)}', + measured, + budget, + )); + } + if (measured <= budget) { + return null; + } + return _overrun('rebuilt', measured, budget) + + formatTypeTable( + records, + top: top, + budgets: {if (type != null) typeLabel(type): budget}, + ); + } + + @override + Description describe(Description description) => + description.add('$_subject rebuilt at most ${times(budget)} ' + 'in the window'); +} diff --git a/lib/src/verdict/presence.dart b/lib/src/verdict/presence.dart new file mode 100644 index 0000000..dc2b598 --- /dev/null +++ b/lib/src/verdict/presence.dart @@ -0,0 +1,72 @@ +import '../tally/build_record.dart'; +import '../tally/rebuild_window.dart'; + +const String _sameNameNote = 'the test and the app probably import it from ' + 'different libraries (for example package:flutter/material.dart and ' + 'package:material_ui/material_ui.dart).'; + +const String _renamedNote = 'If it was renamed or replaced, budget the new ' + 'type. To assert that it never appears, use findsNothing; to keep this ' + 'budget anyway, pass allowAbsent: true.'; + +bool _otherTypeNamedLike(RebuildWindow window, Type type) { + final name = typeLabel(type); + return window.presentTypes.any((t) => t != type && typeLabel(t) == name); +} + +String _noElement(RebuildWindow window, Type type) => + 'no ${typeLabel(type)} element existed during ${windowPhrase(window)}'; + +String _sameNameSentence(Type type) => + 'A different type with the same name, ${typeLabel(type)}, was present: ' + '$_sameNameNote'; + +/// Why a budget for [type] could never fail in [window], or null when an +/// element of [type] was there. +String? absentTypeForBudget(RebuildWindow window, Type type) { + if (window.presentTypes.contains(type)) { + return null; + } + final tail = _otherTypeNamedLike(window, type) + ? _sameNameSentence(type) + : _renamedNote; + return '${_noElement(window, type)}, so this budget could never fail. ' + '$tail'; +} + +/// Why `interactionHappened(rebuilt: type)` cannot hold because [type] was +/// never there, or null when it was. +String? absentTypeForProof(RebuildWindow window, Type type) { + if (window.presentTypes.contains(type)) { + return null; + } + final tail = _otherTypeNamedLike(window, type) + ? _sameNameSentence(type) + : 'Was it renamed or replaced?'; + return '${_noElement(window, type)}. $tail'; +} + +/// Why a budget for the type name [name] could never fail in [window], or +/// null when a type with that name was there. +String? absentNameForBudget(RebuildWindow window, String name) { + if (window.presentTypes.any((t) => typeLabel(t) == name)) { + return null; + } + return 'no widget type named $name existed during ${windowPhrase(window)}, ' + 'so this budget could never fail. $_renamedNote'; +} + +/// Why a budget by [name] would add up unrelated types, or null when at +/// most one type of that name rebuilt. +String? ambiguousName(RebuildWindow window, String name) { + final types = { + for (final record in window.rebuilds) + if (record.typeName == name) record.widgetType, + }; + if (types.length < 2) { + return null; + } + return '${types.length} different types named $name rebuilt during ' + '${windowPhrase(window)}, so a budget by name would add them up. ' + 'Budget each by type, or narrow the budget to one subtree.'; +} diff --git a/lib/src/verdict/proof.dart b/lib/src/verdict/proof.dart new file mode 100644 index 0000000..9cd5d56 --- /dev/null +++ b/lib/src/verdict/proof.dart @@ -0,0 +1,139 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../tally/build_record.dart'; +import '../tally/rebuild_window.dart'; +import '../tally/tree_survey.dart'; +import 'presence.dart'; +import 'window_matcher.dart'; + +/// Matches a closed [RebuildWindow] whose interaction provably ran. +/// +/// Every given condition must hold: +/// +/// * [scrollOffsetMoved]: the scrollable at, inside or around what this +/// finds was on screen when the window opened and closed, and its offset +/// changed. +/// * [fetchCount]: the window's `fetchCount` counter changed by this amount +/// (an `int`) or by an amount this matcher accepts. +/// * [counters]: each named counter changed by its value or matcher. +/// * [rebuilt]: a widget of exactly this type rebuilt at least once. +/// +/// Pair every budget with one, so that a zero means something: a drag that +/// missed or a timer that never fired also rebuilds nothing. +Matcher interactionHappened({ + Finder? scrollOffsetMoved, + Object? fetchCount, + Map counters = const {}, + Type? rebuilt, +}) { + if (scrollOffsetMoved == null && + fetchCount == null && + counters.isEmpty && + rebuilt == null) { + throw ArgumentError('interactionHappened needs at least one condition.'); + } + if (fetchCount != null && counters.containsKey(fetchCountName)) { + throw ArgumentError( + 'Pass fetchCount either as fetchCount: or in counters:, not both.', + ); + } + return _Proof( + scrollOffsetMoved, + { + if (fetchCount != null) fetchCountName: wrapMatcher(fetchCount), + for (final entry in counters.entries) + entry.key: wrapMatcher(entry.value), + }, + rebuilt); +} + +class _Proof extends WindowMatcher { + const _Proof(this.scroll, this.counters, this.rebuilt); + + final Finder? scroll; + final Map counters; + final Type? rebuilt; + + static String _label(String counter) => + counter == fetchCountName ? fetchCountName : 'counter "$counter"'; + + static String _expected(Matcher matcher) => + StringDescription().addDescriptionOf(matcher).toString(); + + @override + String? judge(RebuildWindow window) { + final problems = []; + + final scroll = this.scroll; + if (scroll != null) { + final target = scrollableFor(scroll); + final what = 'the ${scroll.describeMatch(Plurality.one)}'; + if (!target.found) { + problems.add('scrollOffsetMoved: found no ' + '${scroll.describeMatch(Plurality.many)}'); + } else if (target.scrollable == null) { + problems.add('scrollOffsetMoved: there is no Scrollable at, in or ' + 'around $what'); + } else { + final moved = scrollMovement(window, target.scrollable!); + if (moved == null) { + problems.add('scrollOffsetMoved: the Scrollable of $what must be ' + 'on screen both when the window opened and when it closed'); + } else if (moved == 0) { + problems.add('scrollOffsetMoved: the scroll offset of $what did ' + 'not change'); + } + } + } + + final known = window.counterNames.toSet(); + for (final entry in counters.entries) { + final name = entry.key; + if (!known.contains(name)) { + problems.add(name == fetchCountName + ? 'fetchCount: the window was opened without a fetchCount ' + 'callback' + : 'counters: the window was opened without a counter named ' + '"$name"'); + continue; + } + final delta = window.counterDelta(name); + if (!entry.value.matches(delta, {})) { + problems.add('${_label(name)} changed by $delta, expected ' + '${_expected(entry.value)}'); + } + } + + final rebuilt = this.rebuilt; + if (rebuilt != null) { + final absent = absentTypeForProof(window, rebuilt); + if (absent != null) { + problems.add('rebuilt: $absent'); + } else if (window.countOf(rebuilt) == 0) { + problems.add('rebuilt: ${typeLabel(rebuilt)} never rebuilt during ' + '${windowPhrase(window)}'); + } + } + + if (problems.isEmpty) { + return null; + } + return 'gives no proof the interaction ran:' + '${problems.map((p) => '\n - $p').join()}'; + } + + @override + Description describe(Description description) { + final scroll = this.scroll; + final rebuilt = this.rebuilt; + final parts = [ + if (scroll != null) + 'the scroll offset of the ${scroll.describeMatch(Plurality.one)} ' + 'changed', + for (final entry in counters.entries) + '${_label(entry.key)} changed by ${_expected(entry.value)}', + if (rebuilt != null) '${typeLabel(rebuilt)} rebuilt at least once', + ]; + return description.add('proof the interaction ran: ${parts.join(', ')}'); + } +} diff --git a/lib/src/verdict/window_matcher.dart b/lib/src/verdict/window_matcher.dart new file mode 100644 index 0000000..d71d8b6 --- /dev/null +++ b/lib/src/verdict/window_matcher.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../tally/rebuild_window.dart'; + +/// A matcher over a closed [RebuildWindow]. +/// +/// Subclasses implement [judge], which returns the mismatch text or null +/// when the window passes. +abstract class WindowMatcher extends Matcher { + /// Const constructor for subclasses. + const WindowMatcher(); + + static const Object _key = #rebuildcopMismatch; + + /// The mismatch for [window], or null when it passes. + String? judge(RebuildWindow window); + + @override + bool matches(Object? item, Map matchState) { + final String? problem; + if (item is! RebuildWindow) { + problem = 'is not a RebuildWindow'; + } else if (item.isOpen) { + problem = 'is ${windowPhrase(item)}, which is still open. Call close() ' + 'before asserting, so frames pumped later cannot change the counts.'; + } else { + problem = judge(item); + } + if (problem == null) { + return true; + } + matchState[_key] = problem; + return false; + } + + @override + Description describeMismatch( + Object? item, + Description mismatchDescription, + Map matchState, + bool verbose, + ) { + final problem = matchState[_key]; + return problem is String + ? mismatchDescription.add(problem) + : mismatchDescription; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 2a21d2d..2d91a68 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: rebuildcop description: >- Catch unnecessary widget rebuilds in Flutter: a Dart test helper that fails widget tests over a measured rebuild budget. Headless, no device, no probes. -version: 0.2.0 +version: 0.3.0 homepage: https://github.com/saimskywalker/rebuildcop repository: https://github.com/saimskywalker/rebuildcop issue_tracker: https://github.com/saimskywalker/rebuildcop/issues diff --git a/test/absent_type_test.dart b/test/absent_type_test.dart index 615e3dc..1fac92d 100644 --- a/test/absent_type_test.dart +++ b/test/absent_type_test.dart @@ -6,9 +6,9 @@ import 'support/fixtures.dart'; import 'support/tile_a.dart' as a; import 'support/tile_b.dart' as b; -Future bumpOnce(WidgetTester tester, {String? label}) async { - final screen = await pumpScreen(tester); - final window = RebuildCounter.open(label: label); +Future bumpOnce(WidgetTester tester, {String? label}) async { + final screen = await showScreen(tester); + final window = RebuildWindow.open(label: label); screen.bump(); await tester.pump(); window.close(); @@ -113,7 +113,7 @@ void main() { (tester) async { await tester.pumpWidget(const SizedBox()); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); await pumpList(tester, count: 3); await tester.pumpWidget(const SizedBox()); window.close(); diff --git a/test/baseline_test.dart b/test/baseline_test.dart index 25b8c06..c59cc98 100644 --- a/test/baseline_test.dart +++ b/test/baseline_test.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:rebuildcop/rebuildcop.dart'; -import 'package:rebuildcop/src/baseline_store.dart'; +import 'package:rebuildcop/src/verdict/baseline/baseline_file.dart'; import 'support/fixtures.dart'; import 'support/tile_a.dart' as a; @@ -19,12 +19,12 @@ void main() { }); tearDown(() { dir.deleteSync(recursive: true); - RebuildCounter.updateBaselines = false; + RebuildWindow.updateBaselines = false; }); - Future bumpOnce(WidgetTester tester) async { - final screen = await pumpScreen(tester); - final window = RebuildCounter.open(); + Future bumpOnce(WidgetTester tester) async { + final screen = await showScreen(tester); + final window = RebuildWindow.open(); screen.bump(); await tester.pump(); window.close(); @@ -46,7 +46,7 @@ void main() { 'zzz other test': {'Footer': 0}, }); - RebuildCounter.updateBaselines = true; + RebuildWindow.updateBaselines = true; expect(window, baseline()); expect( @@ -68,7 +68,7 @@ void main() { final window = await bumpOnce(tester); path = '${dir.path}/nested/deeper/budgets.json'; - RebuildCounter.updateBaselines = true; + RebuildWindow.updateBaselines = true; expect(window, baseline()); expect(File(path).existsSync(), isTrue); @@ -176,7 +176,7 @@ void main() { (tester) async { final window = await bumpOnce(tester); - RebuildCounter.updateBaselines = true; + RebuildWindow.updateBaselines = true; final message = mismatchOf( matchesRebuildBaseline(path, key: 'bump', types: [ListRow]), window, @@ -251,19 +251,19 @@ void main() { }); test('a relative path resolves next to the test file, like a golden', () { - final resolved = resolveBaselinePath('rebuild_budgets/home.json'); + final resolved = locateBaselineFile('rebuild_budgets/home.json'); final testDir = (goldenFileComparator as LocalFileComparator).basedir; expect(resolved, testDir.resolve('rebuild_budgets/home.json').toFilePath()); expect(resolved, endsWith('test/rebuild_budgets/home.json')); }); test('an absolute path is used as is', () { - expect(resolveBaselinePath(path), path); + expect(locateBaselineFile(path), path); }); test('update mode is off unless UPDATE_REBUILD_BUDGETS is defined', () { expect( - RebuildCounter.updateBaselines, + RebuildWindow.updateBaselines, const bool.fromEnvironment('UPDATE_REBUILD_BUDGETS'), ); }); diff --git a/test/binding/app_binding_test.dart b/test/binding/app_binding_test.dart index e88c2a8..c9ab16d 100644 --- a/test/binding/app_binding_test.dart +++ b/test/binding/app_binding_test.dart @@ -15,7 +15,7 @@ void main() { test('open refuses to run and leaves the hook alone', () { expect( - RebuildCounter.open, + RebuildWindow.open, throwsA( isA().having( (e) => e.message, @@ -36,7 +36,7 @@ void main() { test('record refuses before the interaction runs', () async { var ran = false; await expectLater( - RebuildCounter.record(() async => ran = true), + RebuildWindow.record(() async => ran = true), throwsStateError, ); expect(ran, isFalse); diff --git a/test/binding/live_binding_test.dart b/test/binding/live_binding_test.dart index 2cf88e2..912a6d5 100644 --- a/test/binding/live_binding_test.dart +++ b/test/binding/live_binding_test.dart @@ -22,7 +22,7 @@ void main() { ), ); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); label.value++; await tester.pump(); window.close(); diff --git a/test/binding/no_binding_test.dart b/test/binding/no_binding_test.dart index 1c26351..419da05 100644 --- a/test/binding/no_binding_test.dart +++ b/test/binding/no_binding_test.dart @@ -12,7 +12,7 @@ void main() { test('open refuses to run and says where to open windows', () { expect( - RebuildCounter.open, + RebuildWindow.open, throwsA( isA().having( (e) => e.message, diff --git a/test/interaction_test.dart b/test/interaction_test.dart index f16d66c..0fbb8f3 100644 --- a/test/interaction_test.dart +++ b/test/interaction_test.dart @@ -9,7 +9,7 @@ void main() { testWidgets('passes when the list scrolled', (tester) async { await pumpList(tester); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); await tester.drag(find.byType(ListView), const Offset(0, -400)); await tester.pumpAndSettle(); window.close(); @@ -23,7 +23,7 @@ void main() { testWidgets('fails when the drag missed', (tester) async { await pumpList(tester); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); // A horizontal drag on a vertical list moves nothing. await tester.drag(find.byType(ListView), const Offset(-400, 0)); await tester.pumpAndSettle(); @@ -39,7 +39,7 @@ void main() { testWidgets('fails when the finder finds nothing', (tester) async { await pumpList(tester); - final window = RebuildCounter.open()..close(); + final window = RebuildWindow.open()..close(); expect( mismatchOf( @@ -53,7 +53,7 @@ void main() { testWidgets('fails when the scrollable appeared after the window opened', (tester) async { await tester.pumpWidget(const SizedBox()); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); await pumpList(tester); window.close(); @@ -71,7 +71,7 @@ void main() { testWidgets('match an exact count or any matcher', (tester) async { var fetches = 0; var saves = 0; - final window = RebuildCounter.open( + final window = RebuildWindow.open( fetchCount: () => fetches, counters: {'saves': () => saves}, ); @@ -90,7 +90,7 @@ void main() { testWidgets('a zero fetch count is no proof', (tester) async { const fetches = 0; - final window = RebuildCounter.open(fetchCount: () => fetches)..close(); + final window = RebuildWindow.open(fetchCount: () => fetches)..close(); expect( mismatchOf(interactionHappened(fetchCount: greaterThan(0)), window), @@ -99,7 +99,7 @@ void main() { }); testWidgets('name a counter the window was not given', (tester) async { - final window = RebuildCounter.open()..close(); + final window = RebuildWindow.open()..close(); expect( mismatchOf(interactionHappened(fetchCount: 1), window), @@ -114,8 +114,8 @@ void main() { group('rebuilt', () { testWidgets('requires at least one rebuild of the type', (tester) async { - final screen = await pumpScreen(tester); - final window = RebuildCounter.open(); + final screen = await showScreen(tester); + final window = RebuildWindow.open(); screen.bump(); await tester.pump(); window.close(); @@ -130,7 +130,7 @@ void main() { testWidgets('lists every unmet condition', (tester) async { await pumpList(tester); - final window = RebuildCounter.open(fetchCount: () => 0)..close(); + final window = RebuildWindow.open(fetchCount: () => 0)..close(); final message = mismatchOf( interactionHappened( @@ -156,7 +156,7 @@ void main() { }); testWidgets('refuses an open window', (tester) async { - final window = RebuildCounter.open(fetchCount: () => 0); + final window = RebuildWindow.open(fetchCount: () => 0); final message = mismatchOf(interactionHappened(fetchCount: 0), window); window.close(); expect(message, contains('still open')); diff --git a/test/matchers_test.dart b/test/matchers_test.dart index e99b527..3003d10 100644 --- a/test/matchers_test.dart +++ b/test/matchers_test.dart @@ -6,9 +6,9 @@ import 'package:rebuildcop/rebuildcop.dart'; import 'support/fixtures.dart'; -Future bumpOnce(WidgetTester tester, {String? label}) async { - final screen = await pumpScreen(tester); - final window = RebuildCounter.open(label: label); +Future bumpOnce(WidgetTester tester, {String? label}) async { + final screen = await showScreen(tester); + final window = RebuildWindow.open(label: label); screen.bump(); await tester.pump(); window.close(); @@ -64,7 +64,7 @@ void main() { }); testWidgets('refuses an open window', (tester) async { - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); final message = mismatchOf(rebuiltAtMost(100), window); window.close(); @@ -72,7 +72,7 @@ void main() { }); test('refuses something that is not a window', () { - expect(mismatchOf(rebuiltAtMost(1), 42), 'is not a RebuildCounter'); + expect(mismatchOf(rebuiltAtMost(1), 42), 'is not a RebuildWindow'); }); test('rejects a negative budget and conflicting selectors', () { @@ -85,8 +85,8 @@ void main() { testWidgets('prints the measurement in print mode', (tester) async { final window = await bumpOnce(tester, label: 'bump'); - RebuildCounter.printCounts = true; - addTearDown(() => RebuildCounter.printCounts = false); + RebuildWindow.printCounts = true; + addTearDown(() => RebuildWindow.printCounts = false); final printed = []; runZoned( @@ -97,7 +97,7 @@ void main() { ); expect(printed, [ - '[rebuild counts] CountLabel rebuilt in "bump": measured 2, budget 9', + '[rebuildcop] CountLabel rebuilt in "bump": measured 2, budget 9', ]); }); }); @@ -138,7 +138,7 @@ void main() { ), ); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); notifier.value++; await tester.pump(); window.close(); @@ -183,7 +183,7 @@ void main() { ), ); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); notifier.value++; await tester.pump(); window.close(); diff --git a/test/report_test.dart b/test/report_test.dart index c86bde0..8005a78 100644 --- a/test/report_test.dart +++ b/test/report_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:rebuildcop/rebuildcop.dart'; -import 'package:rebuildcop/src/report.dart'; +import 'package:rebuildcop/src/output/type_table.dart'; import 'support/fixtures.dart'; @@ -9,7 +9,7 @@ void main() { // One notifier tick rebuilds the builder (1), six new ListRows (6), their // Texts and the CountLabel's Text (7) and the CountLabel (1). The const // Footer is skipped. - Future rebuildList(WidgetTester tester) async { + Future rebuildList(WidgetTester tester) async { final notifier = ValueNotifier(0); addTearDown(notifier.dispose); await tester.pumpWidget( @@ -27,17 +27,17 @@ void main() { ), ), ); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); notifier.value++; await tester.pump(); window.close(); return window; } - testWidgets('busiestTable aligns counts and budgets', (tester) async { + testWidgets('formatTypeTable aligns counts and budgets', (tester) async { final window = await rebuildList(tester); - final table = busiestTable(window.rebuilds, budgets: {'CountLabel': 0}); + final table = formatTypeTable(window.rebuilds, budgets: {'CountLabel': 0}); expect( table.split('\n').take(3).join('\n'), @@ -54,11 +54,11 @@ void main() { }); testWidgets( - 'busiestTable truncates to the top N and says how many are ' + 'formatTypeTable truncates to the top N and says how many are ' 'hidden', (tester) async { final window = await rebuildList(tester); - final table = busiestTable(window.rebuilds, top: 1); + final table = formatTypeTable(window.rebuilds, top: 1); expect(table, contains('7 Text')); expect(table, isNot(contains('ListRow'))); @@ -66,8 +66,8 @@ void main() { }); testWidgets('describeBusiest narrows to a subtree', (tester) async { - final screen = await pumpScreen(tester); - final window = RebuildCounter.open(); + final screen = await showScreen(tester); + final window = RebuildWindow.open(); screen.bump(); await tester.pump(); window.close(); @@ -78,15 +78,15 @@ void main() { expect(table, isNot(contains('Screen'))); }); - test('busiestTable on nothing', () { - expect(busiestTable(const []), 'no rebuilds were recorded'); - expect(busiestTable(const [], noun: 'mount'), 'no mounts were recorded'); + test('formatTypeTable on nothing', () { + expect(formatTypeTable(const []), 'no rebuilds were recorded'); + expect(formatTypeTable(const [], noun: 'mount'), 'no mounts were recorded'); }); - test('printMeasurement flags an overrun', () { - expect(printMeasurement('TabBar', 2, 2), - '[rebuild counts] TabBar: measured 2, budget 2'); - expect(printMeasurement('TabBar', 3, 2), - '[rebuild counts] TabBar: measured 3, budget 2 (OVER)'); + test('measurementLine flags an overrun', () { + expect(measurementLine('TabBar', 2, 2), + '[rebuildcop] TabBar: measured 2, budget 2'); + expect(measurementLine('TabBar', 3, 2), + '[rebuildcop] TabBar: measured 3, budget 2 (OVER)'); }); } diff --git a/test/support/fixtures.dart b/test/support/fixtures.dart index 0f64a27..aed205a 100644 --- a/test/support/fixtures.dart +++ b/test/support/fixtures.dart @@ -70,7 +70,7 @@ class Panel extends StatelessWidget { } /// Pumps [Screen] and returns its state, so a test can call [ScreenState.bump]. -Future pumpScreen(WidgetTester tester) async { +Future showScreen(WidgetTester tester) async { await tester.pumpWidget( const Directionality(textDirection: TextDirection.ltr, child: Screen()), ); @@ -103,7 +103,7 @@ class ListRow extends StatelessWidget { /// Pumps the `Tile` from `tile_a.dart` (when [withA]) and the one from /// `tile_b.dart` (when [withB]), rebuilds them once in a window labelled /// "tick", and returns the closed window. -Future tickTiles( +Future tickTiles( WidgetTester tester, { bool withA = false, bool withB = false, @@ -125,7 +125,7 @@ Future tickTiles( ), ); - final window = RebuildCounter.open(label: 'tick'); + final window = RebuildWindow.open(label: 'tick'); notifier.value++; await tester.pump(); window.close(); diff --git a/test/rebuild_counter_test.dart b/test/window_test.dart similarity index 79% rename from test/rebuild_counter_test.dart rename to test/window_test.dart index 8eeeaf4..1429f90 100644 --- a/test/rebuild_counter_test.dart +++ b/test/window_test.dart @@ -3,7 +3,8 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:rebuildcop/rebuildcop.dart'; -import 'package:rebuildcop/src/rebuild_counter.dart' show closeLeftOpenWindow; +import 'package:rebuildcop/src/tally/rebuild_window.dart' + show closeAbandonedWindow; import 'support/fixtures.dart'; @@ -11,9 +12,9 @@ void main() { group('counting', () { testWidgets('counts rebuilds of widgets that already existed', (tester) async { - final screen = await pumpScreen(tester); + final screen = await showScreen(tester); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); screen.bump(); await tester.pump(); window.close(); @@ -30,11 +31,11 @@ void main() { testWidgets('ignores everything before open and after close', (tester) async { - final screen = await pumpScreen(tester); + final screen = await showScreen(tester); screen.bump(); await tester.pump(); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); window.close(); screen.bump(); await tester.pump(); @@ -43,9 +44,9 @@ void main() { }); testWidgets('counts every frame in the window', (tester) async { - final screen = await pumpScreen(tester); + final screen = await showScreen(tester); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); for (var i = 0; i < 3; i++) { screen.bump(); await tester.pump(); @@ -59,7 +60,7 @@ void main() { testWidgets('tells mounts apart from rebuilds', (tester) async { await pumpList(tester); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); await tester.drag(find.byType(ListView), const Offset(0, -500)); await tester.pump(); window.close(); @@ -74,7 +75,7 @@ void main() { 'a widget mounted inside the window then rebuilt counts once ' 'as each', (tester) async { await tester.pumpWidget(const SizedBox()); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); await tester.pumpWidget( const Directionality( textDirection: TextDirection.ltr, @@ -90,9 +91,9 @@ void main() { }); testWidgets('matches by name and by predicate', (tester) async { - final screen = await pumpScreen(tester); + final screen = await showScreen(tester); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); screen.bump(); await tester.pump(); window.close(); @@ -103,9 +104,9 @@ void main() { }); testWidgets('counts a subtree by finder', (tester) async { - final screen = await pumpScreen(tester); + final screen = await showScreen(tester); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); screen.bump(); await tester.pump(); window.close(); @@ -121,9 +122,9 @@ void main() { testWidgets('groups counts by type, busiest first, ties by name', (tester) async { - final screen = await pumpScreen(tester); + final screen = await showScreen(tester); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); screen.bump(); await tester.pump(); window.close(); @@ -137,9 +138,9 @@ void main() { }); testWidgets('records the ancestry of each rebuild', (tester) async { - final screen = await pumpScreen(tester); + final screen = await showScreen(tester); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); screen.bump(); await tester.pump(); window.close(); @@ -159,7 +160,7 @@ void main() { group('the window', () { testWidgets('cannot be closed twice', (tester) async { - final window = RebuildCounter.open(label: 'twice'); + final window = RebuildWindow.open(label: 'twice'); window.close(); expect( window.close, @@ -172,18 +173,18 @@ void main() { }); testWidgets('describes itself', (tester) async { - final window = RebuildCounter.open(label: 'tap'); - expect('$window', 'RebuildCounter("tap", open, 0 rebuilds, 0 mounts)'); + final window = RebuildWindow.open(label: 'tap'); + expect('$window', 'RebuildWindow("tap", open, 0 rebuilds, 0 mounts)'); window.close(); expect( - '${RebuildCounter.open()..close()}', - 'RebuildCounter(closed, 0 rebuilds, 0 mounts)', + '${RebuildWindow.open()..close()}', + 'RebuildWindow(closed, 0 rebuilds, 0 mounts)', ); }); testWidgets('releases the hook when it closes', (tester) async { expect(debugOnRebuildDirtyWidget, isNull); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); expect(debugOnRebuildDirtyWidget, isNotNull); window.close(); expect(debugOnRebuildDirtyWidget, isNull); @@ -197,10 +198,10 @@ void main() { final RebuildDirtyWidgetCallback previous = record; debugOnRebuildDirtyWidget = previous; addTearDown(() => debugOnRebuildDirtyWidget = null); - final screen = await pumpScreen(tester); + final screen = await showScreen(tester); seen.clear(); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); screen.bump(); await tester.pump(); window.close(); @@ -211,12 +212,12 @@ void main() { }); testWidgets('windows overlap and close in any order', (tester) async { - final screen = await pumpScreen(tester); + final screen = await showScreen(tester); - final outer = RebuildCounter.open(label: 'outer'); + final outer = RebuildWindow.open(label: 'outer'); screen.bump(); await tester.pump(); - final inner = RebuildCounter.open(label: 'inner'); + final inner = RebuildWindow.open(label: 'inner'); screen.bump(); await tester.pump(); outer.close(); @@ -235,7 +236,7 @@ void main() { final RebuildDirtyWidgetCallback other = ignore; addTearDown(() => debugOnRebuildDirtyWidget = null); - final window = RebuildCounter.open(label: 'hijacked'); + final window = RebuildWindow.open(label: 'hijacked'); debugOnRebuildDirtyWidget = other; expect( @@ -253,9 +254,9 @@ void main() { testWidgets('a window left open is closed and reported at test end', (tester) async { - final window = RebuildCounter.open(label: 'forgotten'); + final window = RebuildWindow.open(label: 'forgotten'); - final message = closeLeftOpenWindow(window); + final message = closeAbandonedWindow(window); expect( message, @@ -263,14 +264,14 @@ void main() { 'closed')); expect(window.isOpen, isFalse); expect(debugOnRebuildDirtyWidget, isNull); - expect(closeLeftOpenWindow(window), isNull); + expect(closeAbandonedWindow(window), isNull); }); testWidgets('record() closes the window after the interaction', (tester) async { - final screen = await pumpScreen(tester); + final screen = await showScreen(tester); - final window = await RebuildCounter.record(() async { + final window = await RebuildWindow.record(() async { screen.bump(); await tester.pump(); }); @@ -282,7 +283,7 @@ void main() { testWidgets('record() closes the window when the interaction throws', (tester) async { await expectLater( - RebuildCounter.record(() async => throw StateError('boom')), + RebuildWindow.record(() async => throw StateError('boom')), throwsA(isA().having((e) => e.message, 'message', 'boom')), ); expect(debugOnRebuildDirtyWidget, isNull); @@ -295,7 +296,7 @@ void main() { var fetches = 3; var ticks = 10; - final window = RebuildCounter.open( + final window = RebuildWindow.open( fetchCount: () => fetches, counters: {'ticks': () => ticks}, ); @@ -310,20 +311,20 @@ void main() { }); testWidgets('cannot be read while the window is open', (tester) async { - final window = RebuildCounter.open(fetchCount: () => 0); + final window = RebuildWindow.open(fetchCount: () => 0); expect(() => window.counterDelta('fetchCount'), throwsStateError); window.close(); }); testWidgets('must exist to be read', (tester) async { - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); window.close(); expect(() => window.counterDelta('nope'), throwsArgumentError); }); test('fetchCount cannot also be passed as a named counter', () { expect( - () => RebuildCounter.open( + () => RebuildWindow.open( fetchCount: () => 0, counters: {'fetchCount': () => 0}, ), @@ -336,7 +337,7 @@ void main() { testWidgets('are snapshotted at both ends of the window', (tester) async { await pumpList(tester); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); await tester.drag(find.byType(ListView), const Offset(0, -300)); await tester.pump(); window.close(); @@ -351,7 +352,7 @@ void main() { testWidgets('resolve from a widget inside the scrollable', (tester) async { await pumpList(tester); - final window = RebuildCounter.open(); + final window = RebuildWindow.open(); await tester.drag(find.byType(ListView), const Offset(0, -100)); await tester.pump(); window.close(); @@ -361,8 +362,8 @@ void main() { }); testWidgets('are null when nothing scrollable is found', (tester) async { - final screen = await pumpScreen(tester); - final window = RebuildCounter.open(); + final screen = await showScreen(tester); + final window = RebuildWindow.open(); screen.bump(); await tester.pump(); window.close(); @@ -373,13 +374,13 @@ void main() { group('print mode', () { testWidgets('prints per-type counts when a window closes', (tester) async { - final screen = await pumpScreen(tester); - RebuildCounter.printCounts = true; - addTearDown(() => RebuildCounter.printCounts = false); + final screen = await showScreen(tester); + RebuildWindow.printCounts = true; + addTearDown(() => RebuildWindow.printCounts = false); var fetches = 0; final window = - RebuildCounter.open(label: 'bump', fetchCount: () => fetches); + RebuildWindow.open(label: 'bump', fetchCount: () => fetches); screen.bump(); fetches++; await tester.pump(); @@ -392,15 +393,15 @@ void main() { ); expect(printed, hasLength(1)); - expect(printed.single, startsWith('[rebuild counts] window "bump": ')); + expect(printed.single, startsWith('[rebuildcop] window "bump": ')); expect(printed.single, contains('2 CountLabel')); expect(printed.single, contains('counter fetchCount changed by 1')); }); - test('is off unless REBUILD_COUNTS is defined', () { + test('is off unless PRINT_REBUILDS is defined', () { expect( - RebuildCounter.printCounts, - const bool.fromEnvironment('REBUILD_COUNTS'), + RebuildWindow.printCounts, + const bool.fromEnvironment('PRINT_REBUILDS'), ); }); });