From 907d1017318084fc8c211ca695f3237ec4c6a29f Mon Sep 17 00:00:00 2001 From: Dan Field Date: Wed, 18 Oct 2023 11:44:11 -0700 Subject: [PATCH 01/44] Skia gold for dart tests --- testing/dart/BUILD.gn | 2 + testing/dart/canvas_test.dart | 92 +++----------- testing/dart/compile_test.gni | 5 +- testing/dart/goldens.dart | 113 ++++++++++++++++++ testing/dart/pubspec.yaml | 3 + .../canvas_test_dithered_gradient.png | Bin 1701 -> 0 bytes testing/resources/canvas_test_gradient.png | Bin 1029 -> 0 bytes testing/resources/canvas_test_toImage.png | Bin 488 -> 0 bytes ...ath_effect_mixed_with_stroked_geometry.png | Bin 6002 -> 0 bytes .../text_with_gradient_with_matrix.png | Bin 85997 -> 0 bytes 10 files changed, 137 insertions(+), 78 deletions(-) create mode 100644 testing/dart/goldens.dart delete mode 100644 testing/resources/canvas_test_dithered_gradient.png delete mode 100644 testing/resources/canvas_test_gradient.png delete mode 100644 testing/resources/canvas_test_toImage.png delete mode 100644 testing/resources/dotted_path_effect_mixed_with_stroked_geometry.png delete mode 100644 testing/resources/text_with_gradient_with_matrix.png diff --git a/testing/dart/BUILD.gn b/testing/dart/BUILD.gn index 2e1bb1ad38f67..cafb3129c693e 100644 --- a/testing/dart/BUILD.gn +++ b/testing/dart/BUILD.gn @@ -48,9 +48,11 @@ tests = [ ] foreach(test, tests) { + skia_gold_work_dir = rebase_path("$root_gen_dir/skia_gold_$test") compile_flutter_dart_test("compile_$test") { dart_file = test dart_kernel = "$root_gen_dir/$test.dill" + extra_cfe_args = [ "-DkSkiaGoldWorkDirectory=$skia_gold_work_dir" ] packages = ".dart_tool/package_config.json" } } diff --git a/testing/dart/canvas_test.dart b/testing/dart/canvas_test.dart index d317e2f13e420..455cf3ddc000f 100644 --- a/testing/dart/canvas_test.dart +++ b/testing/dart/canvas_test.dart @@ -12,6 +12,7 @@ import 'package:litetest/litetest.dart'; import 'package:path/path.dart' as path; import 'package:vector_math/vector_math_64.dart'; +import 'goldens.dart'; import 'impeller_enabled.dart'; typedef CanvasCallback = void Function(Canvas canvas); @@ -123,61 +124,11 @@ void testNoCrashes() { }); } -/// @returns true When the images are reasonably similar. -/// @todo Make the search actually fuzzy to a certain degree. -Future fuzzyCompareImages(Image golden, Image img) async { - if (golden.width != img.width || golden.height != img.height) { - return false; - } - int getPixel(ByteData data, int x, int y) => data.getUint32((x + y * golden.width) * 4); - final ByteData goldenData = (await golden.toByteData())!; - final ByteData imgData = (await img.toByteData())!; - for (int y = 0; y < golden.height; y++) { - for (int x = 0; x < golden.width; x++) { - if (getPixel(goldenData, x, y) != getPixel(imgData, x, y)) { - return false; - } - } - } - return true; -} - -Future saveTestImage(Image image, String filename) async { - final String imagesPath = path.join('flutter', 'testing', 'resources'); - final ByteData pngData = (await image.toByteData(format: ImageByteFormat.png))!; - final String outPath = path.join(imagesPath, filename); - File(outPath).writeAsBytesSync(pngData.buffer.asUint8List()); - print('wrote: $outPath'); -} - -/// @returns true When the images are reasonably similar. -Future fuzzyGoldenImageCompare( - Image image, String goldenImageName) async { - final String imagesPath = path.join('flutter', 'testing', 'resources'); - final File file = File(path.join(imagesPath, goldenImageName)); - - bool areEqual = false; - - if (file.existsSync()) { - final Uint8List goldenData = await file.readAsBytes(); - - final Codec codec = await instantiateImageCodec(goldenData); - final FrameInfo frame = await codec.getNextFrame(); - expect(frame.image.height, equals(image.height)); - expect(frame.image.width, equals(image.width)); - - areEqual = await fuzzyCompareImages(frame.image, image); - } - - if (!areEqual) { - saveTestImage(image, 'found_$goldenImageName'); - } - return areEqual; -} - -void main() { +void main() async { testNoCrashes(); + final ImageComparer comparer = await ImageComparer.create(testSuiteName: 'canvas_test'); + test('Simple .toImage', () async { final Image image = await toImage((Canvas canvas) { final Path circlePath = Path() @@ -190,11 +141,8 @@ void main() { }, 100, 100); expect(image.width, equals(100)); expect(image.height, equals(100)); - - final bool areEqual = - await fuzzyGoldenImageCompare(image, 'canvas_test_toImage.png'); - expect(areEqual, true); - }, skip: impellerEnabled); + await comparer.addGoldenImage(image, 'canvas_test_toImage.png'); + }); Gradient makeGradient() { return Gradient.linear( @@ -212,10 +160,8 @@ void main() { expect(image.width, equals(100)); expect(image.height, equals(100)); - final bool areEqual = - await fuzzyGoldenImageCompare(image, 'canvas_test_dithered_gradient.png'); - expect(areEqual, true); - }, skip: !Platform.isLinux || impellerEnabled); // https://github.com/flutter/flutter/issues/53784 + await comparer.addGoldenImage(image, 'canvas_test_dithered_gradient.png'); + }); test('Null values allowed for drawAtlas methods', () async { final Image image = await createImage(100, 100); @@ -302,12 +248,8 @@ void main() { }); }, width, height); - final bool areEqual = await fuzzyCompareImages(incrementalMatrixImage, combinedMatrixImage); + final bool areEqual = await comparer.fuzzyCompareImages(incrementalMatrixImage, combinedMatrixImage); - if (!areEqual) { - saveTestImage(incrementalMatrixImage, 'incremental_3D_transform_test_image.png'); - saveTestImage(combinedMatrixImage, 'combined_3D_transform_test_image.png'); - } expect(areEqual, true); }); @@ -348,10 +290,8 @@ void main() { expect(image.width, equals(200)); expect(image.height, equals(250)); - final bool areEqual = - await fuzzyGoldenImageCompare(image, 'dotted_path_effect_mixed_with_stroked_geometry.png'); - expect(areEqual, true); - }, skip: !Platform.isLinux || impellerEnabled); // https://github.com/flutter/flutter/issues/53784 + await comparer.addGoldenImage(image, 'dotted_path_effect_mixed_with_stroked_geometry.png'); + }); test('Gradients with matrices in Paragraphs render correctly', () async { final Image image = await toImage((Canvas canvas) { @@ -400,10 +340,8 @@ void main() { expect(image.width, equals(600)); expect(image.height, equals(400)); - final bool areEqual = - await fuzzyGoldenImageCompare(image, 'text_with_gradient_with_matrix.png'); - expect(areEqual, true); - }, skip: !Platform.isLinux || impellerEnabled); // https://github.com/flutter/flutter/issues/53784 + await comparer.addGoldenImage(image, 'text_with_gradient_with_matrix.png'); + }); test('toImageSync - too big', () async { PictureRecorder recorder = PictureRecorder(); @@ -602,8 +540,8 @@ void main() { final Image tofuImage = await drawText('>\b<'); // The tab's image should be identical to the space's image but not the tofu's image. - final bool tabToSpaceComparison = await fuzzyCompareImages(tabImage, spaceImage); - final bool tabToTofuComparison = await fuzzyCompareImages(tabImage, tofuImage); + final bool tabToSpaceComparison = await comparer.fuzzyCompareImages(tabImage, spaceImage); + final bool tabToTofuComparison = await comparer.fuzzyCompareImages(tabImage, tofuImage); expect(tabToSpaceComparison, isTrue); expect(tabToTofuComparison, isFalse); diff --git a/testing/dart/compile_test.gni b/testing/dart/compile_test.gni index fbdf76ea849da..37c31fff91231 100644 --- a/testing/dart/compile_test.gni +++ b/testing/dart/compile_test.gni @@ -51,8 +51,11 @@ template("compile_flutter_dart_test") { rebase_path(snapshot_depfile), "--output-dill", rebase_path(invoker.dart_kernel, root_out_dir), - rebase_path(invoker.dart_file), ] + if (defined(invoker.extra_cfe_args)) { + common_args += invoker.extra_cfe_args + } + common_args += [ rebase_path(invoker.dart_file) ] if (flutter_prebuilt_dart_sdk) { action(target_name) { diff --git a/testing/dart/goldens.dart b/testing/dart/goldens.dart new file mode 100644 index 0000000000000..19a8ff2e3349b --- /dev/null +++ b/testing/dart/goldens.dart @@ -0,0 +1,113 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:path/path.dart' as path; +import 'package:skia_gold_client/skia_gold_client.dart'; + +import 'impeller_enabled.dart'; + +const String _kSkiaGoldWorkDirectoryKey = 'kSkiaGoldWorkDirectory'; + +/// A helper for doing image comparison (golden) tests. +/// +/// Contains utilities for comparing two images in memory that are expected to +/// be identical, or for adding images to Skia gold for comparison. +class ImageComparer { + ImageComparer._({required this.testSuiteName, required SkiaGoldClient client}) + : _client = client {} + + /// Creates an image comparer and authorizes. + static Future create({required String testSuiteName}) async { + const String workDirectoryPath = + const String.fromEnvironment(_kSkiaGoldWorkDirectoryKey); + if (workDirectoryPath.isEmpty) { + throw UnsupportedError( + 'Using ImageComparer requries defining kSkiaGoldWorkDirectoryKey.'); + } + + final Directory workDirectory = Directory(workDirectoryPath)..createSync(); + final Map dimensions = { + 'impeller_enabled': impellerEnabled.toString(), + }; + final SkiaGoldClient client = isSkiaGoldClientAvailable + ? SkiaGoldClient(workDirectory, dimensions: dimensions) + : _FakeSkiaGoldClient(workDirectory, dimensions); + + await client.auth(); + + return ImageComparer._(testSuiteName: testSuiteName, client: client); + } + + final SkiaGoldClient _client; + + /// A unique name for the suite under test, e.g. `canvas_test`. + final String testSuiteName; + + /// Adds an [Image] to Skia Gold for comparison. + /// + /// The [fileName] must be unique per [testSuiteName]. + Future addGoldenImage(Image image, String fileName) async { + final ByteData data = + (await image.toByteData(format: ImageByteFormat.png))!; + + final File file = File(path.join(_client.workDirectory.path, fileName)) + ..writeAsBytesSync(data.buffer.asUint8List()); + await _client.addImg( + testSuiteName, + file, + screenshotSize: image.width * image.height, + ); + } + + Future fuzzyCompareImages(Image golden, Image testImage) async { + if (golden.width != testImage.width || golden.height != testImage.height) { + return false; + } + int getPixel(ByteData data, int x, int y) => + data.getUint32((x + y * golden.width) * 4); + final ByteData goldenData = (await golden.toByteData())!; + final ByteData testImageData = (await testImage.toByteData())!; + for (int y = 0; y < golden.height; y++) { + for (int x = 0; x < golden.width; x++) { + if (getPixel(goldenData, x, y) != getPixel(testImageData, x, y)) { + return false; + } + } + } + return true; + } +} + +// TODO(dnfield): add local comparison against baseline, +// https://github.com/flutter/flutter/issues/136831 +class _FakeSkiaGoldClient implements SkiaGoldClient { + _FakeSkiaGoldClient(this.workDirectory, this.dimensions); + + @override + final Directory workDirectory; + + @override + final Map dimensions; + + @override + Future auth() async {} + + @override + Future addImg( + String testName, + File goldenFile, { + double differentPixelsRate = 0.01, + int pixelColorDelta = 0, + required int screenshotSize, + }) async {} + + @override + dynamic noSuchMethod(Invocation invocation) { + throw UnimplementedError(invocation.memberName.toString().split('"')[1]); + } +} diff --git a/testing/dart/pubspec.yaml b/testing/dart/pubspec.yaml index 771026fff3627..d8f90d2b47d70 100644 --- a/testing/dart/pubspec.yaml +++ b/testing/dart/pubspec.yaml @@ -19,6 +19,7 @@ environment: dependencies: litetest: any path: any + skia_gold_client: any sky_engine: any vector_math: any vm_service: any @@ -43,6 +44,8 @@ dependency_overrides: path: ../../../third_party/dart/third_party/pkg/protobuf/protobuf smith: path: ../../../third_party/dart/pkg/smith + skia_gold_client: + path: ../skia_gold_client sky_engine: path: ../../sky/packages/sky_engine vector_math: diff --git a/testing/resources/canvas_test_dithered_gradient.png b/testing/resources/canvas_test_dithered_gradient.png deleted file mode 100644 index d8062f2dc1f35424af652e5580097b4c8b1a1d50..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1701 zcmV;W23q-vP)XMhftwzgzDorH#n7nhnuwsrAhbD63C{rP+S`uZx@>$Tj* z%jHs}eZ5{vDbl`{+XL-?e|cVq_P@)%|1Rw!{5HdwqKfdtl4CdNC2{eKHVZ}+B<+V4&uB8_JcZgi zPTH=#65}L|b_&~^T!iOG-&Z=e*BRdYvEASA$o%p?+^@DV9<@z^onc2^L5uy9&ftFS zC>K528MFJ)L6+-}I)QgaZ~kK9w%Aoy=9bLRL42k{JU_<>R+)?u7qQNHMjXuyjK5(B zW5!G}T4l+KX?Bkpm^5qKvmS%ND>h=p95e3E4lv_sU40wlU!#nT*uRGvyfb?1o8Q0R z86%8f@sR$i%otYeYnfpyHfDy;K-N)SC)m!wvwnmbMcI%0I5TkGsw-$ao$*YC2r6U7 z=jx0CHfILvzM49t&kVFZX0XmcyLUWJGvxO$<2`jo8~LkQ#gNxBW41H=F`oamI-_03 znOC173_1aAcD!gmZF8wJVrF2cROTc~#xfRr_MlsIA7Imm^x#e83pWh%)t99PX=bRl(;i6Id+p{%)ohcFrpy6i_Wk`q^~m! z`8~||Or0^nkklD{X5jqmbw+Rg>UV|?;_~P*!#~?oXNr3~@T&Jku{(yV=Mox!e? z@rQSNU&)Mf=!{+*tL==`88RvDzHM}dwY8tBZ%Uovb;@4MpbY7E1}4XDa*P=`j}Ar@ zq)*WqQ8Fk)547ANR7kiz6&$H`OXGEP5>psH}S#e{`Ks$8?>p(p+KfsVy>8Uf&-jf*^|H^{F zD>ik;9?Zb_3#FbqV=XgcKQS}#dhvNk>I`o`J!UY5*!^&33@{{h#*xfGou?QQ1u1n# z5&OsFtY=2RkklEY%!oThT$wI)inf9+@9*<9@<-dsAmu2-OumP89m)c8In377SKbigqzN_yQ|NIKN00000NkvXXu0mjfs6tU# diff --git a/testing/resources/canvas_test_gradient.png b/testing/resources/canvas_test_gradient.png deleted file mode 100644 index 89f3f63dc518a1dfd84c9052125fdf6ac079253f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1029 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 OJe4QUp=FAu@BRUrm8ru3 diff --git a/testing/resources/text_with_gradient_with_matrix.png b/testing/resources/text_with_gradient_with_matrix.png deleted file mode 100644 index b940c1ee4e08b4f25c6ab2dd02210a638808e4a1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 85997 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 LNkvXXu0mjfP?$|P From a363fd13db102dbc2b3c7cd90e141ae2eda3a687 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Wed, 18 Oct 2023 13:37:11 -0700 Subject: [PATCH 02/44] analysis --- testing/dart/goldens.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/testing/dart/goldens.dart b/testing/dart/goldens.dart index 19a8ff2e3349b..7441ef07f9ed2 100644 --- a/testing/dart/goldens.dart +++ b/testing/dart/goldens.dart @@ -18,13 +18,15 @@ const String _kSkiaGoldWorkDirectoryKey = 'kSkiaGoldWorkDirectory'; /// Contains utilities for comparing two images in memory that are expected to /// be identical, or for adding images to Skia gold for comparison. class ImageComparer { - ImageComparer._({required this.testSuiteName, required SkiaGoldClient client}) - : _client = client {} + ImageComparer._({ + required this.testSuiteName, + required SkiaGoldClient client, + }) : _client = client; /// Creates an image comparer and authorizes. static Future create({required String testSuiteName}) async { const String workDirectoryPath = - const String.fromEnvironment(_kSkiaGoldWorkDirectoryKey); + String.fromEnvironment(_kSkiaGoldWorkDirectoryKey); if (workDirectoryPath.isEmpty) { throw UnsupportedError( 'Using ImageComparer requries defining kSkiaGoldWorkDirectoryKey.'); From adbf7509f58186d3a37e99cab381f55216cbb69d Mon Sep 17 00:00:00 2001 From: Dan Field Date: Wed, 18 Oct 2023 14:48:18 -0700 Subject: [PATCH 03/44] package locations --- testing/dart/pubspec.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/testing/dart/pubspec.yaml b/testing/dart/pubspec.yaml index d8f90d2b47d70..dabe32ab93ad4 100644 --- a/testing/dart/pubspec.yaml +++ b/testing/dart/pubspec.yaml @@ -30,8 +30,12 @@ dependency_overrides: path: ../../../third_party/dart/pkg/async_helper collection: path: ../../../third_party/dart/third_party/pkg/collection + crypto: + path: ../../../third_party/dart/third_party/pkg/crypto expect: path: ../../../third_party/dart/pkg/expect + file: + path: ../../../third_party/pkg/file/packages/file fixnum: path: ../../../third_party/dart/third_party/pkg/fixnum litetest: @@ -40,6 +44,10 @@ dependency_overrides: path: ../../../third_party/dart/pkg/meta path: path: ../../../third_party/dart/third_party/pkg/path + platform: + path: ../../../third_party/pkg/platform + process: + path: ../../../third_party/pkg/process protobuf: path: ../../../third_party/dart/third_party/pkg/protobuf/protobuf smith: @@ -48,6 +56,8 @@ dependency_overrides: path: ../skia_gold_client sky_engine: path: ../../sky/packages/sky_engine + typed_data: + path: ../../../third_party/dart/third_party/pkg/typed_data vector_math: path: ../../../third_party/pkg/vector_math vm_service: From 8b9b14ff9f197c1d7e6c7cc118fdd0b0532719a5 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Wed, 18 Oct 2023 16:29:18 -0700 Subject: [PATCH 04/44] more --- testing/dart/goldens.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/testing/dart/goldens.dart b/testing/dart/goldens.dart index 7441ef07f9ed2..ccddf1a689741 100644 --- a/testing/dart/goldens.dart +++ b/testing/dart/goldens.dart @@ -53,17 +53,20 @@ class ImageComparer { /// Adds an [Image] to Skia Gold for comparison. /// /// The [fileName] must be unique per [testSuiteName]. - Future addGoldenImage(Image image, String fileName) async { + Future addGoldenImage(Image image, String fileName) { final ByteData data = (await image.toByteData(format: ImageByteFormat.png))!; final File file = File(path.join(_client.workDirectory.path, fileName)) ..writeAsBytesSync(data.buffer.asUint8List()); - await _client.addImg( + return _client.addImg( testSuiteName, file, screenshotSize: image.width * image.height, - ); + ).catchError((dynamic error) { + print('Skia gold comparison failed: $error'); + throw Exception('Failed comparison: $testSuiteName/$fileName'); + }); } Future fuzzyCompareImages(Image golden, Image testImage) async { From 4d97460a271ceab7335b4e22110df77e9d3fd9a7 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Wed, 18 Oct 2023 16:38:39 -0700 Subject: [PATCH 05/44] syntax... --- testing/dart/goldens.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testing/dart/goldens.dart b/testing/dart/goldens.dart index ccddf1a689741..7b815ea3a5f0f 100644 --- a/testing/dart/goldens.dart +++ b/testing/dart/goldens.dart @@ -53,13 +53,13 @@ class ImageComparer { /// Adds an [Image] to Skia Gold for comparison. /// /// The [fileName] must be unique per [testSuiteName]. - Future addGoldenImage(Image image, String fileName) { + Future addGoldenImage(Image image, String fileName) async { final ByteData data = (await image.toByteData(format: ImageByteFormat.png))!; final File file = File(path.join(_client.workDirectory.path, fileName)) ..writeAsBytesSync(data.buffer.asUint8List()); - return _client.addImg( + await _client.addImg( testSuiteName, file, screenshotSize: image.width * image.height, From f90fa532bf1ce4d2e3aa6c0b6f42e6a0e0479b4f Mon Sep 17 00:00:00 2001 From: Dan Field Date: Thu, 19 Oct 2023 12:39:04 -0700 Subject: [PATCH 06/44] smp 1 --- testing/run_tests.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/testing/run_tests.py b/testing/run_tests.py index 7f196402c90e7..e3b6eeabcd9bb 100755 --- a/testing/run_tests.py +++ b/testing/run_tests.py @@ -605,6 +605,11 @@ def threading_description(self): return 'multithreaded' return 'single-threaded' + def impeller_enabled(self): + if self.enable_impeller: + return 'impeller swiftshader' + return 'skia software' + def gather_dart_test(build_dir, dart_file, options): kernel_file_name = os.path.basename(dart_file) + '.dill' @@ -636,8 +641,8 @@ def gather_dart_test(build_dir, dart_file, options): tester_name = 'flutter_tester' logger.info( - "Running test '%s' using '%s' (%s)", kernel_file_name, tester_name, - options.threading_description() + "Running test '%s' using '%s' (%s, %s)", kernel_file_name, tester_name, + options.threading_description(), options.impeller_enabled() ) forbidden_output = [] if 'unopt' in build_dir or options.expect_failure else [ '[ERROR' @@ -1012,7 +1017,7 @@ def run_engine_tasks_in_parallel(tasks): # processes launched for the queue reader and thread wakeup reader). # # See: https://bugs.python.org/issue26903 - max_processes = multiprocessing.cpu_count() + max_processes = 1 # multiprocessing.cpu_count() if sys_platform.startswith(('cygwin', 'win')) and max_processes > 60: max_processes = 60 From 003d82ac96480204e9bbb2a35046d0cb0f8a0894 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Thu, 19 Oct 2023 14:05:33 -0700 Subject: [PATCH 07/44] more --- .ci.yaml | 4 ++++ ci/builders/linux_host_engine.json | 6 ++++++ testing/dart/goldens.dart | 4 +++- testing/skia_gold_client/README.md | 15 +++++++++++++-- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.ci.yaml b/.ci.yaml index 1f7129b80bd3a..85aa909c8d0b2 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -293,6 +293,10 @@ targets: add_recipes_cq: "true" release_build: "true" config_name: linux_host_engine + dependencies: >- + [ + {"dependency": "goldctl", "version": "git_revision:f808dcff91b221ae313e540c09d79696cd08b8de"} + ] drone_dimensions: - os=Linux diff --git a/ci/builders/linux_host_engine.json b/ci/builders/linux_host_engine.json index aae0ac2835190..f4da8dc8b92df 100644 --- a/ci/builders/linux_host_engine.json +++ b/ci/builders/linux_host_engine.json @@ -185,6 +185,12 @@ "device_type=none", "os=Linux" ], + "dependencies": [ + { + "dependency": "goldctl", + "version": "git_revision:dddc0623e63150cbbafdcb273d4048f329e1dd09" + } + ], "gclient_variables": { "download_android_deps": false }, diff --git a/testing/dart/goldens.dart b/testing/dart/goldens.dart index 7b815ea3a5f0f..508d8db44d7d4 100644 --- a/testing/dart/goldens.dart +++ b/testing/dart/goldens.dart @@ -41,7 +41,7 @@ class ImageComparer { : _FakeSkiaGoldClient(workDirectory, dimensions); await client.auth(); - + print('Auth done!'); return ImageComparer._(testSuiteName: testSuiteName, client: client); } @@ -59,6 +59,7 @@ class ImageComparer { final File file = File(path.join(_client.workDirectory.path, fileName)) ..writeAsBytesSync(data.buffer.asUint8List()); + print('Adding image $testSuiteName $file'); await _client.addImg( testSuiteName, file, @@ -67,6 +68,7 @@ class ImageComparer { print('Skia gold comparison failed: $error'); throw Exception('Failed comparison: $testSuiteName/$fileName'); }); + print('Added image!'); } Future fuzzyCompareImages(Image golden, Image testImage) async { diff --git a/testing/skia_gold_client/README.md b/testing/skia_gold_client/README.md index c9d6a8ce56498..cf0ea2ea9a0ea 100644 --- a/testing/skia_gold_client/README.md +++ b/testing/skia_gold_client/README.md @@ -15,7 +15,18 @@ The web UI is available on https://flutter-engine-gold.skia.org/. dependencies: [{"dependency": "goldctl"}] ``` -2. Add dependency in `pubspec.yaml`: +2. In the builder `.json` file, ensure the drone has a dependency on `goldctl`: + +```yaml + "dependencies": [ + { + "dependency": "goldctl", + "version": "git_revision:" + } + ], +``` + +3. Add dependency in `pubspec.yaml`: ```yaml dependencies: @@ -23,7 +34,7 @@ dependencies: path: /testing/skia_gold_client ``` -3. Use the client: +4. Use the client: ```dart import 'package:skia_gold_client/skia_gold_client.dart'; From de6c2173d00674fb244af16d2b6b6147b1453764 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Thu, 19 Oct 2023 14:16:53 -0700 Subject: [PATCH 08/44] more print --- testing/dart/canvas_test.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/testing/dart/canvas_test.dart b/testing/dart/canvas_test.dart index 455cf3ddc000f..bf700984a3513 100644 --- a/testing/dart/canvas_test.dart +++ b/testing/dart/canvas_test.dart @@ -128,6 +128,7 @@ void main() async { testNoCrashes(); final ImageComparer comparer = await ImageComparer.create(testSuiteName: 'canvas_test'); + print('Got a comparerer $comparer'); test('Simple .toImage', () async { final Image image = await toImage((Canvas canvas) { From 1f536fc15a20bd32302ccbc31da59d657d95d167 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Thu, 19 Oct 2023 15:04:42 -0700 Subject: [PATCH 09/44] sadness --- testing/dart/canvas_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testing/dart/canvas_test.dart b/testing/dart/canvas_test.dart index bf700984a3513..202e23c4ed7f3 100644 --- a/testing/dart/canvas_test.dart +++ b/testing/dart/canvas_test.dart @@ -125,11 +125,11 @@ void testNoCrashes() { } void main() async { - testNoCrashes(); - final ImageComparer comparer = await ImageComparer.create(testSuiteName: 'canvas_test'); print('Got a comparerer $comparer'); + testNoCrashes(); + test('Simple .toImage', () async { final Image image = await toImage((Canvas canvas) { final Path circlePath = Path() From 696f55ab98c0af27bf0734461cb863fea44c0063 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Thu, 19 Oct 2023 15:21:07 -0700 Subject: [PATCH 10/44] reversions --- testing/dart/canvas_test.dart | 1 - testing/dart/goldens.dart | 3 --- testing/run_tests.py | 2 +- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/testing/dart/canvas_test.dart b/testing/dart/canvas_test.dart index 202e23c4ed7f3..a250b751cbe4b 100644 --- a/testing/dart/canvas_test.dart +++ b/testing/dart/canvas_test.dart @@ -126,7 +126,6 @@ void testNoCrashes() { void main() async { final ImageComparer comparer = await ImageComparer.create(testSuiteName: 'canvas_test'); - print('Got a comparerer $comparer'); testNoCrashes(); diff --git a/testing/dart/goldens.dart b/testing/dart/goldens.dart index 508d8db44d7d4..d78d5948d0298 100644 --- a/testing/dart/goldens.dart +++ b/testing/dart/goldens.dart @@ -41,7 +41,6 @@ class ImageComparer { : _FakeSkiaGoldClient(workDirectory, dimensions); await client.auth(); - print('Auth done!'); return ImageComparer._(testSuiteName: testSuiteName, client: client); } @@ -59,7 +58,6 @@ class ImageComparer { final File file = File(path.join(_client.workDirectory.path, fileName)) ..writeAsBytesSync(data.buffer.asUint8List()); - print('Adding image $testSuiteName $file'); await _client.addImg( testSuiteName, file, @@ -68,7 +66,6 @@ class ImageComparer { print('Skia gold comparison failed: $error'); throw Exception('Failed comparison: $testSuiteName/$fileName'); }); - print('Added image!'); } Future fuzzyCompareImages(Image golden, Image testImage) async { diff --git a/testing/run_tests.py b/testing/run_tests.py index e3b6eeabcd9bb..22257e692c57b 100755 --- a/testing/run_tests.py +++ b/testing/run_tests.py @@ -1017,7 +1017,7 @@ def run_engine_tasks_in_parallel(tasks): # processes launched for the queue reader and thread wakeup reader). # # See: https://bugs.python.org/issue26903 - max_processes = 1 # multiprocessing.cpu_count() + max_processes = multiprocessing.cpu_count() if sys_platform.startswith(('cygwin', 'win')) and max_processes > 60: max_processes = 60 From 61294ebcb6ce92f90895556af3c52198c20748e1 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Thu, 19 Oct 2023 15:42:54 -0700 Subject: [PATCH 11/44] delete file --- testing/dart/compile_test.gni | 94 ----------------------------------- 1 file changed, 94 deletions(-) delete mode 100644 testing/dart/compile_test.gni diff --git a/testing/dart/compile_test.gni b/testing/dart/compile_test.gni deleted file mode 100644 index 37c31fff91231..0000000000000 --- a/testing/dart/compile_test.gni +++ /dev/null @@ -1,94 +0,0 @@ -# Copyright 2013 The Flutter Authors. All rights reserved. -# Use of this source code is governed by a BSD-style license that can be -# found in the LICENSE file. - -import("//build/compiled_action.gni") -import("//flutter/common/config.gni") -import("//third_party/dart/build/dart/dart_action.gni") -import("//third_party/dart/sdk_args.gni") - -import("//third_party/dart/build/dart/dart_action.gni") - -# Generates a Dart kernel snapshot using flutter_frontend_server. -# -# Arguments -# dart_main (required): The Main Dart file. -# -# dart_kernel (required): The path to the output kernel snapshot in the out -# directory. -# -# packages (required): The path to the .packages file. -template("compile_flutter_dart_test") { - assert(defined(invoker.dart_file), "The Dart test file must be specified.") - assert(defined(invoker.dart_kernel), - "The Dart Kernel file location must be specified.") - assert(defined(invoker.packages), - "The path to the .packages file must be specified.") - - common_deps = [ - "//flutter/flutter_frontend_server:frontend_server", - "//flutter/lib/snapshot:strong_platform", - ] - if (defined(invoker.deps)) { - common_deps += invoker.deps - } - - snapshot_depfile = - "$root_gen_dir/flutter/testing/snapshot_$target_name.depfile.d" - - common_vm_args = [ "--disable-dart-dev" ] - - flutter_patched_sdk = rebase_path("$root_out_dir/flutter_patched_sdk") - - common_args = [ - "--sound-null-safety", - "--sdk-root", - flutter_patched_sdk, - "--target=flutter", - "--packages", - rebase_path(invoker.packages), - "--depfile", - rebase_path(snapshot_depfile), - "--output-dill", - rebase_path(invoker.dart_kernel, root_out_dir), - ] - if (defined(invoker.extra_cfe_args)) { - common_args += invoker.extra_cfe_args - } - common_args += [ rebase_path(invoker.dart_file) ] - - if (flutter_prebuilt_dart_sdk) { - action(target_name) { - testonly = true - deps = common_deps - pool = "//flutter/build/dart:dart_pool" - script = "//build/gn_run_binary.py" - inputs = [ invoker.dart_file ] - outputs = [ invoker.dart_kernel ] - depfile = snapshot_depfile - - ext = "" - if (is_win) { - ext = ".exe" - } - dart = rebase_path("$host_prebuilt_dart_sdk/bin/dart$ext", root_out_dir) - frontend_server = - rebase_path("$root_gen_dir/frontend_server.dart.snapshot") - - args = [ dart ] + common_vm_args + [ frontend_server ] + common_args - } - } else { - dart_action(target_name) { - testonly = true - deps = common_deps - pool = "//flutter/build/dart:dart_pool" - script = "$root_gen_dir/frontend_server.dart.snapshot" - packages = rebase_path(invoker.packages) - inputs = [ invoker.dart_file ] - outputs = [ invoker.dart_kernel ] - depfile = snapshot_depfile - vm_args = common_vm_args - args = common_args - } - } -} From 7bcd6dadd032be336b10c4d3298150ed521285bb Mon Sep 17 00:00:00 2001 From: Dan Field Date: Thu, 19 Oct 2023 16:31:47 -0700 Subject: [PATCH 12/44] Try less multiprocessing? --- testing/run_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/run_tests.py b/testing/run_tests.py index 22257e692c57b..e3b6eeabcd9bb 100755 --- a/testing/run_tests.py +++ b/testing/run_tests.py @@ -1017,7 +1017,7 @@ def run_engine_tasks_in_parallel(tasks): # processes launched for the queue reader and thread wakeup reader). # # See: https://bugs.python.org/issue26903 - max_processes = multiprocessing.cpu_count() + max_processes = 1 # multiprocessing.cpu_count() if sys_platform.startswith(('cygwin', 'win')) and max_processes > 60: max_processes = 60 From 7baf8fd5b6e7c8fdd2ce3760dc4fa3531fe2400f Mon Sep 17 00:00:00 2001 From: Dan Field Date: Wed, 25 Oct 2023 12:17:51 -0700 Subject: [PATCH 13/44] reduce cases, avoid collisions for SMP --- testing/dart/goldens.dart | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/testing/dart/goldens.dart b/testing/dart/goldens.dart index d78d5948d0298..a12aa4abb398c 100644 --- a/testing/dart/goldens.dart +++ b/testing/dart/goldens.dart @@ -23,6 +23,10 @@ class ImageComparer { required SkiaGoldClient client, }) : _client = client; + // Avoid talking to Skia gold for the force-multithreading variants. + bool get _useSkiaGold => + !Platform.executableArguments.contains('--force-multithreading'); + /// Creates an image comparer and authorizes. static Future create({required String testSuiteName}) async { const String workDirectoryPath = @@ -32,11 +36,13 @@ class ImageComparer { 'Using ImageComparer requries defining kSkiaGoldWorkDirectoryKey.'); } - final Directory workDirectory = Directory(workDirectoryPath)..createSync(); + final Directory workDirectory = Directory( + impellerEnabled ? '${workDirectoryPath}_iplr' : workDirectoryPath, + )..createSync(); final Map dimensions = { 'impeller_enabled': impellerEnabled.toString(), }; - final SkiaGoldClient client = isSkiaGoldClientAvailable + final SkiaGoldClient client = isSkiaGoldClientAvailable && _useSkiaGold ? SkiaGoldClient(workDirectory, dimensions: dimensions) : _FakeSkiaGoldClient(workDirectory, dimensions); @@ -58,11 +64,13 @@ class ImageComparer { final File file = File(path.join(_client.workDirectory.path, fileName)) ..writeAsBytesSync(data.buffer.asUint8List()); - await _client.addImg( + await _client + .addImg( testSuiteName, file, screenshotSize: image.width * image.height, - ).catchError((dynamic error) { + ) + .catchError((dynamic error) { print('Skia gold comparison failed: $error'); throw Exception('Failed comparison: $testSuiteName/$fileName'); }); From dd5207f8b8d14a7ab8df0ba7b9f78f86eedb310a Mon Sep 17 00:00:00 2001 From: Dan Field Date: Wed, 25 Oct 2023 13:48:43 -0700 Subject: [PATCH 14/44] oops --- testing/dart/goldens.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/dart/goldens.dart b/testing/dart/goldens.dart index a12aa4abb398c..e482ff56f82d8 100644 --- a/testing/dart/goldens.dart +++ b/testing/dart/goldens.dart @@ -24,7 +24,7 @@ class ImageComparer { }) : _client = client; // Avoid talking to Skia gold for the force-multithreading variants. - bool get _useSkiaGold => + static bool get _useSkiaGold => !Platform.executableArguments.contains('--force-multithreading'); /// Creates an image comparer and authorizes. From f22b78d096c432cca2389299e7e5d40c7a132ffb Mon Sep 17 00:00:00 2001 From: Dan Field Date: Thu, 26 Oct 2023 10:42:42 -0700 Subject: [PATCH 15/44] fix test suite name --- testing/dart/canvas_test.dart | 2 +- testing/dart/goldens.dart | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/testing/dart/canvas_test.dart b/testing/dart/canvas_test.dart index a250b751cbe4b..53e9c0482c851 100644 --- a/testing/dart/canvas_test.dart +++ b/testing/dart/canvas_test.dart @@ -125,7 +125,7 @@ void testNoCrashes() { } void main() async { - final ImageComparer comparer = await ImageComparer.create(testSuiteName: 'canvas_test'); + final ImageComparer comparer = await ImageComparer.create(testSuiteName: 'canvas_test.dart'); testNoCrashes(); diff --git a/testing/dart/goldens.dart b/testing/dart/goldens.dart index e482ff56f82d8..cc9d6fe69a07f 100644 --- a/testing/dart/goldens.dart +++ b/testing/dart/goldens.dart @@ -29,6 +29,9 @@ class ImageComparer { /// Creates an image comparer and authorizes. static Future create({required String testSuiteName}) async { + if (!testSuiteName.endsWith('.dart')) { + throw ArgumentError('"$testSuiteName" must end in .dart', 'testSuiteName'); + } const String workDirectoryPath = String.fromEnvironment(_kSkiaGoldWorkDirectoryKey); if (workDirectoryPath.isEmpty) { @@ -64,13 +67,11 @@ class ImageComparer { final File file = File(path.join(_client.workDirectory.path, fileName)) ..writeAsBytesSync(data.buffer.asUint8List()); - await _client - .addImg( + await _client.addImg( testSuiteName, file, screenshotSize: image.width * image.height, - ) - .catchError((dynamic error) { + ).catchError((dynamic error) { print('Skia gold comparison failed: $error'); throw Exception('Failed comparison: $testSuiteName/$fileName'); }); From 008fc307ae128c1d94aab7c28699949aaebee4e9 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Thu, 26 Oct 2023 10:47:31 -0700 Subject: [PATCH 16/44] unique --- testing/dart/goldens.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/dart/goldens.dart b/testing/dart/goldens.dart index cc9d6fe69a07f..11c9d795dd749 100644 --- a/testing/dart/goldens.dart +++ b/testing/dart/goldens.dart @@ -50,7 +50,7 @@ class ImageComparer { : _FakeSkiaGoldClient(workDirectory, dimensions); await client.auth(); - return ImageComparer._(testSuiteName: testSuiteName, client: client); + return ImageComparer._(testSuiteName: 'flutter_tester_$testSuiteName', client: client); } final SkiaGoldClient _client; From 41c5b4baf220fd766e4508a550446998081217fb Mon Sep 17 00:00:00 2001 From: Dan Field Date: Thu, 26 Oct 2023 11:04:40 -0700 Subject: [PATCH 17/44] verbose --- testing/skia_gold_client/lib/skia_gold_client.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/testing/skia_gold_client/lib/skia_gold_client.dart b/testing/skia_gold_client/lib/skia_gold_client.dart index b258de044b554..6f9badb96a792 100644 --- a/testing/skia_gold_client/lib/skia_gold_client.dart +++ b/testing/skia_gold_client/lib/skia_gold_client.dart @@ -16,6 +16,7 @@ const String _kLuciEnvName = 'LUCI_CONTEXT'; const String _skiaGoldHost = 'https://flutter-engine-gold.skia.org'; const String _instance = 'flutter-engine'; +const bool _verbose = true; /// Whether the Skia Gold client is available and can be used in this /// environment. bool get isSkiaGoldClientAvailable => Platform.environment.containsKey(_kGoldctlKey); @@ -95,6 +96,7 @@ class SkiaGoldClient { final List authCommand = [ _goldctl, 'auth', + if (_verbose) '--verbose', '--work-dir', _tempPath, '--luci', ]; @@ -134,6 +136,7 @@ class SkiaGoldClient { final List imgtestInitCommand = [ _goldctl, 'imgtest', 'init', + if (_verbose) '--verbose', '--instance', _instance, '--work-dir', _tempPath, '--commit', commitHash, @@ -229,6 +232,7 @@ class SkiaGoldClient { final List imgtestCommand = [ _goldctl, 'imgtest', 'add', + if (_verbose) '--verbose', '--work-dir', _tempPath, '--test-name', cleanTestName(testName), '--png-file', goldenFile.path, @@ -261,6 +265,7 @@ class SkiaGoldClient { final List tryjobInitCommand = [ _goldctl, 'imgtest', 'init', + if (_verbose) '--verbose', '--instance', _instance, '--work-dir', _tempPath, '--commit', commitHash, @@ -317,6 +322,7 @@ class SkiaGoldClient { final List tryjobCommand = [ _goldctl, 'imgtest', 'add', + if (_verbose) '--verbose', '--work-dir', _tempPath, '--test-name', cleanTestName(testName), '--png-file', goldenFile.path, From 5775f31f219a5aedd5e8c48d3eb87c94001cef30 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Thu, 26 Oct 2023 11:23:34 -0700 Subject: [PATCH 18/44] actually verbose --- testing/dart/canvas_test.dart | 5 ++- testing/dart/goldens.dart | 19 ++++++++---- .../lib/skia_gold_client.dart | 31 ++++++++++++++----- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/testing/dart/canvas_test.dart b/testing/dart/canvas_test.dart index 53e9c0482c851..369eefd6b8c27 100644 --- a/testing/dart/canvas_test.dart +++ b/testing/dart/canvas_test.dart @@ -125,7 +125,10 @@ void testNoCrashes() { } void main() async { - final ImageComparer comparer = await ImageComparer.create(testSuiteName: 'canvas_test.dart'); + final ImageComparer comparer = await ImageComparer.create( + testSuiteName: 'canvas_test.dart', + verbose: true, + ); testNoCrashes(); diff --git a/testing/dart/goldens.dart b/testing/dart/goldens.dart index 11c9d795dd749..e2f1cc1bd200d 100644 --- a/testing/dart/goldens.dart +++ b/testing/dart/goldens.dart @@ -28,9 +28,13 @@ class ImageComparer { !Platform.executableArguments.contains('--force-multithreading'); /// Creates an image comparer and authorizes. - static Future create({required String testSuiteName}) async { + static Future create({ + required String testSuiteName, + bool verbose = false, + }) async { if (!testSuiteName.endsWith('.dart')) { - throw ArgumentError('"$testSuiteName" must end in .dart', 'testSuiteName'); + throw ArgumentError( + '"$testSuiteName" must end in .dart', 'testSuiteName'); } const String workDirectoryPath = String.fromEnvironment(_kSkiaGoldWorkDirectoryKey); @@ -46,11 +50,12 @@ class ImageComparer { 'impeller_enabled': impellerEnabled.toString(), }; final SkiaGoldClient client = isSkiaGoldClientAvailable && _useSkiaGold - ? SkiaGoldClient(workDirectory, dimensions: dimensions) + ? SkiaGoldClient(workDirectory, dimensions: dimensions, verbose: verbose) : _FakeSkiaGoldClient(workDirectory, dimensions); await client.auth(); - return ImageComparer._(testSuiteName: 'flutter_tester_$testSuiteName', client: client); + return ImageComparer._( + testSuiteName: 'flutter_tester_$testSuiteName', client: client); } final SkiaGoldClient _client; @@ -67,11 +72,13 @@ class ImageComparer { final File file = File(path.join(_client.workDirectory.path, fileName)) ..writeAsBytesSync(data.buffer.asUint8List()); - await _client.addImg( + await _client + .addImg( testSuiteName, file, screenshotSize: image.width * image.height, - ).catchError((dynamic error) { + ) + .catchError((dynamic error) { print('Skia gold comparison failed: $error'); throw Exception('Failed comparison: $testSuiteName/$fileName'); }); diff --git a/testing/skia_gold_client/lib/skia_gold_client.dart b/testing/skia_gold_client/lib/skia_gold_client.dart index 6f9badb96a792..07810022ba820 100644 --- a/testing/skia_gold_client/lib/skia_gold_client.dart +++ b/testing/skia_gold_client/lib/skia_gold_client.dart @@ -16,7 +16,6 @@ const String _kLuciEnvName = 'LUCI_CONTEXT'; const String _skiaGoldHost = 'https://flutter-engine-gold.skia.org'; const String _instance = 'flutter-engine'; -const bool _verbose = true; /// Whether the Skia Gold client is available and can be used in this /// environment. bool get isSkiaGoldClientAvailable => Platform.environment.containsKey(_kGoldctlKey); @@ -37,7 +36,9 @@ class SkiaGoldClient { /// /// [dimensions] allows to add attributes about the environment /// used to generate the screenshots. - SkiaGoldClient(this.workDirectory, { this.dimensions }); + SkiaGoldClient(this.workDirectory, { this.dimensions, this.verbose = false}); + + final bool verbose; /// Allows to add attributes about the environment used to generate the screenshots. final Map? dimensions; @@ -96,7 +97,7 @@ class SkiaGoldClient { final List authCommand = [ _goldctl, 'auth', - if (_verbose) '--verbose', + if (verbose) '--verbose', '--work-dir', _tempPath, '--luci', ]; @@ -113,6 +114,9 @@ class SkiaGoldClient { ..writeln('stdout: ${result.stdout}') ..writeln('stderr: ${result.stderr}'); throw Exception(buf.toString()); + } else if (verbose) { + print('stdout:\n${result.stdout}'); + print('stderr:\n${result.stderr}'); } } @@ -136,7 +140,7 @@ class SkiaGoldClient { final List imgtestInitCommand = [ _goldctl, 'imgtest', 'init', - if (_verbose) '--verbose', + if (verbose) '--verbose', '--instance', _instance, '--work-dir', _tempPath, '--commit', commitHash, @@ -166,7 +170,11 @@ class SkiaGoldClient { ..writeln('stdout: ${result.stdout}') ..writeln('stderr: ${result.stderr}'); throw Exception(buf.toString()); + } else if (verbose) { + print('stdout:\n${result.stdout}'); + print('stderr:\n${result.stderr}'); } + } /// Executes the `imgtest add` command in the `goldctl` tool. @@ -232,7 +240,7 @@ class SkiaGoldClient { final List imgtestCommand = [ _goldctl, 'imgtest', 'add', - if (_verbose) '--verbose', + if (verbose) '--verbose', '--work-dir', _tempPath, '--test-name', cleanTestName(testName), '--png-file', goldenFile.path, @@ -247,6 +255,9 @@ class SkiaGoldClient { // is meant to inform when an unexpected result occurs. print('goldctl imgtest add stdout: ${result.stdout}'); print('goldctl imgtest add stderr: ${result.stderr}'); + } else if (verbose) { + print('stdout:\n${result.stdout}'); + print('stderr:\n${result.stderr}'); } } @@ -265,7 +276,7 @@ class SkiaGoldClient { final List tryjobInitCommand = [ _goldctl, 'imgtest', 'init', - if (_verbose) '--verbose', + if (verbose) '--verbose', '--instance', _instance, '--work-dir', _tempPath, '--commit', commitHash, @@ -298,6 +309,9 @@ class SkiaGoldClient { ..writeln('stdout: ${result.stdout}') ..writeln('stderr: ${result.stderr}'); throw Exception(buf.toString()); + } else if (verbose) { + print('stdout:\n${result.stdout}'); + print('stderr:\n${result.stderr}'); } } @@ -322,7 +336,7 @@ class SkiaGoldClient { final List tryjobCommand = [ _goldctl, 'imgtest', 'add', - if (_verbose) '--verbose', + if (verbose) '--verbose', '--work-dir', _tempPath, '--test-name', cleanTestName(testName), '--png-file', goldenFile.path, @@ -344,6 +358,9 @@ class SkiaGoldClient { ..writeln('stderr: ${result.stderr}') ..writeln(); throw Exception(buf.toString()); + } else if (verbose) { + print('stdout:\n${result.stdout}'); + print('stderr:\n${result.stderr}'); } } From 4bb2cba98f48317b15d2497123f1ba394a87b006 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Thu, 26 Oct 2023 11:34:15 -0700 Subject: [PATCH 19/44] fix --- testing/dart/goldens.dart | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/testing/dart/goldens.dart b/testing/dart/goldens.dart index e2f1cc1bd200d..be91f708a3d8e 100644 --- a/testing/dart/goldens.dart +++ b/testing/dart/goldens.dart @@ -50,7 +50,8 @@ class ImageComparer { 'impeller_enabled': impellerEnabled.toString(), }; final SkiaGoldClient client = isSkiaGoldClientAvailable && _useSkiaGold - ? SkiaGoldClient(workDirectory, dimensions: dimensions, verbose: verbose) + ? SkiaGoldClient(workDirectory, + dimensions: dimensions, verbose: verbose) : _FakeSkiaGoldClient(workDirectory, dimensions); await client.auth(); @@ -106,7 +107,11 @@ class ImageComparer { // TODO(dnfield): add local comparison against baseline, // https://github.com/flutter/flutter/issues/136831 class _FakeSkiaGoldClient implements SkiaGoldClient { - _FakeSkiaGoldClient(this.workDirectory, this.dimensions); + _FakeSkiaGoldClient( + this.workDirectory, + this.dimensions, { + this.verbose = false, + }); @override final Directory workDirectory; @@ -114,6 +119,9 @@ class _FakeSkiaGoldClient implements SkiaGoldClient { @override final Map dimensions; + @override + final bool verbose; + @override Future auth() async {} From 4e004120c1d6d594874bd59f1189723b6ce499de Mon Sep 17 00:00:00 2001 From: Dan Field Date: Thu, 26 Oct 2023 11:49:53 -0700 Subject: [PATCH 20/44] missing file --- .../golden_tests_harvester/bin/golden_tests_harvester.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/impeller/golden_tests_harvester/bin/golden_tests_harvester.dart b/impeller/golden_tests_harvester/bin/golden_tests_harvester.dart index 21524bf9bc4da..62a5f4525d856 100644 --- a/impeller/golden_tests_harvester/bin/golden_tests_harvester.dart +++ b/impeller/golden_tests_harvester/bin/golden_tests_harvester.dart @@ -16,13 +16,16 @@ bool get isLuciEnv => Platform.environment.containsKey(_kLuciEnvName); /// Fake SkiaGoldClient that is used if the harvester is run outside of Luci. class FakeSkiaGoldClient implements SkiaGoldClient { - FakeSkiaGoldClient(this._workingDirectory, {this.dimensions}); + FakeSkiaGoldClient(this._workingDirectory, {this.dimensions, this.verbose = false}); final Directory _workingDirectory; @override final Map? dimensions; + @override + final bool verbose; + @override Future addImg(String testName, File goldenFile, {double differentPixelsRate = 0.01, From b87ff9d2d45f13018195a26a532fd35ee8eba9a7 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Thu, 26 Oct 2023 14:24:51 -0700 Subject: [PATCH 21/44] ok --- testing/dart/canvas_test.dart | 1 - testing/dart/goldens.dart | 2 +- testing/skia_gold_client/lib/skia_gold_client.dart | 4 ++++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/testing/dart/canvas_test.dart b/testing/dart/canvas_test.dart index 369eefd6b8c27..55dcfb228adfc 100644 --- a/testing/dart/canvas_test.dart +++ b/testing/dart/canvas_test.dart @@ -127,7 +127,6 @@ void testNoCrashes() { void main() async { final ImageComparer comparer = await ImageComparer.create( testSuiteName: 'canvas_test.dart', - verbose: true, ); testNoCrashes(); diff --git a/testing/dart/goldens.dart b/testing/dart/goldens.dart index be91f708a3d8e..c86541f7ee6f6 100644 --- a/testing/dart/goldens.dart +++ b/testing/dart/goldens.dart @@ -52,7 +52,7 @@ class ImageComparer { final SkiaGoldClient client = isSkiaGoldClientAvailable && _useSkiaGold ? SkiaGoldClient(workDirectory, dimensions: dimensions, verbose: verbose) - : _FakeSkiaGoldClient(workDirectory, dimensions); + : _FakeSkiaGoldClient(workDirectory, dimensions, verbose: verbose); await client.auth(); return ImageComparer._( diff --git a/testing/skia_gold_client/lib/skia_gold_client.dart b/testing/skia_gold_client/lib/skia_gold_client.dart index 07810022ba820..4327350b3cbb6 100644 --- a/testing/skia_gold_client/lib/skia_gold_client.dart +++ b/testing/skia_gold_client/lib/skia_gold_client.dart @@ -38,6 +38,10 @@ class SkiaGoldClient { /// used to generate the screenshots. SkiaGoldClient(this.workDirectory, { this.dimensions, this.verbose = false}); + /// Whether to print verbose output from goldctl. + /// + /// This flag is intended for use in debugging CI issues, and should not + /// ordinarily be set to true. final bool verbose; /// Allows to add attributes about the environment used to generate the screenshots. From 85efbb360513c2eb18a771011689832dca87b5cc Mon Sep 17 00:00:00 2001 From: Dan Field Date: Thu, 26 Oct 2023 14:44:24 -0700 Subject: [PATCH 22/44] simplify --- testing/dart/canvas_test.dart | 4 +--- testing/dart/goldens.dart | 24 ++++++------------------ 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/testing/dart/canvas_test.dart b/testing/dart/canvas_test.dart index 55dcfb228adfc..8c4937bdab2d1 100644 --- a/testing/dart/canvas_test.dart +++ b/testing/dart/canvas_test.dart @@ -125,9 +125,7 @@ void testNoCrashes() { } void main() async { - final ImageComparer comparer = await ImageComparer.create( - testSuiteName: 'canvas_test.dart', - ); + final ImageComparer comparer = await ImageComparer.create(); testNoCrashes(); diff --git a/testing/dart/goldens.dart b/testing/dart/goldens.dart index c86541f7ee6f6..5a70ecfc95f78 100644 --- a/testing/dart/goldens.dart +++ b/testing/dart/goldens.dart @@ -19,7 +19,6 @@ const String _kSkiaGoldWorkDirectoryKey = 'kSkiaGoldWorkDirectory'; /// be identical, or for adding images to Skia gold for comparison. class ImageComparer { ImageComparer._({ - required this.testSuiteName, required SkiaGoldClient client, }) : _client = client; @@ -29,13 +28,8 @@ class ImageComparer { /// Creates an image comparer and authorizes. static Future create({ - required String testSuiteName, bool verbose = false, }) async { - if (!testSuiteName.endsWith('.dart')) { - throw ArgumentError( - '"$testSuiteName" must end in .dart', 'testSuiteName'); - } const String workDirectoryPath = String.fromEnvironment(_kSkiaGoldWorkDirectoryKey); if (workDirectoryPath.isEmpty) { @@ -55,33 +49,27 @@ class ImageComparer { : _FakeSkiaGoldClient(workDirectory, dimensions, verbose: verbose); await client.auth(); - return ImageComparer._( - testSuiteName: 'flutter_tester_$testSuiteName', client: client); + return ImageComparer._(client: client); } final SkiaGoldClient _client; - /// A unique name for the suite under test, e.g. `canvas_test`. - final String testSuiteName; - /// Adds an [Image] to Skia Gold for comparison. /// - /// The [fileName] must be unique per [testSuiteName]. + /// The [fileName] must be unique. Future addGoldenImage(Image image, String fileName) async { final ByteData data = (await image.toByteData(format: ImageByteFormat.png))!; final File file = File(path.join(_client.workDirectory.path, fileName)) ..writeAsBytesSync(data.buffer.asUint8List()); - await _client - .addImg( - testSuiteName, + await _client.addImg( + fileName, file, screenshotSize: image.width * image.height, - ) - .catchError((dynamic error) { + ).catchError((dynamic error) { print('Skia gold comparison failed: $error'); - throw Exception('Failed comparison: $testSuiteName/$fileName'); + throw Exception('Failed comparison: $fileName'); }); } From 0dca473d697b917b94e9ed86d60f90471b6f5911 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Mon, 30 Oct 2023 11:07:51 -0700 Subject: [PATCH 23/44] merge --- testing/dart/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/dart/pubspec.yaml b/testing/dart/pubspec.yaml index dabe32ab93ad4..8226fbdfbf7f7 100644 --- a/testing/dart/pubspec.yaml +++ b/testing/dart/pubspec.yaml @@ -35,7 +35,7 @@ dependency_overrides: expect: path: ../../../third_party/dart/pkg/expect file: - path: ../../../third_party/pkg/file/packages/file + path: ../../../third_party/dart/third_party/pkg/file/packages/file fixnum: path: ../../../third_party/dart/third_party/pkg/fixnum litetest: From e9ac14266f777a7daa2792dc950f151cad1d95db Mon Sep 17 00:00:00 2001 From: Dan Field Date: Mon, 30 Oct 2023 11:08:15 -0700 Subject: [PATCH 24/44] verbose true --- testing/dart/canvas_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/dart/canvas_test.dart b/testing/dart/canvas_test.dart index 8c4937bdab2d1..6ed0cd37d8f9f 100644 --- a/testing/dart/canvas_test.dart +++ b/testing/dart/canvas_test.dart @@ -125,7 +125,7 @@ void testNoCrashes() { } void main() async { - final ImageComparer comparer = await ImageComparer.create(); + final ImageComparer comparer = await ImageComparer.create(verbose: true); testNoCrashes(); From e3aebc44fd5f40dad9c030c297a70b10f7d60360 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Mon, 30 Oct 2023 11:42:36 -0700 Subject: [PATCH 25/44] no fuzzy? --- testing/skia_gold_client/lib/skia_gold_client.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/testing/skia_gold_client/lib/skia_gold_client.dart b/testing/skia_gold_client/lib/skia_gold_client.dart index 4327350b3cbb6..6fa77641f7a33 100644 --- a/testing/skia_gold_client/lib/skia_gold_client.dart +++ b/testing/skia_gold_client/lib/skia_gold_client.dart @@ -387,8 +387,8 @@ class SkiaGoldClient { final int maxDifferentPixels = (screenshotSize * differentPixelsRate).toInt(); return [ '--add-test-optional-key', 'image_matching_algorithm:$algorithm', - '--add-test-optional-key', 'fuzzy_max_different_pixels:$maxDifferentPixels', - '--add-test-optional-key', 'fuzzy_pixel_delta_threshold:$pixelDeltaThreshold', + // '--add-test-optional-key', 'fuzzy_max_different_pixels:$maxDifferentPixels', + // '--add-test-optional-key', 'fuzzy_pixel_delta_threshold:$pixelDeltaThreshold', ]; } From be2265894ce4304992502fe7112cb75b466870e1 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Mon, 30 Oct 2023 11:52:25 -0700 Subject: [PATCH 26/44] more --- .../lib/skia_gold_client.dart | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/testing/skia_gold_client/lib/skia_gold_client.dart b/testing/skia_gold_client/lib/skia_gold_client.dart index 6fa77641f7a33..e04106d7a119e 100644 --- a/testing/skia_gold_client/lib/skia_gold_client.dart +++ b/testing/skia_gold_client/lib/skia_gold_client.dart @@ -248,7 +248,7 @@ class SkiaGoldClient { '--work-dir', _tempPath, '--test-name', cleanTestName(testName), '--png-file', goldenFile.path, - ..._getMatchingArguments(testName, screenshotSize, pixelDeltaThreshold, maxDifferentPixelsRate), + // ..._getMatchingArguments(testName, screenshotSize, pixelDeltaThreshold, maxDifferentPixelsRate), ]; final ProcessResult result = await _runCommand(imgtestCommand); @@ -344,7 +344,7 @@ class SkiaGoldClient { '--work-dir', _tempPath, '--test-name', cleanTestName(testName), '--png-file', goldenFile.path, - ..._getMatchingArguments(testName, screenshotSize, pixelDeltaThreshold, differentPixelsRate), + // ..._getMatchingArguments(testName, screenshotSize, pixelDeltaThreshold, differentPixelsRate), ]; final ProcessResult result = await _runCommand(tryjobCommand); @@ -368,29 +368,29 @@ class SkiaGoldClient { } } - List _getMatchingArguments( - String testName, - int screenshotSize, - int pixelDeltaThreshold, - double differentPixelsRate, - ) { - // The algorithm to be used when matching images. The available options are: - // - "fuzzy": Allows for customizing the thresholds of pixel differences. - // - "sobel": Same as "fuzzy" but performs edge detection before performing - // a fuzzy match. - const String algorithm = 'fuzzy'; - - // The number of pixels in this image that are allowed to differ from the - // baseline. It's okay for this to be a slightly high number like 10% of the - // image size because those wrong pixels are constrained by - // `pixelDeltaThreshold` below. - final int maxDifferentPixels = (screenshotSize * differentPixelsRate).toInt(); - return [ - '--add-test-optional-key', 'image_matching_algorithm:$algorithm', - // '--add-test-optional-key', 'fuzzy_max_different_pixels:$maxDifferentPixels', - // '--add-test-optional-key', 'fuzzy_pixel_delta_threshold:$pixelDeltaThreshold', - ]; - } + // List _getMatchingArguments( + // String testName, + // int screenshotSize, + // int pixelDeltaThreshold, + // double differentPixelsRate, + // ) { + // // The algorithm to be used when matching images. The available options are: + // // - "fuzzy": Allows for customizing the thresholds of pixel differences. + // // - "sobel": Same as "fuzzy" but performs edge detection before performing + // // a fuzzy match. + // const String algorithm = 'fuzzy'; + + // // The number of pixels in this image that are allowed to differ from the + // // baseline. It's okay for this to be a slightly high number like 10% of the + // // image size because those wrong pixels are constrained by + // // `pixelDeltaThreshold` below. + // final int maxDifferentPixels = (screenshotSize * differentPixelsRate).toInt(); + // return [ + // '--add-test-optional-key', 'image_matching_algorithm:$algorithm', + // // '--add-test-optional-key', 'fuzzy_max_different_pixels:$maxDifferentPixels', + // // '--add-test-optional-key', 'fuzzy_pixel_delta_threshold:$pixelDeltaThreshold', + // ]; + // } /// Returns the latest positive digest for the given test known to Skia Gold /// at head. From 6856c53da52fed4a17ba8c9c3c5faafb9ee5fa4d Mon Sep 17 00:00:00 2001 From: Dan Field Date: Mon, 30 Oct 2023 13:27:18 -0700 Subject: [PATCH 27/44] debug --- testing/dart/canvas_test.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/testing/dart/canvas_test.dart b/testing/dart/canvas_test.dart index 6ed0cd37d8f9f..63e66b3cd83b7 100644 --- a/testing/dart/canvas_test.dart +++ b/testing/dart/canvas_test.dart @@ -11,6 +11,7 @@ import 'dart:ui'; import 'package:litetest/litetest.dart'; import 'package:path/path.dart' as path; import 'package:vector_math/vector_math_64.dart'; +import 'package:process/process.dart'; import 'goldens.dart'; import 'impeller_enabled.dart'; @@ -1031,6 +1032,19 @@ void main() async { canvas.restoreToCount(canvas.getSaveCount() + 1); expect(canvas.getSaveCount(), equals(6)); }); + + await null; + final command = [ + Platform.environment['GOLDCTL']!, + 'imgtest', + 'finalize', + ]; + print(command.join(' ')); + final result = await LocalProcessManager().run(command); + print(result); + print(result.exitCode); + print(result.stdout); + print(result.stderr); } Matcher listEquals(ByteData expected) => (dynamic v) { From 633260b65588b3aef026c23c8025a66de9c49793 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Mon, 30 Oct 2023 13:37:22 -0700 Subject: [PATCH 28/44] littest? --- testing/dart/canvas_test.dart | 1808 ++++++++++++++++----------------- 1 file changed, 904 insertions(+), 904 deletions(-) diff --git a/testing/dart/canvas_test.dart b/testing/dart/canvas_test.dart index 63e66b3cd83b7..37b2575b0d597 100644 --- a/testing/dart/canvas_test.dart +++ b/testing/dart/canvas_test.dart @@ -128,9 +128,9 @@ void testNoCrashes() { void main() async { final ImageComparer comparer = await ImageComparer.create(verbose: true); - testNoCrashes(); + // testNoCrashes(); - test('Simple .toImage', () async { + // test('Simple .toImage', () async { final Image image = await toImage((Canvas canvas) { final Path circlePath = Path() ..addOval( @@ -143,908 +143,908 @@ void main() async { expect(image.width, equals(100)); expect(image.height, equals(100)); await comparer.addGoldenImage(image, 'canvas_test_toImage.png'); - }); - - Gradient makeGradient() { - return Gradient.linear( - Offset.zero, - const Offset(100, 100), - const [Color(0xFF4C4D52), Color(0xFF202124)], - ); - } - - test('Simple gradient, which is implicitly dithered', () async { - final Image image = await toImage((Canvas canvas) { - final Paint paint = Paint()..shader = makeGradient(); - canvas.drawPaint(paint); - }, 100, 100); - expect(image.width, equals(100)); - expect(image.height, equals(100)); - - await comparer.addGoldenImage(image, 'canvas_test_dithered_gradient.png'); - }); - - test('Null values allowed for drawAtlas methods', () async { - final Image image = await createImage(100, 100); - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - const Rect rect = Rect.fromLTWH(0, 0, 100, 100); - final RSTransform transform = RSTransform(1, 0, 0, 0); - const Color color = Color(0x00000000); - final Paint paint = Paint(); - canvas.drawAtlas(image, [transform], [rect], [color], BlendMode.src, rect, paint); - canvas.drawAtlas(image, [transform], [rect], [color], BlendMode.src, null, paint); - canvas.drawAtlas(image, [transform], [rect], [], null, rect, paint); - canvas.drawAtlas(image, [transform], [rect], null, null, rect, paint); - canvas.drawRawAtlas(image, Float32List(0), Float32List(0), Int32List(0), BlendMode.src, rect, paint); - canvas.drawRawAtlas(image, Float32List(0), Float32List(0), Int32List(0), BlendMode.src, null, paint); - canvas.drawRawAtlas(image, Float32List(0), Float32List(0), null, null, rect, paint); - - expectAssertion(() => canvas.drawAtlas(image, [transform], [rect], [color], null, rect, paint)); - }); - - test('Data lengths must match for drawAtlas methods', () async { - final Image image = await createImage(100, 100); - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - const Rect rect = Rect.fromLTWH(0, 0, 100, 100); - final RSTransform transform = RSTransform(1, 0, 0, 0); - const Color color = Color(0x00000000); - final Paint paint = Paint(); - canvas.drawAtlas(image, [transform], [rect], [color], BlendMode.src, rect, paint); - canvas.drawAtlas(image, [transform, transform], [rect, rect], [color, color], BlendMode.src, rect, paint); - canvas.drawAtlas(image, [transform], [rect], [], null, rect, paint); - canvas.drawAtlas(image, [transform], [rect], null, null, rect, paint); - canvas.drawRawAtlas(image, Float32List(0), Float32List(0), Int32List(0), BlendMode.src, rect, paint); - canvas.drawRawAtlas(image, Float32List(4), Float32List(4), Int32List(1), BlendMode.src, rect, paint); - canvas.drawRawAtlas(image, Float32List(4), Float32List(4), null, null, rect, paint); - - expectArgumentError(() => canvas.drawAtlas(image, [transform], [], [color], BlendMode.src, rect, paint)); - expectArgumentError(() => canvas.drawAtlas(image, [], [rect], [color], BlendMode.src, rect, paint)); - expectArgumentError(() => canvas.drawAtlas(image, [transform], [rect], [color, color], BlendMode.src, rect, paint)); - expectArgumentError(() => canvas.drawAtlas(image, [transform], [rect, rect], [color], BlendMode.src, rect, paint)); - expectArgumentError(() => canvas.drawAtlas(image, [transform, transform], [rect], [color], BlendMode.src, rect, paint)); - expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(3), Float32List(3), null, null, rect, paint)); - expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(4), Float32List(0), null, null, rect, paint)); - expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(0), Float32List(4), null, null, rect, paint)); - expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(4), Float32List(4), Int32List(2), BlendMode.src, rect, paint)); - }); - - test('Canvas preserves perspective data in Matrix4', () async { - const double rotateAroundX = pi / 6; // 30 degrees - const double rotateAroundY = pi / 9; // 20 degrees - const int width = 150; - const int height = 150; - const Color black = Color.fromARGB(255, 0, 0, 0); - const Color green = Color.fromARGB(255, 0, 255, 0); - void paint(Canvas canvas, CanvasCallback rotate) { - canvas.translate(width * 0.5, height * 0.5); - rotate(canvas); - const double width3 = width / 3.0; - const double width5 = width / 5.0; - const double width10 = width / 10.0; - canvas.drawRect(const Rect.fromLTRB(-width3, -width3, width3, width3), Paint()..color = green); - canvas.drawRect(const Rect.fromLTRB(-width5, -width5, -width10, width5), Paint()..color = black); - canvas.drawRect(const Rect.fromLTRB(-width5, -width5, width5, -width10), Paint()..color = black); - } - - final Image incrementalMatrixImage = await toImage((Canvas canvas) { - paint(canvas, (Canvas canvas) { - final Matrix4 matrix = Matrix4.identity(); - matrix.setEntry(3, 2, 0.001); - canvas.transform(matrix.storage); - matrix.setRotationX(rotateAroundX); - canvas.transform(matrix.storage); - matrix.setRotationY(rotateAroundY); - canvas.transform(matrix.storage); - }); - }, width, height); - final Image combinedMatrixImage = await toImage((Canvas canvas) { - paint(canvas, (Canvas canvas) { - final Matrix4 matrix = Matrix4.identity(); - matrix.setEntry(3, 2, 0.001); - matrix.rotateX(rotateAroundX); - matrix.rotateY(rotateAroundY); - canvas.transform(matrix.storage); - }); - }, width, height); - - final bool areEqual = await comparer.fuzzyCompareImages(incrementalMatrixImage, combinedMatrixImage); - - expect(areEqual, true); - }); - - test('Path effects from Paragraphs do not affect further rendering', () async { - void drawText(Canvas canvas, String content, Offset offset, - {TextDecorationStyle style = TextDecorationStyle.solid}) { - final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle()); - builder.pushStyle(TextStyle( - decoration: TextDecoration.underline, - decorationColor: const Color(0xFF0000FF), - fontFamily: 'Ahem', - fontSize: 10, - color: const Color(0xFF000000), - decorationStyle: style, - )); - builder.addText(content); - final Paragraph paragraph = builder.build(); - paragraph.layout(const ParagraphConstraints(width: 100)); - canvas.drawParagraph(paragraph, offset); - } - - final Image image = await toImage((Canvas canvas) { - canvas.drawColor(const Color(0xFFFFFFFF), BlendMode.srcOver); - final Paint paint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 5; - drawText(canvas, 'Hello World', const Offset(20, 10)); - canvas.drawCircle(const Offset(150, 25), 15, paint..color = const Color(0xFF00FF00)); - drawText(canvas, 'Regular text', const Offset(20, 60)); - canvas.drawCircle(const Offset(150, 75), 15, paint..color = const Color(0xFFFFFF00)); - drawText(canvas, 'Dotted text', const Offset(20, 110), style: TextDecorationStyle.dotted); - canvas.drawCircle(const Offset(150, 125), 15, paint..color = const Color(0xFFFF0000)); - drawText(canvas, 'Dashed text', const Offset(20, 160), style: TextDecorationStyle.dashed); - canvas.drawCircle(const Offset(150, 175), 15, paint..color = const Color(0xFFFF0000)); - drawText(canvas, 'Wavy text', const Offset(20, 210), style: TextDecorationStyle.wavy); - canvas.drawCircle(const Offset(150, 225), 15, paint..color = const Color(0xFFFF0000)); - }, 200, 250); - expect(image.width, equals(200)); - expect(image.height, equals(250)); - - await comparer.addGoldenImage(image, 'dotted_path_effect_mixed_with_stroked_geometry.png'); - }); - - test('Gradients with matrices in Paragraphs render correctly', () async { - final Image image = await toImage((Canvas canvas) { - final Paint p = Paint(); - final Float64List transform = Float64List.fromList([ - 86.80000129342079, - 0.0, - 0.0, - 0.0, - 0.0, - 94.5, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.0, - 60.0, - 224.310302734375, - 0.0, - 1.0 - ]); - p.shader = Gradient.radial( - const Offset(2.5, 0.33), - 0.8, - [ - const Color(0xffff0000), - const Color(0xff00ff00), - const Color(0xff0000ff), - const Color(0xffff00ff) - ], - [0.0, 0.3, 0.7, 0.9], - TileMode.mirror, - transform, - const Offset(2.55, 0.4)); - final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle()); - builder.pushStyle(TextStyle( - foreground: p, - fontSize: 200, - )); - builder.addText('Woodstock!'); - final Paragraph paragraph = builder.build(); - paragraph.layout(const ParagraphConstraints(width: 1000)); - canvas.drawParagraph(paragraph, const Offset(10, 150)); - }, 600, 400); - expect(image.width, equals(600)); - expect(image.height, equals(400)); - - await comparer.addGoldenImage(image, 'text_with_gradient_with_matrix.png'); - }); - - test('toImageSync - too big', () async { - PictureRecorder recorder = PictureRecorder(); - Canvas canvas = Canvas(recorder); - canvas.drawPaint(Paint()..color = const Color(0xFF123456)); - final Picture picture = recorder.endRecording(); - final Image image = picture.toImageSync(300000, 4000000); - picture.dispose(); - - expect(image.width, 300000); - expect(image.height, 4000000); - - recorder = PictureRecorder(); - canvas = Canvas(recorder); - - if (impellerEnabled) { - // Impeller tries to automagically scale this. See - // https://github.com/flutter/flutter/issues/128885 - canvas.drawImage(image, Offset.zero, Paint()); - return; - } - // On a slower CI machine, the raster thread may get behind the UI thread - // here. However, once the image is in an error state it will immediately - // throw on subsequent attempts. - bool caughtException = false; - for (int iterations = 0; iterations < 1000; iterations += 1) { - try { - canvas.drawImage(image, Offset.zero, Paint()); - } on PictureRasterizationException catch (e) { - caughtException = true; - expect( - e.message, - contains('unable to create bitmap render target at specified size ${image.width}x${image.height}'), - ); - break; - } - // Let the event loop turn. - await Future.delayed(const Duration(milliseconds: 1)); - } - expect(caughtException, true); - expect( - () => canvas.drawImageRect(image, Rect.zero, Rect.zero, Paint()), - throwsException, - ); - expect( - () => canvas.drawImageNine(image, Rect.zero, Rect.zero, Paint()), - throwsException, - ); - expect( - () => canvas.drawAtlas(image, [], [], null, null, null, Paint()), - throwsException, - ); - }); - - test('toImageSync - succeeds', () async { - PictureRecorder recorder = PictureRecorder(); - Canvas canvas = Canvas(recorder); - canvas.drawPaint(Paint()..color = const Color(0xFF123456)); - final Picture picture = recorder.endRecording(); - final Image image = picture.toImageSync(30, 40); - picture.dispose(); - - expect(image.width, 30); - expect(image.height, 40); - - recorder = PictureRecorder(); - canvas = Canvas(recorder); - expect( - () => canvas.drawImage(image, Offset.zero, Paint()), - returnsNormally, - ); - expect( - () => canvas.drawImageRect(image, Rect.zero, Rect.zero, Paint()), - returnsNormally, - ); - expect( - () => canvas.drawImageNine(image, Rect.zero, Rect.zero, Paint()), - returnsNormally, - ); - expect( - () => canvas.drawAtlas(image, [], [], null, null, null, Paint()), - returnsNormally, - ); - }); - - test('toImageSync - toByteData', () async { - const Color color = Color(0xFF123456); - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - canvas.drawPaint(Paint()..color = color); - final Picture picture = recorder.endRecording(); - final Image image = picture.toImageSync(6, 8); - picture.dispose(); - - expect(image.width, 6); - expect(image.height, 8); - - final ByteData? data = await image.toByteData(); - - expect(data, isNotNull); - expect(data!.lengthInBytes, 6 * 8 * 4); - expect(data.buffer.asUint8List()[0], 0x12); - expect(data.buffer.asUint8List()[1], 0x34); - expect(data.buffer.asUint8List()[2], 0x56); - expect(data.buffer.asUint8List()[3], 0xFF); - }); - - test('toImage and toImageSync have identical contents', () async { - // Note: on linux this stil seems to be different. - // TODO(jonahwilliams): https://github.com/flutter/flutter/issues/108835 - if (Platform.isLinux) { - return; - } - - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - canvas.drawRect( - const Rect.fromLTWH(20, 20, 100, 100), - Paint()..color = const Color(0xA0FF6D00), - ); - final Picture picture = recorder.endRecording(); - final Image toImageImage = await picture.toImage(200, 200); - final Image toImageSyncImage = picture.toImageSync(200, 200); - - // To trigger observable difference in alpha, draw image - // on a second canvas. - Future drawOnCanvas(Image image) async { - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - canvas.drawPaint(Paint()..color = const Color(0x4FFFFFFF)); - canvas.drawImage(image, Offset.zero, Paint()); - final Image resultImage = await recorder.endRecording().toImage(200, 200); - return (await resultImage.toByteData())!; - } - - final ByteData dataSync = await drawOnCanvas(toImageImage); - final ByteData data = await drawOnCanvas(toImageSyncImage); - expect(data, listEquals(dataSync)); - }); - - test('Canvas.drawParagraph throws when Paragraph.layout was not called', () async { - // Regression test for https://github.com/flutter/flutter/issues/97172 - bool assertsEnabled = false; - assert(() { - assertsEnabled = true; - return true; - }()); - - Object? error; - try { - await toImage((Canvas canvas) { - final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle()); - builder.addText('Woodstock!'); - final Paragraph woodstock = builder.build(); - canvas.drawParagraph(woodstock, const Offset(0, 50)); - }, 100, 100); - } catch (e) { - error = e; - } - if (assertsEnabled) { - expect(error, isNotNull); - } else { - expect(error, isNull); - } - }); - - Future drawText(String text) { - return toImage((Canvas canvas) { - final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle( - fontFamily: 'RobotoSerif', - fontStyle: FontStyle.normal, - fontWeight: FontWeight.normal, - fontSize: 15.0, - )); - builder.pushStyle(TextStyle(color: const Color(0xFF0000FF))); - builder.addText(text); - - final Paragraph paragraph = builder.build(); - paragraph.layout(const ParagraphConstraints(width: 20 * 5.0)); - - canvas.drawParagraph(paragraph, Offset.zero); - }, 100, 100); - } - - test('Canvas.drawParagraph renders tab as space instead of tofu', () async { - // Skia renders a tofu if the font does not have a glyph for a character. - // However, Flutter opts-in to a Skia feature to render tabs as a single space. - // See: https://github.com/flutter/flutter/issues/79153 - final File file = File(path.join('flutter', 'testing', 'resources', 'RobotoSlab-VariableFont_wght.ttf')); - final Uint8List fontData = await file.readAsBytes(); - await loadFontFromList(fontData, fontFamily: 'RobotoSerif'); - - // The backspace character, \b, does not have a corresponding glyph and is rendered as a tofu. - final Image tabImage = await drawText('>\t<'); - final Image spaceImage = await drawText('> <'); - final Image tofuImage = await drawText('>\b<'); - - // The tab's image should be identical to the space's image but not the tofu's image. - final bool tabToSpaceComparison = await comparer.fuzzyCompareImages(tabImage, spaceImage); - final bool tabToTofuComparison = await comparer.fuzzyCompareImages(tabImage, tofuImage); - - expect(tabToSpaceComparison, isTrue); - expect(tabToTofuComparison, isFalse); - }); - - Matcher closeToTransform(Float64List expected) => (dynamic v) { - Expect.type(v); - final Float64List value = v as Float64List; - expect(expected.length, equals(16)); - expect(value.length, equals(16)); - for (int r = 0; r < 4; r++) { - for (int c = 0; c < 4; c++) { - final double vActual = value[r*4 + c]; - final double vExpected = expected[r*4 + c]; - if ((vActual - vExpected).abs() > 1e-10) { - Expect.fail('matrix mismatch at $r, $c, $vActual not close to $vExpected'); - } - } - } - }; - - Matcher notCloseToTransform(Float64List expected) => (dynamic v) { - Expect.type(v); - final Float64List value = v as Float64List; - expect(expected.length, equals(16)); - expect(value.length, equals(16)); - for (int r = 0; r < 4; r++) { - for (int c = 0; c < 4; c++) { - final double vActual = value[r*4 + c]; - final double vExpected = expected[r*4 + c]; - if ((vActual - vExpected).abs() > 1e-10) { - return; - } - } - } - Expect.fail('$value is too close to $expected'); - }; - - test('Canvas.translate affects canvas.getTransform', () async { - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - canvas.translate(12, 14.5); - final Float64List matrix = Matrix4.translationValues(12, 14.5, 0).storage; - final Float64List curMatrix = canvas.getTransform(); - expect(curMatrix, closeToTransform(matrix)); - canvas.translate(10, 10); - final Float64List newCurMatrix = canvas.getTransform(); - expect(newCurMatrix, notCloseToTransform(matrix)); - expect(curMatrix, closeToTransform(matrix)); - }); - - test('Canvas.scale affects canvas.getTransform', () async { - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - canvas.scale(12, 14.5); - final Float64List matrix = Matrix4.diagonal3Values(12, 14.5, 1).storage; - final Float64List curMatrix = canvas.getTransform(); - expect(curMatrix, closeToTransform(matrix)); - canvas.scale(10, 10); - final Float64List newCurMatrix = canvas.getTransform(); - expect(newCurMatrix, notCloseToTransform(matrix)); - expect(curMatrix, closeToTransform(matrix)); - }); - - test('Canvas.rotate affects canvas.getTransform', () async { - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - canvas.rotate(pi); - final Float64List matrix = Matrix4.rotationZ(pi).storage; - final Float64List curMatrix = canvas.getTransform(); - expect(curMatrix, closeToTransform(matrix)); - canvas.rotate(pi / 2); - final Float64List newCurMatrix = canvas.getTransform(); - expect(newCurMatrix, notCloseToTransform(matrix)); - expect(curMatrix, closeToTransform(matrix)); - }); - - test('Canvas.skew affects canvas.getTransform', () async { - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - canvas.skew(12, 14.5); - final Float64List matrix = (Matrix4.identity()..setEntry(0, 1, 12)..setEntry(1, 0, 14.5)).storage; - final Float64List curMatrix = canvas.getTransform(); - expect(curMatrix, closeToTransform(matrix)); - canvas.skew(10, 10); - final Float64List newCurMatrix = canvas.getTransform(); - expect(newCurMatrix, notCloseToTransform(matrix)); - expect(curMatrix, closeToTransform(matrix)); - }); - - test('Canvas.transform affects canvas.getTransform', () async { - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - final Float64List matrix = (Matrix4.identity()..translate(12.0, 14.5)..scale(12.0, 14.5)).storage; - canvas.transform(matrix); - final Float64List curMatrix = canvas.getTransform(); - expect(curMatrix, closeToTransform(matrix)); - canvas.translate(10, 10); - final Float64List newCurMatrix = canvas.getTransform(); - expect(newCurMatrix, notCloseToTransform(matrix)); - expect(curMatrix, closeToTransform(matrix)); - }); - - Matcher closeToRect(Rect expected) => (dynamic v) { - Expect.type(v); - final Rect value = v as Rect; - expect(value.left, closeTo(expected.left, 1e-6)); - expect(value.top, closeTo(expected.top, 1e-6)); - expect(value.right, closeTo(expected.right, 1e-6)); - expect(value.bottom, closeTo(expected.bottom, 1e-6)); - }; - - Matcher notCloseToRect(Rect expected) => (dynamic v) { - Expect.type(v); - final Rect value = v as Rect; - if ((value.left - expected.left).abs() > 1e-6 || - (value.top - expected.top).abs() > 1e-6 || - (value.right - expected.right).abs() > 1e-6 || - (value.bottom - expected.bottom).abs() > 1e-6) { - return; - } - Expect.fail('$value is too close to $expected'); - }; - - test('Canvas.clipRect(doAA=true) affects canvas.getClipBounds', () async { - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); - const Rect clipExpandedBounds = Rect.fromLTRB(10, 11, 21, 26); - canvas.clipRect(clipBounds); - - // Save initial return values for testing restored values - final Rect initialLocalBounds = canvas.getLocalClipBounds(); - final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); - expect(initialLocalBounds, closeToRect(clipExpandedBounds)); - expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); - - canvas.save(); - canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15)); - // Both clip bounds have changed - expect(canvas.getLocalClipBounds(), notCloseToRect(clipExpandedBounds)); - expect(canvas.getDestinationClipBounds(), notCloseToRect(clipExpandedBounds)); - // Previous return values have not changed - expect(initialLocalBounds, closeToRect(clipExpandedBounds)); - expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); - canvas.restore(); - - // save/restore returned the values to their original values - expect(canvas.getLocalClipBounds(), initialLocalBounds); - expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - - canvas.save(); - canvas.scale(2, 2); - const Rect scaledExpandedBounds = Rect.fromLTRB(5, 5.5, 10.5, 13); - expect(canvas.getLocalClipBounds(), closeToRect(scaledExpandedBounds)); - // Destination bounds are unaffected by transform - expect(canvas.getDestinationClipBounds(), closeToRect(clipExpandedBounds)); - canvas.restore(); - - // save/restore returned the values to their original values - expect(canvas.getLocalClipBounds(), initialLocalBounds); - expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - }); - - test('Canvas.clipRect(doAA=false) affects canvas.getClipBounds', () async { - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); - canvas.clipRect(clipBounds, doAntiAlias: false); - - // Save initial return values for testing restored values - final Rect initialLocalBounds = canvas.getLocalClipBounds(); - final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); - expect(initialLocalBounds, closeToRect(clipBounds)); - expect(initialDestinationBounds, closeToRect(clipBounds)); - - canvas.save(); - canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15)); - // Both clip bounds have changed - expect(canvas.getLocalClipBounds(), notCloseToRect(clipBounds)); - expect(canvas.getDestinationClipBounds(), notCloseToRect(clipBounds)); - // Previous return values have not changed - expect(initialLocalBounds, closeToRect(clipBounds)); - expect(initialDestinationBounds, closeToRect(clipBounds)); - canvas.restore(); - - // save/restore returned the values to their original values - expect(canvas.getLocalClipBounds(), initialLocalBounds); - expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - - canvas.save(); - canvas.scale(2, 2); - const Rect scaledClipBounds = Rect.fromLTRB(5.1, 5.65, 10.2, 12.85); - expect(canvas.getLocalClipBounds(), closeToRect(scaledClipBounds)); - // Destination bounds are unaffected by transform - expect(canvas.getDestinationClipBounds(), closeToRect(clipBounds)); - canvas.restore(); - - // save/restore returned the values to their original values - expect(canvas.getLocalClipBounds(), initialLocalBounds); - expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - }); - - test('Canvas.clipRect with matrix affects canvas.getClipBounds', () async { - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - const Rect clipBounds1 = Rect.fromLTRB(0.0, 0.0, 10.0, 10.0); - const Rect clipBounds2 = Rect.fromLTRB(10.0, 10.0, 20.0, 20.0); - - canvas.save(); - canvas.clipRect(clipBounds1, doAntiAlias: false); - canvas.translate(0, 10.0); - canvas.clipRect(clipBounds1, doAntiAlias: false); - expect(canvas.getDestinationClipBounds().isEmpty, isTrue); - canvas.restore(); - - canvas.save(); - canvas.clipRect(clipBounds1, doAntiAlias: false); - canvas.translate(-10.0, -10.0); - canvas.clipRect(clipBounds2, doAntiAlias: false); - expect(canvas.getDestinationClipBounds(), clipBounds1); - canvas.restore(); - }); - - test('Canvas.clipRRect(doAA=true) affects canvas.getClipBounds', () async { - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); - const Rect clipExpandedBounds = Rect.fromLTRB(10, 11, 21, 26); - final RRect clip = RRect.fromRectAndRadius(clipBounds, const Radius.circular(3)); - canvas.clipRRect(clip); - - // Save initial return values for testing restored values - final Rect initialLocalBounds = canvas.getLocalClipBounds(); - final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); - expect(initialLocalBounds, closeToRect(clipExpandedBounds)); - expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); - - canvas.save(); - canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15)); - // Both clip bounds have changed - expect(canvas.getLocalClipBounds(), notCloseToRect(clipExpandedBounds)); - expect(canvas.getDestinationClipBounds(), notCloseToRect(clipExpandedBounds)); - // Previous return values have not changed - expect(initialLocalBounds, closeToRect(clipExpandedBounds)); - expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); - canvas.restore(); - - // save/restore returned the values to their original values - expect(canvas.getLocalClipBounds(), initialLocalBounds); - expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - - canvas.save(); - canvas.scale(2, 2); - const Rect scaledExpandedBounds = Rect.fromLTRB(5, 5.5, 10.5, 13); - expect(canvas.getLocalClipBounds(), closeToRect(scaledExpandedBounds)); - // Destination bounds are unaffected by transform - expect(canvas.getDestinationClipBounds(), closeToRect(clipExpandedBounds)); - canvas.restore(); - - // save/restore returned the values to their original values - expect(canvas.getLocalClipBounds(), initialLocalBounds); - expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - }); - - test('Canvas.clipRRect(doAA=false) affects canvas.getClipBounds', () async { - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); - final RRect clip = RRect.fromRectAndRadius(clipBounds, const Radius.circular(3)); - canvas.clipRRect(clip, doAntiAlias: false); - - // Save initial return values for testing restored values - final Rect initialLocalBounds = canvas.getLocalClipBounds(); - final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); - expect(initialLocalBounds, closeToRect(clipBounds)); - expect(initialDestinationBounds, closeToRect(clipBounds)); - - canvas.save(); - canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15), doAntiAlias: false); - // Both clip bounds have changed - expect(canvas.getLocalClipBounds(), notCloseToRect(clipBounds)); - expect(canvas.getDestinationClipBounds(), notCloseToRect(clipBounds)); - // Previous return values have not changed - expect(initialLocalBounds, closeToRect(clipBounds)); - expect(initialDestinationBounds, closeToRect(clipBounds)); - canvas.restore(); - - // save/restore returned the values to their original values - expect(canvas.getLocalClipBounds(), initialLocalBounds); - expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - - canvas.save(); - canvas.scale(2, 2); - const Rect scaledClipBounds = Rect.fromLTRB(5.1, 5.65, 10.2, 12.85); - expect(canvas.getLocalClipBounds(), closeToRect(scaledClipBounds)); - // Destination bounds are unaffected by transform - expect(canvas.getDestinationClipBounds(), closeToRect(clipBounds)); - canvas.restore(); - - // save/restore returned the values to their original values - expect(canvas.getLocalClipBounds(), initialLocalBounds); - expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - }); - - test('Canvas.clipRRect with matrix affects canvas.getClipBounds', () async { - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - const Rect clipBounds1 = Rect.fromLTRB(0.0, 0.0, 10.0, 10.0); - const Rect clipBounds2 = Rect.fromLTRB(10.0, 10.0, 20.0, 20.0); - final RRect clip1 = RRect.fromRectAndRadius(clipBounds1, const Radius.circular(3)); - final RRect clip2 = RRect.fromRectAndRadius(clipBounds2, const Radius.circular(3)); - - canvas.save(); - canvas.clipRRect(clip1, doAntiAlias: false); - canvas.translate(0, 10.0); - canvas.clipRRect(clip1, doAntiAlias: false); - expect(canvas.getDestinationClipBounds().isEmpty, isTrue); - canvas.restore(); - - canvas.save(); - canvas.clipRRect(clip1, doAntiAlias: false); - canvas.translate(-10.0, -10.0); - canvas.clipRRect(clip2, doAntiAlias: false); - expect(canvas.getDestinationClipBounds(), clipBounds1); - canvas.restore(); - }); - - test('Canvas.clipPath(doAA=true) affects canvas.getClipBounds', () async { - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); - const Rect clipExpandedBounds = Rect.fromLTRB(10, 11, 21, 26); - final Path clip = Path()..addRect(clipBounds)..addOval(clipBounds); - canvas.clipPath(clip); - - // Save initial return values for testing restored values - final Rect initialLocalBounds = canvas.getLocalClipBounds(); - final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); - expect(initialLocalBounds, closeToRect(clipExpandedBounds)); - expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); - - canvas.save(); - canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15)); - // Both clip bounds have changed - expect(canvas.getLocalClipBounds(), notCloseToRect(clipExpandedBounds)); - expect(canvas.getDestinationClipBounds(), notCloseToRect(clipExpandedBounds)); - // Previous return values have not changed - expect(initialLocalBounds, closeToRect(clipExpandedBounds)); - expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); - canvas.restore(); - - // save/restore returned the values to their original values - expect(canvas.getLocalClipBounds(), initialLocalBounds); - expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - - canvas.save(); - canvas.scale(2, 2); - const Rect scaledExpandedBounds = Rect.fromLTRB(5, 5.5, 10.5, 13); - expect(canvas.getLocalClipBounds(), closeToRect(scaledExpandedBounds)); - // Destination bounds are unaffected by transform - expect(canvas.getDestinationClipBounds(), closeToRect(clipExpandedBounds)); - canvas.restore(); - - // save/restore returned the values to their original values - expect(canvas.getLocalClipBounds(), initialLocalBounds); - expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - }); - - test('Canvas.clipPath(doAA=false) affects canvas.getClipBounds', () async { - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); - final Path clip = Path()..addRect(clipBounds)..addOval(clipBounds); - canvas.clipPath(clip, doAntiAlias: false); - - // Save initial return values for testing restored values - final Rect initialLocalBounds = canvas.getLocalClipBounds(); - final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); - expect(initialLocalBounds, closeToRect(clipBounds)); - expect(initialDestinationBounds, closeToRect(clipBounds)); - - canvas.save(); - canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15), doAntiAlias: false); - // Both clip bounds have changed - expect(canvas.getLocalClipBounds(), notCloseToRect(clipBounds)); - expect(canvas.getDestinationClipBounds(), notCloseToRect(clipBounds)); - // Previous return values have not changed - expect(initialLocalBounds, closeToRect(clipBounds)); - expect(initialDestinationBounds, closeToRect(clipBounds)); - canvas.restore(); - - // save/restore returned the values to their original values - expect(canvas.getLocalClipBounds(), initialLocalBounds); - expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - - canvas.save(); - canvas.scale(2, 2); - const Rect scaledClipBounds = Rect.fromLTRB(5.1, 5.65, 10.2, 12.85); - expect(canvas.getLocalClipBounds(), closeToRect(scaledClipBounds)); - // Destination bounds are unaffected by transform - expect(canvas.getDestinationClipBounds(), closeToRect(clipBounds)); - canvas.restore(); - - // save/restore returned the values to their original values - expect(canvas.getLocalClipBounds(), initialLocalBounds); - expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - }); - - test('Canvas.clipPath with matrix affects canvas.getClipBounds', () async { - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - const Rect clipBounds1 = Rect.fromLTRB(0.0, 0.0, 10.0, 10.0); - const Rect clipBounds2 = Rect.fromLTRB(10.0, 10.0, 20.0, 20.0); - final Path clip1 = Path()..addRect(clipBounds1)..addOval(clipBounds1); - final Path clip2 = Path()..addRect(clipBounds2)..addOval(clipBounds2); - - canvas.save(); - canvas.clipPath(clip1, doAntiAlias: false); - canvas.translate(0, 10.0); - canvas.clipPath(clip1, doAntiAlias: false); - expect(canvas.getDestinationClipBounds().isEmpty, isTrue); - canvas.restore(); - - canvas.save(); - canvas.clipPath(clip1, doAntiAlias: false); - canvas.translate(-10.0, -10.0); - canvas.clipPath(clip2, doAntiAlias: false); - expect(canvas.getDestinationClipBounds(), clipBounds1); - canvas.restore(); - }); - - test('Canvas.clipRect(diff) does not affect canvas.getClipBounds', () async { - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); - canvas.clipRect(clipBounds, doAntiAlias: false); - - // Save initial return values for testing restored values - final Rect initialLocalBounds = canvas.getLocalClipBounds(); - final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); - expect(initialLocalBounds, closeToRect(clipBounds)); - expect(initialDestinationBounds, closeToRect(clipBounds)); - - canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15), clipOp: ClipOp.difference, doAntiAlias: false); - expect(canvas.getLocalClipBounds(), initialLocalBounds); - expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - }); - - test('RestoreToCount can work', () async { - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - canvas.save(); - canvas.save(); - canvas.save(); - canvas.save(); - canvas.save(); - expect(canvas.getSaveCount(), equals(6)); - canvas.restoreToCount(2); - expect(canvas.getSaveCount(), equals(2)); - canvas.restore(); - expect(canvas.getSaveCount(), equals(1)); - }); - - test('RestoreToCount count less than 1, the stack should be reset', () async { - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - canvas.save(); - canvas.save(); - canvas.save(); - canvas.save(); - canvas.save(); - expect(canvas.getSaveCount(), equals(6)); - canvas.restoreToCount(0); - expect(canvas.getSaveCount(), equals(1)); - }); - - test('RestoreToCount count greater than current [getSaveCount], nothing would happend', () async { - final PictureRecorder recorder = PictureRecorder(); - final Canvas canvas = Canvas(recorder); - canvas.save(); - canvas.save(); - canvas.save(); - canvas.save(); - canvas.save(); - expect(canvas.getSaveCount(), equals(6)); - canvas.restoreToCount(canvas.getSaveCount() + 1); - expect(canvas.getSaveCount(), equals(6)); - }); - - await null; - final command = [ - Platform.environment['GOLDCTL']!, - 'imgtest', - 'finalize', - ]; - print(command.join(' ')); - final result = await LocalProcessManager().run(command); - print(result); - print(result.exitCode); - print(result.stdout); - print(result.stderr); + // }); + + // Gradient makeGradient() { + // return Gradient.linear( + // Offset.zero, + // const Offset(100, 100), + // const [Color(0xFF4C4D52), Color(0xFF202124)], + // ); + // } + + // test('Simple gradient, which is implicitly dithered', () async { + // final Image image = await toImage((Canvas canvas) { + // final Paint paint = Paint()..shader = makeGradient(); + // canvas.drawPaint(paint); + // }, 100, 100); + // expect(image.width, equals(100)); + // expect(image.height, equals(100)); + + // await comparer.addGoldenImage(image, 'canvas_test_dithered_gradient.png'); + // }); + + // test('Null values allowed for drawAtlas methods', () async { + // final Image image = await createImage(100, 100); + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // const Rect rect = Rect.fromLTWH(0, 0, 100, 100); + // final RSTransform transform = RSTransform(1, 0, 0, 0); + // const Color color = Color(0x00000000); + // final Paint paint = Paint(); + // canvas.drawAtlas(image, [transform], [rect], [color], BlendMode.src, rect, paint); + // canvas.drawAtlas(image, [transform], [rect], [color], BlendMode.src, null, paint); + // canvas.drawAtlas(image, [transform], [rect], [], null, rect, paint); + // canvas.drawAtlas(image, [transform], [rect], null, null, rect, paint); + // canvas.drawRawAtlas(image, Float32List(0), Float32List(0), Int32List(0), BlendMode.src, rect, paint); + // canvas.drawRawAtlas(image, Float32List(0), Float32List(0), Int32List(0), BlendMode.src, null, paint); + // canvas.drawRawAtlas(image, Float32List(0), Float32List(0), null, null, rect, paint); + + // expectAssertion(() => canvas.drawAtlas(image, [transform], [rect], [color], null, rect, paint)); + // }); + + // test('Data lengths must match for drawAtlas methods', () async { + // final Image image = await createImage(100, 100); + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // const Rect rect = Rect.fromLTWH(0, 0, 100, 100); + // final RSTransform transform = RSTransform(1, 0, 0, 0); + // const Color color = Color(0x00000000); + // final Paint paint = Paint(); + // canvas.drawAtlas(image, [transform], [rect], [color], BlendMode.src, rect, paint); + // canvas.drawAtlas(image, [transform, transform], [rect, rect], [color, color], BlendMode.src, rect, paint); + // canvas.drawAtlas(image, [transform], [rect], [], null, rect, paint); + // canvas.drawAtlas(image, [transform], [rect], null, null, rect, paint); + // canvas.drawRawAtlas(image, Float32List(0), Float32List(0), Int32List(0), BlendMode.src, rect, paint); + // canvas.drawRawAtlas(image, Float32List(4), Float32List(4), Int32List(1), BlendMode.src, rect, paint); + // canvas.drawRawAtlas(image, Float32List(4), Float32List(4), null, null, rect, paint); + + // expectArgumentError(() => canvas.drawAtlas(image, [transform], [], [color], BlendMode.src, rect, paint)); + // expectArgumentError(() => canvas.drawAtlas(image, [], [rect], [color], BlendMode.src, rect, paint)); + // expectArgumentError(() => canvas.drawAtlas(image, [transform], [rect], [color, color], BlendMode.src, rect, paint)); + // expectArgumentError(() => canvas.drawAtlas(image, [transform], [rect, rect], [color], BlendMode.src, rect, paint)); + // expectArgumentError(() => canvas.drawAtlas(image, [transform, transform], [rect], [color], BlendMode.src, rect, paint)); + // expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(3), Float32List(3), null, null, rect, paint)); + // expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(4), Float32List(0), null, null, rect, paint)); + // expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(0), Float32List(4), null, null, rect, paint)); + // expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(4), Float32List(4), Int32List(2), BlendMode.src, rect, paint)); + // }); + + // test('Canvas preserves perspective data in Matrix4', () async { + // const double rotateAroundX = pi / 6; // 30 degrees + // const double rotateAroundY = pi / 9; // 20 degrees + // const int width = 150; + // const int height = 150; + // const Color black = Color.fromARGB(255, 0, 0, 0); + // const Color green = Color.fromARGB(255, 0, 255, 0); + // void paint(Canvas canvas, CanvasCallback rotate) { + // canvas.translate(width * 0.5, height * 0.5); + // rotate(canvas); + // const double width3 = width / 3.0; + // const double width5 = width / 5.0; + // const double width10 = width / 10.0; + // canvas.drawRect(const Rect.fromLTRB(-width3, -width3, width3, width3), Paint()..color = green); + // canvas.drawRect(const Rect.fromLTRB(-width5, -width5, -width10, width5), Paint()..color = black); + // canvas.drawRect(const Rect.fromLTRB(-width5, -width5, width5, -width10), Paint()..color = black); + // } + + // final Image incrementalMatrixImage = await toImage((Canvas canvas) { + // paint(canvas, (Canvas canvas) { + // final Matrix4 matrix = Matrix4.identity(); + // matrix.setEntry(3, 2, 0.001); + // canvas.transform(matrix.storage); + // matrix.setRotationX(rotateAroundX); + // canvas.transform(matrix.storage); + // matrix.setRotationY(rotateAroundY); + // canvas.transform(matrix.storage); + // }); + // }, width, height); + // final Image combinedMatrixImage = await toImage((Canvas canvas) { + // paint(canvas, (Canvas canvas) { + // final Matrix4 matrix = Matrix4.identity(); + // matrix.setEntry(3, 2, 0.001); + // matrix.rotateX(rotateAroundX); + // matrix.rotateY(rotateAroundY); + // canvas.transform(matrix.storage); + // }); + // }, width, height); + + // final bool areEqual = await comparer.fuzzyCompareImages(incrementalMatrixImage, combinedMatrixImage); + + // expect(areEqual, true); + // }); + + // test('Path effects from Paragraphs do not affect further rendering', () async { + // void drawText(Canvas canvas, String content, Offset offset, + // {TextDecorationStyle style = TextDecorationStyle.solid}) { + // final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle()); + // builder.pushStyle(TextStyle( + // decoration: TextDecoration.underline, + // decorationColor: const Color(0xFF0000FF), + // fontFamily: 'Ahem', + // fontSize: 10, + // color: const Color(0xFF000000), + // decorationStyle: style, + // )); + // builder.addText(content); + // final Paragraph paragraph = builder.build(); + // paragraph.layout(const ParagraphConstraints(width: 100)); + // canvas.drawParagraph(paragraph, offset); + // } + + // final Image image = await toImage((Canvas canvas) { + // canvas.drawColor(const Color(0xFFFFFFFF), BlendMode.srcOver); + // final Paint paint = Paint() + // ..style = PaintingStyle.stroke + // ..strokeWidth = 5; + // drawText(canvas, 'Hello World', const Offset(20, 10)); + // canvas.drawCircle(const Offset(150, 25), 15, paint..color = const Color(0xFF00FF00)); + // drawText(canvas, 'Regular text', const Offset(20, 60)); + // canvas.drawCircle(const Offset(150, 75), 15, paint..color = const Color(0xFFFFFF00)); + // drawText(canvas, 'Dotted text', const Offset(20, 110), style: TextDecorationStyle.dotted); + // canvas.drawCircle(const Offset(150, 125), 15, paint..color = const Color(0xFFFF0000)); + // drawText(canvas, 'Dashed text', const Offset(20, 160), style: TextDecorationStyle.dashed); + // canvas.drawCircle(const Offset(150, 175), 15, paint..color = const Color(0xFFFF0000)); + // drawText(canvas, 'Wavy text', const Offset(20, 210), style: TextDecorationStyle.wavy); + // canvas.drawCircle(const Offset(150, 225), 15, paint..color = const Color(0xFFFF0000)); + // }, 200, 250); + // expect(image.width, equals(200)); + // expect(image.height, equals(250)); + + // await comparer.addGoldenImage(image, 'dotted_path_effect_mixed_with_stroked_geometry.png'); + // }); + + // test('Gradients with matrices in Paragraphs render correctly', () async { + // final Image image = await toImage((Canvas canvas) { + // final Paint p = Paint(); + // final Float64List transform = Float64List.fromList([ + // 86.80000129342079, + // 0.0, + // 0.0, + // 0.0, + // 0.0, + // 94.5, + // 0.0, + // 0.0, + // 0.0, + // 0.0, + // 1.0, + // 0.0, + // 60.0, + // 224.310302734375, + // 0.0, + // 1.0 + // ]); + // p.shader = Gradient.radial( + // const Offset(2.5, 0.33), + // 0.8, + // [ + // const Color(0xffff0000), + // const Color(0xff00ff00), + // const Color(0xff0000ff), + // const Color(0xffff00ff) + // ], + // [0.0, 0.3, 0.7, 0.9], + // TileMode.mirror, + // transform, + // const Offset(2.55, 0.4)); + // final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle()); + // builder.pushStyle(TextStyle( + // foreground: p, + // fontSize: 200, + // )); + // builder.addText('Woodstock!'); + // final Paragraph paragraph = builder.build(); + // paragraph.layout(const ParagraphConstraints(width: 1000)); + // canvas.drawParagraph(paragraph, const Offset(10, 150)); + // }, 600, 400); + // expect(image.width, equals(600)); + // expect(image.height, equals(400)); + + // await comparer.addGoldenImage(image, 'text_with_gradient_with_matrix.png'); + // }); + + // test('toImageSync - too big', () async { + // PictureRecorder recorder = PictureRecorder(); + // Canvas canvas = Canvas(recorder); + // canvas.drawPaint(Paint()..color = const Color(0xFF123456)); + // final Picture picture = recorder.endRecording(); + // final Image image = picture.toImageSync(300000, 4000000); + // picture.dispose(); + + // expect(image.width, 300000); + // expect(image.height, 4000000); + + // recorder = PictureRecorder(); + // canvas = Canvas(recorder); + + // if (impellerEnabled) { + // // Impeller tries to automagically scale this. See + // // https://github.com/flutter/flutter/issues/128885 + // canvas.drawImage(image, Offset.zero, Paint()); + // return; + // } + // // On a slower CI machine, the raster thread may get behind the UI thread + // // here. However, once the image is in an error state it will immediately + // // throw on subsequent attempts. + // bool caughtException = false; + // for (int iterations = 0; iterations < 1000; iterations += 1) { + // try { + // canvas.drawImage(image, Offset.zero, Paint()); + // } on PictureRasterizationException catch (e) { + // caughtException = true; + // expect( + // e.message, + // contains('unable to create bitmap render target at specified size ${image.width}x${image.height}'), + // ); + // break; + // } + // // Let the event loop turn. + // await Future.delayed(const Duration(milliseconds: 1)); + // } + // expect(caughtException, true); + // expect( + // () => canvas.drawImageRect(image, Rect.zero, Rect.zero, Paint()), + // throwsException, + // ); + // expect( + // () => canvas.drawImageNine(image, Rect.zero, Rect.zero, Paint()), + // throwsException, + // ); + // expect( + // () => canvas.drawAtlas(image, [], [], null, null, null, Paint()), + // throwsException, + // ); + // }); + + // test('toImageSync - succeeds', () async { + // PictureRecorder recorder = PictureRecorder(); + // Canvas canvas = Canvas(recorder); + // canvas.drawPaint(Paint()..color = const Color(0xFF123456)); + // final Picture picture = recorder.endRecording(); + // final Image image = picture.toImageSync(30, 40); + // picture.dispose(); + + // expect(image.width, 30); + // expect(image.height, 40); + + // recorder = PictureRecorder(); + // canvas = Canvas(recorder); + // expect( + // () => canvas.drawImage(image, Offset.zero, Paint()), + // returnsNormally, + // ); + // expect( + // () => canvas.drawImageRect(image, Rect.zero, Rect.zero, Paint()), + // returnsNormally, + // ); + // expect( + // () => canvas.drawImageNine(image, Rect.zero, Rect.zero, Paint()), + // returnsNormally, + // ); + // expect( + // () => canvas.drawAtlas(image, [], [], null, null, null, Paint()), + // returnsNormally, + // ); + // }); + + // test('toImageSync - toByteData', () async { + // const Color color = Color(0xFF123456); + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // canvas.drawPaint(Paint()..color = color); + // final Picture picture = recorder.endRecording(); + // final Image image = picture.toImageSync(6, 8); + // picture.dispose(); + + // expect(image.width, 6); + // expect(image.height, 8); + + // final ByteData? data = await image.toByteData(); + + // expect(data, isNotNull); + // expect(data!.lengthInBytes, 6 * 8 * 4); + // expect(data.buffer.asUint8List()[0], 0x12); + // expect(data.buffer.asUint8List()[1], 0x34); + // expect(data.buffer.asUint8List()[2], 0x56); + // expect(data.buffer.asUint8List()[3], 0xFF); + // }); + + // test('toImage and toImageSync have identical contents', () async { + // // Note: on linux this stil seems to be different. + // // TODO(jonahwilliams): https://github.com/flutter/flutter/issues/108835 + // if (Platform.isLinux) { + // return; + // } + + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // canvas.drawRect( + // const Rect.fromLTWH(20, 20, 100, 100), + // Paint()..color = const Color(0xA0FF6D00), + // ); + // final Picture picture = recorder.endRecording(); + // final Image toImageImage = await picture.toImage(200, 200); + // final Image toImageSyncImage = picture.toImageSync(200, 200); + + // // To trigger observable difference in alpha, draw image + // // on a second canvas. + // Future drawOnCanvas(Image image) async { + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // canvas.drawPaint(Paint()..color = const Color(0x4FFFFFFF)); + // canvas.drawImage(image, Offset.zero, Paint()); + // final Image resultImage = await recorder.endRecording().toImage(200, 200); + // return (await resultImage.toByteData())!; + // } + + // final ByteData dataSync = await drawOnCanvas(toImageImage); + // final ByteData data = await drawOnCanvas(toImageSyncImage); + // expect(data, listEquals(dataSync)); + // }); + + // test('Canvas.drawParagraph throws when Paragraph.layout was not called', () async { + // // Regression test for https://github.com/flutter/flutter/issues/97172 + // bool assertsEnabled = false; + // assert(() { + // assertsEnabled = true; + // return true; + // }()); + + // Object? error; + // try { + // await toImage((Canvas canvas) { + // final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle()); + // builder.addText('Woodstock!'); + // final Paragraph woodstock = builder.build(); + // canvas.drawParagraph(woodstock, const Offset(0, 50)); + // }, 100, 100); + // } catch (e) { + // error = e; + // } + // if (assertsEnabled) { + // expect(error, isNotNull); + // } else { + // expect(error, isNull); + // } + // }); + + // Future drawText(String text) { + // return toImage((Canvas canvas) { + // final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle( + // fontFamily: 'RobotoSerif', + // fontStyle: FontStyle.normal, + // fontWeight: FontWeight.normal, + // fontSize: 15.0, + // )); + // builder.pushStyle(TextStyle(color: const Color(0xFF0000FF))); + // builder.addText(text); + + // final Paragraph paragraph = builder.build(); + // paragraph.layout(const ParagraphConstraints(width: 20 * 5.0)); + + // canvas.drawParagraph(paragraph, Offset.zero); + // }, 100, 100); + // } + + // test('Canvas.drawParagraph renders tab as space instead of tofu', () async { + // // Skia renders a tofu if the font does not have a glyph for a character. + // // However, Flutter opts-in to a Skia feature to render tabs as a single space. + // // See: https://github.com/flutter/flutter/issues/79153 + // final File file = File(path.join('flutter', 'testing', 'resources', 'RobotoSlab-VariableFont_wght.ttf')); + // final Uint8List fontData = await file.readAsBytes(); + // await loadFontFromList(fontData, fontFamily: 'RobotoSerif'); + + // // The backspace character, \b, does not have a corresponding glyph and is rendered as a tofu. + // final Image tabImage = await drawText('>\t<'); + // final Image spaceImage = await drawText('> <'); + // final Image tofuImage = await drawText('>\b<'); + + // // The tab's image should be identical to the space's image but not the tofu's image. + // final bool tabToSpaceComparison = await comparer.fuzzyCompareImages(tabImage, spaceImage); + // final bool tabToTofuComparison = await comparer.fuzzyCompareImages(tabImage, tofuImage); + + // expect(tabToSpaceComparison, isTrue); + // expect(tabToTofuComparison, isFalse); + // }); + + // Matcher closeToTransform(Float64List expected) => (dynamic v) { + // Expect.type(v); + // final Float64List value = v as Float64List; + // expect(expected.length, equals(16)); + // expect(value.length, equals(16)); + // for (int r = 0; r < 4; r++) { + // for (int c = 0; c < 4; c++) { + // final double vActual = value[r*4 + c]; + // final double vExpected = expected[r*4 + c]; + // if ((vActual - vExpected).abs() > 1e-10) { + // Expect.fail('matrix mismatch at $r, $c, $vActual not close to $vExpected'); + // } + // } + // } + // }; + + // Matcher notCloseToTransform(Float64List expected) => (dynamic v) { + // Expect.type(v); + // final Float64List value = v as Float64List; + // expect(expected.length, equals(16)); + // expect(value.length, equals(16)); + // for (int r = 0; r < 4; r++) { + // for (int c = 0; c < 4; c++) { + // final double vActual = value[r*4 + c]; + // final double vExpected = expected[r*4 + c]; + // if ((vActual - vExpected).abs() > 1e-10) { + // return; + // } + // } + // } + // Expect.fail('$value is too close to $expected'); + // }; + + // test('Canvas.translate affects canvas.getTransform', () async { + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // canvas.translate(12, 14.5); + // final Float64List matrix = Matrix4.translationValues(12, 14.5, 0).storage; + // final Float64List curMatrix = canvas.getTransform(); + // expect(curMatrix, closeToTransform(matrix)); + // canvas.translate(10, 10); + // final Float64List newCurMatrix = canvas.getTransform(); + // expect(newCurMatrix, notCloseToTransform(matrix)); + // expect(curMatrix, closeToTransform(matrix)); + // }); + + // test('Canvas.scale affects canvas.getTransform', () async { + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // canvas.scale(12, 14.5); + // final Float64List matrix = Matrix4.diagonal3Values(12, 14.5, 1).storage; + // final Float64List curMatrix = canvas.getTransform(); + // expect(curMatrix, closeToTransform(matrix)); + // canvas.scale(10, 10); + // final Float64List newCurMatrix = canvas.getTransform(); + // expect(newCurMatrix, notCloseToTransform(matrix)); + // expect(curMatrix, closeToTransform(matrix)); + // }); + + // test('Canvas.rotate affects canvas.getTransform', () async { + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // canvas.rotate(pi); + // final Float64List matrix = Matrix4.rotationZ(pi).storage; + // final Float64List curMatrix = canvas.getTransform(); + // expect(curMatrix, closeToTransform(matrix)); + // canvas.rotate(pi / 2); + // final Float64List newCurMatrix = canvas.getTransform(); + // expect(newCurMatrix, notCloseToTransform(matrix)); + // expect(curMatrix, closeToTransform(matrix)); + // }); + + // test('Canvas.skew affects canvas.getTransform', () async { + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // canvas.skew(12, 14.5); + // final Float64List matrix = (Matrix4.identity()..setEntry(0, 1, 12)..setEntry(1, 0, 14.5)).storage; + // final Float64List curMatrix = canvas.getTransform(); + // expect(curMatrix, closeToTransform(matrix)); + // canvas.skew(10, 10); + // final Float64List newCurMatrix = canvas.getTransform(); + // expect(newCurMatrix, notCloseToTransform(matrix)); + // expect(curMatrix, closeToTransform(matrix)); + // }); + + // test('Canvas.transform affects canvas.getTransform', () async { + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // final Float64List matrix = (Matrix4.identity()..translate(12.0, 14.5)..scale(12.0, 14.5)).storage; + // canvas.transform(matrix); + // final Float64List curMatrix = canvas.getTransform(); + // expect(curMatrix, closeToTransform(matrix)); + // canvas.translate(10, 10); + // final Float64List newCurMatrix = canvas.getTransform(); + // expect(newCurMatrix, notCloseToTransform(matrix)); + // expect(curMatrix, closeToTransform(matrix)); + // }); + + // Matcher closeToRect(Rect expected) => (dynamic v) { + // Expect.type(v); + // final Rect value = v as Rect; + // expect(value.left, closeTo(expected.left, 1e-6)); + // expect(value.top, closeTo(expected.top, 1e-6)); + // expect(value.right, closeTo(expected.right, 1e-6)); + // expect(value.bottom, closeTo(expected.bottom, 1e-6)); + // }; + + // Matcher notCloseToRect(Rect expected) => (dynamic v) { + // Expect.type(v); + // final Rect value = v as Rect; + // if ((value.left - expected.left).abs() > 1e-6 || + // (value.top - expected.top).abs() > 1e-6 || + // (value.right - expected.right).abs() > 1e-6 || + // (value.bottom - expected.bottom).abs() > 1e-6) { + // return; + // } + // Expect.fail('$value is too close to $expected'); + // }; + + // test('Canvas.clipRect(doAA=true) affects canvas.getClipBounds', () async { + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); + // const Rect clipExpandedBounds = Rect.fromLTRB(10, 11, 21, 26); + // canvas.clipRect(clipBounds); + + // // Save initial return values for testing restored values + // final Rect initialLocalBounds = canvas.getLocalClipBounds(); + // final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); + // expect(initialLocalBounds, closeToRect(clipExpandedBounds)); + // expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); + + // canvas.save(); + // canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15)); + // // Both clip bounds have changed + // expect(canvas.getLocalClipBounds(), notCloseToRect(clipExpandedBounds)); + // expect(canvas.getDestinationClipBounds(), notCloseToRect(clipExpandedBounds)); + // // Previous return values have not changed + // expect(initialLocalBounds, closeToRect(clipExpandedBounds)); + // expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); + // canvas.restore(); + + // // save/restore returned the values to their original values + // expect(canvas.getLocalClipBounds(), initialLocalBounds); + // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + + // canvas.save(); + // canvas.scale(2, 2); + // const Rect scaledExpandedBounds = Rect.fromLTRB(5, 5.5, 10.5, 13); + // expect(canvas.getLocalClipBounds(), closeToRect(scaledExpandedBounds)); + // // Destination bounds are unaffected by transform + // expect(canvas.getDestinationClipBounds(), closeToRect(clipExpandedBounds)); + // canvas.restore(); + + // // save/restore returned the values to their original values + // expect(canvas.getLocalClipBounds(), initialLocalBounds); + // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + // }); + + // test('Canvas.clipRect(doAA=false) affects canvas.getClipBounds', () async { + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); + // canvas.clipRect(clipBounds, doAntiAlias: false); + + // // Save initial return values for testing restored values + // final Rect initialLocalBounds = canvas.getLocalClipBounds(); + // final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); + // expect(initialLocalBounds, closeToRect(clipBounds)); + // expect(initialDestinationBounds, closeToRect(clipBounds)); + + // canvas.save(); + // canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15)); + // // Both clip bounds have changed + // expect(canvas.getLocalClipBounds(), notCloseToRect(clipBounds)); + // expect(canvas.getDestinationClipBounds(), notCloseToRect(clipBounds)); + // // Previous return values have not changed + // expect(initialLocalBounds, closeToRect(clipBounds)); + // expect(initialDestinationBounds, closeToRect(clipBounds)); + // canvas.restore(); + + // // save/restore returned the values to their original values + // expect(canvas.getLocalClipBounds(), initialLocalBounds); + // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + + // canvas.save(); + // canvas.scale(2, 2); + // const Rect scaledClipBounds = Rect.fromLTRB(5.1, 5.65, 10.2, 12.85); + // expect(canvas.getLocalClipBounds(), closeToRect(scaledClipBounds)); + // // Destination bounds are unaffected by transform + // expect(canvas.getDestinationClipBounds(), closeToRect(clipBounds)); + // canvas.restore(); + + // // save/restore returned the values to their original values + // expect(canvas.getLocalClipBounds(), initialLocalBounds); + // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + // }); + + // test('Canvas.clipRect with matrix affects canvas.getClipBounds', () async { + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // const Rect clipBounds1 = Rect.fromLTRB(0.0, 0.0, 10.0, 10.0); + // const Rect clipBounds2 = Rect.fromLTRB(10.0, 10.0, 20.0, 20.0); + + // canvas.save(); + // canvas.clipRect(clipBounds1, doAntiAlias: false); + // canvas.translate(0, 10.0); + // canvas.clipRect(clipBounds1, doAntiAlias: false); + // expect(canvas.getDestinationClipBounds().isEmpty, isTrue); + // canvas.restore(); + + // canvas.save(); + // canvas.clipRect(clipBounds1, doAntiAlias: false); + // canvas.translate(-10.0, -10.0); + // canvas.clipRect(clipBounds2, doAntiAlias: false); + // expect(canvas.getDestinationClipBounds(), clipBounds1); + // canvas.restore(); + // }); + + // test('Canvas.clipRRect(doAA=true) affects canvas.getClipBounds', () async { + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); + // const Rect clipExpandedBounds = Rect.fromLTRB(10, 11, 21, 26); + // final RRect clip = RRect.fromRectAndRadius(clipBounds, const Radius.circular(3)); + // canvas.clipRRect(clip); + + // // Save initial return values for testing restored values + // final Rect initialLocalBounds = canvas.getLocalClipBounds(); + // final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); + // expect(initialLocalBounds, closeToRect(clipExpandedBounds)); + // expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); + + // canvas.save(); + // canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15)); + // // Both clip bounds have changed + // expect(canvas.getLocalClipBounds(), notCloseToRect(clipExpandedBounds)); + // expect(canvas.getDestinationClipBounds(), notCloseToRect(clipExpandedBounds)); + // // Previous return values have not changed + // expect(initialLocalBounds, closeToRect(clipExpandedBounds)); + // expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); + // canvas.restore(); + + // // save/restore returned the values to their original values + // expect(canvas.getLocalClipBounds(), initialLocalBounds); + // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + + // canvas.save(); + // canvas.scale(2, 2); + // const Rect scaledExpandedBounds = Rect.fromLTRB(5, 5.5, 10.5, 13); + // expect(canvas.getLocalClipBounds(), closeToRect(scaledExpandedBounds)); + // // Destination bounds are unaffected by transform + // expect(canvas.getDestinationClipBounds(), closeToRect(clipExpandedBounds)); + // canvas.restore(); + + // // save/restore returned the values to their original values + // expect(canvas.getLocalClipBounds(), initialLocalBounds); + // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + // }); + + // test('Canvas.clipRRect(doAA=false) affects canvas.getClipBounds', () async { + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); + // final RRect clip = RRect.fromRectAndRadius(clipBounds, const Radius.circular(3)); + // canvas.clipRRect(clip, doAntiAlias: false); + + // // Save initial return values for testing restored values + // final Rect initialLocalBounds = canvas.getLocalClipBounds(); + // final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); + // expect(initialLocalBounds, closeToRect(clipBounds)); + // expect(initialDestinationBounds, closeToRect(clipBounds)); + + // canvas.save(); + // canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15), doAntiAlias: false); + // // Both clip bounds have changed + // expect(canvas.getLocalClipBounds(), notCloseToRect(clipBounds)); + // expect(canvas.getDestinationClipBounds(), notCloseToRect(clipBounds)); + // // Previous return values have not changed + // expect(initialLocalBounds, closeToRect(clipBounds)); + // expect(initialDestinationBounds, closeToRect(clipBounds)); + // canvas.restore(); + + // // save/restore returned the values to their original values + // expect(canvas.getLocalClipBounds(), initialLocalBounds); + // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + + // canvas.save(); + // canvas.scale(2, 2); + // const Rect scaledClipBounds = Rect.fromLTRB(5.1, 5.65, 10.2, 12.85); + // expect(canvas.getLocalClipBounds(), closeToRect(scaledClipBounds)); + // // Destination bounds are unaffected by transform + // expect(canvas.getDestinationClipBounds(), closeToRect(clipBounds)); + // canvas.restore(); + + // // save/restore returned the values to their original values + // expect(canvas.getLocalClipBounds(), initialLocalBounds); + // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + // }); + + // test('Canvas.clipRRect with matrix affects canvas.getClipBounds', () async { + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // const Rect clipBounds1 = Rect.fromLTRB(0.0, 0.0, 10.0, 10.0); + // const Rect clipBounds2 = Rect.fromLTRB(10.0, 10.0, 20.0, 20.0); + // final RRect clip1 = RRect.fromRectAndRadius(clipBounds1, const Radius.circular(3)); + // final RRect clip2 = RRect.fromRectAndRadius(clipBounds2, const Radius.circular(3)); + + // canvas.save(); + // canvas.clipRRect(clip1, doAntiAlias: false); + // canvas.translate(0, 10.0); + // canvas.clipRRect(clip1, doAntiAlias: false); + // expect(canvas.getDestinationClipBounds().isEmpty, isTrue); + // canvas.restore(); + + // canvas.save(); + // canvas.clipRRect(clip1, doAntiAlias: false); + // canvas.translate(-10.0, -10.0); + // canvas.clipRRect(clip2, doAntiAlias: false); + // expect(canvas.getDestinationClipBounds(), clipBounds1); + // canvas.restore(); + // }); + + // test('Canvas.clipPath(doAA=true) affects canvas.getClipBounds', () async { + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); + // const Rect clipExpandedBounds = Rect.fromLTRB(10, 11, 21, 26); + // final Path clip = Path()..addRect(clipBounds)..addOval(clipBounds); + // canvas.clipPath(clip); + + // // Save initial return values for testing restored values + // final Rect initialLocalBounds = canvas.getLocalClipBounds(); + // final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); + // expect(initialLocalBounds, closeToRect(clipExpandedBounds)); + // expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); + + // canvas.save(); + // canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15)); + // // Both clip bounds have changed + // expect(canvas.getLocalClipBounds(), notCloseToRect(clipExpandedBounds)); + // expect(canvas.getDestinationClipBounds(), notCloseToRect(clipExpandedBounds)); + // // Previous return values have not changed + // expect(initialLocalBounds, closeToRect(clipExpandedBounds)); + // expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); + // canvas.restore(); + + // // save/restore returned the values to their original values + // expect(canvas.getLocalClipBounds(), initialLocalBounds); + // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + + // canvas.save(); + // canvas.scale(2, 2); + // const Rect scaledExpandedBounds = Rect.fromLTRB(5, 5.5, 10.5, 13); + // expect(canvas.getLocalClipBounds(), closeToRect(scaledExpandedBounds)); + // // Destination bounds are unaffected by transform + // expect(canvas.getDestinationClipBounds(), closeToRect(clipExpandedBounds)); + // canvas.restore(); + + // // save/restore returned the values to their original values + // expect(canvas.getLocalClipBounds(), initialLocalBounds); + // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + // }); + + // test('Canvas.clipPath(doAA=false) affects canvas.getClipBounds', () async { + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); + // final Path clip = Path()..addRect(clipBounds)..addOval(clipBounds); + // canvas.clipPath(clip, doAntiAlias: false); + + // // Save initial return values for testing restored values + // final Rect initialLocalBounds = canvas.getLocalClipBounds(); + // final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); + // expect(initialLocalBounds, closeToRect(clipBounds)); + // expect(initialDestinationBounds, closeToRect(clipBounds)); + + // canvas.save(); + // canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15), doAntiAlias: false); + // // Both clip bounds have changed + // expect(canvas.getLocalClipBounds(), notCloseToRect(clipBounds)); + // expect(canvas.getDestinationClipBounds(), notCloseToRect(clipBounds)); + // // Previous return values have not changed + // expect(initialLocalBounds, closeToRect(clipBounds)); + // expect(initialDestinationBounds, closeToRect(clipBounds)); + // canvas.restore(); + + // // save/restore returned the values to their original values + // expect(canvas.getLocalClipBounds(), initialLocalBounds); + // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + + // canvas.save(); + // canvas.scale(2, 2); + // const Rect scaledClipBounds = Rect.fromLTRB(5.1, 5.65, 10.2, 12.85); + // expect(canvas.getLocalClipBounds(), closeToRect(scaledClipBounds)); + // // Destination bounds are unaffected by transform + // expect(canvas.getDestinationClipBounds(), closeToRect(clipBounds)); + // canvas.restore(); + + // // save/restore returned the values to their original values + // expect(canvas.getLocalClipBounds(), initialLocalBounds); + // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + // }); + + // test('Canvas.clipPath with matrix affects canvas.getClipBounds', () async { + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // const Rect clipBounds1 = Rect.fromLTRB(0.0, 0.0, 10.0, 10.0); + // const Rect clipBounds2 = Rect.fromLTRB(10.0, 10.0, 20.0, 20.0); + // final Path clip1 = Path()..addRect(clipBounds1)..addOval(clipBounds1); + // final Path clip2 = Path()..addRect(clipBounds2)..addOval(clipBounds2); + + // canvas.save(); + // canvas.clipPath(clip1, doAntiAlias: false); + // canvas.translate(0, 10.0); + // canvas.clipPath(clip1, doAntiAlias: false); + // expect(canvas.getDestinationClipBounds().isEmpty, isTrue); + // canvas.restore(); + + // canvas.save(); + // canvas.clipPath(clip1, doAntiAlias: false); + // canvas.translate(-10.0, -10.0); + // canvas.clipPath(clip2, doAntiAlias: false); + // expect(canvas.getDestinationClipBounds(), clipBounds1); + // canvas.restore(); + // }); + + // test('Canvas.clipRect(diff) does not affect canvas.getClipBounds', () async { + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); + // canvas.clipRect(clipBounds, doAntiAlias: false); + + // // Save initial return values for testing restored values + // final Rect initialLocalBounds = canvas.getLocalClipBounds(); + // final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); + // expect(initialLocalBounds, closeToRect(clipBounds)); + // expect(initialDestinationBounds, closeToRect(clipBounds)); + + // canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15), clipOp: ClipOp.difference, doAntiAlias: false); + // expect(canvas.getLocalClipBounds(), initialLocalBounds); + // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + // }); + + // test('RestoreToCount can work', () async { + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // canvas.save(); + // canvas.save(); + // canvas.save(); + // canvas.save(); + // canvas.save(); + // expect(canvas.getSaveCount(), equals(6)); + // canvas.restoreToCount(2); + // expect(canvas.getSaveCount(), equals(2)); + // canvas.restore(); + // expect(canvas.getSaveCount(), equals(1)); + // }); + + // test('RestoreToCount count less than 1, the stack should be reset', () async { + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // canvas.save(); + // canvas.save(); + // canvas.save(); + // canvas.save(); + // canvas.save(); + // expect(canvas.getSaveCount(), equals(6)); + // canvas.restoreToCount(0); + // expect(canvas.getSaveCount(), equals(1)); + // }); + + // test('RestoreToCount count greater than current [getSaveCount], nothing would happend', () async { + // final PictureRecorder recorder = PictureRecorder(); + // final Canvas canvas = Canvas(recorder); + // canvas.save(); + // canvas.save(); + // canvas.save(); + // canvas.save(); + // canvas.save(); + // expect(canvas.getSaveCount(), equals(6)); + // canvas.restoreToCount(canvas.getSaveCount() + 1); + // expect(canvas.getSaveCount(), equals(6)); + // }); + + // await null; + // final command = [ + // Platform.environment['GOLDCTL']!, + // 'imgtest', + // 'finalize', + // ]; + // print(command.join(' ')); + // final result = await LocalProcessManager().run(command); + // print(result); + // print(result.exitCode); + // print(result.stdout); + // print(result.stderr); } Matcher listEquals(ByteData expected) => (dynamic v) { From fc2ff4f175af650ca878ceceeec7237698872da4 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Mon, 30 Oct 2023 13:49:01 -0700 Subject: [PATCH 29/44] ? --- testing/dart/canvas_test.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/testing/dart/canvas_test.dart b/testing/dart/canvas_test.dart index 37b2575b0d597..be7a7790e4d42 100644 --- a/testing/dart/canvas_test.dart +++ b/testing/dart/canvas_test.dart @@ -140,8 +140,9 @@ void main() async { ..style = PaintingStyle.fill; canvas.drawPath(circlePath, paint); }, 100, 100); - expect(image.width, equals(100)); - expect(image.height, equals(100)); + print(image); + // expect(image.width, equals(100)); + // expect(image.height, equals(100)); await comparer.addGoldenImage(image, 'canvas_test_toImage.png'); // }); From 31598d3fb5f2123133bfea59555cb69004e93b67 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Mon, 30 Oct 2023 14:06:21 -0700 Subject: [PATCH 30/44] wut --- testing/dart/goldens.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/testing/dart/goldens.dart b/testing/dart/goldens.dart index 5a70ecfc95f78..4de8c3ba55985 100644 --- a/testing/dart/goldens.dart +++ b/testing/dart/goldens.dart @@ -67,10 +67,11 @@ class ImageComparer { fileName, file, screenshotSize: image.width * image.height, - ).catchError((dynamic error) { - print('Skia gold comparison failed: $error'); - throw Exception('Failed comparison: $fileName'); - }); + ); + // .catchError((dynamic error) { + // print('Skia gold comparison failed: $error'); + // throw Exception('Failed comparison: $fileName'); + // }); } Future fuzzyCompareImages(Image golden, Image testImage) async { From da8e777c2feda88374404656cd169f2ae4f9f48a Mon Sep 17 00:00:00 2001 From: Dan Field Date: Mon, 30 Oct 2023 15:45:53 -0700 Subject: [PATCH 31/44] more debug --- testing/dart/canvas_test.dart | 4 ++++ testing/dart/goldens.dart | 9 ++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/testing/dart/canvas_test.dart b/testing/dart/canvas_test.dart index be7a7790e4d42..3ae4c839f71f2 100644 --- a/testing/dart/canvas_test.dart +++ b/testing/dart/canvas_test.dart @@ -7,6 +7,7 @@ import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; import 'dart:ui'; +import 'dart:isolate'; import 'package:litetest/litetest.dart'; import 'package:path/path.dart' as path; @@ -126,6 +127,7 @@ void testNoCrashes() { } void main() async { + ReceivePort port = ReceivePort('test'); final ImageComparer comparer = await ImageComparer.create(verbose: true); // testNoCrashes(); @@ -144,6 +146,8 @@ void main() async { // expect(image.width, equals(100)); // expect(image.height, equals(100)); await comparer.addGoldenImage(image, 'canvas_test_toImage.png'); + print('Added golden image'); + port.close(); // }); // Gradient makeGradient() { diff --git a/testing/dart/goldens.dart b/testing/dart/goldens.dart index 4de8c3ba55985..5a70ecfc95f78 100644 --- a/testing/dart/goldens.dart +++ b/testing/dart/goldens.dart @@ -67,11 +67,10 @@ class ImageComparer { fileName, file, screenshotSize: image.width * image.height, - ); - // .catchError((dynamic error) { - // print('Skia gold comparison failed: $error'); - // throw Exception('Failed comparison: $fileName'); - // }); + ).catchError((dynamic error) { + print('Skia gold comparison failed: $error'); + throw Exception('Failed comparison: $fileName'); + }); } Future fuzzyCompareImages(Image golden, Image testImage) async { From b335c073a8abfe052c9e9e564ced5be2e76e04dc Mon Sep 17 00:00:00 2001 From: Dan Field Date: Tue, 31 Oct 2023 10:22:41 -0700 Subject: [PATCH 32/44] try this --- .ci.yaml | 4 +- testing/dart/canvas_test.dart | 1804 ++++++++++++++++----------------- 2 files changed, 897 insertions(+), 911 deletions(-) diff --git a/.ci.yaml b/.ci.yaml index 85aa909c8d0b2..2c396e5a80f91 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -295,7 +295,7 @@ targets: config_name: linux_host_engine dependencies: >- [ - {"dependency": "goldctl", "version": "git_revision:f808dcff91b221ae313e540c09d79696cd08b8de"} + {"dependency": "goldctl", "version": "git_revision:720a542f6fe4f92922c3b8f0fdcc4d2ac6bb83cd"} ] drone_dimensions: - os=Linux @@ -445,7 +445,7 @@ targets: config_name: mac_host_engine dependencies: >- [ - {"dependency": "goldctl", "version": "git_revision:f808dcff91b221ae313e540c09d79696cd08b8de"} + {"dependency": "goldctl", "version": "git_revision:720a542f6fe4f92922c3b8f0fdcc4d2ac6bb83cd"} ] $flutter/osx_sdk : >- { diff --git a/testing/dart/canvas_test.dart b/testing/dart/canvas_test.dart index 3ae4c839f71f2..a1d19d7894f96 100644 --- a/testing/dart/canvas_test.dart +++ b/testing/dart/canvas_test.dart @@ -130,9 +130,9 @@ void main() async { ReceivePort port = ReceivePort('test'); final ImageComparer comparer = await ImageComparer.create(verbose: true); - // testNoCrashes(); + testNoCrashes(); - // test('Simple .toImage', () async { + test('Simple .toImage', () async { final Image image = await toImage((Canvas canvas) { final Path circlePath = Path() ..addOval( @@ -142,914 +142,900 @@ void main() async { ..style = PaintingStyle.fill; canvas.drawPath(circlePath, paint); }, 100, 100); - print(image); - // expect(image.width, equals(100)); - // expect(image.height, equals(100)); + expect(image.width, equals(100)); + expect(image.height, equals(100)); await comparer.addGoldenImage(image, 'canvas_test_toImage.png'); - print('Added golden image'); - port.close(); - // }); - - // Gradient makeGradient() { - // return Gradient.linear( - // Offset.zero, - // const Offset(100, 100), - // const [Color(0xFF4C4D52), Color(0xFF202124)], - // ); - // } - - // test('Simple gradient, which is implicitly dithered', () async { - // final Image image = await toImage((Canvas canvas) { - // final Paint paint = Paint()..shader = makeGradient(); - // canvas.drawPaint(paint); - // }, 100, 100); - // expect(image.width, equals(100)); - // expect(image.height, equals(100)); - - // await comparer.addGoldenImage(image, 'canvas_test_dithered_gradient.png'); - // }); - - // test('Null values allowed for drawAtlas methods', () async { - // final Image image = await createImage(100, 100); - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // const Rect rect = Rect.fromLTWH(0, 0, 100, 100); - // final RSTransform transform = RSTransform(1, 0, 0, 0); - // const Color color = Color(0x00000000); - // final Paint paint = Paint(); - // canvas.drawAtlas(image, [transform], [rect], [color], BlendMode.src, rect, paint); - // canvas.drawAtlas(image, [transform], [rect], [color], BlendMode.src, null, paint); - // canvas.drawAtlas(image, [transform], [rect], [], null, rect, paint); - // canvas.drawAtlas(image, [transform], [rect], null, null, rect, paint); - // canvas.drawRawAtlas(image, Float32List(0), Float32List(0), Int32List(0), BlendMode.src, rect, paint); - // canvas.drawRawAtlas(image, Float32List(0), Float32List(0), Int32List(0), BlendMode.src, null, paint); - // canvas.drawRawAtlas(image, Float32List(0), Float32List(0), null, null, rect, paint); - - // expectAssertion(() => canvas.drawAtlas(image, [transform], [rect], [color], null, rect, paint)); - // }); - - // test('Data lengths must match for drawAtlas methods', () async { - // final Image image = await createImage(100, 100); - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // const Rect rect = Rect.fromLTWH(0, 0, 100, 100); - // final RSTransform transform = RSTransform(1, 0, 0, 0); - // const Color color = Color(0x00000000); - // final Paint paint = Paint(); - // canvas.drawAtlas(image, [transform], [rect], [color], BlendMode.src, rect, paint); - // canvas.drawAtlas(image, [transform, transform], [rect, rect], [color, color], BlendMode.src, rect, paint); - // canvas.drawAtlas(image, [transform], [rect], [], null, rect, paint); - // canvas.drawAtlas(image, [transform], [rect], null, null, rect, paint); - // canvas.drawRawAtlas(image, Float32List(0), Float32List(0), Int32List(0), BlendMode.src, rect, paint); - // canvas.drawRawAtlas(image, Float32List(4), Float32List(4), Int32List(1), BlendMode.src, rect, paint); - // canvas.drawRawAtlas(image, Float32List(4), Float32List(4), null, null, rect, paint); - - // expectArgumentError(() => canvas.drawAtlas(image, [transform], [], [color], BlendMode.src, rect, paint)); - // expectArgumentError(() => canvas.drawAtlas(image, [], [rect], [color], BlendMode.src, rect, paint)); - // expectArgumentError(() => canvas.drawAtlas(image, [transform], [rect], [color, color], BlendMode.src, rect, paint)); - // expectArgumentError(() => canvas.drawAtlas(image, [transform], [rect, rect], [color], BlendMode.src, rect, paint)); - // expectArgumentError(() => canvas.drawAtlas(image, [transform, transform], [rect], [color], BlendMode.src, rect, paint)); - // expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(3), Float32List(3), null, null, rect, paint)); - // expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(4), Float32List(0), null, null, rect, paint)); - // expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(0), Float32List(4), null, null, rect, paint)); - // expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(4), Float32List(4), Int32List(2), BlendMode.src, rect, paint)); - // }); - - // test('Canvas preserves perspective data in Matrix4', () async { - // const double rotateAroundX = pi / 6; // 30 degrees - // const double rotateAroundY = pi / 9; // 20 degrees - // const int width = 150; - // const int height = 150; - // const Color black = Color.fromARGB(255, 0, 0, 0); - // const Color green = Color.fromARGB(255, 0, 255, 0); - // void paint(Canvas canvas, CanvasCallback rotate) { - // canvas.translate(width * 0.5, height * 0.5); - // rotate(canvas); - // const double width3 = width / 3.0; - // const double width5 = width / 5.0; - // const double width10 = width / 10.0; - // canvas.drawRect(const Rect.fromLTRB(-width3, -width3, width3, width3), Paint()..color = green); - // canvas.drawRect(const Rect.fromLTRB(-width5, -width5, -width10, width5), Paint()..color = black); - // canvas.drawRect(const Rect.fromLTRB(-width5, -width5, width5, -width10), Paint()..color = black); - // } - - // final Image incrementalMatrixImage = await toImage((Canvas canvas) { - // paint(canvas, (Canvas canvas) { - // final Matrix4 matrix = Matrix4.identity(); - // matrix.setEntry(3, 2, 0.001); - // canvas.transform(matrix.storage); - // matrix.setRotationX(rotateAroundX); - // canvas.transform(matrix.storage); - // matrix.setRotationY(rotateAroundY); - // canvas.transform(matrix.storage); - // }); - // }, width, height); - // final Image combinedMatrixImage = await toImage((Canvas canvas) { - // paint(canvas, (Canvas canvas) { - // final Matrix4 matrix = Matrix4.identity(); - // matrix.setEntry(3, 2, 0.001); - // matrix.rotateX(rotateAroundX); - // matrix.rotateY(rotateAroundY); - // canvas.transform(matrix.storage); - // }); - // }, width, height); - - // final bool areEqual = await comparer.fuzzyCompareImages(incrementalMatrixImage, combinedMatrixImage); - - // expect(areEqual, true); - // }); - - // test('Path effects from Paragraphs do not affect further rendering', () async { - // void drawText(Canvas canvas, String content, Offset offset, - // {TextDecorationStyle style = TextDecorationStyle.solid}) { - // final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle()); - // builder.pushStyle(TextStyle( - // decoration: TextDecoration.underline, - // decorationColor: const Color(0xFF0000FF), - // fontFamily: 'Ahem', - // fontSize: 10, - // color: const Color(0xFF000000), - // decorationStyle: style, - // )); - // builder.addText(content); - // final Paragraph paragraph = builder.build(); - // paragraph.layout(const ParagraphConstraints(width: 100)); - // canvas.drawParagraph(paragraph, offset); - // } - - // final Image image = await toImage((Canvas canvas) { - // canvas.drawColor(const Color(0xFFFFFFFF), BlendMode.srcOver); - // final Paint paint = Paint() - // ..style = PaintingStyle.stroke - // ..strokeWidth = 5; - // drawText(canvas, 'Hello World', const Offset(20, 10)); - // canvas.drawCircle(const Offset(150, 25), 15, paint..color = const Color(0xFF00FF00)); - // drawText(canvas, 'Regular text', const Offset(20, 60)); - // canvas.drawCircle(const Offset(150, 75), 15, paint..color = const Color(0xFFFFFF00)); - // drawText(canvas, 'Dotted text', const Offset(20, 110), style: TextDecorationStyle.dotted); - // canvas.drawCircle(const Offset(150, 125), 15, paint..color = const Color(0xFFFF0000)); - // drawText(canvas, 'Dashed text', const Offset(20, 160), style: TextDecorationStyle.dashed); - // canvas.drawCircle(const Offset(150, 175), 15, paint..color = const Color(0xFFFF0000)); - // drawText(canvas, 'Wavy text', const Offset(20, 210), style: TextDecorationStyle.wavy); - // canvas.drawCircle(const Offset(150, 225), 15, paint..color = const Color(0xFFFF0000)); - // }, 200, 250); - // expect(image.width, equals(200)); - // expect(image.height, equals(250)); - - // await comparer.addGoldenImage(image, 'dotted_path_effect_mixed_with_stroked_geometry.png'); - // }); - - // test('Gradients with matrices in Paragraphs render correctly', () async { - // final Image image = await toImage((Canvas canvas) { - // final Paint p = Paint(); - // final Float64List transform = Float64List.fromList([ - // 86.80000129342079, - // 0.0, - // 0.0, - // 0.0, - // 0.0, - // 94.5, - // 0.0, - // 0.0, - // 0.0, - // 0.0, - // 1.0, - // 0.0, - // 60.0, - // 224.310302734375, - // 0.0, - // 1.0 - // ]); - // p.shader = Gradient.radial( - // const Offset(2.5, 0.33), - // 0.8, - // [ - // const Color(0xffff0000), - // const Color(0xff00ff00), - // const Color(0xff0000ff), - // const Color(0xffff00ff) - // ], - // [0.0, 0.3, 0.7, 0.9], - // TileMode.mirror, - // transform, - // const Offset(2.55, 0.4)); - // final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle()); - // builder.pushStyle(TextStyle( - // foreground: p, - // fontSize: 200, - // )); - // builder.addText('Woodstock!'); - // final Paragraph paragraph = builder.build(); - // paragraph.layout(const ParagraphConstraints(width: 1000)); - // canvas.drawParagraph(paragraph, const Offset(10, 150)); - // }, 600, 400); - // expect(image.width, equals(600)); - // expect(image.height, equals(400)); - - // await comparer.addGoldenImage(image, 'text_with_gradient_with_matrix.png'); - // }); - - // test('toImageSync - too big', () async { - // PictureRecorder recorder = PictureRecorder(); - // Canvas canvas = Canvas(recorder); - // canvas.drawPaint(Paint()..color = const Color(0xFF123456)); - // final Picture picture = recorder.endRecording(); - // final Image image = picture.toImageSync(300000, 4000000); - // picture.dispose(); - - // expect(image.width, 300000); - // expect(image.height, 4000000); - - // recorder = PictureRecorder(); - // canvas = Canvas(recorder); - - // if (impellerEnabled) { - // // Impeller tries to automagically scale this. See - // // https://github.com/flutter/flutter/issues/128885 - // canvas.drawImage(image, Offset.zero, Paint()); - // return; - // } - // // On a slower CI machine, the raster thread may get behind the UI thread - // // here. However, once the image is in an error state it will immediately - // // throw on subsequent attempts. - // bool caughtException = false; - // for (int iterations = 0; iterations < 1000; iterations += 1) { - // try { - // canvas.drawImage(image, Offset.zero, Paint()); - // } on PictureRasterizationException catch (e) { - // caughtException = true; - // expect( - // e.message, - // contains('unable to create bitmap render target at specified size ${image.width}x${image.height}'), - // ); - // break; - // } - // // Let the event loop turn. - // await Future.delayed(const Duration(milliseconds: 1)); - // } - // expect(caughtException, true); - // expect( - // () => canvas.drawImageRect(image, Rect.zero, Rect.zero, Paint()), - // throwsException, - // ); - // expect( - // () => canvas.drawImageNine(image, Rect.zero, Rect.zero, Paint()), - // throwsException, - // ); - // expect( - // () => canvas.drawAtlas(image, [], [], null, null, null, Paint()), - // throwsException, - // ); - // }); - - // test('toImageSync - succeeds', () async { - // PictureRecorder recorder = PictureRecorder(); - // Canvas canvas = Canvas(recorder); - // canvas.drawPaint(Paint()..color = const Color(0xFF123456)); - // final Picture picture = recorder.endRecording(); - // final Image image = picture.toImageSync(30, 40); - // picture.dispose(); - - // expect(image.width, 30); - // expect(image.height, 40); - - // recorder = PictureRecorder(); - // canvas = Canvas(recorder); - // expect( - // () => canvas.drawImage(image, Offset.zero, Paint()), - // returnsNormally, - // ); - // expect( - // () => canvas.drawImageRect(image, Rect.zero, Rect.zero, Paint()), - // returnsNormally, - // ); - // expect( - // () => canvas.drawImageNine(image, Rect.zero, Rect.zero, Paint()), - // returnsNormally, - // ); - // expect( - // () => canvas.drawAtlas(image, [], [], null, null, null, Paint()), - // returnsNormally, - // ); - // }); - - // test('toImageSync - toByteData', () async { - // const Color color = Color(0xFF123456); - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // canvas.drawPaint(Paint()..color = color); - // final Picture picture = recorder.endRecording(); - // final Image image = picture.toImageSync(6, 8); - // picture.dispose(); - - // expect(image.width, 6); - // expect(image.height, 8); - - // final ByteData? data = await image.toByteData(); - - // expect(data, isNotNull); - // expect(data!.lengthInBytes, 6 * 8 * 4); - // expect(data.buffer.asUint8List()[0], 0x12); - // expect(data.buffer.asUint8List()[1], 0x34); - // expect(data.buffer.asUint8List()[2], 0x56); - // expect(data.buffer.asUint8List()[3], 0xFF); - // }); - - // test('toImage and toImageSync have identical contents', () async { - // // Note: on linux this stil seems to be different. - // // TODO(jonahwilliams): https://github.com/flutter/flutter/issues/108835 - // if (Platform.isLinux) { - // return; - // } - - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // canvas.drawRect( - // const Rect.fromLTWH(20, 20, 100, 100), - // Paint()..color = const Color(0xA0FF6D00), - // ); - // final Picture picture = recorder.endRecording(); - // final Image toImageImage = await picture.toImage(200, 200); - // final Image toImageSyncImage = picture.toImageSync(200, 200); - - // // To trigger observable difference in alpha, draw image - // // on a second canvas. - // Future drawOnCanvas(Image image) async { - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // canvas.drawPaint(Paint()..color = const Color(0x4FFFFFFF)); - // canvas.drawImage(image, Offset.zero, Paint()); - // final Image resultImage = await recorder.endRecording().toImage(200, 200); - // return (await resultImage.toByteData())!; - // } - - // final ByteData dataSync = await drawOnCanvas(toImageImage); - // final ByteData data = await drawOnCanvas(toImageSyncImage); - // expect(data, listEquals(dataSync)); - // }); - - // test('Canvas.drawParagraph throws when Paragraph.layout was not called', () async { - // // Regression test for https://github.com/flutter/flutter/issues/97172 - // bool assertsEnabled = false; - // assert(() { - // assertsEnabled = true; - // return true; - // }()); - - // Object? error; - // try { - // await toImage((Canvas canvas) { - // final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle()); - // builder.addText('Woodstock!'); - // final Paragraph woodstock = builder.build(); - // canvas.drawParagraph(woodstock, const Offset(0, 50)); - // }, 100, 100); - // } catch (e) { - // error = e; - // } - // if (assertsEnabled) { - // expect(error, isNotNull); - // } else { - // expect(error, isNull); - // } - // }); - - // Future drawText(String text) { - // return toImage((Canvas canvas) { - // final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle( - // fontFamily: 'RobotoSerif', - // fontStyle: FontStyle.normal, - // fontWeight: FontWeight.normal, - // fontSize: 15.0, - // )); - // builder.pushStyle(TextStyle(color: const Color(0xFF0000FF))); - // builder.addText(text); - - // final Paragraph paragraph = builder.build(); - // paragraph.layout(const ParagraphConstraints(width: 20 * 5.0)); - - // canvas.drawParagraph(paragraph, Offset.zero); - // }, 100, 100); - // } - - // test('Canvas.drawParagraph renders tab as space instead of tofu', () async { - // // Skia renders a tofu if the font does not have a glyph for a character. - // // However, Flutter opts-in to a Skia feature to render tabs as a single space. - // // See: https://github.com/flutter/flutter/issues/79153 - // final File file = File(path.join('flutter', 'testing', 'resources', 'RobotoSlab-VariableFont_wght.ttf')); - // final Uint8List fontData = await file.readAsBytes(); - // await loadFontFromList(fontData, fontFamily: 'RobotoSerif'); - - // // The backspace character, \b, does not have a corresponding glyph and is rendered as a tofu. - // final Image tabImage = await drawText('>\t<'); - // final Image spaceImage = await drawText('> <'); - // final Image tofuImage = await drawText('>\b<'); - - // // The tab's image should be identical to the space's image but not the tofu's image. - // final bool tabToSpaceComparison = await comparer.fuzzyCompareImages(tabImage, spaceImage); - // final bool tabToTofuComparison = await comparer.fuzzyCompareImages(tabImage, tofuImage); - - // expect(tabToSpaceComparison, isTrue); - // expect(tabToTofuComparison, isFalse); - // }); - - // Matcher closeToTransform(Float64List expected) => (dynamic v) { - // Expect.type(v); - // final Float64List value = v as Float64List; - // expect(expected.length, equals(16)); - // expect(value.length, equals(16)); - // for (int r = 0; r < 4; r++) { - // for (int c = 0; c < 4; c++) { - // final double vActual = value[r*4 + c]; - // final double vExpected = expected[r*4 + c]; - // if ((vActual - vExpected).abs() > 1e-10) { - // Expect.fail('matrix mismatch at $r, $c, $vActual not close to $vExpected'); - // } - // } - // } - // }; - - // Matcher notCloseToTransform(Float64List expected) => (dynamic v) { - // Expect.type(v); - // final Float64List value = v as Float64List; - // expect(expected.length, equals(16)); - // expect(value.length, equals(16)); - // for (int r = 0; r < 4; r++) { - // for (int c = 0; c < 4; c++) { - // final double vActual = value[r*4 + c]; - // final double vExpected = expected[r*4 + c]; - // if ((vActual - vExpected).abs() > 1e-10) { - // return; - // } - // } - // } - // Expect.fail('$value is too close to $expected'); - // }; - - // test('Canvas.translate affects canvas.getTransform', () async { - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // canvas.translate(12, 14.5); - // final Float64List matrix = Matrix4.translationValues(12, 14.5, 0).storage; - // final Float64List curMatrix = canvas.getTransform(); - // expect(curMatrix, closeToTransform(matrix)); - // canvas.translate(10, 10); - // final Float64List newCurMatrix = canvas.getTransform(); - // expect(newCurMatrix, notCloseToTransform(matrix)); - // expect(curMatrix, closeToTransform(matrix)); - // }); - - // test('Canvas.scale affects canvas.getTransform', () async { - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // canvas.scale(12, 14.5); - // final Float64List matrix = Matrix4.diagonal3Values(12, 14.5, 1).storage; - // final Float64List curMatrix = canvas.getTransform(); - // expect(curMatrix, closeToTransform(matrix)); - // canvas.scale(10, 10); - // final Float64List newCurMatrix = canvas.getTransform(); - // expect(newCurMatrix, notCloseToTransform(matrix)); - // expect(curMatrix, closeToTransform(matrix)); - // }); - - // test('Canvas.rotate affects canvas.getTransform', () async { - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // canvas.rotate(pi); - // final Float64List matrix = Matrix4.rotationZ(pi).storage; - // final Float64List curMatrix = canvas.getTransform(); - // expect(curMatrix, closeToTransform(matrix)); - // canvas.rotate(pi / 2); - // final Float64List newCurMatrix = canvas.getTransform(); - // expect(newCurMatrix, notCloseToTransform(matrix)); - // expect(curMatrix, closeToTransform(matrix)); - // }); - - // test('Canvas.skew affects canvas.getTransform', () async { - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // canvas.skew(12, 14.5); - // final Float64List matrix = (Matrix4.identity()..setEntry(0, 1, 12)..setEntry(1, 0, 14.5)).storage; - // final Float64List curMatrix = canvas.getTransform(); - // expect(curMatrix, closeToTransform(matrix)); - // canvas.skew(10, 10); - // final Float64List newCurMatrix = canvas.getTransform(); - // expect(newCurMatrix, notCloseToTransform(matrix)); - // expect(curMatrix, closeToTransform(matrix)); - // }); - - // test('Canvas.transform affects canvas.getTransform', () async { - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // final Float64List matrix = (Matrix4.identity()..translate(12.0, 14.5)..scale(12.0, 14.5)).storage; - // canvas.transform(matrix); - // final Float64List curMatrix = canvas.getTransform(); - // expect(curMatrix, closeToTransform(matrix)); - // canvas.translate(10, 10); - // final Float64List newCurMatrix = canvas.getTransform(); - // expect(newCurMatrix, notCloseToTransform(matrix)); - // expect(curMatrix, closeToTransform(matrix)); - // }); - - // Matcher closeToRect(Rect expected) => (dynamic v) { - // Expect.type(v); - // final Rect value = v as Rect; - // expect(value.left, closeTo(expected.left, 1e-6)); - // expect(value.top, closeTo(expected.top, 1e-6)); - // expect(value.right, closeTo(expected.right, 1e-6)); - // expect(value.bottom, closeTo(expected.bottom, 1e-6)); - // }; - - // Matcher notCloseToRect(Rect expected) => (dynamic v) { - // Expect.type(v); - // final Rect value = v as Rect; - // if ((value.left - expected.left).abs() > 1e-6 || - // (value.top - expected.top).abs() > 1e-6 || - // (value.right - expected.right).abs() > 1e-6 || - // (value.bottom - expected.bottom).abs() > 1e-6) { - // return; - // } - // Expect.fail('$value is too close to $expected'); - // }; - - // test('Canvas.clipRect(doAA=true) affects canvas.getClipBounds', () async { - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); - // const Rect clipExpandedBounds = Rect.fromLTRB(10, 11, 21, 26); - // canvas.clipRect(clipBounds); - - // // Save initial return values for testing restored values - // final Rect initialLocalBounds = canvas.getLocalClipBounds(); - // final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); - // expect(initialLocalBounds, closeToRect(clipExpandedBounds)); - // expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); - - // canvas.save(); - // canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15)); - // // Both clip bounds have changed - // expect(canvas.getLocalClipBounds(), notCloseToRect(clipExpandedBounds)); - // expect(canvas.getDestinationClipBounds(), notCloseToRect(clipExpandedBounds)); - // // Previous return values have not changed - // expect(initialLocalBounds, closeToRect(clipExpandedBounds)); - // expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); - // canvas.restore(); - - // // save/restore returned the values to their original values - // expect(canvas.getLocalClipBounds(), initialLocalBounds); - // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - - // canvas.save(); - // canvas.scale(2, 2); - // const Rect scaledExpandedBounds = Rect.fromLTRB(5, 5.5, 10.5, 13); - // expect(canvas.getLocalClipBounds(), closeToRect(scaledExpandedBounds)); - // // Destination bounds are unaffected by transform - // expect(canvas.getDestinationClipBounds(), closeToRect(clipExpandedBounds)); - // canvas.restore(); - - // // save/restore returned the values to their original values - // expect(canvas.getLocalClipBounds(), initialLocalBounds); - // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - // }); - - // test('Canvas.clipRect(doAA=false) affects canvas.getClipBounds', () async { - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); - // canvas.clipRect(clipBounds, doAntiAlias: false); - - // // Save initial return values for testing restored values - // final Rect initialLocalBounds = canvas.getLocalClipBounds(); - // final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); - // expect(initialLocalBounds, closeToRect(clipBounds)); - // expect(initialDestinationBounds, closeToRect(clipBounds)); - - // canvas.save(); - // canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15)); - // // Both clip bounds have changed - // expect(canvas.getLocalClipBounds(), notCloseToRect(clipBounds)); - // expect(canvas.getDestinationClipBounds(), notCloseToRect(clipBounds)); - // // Previous return values have not changed - // expect(initialLocalBounds, closeToRect(clipBounds)); - // expect(initialDestinationBounds, closeToRect(clipBounds)); - // canvas.restore(); - - // // save/restore returned the values to their original values - // expect(canvas.getLocalClipBounds(), initialLocalBounds); - // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - - // canvas.save(); - // canvas.scale(2, 2); - // const Rect scaledClipBounds = Rect.fromLTRB(5.1, 5.65, 10.2, 12.85); - // expect(canvas.getLocalClipBounds(), closeToRect(scaledClipBounds)); - // // Destination bounds are unaffected by transform - // expect(canvas.getDestinationClipBounds(), closeToRect(clipBounds)); - // canvas.restore(); - - // // save/restore returned the values to their original values - // expect(canvas.getLocalClipBounds(), initialLocalBounds); - // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - // }); - - // test('Canvas.clipRect with matrix affects canvas.getClipBounds', () async { - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // const Rect clipBounds1 = Rect.fromLTRB(0.0, 0.0, 10.0, 10.0); - // const Rect clipBounds2 = Rect.fromLTRB(10.0, 10.0, 20.0, 20.0); - - // canvas.save(); - // canvas.clipRect(clipBounds1, doAntiAlias: false); - // canvas.translate(0, 10.0); - // canvas.clipRect(clipBounds1, doAntiAlias: false); - // expect(canvas.getDestinationClipBounds().isEmpty, isTrue); - // canvas.restore(); - - // canvas.save(); - // canvas.clipRect(clipBounds1, doAntiAlias: false); - // canvas.translate(-10.0, -10.0); - // canvas.clipRect(clipBounds2, doAntiAlias: false); - // expect(canvas.getDestinationClipBounds(), clipBounds1); - // canvas.restore(); - // }); - - // test('Canvas.clipRRect(doAA=true) affects canvas.getClipBounds', () async { - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); - // const Rect clipExpandedBounds = Rect.fromLTRB(10, 11, 21, 26); - // final RRect clip = RRect.fromRectAndRadius(clipBounds, const Radius.circular(3)); - // canvas.clipRRect(clip); - - // // Save initial return values for testing restored values - // final Rect initialLocalBounds = canvas.getLocalClipBounds(); - // final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); - // expect(initialLocalBounds, closeToRect(clipExpandedBounds)); - // expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); - - // canvas.save(); - // canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15)); - // // Both clip bounds have changed - // expect(canvas.getLocalClipBounds(), notCloseToRect(clipExpandedBounds)); - // expect(canvas.getDestinationClipBounds(), notCloseToRect(clipExpandedBounds)); - // // Previous return values have not changed - // expect(initialLocalBounds, closeToRect(clipExpandedBounds)); - // expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); - // canvas.restore(); - - // // save/restore returned the values to their original values - // expect(canvas.getLocalClipBounds(), initialLocalBounds); - // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - - // canvas.save(); - // canvas.scale(2, 2); - // const Rect scaledExpandedBounds = Rect.fromLTRB(5, 5.5, 10.5, 13); - // expect(canvas.getLocalClipBounds(), closeToRect(scaledExpandedBounds)); - // // Destination bounds are unaffected by transform - // expect(canvas.getDestinationClipBounds(), closeToRect(clipExpandedBounds)); - // canvas.restore(); - - // // save/restore returned the values to their original values - // expect(canvas.getLocalClipBounds(), initialLocalBounds); - // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - // }); - - // test('Canvas.clipRRect(doAA=false) affects canvas.getClipBounds', () async { - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); - // final RRect clip = RRect.fromRectAndRadius(clipBounds, const Radius.circular(3)); - // canvas.clipRRect(clip, doAntiAlias: false); - - // // Save initial return values for testing restored values - // final Rect initialLocalBounds = canvas.getLocalClipBounds(); - // final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); - // expect(initialLocalBounds, closeToRect(clipBounds)); - // expect(initialDestinationBounds, closeToRect(clipBounds)); - - // canvas.save(); - // canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15), doAntiAlias: false); - // // Both clip bounds have changed - // expect(canvas.getLocalClipBounds(), notCloseToRect(clipBounds)); - // expect(canvas.getDestinationClipBounds(), notCloseToRect(clipBounds)); - // // Previous return values have not changed - // expect(initialLocalBounds, closeToRect(clipBounds)); - // expect(initialDestinationBounds, closeToRect(clipBounds)); - // canvas.restore(); - - // // save/restore returned the values to their original values - // expect(canvas.getLocalClipBounds(), initialLocalBounds); - // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - - // canvas.save(); - // canvas.scale(2, 2); - // const Rect scaledClipBounds = Rect.fromLTRB(5.1, 5.65, 10.2, 12.85); - // expect(canvas.getLocalClipBounds(), closeToRect(scaledClipBounds)); - // // Destination bounds are unaffected by transform - // expect(canvas.getDestinationClipBounds(), closeToRect(clipBounds)); - // canvas.restore(); - - // // save/restore returned the values to their original values - // expect(canvas.getLocalClipBounds(), initialLocalBounds); - // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - // }); - - // test('Canvas.clipRRect with matrix affects canvas.getClipBounds', () async { - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // const Rect clipBounds1 = Rect.fromLTRB(0.0, 0.0, 10.0, 10.0); - // const Rect clipBounds2 = Rect.fromLTRB(10.0, 10.0, 20.0, 20.0); - // final RRect clip1 = RRect.fromRectAndRadius(clipBounds1, const Radius.circular(3)); - // final RRect clip2 = RRect.fromRectAndRadius(clipBounds2, const Radius.circular(3)); - - // canvas.save(); - // canvas.clipRRect(clip1, doAntiAlias: false); - // canvas.translate(0, 10.0); - // canvas.clipRRect(clip1, doAntiAlias: false); - // expect(canvas.getDestinationClipBounds().isEmpty, isTrue); - // canvas.restore(); - - // canvas.save(); - // canvas.clipRRect(clip1, doAntiAlias: false); - // canvas.translate(-10.0, -10.0); - // canvas.clipRRect(clip2, doAntiAlias: false); - // expect(canvas.getDestinationClipBounds(), clipBounds1); - // canvas.restore(); - // }); - - // test('Canvas.clipPath(doAA=true) affects canvas.getClipBounds', () async { - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); - // const Rect clipExpandedBounds = Rect.fromLTRB(10, 11, 21, 26); - // final Path clip = Path()..addRect(clipBounds)..addOval(clipBounds); - // canvas.clipPath(clip); - - // // Save initial return values for testing restored values - // final Rect initialLocalBounds = canvas.getLocalClipBounds(); - // final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); - // expect(initialLocalBounds, closeToRect(clipExpandedBounds)); - // expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); - - // canvas.save(); - // canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15)); - // // Both clip bounds have changed - // expect(canvas.getLocalClipBounds(), notCloseToRect(clipExpandedBounds)); - // expect(canvas.getDestinationClipBounds(), notCloseToRect(clipExpandedBounds)); - // // Previous return values have not changed - // expect(initialLocalBounds, closeToRect(clipExpandedBounds)); - // expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); - // canvas.restore(); - - // // save/restore returned the values to their original values - // expect(canvas.getLocalClipBounds(), initialLocalBounds); - // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - - // canvas.save(); - // canvas.scale(2, 2); - // const Rect scaledExpandedBounds = Rect.fromLTRB(5, 5.5, 10.5, 13); - // expect(canvas.getLocalClipBounds(), closeToRect(scaledExpandedBounds)); - // // Destination bounds are unaffected by transform - // expect(canvas.getDestinationClipBounds(), closeToRect(clipExpandedBounds)); - // canvas.restore(); - - // // save/restore returned the values to their original values - // expect(canvas.getLocalClipBounds(), initialLocalBounds); - // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - // }); - - // test('Canvas.clipPath(doAA=false) affects canvas.getClipBounds', () async { - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); - // final Path clip = Path()..addRect(clipBounds)..addOval(clipBounds); - // canvas.clipPath(clip, doAntiAlias: false); - - // // Save initial return values for testing restored values - // final Rect initialLocalBounds = canvas.getLocalClipBounds(); - // final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); - // expect(initialLocalBounds, closeToRect(clipBounds)); - // expect(initialDestinationBounds, closeToRect(clipBounds)); - - // canvas.save(); - // canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15), doAntiAlias: false); - // // Both clip bounds have changed - // expect(canvas.getLocalClipBounds(), notCloseToRect(clipBounds)); - // expect(canvas.getDestinationClipBounds(), notCloseToRect(clipBounds)); - // // Previous return values have not changed - // expect(initialLocalBounds, closeToRect(clipBounds)); - // expect(initialDestinationBounds, closeToRect(clipBounds)); - // canvas.restore(); - - // // save/restore returned the values to their original values - // expect(canvas.getLocalClipBounds(), initialLocalBounds); - // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - - // canvas.save(); - // canvas.scale(2, 2); - // const Rect scaledClipBounds = Rect.fromLTRB(5.1, 5.65, 10.2, 12.85); - // expect(canvas.getLocalClipBounds(), closeToRect(scaledClipBounds)); - // // Destination bounds are unaffected by transform - // expect(canvas.getDestinationClipBounds(), closeToRect(clipBounds)); - // canvas.restore(); - - // // save/restore returned the values to their original values - // expect(canvas.getLocalClipBounds(), initialLocalBounds); - // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - // }); - - // test('Canvas.clipPath with matrix affects canvas.getClipBounds', () async { - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // const Rect clipBounds1 = Rect.fromLTRB(0.0, 0.0, 10.0, 10.0); - // const Rect clipBounds2 = Rect.fromLTRB(10.0, 10.0, 20.0, 20.0); - // final Path clip1 = Path()..addRect(clipBounds1)..addOval(clipBounds1); - // final Path clip2 = Path()..addRect(clipBounds2)..addOval(clipBounds2); - - // canvas.save(); - // canvas.clipPath(clip1, doAntiAlias: false); - // canvas.translate(0, 10.0); - // canvas.clipPath(clip1, doAntiAlias: false); - // expect(canvas.getDestinationClipBounds().isEmpty, isTrue); - // canvas.restore(); - - // canvas.save(); - // canvas.clipPath(clip1, doAntiAlias: false); - // canvas.translate(-10.0, -10.0); - // canvas.clipPath(clip2, doAntiAlias: false); - // expect(canvas.getDestinationClipBounds(), clipBounds1); - // canvas.restore(); - // }); - - // test('Canvas.clipRect(diff) does not affect canvas.getClipBounds', () async { - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); - // canvas.clipRect(clipBounds, doAntiAlias: false); - - // // Save initial return values for testing restored values - // final Rect initialLocalBounds = canvas.getLocalClipBounds(); - // final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); - // expect(initialLocalBounds, closeToRect(clipBounds)); - // expect(initialDestinationBounds, closeToRect(clipBounds)); - - // canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15), clipOp: ClipOp.difference, doAntiAlias: false); - // expect(canvas.getLocalClipBounds(), initialLocalBounds); - // expect(canvas.getDestinationClipBounds(), initialDestinationBounds); - // }); - - // test('RestoreToCount can work', () async { - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // canvas.save(); - // canvas.save(); - // canvas.save(); - // canvas.save(); - // canvas.save(); - // expect(canvas.getSaveCount(), equals(6)); - // canvas.restoreToCount(2); - // expect(canvas.getSaveCount(), equals(2)); - // canvas.restore(); - // expect(canvas.getSaveCount(), equals(1)); - // }); - - // test('RestoreToCount count less than 1, the stack should be reset', () async { - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // canvas.save(); - // canvas.save(); - // canvas.save(); - // canvas.save(); - // canvas.save(); - // expect(canvas.getSaveCount(), equals(6)); - // canvas.restoreToCount(0); - // expect(canvas.getSaveCount(), equals(1)); - // }); - - // test('RestoreToCount count greater than current [getSaveCount], nothing would happend', () async { - // final PictureRecorder recorder = PictureRecorder(); - // final Canvas canvas = Canvas(recorder); - // canvas.save(); - // canvas.save(); - // canvas.save(); - // canvas.save(); - // canvas.save(); - // expect(canvas.getSaveCount(), equals(6)); - // canvas.restoreToCount(canvas.getSaveCount() + 1); - // expect(canvas.getSaveCount(), equals(6)); - // }); - - // await null; - // final command = [ - // Platform.environment['GOLDCTL']!, - // 'imgtest', - // 'finalize', - // ]; - // print(command.join(' ')); - // final result = await LocalProcessManager().run(command); - // print(result); - // print(result.exitCode); - // print(result.stdout); - // print(result.stderr); + }); + + Gradient makeGradient() { + return Gradient.linear( + Offset.zero, + const Offset(100, 100), + const [Color(0xFF4C4D52), Color(0xFF202124)], + ); + } + + test('Simple gradient, which is implicitly dithered', () async { + final Image image = await toImage((Canvas canvas) { + final Paint paint = Paint()..shader = makeGradient(); + canvas.drawPaint(paint); + }, 100, 100); + expect(image.width, equals(100)); + expect(image.height, equals(100)); + + await comparer.addGoldenImage(image, 'canvas_test_dithered_gradient.png'); + }); + + test('Null values allowed for drawAtlas methods', () async { + final Image image = await createImage(100, 100); + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + const Rect rect = Rect.fromLTWH(0, 0, 100, 100); + final RSTransform transform = RSTransform(1, 0, 0, 0); + const Color color = Color(0x00000000); + final Paint paint = Paint(); + canvas.drawAtlas(image, [transform], [rect], [color], BlendMode.src, rect, paint); + canvas.drawAtlas(image, [transform], [rect], [color], BlendMode.src, null, paint); + canvas.drawAtlas(image, [transform], [rect], [], null, rect, paint); + canvas.drawAtlas(image, [transform], [rect], null, null, rect, paint); + canvas.drawRawAtlas(image, Float32List(0), Float32List(0), Int32List(0), BlendMode.src, rect, paint); + canvas.drawRawAtlas(image, Float32List(0), Float32List(0), Int32List(0), BlendMode.src, null, paint); + canvas.drawRawAtlas(image, Float32List(0), Float32List(0), null, null, rect, paint); + + expectAssertion(() => canvas.drawAtlas(image, [transform], [rect], [color], null, rect, paint)); + }); + + test('Data lengths must match for drawAtlas methods', () async { + final Image image = await createImage(100, 100); + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + const Rect rect = Rect.fromLTWH(0, 0, 100, 100); + final RSTransform transform = RSTransform(1, 0, 0, 0); + const Color color = Color(0x00000000); + final Paint paint = Paint(); + canvas.drawAtlas(image, [transform], [rect], [color], BlendMode.src, rect, paint); + canvas.drawAtlas(image, [transform, transform], [rect, rect], [color, color], BlendMode.src, rect, paint); + canvas.drawAtlas(image, [transform], [rect], [], null, rect, paint); + canvas.drawAtlas(image, [transform], [rect], null, null, rect, paint); + canvas.drawRawAtlas(image, Float32List(0), Float32List(0), Int32List(0), BlendMode.src, rect, paint); + canvas.drawRawAtlas(image, Float32List(4), Float32List(4), Int32List(1), BlendMode.src, rect, paint); + canvas.drawRawAtlas(image, Float32List(4), Float32List(4), null, null, rect, paint); + + expectArgumentError(() => canvas.drawAtlas(image, [transform], [], [color], BlendMode.src, rect, paint)); + expectArgumentError(() => canvas.drawAtlas(image, [], [rect], [color], BlendMode.src, rect, paint)); + expectArgumentError(() => canvas.drawAtlas(image, [transform], [rect], [color, color], BlendMode.src, rect, paint)); + expectArgumentError(() => canvas.drawAtlas(image, [transform], [rect, rect], [color], BlendMode.src, rect, paint)); + expectArgumentError(() => canvas.drawAtlas(image, [transform, transform], [rect], [color], BlendMode.src, rect, paint)); + expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(3), Float32List(3), null, null, rect, paint)); + expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(4), Float32List(0), null, null, rect, paint)); + expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(0), Float32List(4), null, null, rect, paint)); + expectArgumentError(() => canvas.drawRawAtlas(image, Float32List(4), Float32List(4), Int32List(2), BlendMode.src, rect, paint)); + }); + + test('Canvas preserves perspective data in Matrix4', () async { + const double rotateAroundX = pi / 6; // 30 degrees + const double rotateAroundY = pi / 9; // 20 degrees + const int width = 150; + const int height = 150; + const Color black = Color.fromARGB(255, 0, 0, 0); + const Color green = Color.fromARGB(255, 0, 255, 0); + void paint(Canvas canvas, CanvasCallback rotate) { + canvas.translate(width * 0.5, height * 0.5); + rotate(canvas); + const double width3 = width / 3.0; + const double width5 = width / 5.0; + const double width10 = width / 10.0; + canvas.drawRect(const Rect.fromLTRB(-width3, -width3, width3, width3), Paint()..color = green); + canvas.drawRect(const Rect.fromLTRB(-width5, -width5, -width10, width5), Paint()..color = black); + canvas.drawRect(const Rect.fromLTRB(-width5, -width5, width5, -width10), Paint()..color = black); + } + + final Image incrementalMatrixImage = await toImage((Canvas canvas) { + paint(canvas, (Canvas canvas) { + final Matrix4 matrix = Matrix4.identity(); + matrix.setEntry(3, 2, 0.001); + canvas.transform(matrix.storage); + matrix.setRotationX(rotateAroundX); + canvas.transform(matrix.storage); + matrix.setRotationY(rotateAroundY); + canvas.transform(matrix.storage); + }); + }, width, height); + final Image combinedMatrixImage = await toImage((Canvas canvas) { + paint(canvas, (Canvas canvas) { + final Matrix4 matrix = Matrix4.identity(); + matrix.setEntry(3, 2, 0.001); + matrix.rotateX(rotateAroundX); + matrix.rotateY(rotateAroundY); + canvas.transform(matrix.storage); + }); + }, width, height); + + final bool areEqual = await comparer.fuzzyCompareImages(incrementalMatrixImage, combinedMatrixImage); + + expect(areEqual, true); + }); + + test('Path effects from Paragraphs do not affect further rendering', () async { + void drawText(Canvas canvas, String content, Offset offset, + {TextDecorationStyle style = TextDecorationStyle.solid}) { + final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle()); + builder.pushStyle(TextStyle( + decoration: TextDecoration.underline, + decorationColor: const Color(0xFF0000FF), + fontFamily: 'Ahem', + fontSize: 10, + color: const Color(0xFF000000), + decorationStyle: style, + )); + builder.addText(content); + final Paragraph paragraph = builder.build(); + paragraph.layout(const ParagraphConstraints(width: 100)); + canvas.drawParagraph(paragraph, offset); + } + + final Image image = await toImage((Canvas canvas) { + canvas.drawColor(const Color(0xFFFFFFFF), BlendMode.srcOver); + final Paint paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 5; + drawText(canvas, 'Hello World', const Offset(20, 10)); + canvas.drawCircle(const Offset(150, 25), 15, paint..color = const Color(0xFF00FF00)); + drawText(canvas, 'Regular text', const Offset(20, 60)); + canvas.drawCircle(const Offset(150, 75), 15, paint..color = const Color(0xFFFFFF00)); + drawText(canvas, 'Dotted text', const Offset(20, 110), style: TextDecorationStyle.dotted); + canvas.drawCircle(const Offset(150, 125), 15, paint..color = const Color(0xFFFF0000)); + drawText(canvas, 'Dashed text', const Offset(20, 160), style: TextDecorationStyle.dashed); + canvas.drawCircle(const Offset(150, 175), 15, paint..color = const Color(0xFFFF0000)); + drawText(canvas, 'Wavy text', const Offset(20, 210), style: TextDecorationStyle.wavy); + canvas.drawCircle(const Offset(150, 225), 15, paint..color = const Color(0xFFFF0000)); + }, 200, 250); + expect(image.width, equals(200)); + expect(image.height, equals(250)); + + await comparer.addGoldenImage(image, 'dotted_path_effect_mixed_with_stroked_geometry.png'); + }); + + test('Gradients with matrices in Paragraphs render correctly', () async { + final Image image = await toImage((Canvas canvas) { + final Paint p = Paint(); + final Float64List transform = Float64List.fromList([ + 86.80000129342079, + 0.0, + 0.0, + 0.0, + 0.0, + 94.5, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 60.0, + 224.310302734375, + 0.0, + 1.0 + ]); + p.shader = Gradient.radial( + const Offset(2.5, 0.33), + 0.8, + [ + const Color(0xffff0000), + const Color(0xff00ff00), + const Color(0xff0000ff), + const Color(0xffff00ff) + ], + [0.0, 0.3, 0.7, 0.9], + TileMode.mirror, + transform, + const Offset(2.55, 0.4)); + final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle()); + builder.pushStyle(TextStyle( + foreground: p, + fontSize: 200, + )); + builder.addText('Woodstock!'); + final Paragraph paragraph = builder.build(); + paragraph.layout(const ParagraphConstraints(width: 1000)); + canvas.drawParagraph(paragraph, const Offset(10, 150)); + }, 600, 400); + expect(image.width, equals(600)); + expect(image.height, equals(400)); + + await comparer.addGoldenImage(image, 'text_with_gradient_with_matrix.png'); + }); + + test('toImageSync - too big', () async { + PictureRecorder recorder = PictureRecorder(); + Canvas canvas = Canvas(recorder); + canvas.drawPaint(Paint()..color = const Color(0xFF123456)); + final Picture picture = recorder.endRecording(); + final Image image = picture.toImageSync(300000, 4000000); + picture.dispose(); + + expect(image.width, 300000); + expect(image.height, 4000000); + + recorder = PictureRecorder(); + canvas = Canvas(recorder); + + if (impellerEnabled) { + // Impeller tries to automagically scale this. See + // https://github.com/flutter/flutter/issues/128885 + canvas.drawImage(image, Offset.zero, Paint()); + return; + } + // On a slower CI machine, the raster thread may get behind the UI thread + // here. However, once the image is in an error state it will immediately + // throw on subsequent attempts. + bool caughtException = false; + for (int iterations = 0; iterations < 1000; iterations += 1) { + try { + canvas.drawImage(image, Offset.zero, Paint()); + } on PictureRasterizationException catch (e) { + caughtException = true; + expect( + e.message, + contains('unable to create bitmap render target at specified size ${image.width}x${image.height}'), + ); + break; + } + // Let the event loop turn. + await Future.delayed(const Duration(milliseconds: 1)); + } + expect(caughtException, true); + expect( + () => canvas.drawImageRect(image, Rect.zero, Rect.zero, Paint()), + throwsException, + ); + expect( + () => canvas.drawImageNine(image, Rect.zero, Rect.zero, Paint()), + throwsException, + ); + expect( + () => canvas.drawAtlas(image, [], [], null, null, null, Paint()), + throwsException, + ); + }); + + test('toImageSync - succeeds', () async { + PictureRecorder recorder = PictureRecorder(); + Canvas canvas = Canvas(recorder); + canvas.drawPaint(Paint()..color = const Color(0xFF123456)); + final Picture picture = recorder.endRecording(); + final Image image = picture.toImageSync(30, 40); + picture.dispose(); + + expect(image.width, 30); + expect(image.height, 40); + + recorder = PictureRecorder(); + canvas = Canvas(recorder); + expect( + () => canvas.drawImage(image, Offset.zero, Paint()), + returnsNormally, + ); + expect( + () => canvas.drawImageRect(image, Rect.zero, Rect.zero, Paint()), + returnsNormally, + ); + expect( + () => canvas.drawImageNine(image, Rect.zero, Rect.zero, Paint()), + returnsNormally, + ); + expect( + () => canvas.drawAtlas(image, [], [], null, null, null, Paint()), + returnsNormally, + ); + }); + + test('toImageSync - toByteData', () async { + const Color color = Color(0xFF123456); + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + canvas.drawPaint(Paint()..color = color); + final Picture picture = recorder.endRecording(); + final Image image = picture.toImageSync(6, 8); + picture.dispose(); + + expect(image.width, 6); + expect(image.height, 8); + + final ByteData? data = await image.toByteData(); + + expect(data, isNotNull); + expect(data!.lengthInBytes, 6 * 8 * 4); + expect(data.buffer.asUint8List()[0], 0x12); + expect(data.buffer.asUint8List()[1], 0x34); + expect(data.buffer.asUint8List()[2], 0x56); + expect(data.buffer.asUint8List()[3], 0xFF); + }); + + test('toImage and toImageSync have identical contents', () async { + // Note: on linux this stil seems to be different. + // TODO(jonahwilliams): https://github.com/flutter/flutter/issues/108835 + if (Platform.isLinux) { + return; + } + + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + canvas.drawRect( + const Rect.fromLTWH(20, 20, 100, 100), + Paint()..color = const Color(0xA0FF6D00), + ); + final Picture picture = recorder.endRecording(); + final Image toImageImage = await picture.toImage(200, 200); + final Image toImageSyncImage = picture.toImageSync(200, 200); + + // To trigger observable difference in alpha, draw image + // on a second canvas. + Future drawOnCanvas(Image image) async { + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + canvas.drawPaint(Paint()..color = const Color(0x4FFFFFFF)); + canvas.drawImage(image, Offset.zero, Paint()); + final Image resultImage = await recorder.endRecording().toImage(200, 200); + return (await resultImage.toByteData())!; + } + + final ByteData dataSync = await drawOnCanvas(toImageImage); + final ByteData data = await drawOnCanvas(toImageSyncImage); + expect(data, listEquals(dataSync)); + }); + + test('Canvas.drawParagraph throws when Paragraph.layout was not called', () async { + // Regression test for https://github.com/flutter/flutter/issues/97172 + bool assertsEnabled = false; + assert(() { + assertsEnabled = true; + return true; + }()); + + Object? error; + try { + await toImage((Canvas canvas) { + final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle()); + builder.addText('Woodstock!'); + final Paragraph woodstock = builder.build(); + canvas.drawParagraph(woodstock, const Offset(0, 50)); + }, 100, 100); + } catch (e) { + error = e; + } + if (assertsEnabled) { + expect(error, isNotNull); + } else { + expect(error, isNull); + } + }); + + Future drawText(String text) { + return toImage((Canvas canvas) { + final ParagraphBuilder builder = ParagraphBuilder(ParagraphStyle( + fontFamily: 'RobotoSerif', + fontStyle: FontStyle.normal, + fontWeight: FontWeight.normal, + fontSize: 15.0, + )); + builder.pushStyle(TextStyle(color: const Color(0xFF0000FF))); + builder.addText(text); + + final Paragraph paragraph = builder.build(); + paragraph.layout(const ParagraphConstraints(width: 20 * 5.0)); + + canvas.drawParagraph(paragraph, Offset.zero); + }, 100, 100); + } + + test('Canvas.drawParagraph renders tab as space instead of tofu', () async { + // Skia renders a tofu if the font does not have a glyph for a character. + // However, Flutter opts-in to a Skia feature to render tabs as a single space. + // See: https://github.com/flutter/flutter/issues/79153 + final File file = File(path.join('flutter', 'testing', 'resources', 'RobotoSlab-VariableFont_wght.ttf')); + final Uint8List fontData = await file.readAsBytes(); + await loadFontFromList(fontData, fontFamily: 'RobotoSerif'); + + // The backspace character, \b, does not have a corresponding glyph and is rendered as a tofu. + final Image tabImage = await drawText('>\t<'); + final Image spaceImage = await drawText('> <'); + final Image tofuImage = await drawText('>\b<'); + + // The tab's image should be identical to the space's image but not the tofu's image. + final bool tabToSpaceComparison = await comparer.fuzzyCompareImages(tabImage, spaceImage); + final bool tabToTofuComparison = await comparer.fuzzyCompareImages(tabImage, tofuImage); + + expect(tabToSpaceComparison, isTrue); + expect(tabToTofuComparison, isFalse); + }); + + Matcher closeToTransform(Float64List expected) => (dynamic v) { + Expect.type(v); + final Float64List value = v as Float64List; + expect(expected.length, equals(16)); + expect(value.length, equals(16)); + for (int r = 0; r < 4; r++) { + for (int c = 0; c < 4; c++) { + final double vActual = value[r*4 + c]; + final double vExpected = expected[r*4 + c]; + if ((vActual - vExpected).abs() > 1e-10) { + Expect.fail('matrix mismatch at $r, $c, $vActual not close to $vExpected'); + } + } + } + }; + + Matcher notCloseToTransform(Float64List expected) => (dynamic v) { + Expect.type(v); + final Float64List value = v as Float64List; + expect(expected.length, equals(16)); + expect(value.length, equals(16)); + for (int r = 0; r < 4; r++) { + for (int c = 0; c < 4; c++) { + final double vActual = value[r*4 + c]; + final double vExpected = expected[r*4 + c]; + if ((vActual - vExpected).abs() > 1e-10) { + return; + } + } + } + Expect.fail('$value is too close to $expected'); + }; + + test('Canvas.translate affects canvas.getTransform', () async { + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + canvas.translate(12, 14.5); + final Float64List matrix = Matrix4.translationValues(12, 14.5, 0).storage; + final Float64List curMatrix = canvas.getTransform(); + expect(curMatrix, closeToTransform(matrix)); + canvas.translate(10, 10); + final Float64List newCurMatrix = canvas.getTransform(); + expect(newCurMatrix, notCloseToTransform(matrix)); + expect(curMatrix, closeToTransform(matrix)); + }); + + test('Canvas.scale affects canvas.getTransform', () async { + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + canvas.scale(12, 14.5); + final Float64List matrix = Matrix4.diagonal3Values(12, 14.5, 1).storage; + final Float64List curMatrix = canvas.getTransform(); + expect(curMatrix, closeToTransform(matrix)); + canvas.scale(10, 10); + final Float64List newCurMatrix = canvas.getTransform(); + expect(newCurMatrix, notCloseToTransform(matrix)); + expect(curMatrix, closeToTransform(matrix)); + }); + + test('Canvas.rotate affects canvas.getTransform', () async { + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + canvas.rotate(pi); + final Float64List matrix = Matrix4.rotationZ(pi).storage; + final Float64List curMatrix = canvas.getTransform(); + expect(curMatrix, closeToTransform(matrix)); + canvas.rotate(pi / 2); + final Float64List newCurMatrix = canvas.getTransform(); + expect(newCurMatrix, notCloseToTransform(matrix)); + expect(curMatrix, closeToTransform(matrix)); + }); + + test('Canvas.skew affects canvas.getTransform', () async { + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + canvas.skew(12, 14.5); + final Float64List matrix = (Matrix4.identity()..setEntry(0, 1, 12)..setEntry(1, 0, 14.5)).storage; + final Float64List curMatrix = canvas.getTransform(); + expect(curMatrix, closeToTransform(matrix)); + canvas.skew(10, 10); + final Float64List newCurMatrix = canvas.getTransform(); + expect(newCurMatrix, notCloseToTransform(matrix)); + expect(curMatrix, closeToTransform(matrix)); + }); + + test('Canvas.transform affects canvas.getTransform', () async { + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + final Float64List matrix = (Matrix4.identity()..translate(12.0, 14.5)..scale(12.0, 14.5)).storage; + canvas.transform(matrix); + final Float64List curMatrix = canvas.getTransform(); + expect(curMatrix, closeToTransform(matrix)); + canvas.translate(10, 10); + final Float64List newCurMatrix = canvas.getTransform(); + expect(newCurMatrix, notCloseToTransform(matrix)); + expect(curMatrix, closeToTransform(matrix)); + }); + + Matcher closeToRect(Rect expected) => (dynamic v) { + Expect.type(v); + final Rect value = v as Rect; + expect(value.left, closeTo(expected.left, 1e-6)); + expect(value.top, closeTo(expected.top, 1e-6)); + expect(value.right, closeTo(expected.right, 1e-6)); + expect(value.bottom, closeTo(expected.bottom, 1e-6)); + }; + + Matcher notCloseToRect(Rect expected) => (dynamic v) { + Expect.type(v); + final Rect value = v as Rect; + if ((value.left - expected.left).abs() > 1e-6 || + (value.top - expected.top).abs() > 1e-6 || + (value.right - expected.right).abs() > 1e-6 || + (value.bottom - expected.bottom).abs() > 1e-6) { + return; + } + Expect.fail('$value is too close to $expected'); + }; + + test('Canvas.clipRect(doAA=true) affects canvas.getClipBounds', () async { + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); + const Rect clipExpandedBounds = Rect.fromLTRB(10, 11, 21, 26); + canvas.clipRect(clipBounds); + + // Save initial return values for testing restored values + final Rect initialLocalBounds = canvas.getLocalClipBounds(); + final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); + expect(initialLocalBounds, closeToRect(clipExpandedBounds)); + expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); + + canvas.save(); + canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15)); + // Both clip bounds have changed + expect(canvas.getLocalClipBounds(), notCloseToRect(clipExpandedBounds)); + expect(canvas.getDestinationClipBounds(), notCloseToRect(clipExpandedBounds)); + // Previous return values have not changed + expect(initialLocalBounds, closeToRect(clipExpandedBounds)); + expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); + canvas.restore(); + + // save/restore returned the values to their original values + expect(canvas.getLocalClipBounds(), initialLocalBounds); + expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + + canvas.save(); + canvas.scale(2, 2); + const Rect scaledExpandedBounds = Rect.fromLTRB(5, 5.5, 10.5, 13); + expect(canvas.getLocalClipBounds(), closeToRect(scaledExpandedBounds)); + // Destination bounds are unaffected by transform + expect(canvas.getDestinationClipBounds(), closeToRect(clipExpandedBounds)); + canvas.restore(); + + // save/restore returned the values to their original values + expect(canvas.getLocalClipBounds(), initialLocalBounds); + expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + }); + + test('Canvas.clipRect(doAA=false) affects canvas.getClipBounds', () async { + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); + canvas.clipRect(clipBounds, doAntiAlias: false); + + // Save initial return values for testing restored values + final Rect initialLocalBounds = canvas.getLocalClipBounds(); + final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); + expect(initialLocalBounds, closeToRect(clipBounds)); + expect(initialDestinationBounds, closeToRect(clipBounds)); + + canvas.save(); + canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15)); + // Both clip bounds have changed + expect(canvas.getLocalClipBounds(), notCloseToRect(clipBounds)); + expect(canvas.getDestinationClipBounds(), notCloseToRect(clipBounds)); + // Previous return values have not changed + expect(initialLocalBounds, closeToRect(clipBounds)); + expect(initialDestinationBounds, closeToRect(clipBounds)); + canvas.restore(); + + // save/restore returned the values to their original values + expect(canvas.getLocalClipBounds(), initialLocalBounds); + expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + + canvas.save(); + canvas.scale(2, 2); + const Rect scaledClipBounds = Rect.fromLTRB(5.1, 5.65, 10.2, 12.85); + expect(canvas.getLocalClipBounds(), closeToRect(scaledClipBounds)); + // Destination bounds are unaffected by transform + expect(canvas.getDestinationClipBounds(), closeToRect(clipBounds)); + canvas.restore(); + + // save/restore returned the values to their original values + expect(canvas.getLocalClipBounds(), initialLocalBounds); + expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + }); + + test('Canvas.clipRect with matrix affects canvas.getClipBounds', () async { + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + const Rect clipBounds1 = Rect.fromLTRB(0.0, 0.0, 10.0, 10.0); + const Rect clipBounds2 = Rect.fromLTRB(10.0, 10.0, 20.0, 20.0); + + canvas.save(); + canvas.clipRect(clipBounds1, doAntiAlias: false); + canvas.translate(0, 10.0); + canvas.clipRect(clipBounds1, doAntiAlias: false); + expect(canvas.getDestinationClipBounds().isEmpty, isTrue); + canvas.restore(); + + canvas.save(); + canvas.clipRect(clipBounds1, doAntiAlias: false); + canvas.translate(-10.0, -10.0); + canvas.clipRect(clipBounds2, doAntiAlias: false); + expect(canvas.getDestinationClipBounds(), clipBounds1); + canvas.restore(); + }); + + test('Canvas.clipRRect(doAA=true) affects canvas.getClipBounds', () async { + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); + const Rect clipExpandedBounds = Rect.fromLTRB(10, 11, 21, 26); + final RRect clip = RRect.fromRectAndRadius(clipBounds, const Radius.circular(3)); + canvas.clipRRect(clip); + + // Save initial return values for testing restored values + final Rect initialLocalBounds = canvas.getLocalClipBounds(); + final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); + expect(initialLocalBounds, closeToRect(clipExpandedBounds)); + expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); + + canvas.save(); + canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15)); + // Both clip bounds have changed + expect(canvas.getLocalClipBounds(), notCloseToRect(clipExpandedBounds)); + expect(canvas.getDestinationClipBounds(), notCloseToRect(clipExpandedBounds)); + // Previous return values have not changed + expect(initialLocalBounds, closeToRect(clipExpandedBounds)); + expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); + canvas.restore(); + + // save/restore returned the values to their original values + expect(canvas.getLocalClipBounds(), initialLocalBounds); + expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + + canvas.save(); + canvas.scale(2, 2); + const Rect scaledExpandedBounds = Rect.fromLTRB(5, 5.5, 10.5, 13); + expect(canvas.getLocalClipBounds(), closeToRect(scaledExpandedBounds)); + // Destination bounds are unaffected by transform + expect(canvas.getDestinationClipBounds(), closeToRect(clipExpandedBounds)); + canvas.restore(); + + // save/restore returned the values to their original values + expect(canvas.getLocalClipBounds(), initialLocalBounds); + expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + }); + + test('Canvas.clipRRect(doAA=false) affects canvas.getClipBounds', () async { + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); + final RRect clip = RRect.fromRectAndRadius(clipBounds, const Radius.circular(3)); + canvas.clipRRect(clip, doAntiAlias: false); + + // Save initial return values for testing restored values + final Rect initialLocalBounds = canvas.getLocalClipBounds(); + final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); + expect(initialLocalBounds, closeToRect(clipBounds)); + expect(initialDestinationBounds, closeToRect(clipBounds)); + + canvas.save(); + canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15), doAntiAlias: false); + // Both clip bounds have changed + expect(canvas.getLocalClipBounds(), notCloseToRect(clipBounds)); + expect(canvas.getDestinationClipBounds(), notCloseToRect(clipBounds)); + // Previous return values have not changed + expect(initialLocalBounds, closeToRect(clipBounds)); + expect(initialDestinationBounds, closeToRect(clipBounds)); + canvas.restore(); + + // save/restore returned the values to their original values + expect(canvas.getLocalClipBounds(), initialLocalBounds); + expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + + canvas.save(); + canvas.scale(2, 2); + const Rect scaledClipBounds = Rect.fromLTRB(5.1, 5.65, 10.2, 12.85); + expect(canvas.getLocalClipBounds(), closeToRect(scaledClipBounds)); + // Destination bounds are unaffected by transform + expect(canvas.getDestinationClipBounds(), closeToRect(clipBounds)); + canvas.restore(); + + // save/restore returned the values to their original values + expect(canvas.getLocalClipBounds(), initialLocalBounds); + expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + }); + + test('Canvas.clipRRect with matrix affects canvas.getClipBounds', () async { + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + const Rect clipBounds1 = Rect.fromLTRB(0.0, 0.0, 10.0, 10.0); + const Rect clipBounds2 = Rect.fromLTRB(10.0, 10.0, 20.0, 20.0); + final RRect clip1 = RRect.fromRectAndRadius(clipBounds1, const Radius.circular(3)); + final RRect clip2 = RRect.fromRectAndRadius(clipBounds2, const Radius.circular(3)); + + canvas.save(); + canvas.clipRRect(clip1, doAntiAlias: false); + canvas.translate(0, 10.0); + canvas.clipRRect(clip1, doAntiAlias: false); + expect(canvas.getDestinationClipBounds().isEmpty, isTrue); + canvas.restore(); + + canvas.save(); + canvas.clipRRect(clip1, doAntiAlias: false); + canvas.translate(-10.0, -10.0); + canvas.clipRRect(clip2, doAntiAlias: false); + expect(canvas.getDestinationClipBounds(), clipBounds1); + canvas.restore(); + }); + + test('Canvas.clipPath(doAA=true) affects canvas.getClipBounds', () async { + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); + const Rect clipExpandedBounds = Rect.fromLTRB(10, 11, 21, 26); + final Path clip = Path()..addRect(clipBounds)..addOval(clipBounds); + canvas.clipPath(clip); + + // Save initial return values for testing restored values + final Rect initialLocalBounds = canvas.getLocalClipBounds(); + final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); + expect(initialLocalBounds, closeToRect(clipExpandedBounds)); + expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); + + canvas.save(); + canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15)); + // Both clip bounds have changed + expect(canvas.getLocalClipBounds(), notCloseToRect(clipExpandedBounds)); + expect(canvas.getDestinationClipBounds(), notCloseToRect(clipExpandedBounds)); + // Previous return values have not changed + expect(initialLocalBounds, closeToRect(clipExpandedBounds)); + expect(initialDestinationBounds, closeToRect(clipExpandedBounds)); + canvas.restore(); + + // save/restore returned the values to their original values + expect(canvas.getLocalClipBounds(), initialLocalBounds); + expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + + canvas.save(); + canvas.scale(2, 2); + const Rect scaledExpandedBounds = Rect.fromLTRB(5, 5.5, 10.5, 13); + expect(canvas.getLocalClipBounds(), closeToRect(scaledExpandedBounds)); + // Destination bounds are unaffected by transform + expect(canvas.getDestinationClipBounds(), closeToRect(clipExpandedBounds)); + canvas.restore(); + + // save/restore returned the values to their original values + expect(canvas.getLocalClipBounds(), initialLocalBounds); + expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + }); + + test('Canvas.clipPath(doAA=false) affects canvas.getClipBounds', () async { + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); + final Path clip = Path()..addRect(clipBounds)..addOval(clipBounds); + canvas.clipPath(clip, doAntiAlias: false); + + // Save initial return values for testing restored values + final Rect initialLocalBounds = canvas.getLocalClipBounds(); + final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); + expect(initialLocalBounds, closeToRect(clipBounds)); + expect(initialDestinationBounds, closeToRect(clipBounds)); + + canvas.save(); + canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15), doAntiAlias: false); + // Both clip bounds have changed + expect(canvas.getLocalClipBounds(), notCloseToRect(clipBounds)); + expect(canvas.getDestinationClipBounds(), notCloseToRect(clipBounds)); + // Previous return values have not changed + expect(initialLocalBounds, closeToRect(clipBounds)); + expect(initialDestinationBounds, closeToRect(clipBounds)); + canvas.restore(); + + // save/restore returned the values to their original values + expect(canvas.getLocalClipBounds(), initialLocalBounds); + expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + + canvas.save(); + canvas.scale(2, 2); + const Rect scaledClipBounds = Rect.fromLTRB(5.1, 5.65, 10.2, 12.85); + expect(canvas.getLocalClipBounds(), closeToRect(scaledClipBounds)); + // Destination bounds are unaffected by transform + expect(canvas.getDestinationClipBounds(), closeToRect(clipBounds)); + canvas.restore(); + + // save/restore returned the values to their original values + expect(canvas.getLocalClipBounds(), initialLocalBounds); + expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + }); + + test('Canvas.clipPath with matrix affects canvas.getClipBounds', () async { + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + const Rect clipBounds1 = Rect.fromLTRB(0.0, 0.0, 10.0, 10.0); + const Rect clipBounds2 = Rect.fromLTRB(10.0, 10.0, 20.0, 20.0); + final Path clip1 = Path()..addRect(clipBounds1)..addOval(clipBounds1); + final Path clip2 = Path()..addRect(clipBounds2)..addOval(clipBounds2); + + canvas.save(); + canvas.clipPath(clip1, doAntiAlias: false); + canvas.translate(0, 10.0); + canvas.clipPath(clip1, doAntiAlias: false); + expect(canvas.getDestinationClipBounds().isEmpty, isTrue); + canvas.restore(); + + canvas.save(); + canvas.clipPath(clip1, doAntiAlias: false); + canvas.translate(-10.0, -10.0); + canvas.clipPath(clip2, doAntiAlias: false); + expect(canvas.getDestinationClipBounds(), clipBounds1); + canvas.restore(); + }); + + test('Canvas.clipRect(diff) does not affect canvas.getClipBounds', () async { + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + const Rect clipBounds = Rect.fromLTRB(10.2, 11.3, 20.4, 25.7); + canvas.clipRect(clipBounds, doAntiAlias: false); + + // Save initial return values for testing restored values + final Rect initialLocalBounds = canvas.getLocalClipBounds(); + final Rect initialDestinationBounds = canvas.getDestinationClipBounds(); + expect(initialLocalBounds, closeToRect(clipBounds)); + expect(initialDestinationBounds, closeToRect(clipBounds)); + + canvas.clipRect(const Rect.fromLTRB(0, 0, 15, 15), clipOp: ClipOp.difference, doAntiAlias: false); + expect(canvas.getLocalClipBounds(), initialLocalBounds); + expect(canvas.getDestinationClipBounds(), initialDestinationBounds); + }); + + test('RestoreToCount can work', () async { + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + canvas.save(); + canvas.save(); + canvas.save(); + canvas.save(); + canvas.save(); + expect(canvas.getSaveCount(), equals(6)); + canvas.restoreToCount(2); + expect(canvas.getSaveCount(), equals(2)); + canvas.restore(); + expect(canvas.getSaveCount(), equals(1)); + }); + + test('RestoreToCount count less than 1, the stack should be reset', () async { + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + canvas.save(); + canvas.save(); + canvas.save(); + canvas.save(); + canvas.save(); + expect(canvas.getSaveCount(), equals(6)); + canvas.restoreToCount(0); + expect(canvas.getSaveCount(), equals(1)); + }); + + test('RestoreToCount count greater than current [getSaveCount], nothing would happend', () async { + final PictureRecorder recorder = PictureRecorder(); + final Canvas canvas = Canvas(recorder); + canvas.save(); + canvas.save(); + canvas.save(); + canvas.save(); + canvas.save(); + expect(canvas.getSaveCount(), equals(6)); + canvas.restoreToCount(canvas.getSaveCount() + 1); + expect(canvas.getSaveCount(), equals(6)); + }); + + port.close(); } Matcher listEquals(ByteData expected) => (dynamic v) { From e792bb6458c93bd1caf3cce76b0501887a759464 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Tue, 31 Oct 2023 11:17:16 -0700 Subject: [PATCH 33/44] fix async? --- testing/dart/canvas_test.dart | 4 -- testing/litetest/lib/src/test.dart | 43 ++++++++-------- .../lib/skia_gold_client.dart | 50 +++++++++---------- 3 files changed, 47 insertions(+), 50 deletions(-) diff --git a/testing/dart/canvas_test.dart b/testing/dart/canvas_test.dart index a1d19d7894f96..489b83c422a64 100644 --- a/testing/dart/canvas_test.dart +++ b/testing/dart/canvas_test.dart @@ -7,7 +7,6 @@ import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; import 'dart:ui'; -import 'dart:isolate'; import 'package:litetest/litetest.dart'; import 'package:path/path.dart' as path; @@ -127,7 +126,6 @@ void testNoCrashes() { } void main() async { - ReceivePort port = ReceivePort('test'); final ImageComparer comparer = await ImageComparer.create(verbose: true); testNoCrashes(); @@ -1034,8 +1032,6 @@ void main() async { canvas.restoreToCount(canvas.getSaveCount() + 1); expect(canvas.getSaveCount(), equals(6)); }); - - port.close(); } Matcher listEquals(ByteData expected) => (dynamic v) { diff --git a/testing/litetest/lib/src/test.dart b/testing/litetest/lib/src/test.dart index 7a3f908bfabe1..b512c6942ca28 100644 --- a/testing/litetest/lib/src/test.dart +++ b/testing/litetest/lib/src/test.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'dart:io' show stdout; import 'dart:isolate'; -import 'package:async_helper/async_minitest.dart' as m; +import 'package:async_helper/async_helper.dart' as m; import 'package:expect/expect.dart' as e; /// The state that each [Test] may be in. @@ -35,9 +35,8 @@ class Test { this.body, { StringSink? logger, TestLifecycle? lifecycle, - }) : - _logger = logger ?? stdout, - _lifecycle = lifecycle ?? _DefaultTestLifecycle(name); + }) : _logger = logger ?? stdout, + _lifecycle = lifecycle ?? _DefaultTestLifecycle(name); /// The name of the test. final String name; @@ -52,30 +51,32 @@ class Test { TestState state = TestState.allocated; final TestLifecycle _lifecycle; + final Object _testToken = Object(); /// Runs the test. /// /// Also signals the test's progress to the [TestLifecycle] object /// that was provided when the [Test] was constructed, which will eventually /// call the provided [onDone] callback. - void run({ + Future run({ void Function()? onDone, - }) { - m.test(name, () async { - await Future(() async { - state = TestState.started; - _logger.writeln('Test "$name": Started'); - try { - await body(); - state = TestState.succeeded; - _logger.writeln('Test "$name": Passed'); - } on e.ExpectException catch (e, st) { - state = TestState.failed; - _logger.writeln('Test "$name": Failed\n$e\n$st'); - } finally { - _lifecycle.onDone(cleanup: onDone); - } - }); + }) async { + m.asyncStart(); + await Future(() async { + state = TestState.started; + _logger.writeln('Test "$name": Started'); + try { + await body(); + state = TestState.succeeded; + _logger.writeln('Test "$name": Passed'); + } on e.ExpectException catch (e, st) { + state = TestState.failed; + _logger.writeln('Test "$name": Failed\n$e\n$st'); + } finally { + _lifecycle.onDone(cleanup: onDone); + } + }).then((_) { + m.asyncEnd(); }); _lifecycle.onStart(); } diff --git a/testing/skia_gold_client/lib/skia_gold_client.dart b/testing/skia_gold_client/lib/skia_gold_client.dart index e04106d7a119e..4327350b3cbb6 100644 --- a/testing/skia_gold_client/lib/skia_gold_client.dart +++ b/testing/skia_gold_client/lib/skia_gold_client.dart @@ -248,7 +248,7 @@ class SkiaGoldClient { '--work-dir', _tempPath, '--test-name', cleanTestName(testName), '--png-file', goldenFile.path, - // ..._getMatchingArguments(testName, screenshotSize, pixelDeltaThreshold, maxDifferentPixelsRate), + ..._getMatchingArguments(testName, screenshotSize, pixelDeltaThreshold, maxDifferentPixelsRate), ]; final ProcessResult result = await _runCommand(imgtestCommand); @@ -344,7 +344,7 @@ class SkiaGoldClient { '--work-dir', _tempPath, '--test-name', cleanTestName(testName), '--png-file', goldenFile.path, - // ..._getMatchingArguments(testName, screenshotSize, pixelDeltaThreshold, differentPixelsRate), + ..._getMatchingArguments(testName, screenshotSize, pixelDeltaThreshold, differentPixelsRate), ]; final ProcessResult result = await _runCommand(tryjobCommand); @@ -368,29 +368,29 @@ class SkiaGoldClient { } } - // List _getMatchingArguments( - // String testName, - // int screenshotSize, - // int pixelDeltaThreshold, - // double differentPixelsRate, - // ) { - // // The algorithm to be used when matching images. The available options are: - // // - "fuzzy": Allows for customizing the thresholds of pixel differences. - // // - "sobel": Same as "fuzzy" but performs edge detection before performing - // // a fuzzy match. - // const String algorithm = 'fuzzy'; - - // // The number of pixels in this image that are allowed to differ from the - // // baseline. It's okay for this to be a slightly high number like 10% of the - // // image size because those wrong pixels are constrained by - // // `pixelDeltaThreshold` below. - // final int maxDifferentPixels = (screenshotSize * differentPixelsRate).toInt(); - // return [ - // '--add-test-optional-key', 'image_matching_algorithm:$algorithm', - // // '--add-test-optional-key', 'fuzzy_max_different_pixels:$maxDifferentPixels', - // // '--add-test-optional-key', 'fuzzy_pixel_delta_threshold:$pixelDeltaThreshold', - // ]; - // } + List _getMatchingArguments( + String testName, + int screenshotSize, + int pixelDeltaThreshold, + double differentPixelsRate, + ) { + // The algorithm to be used when matching images. The available options are: + // - "fuzzy": Allows for customizing the thresholds of pixel differences. + // - "sobel": Same as "fuzzy" but performs edge detection before performing + // a fuzzy match. + const String algorithm = 'fuzzy'; + + // The number of pixels in this image that are allowed to differ from the + // baseline. It's okay for this to be a slightly high number like 10% of the + // image size because those wrong pixels are constrained by + // `pixelDeltaThreshold` below. + final int maxDifferentPixels = (screenshotSize * differentPixelsRate).toInt(); + return [ + '--add-test-optional-key', 'image_matching_algorithm:$algorithm', + '--add-test-optional-key', 'fuzzy_max_different_pixels:$maxDifferentPixels', + '--add-test-optional-key', 'fuzzy_pixel_delta_threshold:$pixelDeltaThreshold', + ]; + } /// Returns the latest positive digest for the given test known to Skia Gold /// at head. From 268af9ab50250f94b58c55a3c0fb7245e3944e60 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Tue, 31 Oct 2023 13:09:18 -0700 Subject: [PATCH 34/44] ok --- testing/dart/canvas_test.dart | 2 +- testing/litetest/lib/src/test.dart | 12 +++++++++--- testing/litetest/test/litetest_test.dart | 4 ++-- .../skia_gold_client/lib/skia_gold_client.dart | 15 +++++++++++++-- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/testing/dart/canvas_test.dart b/testing/dart/canvas_test.dart index 489b83c422a64..2d241c5211f48 100644 --- a/testing/dart/canvas_test.dart +++ b/testing/dart/canvas_test.dart @@ -126,7 +126,7 @@ void testNoCrashes() { } void main() async { - final ImageComparer comparer = await ImageComparer.create(verbose: true); + final ImageComparer comparer = await ImageComparer.create(); testNoCrashes(); diff --git a/testing/litetest/lib/src/test.dart b/testing/litetest/lib/src/test.dart index b512c6942ca28..c800cdb867c17 100644 --- a/testing/litetest/lib/src/test.dart +++ b/testing/litetest/lib/src/test.dart @@ -51,7 +51,7 @@ class Test { TestState state = TestState.allocated; final TestLifecycle _lifecycle; - final Object _testToken = Object(); + bool _initializedTestNameCallback = false; /// Runs the test. /// @@ -61,8 +61,14 @@ class Test { Future run({ void Function()? onDone, }) async { + if (!_initializedTestNameCallback) { + e.ExpectException.setTestNameCallback(() { + return Zone.current[#testName] as String? ?? ''; + }); + _initializedTestNameCallback = true; + } m.asyncStart(); - await Future(() async { + await runZoned(() async { state = TestState.started; _logger.writeln('Test "$name": Started'); try { @@ -75,7 +81,7 @@ class Test { } finally { _lifecycle.onDone(cleanup: onDone); } - }).then((_) { + }, zoneValues: {#testName: name}).then((_) { m.asyncEnd(); }); _lifecycle.onStart(); diff --git a/testing/litetest/test/litetest_test.dart b/testing/litetest/test/litetest_test.dart index c8e61a2b15623..8dbf021cd5c29 100644 --- a/testing/litetest/test/litetest_test.dart +++ b/testing/litetest/test/litetest_test.dart @@ -54,10 +54,10 @@ Future main() async { expect(result, true); expect(buffer.toString(), equals(''' Test "Test1": Started -Test "Test1": Passed Test "Test2": Started -Test "Test2": Passed Test "Test3": Started +Test "Test1": Passed +Test "Test2": Passed Test "Test3": Passed ''', )); diff --git a/testing/skia_gold_client/lib/skia_gold_client.dart b/testing/skia_gold_client/lib/skia_gold_client.dart index 4327350b3cbb6..c620d3d1f92ec 100644 --- a/testing/skia_gold_client/lib/skia_gold_client.dart +++ b/testing/skia_gold_client/lib/skia_gold_client.dart @@ -12,6 +12,7 @@ import 'package:process/process.dart'; const String _kGoldctlKey = 'GOLDCTL'; const String _kPresubmitEnvName = 'GOLD_TRYJOB'; const String _kLuciEnvName = 'LUCI_CONTEXT'; +const String _kEngineCheckout = 'ENGINE_CHECKOUT'; const String _skiaGoldHost = 'https://flutter-engine-gold.skia.org'; const String _instance = 'flutter-engine'; @@ -446,12 +447,22 @@ class SkiaGoldClient { return imageBytes; } + String _getEngineCheckoutPath() { + // TODO(137638): This should not be necessary, but Platform.script on + // flutter_tester does not actually provide helpful information. + final String? engineCheckout = Platform.environment[_kEngineCheckout] + if (engineCheckout != null) { + return path.join(engineCheckout, 'src', 'flutter'); + } + final File currentScript = File.fromUri(Platform.script); + return currentScript.parent.absolute.path; + } + /// Returns the current commit hash of the engine repository. Future _getCurrentCommit() async { - final File currentScript = File.fromUri(Platform.script); final ProcessResult revParse = await process.run( ['git', 'rev-parse', 'HEAD'], - workingDirectory: currentScript.parent.absolute.path, + workingDirectory: _getEngineCheckoutPath(), ); if (revParse.exitCode != 0) { throw Exception('Current commit of the engine can not be found from path ${currentScript.path}.'); From 1f6c3a285f63cdfb011bb613e24e6610cdc57ee0 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Tue, 31 Oct 2023 13:29:42 -0700 Subject: [PATCH 35/44] ... --- testing/skia_gold_client/lib/skia_gold_client.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/testing/skia_gold_client/lib/skia_gold_client.dart b/testing/skia_gold_client/lib/skia_gold_client.dart index c620d3d1f92ec..c4fe7fdb68342 100644 --- a/testing/skia_gold_client/lib/skia_gold_client.dart +++ b/testing/skia_gold_client/lib/skia_gold_client.dart @@ -450,7 +450,7 @@ class SkiaGoldClient { String _getEngineCheckoutPath() { // TODO(137638): This should not be necessary, but Platform.script on // flutter_tester does not actually provide helpful information. - final String? engineCheckout = Platform.environment[_kEngineCheckout] + final String? engineCheckout = Platform.environment[_kEngineCheckout]; if (engineCheckout != null) { return path.join(engineCheckout, 'src', 'flutter'); } @@ -460,12 +460,13 @@ class SkiaGoldClient { /// Returns the current commit hash of the engine repository. Future _getCurrentCommit() async { + final String engineCheckout = _getEngineCheckoutPath(); final ProcessResult revParse = await process.run( ['git', 'rev-parse', 'HEAD'], - workingDirectory: _getEngineCheckoutPath(), + workingDirectory: engineCheckout, ); if (revParse.exitCode != 0) { - throw Exception('Current commit of the engine can not be found from path ${currentScript.path}.'); + throw Exception('Current commit of the engine can not be found from path $engineCheckout.'); } return (revParse.stdout as String).trim(); } From 3d477772279d8121f74a5b0f972858afcdf83393 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Tue, 31 Oct 2023 13:52:00 -0700 Subject: [PATCH 36/44] Engine checkout path var name --- testing/skia_gold_client/lib/skia_gold_client.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/skia_gold_client/lib/skia_gold_client.dart b/testing/skia_gold_client/lib/skia_gold_client.dart index c4fe7fdb68342..874671fd80a07 100644 --- a/testing/skia_gold_client/lib/skia_gold_client.dart +++ b/testing/skia_gold_client/lib/skia_gold_client.dart @@ -12,7 +12,7 @@ import 'package:process/process.dart'; const String _kGoldctlKey = 'GOLDCTL'; const String _kPresubmitEnvName = 'GOLD_TRYJOB'; const String _kLuciEnvName = 'LUCI_CONTEXT'; -const String _kEngineCheckout = 'ENGINE_CHECKOUT'; +const String _kEngineCheckout = 'ENGINE_CHECKOUT_PATH'; const String _skiaGoldHost = 'https://flutter-engine-gold.skia.org'; const String _instance = 'flutter-engine'; From 678b8a9d5ee84b4d9716e3b8dae631681bd421f1 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Tue, 31 Oct 2023 14:05:48 -0700 Subject: [PATCH 37/44] see if we can live without litetest changes --- ci/builders/linux_host_engine.json | 2 +- testing/litetest/lib/src/test.dart | 47 ++++++++++-------------- testing/litetest/test/litetest_test.dart | 4 +- 3 files changed, 23 insertions(+), 30 deletions(-) diff --git a/ci/builders/linux_host_engine.json b/ci/builders/linux_host_engine.json index f4da8dc8b92df..8c1f46f614f84 100644 --- a/ci/builders/linux_host_engine.json +++ b/ci/builders/linux_host_engine.json @@ -188,7 +188,7 @@ "dependencies": [ { "dependency": "goldctl", - "version": "git_revision:dddc0623e63150cbbafdcb273d4048f329e1dd09" + "version": "git_revision:720a542f6fe4f92922c3b8f0fdcc4d2ac6bb83cd" } ], "gclient_variables": { diff --git a/testing/litetest/lib/src/test.dart b/testing/litetest/lib/src/test.dart index c800cdb867c17..7a3f908bfabe1 100644 --- a/testing/litetest/lib/src/test.dart +++ b/testing/litetest/lib/src/test.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'dart:io' show stdout; import 'dart:isolate'; -import 'package:async_helper/async_helper.dart' as m; +import 'package:async_helper/async_minitest.dart' as m; import 'package:expect/expect.dart' as e; /// The state that each [Test] may be in. @@ -35,8 +35,9 @@ class Test { this.body, { StringSink? logger, TestLifecycle? lifecycle, - }) : _logger = logger ?? stdout, - _lifecycle = lifecycle ?? _DefaultTestLifecycle(name); + }) : + _logger = logger ?? stdout, + _lifecycle = lifecycle ?? _DefaultTestLifecycle(name); /// The name of the test. final String name; @@ -51,38 +52,30 @@ class Test { TestState state = TestState.allocated; final TestLifecycle _lifecycle; - bool _initializedTestNameCallback = false; /// Runs the test. /// /// Also signals the test's progress to the [TestLifecycle] object /// that was provided when the [Test] was constructed, which will eventually /// call the provided [onDone] callback. - Future run({ + void run({ void Function()? onDone, - }) async { - if (!_initializedTestNameCallback) { - e.ExpectException.setTestNameCallback(() { - return Zone.current[#testName] as String? ?? ''; + }) { + m.test(name, () async { + await Future(() async { + state = TestState.started; + _logger.writeln('Test "$name": Started'); + try { + await body(); + state = TestState.succeeded; + _logger.writeln('Test "$name": Passed'); + } on e.ExpectException catch (e, st) { + state = TestState.failed; + _logger.writeln('Test "$name": Failed\n$e\n$st'); + } finally { + _lifecycle.onDone(cleanup: onDone); + } }); - _initializedTestNameCallback = true; - } - m.asyncStart(); - await runZoned(() async { - state = TestState.started; - _logger.writeln('Test "$name": Started'); - try { - await body(); - state = TestState.succeeded; - _logger.writeln('Test "$name": Passed'); - } on e.ExpectException catch (e, st) { - state = TestState.failed; - _logger.writeln('Test "$name": Failed\n$e\n$st'); - } finally { - _lifecycle.onDone(cleanup: onDone); - } - }, zoneValues: {#testName: name}).then((_) { - m.asyncEnd(); }); _lifecycle.onStart(); } diff --git a/testing/litetest/test/litetest_test.dart b/testing/litetest/test/litetest_test.dart index 8dbf021cd5c29..c8e61a2b15623 100644 --- a/testing/litetest/test/litetest_test.dart +++ b/testing/litetest/test/litetest_test.dart @@ -54,10 +54,10 @@ Future main() async { expect(result, true); expect(buffer.toString(), equals(''' Test "Test1": Started -Test "Test2": Started -Test "Test3": Started Test "Test1": Passed +Test "Test2": Started Test "Test2": Passed +Test "Test3": Started Test "Test3": Passed ''', )); From 48f28b5569a505ff9a37cba4ecb297afd53c7049 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Tue, 31 Oct 2023 14:36:56 -0700 Subject: [PATCH 38/44] unused import --- testing/dart/canvas_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/testing/dart/canvas_test.dart b/testing/dart/canvas_test.dart index 2d241c5211f48..8c4937bdab2d1 100644 --- a/testing/dart/canvas_test.dart +++ b/testing/dart/canvas_test.dart @@ -11,7 +11,6 @@ import 'dart:ui'; import 'package:litetest/litetest.dart'; import 'package:path/path.dart' as path; import 'package:vector_math/vector_math_64.dart'; -import 'package:process/process.dart'; import 'goldens.dart'; import 'impeller_enabled.dart'; From ab92d4979c63851aaf0ad1f897e307483186f652 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Wed, 1 Nov 2023 14:12:03 -0700 Subject: [PATCH 39/44] zanderso review --- testing/run_tests.py | 2 +- .../skia_gold_client/lib/skia_gold_client.dart | 15 ++------------- testing/skia_gold_client/pubspec.yaml | 3 +++ 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/testing/run_tests.py b/testing/run_tests.py index a3924b92044f8..8074d81dd5b9f 100755 --- a/testing/run_tests.py +++ b/testing/run_tests.py @@ -1018,7 +1018,7 @@ def run_engine_tasks_in_parallel(tasks): # processes launched for the queue reader and thread wakeup reader). # # See: https://bugs.python.org/issue26903 - max_processes = 1 # multiprocessing.cpu_count() + max_processes = multiprocessing.cpu_count() if sys_platform.startswith(('cygwin', 'win')) and max_processes > 60: max_processes = 60 diff --git a/testing/skia_gold_client/lib/skia_gold_client.dart b/testing/skia_gold_client/lib/skia_gold_client.dart index 874671fd80a07..471aec03ece3f 100644 --- a/testing/skia_gold_client/lib/skia_gold_client.dart +++ b/testing/skia_gold_client/lib/skia_gold_client.dart @@ -6,13 +6,13 @@ import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; +import 'package:engine_repo_tools/engine_repo_tools.dart'; import 'package:path/path.dart' as path; import 'package:process/process.dart'; const String _kGoldctlKey = 'GOLDCTL'; const String _kPresubmitEnvName = 'GOLD_TRYJOB'; const String _kLuciEnvName = 'LUCI_CONTEXT'; -const String _kEngineCheckout = 'ENGINE_CHECKOUT_PATH'; const String _skiaGoldHost = 'https://flutter-engine-gold.skia.org'; const String _instance = 'flutter-engine'; @@ -447,20 +447,9 @@ class SkiaGoldClient { return imageBytes; } - String _getEngineCheckoutPath() { - // TODO(137638): This should not be necessary, but Platform.script on - // flutter_tester does not actually provide helpful information. - final String? engineCheckout = Platform.environment[_kEngineCheckout]; - if (engineCheckout != null) { - return path.join(engineCheckout, 'src', 'flutter'); - } - final File currentScript = File.fromUri(Platform.script); - return currentScript.parent.absolute.path; - } - /// Returns the current commit hash of the engine repository. Future _getCurrentCommit() async { - final String engineCheckout = _getEngineCheckoutPath(); + final String engineCheckout = Engine.findWithin().flutterDir.path; final ProcessResult revParse = await process.run( ['git', 'rev-parse', 'HEAD'], workingDirectory: engineCheckout, diff --git a/testing/skia_gold_client/pubspec.yaml b/testing/skia_gold_client/pubspec.yaml index 8c7bc0a6a7ff9..693424d5350d6 100644 --- a/testing/skia_gold_client/pubspec.yaml +++ b/testing/skia_gold_client/pubspec.yaml @@ -17,6 +17,7 @@ environment: dependencies: crypto: any path: any + engine_repo_tools: any process: any dependency_overrides: @@ -24,6 +25,8 @@ dependency_overrides: path: ../../../third_party/dart/third_party/pkg/collection crypto: path: ../../../third_party/dart/third_party/pkg/crypto + engine_repo_tools: + path: ../../tools/pkg/engine_repo_tools file: path: ../../../third_party/dart/third_party/pkg/file/packages/file meta: From 7de9ca7c1173c50c5861e48ac11bb1caeeb27f1d Mon Sep 17 00:00:00 2001 From: Dan Field Date: Wed, 1 Nov 2023 14:42:09 -0700 Subject: [PATCH 40/44] override for harvester --- impeller/golden_tests_harvester/pubspec.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/impeller/golden_tests_harvester/pubspec.yaml b/impeller/golden_tests_harvester/pubspec.yaml index d733c8c162583..02d10c2c6434f 100644 --- a/impeller/golden_tests_harvester/pubspec.yaml +++ b/impeller/golden_tests_harvester/pubspec.yaml @@ -30,6 +30,8 @@ dependency_overrides: path: ../../../third_party/dart/third_party/pkg/file/packages/file meta: path: ../../../third_party/dart/pkg/meta + engine_repo_tools: + path: ../../tools/pkg/engine_repo_tools path: path: ../../../third_party/dart/third_party/pkg/path platform: From 65d84337c6cd307458929067410ad32e52f416c6 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Wed, 1 Nov 2023 15:16:52 -0700 Subject: [PATCH 41/44] more --- lib/web_ui/pubspec.yaml | 2 ++ testing/dart/pubspec.yaml | 2 ++ testing/skia_gold_client/README.md | 3 +++ 3 files changed, 7 insertions(+) diff --git a/lib/web_ui/pubspec.yaml b/lib/web_ui/pubspec.yaml index 1b53ead9bd9c8..f2b25daba747e 100644 --- a/lib/web_ui/pubspec.yaml +++ b/lib/web_ui/pubspec.yaml @@ -23,6 +23,8 @@ dev_dependencies: async: any convert: any crypto: any + engine_repo_tools: + path: ../../tools/pkg/engine_repo_tools html: 0.15.4 http: 1.1.0 http_multi_server: any diff --git a/testing/dart/pubspec.yaml b/testing/dart/pubspec.yaml index 8226fbdfbf7f7..e13765a1972be 100644 --- a/testing/dart/pubspec.yaml +++ b/testing/dart/pubspec.yaml @@ -32,6 +32,8 @@ dependency_overrides: path: ../../../third_party/dart/third_party/pkg/collection crypto: path: ../../../third_party/dart/third_party/pkg/crypto + engine_repo_tools: + path: ../../tools/pkg/engine_repo_tools expect: path: ../../../third_party/dart/pkg/expect file: diff --git a/testing/skia_gold_client/README.md b/testing/skia_gold_client/README.md index cf0ea2ea9a0ea..3e7f6ec2d5fea 100644 --- a/testing/skia_gold_client/README.md +++ b/testing/skia_gold_client/README.md @@ -30,6 +30,9 @@ The web UI is available on https://flutter-engine-gold.skia.org/. ```yaml dependencies: + # needed for skia_gold_client to avoid a cache miss. + engine_repo_tools: + path: /tools/pkg/engine_repo_tools skia_gold_client: path: /testing/skia_gold_client ``` From f0ed06b805ea3aa63c78682e8bae1922e6bc7278 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Wed, 1 Nov 2023 15:32:46 -0700 Subject: [PATCH 42/44] last one --- testing/scenario_app/pubspec.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/testing/scenario_app/pubspec.yaml b/testing/scenario_app/pubspec.yaml index 4c579aa4d8230..fb13f5848f68b 100644 --- a/testing/scenario_app/pubspec.yaml +++ b/testing/scenario_app/pubspec.yaml @@ -29,6 +29,8 @@ dependency_overrides: path: ../../../third_party/dart/third_party/pkg/collection crypto: path: ../../../third_party/dart/third_party/pkg/crypto + engine_repo_tools: + path: ../../tools/pkg/engine_repo_tools file: path: ../../../third_party/dart/third_party/pkg/file/packages/file meta: From 6180f24d0d29aa72f27914e12f1e4204001517f8 Mon Sep 17 00:00:00 2001 From: Dan Field Date: Wed, 1 Nov 2023 15:45:36 -0700 Subject: [PATCH 43/44] fix web_ui --- lib/web_ui/pubspec.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/web_ui/pubspec.yaml b/lib/web_ui/pubspec.yaml index f2b25daba747e..e777c0ef26603 100644 --- a/lib/web_ui/pubspec.yaml +++ b/lib/web_ui/pubspec.yaml @@ -23,8 +23,6 @@ dev_dependencies: async: any convert: any crypto: any - engine_repo_tools: - path: ../../tools/pkg/engine_repo_tools html: 0.15.4 http: 1.1.0 http_multi_server: any @@ -56,3 +54,7 @@ dev_dependencies: path: ../../web_sdk/web_engine_tester skia_gold_client: path: ../../testing/skia_gold_client + +dependency_overrides: + engine_repo_tools: + path: ../../tools/pkg/engine_repo_tools From 8db70f304e9cb0d66eaa83219fca3926d08a8c0f Mon Sep 17 00:00:00 2001 From: Dan Field Date: Wed, 1 Nov 2023 16:15:14 -0700 Subject: [PATCH 44/44] ...one more for web --- web_sdk/web_test_utils/pubspec.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web_sdk/web_test_utils/pubspec.yaml b/web_sdk/web_test_utils/pubspec.yaml index ca7efd336eec7..fbdd1be557741 100644 --- a/web_sdk/web_test_utils/pubspec.yaml +++ b/web_sdk/web_test_utils/pubspec.yaml @@ -16,3 +16,7 @@ dependencies: path: ../../testing/skia_gold_client typed_data: 1.3.0 yaml: 3.0.0 + +dependency_overrides: + engine_repo_tools: + path: ../../tools/pkg/engine_repo_tools