Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion lib/src/goldens/golden_collections.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:ui';

import 'package:flutter_test_goldens/src/goldens/golden_scenes.dart';
import 'package:image/image.dart' as img;

/// A collection of in-memory golden images or screenshot images.
Expand All @@ -12,10 +13,15 @@ import 'package:image/image.dart' as img;
/// corresponding golden image.
/// {@endtemplate}
class GoldenCollection {
GoldenCollection(this.imagesById);
GoldenCollection(
this.imagesById, {
required this.metadata,
});

final Map<String, GoldenImage> imagesById;

final GoldenSceneMetadata metadata;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be here. I assume this was added to make the scene metadata available somewhere that we have a collection. Currently these are separate concepts. Perhaps later we'll merge them both into GoldenSceneMetadata, but for now we should respect the barrier.

Is there a reasonable way to get the scene metadata where you need it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.


List<String> get ids => imagesById.keys.toList(growable: false);

bool hasId(String id) => imagesById[id] != null;
Expand Down
33 changes: 20 additions & 13 deletions lib/src/goldens/golden_comparisons.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ GoldenCollectionMismatches compareGoldenCollections(
// For every golden, look for missing and mismatching screenshots.
for (final id in goldens.ids) {
if (!screenshots.hasId(id)) {
mismatches[id] = MissingGoldenMismatch(
golden: goldens[id],
mismatches[id] = MissingCandidateMismatch(
golden: goldens[id]!,
);
continue;
}
Expand Down Expand Up @@ -44,7 +44,7 @@ GoldenCollectionMismatches compareGoldenCollections(
for (final id in screenshots.ids) {
if (!goldens.hasId(id)) {
mismatches[id] = MissingGoldenMismatch(
screenshot: screenshots[id],
screenshot: screenshots[id]!,
);
}
}
Expand Down Expand Up @@ -132,22 +132,29 @@ class WrongSizeGoldenMismatch extends GoldenMismatch {
Map<String, dynamic> get describeStructured => throw UnimplementedError();
}

/// Attempted to compare a screenshot to a golden, but either the screenshot was never
/// generated, or the screenshot was generated for a golden that doesn't exist.
/// Attempted to compare a candidate to a golden, but the candidate was generated for a golden that doesn't exist.
class MissingGoldenMismatch extends GoldenMismatch {
MissingGoldenMismatch({
super.golden,
super.screenshot,
});
required GoldenImage screenshot,
}) : super(screenshot: screenshot);

GoldenImage get _existingGolden => golden ?? screenshot!;
@override
String get describe =>
"A new screenshot was generated with ID '${screenshot!.id}', but there's no existing golden image with that ID.";

@override
String get describe => "A new screenshot was generated with ID '${_existingGolden.id}', $_missingMessage";
Map<String, dynamic> get describeStructured => throw UnimplementedError();
}

/// Attempted to compare a candidante to a golden, but the candidate was never generated.
class MissingCandidateMismatch extends GoldenMismatch {
MissingCandidateMismatch({
required GoldenImage golden,
}) : super(golden: golden);

String get _missingMessage => golden != null //
? "but no screenshot was generated with that ID."
: "but there's no existing golden image with that ID.";
@override
String get describe =>
"A new screenshot was generated with ID '${golden!.id}', but no screenshot was generated with that ID.";

@override
Map<String, dynamic> get describeStructured => throw UnimplementedError();
Expand Down
5 changes: 4 additions & 1 deletion lib/src/goldens/golden_scenes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ GoldenCollection _extractCollectionFromScene(GoldenSceneMetadata sceneMetadata,
);
}

return GoldenCollection(goldenImages);
return GoldenCollection(
goldenImages,
metadata: sceneMetadata,
);
}

RenderRepaintBoundary? _findNearestRepaintBoundary(Finder bounds) {
Expand Down
83 changes: 83 additions & 0 deletions lib/src/scenes/failure_scene.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import 'dart:math';
import 'dart:ui' as ui;

import 'package:flutter_test_goldens/src/goldens/pixel_comparisons.dart';
import 'package:image/image.dart';

import 'package:flutter_test_goldens/flutter_test_goldens.dart';

/// Given a [mismatch] between a golden and a screenshot, generates an image
/// that shows the golden, the screenshot, and the differences between them.
Future<Image> paintGoldenMismatchImages(GoldenMismatch mismatch) async {
final goldenWidth = mismatch.golden!.image.width;
final goldenHeight = mismatch.golden!.image.height;

final screenshotWidth = mismatch.screenshot!.image.width;
final screenshotHeight = mismatch.screenshot!.image.height;

final maxWidth = max(goldenWidth, screenshotWidth);
final maxHeight = max(goldenHeight, screenshotHeight);

final failureImage = Image(
width: maxWidth * 2,
height: maxHeight * 2,
);

// Copy golden to top left corner.
for (int x = 0; x < goldenWidth; x += 1) {
for (int y = 0; y < goldenHeight; y += 1) {
final goldenPixel = mismatch.golden!.image.getPixel(x, y);
failureImage.setPixel(x, y, goldenPixel);
}
}

// Copy screenshot to top right corner.
for (int x = 0; x < screenshotWidth; x += 1) {
for (int y = 0; y < screenshotHeight; y += 1) {
final screenshotPixel = mismatch.screenshot!.image.getPixel(x, y);
failureImage.setPixel(maxWidth + x, y, screenshotPixel);
}
}

// 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 >= goldenWidth || x >= screenshotWidth || y >= goldenHeight || y >= screenshotHeight) {
// 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.
failureImage.setPixel(x, maxHeight + y, absoluteDiffColor);

// Paint this pixel in the relative severity diff image.
failureImage.setPixel(maxWidth + x, maxHeight + y, absoluteDiffColor);

continue;
}

// Check if the screenshot matches the golden.
final goldenPixel = mismatch.golden!.image.getPixel(x, y);
final screenshotPixel = mismatch.screenshot!.image.getPixel(x, y);
final pixelsMatch = goldenPixel == screenshotPixel;
if (pixelsMatch) {
continue;
}

// Paint this pixel in the absolute diff image.
failureImage.setPixel(x, maxHeight + y, absoluteDiffColor);

// Paint this pixel in the relative severity diff image.
final mismatchPercent = calculateColorMismatchPercent(goldenPixel, screenshotPixel);
final yellowAmount = ui.lerpDouble(0.2, 1.0, mismatchPercent)!;
failureImage.setPixel(
goldenWidth + x,
goldenHeight + y,
ColorUint32.rgb((255 * yellowAmount).round(), (255 * yellowAmount).round(), 0),
);
}
}

return failureImage;
}
181 changes: 89 additions & 92 deletions lib/src/scenes/gallery.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:ui' as ui;

