diff --git a/CHANGELOG.md b/CHANGELOG.md index fb95c3da..da9c5875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Other changes * Update `MacosScrollBar.thumbVisibility` with the latest change introduced in Flutter 3.7 * Update `README.md` to address issues [#325](https://github.com/GroovinChip/macos_ui/issues/325) & [#332](https://github.com/GroovinChip/macos_ui/issues/332) +* Fixed a bug where `CapacityIndicator` only worked correctly for splits = 10 + ## [1.7.6] * Fixed a bug where `MacosPopupButton` would report that a `ScrollController` was not attached to any views diff --git a/example/lib/pages/indicators_page.dart b/example/lib/pages/indicators_page.dart index 58a6d391..e5923d2d 100644 --- a/example/lib/pages/indicators_page.dart +++ b/example/lib/pages/indicators_page.dart @@ -41,6 +41,7 @@ class _IndicatorsPageState extends State { CapacityIndicator( value: sliderValue, onChanged: (v) => setState(() => sliderValue = v), + splits: 20, discrete: true, ), const SizedBox(height: 20), diff --git a/lib/src/indicators/capacity_indicators.dart b/lib/src/indicators/capacity_indicators.dart index 461f3055..ed26f90f 100644 --- a/lib/src/indicators/capacity_indicators.dart +++ b/lib/src/indicators/capacity_indicators.dart @@ -90,7 +90,7 @@ class CapacityIndicator extends StatelessWidget { } void _handleUpdate(Offset lp, double width) { - double value = (lp.dx / width) * splits; + double value = (lp.dx / width) * 100 / splits; onChanged?.call(value.clamp(0.0, 100.0)); } @@ -108,7 +108,7 @@ class CapacityIndicator extends StatelessWidget { if (width.isInfinite) width = 100; final splitWidth = width / splits; if (discrete) { - final fillToIndex = value / splits - 1; + final fillToIndex = (value / 100) * splits - 1; return SizedBox( width: width, child: GestureDetector( diff --git a/test/indicators/capacity_indicators_test.dart b/test/indicators/capacity_indicators_test.dart index 25b274cf..5f5eb33a 100644 --- a/test/indicators/capacity_indicators_test.dart +++ b/test/indicators/capacity_indicators_test.dart @@ -1,7 +1,12 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + import 'package:flutter_test/flutter_test.dart'; + import 'package:macos_ui/macos_ui.dart'; +import '../mock_canvas.dart'; + void main() { testWidgets('debugFillProperties', (tester) async { final builder = DiagnosticPropertiesBuilder(); @@ -27,4 +32,163 @@ void main() { ], ); }); + + testWidgets('debugFillProperties with discrete splits = 20', (tester) async { + final builder = DiagnosticPropertiesBuilder(); + const CapacityIndicator( + value: 50, + splits: 20, + discrete: true, + ).debugFillProperties(builder); + + final description = builder.properties + .where((node) => !node.isFiltered(DiagnosticLevel.info)) + .map((node) => node.toString()) + .toList(); + + expect( + description, + [ + 'value: 50.0', + 'splits: 20', + 'color: systemGreen(*color = Color(0xff34c759)*, darkColor = Color(0xff30d158), highContrastColor = Color(0xff248a3d), darkHighContrastColor = Color(0xff30db5b), resolved by: UNRESOLVED)', + 'backgroundColor: tertiarySystemGroupedBackground(*color = Color(0xfff2f2f7)*, darkColor = Color(0xff2c2c2e), highContrastColor = Color(0xffebebf0), darkHighContrastColor = Color(0xff363638), *elevatedColor = Color(0xfff2f2f7)*, darkElevatedColor = Color(0xff3a3a3c), highContrastElevatedColor = Color(0xffebebf0), darkHighContrastElevatedColor = Color(0xff444446), resolved by: UNRESOLVED)', + 'borderColor: tertiaryLabel(*color = Color(0x4c3c3c43)*, darkColor = Color(0x4cebebf5), highContrastColor = Color(0x603c3c43), darkHighContrastColor = Color(0x60ebebf5), resolved by: UNRESOLVED)', + 'semanticLabel: null', + ], + ); + }); + + testWidgets( + 'CapacityIndicator paints the correct number of segments', + (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 200.0, + child: CapacityIndicator( + value: 50, + splits: 20, + discrete: true, + ), + ), + ), + ), + ); + + expect( + find.byType(CapacityIndicator), + // each discrete segment is drawn 3 times, two times with fill, last time with stroke + paintsExactlyCountTimes(#drawRRect, 20 * 3), + ); + }, + ); + + testWidgets( + 'CapacityIndicator paints two filled segments for value=10 and 20 segments', + (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 200.0, + child: CapacityIndicator( + value: 10, + splits: 20, + discrete: true, + ), + ), + ), + ), + ); + + expect( + find.byType(CapacityIndicator), + // each discrete segment is drawn 3 times, background - fill - stroke + // a filled segment is drawn by fromLTRBR with LTRB=0,0,8,16 + // an empty segment is drawnby fromLTRBAndCorners with LTRB=0,0,0,16 + paints + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 8.0, + 16.0, + const Radius.circular(2.0), + ), + ) + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 8.0, + 16.0, + const Radius.circular(2.0), + ), + ) + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 8.0, + 16.0, + const Radius.circular(2.0), + ), + ) + ..translate(x: 10.0, y: 0.0) + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 8.0, + 16.0, + const Radius.circular(2.0), + ), + ) + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 8.0, + 16.0, + const Radius.circular(2.0), + ), + ) + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 8.0, + 16.0, + const Radius.circular(2.0), + ), + ) + ..translate(x: 20.0, y: 0.0) + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 8.0, + 16.0, + const Radius.circular(2.0), + ), + ) + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 0.0, + 0.0, + 0.0, + 16.0, + topLeft: const Radius.circular(2.0), + topRight: const Radius.circular(0.0), + bottomRight: const Radius.circular(0.0), + bottomLeft: const Radius.circular(2.0), + ), + ), + ); + }, + ); } diff --git a/test/mock_canvas.dart b/test/mock_canvas.dart new file mode 100644 index 00000000..c86c7482 --- /dev/null +++ b/test/mock_canvas.dart @@ -0,0 +1,1912 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui' as ui show Paragraph, Image; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'recording_canvas.dart'; + +/// Matches objects or functions that paint a display list that matches the +/// canvas calls described by the pattern. +/// +/// Specifically, this can be applied to [RenderObject]s, [Finder]s that +/// correspond to a single [RenderObject], and functions that have either of the +/// following signatures: +/// +/// ```dart +/// void function(PaintingContext context, Offset offset); +/// void function(Canvas canvas); +/// ``` +/// +/// In the case of functions that take a [PaintingContext] and an [Offset], the +/// [paints] matcher will always pass a zero offset. +/// +/// To specify the pattern, call the methods on the returned object. For example: +/// +/// ```dart +/// expect(myRenderObject, paints..circle(radius: 10.0)..circle(radius: 20.0)); +/// ``` +/// +/// This particular pattern would verify that the render object `myRenderObject` +/// paints, among other things, two circles of radius 10.0 and 20.0 (in that +/// order). +/// +/// See [PaintPattern] for a discussion of the semantics of paint patterns. +/// +/// To match something which paints nothing, see [paintsNothing]. +/// +/// To match something which asserts instead of painting, see [paintsAssertion]. +PaintPattern get paints => _TestRecordingCanvasPatternMatcher(); + +/// Matches objects or functions that does not paint anything on the canvas. +Matcher get paintsNothing => _TestRecordingCanvasPaintsNothingMatcher(); + +/// Matches objects or functions that assert when they try to paint. +Matcher get paintsAssertion => _TestRecordingCanvasPaintsAssertionMatcher(); + +/// Matches objects or functions that draw `methodName` exactly `count` number of times. +Matcher paintsExactlyCountTimes(Symbol methodName, int count) { + return _TestRecordingCanvasPaintsCountMatcher(methodName, count); +} + +/// Signature for the [PaintPattern.something] and [PaintPattern.everything] +/// predicate argument. +/// +/// Used by the [paints] matcher. +/// +/// The `methodName` argument is a [Symbol], and can be compared with the symbol +/// literal syntax, for example: +/// +/// ```dart +/// if (methodName == #drawCircle) { ... } +/// ``` +typedef PaintPatternPredicate = bool Function( + Symbol methodName, List arguments); + +/// The signature of [RenderObject.paint] functions. +typedef _ContextPainterFunction = void Function( + PaintingContext context, Offset offset); + +/// The signature of functions that paint directly on a canvas. +typedef _CanvasPainterFunction = void Function(Canvas canvas); + +/// Builder interface for patterns used to match display lists (canvas calls). +/// +/// The [paints] matcher returns a [PaintPattern] so that you can build the +/// pattern in the [expect] call. +/// +/// Patterns are subset matches, meaning that any calls not described by the +/// pattern are ignored. This allows, for instance, transforms to be skipped. +abstract class PaintPattern { + /// Indicates that a transform is expected next. + /// + /// Calls are skipped until a call to [Canvas.transform] is found. The call's + /// arguments are compared to those provided here. If any fail to match, or if + /// no call to [Canvas.transform] is found, then the matcher fails. + /// + /// Dynamic so matchers can be more easily passed in. + /// + /// The `matrix4` argument is dynamic so it can be either a [Matcher], or a + /// [Float64List] of [double]s. If it is a [Float64List] of [double]s then + /// each value in the matrix must match in the expected matrix. A deep + /// matching [Matcher] such as [equals] can be used to test each value in the + /// matrix with utilities such as [moreOrLessEquals]. + void transform({dynamic matrix4}); + + /// Indicates that a translation transform is expected next. + /// + /// Calls are skipped until a call to [Canvas.translate] is found. The call's + /// arguments are compared to those provided here. If any fail to match, or if + /// no call to [Canvas.translate] is found, then the matcher fails. + void translate({double? x, double? y}); + + /// Indicates that a scale transform is expected next. + /// + /// Calls are skipped until a call to [Canvas.scale] is found. The call's + /// arguments are compared to those provided here. If any fail to match, or if + /// no call to [Canvas.scale] is found, then the matcher fails. + void scale({double? x, double? y}); + + /// Indicates that a rotate transform is expected next. + /// + /// Calls are skipped until a call to [Canvas.rotate] is found. If the `angle` + /// argument is provided here, the call's argument is compared to it. If that + /// fails to match, or if no call to [Canvas.rotate] is found, then the + /// matcher fails. + void rotate({double? angle}); + + /// Indicates that a save is expected next. + /// + /// Calls are skipped until a call to [Canvas.save] is found. If none is + /// found, the matcher fails. + /// + /// See also: + /// + /// * [restore], which indicates that a restore is expected next. + /// * [saveRestore], which indicates that a matching pair of save/restore + /// calls is expected next. + void save(); + + /// Indicates that a restore is expected next. + /// + /// Calls are skipped until a call to [Canvas.restore] is found. If none is + /// found, the matcher fails. + /// + /// See also: + /// + /// * [save], which indicates that a save is expected next. + /// * [saveRestore], which indicates that a matching pair of save/restore + /// calls is expected next. + void restore(); + + /// Indicates that a matching pair of save/restore calls is expected next. + /// + /// Calls are skipped until a call to [Canvas.save] is found, then, calls are + /// skipped until the matching [Canvas.restore] call is found. If no matching + /// pair of calls could be found, the matcher fails. + /// + /// See also: + /// + /// * [save], which indicates that a save is expected next. + /// * [restore], which indicates that a restore is expected next. + void saveRestore(); + + /// Indicates that a rectangular clip is expected next. + /// + /// The next rectangular clip is examined. Any arguments that are passed to + /// this method are compared to the actual [Canvas.clipRect] call's argument + /// and any mismatches result in failure. + /// + /// If no call to [Canvas.clipRect] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.clipRect] call are ignored. + void clipRect({Rect? rect}); + + /// Indicates that a path clip is expected next. + /// + /// The next path clip is examined. + /// The path that is passed to the actual [Canvas.clipPath] call is matched + /// using [pathMatcher]. + /// + /// If no call to [Canvas.clipPath] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.clipPath] call are ignored. + void clipPath({Matcher? pathMatcher}); + + /// Indicates that a rectangle is expected next. + /// + /// The next rectangle is examined. Any arguments that are passed to this + /// method are compared to the actual [Canvas.drawRect] call's arguments + /// and any mismatches result in failure. + /// + /// If no call to [Canvas.drawRect] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawRect] call are ignored. + /// + /// The [Paint]-related arguments (`color`, `strokeWidth`, `hasMaskFilter`, + /// `style`) are compared against the state of the [Paint] object after the + /// painting has completed, not at the time of the call. If the same [Paint] + /// object is reused multiple times, then this may not match the actual + /// arguments as they were seen by the method. + void rect( + {Rect? rect, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}); + + /// Indicates that a rounded rectangle clip is expected next. + /// + /// The next rounded rectangle clip is examined. Any arguments that are passed + /// to this method are compared to the actual [Canvas.clipRRect] call's + /// argument and any mismatches result in failure. + /// + /// If no call to [Canvas.clipRRect] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.clipRRect] call are ignored. + void clipRRect({RRect? rrect}); + + /// Indicates that a rounded rectangle is expected next. + /// + /// The next rounded rectangle is examined. Any arguments that are passed to + /// this method are compared to the actual [Canvas.drawRRect] call's arguments + /// and any mismatches result in failure. + /// + /// If no call to [Canvas.drawRRect] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawRRect] call are ignored. + /// + /// The [Paint]-related arguments (`color`, `strokeWidth`, `hasMaskFilter`, + /// `style`) are compared against the state of the [Paint] object after the + /// painting has completed, not at the time of the call. If the same [Paint] + /// object is reused multiple times, then this may not match the actual + /// arguments as they were seen by the method. + void rrect( + {RRect? rrect, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}); + + /// Indicates that a rounded rectangle outline is expected next. + /// + /// The next call to [Canvas.drawRRect] is examined. Any arguments that are + /// passed to this method are compared to the actual [Canvas.drawRRect] call's + /// arguments and any mismatches result in failure. + /// + /// If no call to [Canvas.drawRRect] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawRRect] call are ignored. + /// + /// The [Paint]-related arguments (`color`, `strokeWidth`, `hasMaskFilter`, + /// `style`) are compared against the state of the [Paint] object after the + /// painting has completed, not at the time of the call. If the same [Paint] + /// object is reused multiple times, then this may not match the actual + /// arguments as they were seen by the method. + void drrect( + {RRect? outer, + RRect? inner, + Color? color, + double strokeWidth, + bool hasMaskFilter, + PaintingStyle style}); + + /// Indicates that a circle is expected next. + /// + /// The next circle is examined. Any arguments that are passed to this method + /// are compared to the actual [Canvas.drawCircle] call's arguments and any + /// mismatches result in failure. + /// + /// If no call to [Canvas.drawCircle] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawCircle] call are ignored. + /// + /// The [Paint]-related arguments (`color`, `strokeWidth`, `hasMaskFilter`, + /// `style`) are compared against the state of the [Paint] object after the + /// painting has completed, not at the time of the call. If the same [Paint] + /// object is reused multiple times, then this may not match the actual + /// arguments as they were seen by the method. + void circle( + {double? x, + double? y, + double? radius, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}); + + /// Indicates that a path is expected next. + /// + /// The next path is examined. Any arguments that are passed to this method + /// are compared to the actual [Canvas.drawPath] call's `paint` argument, and + /// any mismatches result in failure. + /// + /// To introspect the Path object (as it stands after the painting has + /// completed), the `includes` and `excludes` arguments can be provided to + /// specify points that should be considered inside or outside the path + /// (respectively). + /// + /// If no call to [Canvas.drawPath] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawPath] call are ignored. + /// + /// The [Paint]-related arguments (`color`, `strokeWidth`, `hasMaskFilter`, + /// `style`) are compared against the state of the [Paint] object after the + /// painting has completed, not at the time of the call. If the same [Paint] + /// object is reused multiple times, then this may not match the actual + /// arguments as they were seen by the method. + void path( + {Iterable? includes, + Iterable? excludes, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}); + + /// Indicates that a line is expected next. + /// + /// The next line is examined. Any arguments that are passed to this method + /// are compared to the actual [Canvas.drawLine] call's `p1`, `p2`, and + /// `paint` arguments, and any mismatches result in failure. + /// + /// If no call to [Canvas.drawLine] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawLine] call are ignored. + /// + /// The [Paint]-related arguments (`color`, `strokeWidth`, `hasMaskFilter`, + /// `style`) are compared against the state of the [Paint] object after the + /// painting has completed, not at the time of the call. If the same [Paint] + /// object is reused multiple times, then this may not match the actual + /// arguments as they were seen by the method. + void line( + {Offset? p1, + Offset? p2, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}); + + /// Indicates that an arc is expected next. + /// + /// The next arc is examined. Any arguments that are passed to this method + /// are compared to the actual [Canvas.drawArc] call's `paint` argument, and + /// any mismatches result in failure. + /// + /// If no call to [Canvas.drawArc] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawArc] call are ignored. + /// + /// The [Paint]-related arguments (`color`, `strokeWidth`, `hasMaskFilter`, + /// `style`) are compared against the state of the [Paint] object after the + /// painting has completed, not at the time of the call. If the same [Paint] + /// object is reused multiple times, then this may not match the actual + /// arguments as they were seen by the method. + void arc( + {Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}); + + /// Indicates that a paragraph is expected next. + /// + /// Calls are skipped until a call to [Canvas.drawParagraph] is found. Any + /// arguments that are passed to this method are compared to the actual + /// [Canvas.drawParagraph] call's argument, and any mismatches result in failure. + /// + /// The `offset` argument can be either an [Offset] or a [Matcher]. If it is + /// an [Offset] then the actual value must match the expected offset + /// precisely. If it is a [Matcher] then the comparison is made according to + /// the semantics of the [Matcher]. For example, [within] can be used to + /// assert that the actual offset is within a given distance from the expected + /// offset. + /// + /// If no call to [Canvas.drawParagraph] was made, then this results in failure. + void paragraph({ui.Paragraph? paragraph, dynamic offset}); + + /// Indicates that a shadow is expected next. + /// + /// The next shadow is examined. Any arguments that are passed to this method + /// are compared to the actual [Canvas.drawShadow] call's `paint` argument, + /// and any mismatches result in failure. + /// + /// In tests, shadows from framework features such as [BoxShadow] or + /// [Material] are disabled by default, and thus this predicate would not + /// match. The [debugDisableShadows] flag controls this. + /// + /// To introspect the Path object (as it stands after the painting has + /// completed), the `includes` and `excludes` arguments can be provided to + /// specify points that should be considered inside or outside the path + /// (respectively). + /// + /// If no call to [Canvas.drawShadow] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawShadow] call are ignored. + void shadow( + {Iterable? includes, + Iterable? excludes, + Color? color, + double? elevation, + bool? transparentOccluder}); + + /// Indicates that an image is expected next. + /// + /// The next call to [Canvas.drawImage] is examined, and its arguments + /// compared to those passed to _this_ method. + /// + /// If no call to [Canvas.drawImage] was made, then this results in + /// failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawImage] call are ignored. + /// + /// The [Paint]-related arguments (`color`, `strokeWidth`, `hasMaskFilter`, + /// `style`) are compared against the state of the [Paint] object after the + /// painting has completed, not at the time of the call. If the same [Paint] + /// object is reused multiple times, then this may not match the actual + /// arguments as they were seen by the method. + void image( + {ui.Image? image, + double? x, + double? y, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}); + + /// Indicates that an image subsection is expected next. + /// + /// The next call to [Canvas.drawImageRect] is examined, and its arguments + /// compared to those passed to _this_ method. + /// + /// If no call to [Canvas.drawImageRect] was made, then this results in + /// failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawImageRect] call are ignored. + /// + /// The [Paint]-related arguments (`color`, `strokeWidth`, `hasMaskFilter`, + /// `style`) are compared against the state of the [Paint] object after the + /// painting has completed, not at the time of the call. If the same [Paint] + /// object is reused multiple times, then this may not match the actual + /// arguments as they were seen by the method. + void drawImageRect( + {ui.Image? image, + Rect? source, + Rect? destination, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}); + + /// Provides a custom matcher. + /// + /// Each method call after the last matched call (if any) will be passed to + /// the given predicate, along with the values of its (positional) arguments. + /// + /// For each one, the predicate must either return a boolean or throw a [String]. + /// + /// If the predicate returns true, the call is considered a successful match + /// and the next step in the pattern is examined. If this was the last step, + /// then any calls that were not yet matched are ignored and the [paints] + /// [Matcher] is considered a success. + /// + /// If the predicate returns false, then the call is considered uninteresting + /// and the predicate will be called again for the next [Canvas] call that was + /// made by the [RenderObject] under test. If this was the last call, then the + /// [paints] [Matcher] is considered to have failed. + /// + /// If the predicate throws a [String], then the [paints] [Matcher] is + /// considered to have failed. The thrown string is used in the message + /// displayed from the test framework and should be complete sentence + /// describing the problem. + void something(PaintPatternPredicate predicate); + + /// Provides a custom matcher. + /// + /// Each method call after the last matched call (if any) will be passed to + /// the given predicate, along with the values of its (positional) arguments. + /// + /// For each one, the predicate must either return a boolean or throw a [String]. + /// + /// The predicate will be applied to each [Canvas] call until it returns false + /// or all of the method calls have been tested. + /// + /// If the predicate returns false, then the [paints] [Matcher] is considered + /// to have failed. If all calls are tested without failing, then the [paints] + /// [Matcher] is considered a success. + /// + /// If the predicate throws a [String], then the [paints] [Matcher] is + /// considered to have failed. The thrown string is used in the message + /// displayed from the test framework and should be complete sentence + /// describing the problem. + void everything(PaintPatternPredicate predicate); +} + +/// Matches a [Path] that contains (as defined by [Path.contains]) the given +/// `includes` points and does not contain the given `excludes` points. +Matcher isPathThat({ + Iterable includes = const [], + Iterable excludes = const [], +}) { + return _PathMatcher(includes.toList(), excludes.toList()); +} + +class _PathMatcher extends Matcher { + _PathMatcher(this.includes, this.excludes); + + List includes; + List excludes; + + @override + bool matches(Object? object, Map matchState) { + if (object is! Path) { + matchState[this] = 'The given object ($object) was not a Path.'; + return false; + } + final Path path = object; + final List errors = [ + for (final Offset offset in includes) + if (!path.contains(offset)) + 'Offset $offset should be inside the path, but is not.', + for (final Offset offset in excludes) + if (path.contains(offset)) + 'Offset $offset should be outside the path, but is not.', + ]; + if (errors.isEmpty) { + return true; + } + matchState[this] = + 'Not all the given points were inside or outside the path as expected:\n ${errors.join("\n ")}'; + return false; + } + + @override + Description describe(Description description) { + String points(List list) { + final int count = list.length; + if (count == 1) { + return 'one particular point'; + } + return '$count particular points'; + } + + return description.add( + 'A Path that contains ${points(includes)} but does not contain ${points(excludes)}.'); + } + + @override + Description describeMismatch( + dynamic item, + Description description, + Map matchState, + bool verbose, + ) { + return description.add(matchState[this] as String); + } +} + +class _MismatchedCall { + const _MismatchedCall(this.message, this.callIntroduction, this.call); + final String message; + final String callIntroduction; + final RecordedInvocation call; +} + +bool _evaluatePainter(Object? object, Canvas canvas, PaintingContext context) { + if (object is _ContextPainterFunction) { + final _ContextPainterFunction function = object; + function(context, Offset.zero); + } else if (object is _CanvasPainterFunction) { + final _CanvasPainterFunction function = object; + function(canvas); + } else { + if (object is Finder) { + TestAsyncUtils.guardSync(); + final Finder finder = object; + object = finder.evaluate().single.renderObject; + } + if (object is RenderObject) { + final RenderObject renderObject = object; + renderObject.paint(context, Offset.zero); + } else { + return false; + } + } + return true; +} + +abstract class _TestRecordingCanvasMatcher extends Matcher { + @override + bool matches(Object? object, Map matchState) { + final TestRecordingCanvas canvas = TestRecordingCanvas(); + final TestRecordingPaintingContext context = + TestRecordingPaintingContext(canvas); + final StringBuffer description = StringBuffer(); + String prefixMessage = 'unexpectedly failed.'; + bool result = false; + try { + if (!_evaluatePainter(object, canvas, context)) { + matchState[this] = + 'was not one of the supported objects for the "paints" matcher.'; + return false; + } + result = _evaluatePredicates(canvas.invocations, description); + if (!result) { + prefixMessage = 'did not match the pattern.'; + } + } catch (error, stack) { + prefixMessage = 'threw the following exception:'; + description.writeln(error.toString()); + description.write(stack.toString()); + result = false; + } + if (!result) { + if (canvas.invocations.isNotEmpty) { + description.write('The complete display list was:'); + for (final RecordedInvocation call in canvas.invocations) { + description.write('\n * $call'); + } + } + matchState[this] = '$prefixMessage\n$description'; + } + return result; + } + + bool _evaluatePredicates( + Iterable calls, StringBuffer description); + + @override + Description describeMismatch( + dynamic item, + Description description, + Map matchState, + bool verbose, + ) { + return description.add(matchState[this] as String); + } +} + +class _TestRecordingCanvasPaintsCountMatcher + extends _TestRecordingCanvasMatcher { + _TestRecordingCanvasPaintsCountMatcher(Symbol methodName, int count) + : _methodName = methodName, + _count = count; + + final Symbol _methodName; + final int _count; + + @override + Description describe(Description description) { + return description + .add('Object or closure painting $_methodName exactly $_count times'); + } + + @override + bool _evaluatePredicates( + Iterable calls, StringBuffer description) { + int count = 0; + for (final RecordedInvocation call in calls) { + if (call.invocation.isMethod && + call.invocation.memberName == _methodName) { + count++; + } + } + if (count != _count) { + description.write( + 'It painted $_methodName $count times instead of $_count times.'); + } + return count == _count; + } +} + +class _TestRecordingCanvasPaintsNothingMatcher + extends _TestRecordingCanvasMatcher { + @override + Description describe(Description description) { + return description.add('An object or closure that paints nothing.'); + } + + @override + bool _evaluatePredicates( + Iterable calls, StringBuffer description) { + final Iterable paintingCalls = + _filterCanvasCalls(calls); + if (paintingCalls.isEmpty) { + return true; + } + description.write( + 'painted something, the first call having the following stack:\n' + '${paintingCalls.first.stackToString(indent: " ")}\n', + ); + return false; + } + + static const List _nonPaintingOperations = [ + #save, + #restore, + ]; + + // Filters out canvas calls that are not painting anything. + static Iterable _filterCanvasCalls( + Iterable canvasCalls) { + return canvasCalls.where( + (RecordedInvocation canvasCall) => + !_nonPaintingOperations.contains(canvasCall.invocation.memberName), + ); + } +} + +class _TestRecordingCanvasPaintsAssertionMatcher extends Matcher { + @override + bool matches(Object? object, Map matchState) { + final TestRecordingCanvas canvas = TestRecordingCanvas(); + final TestRecordingPaintingContext context = + TestRecordingPaintingContext(canvas); + final StringBuffer description = StringBuffer(); + String prefixMessage = 'unexpectedly failed.'; + bool result = false; + try { + if (!_evaluatePainter(object, canvas, context)) { + matchState[this] = + 'was not one of the supported objects for the "paints" matcher.'; + return false; + } + prefixMessage = 'did not assert.'; + } on AssertionError { + result = true; + } catch (error, stack) { + prefixMessage = 'threw the following exception:'; + description.writeln(error.toString()); + description.write(stack.toString()); + result = false; + } + if (!result) { + if (canvas.invocations.isNotEmpty) { + description.write('The complete display list was:'); + for (final RecordedInvocation call in canvas.invocations) { + description.write('\n * $call'); + } + } + matchState[this] = '$prefixMessage\n$description'; + } + return result; + } + + @override + Description describe(Description description) { + return description + .add('An object or closure that asserts when it tries to paint.'); + } + + @override + Description describeMismatch( + dynamic item, + Description description, + Map matchState, + bool verbose, + ) { + return description.add(matchState[this] as String); + } +} + +class _TestRecordingCanvasPatternMatcher extends _TestRecordingCanvasMatcher + implements PaintPattern { + final List<_PaintPredicate> _predicates = <_PaintPredicate>[]; + + @override + void transform({dynamic matrix4}) { + _predicates.add(_FunctionPaintPredicate(#transform, [matrix4])); + } + + @override + void translate({double? x, double? y}) { + _predicates.add(_FunctionPaintPredicate(#translate, [x, y])); + } + + @override + void scale({double? x, double? y}) { + _predicates.add(_FunctionPaintPredicate(#scale, [x, y])); + } + + @override + void rotate({double? angle}) { + _predicates.add(_FunctionPaintPredicate(#rotate, [angle])); + } + + @override + void save() { + _predicates.add(_FunctionPaintPredicate(#save, [])); + } + + @override + void restore() { + _predicates.add(_FunctionPaintPredicate(#restore, [])); + } + + @override + void saveRestore() { + _predicates.add(_SaveRestorePairPaintPredicate()); + } + + @override + void clipRect({Rect? rect}) { + _predicates.add(_FunctionPaintPredicate(#clipRect, [rect])); + } + + @override + void clipPath({Matcher? pathMatcher}) { + _predicates.add(_FunctionPaintPredicate(#clipPath, [pathMatcher])); + } + + @override + void rect( + {Rect? rect, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) { + _predicates.add(_RectPaintPredicate( + rect: rect, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style)); + } + + @override + void clipRRect({RRect? rrect}) { + _predicates.add(_FunctionPaintPredicate(#clipRRect, [rrect])); + } + + @override + void rrect( + {RRect? rrect, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) { + _predicates.add(_RRectPaintPredicate( + rrect: rrect, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style)); + } + + @override + void drrect( + {RRect? outer, + RRect? inner, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) { + _predicates.add(_DRRectPaintPredicate( + outer: outer, + inner: inner, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style)); + } + + @override + void circle( + {double? x, + double? y, + double? radius, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) { + _predicates.add(_CirclePaintPredicate( + x: x, + y: y, + radius: radius, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style)); + } + + @override + void path( + {Iterable? includes, + Iterable? excludes, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) { + _predicates.add(_PathPaintPredicate( + includes: includes, + excludes: excludes, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style)); + } + + @override + void line( + {Offset? p1, + Offset? p2, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) { + _predicates.add(_LinePaintPredicate( + p1: p1, + p2: p2, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style)); + } + + @override + void arc( + {Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) { + _predicates.add(_ArcPaintPredicate( + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style)); + } + + @override + void paragraph({ui.Paragraph? paragraph, dynamic offset}) { + _predicates.add( + _FunctionPaintPredicate(#drawParagraph, [paragraph, offset])); + } + + @override + void shadow( + {Iterable? includes, + Iterable? excludes, + Color? color, + double? elevation, + bool? transparentOccluder}) { + _predicates.add(_ShadowPredicate( + includes: includes, + excludes: excludes, + color: color, + elevation: elevation, + transparentOccluder: transparentOccluder)); + } + + @override + void image( + {ui.Image? image, + double? x, + double? y, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) { + _predicates.add(_DrawImagePaintPredicate( + image: image, + x: x, + y: y, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style)); + } + + @override + void drawImageRect( + {ui.Image? image, + Rect? source, + Rect? destination, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) { + _predicates.add(_DrawImageRectPaintPredicate( + image: image, + source: source, + destination: destination, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style)); + } + + @override + void something(PaintPatternPredicate predicate) { + _predicates.add(_SomethingPaintPredicate(predicate)); + } + + @override + void everything(PaintPatternPredicate predicate) { + _predicates.add(_EverythingPaintPredicate(predicate)); + } + + @override + Description describe(Description description) { + if (_predicates.isEmpty) { + return description.add('An object or closure and a paint pattern.'); + } + description.add('Object or closure painting:\n'); + return description.addAll( + '', + '\n', + '', + _predicates + .map((_PaintPredicate predicate) => predicate.toString()), + ); + } + + @override + bool _evaluatePredicates( + Iterable calls, StringBuffer description) { + if (calls.isEmpty) { + description.writeln('It painted nothing.'); + return false; + } + if (_predicates.isEmpty) { + description.writeln( + 'It painted something, but you must now add a pattern to the paints matcher ' + 'in the test to verify that it matches the important parts of the following.', + ); + return false; + } + final Iterator<_PaintPredicate> predicate = _predicates.iterator; + final Iterator call = calls.iterator..moveNext(); + try { + while (predicate.moveNext()) { + predicate.current.match(call); + } + // We allow painting more than expected. + } on _MismatchedCall catch (data) { + description.writeln(data.message); + description.writeln(data.callIntroduction); + description.writeln(data.call.stackToString(indent: ' ')); + return false; + } on String catch (s) { + description.writeln(s); + try { + description.write( + 'The stack of the offending call was:\n${call.current.stackToString(indent: " ")}\n'); + } on TypeError catch (_) { + // All calls have been evaluated + } + return false; + } + return true; + } +} + +abstract class _PaintPredicate { + void match(Iterator call); + + @protected + void checkMethod(Iterator call, Symbol symbol) { + int others = 0; + final RecordedInvocation firstCall = call.current; + while (!call.current.invocation.isMethod || + call.current.invocation.memberName != symbol) { + others += 1; + if (!call.moveNext()) { + throw _MismatchedCall( + 'It called $others other method${others == 1 ? "" : "s"} on the canvas, ' + 'the first of which was $firstCall, but did not ' + 'call ${_symbolName(symbol)}() at the time where $this was expected.', + 'The first method that was called when the call to ${_symbolName(symbol)}() ' + 'was expected, $firstCall, was called with the following stack:', + firstCall, + ); + } + } + } + + @override + String toString() { + throw FlutterError('$runtimeType does not implement toString.'); + } +} + +abstract class _DrawCommandPaintPredicate extends _PaintPredicate { + _DrawCommandPaintPredicate( + this.symbol, + this.name, + this.argumentCount, + this.paintArgumentIndex, { + this.color, + this.strokeWidth, + this.hasMaskFilter, + this.style, + }); + + final Symbol symbol; + final String name; + final int argumentCount; + final int paintArgumentIndex; + final Color? color; + final double? strokeWidth; + final bool? hasMaskFilter; + final PaintingStyle? style; + + String get methodName => _symbolName(symbol); + + @override + void match(Iterator call) { + checkMethod(call, symbol); + final int actualArgumentCount = + call.current.invocation.positionalArguments.length; + if (actualArgumentCount != argumentCount) { + throw 'It called $methodName with $actualArgumentCount argument${actualArgumentCount == 1 ? "" : "s"}; expected $argumentCount.'; + } + verifyArguments(call.current.invocation.positionalArguments); + call.moveNext(); + } + + @protected + @mustCallSuper + void verifyArguments(List arguments) { + final Paint paintArgument = arguments[paintArgumentIndex] as Paint; + if (color != null && paintArgument.color != color) { + throw 'It called $methodName with a paint whose color, ${paintArgument.color}, was not exactly the expected color ($color).'; + } + if (strokeWidth != null && paintArgument.strokeWidth != strokeWidth) { + throw 'It called $methodName with a paint whose strokeWidth, ${paintArgument.strokeWidth}, was not exactly the expected strokeWidth ($strokeWidth).'; + } + if (hasMaskFilter != null && + (paintArgument.maskFilter != null) != hasMaskFilter) { + if (hasMaskFilter!) { + throw 'It called $methodName with a paint that did not have a mask filter, despite expecting one.'; + } else { + throw 'It called $methodName with a paint that did have a mask filter, despite not expecting one.'; + } + } + if (style != null && paintArgument.style != style) { + throw 'It called $methodName with a paint whose style, ${paintArgument.style}, was not exactly the expected style ($style).'; + } + } + + @override + String toString() { + final List description = []; + debugFillDescription(description); + String result = name; + if (description.isNotEmpty) { + result += ' with ${description.join(", ")}'; + } + return result; + } + + @protected + @mustCallSuper + void debugFillDescription(List description) { + if (color != null) { + description.add('$color'); + } + if (strokeWidth != null) { + description.add('strokeWidth: $strokeWidth'); + } + if (hasMaskFilter != null) { + description.add(hasMaskFilter! ? 'a mask filter' : 'no mask filter'); + } + if (style != null) { + description.add('$style'); + } + } +} + +class _OneParameterPaintPredicate extends _DrawCommandPaintPredicate { + _OneParameterPaintPredicate( + Symbol symbol, + String name, { + required this.expected, + required Color? color, + required double? strokeWidth, + required bool? hasMaskFilter, + required PaintingStyle? style, + }) : super( + symbol, + name, + 2, + 1, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); + + final T? expected; + + @override + void verifyArguments(List arguments) { + super.verifyArguments(arguments); + final T actual = arguments[0] as T; + if (expected != null && actual != expected) { + throw 'It called $methodName with $T, $actual, which was not exactly the expected $T ($expected).'; + } + } + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + if (expected != null) { + if (expected.toString().contains(T.toString())) { + description.add('$expected'); + } else { + description.add('$T: $expected'); + } + } + } +} + +class _TwoParameterPaintPredicate extends _DrawCommandPaintPredicate { + _TwoParameterPaintPredicate( + Symbol symbol, + String name, { + required this.expected1, + required this.expected2, + required Color? color, + required double? strokeWidth, + required bool? hasMaskFilter, + required PaintingStyle? style, + }) : super( + symbol, + name, + 3, + 2, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); + + final T1? expected1; + + final T2? expected2; + + @override + void verifyArguments(List arguments) { + super.verifyArguments(arguments); + final T1 actual1 = arguments[0] as T1; + if (expected1 != null && actual1 != expected1) { + throw 'It called $methodName with its first argument (a $T1), $actual1, which was not exactly the expected $T1 ($expected1).'; + } + final T2 actual2 = arguments[1] as T2; + if (expected2 != null && actual2 != expected2) { + throw 'It called $methodName with its second argument (a $T2), $actual2, which was not exactly the expected $T2 ($expected2).'; + } + } + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + if (expected1 != null) { + if (expected1.toString().contains(T1.toString())) { + description.add('$expected1'); + } else { + description.add('$T1: $expected1'); + } + } + if (expected2 != null) { + if (expected2.toString().contains(T2.toString())) { + description.add('$expected2'); + } else { + description.add('$T2: $expected2'); + } + } + } +} + +class _RectPaintPredicate extends _OneParameterPaintPredicate { + _RectPaintPredicate( + {Rect? rect, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) + : super( + #drawRect, + 'a rectangle', + expected: rect, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); +} + +class _RRectPaintPredicate extends _DrawCommandPaintPredicate { + _RRectPaintPredicate( + {this.rrect, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) + : super( + #drawRRect, + 'a rounded rectangle', + 2, + 1, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); + + final RRect? rrect; + + @override + void verifyArguments(List arguments) { + super.verifyArguments(arguments); + const double eps = .0001; + final RRect actual = arguments[0] as RRect; + if (rrect != null && + ((actual.left - rrect!.left).abs() > eps || + (actual.right - rrect!.right).abs() > eps || + (actual.top - rrect!.top).abs() > eps || + (actual.bottom - rrect!.bottom).abs() > eps || + (actual.blRadiusX - rrect!.blRadiusX).abs() > eps || + (actual.blRadiusY - rrect!.blRadiusY).abs() > eps || + (actual.brRadiusX - rrect!.brRadiusX).abs() > eps || + (actual.brRadiusY - rrect!.brRadiusY).abs() > eps || + (actual.tlRadiusX - rrect!.tlRadiusX).abs() > eps || + (actual.tlRadiusY - rrect!.tlRadiusY).abs() > eps || + (actual.trRadiusX - rrect!.trRadiusX).abs() > eps || + (actual.trRadiusY - rrect!.trRadiusY).abs() > eps)) { + throw 'It called $methodName with RRect, $actual, which was not exactly the expected RRect ($rrect).'; + } + } + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + if (rrect != null) { + description.add('RRect: $rrect'); + } + } +} + +class _DRRectPaintPredicate extends _TwoParameterPaintPredicate { + _DRRectPaintPredicate( + {RRect? inner, + RRect? outer, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) + : super( + #drawDRRect, + 'a rounded rectangle outline', + expected1: outer, + expected2: inner, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); +} + +class _CirclePaintPredicate extends _DrawCommandPaintPredicate { + _CirclePaintPredicate( + {this.x, + this.y, + this.radius, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) + : super( + #drawCircle, + 'a circle', + 3, + 2, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); + + final double? x; + final double? y; + final double? radius; + + @override + void verifyArguments(List arguments) { + super.verifyArguments(arguments); + final Offset pointArgument = arguments[0] as Offset; + if (x != null && y != null) { + final Offset point = Offset(x!, y!); + if (point != pointArgument) { + throw 'It called $methodName with a center coordinate, $pointArgument, which was not exactly the expected coordinate ($point).'; + } + } else { + if (x != null && pointArgument.dx != x) { + throw 'It called $methodName with a center coordinate, $pointArgument, whose x-coordinate not exactly the expected coordinate (${x!.toStringAsFixed(1)}).'; + } + if (y != null && pointArgument.dy != y) { + throw 'It called $methodName with a center coordinate, $pointArgument, whose y-coordinate not exactly the expected coordinate (${y!.toStringAsFixed(1)}).'; + } + } + final double radiusArgument = arguments[1] as double; + if (radius != null && radiusArgument != radius) { + throw 'It called $methodName with radius, ${radiusArgument.toStringAsFixed(1)}, which was not exactly the expected radius (${radius!.toStringAsFixed(1)}).'; + } + } + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + if (x != null && y != null) { + description.add('point ${Offset(x!, y!)}'); + } else { + if (x != null) { + description.add('x-coordinate ${x!.toStringAsFixed(1)}'); + } + if (y != null) { + description.add('y-coordinate ${y!.toStringAsFixed(1)}'); + } + } + if (radius != null) { + description.add('radius ${radius!.toStringAsFixed(1)}'); + } + } +} + +class _PathPaintPredicate extends _DrawCommandPaintPredicate { + _PathPaintPredicate( + {this.includes, + this.excludes, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) + : super( + #drawPath, + 'a path', + 2, + 1, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); + + final Iterable? includes; + final Iterable? excludes; + + @override + void verifyArguments(List arguments) { + super.verifyArguments(arguments); + final Path pathArgument = arguments[0] as Path; + if (includes != null) { + for (final Offset offset in includes!) { + if (!pathArgument.contains(offset)) { + throw 'It called $methodName with a path that unexpectedly did not contain $offset.'; + } + } + } + if (excludes != null) { + for (final Offset offset in excludes!) { + if (pathArgument.contains(offset)) { + throw 'It called $methodName with a path that unexpectedly contained $offset.'; + } + } + } + } + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + if (includes != null && excludes != null) { + description.add('that contains $includes and does not contain $excludes'); + } else if (includes != null) { + description.add('that contains $includes'); + } else if (excludes != null) { + description.add('that does not contain $excludes'); + } + } +} + +// TODO(ianh): add arguments to test the length, angle, that kind of thing +class _LinePaintPredicate extends _DrawCommandPaintPredicate { + _LinePaintPredicate( + {this.p1, + this.p2, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) + : super( + #drawLine, + 'a line', + 3, + 2, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); + + final Offset? p1; + final Offset? p2; + + @override + void verifyArguments(List arguments) { + super.verifyArguments(arguments); // Checks the 3rd argument, a Paint + if (arguments.length != 3) { + throw 'It called $methodName with ${arguments.length} arguments; expected 3.'; + } + final Offset p1Argument = arguments[0] as Offset; + final Offset p2Argument = arguments[1] as Offset; + if (p1 != null && p1Argument != p1) { + throw 'It called $methodName with p1 endpoint, $p1Argument, which was not exactly the expected endpoint ($p1).'; + } + if (p2 != null && p2Argument != p2) { + throw 'It called $methodName with p2 endpoint, $p2Argument, which was not exactly the expected endpoint ($p2).'; + } + } + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + if (p1 != null) { + description.add('end point p1: $p1'); + } + if (p2 != null) { + description.add('end point p2: $p2'); + } + } +} + +class _ArcPaintPredicate extends _DrawCommandPaintPredicate { + _ArcPaintPredicate( + {Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) + : super( + #drawArc, + 'an arc', + 5, + 4, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); +} + +class _ShadowPredicate extends _PaintPredicate { + _ShadowPredicate( + {this.includes, + this.excludes, + this.color, + this.elevation, + this.transparentOccluder}); + + final Iterable? includes; + final Iterable? excludes; + final Color? color; + final double? elevation; + final bool? transparentOccluder; + + static const Symbol symbol = #drawShadow; + String get methodName => _symbolName(symbol); + + @protected + void verifyArguments(List arguments) { + if (arguments.length != 4) { + throw 'It called $methodName with ${arguments.length} arguments; expected 4.'; + } + final Path pathArgument = arguments[0] as Path; + if (includes != null) { + for (final Offset offset in includes!) { + if (!pathArgument.contains(offset)) { + throw 'It called $methodName with a path that unexpectedly did not contain $offset.'; + } + } + } + if (excludes != null) { + for (final Offset offset in excludes!) { + if (pathArgument.contains(offset)) { + throw 'It called $methodName with a path that unexpectedly contained $offset.'; + } + } + } + final Color actualColor = arguments[1] as Color; + if (color != null && actualColor != color) { + throw 'It called $methodName with a color, $actualColor, which was not exactly the expected color ($color).'; + } + final double actualElevation = arguments[2] as double; + if (elevation != null && actualElevation != elevation) { + throw 'It called $methodName with an elevation, $actualElevation, which was not exactly the expected value ($elevation).'; + } + final bool actualTransparentOccluder = arguments[3] as bool; + if (transparentOccluder != null && + actualTransparentOccluder != transparentOccluder) { + throw 'It called $methodName with a transparentOccluder value, $actualTransparentOccluder, which was not exactly the expected value ($transparentOccluder).'; + } + } + + @override + void match(Iterator call) { + checkMethod(call, symbol); + verifyArguments(call.current.invocation.positionalArguments); + call.moveNext(); + } + + @protected + void debugFillDescription(List description) { + if (includes != null && excludes != null) { + description.add('that contains $includes and does not contain $excludes'); + } else if (includes != null) { + description.add('that contains $includes'); + } else if (excludes != null) { + description.add('that does not contain $excludes'); + } + if (color != null) { + description.add('$color'); + } + if (elevation != null) { + description.add('elevation: $elevation'); + } + if (transparentOccluder != null) { + description.add('transparentOccluder: $transparentOccluder'); + } + } + + @override + String toString() { + final List description = []; + debugFillDescription(description); + String result = methodName; + if (description.isNotEmpty) { + result += ' with ${description.join(", ")}'; + } + return result; + } +} + +class _DrawImagePaintPredicate extends _DrawCommandPaintPredicate { + _DrawImagePaintPredicate( + {this.image, + this.x, + this.y, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) + : super( + #drawImage, + 'an image', + 3, + 2, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); + + final ui.Image? image; + final double? x; + final double? y; + + @override + void verifyArguments(List arguments) { + super.verifyArguments(arguments); + final ui.Image imageArgument = arguments[0] as ui.Image; + if (image != null && !image!.isCloneOf(imageArgument)) { + throw 'It called $methodName with an image, $imageArgument, which was not exactly the expected image ($image).'; + } + final Offset pointArgument = arguments[0] as Offset; + if (x != null && y != null) { + final Offset point = Offset(x!, y!); + if (point != pointArgument) { + throw 'It called $methodName with an offset coordinate, $pointArgument, which was not exactly the expected coordinate ($point).'; + } + } else { + if (x != null && pointArgument.dx != x) { + throw 'It called $methodName with an offset coordinate, $pointArgument, whose x-coordinate not exactly the expected coordinate (${x!.toStringAsFixed(1)}).'; + } + if (y != null && pointArgument.dy != y) { + throw 'It called $methodName with an offset coordinate, $pointArgument, whose y-coordinate not exactly the expected coordinate (${y!.toStringAsFixed(1)}).'; + } + } + } + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + if (image != null) { + description.add('image $image'); + } + if (x != null && y != null) { + description.add('point ${Offset(x!, y!)}'); + } else { + if (x != null) { + description.add('x-coordinate ${x!.toStringAsFixed(1)}'); + } + if (y != null) { + description.add('y-coordinate ${y!.toStringAsFixed(1)}'); + } + } + } +} + +class _DrawImageRectPaintPredicate extends _DrawCommandPaintPredicate { + _DrawImageRectPaintPredicate( + {this.image, + this.source, + this.destination, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) + : super( + #drawImageRect, + 'an image', + 4, + 3, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); + + final ui.Image? image; + final Rect? source; + final Rect? destination; + + @override + void verifyArguments(List arguments) { + super.verifyArguments(arguments); + final ui.Image imageArgument = arguments[0] as ui.Image; + if (image != null && !image!.isCloneOf(imageArgument)) { + throw 'It called $methodName with an image, $imageArgument, which was not exactly the expected image ($image).'; + } + final Rect sourceArgument = arguments[1] as Rect; + if (source != null && sourceArgument != source) { + throw 'It called $methodName with a source rectangle, $sourceArgument, which was not exactly the expected rectangle ($source).'; + } + final Rect destinationArgument = arguments[2] as Rect; + if (destination != null && destinationArgument != destination) { + throw 'It called $methodName with a destination rectangle, $destinationArgument, which was not exactly the expected rectangle ($destination).'; + } + } + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + if (image != null) { + description.add('image $image'); + } + if (source != null) { + description.add('source $source'); + } + if (destination != null) { + description.add('destination $destination'); + } + } +} + +class _SomethingPaintPredicate extends _PaintPredicate { + _SomethingPaintPredicate(this.predicate); + + final PaintPatternPredicate predicate; + + @override + void match(Iterator call) { + RecordedInvocation currentCall; + bool testedAllCalls = false; + do { + if (testedAllCalls) { + throw 'It painted methods that the predicate passed to a "something" step, ' + 'in the paint pattern, none of which were considered correct.'; + } + currentCall = call.current; + if (!currentCall.invocation.isMethod) { + throw 'It called $currentCall, which was not a method, when the paint pattern expected a method call'; + } + testedAllCalls = !call.moveNext(); + } while (!_runPredicate(currentCall.invocation.memberName, + currentCall.invocation.positionalArguments)); + } + + bool _runPredicate(Symbol methodName, List arguments) { + try { + return predicate(methodName, arguments); + } on String catch (s) { + throw 'It painted something that the predicate passed to a "something" step ' + 'in the paint pattern considered incorrect:\n $s\n '; + } + } + + @override + String toString() => 'a "something" step'; +} + +class _EverythingPaintPredicate extends _PaintPredicate { + _EverythingPaintPredicate(this.predicate); + + final PaintPatternPredicate predicate; + + @override + void match(Iterator call) { + do { + final RecordedInvocation currentCall = call.current; + if (!currentCall.invocation.isMethod) { + throw 'It called $currentCall, which was not a method, when the paint pattern expected a method call'; + } + if (!_runPredicate(currentCall.invocation.memberName, + currentCall.invocation.positionalArguments)) { + throw 'It painted something that the predicate passed to an "everything" step ' + 'in the paint pattern considered incorrect.\n'; + } + } while (call.moveNext()); + } + + bool _runPredicate(Symbol methodName, List arguments) { + try { + return predicate(methodName, arguments); + } on String catch (s) { + throw 'It painted something that the predicate passed to an "everything" step ' + 'in the paint pattern considered incorrect:\n $s\n '; + } + } + + @override + String toString() => 'an "everything" step'; +} + +class _FunctionPaintPredicate extends _PaintPredicate { + _FunctionPaintPredicate(this.symbol, this.arguments); + + final Symbol symbol; + + final List arguments; + + @override + void match(Iterator call) { + checkMethod(call, symbol); + if (call.current.invocation.positionalArguments.length != + arguments.length) { + throw 'It called ${_symbolName(symbol)} with ${call.current.invocation.positionalArguments.length} arguments; expected ${arguments.length}.'; + } + for (int index = 0; index < arguments.length; index += 1) { + final dynamic actualArgument = + call.current.invocation.positionalArguments[index]; + final dynamic desiredArgument = arguments[index]; + + if (desiredArgument is Matcher) { + expect(actualArgument, desiredArgument); + } else if (desiredArgument != null && desiredArgument != actualArgument) { + throw 'It called ${_symbolName(symbol)} with argument $index having value ${_valueName(actualArgument)} when ${_valueName(desiredArgument)} was expected.'; + } + } + call.moveNext(); + } + + @override + String toString() { + final List adjectives = [ + for (int index = 0; index < arguments.length; index += 1) + arguments[index] != null ? _valueName(arguments[index]) : '...', + ]; + return '${_symbolName(symbol)}(${adjectives.join(", ")})'; + } +} + +class _SaveRestorePairPaintPredicate extends _PaintPredicate { + @override + void match(Iterator call) { + checkMethod(call, #save); + int depth = 1; + while (depth > 0) { + if (!call.moveNext()) { + throw 'It did not have a matching restore() for the save() that was found where $this was expected.'; + } + if (call.current.invocation.isMethod) { + if (call.current.invocation.memberName == #save) { + depth += 1; + } else if (call.current.invocation.memberName == #restore) { + depth -= 1; + } + } + } + call.moveNext(); + } + + @override + String toString() => 'a matching save/restore pair'; +} + +String _valueName(Object? value) { + if (value is double) { + return value.toStringAsFixed(1); + } + return value.toString(); +} + +// Workaround for https://github.com/dart-lang/sdk/issues/28372 +String _symbolName(Symbol symbol) { + // WARNING: Assumes a fixed format for Symbol.toString which is *not* + // guaranteed anywhere. + final String s = '$symbol'; + return s.substring(8, s.length - 2); +} diff --git a/test/recording_canvas.dart b/test/recording_canvas.dart new file mode 100644 index 00000000..d2bd2f33 --- /dev/null +++ b/test/recording_canvas.dart @@ -0,0 +1,252 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/rendering.dart'; + +/// An [Invocation] and the [stack] trace that led to it. +/// +/// Used by [TestRecordingCanvas] to trace canvas calls. +class RecordedInvocation { + /// Create a record for an invocation list. + const RecordedInvocation(this.invocation, {required this.stack}); + + /// The method that was called and its arguments. + /// + /// The arguments preserve identity, but not value. Thus, if two invocations + /// were made with the same [Paint] object, but with that object configured + /// differently each time, then they will both have the same object as their + /// argument, and inspecting that object will return the object's current + /// values (mostly likely those passed to the second call). + final Invocation invocation; + + /// The stack trace at the time of the method call. + final StackTrace stack; + + @override + String toString() => _describeInvocation(invocation); + + /// Converts [stack] to a string using the [FlutterError.defaultStackFilter] logic. + String stackToString({String indent = ''}) { + return indent + + FlutterError.defaultStackFilter( + stack.toString().trimRight().split('\n'), + ).join('\n$indent'); + } +} + +/// A [Canvas] for tests that records its method calls. +/// +/// This class can be used in conjunction with [TestRecordingPaintingContext] +/// to record the [Canvas] method calls made by a renderer. For example: +/// +/// ```dart +/// RenderBox box = tester.renderObject(find.text('ABC')); +/// TestRecordingCanvas canvas = TestRecordingCanvas(); +/// TestRecordingPaintingContext context = TestRecordingPaintingContext(canvas); +/// box.paint(context, Offset.zero); +/// // Now test the expected canvas.invocations. +/// ``` +/// +/// In some cases it may be useful to define a subclass that overrides the +/// [Canvas] methods the test is checking and squirrels away the parameters +/// that the test requires. +/// +/// For simple tests, consider using the [paints] matcher, which overlays a +/// pattern matching API over [TestRecordingCanvas]. +class TestRecordingCanvas implements Canvas { + /// All of the method calls on this canvas. + final List invocations = []; + + int _saveCount = 0; + + @override + int getSaveCount() => _saveCount; + + @override + void save() { + _saveCount += 1; + invocations + .add(RecordedInvocation(_MethodCall(#save), stack: StackTrace.current)); + } + + @override + void saveLayer(Rect? bounds, Paint paint) { + _saveCount += 1; + invocations.add(RecordedInvocation( + _MethodCall(#saveLayer, [bounds, paint]), + stack: StackTrace.current)); + } + + @override + void restore() { + _saveCount -= 1; + assert(_saveCount >= 0); + invocations.add( + RecordedInvocation(_MethodCall(#restore), stack: StackTrace.current)); + } + + @override + void noSuchMethod(Invocation invocation) { + invocations.add(RecordedInvocation(invocation, stack: StackTrace.current)); + } +} + +/// A [PaintingContext] for tests that use [TestRecordingCanvas]. +class TestRecordingPaintingContext extends ClipContext + implements PaintingContext { + /// Creates a [PaintingContext] for tests that use [TestRecordingCanvas]. + TestRecordingPaintingContext(this.canvas); + + @override + final Canvas canvas; + + @override + void paintChild(RenderObject child, Offset offset) { + child.paint(this, offset); + } + + @override + ClipRectLayer? pushClipRect( + bool needsCompositing, + Offset offset, + Rect clipRect, + PaintingContextCallback painter, { + Clip clipBehavior = Clip.hardEdge, + ClipRectLayer? oldLayer, + }) { + clipRectAndPaint(clipRect.shift(offset), clipBehavior, + clipRect.shift(offset), () => painter(this, offset)); + return null; + } + + @override + ClipRRectLayer? pushClipRRect( + bool needsCompositing, + Offset offset, + Rect bounds, + RRect clipRRect, + PaintingContextCallback painter, { + Clip clipBehavior = Clip.antiAlias, + ClipRRectLayer? oldLayer, + }) { + clipRRectAndPaint(clipRRect.shift(offset), clipBehavior, + bounds.shift(offset), () => painter(this, offset)); + return null; + } + + @override + ClipPathLayer? pushClipPath( + bool needsCompositing, + Offset offset, + Rect bounds, + Path clipPath, + PaintingContextCallback painter, { + Clip clipBehavior = Clip.antiAlias, + ClipPathLayer? oldLayer, + }) { + clipPathAndPaint(clipPath.shift(offset), clipBehavior, bounds.shift(offset), + () => painter(this, offset)); + return null; + } + + @override + TransformLayer? pushTransform( + bool needsCompositing, + Offset offset, + Matrix4 transform, + PaintingContextCallback painter, { + TransformLayer? oldLayer, + }) { + canvas.save(); + canvas.transform(transform.storage); + painter(this, offset); + canvas.restore(); + return null; + } + + @override + OpacityLayer pushOpacity( + Offset offset, + int alpha, + PaintingContextCallback painter, { + OpacityLayer? oldLayer, + }) { + canvas.saveLayer(null, Paint()); // TODO(ianh): Expose the alpha somewhere. + painter(this, offset); + canvas.restore(); + return OpacityLayer(); + } + + @override + void pushLayer( + Layer childLayer, + PaintingContextCallback painter, + Offset offset, { + Rect? childPaintBounds, + }) { + painter(this, offset); + } + + @override + void noSuchMethod(Invocation invocation) {} +} + +class _MethodCall implements Invocation { + _MethodCall(this._name, [this._arguments = const []]); + final Symbol _name; + final List _arguments; + @override + bool get isAccessor => false; + @override + bool get isGetter => false; + @override + bool get isMethod => true; + @override + bool get isSetter => false; + @override + Symbol get memberName => _name; + @override + Map get namedArguments => {}; + @override + List get positionalArguments => _arguments; + @override + List get typeArguments => const []; +} + +String _valueName(Object? value) { + if (value is double) { + return value.toStringAsFixed(1); + } + return value.toString(); +} + +// Workaround for https://github.com/dart-lang/sdk/issues/28372 +String _symbolName(Symbol symbol) { + // WARNING: Assumes a fixed format for Symbol.toString which is *not* + // guaranteed anywhere. + final String s = '$symbol'; + return s.substring(8, s.length - 2); +} + +// Workaround for https://github.com/dart-lang/sdk/issues/28373 +String _describeInvocation(Invocation call) { + final StringBuffer buffer = StringBuffer(); + buffer.write(_symbolName(call.memberName)); + if (call.isSetter) { + buffer.write(call.positionalArguments[0].toString()); + } else if (call.isMethod) { + buffer.write('('); + buffer.writeAll(call.positionalArguments.map(_valueName), ', '); + String separator = call.positionalArguments.isEmpty ? '' : ', '; + call.namedArguments.forEach((Symbol name, Object? value) { + buffer.write(separator); + buffer.write(_symbolName(name)); + buffer.write(': '); + buffer.write(_valueName(value)); + separator = ', '; + }); + buffer.write(')'); + } + return buffer.toString(); +}