From f6c4ddc9ef70cc182d35c2301d546d8ecfb04c76 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Sun, 22 Jun 2025 18:09:09 -0300 Subject: [PATCH 1/4] Implement failure scene --- lib/src/scenes/failure_scene.dart | 404 ++++++++++++++++++++++++++++++ lib/src/scenes/gallery.dart | 36 +-- 2 files changed, 416 insertions(+), 24 deletions(-) diff --git a/lib/src/scenes/failure_scene.dart b/lib/src/scenes/failure_scene.dart index c8ce071..30f9f7c 100644 --- a/lib/src/scenes/failure_scene.dart +++ b/lib/src/scenes/failure_scene.dart @@ -1,6 +1,11 @@ +import 'dart:async'; import 'dart:math'; +import 'dart:typed_data'; import 'dart:ui' as ui; +import 'package:flutter/material.dart' as material; +import 'package:flutter/widgets.dart' hide Image; +import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test_goldens/src/goldens/pixel_comparisons.dart'; import 'package:image/image.dart'; @@ -81,3 +86,402 @@ Future paintGoldenMismatchImages(GoldenMismatch mismatch) async { return failureImage; } + +/// Given a [report], generates that shows all the mismatches found in the report. +Future paintFailureScene(WidgetTester tester, GoldenSceneReport report) async { + final photos = []; + + for (final item in report.items) { + final mismatch = item.mismatch; + if (!(mismatch is WrongSizeGoldenMismatch || mismatch is PixelGoldenMismatch)) { + // Missing candidates and extra candidates are handled separately. + continue; + } + + final golden = mismatch!.golden!; + final candidate = mismatch.screenshot!; + final absoluteDiff = _generateAbsoluteDiff(golden, candidate, mismatch); + final relativeDiff = _generateRelativeDiff(golden, candidate, mismatch); + + final reportImage = _layoutGoldenFailure( + report: report, + golden: golden.image, + candidate: candidate.image, + absoluteDiff: absoluteDiff, + relativeDiff: relativeDiff, + ); + + String description = item.metadata.id; + if (mismatch is PixelGoldenMismatch) { + description += " (${mismatch.mismatchPixelCount.toInt()}px, ${(mismatch.percent * 100).toStringAsFixed(2)}%)"; + } else if (mismatch is WrongSizeGoldenMismatch) { + description += " (wrong size)"; + } + photos.add( + GoldenFailurePhoto( + description: description, + pixels: reportImage, + ), + ); + } + + for (final missingCandidate in report.missingCandidates) { + photos.add( + GoldenFailurePhoto( + description: "${missingCandidate.golden!.id} (missing candidate)", + pixels: missingCandidate.golden!.image, + ), + ); + } + + for (final extraCandidate in report.extraCandidates) { + photos.add( + GoldenFailurePhoto( + description: "${extraCandidate.screenshot!.id} (extra candidate)", + pixels: extraCandidate.screenshot!.image, + ), + ); + } + + return _layoutFailureScene(tester, photos); +} + +/// Generates a single image that shows all the golden failures. +Future _layoutFailureScene(WidgetTester tester, List images) async { + final renderablePhotos = {}; + for (final photo in images) { + final image = await _convertImagePackageToUiImage(photo.pixels); + final pixels = (await image.toByteData(format: ui.ImageByteFormat.png))!.buffer.asUint8List(); + renderablePhotos[photo] = (pixels, GlobalKey()); + } + + final sceneKey = GlobalKey(); + final scene = GoldenSceneBounds( + child: IntrinsicWidth( + child: IntrinsicHeight( + child: GoldenFailureScene( + key: sceneKey, + direction: Axis.horizontal, + renderablePhotos: renderablePhotos, + background: null, + ), + ), + ), + ); + await tester.pumpWidgetAndAdjustWindow(scene); + + for (final entry in renderablePhotos.entries) { + await precacheImage( + MemoryImage(entry.value.$1), + tester.element(find.byKey(entry.value.$2)), + ); + } + await tester.pumpAndSettle(); + + final uiImage = await captureImage(find.byKey(sceneKey).evaluate().single); + final bytes = await uiImage.toByteData(format: ui.ImageByteFormat.rawRgba); + final result = Image.fromBytes( + width: uiImage.width, + height: uiImage.height, + bytes: bytes!.buffer, + order: ChannelOrder.rgba, + ); + return result; +} + +/// Generates a single image that shows the golden, the candidate, and the +/// absolute and relative differences between them. +Image _layoutGoldenFailure({ + required GoldenSceneReport report, + required Image golden, + required Image candidate, + required Image absoluteDiff, + required Image relativeDiff, +}) { + final image = Image( + width: golden.width + candidate.width, + height: golden.height + candidate.height, + ); + + // Copy golden to top left corner. + _drawImage( + source: golden, + destination: image, + x: 0, + y: 0, + ); + + // Copy screenshot to top right corner. + _drawImage( + source: candidate, + destination: image, + x: golden.width, + y: 0, + ); + + // Copy absolute diff to bottom left corner. + _drawImage( + source: absoluteDiff, + destination: image, + x: 0, + y: golden.height, + ); + + // Copy relative diff to bottom right corner. + _drawImage( + source: relativeDiff, + destination: image, + x: golden.width, + y: golden.height, + ); + + return image; +} + +/// Generates an image that shows the absolute differences between the golden +/// and the candidate images. +Image _generateAbsoluteDiff( + GoldenImage golden, + GoldenImage candidate, + GoldenMismatch mismatch, +) { + final maxWidth = max(golden.image.width, candidate.image.width); + final maxHeight = max(golden.image.height, candidate.image.height); + + final failureImage = Image(width: maxWidth, height: maxHeight); + _paintAbsoluteDiff( + destination: failureImage, + originX: 0, + originY: 0, + golden: golden, + candidate: candidate, + ); + + return failureImage; +} + +/// Paints the absolute differences between the golden and candidate images +/// into the [destination] image at the specified [originX] and [originY]. +void _paintAbsoluteDiff({ + required Image destination, + required int originX, + required int originY, + required GoldenImage golden, + required GoldenImage candidate, +}) { + final maxWidth = max(golden.image.width, candidate.image.width); + final maxHeight = max(golden.image.height, candidate.image.height); + + // Paint mismatch images. + final absoluteDiffColor = ColorUint32.rgb(255, 255, 0); + for (int x = 0; x < maxWidth; x += 1) { + for (int y = 0; y < maxHeight; y += 1) { + if (x >= golden.image.width || + x >= candidate.image.width || + y >= golden.image.height || + y >= candidate.image.height) { + // This pixel doesn't exist in the golden, or it doesn't exist in the + // screenshot. Therefore, we have nothing to compare. Treat this pixel + // as a max severity difference. + + // Paint this pixel in the absolute diff image. + destination.setPixel(originX + x, originY + y, absoluteDiffColor); + + continue; + } + + // Check if the screenshot matches the golden. + final goldenPixel = golden.image.getPixel(x, y); + final screenshotPixel = candidate.image.getPixel(x, y); + final pixelsMatch = goldenPixel == screenshotPixel; + if (pixelsMatch) { + continue; + } + + // Paint this pixel in the absolute diff image. + destination.setPixel(originX + x, originY + y, absoluteDiffColor); + } + } +} + +/// Generates an image that shows the relative differences between the golden +/// and the candidate images. +Image _generateRelativeDiff( + GoldenImage golden, + GoldenImage candidate, + GoldenMismatch mismatch, +) { + final maxWidth = max(golden.image.width, candidate.image.width); + final maxHeight = max(golden.image.height, candidate.image.height); + + final failureImage = Image(width: maxWidth, height: maxHeight); + _paintRelativeDiff( + destination: failureImage, + originX: 0, + originY: 0, + golden: golden, + candidate: candidate, + ); + + return failureImage; +} + +/// Paints the relative differences between the golden and candidate images +/// into the [destination] image at the specified [originX] and [originY]. +void _paintRelativeDiff({ + required Image destination, + required int originX, + required int originY, + required GoldenImage golden, + required GoldenImage candidate, +}) { + final maxWidth = max(golden.image.width, candidate.image.width); + final maxHeight = max(golden.image.height, candidate.image.height); + + // Paint mismatch images. + final absoluteDiffColor = ColorUint32.rgb(255, 255, 0); + for (int x = 0; x < maxWidth; x += 1) { + for (int y = 0; y < maxHeight; y += 1) { + if (x >= golden.image.width || + x >= candidate.image.width || + y >= golden.image.height || + y >= candidate.image.height) { + // This pixel doesn't exist in the golden, or it doesn't exist in the + // screenshot. Therefore, we have nothing to compare. Treat this pixel + // as a max severity difference. + destination.setPixel(originX + x, originY + y, absoluteDiffColor); + + continue; + } + + // Check if the screenshot matches the golden. + final goldenPixel = golden.image.getPixel(x, y); + final screenshotPixel = candidate.image.getPixel(x, y); + final pixelsMatch = goldenPixel == screenshotPixel; + if (pixelsMatch) { + continue; + } + + final mismatchPercent = calculateColorMismatchPercent(goldenPixel, screenshotPixel); + final yellowAmount = ui.lerpDouble(0.2, 1.0, mismatchPercent)!; + destination.setPixel( + originX + x, + originY + y, + ColorUint32.rgb((255 * yellowAmount).round(), (255 * yellowAmount).round(), 0), + ); + } + } +} + +/// Draws the [source] image onto the [destination] image at the specified +/// [x] and [y] coordinates. +void _drawImage({ + required Image source, + required Image destination, + required int x, + required int y, +}) { + for (int i = 0; i < source.width; i += 1) { + for (int j = 0; j < source.height; j += 1) { + final pixel = source.getPixel(i, j); + destination.setPixel(x + i, y + j, pixel); + } + } +} + +/// Converts an [Image] from the image package to a [ui.Image]. +Future _convertImagePackageToUiImage(Image image) async { + final pixels = image.getBytes(order: ChannelOrder.rgba); + + final completer = Completer(); + ui.decodeImageFromPixels( + pixels, + image.width, + image.height, + ui.PixelFormat.rgba8888, + (ui.Image img) => completer.complete(img), + ); + return completer.future; +} + +class GoldenFailureScene extends StatelessWidget { + const GoldenFailureScene({ + super.key, + required this.direction, + required this.renderablePhotos, + this.background, + }); + + final Axis direction; + final Map renderablePhotos; + final Widget? background; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: const material.Color(0xFF666666), + child: Stack( + children: [ + if (background != null) // + Positioned.fill( + child: ColoredBox(color: material.Colors.green), + ), + if (background != null) // + Positioned.fill( + child: background!, + ), + Padding( + padding: const EdgeInsets.all(48), + child: Flex( + direction: direction, + mainAxisSize: MainAxisSize.min, + spacing: 48, + children: [ + for (final entry in renderablePhotos.entries) // + SizedBox( + width: entry.key.pixels.width.toDouble(), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ColoredBox( + color: material.Colors.white, + child: material.Image.memory( + key: entry.value.$2, + entry.value.$1, + width: entry.key.pixels.width.toDouble(), + height: entry.key.pixels.height.toDouble(), + ), + ), + Container( + color: material.Colors.white, + padding: const EdgeInsets.all(16), + child: Text( + entry.key.description, + style: TextStyle( + color: material.Colors.black, + fontFamily: "packages/flutter_test_goldens/OpenSans", + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class GoldenFailurePhoto { + const GoldenFailurePhoto({ + required this.description, + required this.pixels, + }); + + final String description; + final Image pixels; +} diff --git a/lib/src/scenes/gallery.dart b/lib/src/scenes/gallery.dart index 16e8dda..36ef732 100644 --- a/lib/src/scenes/gallery.dart +++ b/lib/src/scenes/gallery.dart @@ -611,36 +611,24 @@ Image.memory( FtgLog.pipeline.info("No golden mismatches found"); } - for (final mismatch in mismatches.mismatches.values) { - if (mismatch.golden == null || mismatch.screenshot == null) { - continue; - } - - FtgLog.pipeline.fine("Painting a golden failure: $mismatch"); - Directory(_goldenFailureDirectoryPath).createSync(); - - await tester.runAsync(() async { - final failureImage = await paintGoldenMismatchImages(mismatch); - - try { - await encodePngFile( - "$_goldenFailureDirectoryPath/failure_${existingGoldenFileName}_${mismatch.golden!.id}.png", - failureImage, - ); - } catch (exception) { - throw Exception( - "Goldens failed with ${mismatches.mismatches.length} mismatch(es), BUT we were unable to paint the mismatches to a failure file. Originating exception: $exception", - ); - } - }); - } - final report = GoldenSceneReport( metadata: metadata, items: items, missingCandidates: missingCandidates, extraCandidates: extraCandidates, ); + + Directory(_goldenFailureDirectoryPath).createSync(); + + await tester.runAsync(() async { + final failureImage = await paintFailureScene(_tester, report); + + await encodePngFile( + "$_goldenFailureDirectoryPath/failure_$existingGoldenFileName.png", + failureImage, + ); + }); + _printReport(report); if (mismatches.mismatches.isNotEmpty) { From 9b96084aea377ec3fa53520d30fbdcb60a11e698 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Mon, 23 Jun 2025 19:42:04 -0300 Subject: [PATCH 2/4] Add failure metadata --- lib/src/scenes/failure_scene.dart | 110 +++++++++++++++++++++++++++--- lib/src/scenes/gallery.dart | 12 ++-- 2 files changed, 107 insertions(+), 15 deletions(-) diff --git a/lib/src/scenes/failure_scene.dart b/lib/src/scenes/failure_scene.dart index 30f9f7c..9912213 100644 --- a/lib/src/scenes/failure_scene.dart +++ b/lib/src/scenes/failure_scene.dart @@ -88,7 +88,7 @@ Future paintGoldenMismatchImages(GoldenMismatch mismatch) async { } /// Given a [report], generates that shows all the mismatches found in the report. -Future paintFailureScene(WidgetTester tester, GoldenSceneReport report) async { +Future<(Image, FailureSceneMetadata)> paintFailureScene(WidgetTester tester, GoldenSceneReport report) async { final photos = []; for (final item in report.items) { @@ -143,11 +143,12 @@ Future paintFailureScene(WidgetTester tester, GoldenSceneReport report) a ); } - return _layoutFailureScene(tester, photos); + return _layoutFailureScene(tester, report, photos); } /// Generates a single image that shows all the golden failures. -Future _layoutFailureScene(WidgetTester tester, List images) async { +Future<(Image, FailureSceneMetadata)> _layoutFailureScene( + WidgetTester tester, GoldenSceneReport report, List images) async { final renderablePhotos = {}; for (final photo in images) { final image = await _convertImagePackageToUiImage(photo.pixels); @@ -161,7 +162,7 @@ Future _layoutFailureScene(WidgetTester tester, List child: IntrinsicHeight( child: GoldenFailureScene( key: sceneKey, - direction: Axis.horizontal, + direction: Axis.vertical, renderablePhotos: renderablePhotos, background: null, ), @@ -180,13 +181,29 @@ Future _layoutFailureScene(WidgetTester tester, List final uiImage = await captureImage(find.byKey(sceneKey).evaluate().single); final bytes = await uiImage.toByteData(format: ui.ImageByteFormat.rawRgba); - final result = Image.fromBytes( + final failureImage = Image.fromBytes( width: uiImage.width, height: uiImage.height, bytes: bytes!.buffer, order: ChannelOrder.rgba, ); - return result; + + // Lookup and return metadata for the position and size of each failure image + // within the scene. + final metadata = FailureSceneMetadata( + description: report.metadata.description, + images: [ + for (final golden in renderablePhotos.keys) + FailureImageMetadata( + id: golden.description, + topLeft: + (renderablePhotos[golden]!.$2.currentContext!.findRenderObject() as RenderBox).localToGlobal(Offset.zero), + size: renderablePhotos[golden]!.$2.currentContext!.size!, + ), + ], + ); + + return (failureImage, metadata); } /// Generates a single image that shows the golden, the candidate, and the @@ -456,11 +473,15 @@ class GoldenFailureScene extends StatelessWidget { Container( color: material.Colors.white, padding: const EdgeInsets.all(16), - child: Text( - entry.key.description, - style: TextStyle( - color: material.Colors.black, - fontFamily: "packages/flutter_test_goldens/OpenSans", + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + entry.key.description, + softWrap: false, + style: TextStyle( + color: material.Colors.black, + fontFamily: "packages/flutter_test_goldens/OpenSans", + ), ), ), ), @@ -485,3 +506,70 @@ class GoldenFailurePhoto { final String description; final Image pixels; } + +class FailureSceneMetadata { + static FailureSceneMetadata fromJson(Map json) { + return FailureSceneMetadata( + description: json["description"] ?? "", + images: [ + for (final photoJson in (json["images"] as List)) // + FailureImageMetadata.fromJson(photoJson), + ], + ); + } + + const FailureSceneMetadata({ + required this.description, + required this.images, + }); + + final String description; + final List images; + + Map toJson() { + return { + "description": description, + "images": images.map((photo) => photo.toJson()).toList(), + }; + } +} + +class FailureImageMetadata { + static FailureImageMetadata fromJson(Map json) { + return FailureImageMetadata( + id: json["id"], + topLeft: ui.Offset( + (json["topLeft"]["x"] as num).toDouble(), + (json["topLeft"]["y"] as num).toDouble(), + ), + size: ui.Size( + (json["size"]["width"] as num).toDouble(), + (json["size"]["height"] as num).toDouble(), + ), + ); + } + + FailureImageMetadata({ + required this.id, + required this.topLeft, + required this.size, + }); + + final String id; + final ui.Offset topLeft; + final ui.Size size; + + Map toJson() { + return { + "id": id, + "topLeft": { + "x": topLeft.dx, + "y": topLeft.dy, + }, + "size": { + "width": size.width, + "height": size.height, + }, + }; + } +} diff --git a/lib/src/scenes/gallery.dart b/lib/src/scenes/gallery.dart index 36ef732..5d4469e 100644 --- a/lib/src/scenes/gallery.dart +++ b/lib/src/scenes/gallery.dart @@ -621,12 +621,16 @@ Image.memory( Directory(_goldenFailureDirectoryPath).createSync(); await tester.runAsync(() async { - final failureImage = await paintFailureScene(_tester, report); + final (failureImage, metadata) = await paintFailureScene(_tester, report); - await encodePngFile( - "$_goldenFailureDirectoryPath/failure_$existingGoldenFileName.png", - failureImage, + Uint8List pngData = encodePng(failureImage); + pngData = pngData.copyWithTextMetadata( + "flutter_test_goldens_failure", + const JsonEncoder().convert(metadata.toJson()), ); + + final file = File("$_goldenFailureDirectoryPath/failure_$existingGoldenFileName.png"); + file.writeAsBytesSync(pngData); }); _printReport(report); From 7f884076465bedd9c5b944e67f25d7b2d07dd0d7 Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Tue, 24 Jun 2025 20:30:20 -0300 Subject: [PATCH 3/4] Update failure rendering --- lib/src/goldens/golden_collections.dart | 10 ++ lib/src/scenes/failure_scene.dart | 197 +++++++++--------------- lib/src/scenes/gallery.dart | 2 +- 3 files changed, 88 insertions(+), 121 deletions(-) diff --git a/lib/src/goldens/golden_collections.dart b/lib/src/goldens/golden_collections.dart index 24abf22..b26c6db 100644 --- a/lib/src/goldens/golden_collections.dart +++ b/lib/src/goldens/golden_collections.dart @@ -68,4 +68,14 @@ class GoldenScreenshotMetadata { /// /// This is *NOT* the same thing as the platform used to run the golden test suite. final TargetPlatform simulatedPlatform; + + GoldenScreenshotMetadata copyWith({ + String? description, + TargetPlatform? simulatedPlatform, + }) { + return GoldenScreenshotMetadata( + description: description ?? this.description, + simulatedPlatform: simulatedPlatform ?? this.simulatedPlatform, + ); + } } diff --git a/lib/src/scenes/failure_scene.dart b/lib/src/scenes/failure_scene.dart index 9912213..e8d6b9f 100644 --- a/lib/src/scenes/failure_scene.dart +++ b/lib/src/scenes/failure_scene.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:math'; -import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter/material.dart' as material; @@ -88,8 +87,9 @@ Future paintGoldenMismatchImages(GoldenMismatch mismatch) async { } /// Given a [report], generates that shows all the mismatches found in the report. -Future<(Image, FailureSceneMetadata)> paintFailureScene(WidgetTester tester, GoldenSceneReport report) async { - final photos = []; +Future<(Image, FailureSceneMetadata)> paintFailureScene( + WidgetTester tester, GoldenSceneReport report, SceneLayout layout) async { + final photos = []; for (final item in report.items) { final mismatch = item.mismatch; @@ -110,62 +110,83 @@ Future<(Image, FailureSceneMetadata)> paintFailureScene(WidgetTester tester, Gol absoluteDiff: absoluteDiff, relativeDiff: relativeDiff, ); + final image = await _convertImagePackageToUiImage(reportImage); + final pixels = (await image.toByteData(format: ui.ImageByteFormat.png))!.buffer.asUint8List(); - String description = item.metadata.id; + String description = item.metadata.metadata.description; if (mismatch is PixelGoldenMismatch) { description += " (${mismatch.mismatchPixelCount.toInt()}px, ${(mismatch.percent * 100).toStringAsFixed(2)}%)"; } else if (mismatch is WrongSizeGoldenMismatch) { description += " (wrong size)"; } + photos.add( - GoldenFailurePhoto( - description: description, - pixels: reportImage, + GoldenSceneScreenshot( + item.metadata.id, + item.metadata.metadata.copyWith(description: description), + reportImage, + pixels, ), ); } for (final missingCandidate in report.missingCandidates) { + // TODO: Figure out why using missingCandidate.golden!.pngBytes causes an "Invalid image data" error. + final image = await _convertImagePackageToUiImage(missingCandidate.golden!.image); + final pixels = (await image.toByteData(format: ui.ImageByteFormat.png))!.buffer.asUint8List(); photos.add( - GoldenFailurePhoto( - description: "${missingCandidate.golden!.id} (missing candidate)", - pixels: missingCandidate.golden!.image, + GoldenSceneScreenshot( + missingCandidate.golden!.id, + missingCandidate.golden!.metadata.copyWith( + description: "${missingCandidate.golden!.metadata.description} (missing candidate)", + ), + missingCandidate.golden!.image, + pixels, ), ); } for (final extraCandidate in report.extraCandidates) { photos.add( - GoldenFailurePhoto( - description: "${extraCandidate.screenshot!.id} (extra candidate)", - pixels: extraCandidate.screenshot!.image, + GoldenSceneScreenshot( + extraCandidate.screenshot!.id, + extraCandidate.screenshot!.metadata.copyWith( + description: "${extraCandidate.screenshot!.metadata.description} (extra candidate)", + ), + extraCandidate.screenshot!.image, + extraCandidate.screenshot!.pngBytes, ), ); } - return _layoutFailureScene(tester, report, photos); + return _layoutFailureScene(tester, report, photos, layout); } /// Generates a single image that shows all the golden failures. Future<(Image, FailureSceneMetadata)> _layoutFailureScene( - WidgetTester tester, GoldenSceneReport report, List images) async { - final renderablePhotos = {}; + WidgetTester tester, + GoldenSceneReport report, + List images, + SceneLayout layout, +) async { + final renderablePhotos = {}; for (final photo in images) { - final image = await _convertImagePackageToUiImage(photo.pixels); - final pixels = (await image.toByteData(format: ui.ImageByteFormat.png))!.buffer.asUint8List(); - renderablePhotos[photo] = (pixels, GlobalKey()); + renderablePhotos[photo] = GlobalKey(); } final sceneKey = GlobalKey(); final scene = GoldenSceneBounds( child: IntrinsicWidth( child: IntrinsicHeight( - child: GoldenFailureScene( - key: sceneKey, - direction: Axis.vertical, - renderablePhotos: renderablePhotos, - background: null, - ), + child: material.Builder( + key: sceneKey, + builder: (context) { + return layout.build( + tester, + context, + renderablePhotos, + ); + }), ), ), ); @@ -173,10 +194,11 @@ Future<(Image, FailureSceneMetadata)> _layoutFailureScene( for (final entry in renderablePhotos.entries) { await precacheImage( - MemoryImage(entry.value.$1), - tester.element(find.byKey(entry.value.$2)), + MemoryImage(entry.key.pngBytes), + tester.element(find.byKey(entry.value)), ); } + await tester.pumpAndSettle(); final uiImage = await captureImage(find.byKey(sceneKey).evaluate().single); @@ -195,10 +217,10 @@ Future<(Image, FailureSceneMetadata)> _layoutFailureScene( images: [ for (final golden in renderablePhotos.keys) FailureImageMetadata( - id: golden.description, + id: golden.id, topLeft: - (renderablePhotos[golden]!.$2.currentContext!.findRenderObject() as RenderBox).localToGlobal(Offset.zero), - size: renderablePhotos[golden]!.$2.currentContext!.size!, + (renderablePhotos[golden]!.currentContext!.findRenderObject() as RenderBox).localToGlobal(Offset.zero), + size: renderablePhotos[golden]!.currentContext!.size!, ), ], ); @@ -215,11 +237,22 @@ Image _layoutGoldenFailure({ required Image absoluteDiff, required Image relativeDiff, }) { + final maxWidth = max(golden.width, candidate.width); + final maxHeight = max(golden.height, candidate.height); + const gap = 4; + final image = Image( - width: golden.width + candidate.width, - height: golden.height + candidate.height, + width: maxWidth * 2 + gap, + height: maxHeight * 2 + gap, ); + final white = ColorUint32.rgb(255, 255, 255); + for (int x = 0; x < image.width; x += 1) { + for (int y = 0; y < image.height; y += 1) { + image.setPixel(x, y, white); + } + } + // Copy golden to top left corner. _drawImage( source: golden, @@ -232,7 +265,7 @@ Image _layoutGoldenFailure({ _drawImage( source: candidate, destination: image, - x: golden.width, + x: maxWidth + gap, y: 0, ); @@ -241,15 +274,15 @@ Image _layoutGoldenFailure({ source: absoluteDiff, destination: image, x: 0, - y: golden.height, + y: maxHeight + gap, ); // Copy relative diff to bottom right corner. _drawImage( source: relativeDiff, destination: image, - x: golden.width, - y: golden.height, + x: maxWidth + gap, + y: maxHeight + gap, ); return image; @@ -258,8 +291,8 @@ Image _layoutGoldenFailure({ /// Generates an image that shows the absolute differences between the golden /// and the candidate images. Image _generateAbsoluteDiff( - GoldenImage golden, - GoldenImage candidate, + GoldenSceneScreenshot golden, + GoldenSceneScreenshot candidate, GoldenMismatch mismatch, ) { final maxWidth = max(golden.image.width, candidate.image.width); @@ -283,8 +316,8 @@ void _paintAbsoluteDiff({ required Image destination, required int originX, required int originY, - required GoldenImage golden, - required GoldenImage candidate, + required GoldenSceneScreenshot golden, + required GoldenSceneScreenshot candidate, }) { final maxWidth = max(golden.image.width, candidate.image.width); final maxHeight = max(golden.image.height, candidate.image.height); @@ -324,8 +357,8 @@ void _paintAbsoluteDiff({ /// Generates an image that shows the relative differences between the golden /// and the candidate images. Image _generateRelativeDiff( - GoldenImage golden, - GoldenImage candidate, + GoldenSceneScreenshot golden, + GoldenSceneScreenshot candidate, GoldenMismatch mismatch, ) { final maxWidth = max(golden.image.width, candidate.image.width); @@ -349,8 +382,8 @@ void _paintRelativeDiff({ required Image destination, required int originX, required int originY, - required GoldenImage golden, - required GoldenImage candidate, + required GoldenSceneScreenshot golden, + required GoldenSceneScreenshot candidate, }) { final maxWidth = max(golden.image.width, candidate.image.width); final maxHeight = max(golden.image.height, candidate.image.height); @@ -421,82 +454,6 @@ Future _convertImagePackageToUiImage(Image image) async { return completer.future; } -class GoldenFailureScene extends StatelessWidget { - const GoldenFailureScene({ - super.key, - required this.direction, - required this.renderablePhotos, - this.background, - }); - - final Axis direction; - final Map renderablePhotos; - final Widget? background; - - @override - Widget build(BuildContext context) { - return ColoredBox( - color: const material.Color(0xFF666666), - child: Stack( - children: [ - if (background != null) // - Positioned.fill( - child: ColoredBox(color: material.Colors.green), - ), - if (background != null) // - Positioned.fill( - child: background!, - ), - Padding( - padding: const EdgeInsets.all(48), - child: Flex( - direction: direction, - mainAxisSize: MainAxisSize.min, - spacing: 48, - children: [ - for (final entry in renderablePhotos.entries) // - SizedBox( - width: entry.key.pixels.width.toDouble(), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ColoredBox( - color: material.Colors.white, - child: material.Image.memory( - key: entry.value.$2, - entry.value.$1, - width: entry.key.pixels.width.toDouble(), - height: entry.key.pixels.height.toDouble(), - ), - ), - Container( - color: material.Colors.white, - padding: const EdgeInsets.all(16), - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - entry.key.description, - softWrap: false, - style: TextStyle( - color: material.Colors.black, - fontFamily: "packages/flutter_test_goldens/OpenSans", - ), - ), - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ); - } -} - class GoldenFailurePhoto { const GoldenFailurePhoto({ required this.description, diff --git a/lib/src/scenes/gallery.dart b/lib/src/scenes/gallery.dart index 5d4469e..542b9e4 100644 --- a/lib/src/scenes/gallery.dart +++ b/lib/src/scenes/gallery.dart @@ -621,7 +621,7 @@ Image.memory( Directory(_goldenFailureDirectoryPath).createSync(); await tester.runAsync(() async { - final (failureImage, metadata) = await paintFailureScene(_tester, report); + final (failureImage, metadata) = await paintFailureScene(tester, report, _layout); Uint8List pngData = encodePng(failureImage); pngData = pngData.copyWithTextMetadata( From 22a8c8956fce33b843c76feaea631fcedfb5dbee Mon Sep 17 00:00:00 2001 From: Angelo Silvestre Date: Tue, 24 Jun 2025 22:00:05 -0300 Subject: [PATCH 4/4] Update failure layout --- lib/src/scenes/failure_scene.dart | 132 +++++++++++++++++++----------- lib/src/scenes/gallery.dart | 2 +- 2 files changed, 87 insertions(+), 47 deletions(-) diff --git a/lib/src/scenes/failure_scene.dart b/lib/src/scenes/failure_scene.dart index e8d6b9f..d5c487e 100644 --- a/lib/src/scenes/failure_scene.dart +++ b/lib/src/scenes/failure_scene.dart @@ -87,8 +87,7 @@ Future paintGoldenMismatchImages(GoldenMismatch mismatch) async { } /// Given a [report], generates that shows all the mismatches found in the report. -Future<(Image, FailureSceneMetadata)> paintFailureScene( - WidgetTester tester, GoldenSceneReport report, SceneLayout layout) async { +Future<(Image, FailureSceneMetadata)> paintFailureScene(WidgetTester tester, GoldenSceneReport report) async { final photos = []; for (final item in report.items) { @@ -103,7 +102,7 @@ Future<(Image, FailureSceneMetadata)> paintFailureScene( final absoluteDiff = _generateAbsoluteDiff(golden, candidate, mismatch); final relativeDiff = _generateRelativeDiff(golden, candidate, mismatch); - final reportImage = _layoutGoldenFailure( + final reportImage = await _layoutGoldenFailure( report: report, golden: golden.image, candidate: candidate.image, @@ -130,36 +129,36 @@ Future<(Image, FailureSceneMetadata)> paintFailureScene( ); } - for (final missingCandidate in report.missingCandidates) { - // TODO: Figure out why using missingCandidate.golden!.pngBytes causes an "Invalid image data" error. - final image = await _convertImagePackageToUiImage(missingCandidate.golden!.image); - final pixels = (await image.toByteData(format: ui.ImageByteFormat.png))!.buffer.asUint8List(); - photos.add( - GoldenSceneScreenshot( - missingCandidate.golden!.id, - missingCandidate.golden!.metadata.copyWith( - description: "${missingCandidate.golden!.metadata.description} (missing candidate)", - ), - missingCandidate.golden!.image, - pixels, - ), - ); - } - - for (final extraCandidate in report.extraCandidates) { - photos.add( - GoldenSceneScreenshot( - extraCandidate.screenshot!.id, - extraCandidate.screenshot!.metadata.copyWith( - description: "${extraCandidate.screenshot!.metadata.description} (extra candidate)", - ), - extraCandidate.screenshot!.image, - extraCandidate.screenshot!.pngBytes, - ), - ); - } - - return _layoutFailureScene(tester, report, photos, layout); + // for (final missingCandidate in report.missingCandidates) { + // // TODO: Figure out why using missingCandidate.golden!.pngBytes causes an "Invalid image data" error. + // final image = await _convertImagePackageToUiImage(missingCandidate.golden!.image); + // final pixels = (await image.toByteData(format: ui.ImageByteFormat.png))!.buffer.asUint8List(); + // photos.add( + // GoldenSceneScreenshot( + // missingCandidate.golden!.id, + // missingCandidate.golden!.metadata.copyWith( + // description: "${missingCandidate.golden!.metadata.description} (missing candidate)", + // ), + // missingCandidate.golden!.image, + // pixels, + // ), + // ); + // } + + // for (final extraCandidate in report.extraCandidates) { + // photos.add( + // GoldenSceneScreenshot( + // extraCandidate.screenshot!.id, + // extraCandidate.screenshot!.metadata.copyWith( + // description: "${extraCandidate.screenshot!.metadata.description} (extra candidate)", + // ), + // extraCandidate.screenshot!.image, + // extraCandidate.screenshot!.pngBytes, + // ), + // ); + // } + + return _layoutFailureScene(tester, report, photos); } /// Generates a single image that shows all the golden failures. @@ -167,26 +166,30 @@ Future<(Image, FailureSceneMetadata)> _layoutFailureScene( WidgetTester tester, GoldenSceneReport report, List images, - SceneLayout layout, ) async { final renderablePhotos = {}; for (final photo in images) { renderablePhotos[photo] = GlobalKey(); } + final layout = RowSceneLayout( + itemDecorator: _itemDecorator, + ); + final sceneKey = GlobalKey(); final scene = GoldenSceneBounds( child: IntrinsicWidth( child: IntrinsicHeight( child: material.Builder( - key: sceneKey, - builder: (context) { - return layout.build( - tester, - context, - renderablePhotos, - ); - }), + key: sceneKey, + builder: (context) { + return layout.build( + tester, + context, + renderablePhotos, + ); + }, + ), ), ), ); @@ -230,13 +233,13 @@ Future<(Image, FailureSceneMetadata)> _layoutFailureScene( /// Generates a single image that shows the golden, the candidate, and the /// absolute and relative differences between them. -Image _layoutGoldenFailure({ +Future _layoutGoldenFailure({ required GoldenSceneReport report, required Image golden, required Image candidate, required Image absoluteDiff, required Image relativeDiff, -}) { +}) async { final maxWidth = max(golden.width, candidate.width); final maxHeight = max(golden.height, candidate.height); const gap = 4; @@ -270,11 +273,12 @@ Image _layoutGoldenFailure({ ); // Copy absolute diff to bottom left corner. + final diffY = maxHeight + gap; _drawImage( source: absoluteDiff, destination: image, x: 0, - y: maxHeight + gap, + y: diffY, ); // Copy relative diff to bottom right corner. @@ -282,7 +286,7 @@ Image _layoutGoldenFailure({ source: relativeDiff, destination: image, x: maxWidth + gap, - y: maxHeight + gap, + y: diffY, ); return image; @@ -530,3 +534,39 @@ class FailureImageMetadata { }; } } + +Widget _itemDecorator( + BuildContext context, + GoldenScreenshotMetadata metadata, + Widget content, +) { + return Padding( + padding: const EdgeInsets.all(24), + child: IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 4, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded(child: Text('Golden')), + Expanded(child: Text('Candidate')), + ], + ), + content, + Row( + children: [ + Expanded(child: Text('Absolute Diff')), + Expanded(child: Text('Relative Diff')), + ], + ), + const material.Divider(), + Expanded( + child: Text(metadata.description), + ), + ], + ), + ), + ); +} diff --git a/lib/src/scenes/gallery.dart b/lib/src/scenes/gallery.dart index 542b9e4..76356f8 100644 --- a/lib/src/scenes/gallery.dart +++ b/lib/src/scenes/gallery.dart @@ -621,7 +621,7 @@ Image.memory( Directory(_goldenFailureDirectoryPath).createSync(); await tester.runAsync(() async { - final (failureImage, metadata) = await paintFailureScene(tester, report, _layout); + final (failureImage, metadata) = await paintFailureScene(tester, report); Uint8List pngData = encodePng(failureImage); pngData = pngData.copyWithTextMetadata(