import 'package:flutter/foundation.dart';
Expand All @@ -13,11 +12,12 @@ import 'package:flutter_test_goldens/src/goldens/golden_collections.dart';
import 'package:flutter_test_goldens/src/goldens/golden_comparisons.dart';
import 'package:flutter_test_goldens/src/goldens/golden_rendering.dart';
import 'package:flutter_test_goldens/src/goldens/golden_scenes.dart';
import 'package:flutter_test_goldens/src/goldens/pixel_comparisons.dart';
import 'package:flutter_test_goldens/src/logging.dart';
import 'package:flutter_test_goldens/src/png/png_metadata.dart';
import 'package:flutter_test_goldens/src/scenes/failure_scene.dart';
import 'package:flutter_test_goldens/src/scenes/golden_files.dart';
import 'package:flutter_test_goldens/src/scenes/golden_scene.dart';
import 'package:flutter_test_goldens/src/scenes/golden_scene_report_printer.dart';
import 'package:flutter_test_goldens/src/scenes/scene_layout.dart';
import 'package:image/image.dart';
import 'package:path/path.dart';
Expand Down Expand Up @@ -471,102 +471,94 @@ class Gallery {
// Compare goldens in the scene.
FtgLog.pipeline.fine("Comparing goldens and screenshots");
final mismatches = compareGoldenCollections(goldenCollection, screenshotCollection);
if (mismatches.mismatches.isNotEmpty) {
FtgLog.pipeline.fine("Mismatches ($existingGoldenFileName):");
for (final mismatch in mismatches.mismatches.values) {
FtgLog.pipeline.fine(" - ${mismatch.golden?.id ?? mismatch.screenshot?.id}: $mismatch");

final items = <GoldenReport>[];
final missingCandidates = <MissingCandidateMismatch>[];
final extraCandidates = <MissingGoldenMismatch>[];

FtgLog.pipeline.fine("Mismatches ($existingGoldenFileName):");
for (final mismatch in mismatches.mismatches.values) {
FtgLog.pipeline.fine(" - ${mismatch.golden?.id ?? mismatch.screenshot?.id}: $mismatch");
switch (mismatch) {
case MissingCandidateMismatch():
// A golden candidate is missing.
missingCandidates.add(mismatch);
break;
case MissingGoldenMismatch():
// We have a golden candidate, but not the original golden.
extraCandidates.add(mismatch);
break;
}
}

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 goldenWidth = mismatch.golden!.image.width;
final goldenHeight = mismatch.golden!.image.height;

final screenshotWidth = mismatch.screenshot!.image.width;
final screenshotHeight = mismatch.screenshot!.image.height;

final maxWidth = max(goldenWidth, screenshotWidth);
final maxHeight = max(goldenHeight, screenshotHeight);

final failureImage = Image(
width: maxWidth * 2,
height: maxHeight * 2,
);

// Copy golden to top left corner.
for (int x = 0; x < goldenWidth; x += 1) {
for (int y = 0; y < goldenHeight; y += 1) {
final goldenPixel = mismatch.golden!.image.getPixel(x, y);
failureImage.setPixel(x, y, goldenPixel);
}
}

// Copy screenshot to top right corner.
for (int x = 0; x < screenshotWidth; x += 1) {
for (int y = 0; y < screenshotHeight; y += 1) {
final screenshotPixel = mismatch.screenshot!.image.getPixel(x, y);
failureImage.setPixel(maxWidth + x, y, screenshotPixel);
}
}

// 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 >= goldenWidth || x >= screenshotWidth || y >= goldenHeight || y >= screenshotHeight) {
// 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.
failureImage.setPixel(x, maxHeight + y, absoluteDiffColor);

// Paint this pixel in the relative severity diff image.
failureImage.setPixel(maxWidth + x, maxHeight + y, absoluteDiffColor);

continue;
}

// Check if the screenshot matches the golden.
final goldenPixel = mismatch.golden!.image.getPixel(x, y);
final screenshotPixel = mismatch.screenshot!.image.getPixel(x, y);
final pixelsMatch = goldenPixel == screenshotPixel;
if (pixelsMatch) {
continue;
}

// Paint this pixel in the absolute diff image.
failureImage.setPixel(x, maxHeight + y, absoluteDiffColor);

// Paint this pixel in the relative severity diff image.
final mismatchPercent = calculateColorMismatchPercent(goldenPixel, screenshotPixel);
final yellowAmount = ui.lerpDouble(0.2, 1.0, mismatchPercent)!;
failureImage.setPixel(
goldenWidth + x,
goldenHeight + y,
ColorUint32.rgb((255 * yellowAmount).round(), (255 * yellowAmount).round(), 0),
);
}
}

