diff --git a/doc/marketing_goldens/platform_adaptive_gallery/platform_adaptive_gallery_test.dart b/doc/marketing_goldens/platform_adaptive_gallery/platform_adaptive_gallery_test.dart index 839309b..b18607e 100644 --- a/doc/marketing_goldens/platform_adaptive_gallery/platform_adaptive_gallery_test.dart +++ b/doc/marketing_goldens/platform_adaptive_gallery/platform_adaptive_gallery_test.dart @@ -97,12 +97,12 @@ Widget _itemDecorator( ) { return Padding( padding: const EdgeInsets.all(24), - child: Column( + child: PixelSnapColumn( mainAxisSize: MainAxisSize.min, children: [ content, Divider(), - Row( + PixelSnapRow( mainAxisSize: MainAxisSize.min, spacing: 16, children: [ diff --git a/doc/marketing_goldens/shadcn_test_tools.dart b/doc/marketing_goldens/shadcn_test_tools.dart index 3b0226c..0970155 100644 --- a/doc/marketing_goldens/shadcn_test_tools.dart +++ b/doc/marketing_goldens/shadcn_test_tools.dart @@ -87,7 +87,7 @@ class ShadcnSingleShotSceneLayout implements SceneLayout { margin: const EdgeInsets.all(48), padding: const EdgeInsets.all(48), color: ShadBlueColorScheme.dark().background, - child: Column( + child: PixelSnapColumn( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, spacing: 24, @@ -148,7 +148,7 @@ class ShadcnGalleryLayout implements SceneLayout { ), child: Padding( padding: const EdgeInsets.all(48), - child: Column( + child: PixelSnapColumn( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, spacing: 24, @@ -187,7 +187,7 @@ class ShadcnGalleryLayout implements SceneLayout { child: Container( padding: const EdgeInsets.all(48), color: ShadBlueColorScheme.dark().background, - child: Column( + child: PixelSnapColumn( mainAxisSize: MainAxisSize.min, spacing: 24, children: [ @@ -251,7 +251,7 @@ Widget shadcnItemDecorator( ) { return ColoredBox( color: ShadBlueColorScheme.dark().background, - child: Column( + child: PixelSnapColumn( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/doc/website/bin/main.dart b/doc/website/bin/main.dart index 97f730e..b0512fb 100644 --- a/doc/website/bin/main.dart +++ b/doc/website/bin/main.dart @@ -8,6 +8,7 @@ Future main(List arguments) async { // Here, you can directly hook into the StaticShock pipeline. For example, // you can copy an "images" directory from the source set to build set: ..pick(DirectoryPicker.parse("images")) + ..pick(ExtensionPicker("png")) // All 3rd party behavior is added through plugins, even the behavior // shipped with Static Shock. ..plugin(const MarkdownPlugin()) diff --git a/doc/website/source/_data.yaml b/doc/website/source/_data.yaml index 4e42059..ef55212 100644 --- a/doc/website/source/_data.yaml +++ b/doc/website/source/_data.yaml @@ -53,6 +53,10 @@ navigation: tag: failure-scenes sortBy: navOrder + - title: Reduce Flakiness + tag: reduce-flakiness + sortBy: navOrder + - title: Flutter's Implementation tag: flutter-implementation sortBy: navOrder \ No newline at end of file diff --git a/doc/website/source/reduce-flakiness/_data.yaml b/doc/website/source/reduce-flakiness/_data.yaml new file mode 100644 index 0000000..0529c7f --- /dev/null +++ b/doc/website/source/reduce-flakiness/_data.yaml @@ -0,0 +1 @@ +tags: reduce-flakiness diff --git a/doc/website/source/reduce-flakiness/failure_centered-square.png b/doc/website/source/reduce-flakiness/failure_centered-square.png new file mode 100644 index 0000000..719dadb Binary files /dev/null and b/doc/website/source/reduce-flakiness/failure_centered-square.png differ diff --git a/doc/website/source/reduce-flakiness/position-and-size.md b/doc/website/source/reduce-flakiness/position-and-size.md new file mode 100644 index 0000000..6790d43 --- /dev/null +++ b/doc/website/source/reduce-flakiness/position-and-size.md @@ -0,0 +1,159 @@ +--- +title: Position and Size +description: How to avoid partial-pixel offsets and sizes in golden tests. +navOrder: 10 +--- +Partial pixel offsets and sizes guarantee that your golden tests will have flaky false +failures when running on different platforms, or even running on the same platform +through Docker containers. + +![Flaky Fractional Position & Size](/reduce-flakiness/failure_centered-square.png) + +Most of these situations are outside your control. Your UI is what it is, and that UI +probably positions some things at partial-pixel offsets, e.g. `(45.7, 203.83)`, and/or +partial-pixel sizes, e.g. `103.2 x 46.7`. + +That said, some steps can be taken to reduce the likelihood and severity of these +partial-pixel offsets and sizes. + +## Golden Test Device Pixel Ratio +It's important to understand the concept of device pixel ratios and how they relate to +widget tests and golden tests. + +### Background of Logical Pixels +Over time, screens have evolved to include higher and higher pixel densities. This posed +a problem for app layouts in earlier years, where developers hard-coded various dimensions, +which then looked comically tiny on newer device screens. + +To help port existing layouts to new screens with higher pixel densities, devices started +reporting dimensions in terms of ["points" (Apple)](https://developer.apple.com/library/archive/documentation/GraphicsAnimation/Conceptual/HighResolutionOSX/Explained/Explained.html) +and ["density-independent pixels (DIPs)" (Android)](https://developer.android.com/training/multiscreen/screendensities). +Both of these concepts are similar - they define dimensions in terms of physical distances, +rather than pixel count. This way, a screen layout that's made for a 400px x 800px +mobile device will work equally well on a screen with a size of 800px x 1,600px. + +Of course, Flutter honors measurements in terms of these physical distances rather than +pixel distances. In fact, every width, height, x, and y value that you define in Flutter +is actually a density-independent value, NOT a pixel value. Flutter calls these "logical pixels". +Flutter tracks and reports the number of "physical pixels" per "logical pixel" in a property +called [`devicePixelRatio`](https://api.flutter.dev/flutter/dart-ui/FlutterView/devicePixelRatio.html), +which is published as part of Flutter's `MediaQuery`. + +### Device Pixel Ratios in Golden Tests +With all of that background information, why do pixels, points, and DIPs matter for golden tests? They matter +because **Flutter attempts to simulate real `devicePixelRatio`s in widget tests** (including golden tests). +Speaking of which, another detail about widget tests you might know is that **every widget test, +by default, is configured as if its running on an Android device**. These two facts, combined, +means that every widget test, by default, simulates a `devicePixelRatio` greater than `1.0`. +Historically, tests have used a `devicePixelRatio` of `3.0`. This number is likely subject to +change based on the industry standard at any given time. + +Golden Scenes are rendered to physical pixels. Therefore, rendering Golden Scenes requires +mapping from Flutter's logical pixels to the bitmap's physical pixels. This mapping can change +whole-number pixel values into partial-pixel values depending on the `devicePixelRatio`. For +example, imagine a `devicePixelRatio` of `1.75`, and a logical pixel value of `30`. Where would +that `30` end up in the final bitmap? `30 * 1.75 = 52.5`. Thus, a whole value has become a +partial-value, and it will now undergo anti-aliasing effects when rendering to the bitmap. + +### What To Do About It +The answer is to change Flutter's test configuration to use a `devicePixelRatio` of `1.0` +instead of the default. + +```dart +testWidgets("my test", (tester) async { + // Change the configuration for this test, and reset it after. + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.reset()); + + // Now do your real test work... +}); +``` + +When you use `flutter_test_goldens` test runners, you don't need to worry about this. It's done +automatically, on your behalf. + +```dart +testGoldenScene("my golden test", (tester) async { + // Just worry about your test, we've already changed the devicePixelRatio to 1.0... +}); +``` + +So I guess what we're saying is...if you use `flutter_test_goldens` you don't need to +worry about anything you just read :) + +## Positioning and Sizing Widgets +You may not think about it, but it's very common for widgets to be sized or positioned +at partial pixel boundaries. + +For example: Imagine a `25x25px` square that's centered within a `100x100` area. The top/left +offset of the centered square would be `(87.5, 87.5)`. + +For example: Imagine a `Row` that's `100px` wide, with 3 squares (`25x25px`) spaced evenly across it. +Those squares would sit at x-values of `6.25px`, `37.6px`, and `68.75px`, respectively. + +You may not be able to control these details within your app UI, but there's one place +where it's very important to control these values - and that's within Golden Scene layouts. + +Typical Golden Scene layouts include rows, columns, grids, and any other layout strategy +that you choose to employ. A Golden Scene layout is responsible for placing every golden image +in the scene. These golden images are later extracted for comparison within tests. Therefore, +it's absolutely critical that a Golden Scene places golden images precisely on whole-pixel +boundaries. If a Golden Scene positions an individual golden image on a partial boundary, the +scene will interpolate the color of the pixels on the edge of the golden, which will change +at least one pixel of detail all around the perimeter of the image. This will cause the golden +to fail, even when run on the exact same machine that generated it. + +To help our team, and your team, to create expressive Golden Scenes without breaking golden +comparison, we've published variations of a few of your favorite widgets, which now snap +their children to whole-pixel locations and sizes. + + * `PixelSnapCenter`: Like `Center` but with offset and size snapping. + * `PixelSnapAlign`: Like `Align` but with offset and size snapping. + * `PixelSnapRow`: Like `Row` but with offset and size snapping. + * `PixelSnapColumn`: Like `Column` but with offset and size snapping. + * `PixelSnapFlex`: Like `Flex` but with offset and size snapping. + +Take the earlier example of a square centered in a larger area. We can fix that +situation with a `PixelSnapCenter`. + +First, the bad version. + +```dart +SizedBox( + width: 50, + height: 50, + child: Center( + child: Container( + width: 24.5, + height: 24.5, + color: Colors.red, + ), + ), +); +``` + +The bad version includes a square with a partial-pixel size of `24.5x24.5px`, and that +square is located at `(12.75, 12.75)`. + +Let's snap the offset and the size with `PixelSnapCenter`. + +```dart +SizedBox( + width: 50, + height: 50, + child: PixelSnapCenter( // <-- the change + child: Container( + width: 24.5, + height: 24.5, + color: Colors.red, + ), + ), +); +``` + +The version with `PixelSnapCenter` positions the square at `(12, 12)` and forces the +square to become `(24, 24)`. No more partial pixels. + +These snapping widgets MUST be used when building Golden Scenes, to ensure that new/updated +goldens are consistent with extracted goldens. However, if desired, these widgets can also +be used in your regular widget trees. diff --git a/doc/website/source/styles/docs_page_layout.scss b/doc/website/source/styles/docs_page_layout.scss index 78036ef..92d926a 100644 --- a/doc/website/source/styles/docs_page_layout.scss +++ b/doc/website/source/styles/docs_page_layout.scss @@ -258,7 +258,7 @@ main.page-content { } h2, h3, h4, h5, h6 { - margin-top: 1em; + margin-top: 2em; } p { @@ -280,6 +280,11 @@ main.page-content { margin-bottom: 1.5em; } + img { + display: block; + margin: auto; + } + pre code { margin-top: 1em; margin-bottom: 1em; diff --git a/golden_tester.Dockerfile b/golden_tester.Dockerfile index b61f350..10b3100 100644 --- a/golden_tester.Dockerfile +++ b/golden_tester.Dockerfile @@ -13,7 +13,7 @@ RUN apt install -y git curl unzip RUN cat /etc/lsb-release # Invalidate the cache when flutter pushes a new commit. -ADD https://api.github.com/repos/flutter/flutter/git/refs/heads/master ./flutter-latest-master +ADD https://api.github.com/repos/flutter/flutter/git/refs/heads/stable ./flutter-latest-stable RUN git clone https://github.com/flutter/flutter.git ${FLUTTER_HOME} diff --git a/lib/flutter_test_goldens.dart b/lib/flutter_test_goldens.dart index 04ff04f..786b49c 100644 --- a/lib/flutter_test_goldens.dart +++ b/lib/flutter_test_goldens.dart @@ -1,5 +1,6 @@ export 'src/flutter/flutter_camera.dart'; export 'src/flutter/flutter_golden_matcher.dart'; +export 'src/flutter/flutter_pixel_alignment.dart'; export 'src/flutter/flutter_test_extensions.dart'; export 'src/fonts/fonts.dart'; export 'src/fonts/icons.dart'; diff --git a/lib/src/flutter/flutter_pixel_alignment.dart b/lib/src/flutter/flutter_pixel_alignment.dart new file mode 100644 index 0000000..3da7c68 --- /dev/null +++ b/lib/src/flutter/flutter_pixel_alignment.dart @@ -0,0 +1,306 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// A widget that works like [Center], except the child's final offset is +/// forced to an integer value, e.g., `(50.4, 30.2)` -> `(50.0, 30.0)`, also +/// the size of the child can optionally be forced to an integer value, too. +/// +/// {@template integer_pixel_flakiness} +/// Integer pixel positioning is a strategy to reduce flakiness in golden +/// tests, though it still isn't perfect. +/// {@endtemplate} +class PixelSnapCenter extends SingleChildRenderObjectWidget { + const PixelSnapCenter({ + super.key, + this.snapSize = true, + super.child, + }); + + final bool snapSize; + + @override + RenderPositionedBoxAtPixel createRenderObject(BuildContext context) { + return RenderPositionedBoxAtPixel() // + ..alignment = Alignment.center + ..snapSize = snapSize; + } + + @override + void updateRenderObject(BuildContext context, RenderPositionedBoxAtPixel renderObject) { + renderObject.snapSize = snapSize; + } +} + +/// A widget that works like [Align], except the child's final offset is +/// forced to an integer value, e.g., `(50.4, 30.2)` -> `(50.0, 30.0)`. +/// +/// {@macro integer_pixel_flakiness} +class PixelSnapAlign extends SingleChildRenderObjectWidget { + const PixelSnapAlign({ + super.key, + required this.alignment, + this.snapSize = true, + super.child, + }); + + final Alignment alignment; + final bool snapSize; + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderPositionedBoxAtPixel() // + ..alignment = alignment + ..snapSize = snapSize; + } + + @override + void updateRenderObject(BuildContext context, RenderPositionedBoxAtPixel renderObject) { + renderObject // + ..alignment = alignment + ..snapSize = snapSize; + } +} + +/// A [RenderPositionedBox] subclass that ensures each child sits at a whole-pixel (x, y) offset +/// and (optionally) forces children to be sized at integer values. +/// +/// {@template render_pixel_snap_performance} +/// This render object works by running the standard layout and then making adjustments +/// after the fact. This isn't great for performance, but this render object is intended +/// for tests where such performance isn't critical. +/// {@endtemplate} +class RenderPositionedBoxAtPixel extends RenderPositionedBox { + bool _snapSize = true; + set snapSize(bool newValue) { + if (newValue == _snapSize) { + return; + } + + _snapSize = newValue; + markNeedsLayout(); + } + + @override + void performLayout() { + super.performLayout(); + + // Re-position (and maybe resize) child so that it sits on a whole-pixel. + final child = this.child; + if (child != null) { + final parentData = child.parentData as BoxParentData; + final offset = parentData.offset; + if (offset.dx != offset.dx.floorToDouble() || offset.dy != offset.dy.floorToDouble()) { + parentData.offset = Offset( + offset.dx.floorToDouble(), + offset.dy.floorToDouble(), + ); + } + + if (_snapSize && + (child.size.width != child.size.width.floorToDouble() || + child.size.height != child.size.height.floorToDouble())) { + // This child doesn't have an integer width/height - run layout again, + // forcing the nearest smaller size. + child.layout( + BoxConstraints.tightFor( + width: child.size.width.floorToDouble(), + height: child.size.height.floorToDouble(), + ), + ); + } + } + } +} + +/// A [Row] whose children are positioned at whole-pixel offsets, and whose +/// children are forced to layout at whole-pixel widths and heights. +/// +/// {@macro integer_pixel_flakiness} +class PixelSnapRow extends Row { + const PixelSnapRow({ + super.key, + super.mainAxisAlignment, + super.mainAxisSize, + super.crossAxisAlignment, + super.textDirection, + super.verticalDirection, + super.textBaseline, // NO DEFAULT: we don't know what the text's baseline should be + super.spacing, + super.children, + }); + + @override + RenderPixelSnapFlex createRenderObject(BuildContext context) { + return RenderPixelSnapFlex( + direction: direction, + mainAxisAlignment: mainAxisAlignment, + mainAxisSize: mainAxisSize, + crossAxisAlignment: crossAxisAlignment, + textDirection: getEffectiveTextDirection(context), + verticalDirection: verticalDirection, + textBaseline: textBaseline, + clipBehavior: clipBehavior, + spacing: spacing, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderPixelSnapFlex renderObject) { + renderObject + ..direction = direction + ..mainAxisAlignment = mainAxisAlignment + ..mainAxisSize = mainAxisSize + ..crossAxisAlignment = crossAxisAlignment + ..textDirection = getEffectiveTextDirection(context) + ..verticalDirection = verticalDirection + ..textBaseline = textBaseline + ..clipBehavior = clipBehavior + ..spacing = spacing; + } +} + +/// A [Column] whose children are positioned at whole-pixel offsets, and whose +/// children are forced to layout at whole-pixel widths and heights. +/// +/// {@macro integer_pixel_flakiness} +class PixelSnapColumn extends Column { + const PixelSnapColumn({ + super.key, + super.mainAxisAlignment, + super.mainAxisSize, + super.crossAxisAlignment, + super.textDirection, + super.verticalDirection, + super.textBaseline, + super.spacing, + super.children, + }); + + @override + RenderPixelSnapFlex createRenderObject(BuildContext context) { + return RenderPixelSnapFlex( + direction: direction, + mainAxisAlignment: mainAxisAlignment, + mainAxisSize: mainAxisSize, + crossAxisAlignment: crossAxisAlignment, + textDirection: getEffectiveTextDirection(context), + verticalDirection: verticalDirection, + textBaseline: textBaseline, + clipBehavior: clipBehavior, + spacing: spacing, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderPixelSnapFlex renderObject) { + renderObject + ..direction = direction + ..mainAxisAlignment = mainAxisAlignment + ..mainAxisSize = mainAxisSize + ..crossAxisAlignment = crossAxisAlignment + ..textDirection = getEffectiveTextDirection(context) + ..verticalDirection = verticalDirection + ..textBaseline = textBaseline + ..clipBehavior = clipBehavior + ..spacing = spacing; + } +} + +/// A [Flex] whose children are positioned at whole-pixel offsets, and whose +/// children are forced to layout at whole-pixel widths and heights. +/// +/// {@macro integer_pixel_flakiness} +class PixelSnapFlex extends Flex { + const PixelSnapFlex({ + super.key, + required super.direction, + super.mainAxisAlignment, + super.mainAxisSize, + super.crossAxisAlignment, + super.textDirection, + super.verticalDirection, + super.textBaseline, + super.spacing, + super.children, + }); + + @override + RenderPixelSnapFlex createRenderObject(BuildContext context) { + return RenderPixelSnapFlex( + direction: direction, + mainAxisAlignment: mainAxisAlignment, + mainAxisSize: mainAxisSize, + crossAxisAlignment: crossAxisAlignment, + textDirection: getEffectiveTextDirection(context), + verticalDirection: verticalDirection, + textBaseline: textBaseline, + clipBehavior: clipBehavior, + spacing: spacing, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderPixelSnapFlex renderObject) { + renderObject + ..direction = direction + ..mainAxisAlignment = mainAxisAlignment + ..mainAxisSize = mainAxisSize + ..crossAxisAlignment = crossAxisAlignment + ..textDirection = getEffectiveTextDirection(context) + ..verticalDirection = verticalDirection + ..textBaseline = textBaseline + ..clipBehavior = clipBehavior + ..spacing = spacing; + } +} + +/// A [RenderFlex] subclass that ensures each child sits at a whole-pixel (x, y) offset +/// and (optionally) forces children to be sized at integer values. +/// +/// {@macro render_pixel_snap_performance} +class RenderPixelSnapFlex extends RenderFlex { + RenderPixelSnapFlex({ + required super.direction, + super.mainAxisSize = MainAxisSize.max, + super.mainAxisAlignment = MainAxisAlignment.start, + super.crossAxisAlignment = CrossAxisAlignment.center, + super.textDirection, + super.verticalDirection = VerticalDirection.down, + super.textBaseline, + super.clipBehavior = Clip.none, + super.spacing = 0.0, + super.children, + }); + + @override + void performLayout() { + super.performLayout(); + + // Re-position children so that they sit on a whole-pixel. + final children = getChildrenAsList(); + for (final child in children) { + final parentData = child.parentData as BoxParentData; + final offset = parentData.offset; + if (offset.dx != offset.dx.floorToDouble() || offset.dy != offset.dy.floorToDouble()) { + // This child doesn't have an integer x/y offset - change the offset to + // be the nearest lesser integer offset. + parentData.offset = Offset( + offset.dx.floorToDouble(), + offset.dy.floorToDouble(), + ); + } + + if (child.size.width != child.size.width.floorToDouble() || + child.size.height != child.size.height.floorToDouble()) { + // This child doesn't have an integer width/height - run layout again, + // forcing the nearest smaller size. + child.layout( + BoxConstraints.tightFor( + width: child.size.width.floorToDouble(), + height: child.size.height.floorToDouble(), + ), + ); + } + } + } +} diff --git a/lib/src/flutter/flutter_test_extensions.dart b/lib/src/flutter/flutter_test_extensions.dart index fb2b56e..d62fe56 100644 --- a/lib/src/flutter/flutter_test_extensions.dart +++ b/lib/src/flutter/flutter_test_extensions.dart @@ -102,3 +102,9 @@ extension FlutterTestGoldens on WidgetTester { return Future.wait(futures); } } + +extension Snapshot on WidgetTester { + Future takePhoto(String name, {Finder? finder}) async { + expectLater(finder ?? find.byType(WidgetsApp), matchesGoldenFile(name)); + } +} diff --git a/lib/src/scenes/failure_scene.dart b/lib/src/scenes/failure_scene.dart index 41ec9fc..e5e2b07 100644 --- a/lib/src/scenes/failure_scene.dart +++ b/lib/src/scenes/failure_scene.dart @@ -543,19 +543,19 @@ Widget _itemDecorator( return Padding( padding: const EdgeInsets.all(24), child: IntrinsicWidth( - child: Column( + child: PixelSnapColumn( mainAxisSize: MainAxisSize.min, spacing: 4, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( + PixelSnapRow( children: [ Expanded(child: Text('Golden')), Expanded(child: Text('Candidate')), ], ), content, - Row( + PixelSnapRow( children: [ Expanded(child: Text('Absolute Diff')), Expanded(child: Text('Relative Diff')), diff --git a/lib/src/scenes/golden_scene.dart b/lib/src/scenes/golden_scene.dart index 885a8f6..ca2010b 100644 --- a/lib/src/scenes/golden_scene.dart +++ b/lib/src/scenes/golden_scene.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' show Colors, MaterialApp, Scaffold, ThemeData; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_goldens/src/flutter/flutter_pixel_alignment.dart'; import 'package:flutter_test_goldens/src/fonts/fonts.dart'; import 'package:flutter_test_goldens/src/goldens/golden_collections.dart'; import 'package:flutter_test_goldens/src/goldens/golden_comparisons.dart'; @@ -213,22 +214,23 @@ Widget defaultGoldenSceneItemDecorator( return ColoredBox( // TODO: need this to be configurable, e.g., light vs dark color: Colors.white, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Align( - alignment: Alignment.centerLeft, - child: content, - ), - Padding( - padding: const EdgeInsets.all(24), - child: Text( - metadata.description, - style: TextStyle(fontFamily: TestFonts.openSans), + child: IntrinsicWidth( + child: PixelSnapColumn( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PixelSnapCenter( + child: content, + ), + Padding( + padding: const EdgeInsets.all(24), + child: Text( + metadata.description, + style: TextStyle(fontFamily: TestFonts.openSans), + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/src/scenes/layouts/animation_timeline_layout.dart b/lib/src/scenes/layouts/animation_timeline_layout.dart index 6b7c3e1..5c94843 100644 --- a/lib/src/scenes/layouts/animation_timeline_layout.dart +++ b/lib/src/scenes/layouts/animation_timeline_layout.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_goldens/src/flutter/flutter_pixel_alignment.dart'; import 'package:flutter_test_goldens/src/fonts/fonts.dart'; import 'package:flutter_test_goldens/src/goldens/golden_collections.dart'; import 'package:flutter_test_goldens/src/goldens/golden_rendering.dart'; @@ -129,7 +130,7 @@ class AnimationTimelineGoldenScene extends StatelessWidget { Widget _buildGoldens() { return Padding( padding: spacing.around, - child: Column( + child: PixelSnapColumn( mainAxisSize: MainAxisSize.min, children: [ Text( @@ -162,7 +163,7 @@ class AnimationTimelineGoldenScene extends StatelessWidget { Widget _buildRows() { final itemRows = _breakDownRows(); - return Column( + return PixelSnapColumn( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, spacing: spacing.between, @@ -241,17 +242,17 @@ class AnimationTimelineGoldenScene extends StatelessWidget { } Widget _buildRow(List> items) { - return Column( + return PixelSnapColumn( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( + PixelSnapRow( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.start, spacing: spacing.between, children: [ for (final entry in items) // - Column( + PixelSnapColumn( mainAxisSize: MainAxisSize.min, children: [ IntrinsicWidth( @@ -283,7 +284,7 @@ class AnimationTimelineGoldenScene extends StatelessWidget { ), Divider(height: 2, thickness: 2, color: _accentColor), const SizedBox(height: 16), - Row( + PixelSnapRow( children: [ Text( "Start >", @@ -320,7 +321,7 @@ Widget _itemDecorator( ) { return ColoredBox( color: const Color(0xff020817), - child: Column( + child: PixelSnapColumn( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/lib/src/scenes/layouts/grid_layout.dart b/lib/src/scenes/layouts/grid_layout.dart index 8d88cef..d16568c 100644 --- a/lib/src/scenes/layouts/grid_layout.dart +++ b/lib/src/scenes/layouts/grid_layout.dart @@ -78,13 +78,12 @@ class GridGoldenScene extends StatelessWidget { Widget _buildGoldens() { final entries = goldens.entries.toList(); - final rows = []; + final rows = []; for (int row = 0; row < goldens.length / 3; row += 1) { final items = []; for (int col = 0; col < 3; col += 1) { final index = row * 3 + col; if (index >= entries.length) { - items.add(const SizedBox()); continue; } @@ -92,7 +91,6 @@ class GridGoldenScene extends StatelessWidget { Padding( padding: EdgeInsets.only( top: row > 0 ? defaultGridSpacing.between : 0, - left: col > 0 ? defaultGridSpacing.between : 0, ), child: Builder(builder: (context) { return _decorator( @@ -111,14 +109,18 @@ class GridGoldenScene extends StatelessWidget { } rows.add( - TableRow( + PixelSnapRow( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + spacing: spacing.between, children: items, ), ); } - return Table( - defaultColumnWidth: IntrinsicColumnWidth(), + return PixelSnapColumn( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: rows, ); } diff --git a/lib/src/scenes/layouts/magazine_layout.dart b/lib/src/scenes/layouts/magazine_layout.dart index 6442e0a..cdc15a1 100644 --- a/lib/src/scenes/layouts/magazine_layout.dart +++ b/lib/src/scenes/layouts/magazine_layout.dart @@ -4,6 +4,7 @@ import 'dart:ui'; import 'package:flutter/material.dart' show Colors; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_goldens/src/flutter/flutter_pixel_alignment.dart'; import 'package:flutter_test_goldens/src/fonts/fonts.dart'; import 'package:flutter_test_goldens/src/goldens/golden_collections.dart'; import 'package:flutter_test_goldens/src/goldens/golden_rendering.dart'; @@ -101,7 +102,7 @@ class MagazineGoldenScene extends StatelessWidget { Widget build(BuildContext context) { return GoldenSceneBounds( child: IntrinsicHeight( - child: Row( + child: PixelSnapRow( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Stack( @@ -193,7 +194,7 @@ class MagazineGoldenScene extends StatelessWidget { IntrinsicWidth( child: Padding( padding: const EdgeInsets.all(12), - child: Column( + child: PixelSnapColumn( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/lib/src/scenes/layouts/row_and_column_layout.dart b/lib/src/scenes/layouts/row_and_column_layout.dart index 8332f75..d80a385 100644 --- a/lib/src/scenes/layouts/row_and_column_layout.dart +++ b/lib/src/scenes/layouts/row_and_column_layout.dart @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_goldens/src/flutter/flutter_pixel_alignment.dart'; import 'package:flutter_test_goldens/src/goldens/golden_collections.dart'; import 'package:flutter_test_goldens/src/goldens/golden_rendering.dart'; import 'package:flutter_test_goldens/src/scenes/golden_scene.dart'; @@ -118,7 +119,7 @@ class FlexGoldenScene extends StatelessWidget { Widget _buildGoldens() { return Padding( padding: spacing.around, - child: Flex( + child: PixelSnapFlex( direction: direction, mainAxisSize: MainAxisSize.min, spacing: spacing.between, diff --git a/lib/src/scenes/single_shot.dart b/lib/src/scenes/single_shot.dart index 0c51373..8c9972b 100644 --- a/lib/src/scenes/single_shot.dart +++ b/lib/src/scenes/single_shot.dart @@ -51,6 +51,15 @@ class SingleShotConfigurator { ); } + SingleShotConfigurator withConstraints(BoxConstraints constraints) { + _ensureStepNotComplete("constraints"); + + return SingleShotConfigurator( + _config.copyWith(constraints: constraints), + {..._stepsCompleted, "constraints"}, + ); + } + SingleShotConfigurator withSetup(GoldenSetup setup) { _ensureStepNotComplete("setup"); diff --git a/test/flutter/pixel_snapping_test.dart b/test/flutter/pixel_snapping_test.dart new file mode 100644 index 0000000..3a0843e --- /dev/null +++ b/test/flutter/pixel_snapping_test.dart @@ -0,0 +1,298 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_goldens/flutter_test_goldens.dart'; + +void main() { + group("Pixel snapping >", () { + testWidgets("PixelSnapCenter", (tester) async { + _configureWindow(tester); + + final contentKey = GlobalKey(); + + // Show regular Center behavior. + await _pumpScaffold(tester, _CenteredSquareAtPartialPixel(contentKey)); + expect( + tester.getTopLeft(find.byKey(contentKey)), + Offset(25.35, 25.35), + ); + expect(tester.getSize(find.byKey(contentKey)), Size(49.3, 49.3)); + + // Show PixelSnapCenter behavior (no size snapping). + await _pumpScaffold(tester, _CenteredSquareAtPartialPixel(contentKey, snapOffset: true)); + + // Ensure a whole-pixel offset, but fractional size. + expect( + tester.getTopLeft(find.byKey(contentKey)), + Offset(25, 25), + ); + expect(tester.getSize(find.byKey(contentKey)), Size(49.3, 49.3)); + + // Show PixelSnapCenter behavior (with size snapping). + await _pumpScaffold(tester, _CenteredSquareAtPartialPixel(contentKey, snapOffset: true, snapSize: true)); + + // Ensure a whole-pixel offset, AND whole-pixel size. + expect( + tester.getTopLeft(find.byKey(contentKey)), + Offset(25, 25), + ); + expect(tester.getSize(find.byKey(contentKey)), Size(49, 49)); + }); + + testWidgets("PixelSnapAlign", (tester) async { + _configureWindow(tester); + + final contentKey = GlobalKey(); + + // Show regular Align behavior. + await _pumpScaffold(tester, _AlignedSquareAtPartialPixel(contentKey)); + expect( + tester.getTopLeft(find.byKey(contentKey)), + Offset(13.435500000000001, 13.435500000000001), + ); + expect(tester.getSize(find.byKey(contentKey)), Size(49.3, 49.3)); + + // Show PixelSnapCenter behavior (no size snapping). + await _pumpScaffold(tester, _AlignedSquareAtPartialPixel(contentKey, snapOffset: true)); + + // Ensure a whole-pixel offset. + expect( + tester.getTopLeft(find.byKey(contentKey)), + Offset(13, 13), + ); + expect(tester.getSize(find.byKey(contentKey)), Size(49.3, 49.3)); + + // Show PixelSnapCenter behavior (with size snapping). + await _pumpScaffold(tester, _AlignedSquareAtPartialPixel(contentKey, snapOffset: true, snapSize: true)); + + // Ensure a whole-pixel offset, AND size snapping. + expect( + tester.getTopLeft(find.byKey(contentKey)), + Offset(13, 13), + ); + expect(tester.getSize(find.byKey(contentKey)), Size(49, 49)); + }); + + testWidgets("PixelSnapRow", (tester) async { + _configureWindow(tester); + + final item1Key = GlobalKey(); + final item2Key = GlobalKey(); + final item3Key = GlobalKey(); + + // Show regular Align behavior. + await _pumpScaffold( + tester, + _RowWithPartialPixels(item1Key: item1Key, item2Key: item2Key, item3Key: item3Key), + ); + expect(tester.getTopLeft(find.byKey(item1Key)), Offset(7.524999999999999, 38.35)); + expect(tester.getSize(find.byKey(item1Key)), Size(23.3, 23.3)); + expect(tester.getTopLeft(find.byKey(item2Key)), Offset(38.349999999999994, 38.35)); + expect(tester.getSize(find.byKey(item2Key)), Size(23.3, 23.3)); + expect(tester.getTopLeft(find.byKey(item3Key)), Offset(69.175, 38.35)); + expect(tester.getSize(find.byKey(item3Key)), Size(23.3, 23.3)); + + // Show PixelSnapAlign behavior. + await _pumpScaffold( + tester, + _RowWithPartialPixels(snap: true, item1Key: item1Key, item2Key: item2Key, item3Key: item3Key), + ); + + // Ensure a whole-pixel offset. + expect(tester.getTopLeft(find.byKey(item1Key)), Offset(7, 38)); + expect(tester.getSize(find.byKey(item1Key)), Size(23, 23)); + expect(tester.getTopLeft(find.byKey(item2Key)), Offset(38, 38)); + expect(tester.getSize(find.byKey(item2Key)), Size(23, 23)); + expect(tester.getTopLeft(find.byKey(item3Key)), Offset(69, 38)); + expect(tester.getSize(find.byKey(item3Key)), Size(23, 23)); + }); + + testWidgets("PixelSnapColumn", (tester) async { + _configureWindow(tester); + + final item1Key = GlobalKey(); + final item2Key = GlobalKey(); + final item3Key = GlobalKey(); + + // Show regular Align behavior. + await _pumpScaffold( + tester, + _ColumnWithPartialPixels(item1Key: item1Key, item2Key: item2Key, item3Key: item3Key), + ); + expect(tester.getTopLeft(find.byKey(item1Key)), Offset(38.35, 7.524999999999999)); + expect(tester.getSize(find.byKey(item1Key)), Size(23.3, 23.3)); + expect(tester.getTopLeft(find.byKey(item2Key)), Offset(38.35, 38.349999999999994)); + expect(tester.getSize(find.byKey(item2Key)), Size(23.3, 23.3)); + expect(tester.getTopLeft(find.byKey(item3Key)), Offset(38.35, 69.175)); + expect(tester.getSize(find.byKey(item3Key)), Size(23.3, 23.3)); + + // Show PixelSnapAlign behavior. + await _pumpScaffold( + tester, + _ColumnWithPartialPixels(snap: true, item1Key: item1Key, item2Key: item2Key, item3Key: item3Key), + ); + + // Ensure a whole-pixel offset. + expect(tester.getTopLeft(find.byKey(item1Key)), Offset(38, 7)); + expect(tester.getSize(find.byKey(item1Key)), Size(23, 23)); + expect(tester.getTopLeft(find.byKey(item2Key)), Offset(38, 38)); + expect(tester.getSize(find.byKey(item2Key)), Size(23, 23)); + expect(tester.getTopLeft(find.byKey(item3Key)), Offset(38, 69)); + expect(tester.getSize(find.byKey(item3Key)), Size(23, 23)); + }); + }); +} + +Future _pumpScaffold(WidgetTester tester, Widget content) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: content, + ), + debugShowCheckedModeBanner: false, + ), + ); +} + +void _configureWindow(WidgetTester tester) { + tester.view + ..devicePixelRatio = 1 + ..physicalSize = Size(100, 100); +} + +class _CenteredSquareAtPartialPixel extends StatelessWidget { + const _CenteredSquareAtPartialPixel(this.contentKey, {this.snapOffset = false, this.snapSize = false}); + + final Key contentKey; + final bool snapOffset; + final bool snapSize; + + @override + Widget build(BuildContext context) { + final square = Container( + key: contentKey, + width: 49.3, + height: 49.3, + color: Colors.red, + ); + + return snapOffset // + ? PixelSnapCenter( + snapSize: snapSize, + child: square, + ) + : Center(child: square); + } +} + +class _AlignedSquareAtPartialPixel extends StatelessWidget { + const _AlignedSquareAtPartialPixel( + this.contentKey, { + this.snapOffset = false, + this.snapSize = false, + }); + + final Key contentKey; + final bool snapOffset; + final bool snapSize; + + @override + Widget build(BuildContext context) { + final square = Container( + key: contentKey, + width: 49.3, + height: 49.3, + color: Colors.red, + ); + + return snapOffset + ? PixelSnapAlign( + alignment: Alignment(-0.47, -0.47), + snapSize: snapSize, + child: square, + ) + : Align( + alignment: Alignment(-0.47, -0.47), + child: square, + ); + } +} + +class _RowWithPartialPixels extends StatelessWidget { + const _RowWithPartialPixels({ + this.snap = false, + required this.item1Key, + required this.item2Key, + required this.item3Key, + }); + + final bool snap; + final Key item1Key; + final Key item2Key; + final Key item3Key; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: double.infinity, + child: snap + ? PixelSnapRow( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.center, + children: _buildItems(), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.center, + children: _buildItems(), + ), + ); + } + + List _buildItems() { + return [ + Container(key: item1Key, width: 23.3, height: 23.3, color: Colors.red), + Container(key: item2Key, width: 23.3, height: 23.3, color: Colors.red), + Container(key: item3Key, width: 23.3, height: 23.3, color: Colors.red), + ]; + } +} + +class _ColumnWithPartialPixels extends StatelessWidget { + const _ColumnWithPartialPixels({ + this.snap = false, + required this.item1Key, + required this.item2Key, + required this.item3Key, + }); + + final bool snap; + final Key item1Key; + final Key item2Key; + final Key item3Key; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: snap + ? PixelSnapColumn( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.center, + children: _buildItems(), + ) + : Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.center, + children: _buildItems(), + ), + ); + } + + List _buildItems() { + return [ + Container(key: item1Key, width: 23.3, height: 23.3, color: Colors.red), + Container(key: item2Key, width: 23.3, height: 23.3, color: Colors.red), + Container(key: item3Key, width: 23.3, height: 23.3, color: Colors.red), + ]; + } +} diff --git a/test_golden_references/intentional_failures/failing_test.dart b/test_golden_references/intentional_failures/failing_test.dart index 47fa4d6..2fa6f9f 100644 --- a/test_golden_references/intentional_failures/failing_test.dart +++ b/test_golden_references/intentional_failures/failing_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_goldens/src/flutter/flutter_pixel_alignment.dart'; void main() { testWidgets("failing test", (tester) async { @@ -22,7 +23,7 @@ void main() { // ignore: unused_element Widget _buildVersion1() { return Center( - child: Column( + child: PixelSnapColumn( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( @@ -52,7 +53,7 @@ Widget _buildVersion1() { Widget _buildVersion2() { return Center( - child: Column( + child: PixelSnapColumn( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( diff --git a/test_goldens/scene_types/gallery/gallery_grid_layout.png b/test_goldens/scene_types/gallery/gallery_grid_layout.png new file mode 100644 index 0000000..0bb40ac Binary files /dev/null and b/test_goldens/scene_types/gallery/gallery_grid_layout.png differ diff --git a/test_goldens/scene_types/gallery/gallery_grid_test.dart b/test_goldens/scene_types/gallery/gallery_grid_test.dart new file mode 100644 index 0000000..0b64fb0 --- /dev/null +++ b/test_goldens/scene_types/gallery/gallery_grid_test.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_test_goldens/flutter_test_goldens.dart'; + +void main() { + group("Scene types > gallery >", () { + testGoldenScene( + "grid", + (tester) async { + await Gallery( + "Grid Layout", + directory: Directory("."), + fileName: "gallery_grid_layout", + layout: GridGoldenSceneLayout(), + ) + .itemFromWidget( + description: "Red", + widget: _buildItem(Colors.red), + ) + .itemFromWidget( + description: "Orange", + widget: _buildItem(Colors.orange), + ) + .itemFromWidget( + description: "Yellow", + widget: _buildItem(Colors.yellow), + ) + .itemFromWidget( + description: "Green", + widget: _buildItem(Colors.green), + ) + .itemFromWidget( + description: "Blue", + widget: _buildItem(Colors.blue), + ) + .itemFromWidget( + description: "Indigo", + widget: _buildItem(Colors.indigo), + ) + .itemFromWidget( + description: "Violet", + widget: _buildItem(Colors.purple), + ) + .run(tester); + }, + ); + }); +} + +Widget _buildItem(Color color) { + return SizedBox( + width: 100, + height: 100, + child: PixelSnapCenter( + child: Container( + width: 50, + height: 50, + color: color, + ), + ), + ); +} diff --git a/test_goldens/theming/scoped_theme_test.dart b/test_goldens/theming/scoped_theme_test.dart index dd80c26..f4806c6 100644 --- a/test_goldens/theming/scoped_theme_test.dart +++ b/test_goldens/theming/scoped_theme_test.dart @@ -60,7 +60,7 @@ Widget yellowItemScaffold(WidgetTester tester, Widget content) { } Widget yellowItemDecorator(BuildContext context, GoldenScreenshotMetadata metadata, Widget content) { - return Column( + return PixelSnapColumn( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( @@ -105,7 +105,7 @@ Widget redItemScaffold(WidgetTester tester, Widget content) { } Widget redItemDecorator(BuildContext context, GoldenScreenshotMetadata metadata, Widget content) { - return Column( + return PixelSnapColumn( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( diff --git a/test_goldens_known_failure_cases/failures/failure_text_layout_with_partial_pixel_1.png b/test_goldens_known_failure_cases/failures/failure_text_layout_with_partial_pixel_1.png deleted file mode 100644 index 9efc618..0000000 Binary files a/test_goldens_known_failure_cases/failures/failure_text_layout_with_partial_pixel_1.png and /dev/null differ diff --git a/test_goldens_known_failure_cases/known_failure_cases.dart b/test_goldens_known_failure_cases/known_failure_cases.dart index ad14fc3..2ef4df3 100644 --- a/test_goldens_known_failure_cases/known_failure_cases.dart +++ b/test_goldens_known_failure_cases/known_failure_cases.dart @@ -47,21 +47,23 @@ Widget _centeredItemDecorator( return ColoredBox( // TODO: need this to be configurable, e.g., light vs dark color: Colors.white, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: content, - ), - Padding( - padding: const EdgeInsets.all(24), - child: Text( - metadata.description, - style: TextStyle(fontFamily: TestFonts.openSans), + child: IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PixelSnapCenter( + child: content, ), - ), - ], + Padding( + padding: const EdgeInsets.all(24), + child: Text( + metadata.description, + style: TextStyle(fontFamily: TestFonts.openSans), + ), + ), + ], + ), ), ); }