From 901b2eeb1021536eb4c7cafa1f31d41c7ee1e138 Mon Sep 17 00:00:00 2001 From: saimskywalker <15120878+saimskywalker@users.noreply.github.com> Date: Mon, 2 Nov 2026 00:13:24 +0800 Subject: [PATCH] refactor: rewrite the rebuild tracking core Write the counting window, matchers, failure tables and baseline files again from the public behaviour spec. The window class is now RebuildWindow and print mode reads PRINT_REBUILDS. Release 0.3.0. --- CHANGELOG.md | 48 +- README.md | 17 +- doc/api.md | 13 +- doc/budgets.md | 6 +- doc/faq.md | 6 +- example/README.md | 4 +- example/test/bloc_test.dart | 4 +- example/test/provider_test.dart | 4 +- example/test/riverpod_test.dart | 6 +- example/test/set_state_test.dart | 10 +- lib/rebuildcop.dart | 33 +- lib/src/baseline.dart | 211 ------ lib/src/baseline_store.dart | 7 - lib/src/baseline_store_io.dart | 71 -- lib/src/baseline_store_stub.dart | 18 - lib/src/interaction.dart | 156 ----- lib/src/matchers.dart | 265 -------- lib/src/output/type_table.dart | 81 +++ lib/src/rebuild_counter.dart | 610 ------------------ lib/src/report.dart | 95 --- lib/src/tally/build_record.dart | 71 ++ lib/src/tally/hook_relay.dart | 63 ++ lib/src/tally/rebuild_window.dart | 370 +++++++++++ lib/src/tally/tree_survey.dart | 67 ++ lib/src/verdict/baseline/baseline_file.dart | 3 + .../verdict/baseline/baseline_file_vm.dart | 31 + .../verdict/baseline/baseline_file_web.dart | 13 + .../verdict/baseline/baseline_matcher.dart | 220 +++++++ lib/src/verdict/budgets.dart | 248 +++++++ lib/src/verdict/presence.dart | 72 +++ lib/src/verdict/proof.dart | 139 ++++ lib/src/verdict/window_matcher.dart | 48 ++ pubspec.yaml | 2 +- test/absent_type_test.dart | 8 +- test/baseline_test.dart | 22 +- test/binding/app_binding_test.dart | 4 +- test/binding/live_binding_test.dart | 2 +- test/binding/no_binding_test.dart | 2 +- test/interaction_test.dart | 22 +- test/matchers_test.dart | 20 +- test/report_test.dart | 34 +- test/support/fixtures.dart | 6 +- ...ild_counter_test.dart => window_test.dart} | 103 +-- 43 files changed, 1627 insertions(+), 1608 deletions(-) delete mode 100644 lib/src/baseline.dart delete mode 100644 lib/src/baseline_store.dart delete mode 100644 lib/src/baseline_store_io.dart delete mode 100644 lib/src/baseline_store_stub.dart delete mode 100644 lib/src/interaction.dart delete mode 100644 lib/src/matchers.dart create mode 100644 lib/src/output/type_table.dart delete mode 100644 lib/src/rebuild_counter.dart delete mode 100644 lib/src/report.dart create mode 100644 lib/src/tally/build_record.dart create mode 100644 lib/src/tally/hook_relay.dart create mode 100644 lib/src/tally/rebuild_window.dart create mode 100644 lib/src/tally/tree_survey.dart create mode 100644 lib/src/verdict/baseline/baseline_file.dart create mode 100644 lib/src/verdict/baseline/baseline_file_vm.dart create mode 100644 lib/src/verdict/baseline/baseline_file_web.dart create mode 100644 lib/src/verdict/baseline/baseline_matcher.dart create mode 100644 lib/src/verdict/budgets.dart create mode 100644 lib/src/verdict/presence.dart create mode 100644 lib/src/verdict/proof.dart create mode 100644 lib/src/verdict/window_matcher.dart rename test/{rebuild_counter_test.dart => window_test.dart} (79%) 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'), ); }); });