await encodePngFile(
"$_goldenFailureDirectoryPath/failure_${existingGoldenFileName}_${mismatch.golden!.id}.png",
failureImage,
);
});
// For each candidate found in the scene, report whether it passed or failed.
for (final screenshotId in screenshotCollection.ids) {
if (!goldenCollection.hasId(screenshotId)) {
// This candidate is an extra candidate, i.e., it was found in the scene,
// but it doesn't have a golden counterpart. We already reported extra candidates
// above, so we can skip this candidate.
continue;
}

throw Exception("Goldens failed with ${mismatches.mismatches.length} mismatch(es)");
} else {
final mismatch = mismatches.mismatches[screenshotId];
if (mismatch == null) {
// The golden check passed.
items.add(
GoldenReport.success(
goldenCollection.metadata.images.where((image) => image.id == screenshotId).first,
),
);
} else {
// The golden check failed.
items.add(
GoldenReport.failure(
metadata: goldenCollection.metadata.images.where((image) => image.id == screenshotId).first,
details: [
GoldenCheckDetail(
status: GoldenTestStatus.failure,
description: mismatch.toString(),
mismatch: mismatch,
),
],
),
);
}
}

if (mismatches.mismatches.isEmpty) {
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);

await encodePngFile(
"$_goldenFailureDirectoryPath/failure_${existingGoldenFileName}_${mismatch.golden!.id}.png",
failureImage,
);
});
}

final report = GoldenSceneReport(
sceneDescription: _sceneDescription,
metadata: goldenCollection.metadata,
items: items,
missingCandidates: missingCandidates,
extraCandidates: extraCandidates,
);
_printReport(report);

if (mismatches.mismatches.isNotEmpty) {
fail("Goldens failed with ${mismatches.mismatches.length} mismatch(es)");
}
}

String get _testFileDirectory => (goldenFileComparator as LocalFileComparator).basedir.path;
Expand All @@ -580,6 +572,11 @@ class Gallery {
"$_goldenDirectory$_fileName${includeExtension ? ".png" : ""}";

String get _goldenFailureDirectoryPath => "${_goldenDirectory}failures";

/// Prints the report in an human readable format to the console.
void _printReport(GoldenSceneReport report) {
GoldenSceneReportPrinter().printReport(report);
}
}

/// Pumps a widget tree into the given [tester], wrapping its content within the given [decorator].
Expand Down
Loading