mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
WIP Commits separated as follows: - Update lints in analysis_options files - Run `dart fix --apply` - Clean up leftover analysis issues - Run `dart format .` in the right places. Local analysis and testing passes. Checking CI now. Part of https://github.com/flutter/flutter/issues/178827 - Adoption of flutter_lints in examples/api coming in a separate change (cc @loic-sharma) ## Pre-launch Checklist - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
3315 lines
114 KiB
Dart
3315 lines
114 KiB
Dart
// Copyright 2014 The Flutter Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style license that can be
|
|
// found in the LICENSE file.
|
|
|
|
// This file is run as part of a reduced test set in CI on Mac and Windows
|
|
// machines.
|
|
@Tags(<String>['reduced-test-set'])
|
|
library;
|
|
|
|
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter/scheduler.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart';
|
|
|
|
import '../image_data.dart';
|
|
import 'semantics_tester.dart';
|
|
|
|
void main() {
|
|
late int originalCacheSize;
|
|
late ui.Image image10x10;
|
|
|
|
setUp(() async {
|
|
originalCacheSize = imageCache.maximumSize;
|
|
imageCache.clear();
|
|
imageCache.clearLiveImages();
|
|
image10x10 = await createTestImage(width: 10, height: 10);
|
|
});
|
|
|
|
tearDown(() {
|
|
imageCache.maximumSize = originalCacheSize;
|
|
});
|
|
|
|
testWidgets('Verify Image does not use disposed handles', (WidgetTester tester) async {
|
|
final ui.Image image100x100 = (await tester.runAsync(
|
|
() async => createTestImage(width: 100, height: 100),
|
|
))!;
|
|
|
|
final imageProvider1 = _TestImageProvider();
|
|
final imageProvider2 = _TestImageProvider();
|
|
|
|
final imageListenable = ValueNotifier<_TestImageProvider>(imageProvider1);
|
|
addTearDown(imageListenable.dispose);
|
|
final innerListenable = ValueNotifier<int>(0);
|
|
addTearDown(innerListenable.dispose);
|
|
|
|
var imageLoaded = false;
|
|
|
|
await tester.pumpWidget(
|
|
ValueListenableBuilder<_TestImageProvider>(
|
|
valueListenable: imageListenable,
|
|
builder: (BuildContext context, _TestImageProvider image, Widget? child) => Image(
|
|
image: image,
|
|
frameBuilder:
|
|
(BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) {
|
|
if (frame == 0) {
|
|
imageLoaded = true;
|
|
}
|
|
return LayoutBuilder(
|
|
builder: (BuildContext context, BoxConstraints constraints) =>
|
|
ValueListenableBuilder<int>(
|
|
valueListenable: innerListenable,
|
|
builder: (BuildContext context, int value, Widget? valueListenableChild) =>
|
|
KeyedSubtree(key: UniqueKey(), child: child),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
|
|
imageLoaded = false;
|
|
imageProvider1.complete(image10x10);
|
|
await tester.idle();
|
|
await tester.pump();
|
|
expect(imageLoaded, true);
|
|
|
|
imageLoaded = false;
|
|
imageListenable.value = imageProvider2;
|
|
innerListenable.value += 1;
|
|
imageProvider2.complete(image100x100);
|
|
await tester.idle();
|
|
await tester.pump();
|
|
expect(imageLoaded, true);
|
|
});
|
|
|
|
testWidgets('Verify Image resets its RenderImage when changing providers', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final GlobalKey key = GlobalKey();
|
|
final imageProvider1 = _TestImageProvider();
|
|
await tester.pumpWidget(
|
|
Container(
|
|
key: key,
|
|
child: Image(image: imageProvider1, excludeFromSemantics: true),
|
|
),
|
|
phase: EnginePhase.layout,
|
|
);
|
|
var renderImage = key.currentContext!.findRenderObject()! as RenderImage;
|
|
expect(renderImage.image, isNull);
|
|
|
|
imageProvider1.complete(image10x10);
|
|
await tester.idle(); // resolve the future from the image provider
|
|
await tester.pump(null, EnginePhase.layout);
|
|
|
|
renderImage = key.currentContext!.findRenderObject()! as RenderImage;
|
|
expect(renderImage.image, isNotNull);
|
|
|
|
final imageProvider2 = _TestImageProvider();
|
|
await tester.pumpWidget(
|
|
Container(
|
|
key: key,
|
|
child: Image(image: imageProvider2, excludeFromSemantics: true),
|
|
),
|
|
phase: EnginePhase.layout,
|
|
);
|
|
|
|
renderImage = key.currentContext!.findRenderObject()! as RenderImage;
|
|
expect(renderImage.image, isNull);
|
|
});
|
|
|
|
testWidgets(
|
|
"Verify Image doesn't reset its RenderImage when changing providers if it has gaplessPlayback set",
|
|
(WidgetTester tester) async {
|
|
final GlobalKey key = GlobalKey();
|
|
final imageProvider1 = _TestImageProvider();
|
|
await tester.pumpWidget(
|
|
Container(
|
|
key: key,
|
|
child: Image(gaplessPlayback: true, image: imageProvider1, excludeFromSemantics: true),
|
|
),
|
|
phase: EnginePhase.layout,
|
|
);
|
|
var renderImage = key.currentContext!.findRenderObject()! as RenderImage;
|
|
expect(renderImage.image, isNull);
|
|
|
|
imageProvider1.complete(image10x10);
|
|
await tester.idle(); // resolve the future from the image provider
|
|
await tester.pump(null, EnginePhase.layout);
|
|
|
|
renderImage = key.currentContext!.findRenderObject()! as RenderImage;
|
|
expect(renderImage.image, isNotNull);
|
|
|
|
final imageProvider2 = _TestImageProvider();
|
|
await tester.pumpWidget(
|
|
Container(
|
|
key: key,
|
|
child: Image(gaplessPlayback: true, image: imageProvider2, excludeFromSemantics: true),
|
|
),
|
|
phase: EnginePhase.layout,
|
|
);
|
|
|
|
renderImage = key.currentContext!.findRenderObject()! as RenderImage;
|
|
expect(renderImage.image, isNotNull);
|
|
},
|
|
);
|
|
|
|
testWidgets('Verify Image resets its RenderImage when changing providers if it has a key', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final GlobalKey key = GlobalKey();
|
|
final imageProvider1 = _TestImageProvider();
|
|
await tester.pumpWidget(
|
|
Image(key: key, image: imageProvider1, excludeFromSemantics: true),
|
|
phase: EnginePhase.layout,
|
|
);
|
|
var renderImage = key.currentContext!.findRenderObject()! as RenderImage;
|
|
expect(renderImage.image, isNull);
|
|
|
|
imageProvider1.complete(image10x10);
|
|
await tester.idle(); // resolve the future from the image provider
|
|
await tester.pump(null, EnginePhase.layout);
|
|
|
|
renderImage = key.currentContext!.findRenderObject()! as RenderImage;
|
|
expect(renderImage.image, isNotNull);
|
|
|
|
final imageProvider2 = _TestImageProvider();
|
|
await tester.pumpWidget(
|
|
Image(key: key, image: imageProvider2, excludeFromSemantics: true),
|
|
phase: EnginePhase.layout,
|
|
);
|
|
|
|
renderImage = key.currentContext!.findRenderObject()! as RenderImage;
|
|
expect(renderImage.image, isNull);
|
|
});
|
|
|
|
testWidgets(
|
|
"Verify Image doesn't reset its RenderImage when changing providers if it has gaplessPlayback set",
|
|
(WidgetTester tester) async {
|
|
final GlobalKey key = GlobalKey();
|
|
final imageProvider1 = _TestImageProvider();
|
|
await tester.pumpWidget(
|
|
Image(key: key, gaplessPlayback: true, image: imageProvider1, excludeFromSemantics: true),
|
|
phase: EnginePhase.layout,
|
|
);
|
|
var renderImage = key.currentContext!.findRenderObject()! as RenderImage;
|
|
expect(renderImage.image, isNull);
|
|
|
|
imageProvider1.complete(image10x10);
|
|
await tester.idle(); // resolve the future from the image provider
|
|
await tester.pump(null, EnginePhase.layout);
|
|
|
|
renderImage = key.currentContext!.findRenderObject()! as RenderImage;
|
|
expect(renderImage.image, isNotNull);
|
|
|
|
final imageProvider2 = _TestImageProvider();
|
|
await tester.pumpWidget(
|
|
Image(key: key, gaplessPlayback: true, excludeFromSemantics: true, image: imageProvider2),
|
|
phase: EnginePhase.layout,
|
|
);
|
|
|
|
renderImage = key.currentContext!.findRenderObject()! as RenderImage;
|
|
expect(renderImage.image, isNotNull);
|
|
},
|
|
);
|
|
|
|
testWidgets('Verify ImageProvider configuration inheritance', (WidgetTester tester) async {
|
|
final GlobalKey mediaQueryKey1 = GlobalKey(debugLabel: 'mediaQueryKey1');
|
|
final GlobalKey mediaQueryKey2 = GlobalKey(debugLabel: 'mediaQueryKey2');
|
|
final GlobalKey imageKey = GlobalKey(debugLabel: 'image');
|
|
final imageProvider = _ConfigurationKeyedTestImageProvider();
|
|
final seenKeys = <Object>{};
|
|
final debouncingProvider = _DebouncingImageProvider(imageProvider, seenKeys);
|
|
|
|
// Of the two nested MediaQuery objects, the innermost one,
|
|
// mediaQuery2, should define the configuration of the imageProvider.
|
|
await tester.pumpWidget(
|
|
MediaQuery(
|
|
key: mediaQueryKey1,
|
|
data: const MediaQueryData(devicePixelRatio: 10.0),
|
|
child: MediaQuery(
|
|
key: mediaQueryKey2,
|
|
data: const MediaQueryData(devicePixelRatio: 5.0),
|
|
child: Image(excludeFromSemantics: true, key: imageKey, image: debouncingProvider),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(imageProvider._lastResolvedConfiguration.devicePixelRatio, 5.0);
|
|
|
|
// This is the same widget hierarchy as before except that the
|
|
// two MediaQuery objects have exchanged places. The imageProvider
|
|
// should be resolved again, with the new innermost MediaQuery.
|
|
await tester.pumpWidget(
|
|
MediaQuery(
|
|
key: mediaQueryKey2,
|
|
data: const MediaQueryData(devicePixelRatio: 5.0),
|
|
child: MediaQuery(
|
|
key: mediaQueryKey1,
|
|
data: const MediaQueryData(devicePixelRatio: 10.0),
|
|
child: Image(excludeFromSemantics: true, key: imageKey, image: debouncingProvider),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(imageProvider._lastResolvedConfiguration.devicePixelRatio, 10.0);
|
|
});
|
|
|
|
testWidgets('Verify ImageProvider configuration inheritance again', (WidgetTester tester) async {
|
|
final GlobalKey mediaQueryKey1 = GlobalKey(debugLabel: 'mediaQueryKey1');
|
|
final GlobalKey mediaQueryKey2 = GlobalKey(debugLabel: 'mediaQueryKey2');
|
|
final GlobalKey imageKey = GlobalKey(debugLabel: 'image');
|
|
final imageProvider = _ConfigurationKeyedTestImageProvider();
|
|
final seenKeys = <Object>{};
|
|
final debouncingProvider = _DebouncingImageProvider(imageProvider, seenKeys);
|
|
|
|
// This is just a variation on the previous test. In this version the location
|
|
// of the Image changes and the MediaQuery widgets do not.
|
|
await tester.pumpWidget(
|
|
Row(
|
|
textDirection: TextDirection.ltr,
|
|
children: <Widget>[
|
|
MediaQuery(
|
|
key: mediaQueryKey2,
|
|
data: const MediaQueryData(devicePixelRatio: 5.0),
|
|
child: Image(excludeFromSemantics: true, key: imageKey, image: debouncingProvider),
|
|
),
|
|
MediaQuery(
|
|
key: mediaQueryKey1,
|
|
data: const MediaQueryData(devicePixelRatio: 10.0),
|
|
child: Container(width: 100.0),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
expect(imageProvider._lastResolvedConfiguration.devicePixelRatio, 5.0);
|
|
|
|
await tester.pumpWidget(
|
|
Row(
|
|
textDirection: TextDirection.ltr,
|
|
children: <Widget>[
|
|
MediaQuery(
|
|
key: mediaQueryKey2,
|
|
data: const MediaQueryData(devicePixelRatio: 5.0),
|
|
child: Container(width: 100.0),
|
|
),
|
|
MediaQuery(
|
|
key: mediaQueryKey1,
|
|
data: const MediaQueryData(devicePixelRatio: 10.0),
|
|
child: Image(excludeFromSemantics: true, key: imageKey, image: debouncingProvider),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
expect(imageProvider._lastResolvedConfiguration.devicePixelRatio, 10.0);
|
|
});
|
|
|
|
testWidgets('Verify ImageProvider does not inherit configuration when it does not key to it', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final GlobalKey mediaQueryKey1 = GlobalKey(debugLabel: 'mediaQueryKey1');
|
|
final GlobalKey mediaQueryKey2 = GlobalKey(debugLabel: 'mediaQueryKey2');
|
|
final GlobalKey imageKey = GlobalKey(debugLabel: 'image');
|
|
final imageProvider = _TestImageProvider();
|
|
final seenKeys = <Object>{};
|
|
final debouncingProvider = _DebouncingImageProvider(imageProvider, seenKeys);
|
|
|
|
// Of the two nested MediaQuery objects, the innermost one,
|
|
// mediaQuery2, should define the configuration of the imageProvider.
|
|
await tester.pumpWidget(
|
|
MediaQuery(
|
|
key: mediaQueryKey1,
|
|
data: const MediaQueryData(devicePixelRatio: 10.0),
|
|
child: MediaQuery(
|
|
key: mediaQueryKey2,
|
|
data: const MediaQueryData(devicePixelRatio: 5.0),
|
|
child: Image(excludeFromSemantics: true, key: imageKey, image: debouncingProvider),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(imageProvider._lastResolvedConfiguration.devicePixelRatio, 5.0);
|
|
|
|
// This is the same widget hierarchy as before except that the
|
|
// two MediaQuery objects have exchanged places. The imageProvider
|
|
// should not be resolved again, because it does not key to configuration.
|
|
await tester.pumpWidget(
|
|
MediaQuery(
|
|
key: mediaQueryKey2,
|
|
data: const MediaQueryData(devicePixelRatio: 5.0),
|
|
child: MediaQuery(
|
|
key: mediaQueryKey1,
|
|
data: const MediaQueryData(devicePixelRatio: 10.0),
|
|
child: Image(excludeFromSemantics: true, key: imageKey, image: debouncingProvider),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(imageProvider._lastResolvedConfiguration.devicePixelRatio, 5.0);
|
|
});
|
|
|
|
testWidgets(
|
|
'Verify ImageProvider does not inherit configuration when it does not key to it again',
|
|
(WidgetTester tester) async {
|
|
final GlobalKey mediaQueryKey1 = GlobalKey(debugLabel: 'mediaQueryKey1');
|
|
final GlobalKey mediaQueryKey2 = GlobalKey(debugLabel: 'mediaQueryKey2');
|
|
final GlobalKey imageKey = GlobalKey(debugLabel: 'image');
|
|
final imageProvider = _TestImageProvider();
|
|
final seenKeys = <Object>{};
|
|
final debouncingProvider = _DebouncingImageProvider(imageProvider, seenKeys);
|
|
|
|
// This is just a variation on the previous test. In this version the location
|
|
// of the Image changes and the MediaQuery widgets do not.
|
|
await tester.pumpWidget(
|
|
Row(
|
|
textDirection: TextDirection.ltr,
|
|
children: <Widget>[
|
|
MediaQuery(
|
|
key: mediaQueryKey2,
|
|
data: const MediaQueryData(devicePixelRatio: 5.0),
|
|
child: Image(excludeFromSemantics: true, key: imageKey, image: debouncingProvider),
|
|
),
|
|
MediaQuery(
|
|
key: mediaQueryKey1,
|
|
data: const MediaQueryData(devicePixelRatio: 10.0),
|
|
child: Container(width: 100.0),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
expect(imageProvider._lastResolvedConfiguration.devicePixelRatio, 5.0);
|
|
|
|
await tester.pumpWidget(
|
|
Row(
|
|
textDirection: TextDirection.ltr,
|
|
children: <Widget>[
|
|
MediaQuery(
|
|
key: mediaQueryKey2,
|
|
data: const MediaQueryData(devicePixelRatio: 5.0),
|
|
child: Container(width: 100.0),
|
|
),
|
|
MediaQuery(
|
|
key: mediaQueryKey1,
|
|
data: const MediaQueryData(devicePixelRatio: 10.0),
|
|
child: Image(excludeFromSemantics: true, key: imageKey, image: debouncingProvider),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
expect(imageProvider._lastResolvedConfiguration.devicePixelRatio, 5.0);
|
|
},
|
|
);
|
|
|
|
testWidgets('Verify Image stops listening to ImageStream', (WidgetTester tester) async {
|
|
final ui.Image image100x100 = (await tester.runAsync(
|
|
() async => createTestImage(width: 100, height: 100),
|
|
))!;
|
|
// Web does not override the toString, whereas VM does
|
|
final imageString = image100x100.toString();
|
|
|
|
final imageProvider = _TestImageProvider();
|
|
await tester.pumpWidget(Image(image: imageProvider, excludeFromSemantics: true));
|
|
final State<Image> image = tester.state /*State<Image>*/ (find.byType(Image));
|
|
expect(
|
|
image.toString(),
|
|
equalsIgnoringHashCodes(
|
|
'_ImageState#00000(stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, unresolved, 2 listeners, 0 ephemeralErrorListeners), pixels: null, loadingProgress: null, frameNumber: null, wasSynchronouslyLoaded: false)',
|
|
),
|
|
);
|
|
imageProvider.complete(image100x100);
|
|
await tester.pump();
|
|
expect(
|
|
image.toString(),
|
|
equalsIgnoringHashCodes(
|
|
'_ImageState#00000(stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, $imageString @ 1.0x, 1 listener, 0 ephemeralErrorListeners), pixels: $imageString @ 1.0x, loadingProgress: null, frameNumber: 0, wasSynchronouslyLoaded: false)',
|
|
),
|
|
);
|
|
await tester.pumpWidget(Container());
|
|
expect(
|
|
image.toString(),
|
|
equalsIgnoringHashCodes(
|
|
'_ImageState#00000(lifecycle state: defunct, not mounted, stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, $imageString @ 1.0x, 0 listeners, 0 ephemeralErrorListeners), pixels: null, loadingProgress: null, frameNumber: 0, wasSynchronouslyLoaded: false)',
|
|
),
|
|
);
|
|
});
|
|
|
|
testWidgets('Stream completer errors can be listened to by attaching before resolving', (
|
|
WidgetTester tester,
|
|
) async {
|
|
dynamic capturedException;
|
|
StackTrace? capturedStackTrace;
|
|
ImageInfo? capturedImage;
|
|
void errorListener(dynamic exception, StackTrace? stackTrace) {
|
|
capturedException = exception;
|
|
capturedStackTrace = stackTrace;
|
|
}
|
|
|
|
void listener(ImageInfo info, bool synchronous) {
|
|
capturedImage = info;
|
|
}
|
|
|
|
final testException = Exception('cannot resolve host');
|
|
final StackTrace testStack = StackTrace.current;
|
|
final imageProvider = _TestImageProvider();
|
|
imageProvider._streamCompleter.addListener(
|
|
ImageStreamListener(listener, onError: errorListener),
|
|
);
|
|
late ImageConfiguration configuration;
|
|
await tester.pumpWidget(
|
|
Builder(
|
|
builder: (BuildContext context) {
|
|
configuration = createLocalImageConfiguration(context);
|
|
return Container();
|
|
},
|
|
),
|
|
);
|
|
imageProvider.resolve(configuration);
|
|
imageProvider.fail(testException, testStack);
|
|
|
|
expect(tester.binding.microtaskCount, 1);
|
|
await tester.idle(); // Let the failed completer's future hit the stream completer.
|
|
expect(tester.binding.microtaskCount, 0);
|
|
|
|
expect(capturedImage, isNull); // The image stream listeners should never be called.
|
|
// The image stream error handler should have the original exception.
|
|
expect(capturedException, testException);
|
|
expect(capturedStackTrace, testStack);
|
|
// If there is an error listener, there should be no FlutterError reported.
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('Stream completer errors can be listened to by attaching after resolving', (
|
|
WidgetTester tester,
|
|
) async {
|
|
dynamic capturedException;
|
|
StackTrace? capturedStackTrace;
|
|
dynamic reportedException;
|
|
StackTrace? reportedStackTrace;
|
|
ImageInfo? capturedImage;
|
|
void errorListener(dynamic exception, StackTrace? stackTrace) {
|
|
capturedException = exception;
|
|
capturedStackTrace = stackTrace;
|
|
}
|
|
|
|
void listener(ImageInfo info, bool synchronous) {
|
|
capturedImage = info;
|
|
}
|
|
|
|
FlutterError.onError = (FlutterErrorDetails flutterError) {
|
|
reportedException = flutterError.exception;
|
|
reportedStackTrace = flutterError.stack;
|
|
};
|
|
|
|
final testException = Exception('cannot resolve host');
|
|
final StackTrace testStack = StackTrace.current;
|
|
final imageProvider = _TestImageProvider();
|
|
late ImageConfiguration configuration;
|
|
await tester.pumpWidget(
|
|
Builder(
|
|
builder: (BuildContext context) {
|
|
configuration = createLocalImageConfiguration(context);
|
|
return Container();
|
|
},
|
|
),
|
|
);
|
|
final ImageStream streamUnderTest = imageProvider.resolve(configuration);
|
|
|
|
imageProvider.fail(testException, testStack);
|
|
|
|
expect(tester.binding.microtaskCount, 1);
|
|
await tester.idle(); // Let the failed completer's future hit the stream completer.
|
|
expect(tester.binding.microtaskCount, 0);
|
|
|
|
// Since there's no listeners attached yet, report error up via
|
|
// FlutterError.
|
|
expect(reportedException, testException);
|
|
expect(reportedStackTrace, testStack);
|
|
|
|
streamUnderTest.addListener(ImageStreamListener(listener, onError: errorListener));
|
|
|
|
expect(capturedImage, isNull); // The image stream listeners should never be called.
|
|
// The image stream error handler should have the original exception.
|
|
expect(capturedException, testException);
|
|
expect(capturedStackTrace, testStack);
|
|
});
|
|
|
|
testWidgets('Duplicate listener registration does not affect error listeners', (
|
|
WidgetTester tester,
|
|
) async {
|
|
dynamic capturedException;
|
|
StackTrace? capturedStackTrace;
|
|
ImageInfo? capturedImage;
|
|
void errorListener(dynamic exception, StackTrace? stackTrace) {
|
|
capturedException = exception;
|
|
capturedStackTrace = stackTrace;
|
|
}
|
|
|
|
void listener(ImageInfo info, bool synchronous) {
|
|
capturedImage = info;
|
|
}
|
|
|
|
final testException = Exception('cannot resolve host');
|
|
final StackTrace testStack = StackTrace.current;
|
|
final imageProvider = _TestImageProvider();
|
|
imageProvider._streamCompleter.addListener(
|
|
ImageStreamListener(listener, onError: errorListener),
|
|
);
|
|
// Add the exact same listener a second time without the errorListener.
|
|
imageProvider._streamCompleter.addListener(ImageStreamListener(listener));
|
|
late ImageConfiguration configuration;
|
|
await tester.pumpWidget(
|
|
Builder(
|
|
builder: (BuildContext context) {
|
|
configuration = createLocalImageConfiguration(context);
|
|
return Container();
|
|
},
|
|
),
|
|
);
|
|
imageProvider.resolve(configuration);
|
|
imageProvider.fail(testException, testStack);
|
|
|
|
expect(tester.binding.microtaskCount, 1);
|
|
await tester.idle(); // Let the failed completer's future hit the stream completer.
|
|
expect(tester.binding.microtaskCount, 0);
|
|
|
|
expect(capturedImage, isNull); // The image stream listeners should never be called.
|
|
// The image stream error handler should have the original exception.
|
|
expect(capturedException, testException);
|
|
expect(capturedStackTrace, testStack);
|
|
// If there is an error listener, there should be no FlutterError reported.
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('Duplicate error listeners are all called', (WidgetTester tester) async {
|
|
dynamic capturedException;
|
|
StackTrace? capturedStackTrace;
|
|
ImageInfo? capturedImage;
|
|
var errorListenerCalled = 0;
|
|
void errorListener(dynamic exception, StackTrace? stackTrace) {
|
|
capturedException = exception;
|
|
capturedStackTrace = stackTrace;
|
|
errorListenerCalled++;
|
|
}
|
|
|
|
void listener(ImageInfo info, bool synchronous) {
|
|
capturedImage = info;
|
|
}
|
|
|
|
final testException = Exception('cannot resolve host');
|
|
final StackTrace testStack = StackTrace.current;
|
|
final imageProvider = _TestImageProvider();
|
|
imageProvider._streamCompleter.addListener(
|
|
ImageStreamListener(listener, onError: errorListener),
|
|
);
|
|
// Add the exact same errorListener a second time.
|
|
imageProvider._streamCompleter.addListener(
|
|
ImageStreamListener(listener, onError: errorListener),
|
|
);
|
|
late ImageConfiguration configuration;
|
|
await tester.pumpWidget(
|
|
Builder(
|
|
builder: (BuildContext context) {
|
|
configuration = createLocalImageConfiguration(context);
|
|
return Container();
|
|
},
|
|
),
|
|
);
|
|
imageProvider.resolve(configuration);
|
|
imageProvider.fail(testException, testStack);
|
|
|
|
expect(tester.binding.microtaskCount, 1);
|
|
await tester.idle(); // Let the failed completer's future hit the stream completer.
|
|
expect(tester.binding.microtaskCount, 0);
|
|
|
|
expect(capturedImage, isNull); // The image stream listeners should never be called.
|
|
// The image stream error handler should have the original exception.
|
|
expect(capturedException, testException);
|
|
expect(capturedStackTrace, testStack);
|
|
expect(errorListenerCalled, 2);
|
|
// If there is an error listener, there should be no FlutterError reported.
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('Listeners are only removed if callback tuple matches', (WidgetTester tester) async {
|
|
var errorListenerCalled = false;
|
|
dynamic reportedException;
|
|
StackTrace? reportedStackTrace;
|
|
ImageInfo? capturedImage;
|
|
void errorListener(dynamic exception, StackTrace? stackTrace) {
|
|
errorListenerCalled = true;
|
|
reportedException = exception;
|
|
reportedStackTrace = stackTrace;
|
|
}
|
|
|
|
void listener(ImageInfo info, bool synchronous) {
|
|
capturedImage = info;
|
|
}
|
|
|
|
final testException = Exception('cannot resolve host');
|
|
final StackTrace testStack = StackTrace.current;
|
|
final imageProvider = _TestImageProvider();
|
|
imageProvider._streamCompleter.addListener(
|
|
ImageStreamListener(listener, onError: errorListener),
|
|
);
|
|
// Now remove the listener the error listener is attached to.
|
|
// Don't explicitly remove the error listener.
|
|
imageProvider._streamCompleter.removeListener(ImageStreamListener(listener));
|
|
late ImageConfiguration configuration;
|
|
await tester.pumpWidget(
|
|
Builder(
|
|
builder: (BuildContext context) {
|
|
configuration = createLocalImageConfiguration(context);
|
|
return Container();
|
|
},
|
|
),
|
|
);
|
|
imageProvider.resolve(configuration);
|
|
|
|
imageProvider.fail(testException, testStack);
|
|
|
|
expect(tester.binding.microtaskCount, 1);
|
|
await tester.idle(); // Let the failed completer's future hit the stream completer.
|
|
expect(tester.binding.microtaskCount, 0);
|
|
|
|
expect(errorListenerCalled, true);
|
|
expect(reportedException, testException);
|
|
expect(reportedStackTrace, testStack);
|
|
expect(capturedImage, isNull); // The image stream listeners should never be called.
|
|
});
|
|
|
|
testWidgets('Removing listener removes one listener and error listener', (
|
|
WidgetTester tester,
|
|
) async {
|
|
var errorListenerCalled = 0;
|
|
ImageInfo? capturedImage;
|
|
void errorListener(dynamic exception, StackTrace? stackTrace) {
|
|
errorListenerCalled++;
|
|
}
|
|
|
|
void listener(ImageInfo info, bool synchronous) {
|
|
capturedImage = info;
|
|
}
|
|
|
|
final testException = Exception('cannot resolve host');
|
|
final StackTrace testStack = StackTrace.current;
|
|
final imageProvider = _TestImageProvider();
|
|
imageProvider._streamCompleter.addListener(
|
|
ImageStreamListener(listener, onError: errorListener),
|
|
);
|
|
// Duplicates the same set of listener and errorListener.
|
|
imageProvider._streamCompleter.addListener(
|
|
ImageStreamListener(listener, onError: errorListener),
|
|
);
|
|
// Now remove one entry of the specified listener and associated error listener.
|
|
// Don't explicitly remove the error listener.
|
|
imageProvider._streamCompleter.removeListener(
|
|
ImageStreamListener(listener, onError: errorListener),
|
|
);
|
|
late ImageConfiguration configuration;
|
|
await tester.pumpWidget(
|
|
Builder(
|
|
builder: (BuildContext context) {
|
|
configuration = createLocalImageConfiguration(context);
|
|
return Container();
|
|
},
|
|
),
|
|
);
|
|
imageProvider.resolve(configuration);
|
|
|
|
imageProvider.fail(testException, testStack);
|
|
|
|
expect(tester.binding.microtaskCount, 1);
|
|
await tester.idle(); // Let the failed completer's future hit the stream completer.
|
|
expect(tester.binding.microtaskCount, 0);
|
|
|
|
expect(errorListenerCalled, 1);
|
|
expect(capturedImage, isNull); // The image stream listeners should never be called.
|
|
});
|
|
|
|
testWidgets('Image.memory control test', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
Image.memory(Uint8List.fromList(kTransparentImage), excludeFromSemantics: true),
|
|
);
|
|
});
|
|
|
|
testWidgets('Image color and colorBlend parameters', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
Image(
|
|
excludeFromSemantics: true,
|
|
image: _TestImageProvider(),
|
|
color: const Color(0xFF00FF00),
|
|
colorBlendMode: BlendMode.clear,
|
|
),
|
|
);
|
|
final RenderImage renderer = tester.renderObject<RenderImage>(find.byType(Image));
|
|
expect(renderer.color, const Color(0xFF00FF00));
|
|
expect(renderer.colorBlendMode, BlendMode.clear);
|
|
});
|
|
|
|
testWidgets('Image opacity parameter', (WidgetTester tester) async {
|
|
const Animation<double> opacity = AlwaysStoppedAnimation<double>(0.5);
|
|
await tester.pumpWidget(
|
|
Image(excludeFromSemantics: true, image: _TestImageProvider(), opacity: opacity),
|
|
);
|
|
final RenderImage renderer = tester.renderObject<RenderImage>(find.byType(Image));
|
|
expect(renderer.opacity, opacity);
|
|
});
|
|
|
|
testWidgets('Precache', (WidgetTester tester) async {
|
|
final provider = _TestImageProvider();
|
|
late Future<void> precache;
|
|
await tester.pumpWidget(
|
|
Builder(
|
|
builder: (BuildContext context) {
|
|
precache = precacheImage(provider, context);
|
|
return Container();
|
|
},
|
|
),
|
|
);
|
|
provider.complete(image10x10);
|
|
await precache;
|
|
expect(provider._lastResolvedConfiguration, isNotNull);
|
|
|
|
// Check that a second resolve of the same image is synchronous.
|
|
final ImageStream stream = provider.resolve(provider._lastResolvedConfiguration);
|
|
late bool isSync;
|
|
stream.addListener(
|
|
ImageStreamListener((ImageInfo image, bool sync) {
|
|
image.dispose();
|
|
isSync = sync;
|
|
}),
|
|
);
|
|
expect(isSync, isTrue);
|
|
});
|
|
|
|
testWidgets(
|
|
'Precache removes original listener immediately after future completes, does not crash on successive calls #25143',
|
|
experimentalLeakTesting: LeakTesting.settings
|
|
.withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter].
|
|
(WidgetTester tester) async {
|
|
final imageStreamCompleter = _TestImageStreamCompleter();
|
|
final provider = _TestImageProvider(streamCompleter: imageStreamCompleter);
|
|
|
|
await tester.pumpWidget(
|
|
Builder(
|
|
builder: (BuildContext context) {
|
|
precacheImage(provider, context);
|
|
return Container();
|
|
},
|
|
),
|
|
);
|
|
|
|
// Two listeners - one is the listener added by precacheImage, the other by the ImageCache.
|
|
final List<ImageStreamListener> listeners = imageStreamCompleter.listeners.toList();
|
|
expect(listeners.length, 2);
|
|
|
|
// Make sure the first listener can be called re-entrantly
|
|
final imageInfo = ImageInfo(image: image10x10);
|
|
|
|
listeners[1].onImage(imageInfo.clone(), false);
|
|
listeners[1].onImage(imageInfo.clone(), false);
|
|
|
|
// Make sure the second listener can be called re-entrantly.
|
|
listeners[0].onImage(imageInfo.clone(), false);
|
|
listeners[0].onImage(imageInfo.clone(), false);
|
|
|
|
imageInfo.dispose();
|
|
imageStreamCompleter.dispose();
|
|
imageCache.clear();
|
|
},
|
|
);
|
|
|
|
testWidgets('Precache completes with onError on error', (WidgetTester tester) async {
|
|
dynamic capturedException;
|
|
StackTrace? capturedStackTrace;
|
|
void errorListener(dynamic exception, StackTrace? stackTrace) {
|
|
capturedException = exception;
|
|
capturedStackTrace = stackTrace;
|
|
}
|
|
|
|
final testException = Exception('cannot resolve host');
|
|
final StackTrace testStack = StackTrace.current;
|
|
final imageProvider = _TestImageProvider();
|
|
late Future<void> precache;
|
|
await tester.pumpWidget(
|
|
Builder(
|
|
builder: (BuildContext context) {
|
|
precache = precacheImage(imageProvider, context, onError: errorListener);
|
|
return Container();
|
|
},
|
|
),
|
|
);
|
|
imageProvider.fail(testException, testStack);
|
|
await precache;
|
|
|
|
// The image stream error handler should have the original exception.
|
|
expect(capturedException, testException);
|
|
expect(capturedStackTrace, testStack);
|
|
// If there is an error listener, there should be no FlutterError reported.
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets(
|
|
'TickerMode controls stream registration',
|
|
experimentalLeakTesting: LeakTesting.settings
|
|
.withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter].
|
|
(WidgetTester tester) async {
|
|
final imageStreamCompleter = _TestImageStreamCompleter();
|
|
final image = Image(
|
|
excludeFromSemantics: true,
|
|
image: _TestImageProvider(streamCompleter: imageStreamCompleter),
|
|
);
|
|
|
|
final ui.Codec codec = (await tester.runAsync(() {
|
|
return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif));
|
|
}))!;
|
|
Future<ui.Image> nextFrame() async {
|
|
final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!;
|
|
return frameInfo.image;
|
|
}
|
|
|
|
expect(imageStreamCompleter.listeners.length, 0);
|
|
await tester.pumpWidget(TickerMode(enabled: true, child: image));
|
|
expect(imageStreamCompleter.listeners.length, 2);
|
|
await tester.pumpWidget(TickerMode(enabled: false, child: image));
|
|
// Despite being paused, the first frame hasn't come in yet, so it's still
|
|
// listening.
|
|
expect(imageStreamCompleter.listeners.length, 2);
|
|
|
|
// Send the first frame and the listeners will be removed.
|
|
imageStreamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(imageStreamCompleter.listeners.length, 0);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'MediaQuery.disableAnimations controls stream registration',
|
|
experimentalLeakTesting: LeakTesting.settings
|
|
.withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter].
|
|
(WidgetTester tester) async {
|
|
final imageStreamCompleter = _TestImageStreamCompleter();
|
|
final image = Image(
|
|
excludeFromSemantics: true,
|
|
image: _TestImageProvider(streamCompleter: imageStreamCompleter),
|
|
);
|
|
final ui.Codec codec = (await tester.runAsync(() {
|
|
return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif));
|
|
}))!;
|
|
Future<ui.Image> nextFrame() async {
|
|
final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!;
|
|
return frameInfo.image;
|
|
}
|
|
|
|
expect(imageStreamCompleter.listeners.length, 0);
|
|
await tester.pumpWidget(image);
|
|
expect(imageStreamCompleter.listeners.length, 2);
|
|
await tester.pumpWidget(
|
|
MediaQuery(data: const MediaQueryData(disableAnimations: true), child: image),
|
|
);
|
|
// Despite being paused, the first frame hasn't come in yet, so it's still
|
|
// listening.
|
|
expect(imageStreamCompleter.listeners.length, 2);
|
|
|
|
// Send the first frame and the listeners will be removed.
|
|
imageStreamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(imageStreamCompleter.listeners.length, 0);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'Verify Image shows correct RenderImage when changing to an already completed provider',
|
|
(WidgetTester tester) async {
|
|
final GlobalKey key = GlobalKey();
|
|
|
|
final imageProvider1 = _TestImageProvider();
|
|
final imageProvider2 = _TestImageProvider();
|
|
final ui.Image image100x100 = (await tester.runAsync(
|
|
() async => createTestImage(width: 100, height: 100),
|
|
))!;
|
|
|
|
await tester.pumpWidget(
|
|
Container(
|
|
key: key,
|
|
child: Image(excludeFromSemantics: true, image: imageProvider1),
|
|
),
|
|
phase: EnginePhase.layout,
|
|
);
|
|
var renderImage = key.currentContext!.findRenderObject()! as RenderImage;
|
|
expect(renderImage.image, isNull);
|
|
|
|
imageProvider1.complete(image10x10);
|
|
imageProvider2.complete(image100x100);
|
|
await tester.idle(); // resolve the future from the image provider
|
|
await tester.pump(null, EnginePhase.layout);
|
|
|
|
renderImage = key.currentContext!.findRenderObject()! as RenderImage;
|
|
expect(renderImage.image, isNotNull);
|
|
|
|
final ui.Image oldImage = renderImage.image!;
|
|
|
|
await tester.pumpWidget(
|
|
Container(
|
|
key: key,
|
|
child: Image(excludeFromSemantics: true, image: imageProvider2),
|
|
),
|
|
phase: EnginePhase.layout,
|
|
);
|
|
|
|
renderImage = key.currentContext!.findRenderObject()! as RenderImage;
|
|
expect(renderImage.image, isNotNull);
|
|
expect(renderImage.image, isNot(equals(oldImage)));
|
|
},
|
|
);
|
|
|
|
testWidgets('Image State can be reconfigured to use another image', (WidgetTester tester) async {
|
|
final image1 = Image(
|
|
image: _TestImageProvider()..complete(image10x10.clone()),
|
|
width: 10.0,
|
|
excludeFromSemantics: true,
|
|
);
|
|
final image2 = Image(
|
|
image: _TestImageProvider()..complete(image10x10.clone()),
|
|
width: 20.0,
|
|
excludeFromSemantics: true,
|
|
);
|
|
|
|
final column = Column(children: <Widget>[image1, image2]);
|
|
await tester.pumpWidget(column, phase: EnginePhase.layout);
|
|
|
|
final columnSwapped = Column(children: <Widget>[image2, image1]);
|
|
await tester.pumpWidget(columnSwapped, phase: EnginePhase.layout);
|
|
|
|
final List<RenderImage> renderObjects = tester
|
|
.renderObjectList<RenderImage>(find.byType(Image))
|
|
.toList();
|
|
expect(renderObjects, hasLength(2));
|
|
expect(renderObjects[0].image, isNotNull);
|
|
expect(renderObjects[0].width, 20.0);
|
|
expect(renderObjects[1].image, isNotNull);
|
|
expect(renderObjects[1].width, 10.0);
|
|
});
|
|
|
|
testWidgets('Image contributes semantics', (WidgetTester tester) async {
|
|
final semantics = SemanticsTester(tester);
|
|
await tester.pumpWidget(
|
|
Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Row(
|
|
children: <Widget>[
|
|
Image(image: _TestImageProvider(), width: 100.0, height: 100.0, semanticLabel: 'test'),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
semantics,
|
|
hasSemantics(
|
|
TestSemantics.root(
|
|
children: <TestSemantics>[
|
|
TestSemantics.rootChild(
|
|
id: 1,
|
|
label: 'test',
|
|
rect: const Rect.fromLTWH(0.0, 0.0, 100.0, 100.0),
|
|
textDirection: TextDirection.ltr,
|
|
flags: <SemanticsFlag>[SemanticsFlag.isImage],
|
|
),
|
|
],
|
|
),
|
|
ignoreTransform: true,
|
|
),
|
|
);
|
|
semantics.dispose();
|
|
});
|
|
|
|
testWidgets('Image can exclude semantics', (WidgetTester tester) async {
|
|
final semantics = SemanticsTester(tester);
|
|
await tester.pumpWidget(
|
|
Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Image(
|
|
image: _TestImageProvider(),
|
|
width: 100.0,
|
|
height: 100.0,
|
|
excludeFromSemantics: true,
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(semantics, hasSemantics(TestSemantics.root(children: <TestSemantics>[])));
|
|
semantics.dispose();
|
|
});
|
|
|
|
testWidgets(
|
|
'Image invokes frameBuilder with correct frameNumber argument',
|
|
experimentalLeakTesting: LeakTesting.settings
|
|
.withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter].
|
|
(WidgetTester tester) async {
|
|
final ui.Codec codec = (await tester.runAsync(() {
|
|
return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif));
|
|
}))!;
|
|
|
|
Future<ui.Image> nextFrame() async {
|
|
final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!;
|
|
return frameInfo.image;
|
|
}
|
|
|
|
final streamCompleter = _TestImageStreamCompleter();
|
|
final imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
|
|
int? lastFrame;
|
|
|
|
await tester.pumpWidget(
|
|
Image(
|
|
image: imageProvider,
|
|
frameBuilder:
|
|
(BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) {
|
|
lastFrame = frame;
|
|
return Center(child: child);
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(lastFrame, isNull);
|
|
expect(find.byType(Center), findsOneWidget);
|
|
expect(find.byType(RawImage), findsOneWidget);
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame, 0);
|
|
expect(find.byType(Center), findsOneWidget);
|
|
expect(find.byType(RawImage), findsOneWidget);
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame, 1);
|
|
expect(find.byType(Center), findsOneWidget);
|
|
expect(find.byType(RawImage), findsOneWidget);
|
|
},
|
|
);
|
|
|
|
testWidgets('Image invokes frameBuilder with correct wasSynchronouslyLoaded=false', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final streamCompleter = _TestImageStreamCompleter();
|
|
final imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
|
|
int? lastFrame;
|
|
late bool lastFrameWasSync;
|
|
|
|
await tester.pumpWidget(
|
|
Image(
|
|
image: imageProvider,
|
|
frameBuilder:
|
|
(BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) {
|
|
lastFrame = frame;
|
|
lastFrameWasSync = wasSynchronouslyLoaded;
|
|
return child;
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(lastFrame, isNull);
|
|
expect(lastFrameWasSync, isFalse);
|
|
expect(find.byType(RawImage), findsOneWidget);
|
|
|
|
final info = ImageInfo(image: image10x10);
|
|
addTearDown(info.dispose);
|
|
streamCompleter.setData(imageInfo: info);
|
|
await tester.pump();
|
|
|
|
expect(lastFrame, 0);
|
|
expect(lastFrameWasSync, isFalse);
|
|
});
|
|
|
|
testWidgets(
|
|
'Image invokes frameBuilder with correct wasSynchronouslyLoaded=true',
|
|
experimentalLeakTesting: LeakTesting.settings
|
|
.withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter].
|
|
(WidgetTester tester) async {
|
|
final streamCompleter = _TestImageStreamCompleter(ImageInfo(image: image10x10.clone()));
|
|
final imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
|
|
int? lastFrame;
|
|
late bool lastFrameWasSync;
|
|
|
|
await tester.pumpWidget(
|
|
Image(
|
|
image: imageProvider,
|
|
frameBuilder:
|
|
(BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) {
|
|
lastFrame = frame;
|
|
lastFrameWasSync = wasSynchronouslyLoaded;
|
|
return child;
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(lastFrame, 0);
|
|
expect(lastFrameWasSync, isTrue);
|
|
expect(find.byType(RawImage), findsOneWidget);
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: image10x10.clone()));
|
|
await tester.pump();
|
|
expect(lastFrame, 1);
|
|
expect(lastFrameWasSync, isTrue);
|
|
},
|
|
);
|
|
|
|
testWidgets('Image state handles frameBuilder update', (WidgetTester tester) async {
|
|
final streamCompleter = _TestImageStreamCompleter();
|
|
final imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
|
|
|
|
await tester.pumpWidget(
|
|
Image(
|
|
image: imageProvider,
|
|
frameBuilder:
|
|
(BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) {
|
|
return Center(child: child);
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(find.byType(Center), findsOneWidget);
|
|
expect(find.byType(RawImage), findsOneWidget);
|
|
final State<Image> state = tester.state(find.byType(Image));
|
|
|
|
await tester.pumpWidget(
|
|
Image(
|
|
image: imageProvider,
|
|
frameBuilder:
|
|
(BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) {
|
|
return Padding(padding: const EdgeInsets.all(1), child: child);
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(find.byType(Center), findsNothing);
|
|
expect(find.byType(Padding), findsOneWidget);
|
|
expect(find.byType(RawImage), findsOneWidget);
|
|
expect(tester.state(find.byType(Image)), same(state));
|
|
});
|
|
|
|
testWidgets(
|
|
'Image state handles enabling and disabling of tickers',
|
|
experimentalLeakTesting: LeakTesting.settings
|
|
.withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter].
|
|
(WidgetTester tester) async {
|
|
final ui.Codec codec = (await tester.runAsync(() {
|
|
return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif));
|
|
}))!;
|
|
|
|
Future<ui.Image> nextFrame() async {
|
|
final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!;
|
|
return frameInfo.image;
|
|
}
|
|
|
|
final streamCompleter = _TestImageStreamCompleter();
|
|
final imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
|
|
int? lastFrame;
|
|
var buildCount = 0;
|
|
|
|
Widget buildFrame(
|
|
BuildContext context,
|
|
Widget child,
|
|
int? frame,
|
|
bool wasSynchronouslyLoaded,
|
|
) {
|
|
lastFrame = frame;
|
|
buildCount++;
|
|
return child;
|
|
}
|
|
|
|
await tester.pumpWidget(
|
|
TickerMode(
|
|
enabled: true,
|
|
child: Image(image: imageProvider, frameBuilder: buildFrame),
|
|
),
|
|
);
|
|
|
|
final State<Image> state = tester.state(find.byType(Image));
|
|
expect(lastFrame, isNull);
|
|
expect(buildCount, 1);
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame, 0);
|
|
expect(buildCount, 2);
|
|
|
|
await tester.pumpWidget(
|
|
TickerMode(
|
|
enabled: false,
|
|
child: Image(image: imageProvider, frameBuilder: buildFrame),
|
|
),
|
|
);
|
|
|
|
expect(tester.state(find.byType(Image)), same(state));
|
|
expect(lastFrame, 0);
|
|
expect(buildCount, 3);
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame, 0);
|
|
expect(buildCount, 3);
|
|
|
|
await tester.pumpWidget(
|
|
TickerMode(
|
|
enabled: true,
|
|
child: Image(image: imageProvider, frameBuilder: buildFrame),
|
|
),
|
|
);
|
|
|
|
expect(tester.state(find.byType(Image)), same(state));
|
|
expect(lastFrame, 1); // missed a frame because we weren't animating at the time
|
|
expect(buildCount, 4);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'disableAnimations prevents the image from updating',
|
|
experimentalLeakTesting: LeakTesting.settings
|
|
.withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter].
|
|
(WidgetTester tester) async {
|
|
final ui.Codec codec = (await tester.runAsync(() {
|
|
return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif));
|
|
}))!;
|
|
|
|
Future<ui.Image> nextFrame() async {
|
|
final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!;
|
|
return frameInfo.image;
|
|
}
|
|
|
|
final streamCompleter = _TestImageStreamCompleter();
|
|
final imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
|
|
int? lastFrame;
|
|
var buildCount = 0;
|
|
|
|
Widget buildFrame(
|
|
BuildContext context,
|
|
Widget child,
|
|
int? frame,
|
|
bool wasSynchronouslyLoaded,
|
|
) {
|
|
lastFrame = frame;
|
|
buildCount++;
|
|
return child;
|
|
}
|
|
|
|
var disableAnimations = false;
|
|
late StateSetter setState;
|
|
await tester.pumpWidget(
|
|
StatefulBuilder(
|
|
builder: (BuildContext context, StateSetter localSetState) {
|
|
setState = localSetState;
|
|
return MediaQuery(
|
|
data: MediaQueryData(disableAnimations: disableAnimations),
|
|
child: Image(image: imageProvider, frameBuilder: buildFrame),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(lastFrame, isNull);
|
|
expect(buildCount, 1);
|
|
|
|
// Pumping another frame doesn't do anything.
|
|
await tester.pump();
|
|
expect(lastFrame, isNull);
|
|
expect(buildCount, 1);
|
|
|
|
// When some data comes through for the image, it updates to show the image.
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame, 0);
|
|
expect(buildCount, 2);
|
|
|
|
// Pumping another frame doesn't do anything.
|
|
await tester.pump();
|
|
expect(lastFrame, 0);
|
|
expect(buildCount, 2);
|
|
|
|
// When another image frame comes, it updates again.
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame, 1);
|
|
expect(buildCount, 3);
|
|
|
|
// Disable animations. A rebuild happens of the same frame.
|
|
setState(() {
|
|
disableAnimations = true;
|
|
});
|
|
await tester.pump();
|
|
expect(lastFrame, 1);
|
|
expect(buildCount, 4);
|
|
|
|
// A new frame arriving does nothing because animations are disabled.
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame, 1);
|
|
expect(buildCount, 4);
|
|
|
|
// Re-enable animations. The image updates to show the frame that was
|
|
// received while disabled.
|
|
setState(() {
|
|
disableAnimations = false;
|
|
});
|
|
await tester.pump();
|
|
expect(lastFrame, 2);
|
|
expect(buildCount, 5);
|
|
|
|
// Subsequent frames showing up update the image.
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame, 3);
|
|
expect(buildCount, 6);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'initial load',
|
|
experimentalLeakTesting: LeakTesting.settings
|
|
.withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter].
|
|
(WidgetTester tester) async {
|
|
final ui.Codec codec = (await tester.runAsync(() {
|
|
return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif));
|
|
}))!;
|
|
|
|
Future<ui.Image> nextFrame() async {
|
|
final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!;
|
|
return frameInfo.image;
|
|
}
|
|
|
|
final streamCompleter = _TestImageStreamCompleter();
|
|
final imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
|
|
int? lastFrame;
|
|
var buildCount = 0;
|
|
|
|
Widget buildFrame(
|
|
BuildContext context,
|
|
Widget child,
|
|
int? frame,
|
|
bool wasSynchronouslyLoaded,
|
|
) {
|
|
lastFrame = frame;
|
|
buildCount++;
|
|
return child;
|
|
}
|
|
|
|
await tester.pumpWidget(Image(image: imageProvider, frameBuilder: buildFrame));
|
|
|
|
expect(lastFrame, isNull);
|
|
expect(buildCount, 1);
|
|
|
|
// Pumping another frame doesn't do anything.
|
|
await tester.pump();
|
|
expect(lastFrame, isNull);
|
|
expect(buildCount, 1);
|
|
|
|
// When some image data comes through, it updates to show the image.
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame, 0);
|
|
expect(buildCount, 2);
|
|
|
|
// Pumping another frame doesn't do anything.
|
|
await tester.pump();
|
|
expect(lastFrame, 0);
|
|
expect(buildCount, 2);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'initial load with existing image data',
|
|
experimentalLeakTesting: LeakTesting.settings
|
|
.withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter].
|
|
(WidgetTester tester) async {
|
|
final ui.Codec codec = (await tester.runAsync(() {
|
|
return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif));
|
|
}))!;
|
|
|
|
Future<ui.Image> nextFrame() async {
|
|
final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!;
|
|
return frameInfo.image;
|
|
}
|
|
|
|
final streamCompleter = _TestImageStreamCompleter();
|
|
final imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
|
|
int? lastFrame;
|
|
var buildCount = 0;
|
|
|
|
Widget buildFrame(
|
|
BuildContext context,
|
|
Widget child,
|
|
int? frame,
|
|
bool wasSynchronouslyLoaded,
|
|
) {
|
|
lastFrame = frame;
|
|
buildCount++;
|
|
return child;
|
|
}
|
|
|
|
// Load a frame before even pumping the widget.
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
|
|
expect(lastFrame, isNull);
|
|
expect(buildCount, 0);
|
|
|
|
await tester.pumpWidget(Image(image: imageProvider, frameBuilder: buildFrame));
|
|
|
|
// The first frame of the image is shown in the first frame of the app.
|
|
expect(lastFrame, 0);
|
|
expect(buildCount, 1);
|
|
|
|
// Pumping another frame doesn't do anything.
|
|
await tester.pump();
|
|
expect(lastFrame, 0);
|
|
expect(buildCount, 1);
|
|
},
|
|
);
|
|
|
|
for (final _DisableMethod disableMethod in _DisableMethod.values) {
|
|
testWidgets(
|
|
'image source swapping with $disableMethod',
|
|
experimentalLeakTesting: LeakTesting.settings
|
|
.withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter].
|
|
(WidgetTester tester) async {
|
|
final ui.Codec codec = (await tester.runAsync(() {
|
|
return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif));
|
|
}))!;
|
|
|
|
Future<ui.Image> nextFrame() async {
|
|
final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!;
|
|
return frameInfo.image;
|
|
}
|
|
|
|
final streamCompleter1 = _TestImageStreamCompleter();
|
|
final imageProvider1 = _TestImageProvider(streamCompleter: streamCompleter1);
|
|
final streamCompleter2 = _TestImageStreamCompleter();
|
|
final imageProvider2 = _TestImageProvider(streamCompleter: streamCompleter2);
|
|
int? lastFrame1;
|
|
int? lastFrame2;
|
|
var buildCount = 0;
|
|
|
|
var imageProvider = imageProvider1;
|
|
Widget buildFrame1(
|
|
BuildContext context,
|
|
Widget child,
|
|
int? frame,
|
|
bool wasSynchronouslyLoaded,
|
|
) {
|
|
lastFrame1 = frame;
|
|
buildCount++;
|
|
return child;
|
|
}
|
|
|
|
Widget buildFrame2(
|
|
BuildContext context,
|
|
Widget child,
|
|
int? frame,
|
|
bool wasSynchronouslyLoaded,
|
|
) {
|
|
lastFrame2 = frame;
|
|
buildCount++;
|
|
return child;
|
|
}
|
|
|
|
var disableAnimations = false;
|
|
late StateSetter setState;
|
|
await tester.pumpWidget(
|
|
StatefulBuilder(
|
|
builder: (BuildContext context, StateSetter localSetState) {
|
|
setState = localSetState;
|
|
return switch (disableMethod) {
|
|
_DisableMethod.tickerMode => TickerMode(
|
|
enabled: !disableAnimations,
|
|
child: Image(
|
|
image: imageProvider,
|
|
frameBuilder: imageProvider == imageProvider1 ? buildFrame1 : buildFrame2,
|
|
),
|
|
),
|
|
_DisableMethod.mediaQuery => MediaQuery(
|
|
data: MediaQueryData(disableAnimations: disableAnimations),
|
|
child: Image(
|
|
image: imageProvider,
|
|
frameBuilder: imageProvider == imageProvider1 ? buildFrame1 : buildFrame2,
|
|
),
|
|
),
|
|
};
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(lastFrame1, isNull);
|
|
expect(lastFrame2, isNull);
|
|
expect(buildCount, 1);
|
|
|
|
// Pumping another frame doesn't do anything.
|
|
await tester.pump();
|
|
expect(lastFrame1, isNull);
|
|
expect(lastFrame2, isNull);
|
|
expect(buildCount, 1);
|
|
|
|
// When some data comes through for image 1, it updates to show the image.
|
|
streamCompleter1.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, isNull);
|
|
expect(buildCount, 2);
|
|
|
|
// Pumping another frame doesn't do anything.
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, isNull);
|
|
expect(buildCount, 2);
|
|
|
|
// Swap the image source and pump a frame. The second image hasn't
|
|
// displayed because its first frame hasn't arrived yet.
|
|
setState(() {
|
|
imageProvider = imageProvider2;
|
|
});
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, isNull);
|
|
expect(buildCount, 3);
|
|
|
|
// If another frame comes for image 1, nothing happens, because we have
|
|
// swapped the image source to image 2.
|
|
streamCompleter1.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, isNull);
|
|
expect(buildCount, 3);
|
|
|
|
// When image 2's first frame comes, it updates.
|
|
streamCompleter2.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, 0);
|
|
expect(buildCount, 4);
|
|
|
|
// Disable animations. A rebuild happens of the same frame.
|
|
setState(() {
|
|
disableAnimations = true;
|
|
});
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, 0);
|
|
expect(buildCount, 5);
|
|
|
|
// A new frame arriving for either image does nothing because animations
|
|
// are disabled.
|
|
streamCompleter1.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, 0);
|
|
expect(buildCount, 5);
|
|
streamCompleter2.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, 0);
|
|
expect(buildCount, 5);
|
|
|
|
// Swapping the image source back rebuilds to show the old image.
|
|
setState(() {
|
|
imageProvider = imageProvider1;
|
|
});
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, 0);
|
|
expect(buildCount, 6);
|
|
|
|
// Re-enable animations. Subsequent frames showing up update the image.
|
|
setState(() {
|
|
disableAnimations = false;
|
|
});
|
|
await tester.pump();
|
|
expect(lastFrame1, 1);
|
|
expect(lastFrame2, 0);
|
|
expect(buildCount, 7);
|
|
streamCompleter1.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame1, 2);
|
|
expect(lastFrame2, 0);
|
|
expect(buildCount, 8);
|
|
streamCompleter1.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame1, 3);
|
|
expect(lastFrame2, 0);
|
|
expect(buildCount, 9);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'image source swapping while paused with $disableMethod',
|
|
experimentalLeakTesting: LeakTesting.settings
|
|
.withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter].
|
|
(WidgetTester tester) async {
|
|
final ui.Codec codec = (await tester.runAsync(() {
|
|
return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif));
|
|
}))!;
|
|
|
|
Future<ui.Image> nextFrame() async {
|
|
final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!;
|
|
return frameInfo.image;
|
|
}
|
|
|
|
final streamCompleter1 = _TestImageStreamCompleter();
|
|
final imageProvider1 = _TestImageProvider(streamCompleter: streamCompleter1);
|
|
final streamCompleter2 = _TestImageStreamCompleter();
|
|
final imageProvider2 = _TestImageProvider(streamCompleter: streamCompleter2);
|
|
int? lastFrame1;
|
|
int? lastFrame2;
|
|
var buildCount = 0;
|
|
|
|
var imageProvider = imageProvider1;
|
|
Widget buildFrame1(
|
|
BuildContext context,
|
|
Widget child,
|
|
int? frame,
|
|
bool wasSynchronouslyLoaded,
|
|
) {
|
|
lastFrame1 = frame;
|
|
buildCount++;
|
|
return child;
|
|
}
|
|
|
|
Widget buildFrame2(
|
|
BuildContext context,
|
|
Widget child,
|
|
int? frame,
|
|
bool wasSynchronouslyLoaded,
|
|
) {
|
|
lastFrame2 = frame;
|
|
buildCount++;
|
|
return child;
|
|
}
|
|
|
|
late StateSetter setState;
|
|
await tester.pumpWidget(
|
|
StatefulBuilder(
|
|
builder: (BuildContext context, StateSetter localSetState) {
|
|
setState = localSetState;
|
|
return switch (disableMethod) {
|
|
_DisableMethod.tickerMode => TickerMode(
|
|
enabled: false,
|
|
child: Image(
|
|
image: imageProvider,
|
|
frameBuilder: imageProvider == imageProvider1 ? buildFrame1 : buildFrame2,
|
|
),
|
|
),
|
|
_DisableMethod.mediaQuery => MediaQuery(
|
|
data: const MediaQueryData(disableAnimations: true),
|
|
child: Image(
|
|
image: imageProvider,
|
|
frameBuilder: imageProvider == imageProvider1 ? buildFrame1 : buildFrame2,
|
|
),
|
|
),
|
|
};
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(lastFrame1, isNull);
|
|
expect(lastFrame2, isNull);
|
|
expect(buildCount, 1);
|
|
|
|
// Pumping another frame doesn't do anything.
|
|
await tester.pump();
|
|
expect(lastFrame1, isNull);
|
|
expect(lastFrame2, isNull);
|
|
expect(buildCount, 1);
|
|
|
|
// When some data comes through for image 1, it updates to show the
|
|
// first frame.
|
|
streamCompleter1.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, isNull);
|
|
expect(buildCount, 2);
|
|
|
|
// When some data comes through for image 2, it doesn't update because
|
|
// it's not showing that image.
|
|
streamCompleter2.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, isNull);
|
|
expect(buildCount, 2);
|
|
|
|
// Pumping another frame doesn't do anything.
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, isNull);
|
|
expect(buildCount, 2);
|
|
|
|
// Subsequent frames do nothing because it's paused.
|
|
streamCompleter1.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, isNull);
|
|
expect(buildCount, 2);
|
|
streamCompleter2.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, isNull);
|
|
expect(buildCount, 2);
|
|
|
|
// Swap the image source and pump a frame. The second image updates with
|
|
// the frame that already came in.
|
|
setState(() {
|
|
imageProvider = imageProvider2;
|
|
});
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, 0);
|
|
expect(buildCount, 3);
|
|
|
|
// Subsequently swapping the image source loads the new image but does
|
|
// not advance the frame.
|
|
setState(() {
|
|
imageProvider = imageProvider1;
|
|
});
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, 0);
|
|
expect(buildCount, 4);
|
|
setState(() {
|
|
imageProvider = imageProvider2;
|
|
});
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, 0);
|
|
expect(buildCount, 5);
|
|
|
|
// Even when new frames come in, they are not displayed.
|
|
streamCompleter1.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, 0);
|
|
expect(buildCount, 5);
|
|
streamCompleter2.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, 0);
|
|
expect(buildCount, 5);
|
|
|
|
// Even when the source is swapped again, the new frames that previously
|
|
// came in are not displayed.
|
|
setState(() {
|
|
imageProvider = imageProvider1;
|
|
});
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, 0);
|
|
expect(buildCount, 6);
|
|
setState(() {
|
|
imageProvider = imageProvider2;
|
|
});
|
|
await tester.pump();
|
|
expect(lastFrame1, 0);
|
|
expect(lastFrame2, 0);
|
|
expect(buildCount, 7);
|
|
},
|
|
);
|
|
|
|
testWidgets('image source swapping and image disposal with $disableMethod', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final ui.Image image1 = (await tester.runAsync(() => createTestImage(cache: false)))!;
|
|
final imageProvider1 = _TestImageProvider(
|
|
streamCompleter: OneFrameImageStreamCompleter(
|
|
Future<ImageInfo>.value(ImageInfo(image: image1, debugLabel: '_TestImage1')),
|
|
),
|
|
);
|
|
final ui.Image image2 = (await tester.runAsync(() => createTestImage(cache: false)))!;
|
|
final imageProvider2 = _TestImageProvider(
|
|
streamCompleter: OneFrameImageStreamCompleter(
|
|
Future<ImageInfo>.value(ImageInfo(image: image2, debugLabel: '_TestImage2')),
|
|
),
|
|
);
|
|
|
|
expect(image1.debugGetOpenHandleStackTraces()!.length, 1);
|
|
expect(image2.debugGetOpenHandleStackTraces()!.length, 1);
|
|
|
|
var imageProvider = imageProvider1;
|
|
var disableAnimations = false;
|
|
late StateSetter setState;
|
|
await tester.pumpWidget(
|
|
StatefulBuilder(
|
|
builder: (BuildContext context, StateSetter localSetState) {
|
|
setState = localSetState;
|
|
return switch (disableMethod) {
|
|
_DisableMethod.tickerMode => TickerMode(
|
|
enabled: !disableAnimations,
|
|
child: Image(image: imageProvider),
|
|
),
|
|
_DisableMethod.mediaQuery => MediaQuery(
|
|
data: MediaQueryData(disableAnimations: disableAnimations),
|
|
child: Image(image: imageProvider),
|
|
),
|
|
};
|
|
},
|
|
),
|
|
);
|
|
|
|
// Image widget + 1, render object + 1 for the active image.
|
|
expect(image1.debugGetOpenHandleStackTraces()!.length, 3);
|
|
expect(image2.debugGetOpenHandleStackTraces()!.length, 1);
|
|
|
|
// Pumping another frame doesn't change anything.
|
|
await tester.pump();
|
|
expect(image1.debugGetOpenHandleStackTraces()!.length, 3);
|
|
expect(image2.debugGetOpenHandleStackTraces()!.length, 1);
|
|
|
|
// Swap the image source and pump a frame.
|
|
// Image widget + 1, render object + 1 for the active image.
|
|
// Image widget - 1, render object - 1 for the inactive image.
|
|
setState(() {
|
|
imageProvider = imageProvider2;
|
|
});
|
|
await tester.pump();
|
|
expect(image1.debugGetOpenHandleStackTraces()!.length, 1);
|
|
expect(image2.debugGetOpenHandleStackTraces()!.length, 3);
|
|
|
|
// Disable animations.
|
|
setState(() {
|
|
disableAnimations = true;
|
|
});
|
|
await tester.pump();
|
|
expect(image1.debugGetOpenHandleStackTraces()!.length, 1);
|
|
expect(image2.debugGetOpenHandleStackTraces()!.length, 3);
|
|
|
|
// Swapping the image source back.
|
|
// Image widget + 1, render object + 1 for the active image.
|
|
// Image widget - 1, render object + 1 for the inactive image.
|
|
setState(() {
|
|
imageProvider = imageProvider1;
|
|
});
|
|
await tester.pump();
|
|
expect(image1.debugGetOpenHandleStackTraces()!.length, 3);
|
|
expect(image2.debugGetOpenHandleStackTraces()!.length, 1);
|
|
|
|
// Re-enable animations.
|
|
setState(() {
|
|
disableAnimations = false;
|
|
});
|
|
await tester.pump();
|
|
expect(image1.debugGetOpenHandleStackTraces()!.length, 3);
|
|
expect(image2.debugGetOpenHandleStackTraces()!.length, 1);
|
|
|
|
// Disposing disposes both the active and inactive images.
|
|
await tester.pumpWidget(const SizedBox());
|
|
|
|
// Image widget and render object go away.
|
|
expect(image1.debugGetOpenHandleStackTraces()!.length, 1);
|
|
expect(image2.debugGetOpenHandleStackTraces()!.length, 1);
|
|
|
|
await imageProvider1.evict();
|
|
await imageProvider2.evict();
|
|
|
|
tester.binding.scheduleFrame();
|
|
await tester.pump();
|
|
expect(image1.debugGetOpenHandleStackTraces()!.length, 0);
|
|
expect(image2.debugGetOpenHandleStackTraces()!.length, 0);
|
|
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/87442
|
|
}
|
|
|
|
testWidgets(
|
|
'the first frame is still loaded when disableAnimations is true on first load',
|
|
experimentalLeakTesting: LeakTesting.settings
|
|
.withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter].
|
|
(WidgetTester tester) async {
|
|
final ui.Codec codec = (await tester.runAsync(() {
|
|
return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif));
|
|
}))!;
|
|
|
|
Future<ui.Image> nextFrame() async {
|
|
final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!;
|
|
return frameInfo.image;
|
|
}
|
|
|
|
final streamCompleter = _TestImageStreamCompleter();
|
|
final imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
|
|
int? lastFrame;
|
|
var buildCount = 0;
|
|
|
|
Widget buildFrame(
|
|
BuildContext context,
|
|
Widget child,
|
|
int? frame,
|
|
bool wasSynchronouslyLoaded,
|
|
) {
|
|
lastFrame = frame;
|
|
buildCount++;
|
|
return child;
|
|
}
|
|
|
|
await tester.pumpWidget(
|
|
MediaQuery(
|
|
data: const MediaQueryData(disableAnimations: true),
|
|
child: Image(image: imageProvider, frameBuilder: buildFrame),
|
|
),
|
|
);
|
|
|
|
expect(lastFrame, isNull);
|
|
expect(buildCount, 1);
|
|
|
|
// Pumping another frame doesn't do anything.
|
|
await tester.pump();
|
|
expect(lastFrame, isNull);
|
|
expect(buildCount, 1);
|
|
|
|
// When some data comes through for the image, it updates to show the image,
|
|
// even though disableAnimations is true.
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame, 0);
|
|
expect(buildCount, 2);
|
|
|
|
// Pumping another frame doesn't do anything.
|
|
await tester.pump();
|
|
expect(lastFrame, 0);
|
|
expect(buildCount, 2);
|
|
|
|
// Subsequent frames arriving don't do anything, because disableAnimations
|
|
// is true.
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame, 0);
|
|
expect(buildCount, 2);
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame, 0);
|
|
expect(buildCount, 2);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'the first frame is still loaded when TickerMode is disabled on first load',
|
|
experimentalLeakTesting: LeakTesting.settings
|
|
.withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter].
|
|
(WidgetTester tester) async {
|
|
final ui.Codec codec = (await tester.runAsync(() {
|
|
return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif));
|
|
}))!;
|
|
|
|
Future<ui.Image> nextFrame() async {
|
|
final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!;
|
|
return frameInfo.image;
|
|
}
|
|
|
|
final streamCompleter = _TestImageStreamCompleter();
|
|
final imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
|
|
int? lastFrame;
|
|
var buildCount = 0;
|
|
|
|
Widget buildFrame(
|
|
BuildContext context,
|
|
Widget child,
|
|
int? frame,
|
|
bool wasSynchronouslyLoaded,
|
|
) {
|
|
lastFrame = frame;
|
|
buildCount++;
|
|
return child;
|
|
}
|
|
|
|
await tester.pumpWidget(
|
|
TickerMode(
|
|
enabled: false,
|
|
child: Image(image: imageProvider, frameBuilder: buildFrame),
|
|
),
|
|
);
|
|
|
|
expect(lastFrame, isNull);
|
|
expect(buildCount, 1);
|
|
|
|
// Pumping another frame doesn't do anything.
|
|
await tester.pump();
|
|
expect(lastFrame, isNull);
|
|
expect(buildCount, 1);
|
|
|
|
// When some data comes through for the image, it updates to show the image,
|
|
// even though disableAnimations is true.
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame, 0);
|
|
expect(buildCount, 2);
|
|
|
|
// Pumping another frame doesn't do anything.
|
|
await tester.pump();
|
|
expect(lastFrame, 0);
|
|
expect(buildCount, 2);
|
|
|
|
// Subsequent frames arriving don't do anything, because disableAnimations
|
|
// is true.
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame, 0);
|
|
expect(buildCount, 2);
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame, 0);
|
|
expect(buildCount, 2);
|
|
},
|
|
);
|
|
|
|
testWidgets('Image invokes loadingBuilder on chunk event notification', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final streamCompleter = _TestImageStreamCompleter();
|
|
final imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
|
|
final chunkEvents = <ImageChunkEvent?>[];
|
|
|
|
await tester.pumpWidget(
|
|
Image(
|
|
image: imageProvider,
|
|
loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
|
|
chunkEvents.add(loadingProgress);
|
|
if (loadingProgress == null) {
|
|
return child;
|
|
}
|
|
return Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Text(
|
|
'loading ${loadingProgress.cumulativeBytesLoaded} / ${loadingProgress.expectedTotalBytes}',
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(chunkEvents.length, 1);
|
|
expect(chunkEvents.first, isNull);
|
|
expect(tester.binding.hasScheduledFrame, isFalse);
|
|
streamCompleter.setData(
|
|
chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100),
|
|
);
|
|
expect(tester.binding.hasScheduledFrame, isTrue);
|
|
await tester.pump();
|
|
expect(chunkEvents.length, 2);
|
|
expect(find.text('loading 10 / 100'), findsOneWidget);
|
|
expect(find.byType(RawImage), findsNothing);
|
|
streamCompleter.setData(
|
|
chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 30, expectedTotalBytes: 100),
|
|
);
|
|
expect(tester.binding.hasScheduledFrame, isTrue);
|
|
await tester.pump();
|
|
expect(chunkEvents.length, 3);
|
|
expect(find.text('loading 30 / 100'), findsOneWidget);
|
|
expect(find.byType(RawImage), findsNothing);
|
|
|
|
final info = ImageInfo(image: image10x10);
|
|
addTearDown(info.dispose);
|
|
streamCompleter.setData(imageInfo: info);
|
|
await tester.pump();
|
|
|
|
expect(chunkEvents.length, 4);
|
|
expect(find.byType(Text), findsNothing);
|
|
expect(find.byType(RawImage), findsOneWidget);
|
|
});
|
|
|
|
testWidgets("Image doesn't rebuild on chunk events if loadingBuilder is null", (
|
|
WidgetTester tester,
|
|
) async {
|
|
final streamCompleter = _TestImageStreamCompleter();
|
|
final imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
|
|
|
|
await tester.pumpWidget(Image(image: imageProvider, excludeFromSemantics: true));
|
|
|
|
expect(tester.binding.hasScheduledFrame, isFalse);
|
|
streamCompleter.setData(
|
|
chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100),
|
|
);
|
|
expect(tester.binding.hasScheduledFrame, isFalse);
|
|
final info = ImageInfo(image: image10x10);
|
|
addTearDown(info.dispose);
|
|
streamCompleter.setData(imageInfo: info);
|
|
expect(tester.binding.hasScheduledFrame, isTrue);
|
|
await tester.pump();
|
|
streamCompleter.setData(
|
|
chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100),
|
|
);
|
|
expect(tester.binding.hasScheduledFrame, isFalse);
|
|
expect(find.byType(RawImage), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('Image chains the results of frameBuilder and loadingBuilder', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final streamCompleter = _TestImageStreamCompleter();
|
|
final imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
|
|
|
|
await tester.pumpWidget(
|
|
Image(
|
|
image: imageProvider,
|
|
excludeFromSemantics: true,
|
|
frameBuilder:
|
|
(BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) {
|
|
return Padding(padding: const EdgeInsets.all(1), child: child);
|
|
},
|
|
loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
|
|
return Center(child: child);
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(find.byType(Center), findsOneWidget);
|
|
expect(find.byType(Padding), findsOneWidget);
|
|
expect(find.byType(RawImage), findsOneWidget);
|
|
expect(tester.widget<Padding>(find.byType(Padding)).child, isA<RawImage>());
|
|
streamCompleter.setData(
|
|
chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100),
|
|
);
|
|
await tester.pump();
|
|
expect(find.byType(Center), findsOneWidget);
|
|
expect(find.byType(Padding), findsOneWidget);
|
|
expect(find.byType(RawImage), findsOneWidget);
|
|
expect(tester.widget<Center>(find.byType(Center)).child, isA<Padding>());
|
|
expect(tester.widget<Padding>(find.byType(Padding)).child, isA<RawImage>());
|
|
});
|
|
|
|
testWidgets('Image state handles loadingBuilder update from null to non-null', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final streamCompleter = _TestImageStreamCompleter();
|
|
final imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
|
|
|
|
await tester.pumpWidget(Image(image: imageProvider));
|
|
|
|
expect(find.byType(RawImage), findsOneWidget);
|
|
streamCompleter.setData(
|
|
chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100),
|
|
);
|
|
expect(tester.binding.hasScheduledFrame, isFalse);
|
|
final State<Image> state = tester.state(find.byType(Image));
|
|
|
|
await tester.pumpWidget(
|
|
Image(
|
|
image: imageProvider,
|
|
loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
|
|
return Center(child: child);
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(find.byType(Center), findsOneWidget);
|
|
expect(find.byType(RawImage), findsOneWidget);
|
|
expect(tester.state(find.byType(Image)), same(state));
|
|
streamCompleter.setData(
|
|
chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100),
|
|
);
|
|
expect(tester.binding.hasScheduledFrame, isTrue);
|
|
await tester.pump();
|
|
expect(find.byType(Center), findsOneWidget);
|
|
expect(find.byType(RawImage), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('Image state handles loadingBuilder update from non-null to null', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final streamCompleter = _TestImageStreamCompleter();
|
|
final imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
|
|
|
|
await tester.pumpWidget(
|
|
Image(
|
|
image: imageProvider,
|
|
loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
|
|
return Center(child: child);
|
|
},
|
|
),
|
|
);
|
|
|
|
expect(find.byType(Center), findsOneWidget);
|
|
expect(find.byType(RawImage), findsOneWidget);
|
|
streamCompleter.setData(
|
|
chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100),
|
|
);
|
|
expect(tester.binding.hasScheduledFrame, isTrue);
|
|
await tester.pump();
|
|
expect(find.byType(Center), findsOneWidget);
|
|
expect(find.byType(RawImage), findsOneWidget);
|
|
final State<Image> state = tester.state(find.byType(Image));
|
|
|
|
await tester.pumpWidget(Image(image: imageProvider));
|
|
|
|
expect(find.byType(Center), findsNothing);
|
|
expect(find.byType(RawImage), findsOneWidget);
|
|
expect(tester.state(find.byType(Image)), same(state));
|
|
streamCompleter.setData(
|
|
chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100),
|
|
);
|
|
expect(tester.binding.hasScheduledFrame, isFalse);
|
|
});
|
|
|
|
testWidgets('Verify Image resets its ImageListeners', (WidgetTester tester) async {
|
|
final GlobalKey key = GlobalKey();
|
|
final imageStreamCompleter = _TestImageStreamCompleter();
|
|
final imageProvider1 = _TestImageProvider(streamCompleter: imageStreamCompleter);
|
|
await tester.pumpWidget(
|
|
Container(
|
|
key: key,
|
|
child: Image(image: imageProvider1),
|
|
),
|
|
);
|
|
// listener from resolveStreamForKey is always added.
|
|
expect(imageStreamCompleter.listeners.length, 2);
|
|
|
|
final imageProvider2 = _TestImageProvider();
|
|
await tester.pumpWidget(
|
|
Container(
|
|
key: key,
|
|
child: Image(image: imageProvider2, excludeFromSemantics: true),
|
|
),
|
|
phase: EnginePhase.layout,
|
|
);
|
|
|
|
// only listener from resolveStreamForKey is left.
|
|
expect(imageStreamCompleter.listeners.length, 1);
|
|
});
|
|
|
|
testWidgets('Verify Image resets its ErrorListeners', (WidgetTester tester) async {
|
|
final GlobalKey key = GlobalKey();
|
|
final imageStreamCompleter = _TestImageStreamCompleter();
|
|
final imageProvider1 = _TestImageProvider(streamCompleter: imageStreamCompleter);
|
|
await tester.pumpWidget(
|
|
Container(
|
|
key: key,
|
|
child: Image(image: imageProvider1, errorBuilder: (_, _, _) => Container()),
|
|
),
|
|
);
|
|
// listener from resolveStreamForKey is always added.
|
|
expect(imageStreamCompleter.listeners.length, 2);
|
|
|
|
final imageProvider2 = _TestImageProvider();
|
|
await tester.pumpWidget(
|
|
Container(
|
|
key: key,
|
|
child: Image(image: imageProvider2, excludeFromSemantics: true),
|
|
),
|
|
phase: EnginePhase.layout,
|
|
);
|
|
|
|
// only listener from resolveStreamForKey is left.
|
|
expect(imageStreamCompleter.listeners.length, 1);
|
|
});
|
|
|
|
testWidgets('Image defers loading while fast scrolling', (WidgetTester tester) async {
|
|
const gridCells = 1000;
|
|
final imageProviders = <_TestImageProvider>[];
|
|
final controller = ScrollController();
|
|
addTearDown(controller.dispose);
|
|
await tester.pumpWidget(
|
|
Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: GridView.builder(
|
|
controller: controller,
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3),
|
|
itemCount: gridCells,
|
|
itemBuilder: (_, int index) {
|
|
final provider = _TestImageProvider();
|
|
imageProviders.add(provider);
|
|
return SizedBox(
|
|
height: 250,
|
|
width: 250,
|
|
child: Image(image: provider, semanticLabel: index.toString()),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
|
|
bool loadCalled(_TestImageProvider provider) => provider.loadCalled;
|
|
bool loadNotCalled(_TestImageProvider provider) => !provider.loadCalled;
|
|
|
|
expect(find.bySemanticsLabel('5'), findsOneWidget);
|
|
expect(imageProviders.length, 12);
|
|
expect(imageProviders.every(loadCalled), true);
|
|
|
|
imageProviders.clear();
|
|
|
|
// Simulate a very fast fling.
|
|
controller.animateTo(30000, duration: const Duration(seconds: 2), curve: Curves.linear);
|
|
await tester.pumpAndSettle();
|
|
// The last 15 images on screen have loaded because the scrolling settled there.
|
|
// The rest have not loaded.
|
|
expect(imageProviders.length, 309);
|
|
expect(imageProviders.skip(309 - 15).every(loadCalled), true);
|
|
expect(imageProviders.take(309 - 15).every(loadNotCalled), true);
|
|
});
|
|
|
|
testWidgets('Same image provider in multiple parts of the tree, no cache room left', (
|
|
WidgetTester tester,
|
|
) async {
|
|
imageCache.maximumSize = 0;
|
|
|
|
final provider1 = _TestImageProvider();
|
|
final provider2 = _TestImageProvider();
|
|
|
|
expect(provider1.loadCallCount, 0);
|
|
expect(provider2.loadCallCount, 0);
|
|
expect(imageCache.liveImageCount, 0);
|
|
|
|
await tester.pumpWidget(
|
|
Column(
|
|
children: <Widget>[
|
|
Image(image: provider1),
|
|
Image(image: provider2),
|
|
Image(image: provider1),
|
|
Image(image: provider1),
|
|
Image(image: provider2),
|
|
],
|
|
),
|
|
);
|
|
|
|
expect(imageCache.liveImageCount, 2);
|
|
expect(imageCache.statusForKey(provider1).live, true);
|
|
expect(imageCache.statusForKey(provider1).pending, false);
|
|
expect(imageCache.statusForKey(provider1).keepAlive, false);
|
|
expect(imageCache.statusForKey(provider2).live, true);
|
|
expect(imageCache.statusForKey(provider2).pending, false);
|
|
expect(imageCache.statusForKey(provider2).keepAlive, false);
|
|
|
|
expect(provider1.loadCallCount, 1);
|
|
expect(provider2.loadCallCount, 1);
|
|
|
|
provider1.complete(image10x10.clone());
|
|
await tester.idle();
|
|
|
|
provider2.complete(image10x10.clone());
|
|
await tester.idle();
|
|
|
|
expect(imageCache.liveImageCount, 2);
|
|
expect(imageCache.currentSize, 0);
|
|
|
|
await tester.pumpWidget(Image(image: provider2));
|
|
await tester.idle();
|
|
expect(imageCache.statusForKey(provider1).untracked, true);
|
|
expect(imageCache.statusForKey(provider2).live, true);
|
|
expect(imageCache.statusForKey(provider2).pending, false);
|
|
expect(imageCache.statusForKey(provider2).keepAlive, false);
|
|
expect(imageCache.liveImageCount, 1);
|
|
|
|
await tester.pumpWidget(const SizedBox());
|
|
await tester.idle();
|
|
expect(provider1.loadCallCount, 1);
|
|
expect(provider2.loadCallCount, 1);
|
|
expect(imageCache.liveImageCount, 0);
|
|
});
|
|
|
|
testWidgets('precacheImage does not hold weak ref for more than a frame', (
|
|
WidgetTester tester,
|
|
) async {
|
|
imageCache.maximumSize = 0;
|
|
final provider = _TestImageProvider();
|
|
late Future<void> precache;
|
|
await tester.pumpWidget(
|
|
Builder(
|
|
builder: (BuildContext context) {
|
|
precache = precacheImage(provider, context);
|
|
return Container();
|
|
},
|
|
),
|
|
);
|
|
provider.complete(image10x10);
|
|
await precache;
|
|
|
|
// Should have ended up with only a weak ref, not in cache because cache size is 0
|
|
expect(imageCache.liveImageCount, 1);
|
|
expect(imageCache.containsKey(provider), false);
|
|
|
|
final ImageCacheStatus providerLocation = (await provider.obtainCacheStatus(
|
|
configuration: ImageConfiguration.empty,
|
|
))!;
|
|
|
|
expect(providerLocation, isNotNull);
|
|
expect(providerLocation.live, true);
|
|
expect(providerLocation.keepAlive, false);
|
|
expect(providerLocation.pending, false);
|
|
|
|
// Check that a second resolve of the same image is synchronous.
|
|
expect(provider._lastResolvedConfiguration, isNotNull);
|
|
final ImageStream stream = provider.resolve(provider._lastResolvedConfiguration);
|
|
late bool isSync;
|
|
final listener = ImageStreamListener((ImageInfo image, bool syncCall) {
|
|
image.dispose();
|
|
isSync = syncCall;
|
|
});
|
|
|
|
// Still have live ref because frame has not pumped yet.
|
|
await tester.pump();
|
|
expect(imageCache.liveImageCount, 1);
|
|
|
|
SchedulerBinding.instance.scheduleFrame();
|
|
await tester.pump();
|
|
// Live ref should be gone - we didn't listen to the stream.
|
|
expect(imageCache.liveImageCount, 0);
|
|
expect(imageCache.currentSize, 0);
|
|
|
|
stream.addListener(listener);
|
|
expect(isSync, true); // because the stream still has the image.
|
|
|
|
expect(imageCache.liveImageCount, 0);
|
|
expect(imageCache.currentSize, 0);
|
|
|
|
expect(provider.loadCallCount, 1);
|
|
});
|
|
|
|
testWidgets(
|
|
'precacheImage allows time to take over weak reference',
|
|
experimentalLeakTesting: LeakTesting.settings
|
|
.withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter].
|
|
(WidgetTester tester) async {
|
|
final provider = _TestImageProvider();
|
|
late Future<void> precache;
|
|
await tester.pumpWidget(
|
|
Builder(
|
|
builder: (BuildContext context) {
|
|
precache = precacheImage(provider, context);
|
|
return Container();
|
|
},
|
|
),
|
|
);
|
|
provider.complete(image10x10);
|
|
await precache;
|
|
|
|
// Should have ended up in the cache and have a weak reference.
|
|
expect(imageCache.liveImageCount, 1);
|
|
expect(imageCache.currentSize, 1);
|
|
expect(imageCache.containsKey(provider), true);
|
|
|
|
// Check that a second resolve of the same image is synchronous.
|
|
expect(provider._lastResolvedConfiguration, isNotNull);
|
|
final ImageStream stream = provider.resolve(provider._lastResolvedConfiguration);
|
|
late bool isSync;
|
|
final listener = ImageStreamListener((ImageInfo image, bool syncCall) {
|
|
isSync = syncCall;
|
|
});
|
|
|
|
// Should have ended up in the cache and still have a weak reference.
|
|
expect(imageCache.liveImageCount, 1);
|
|
expect(imageCache.currentSize, 1);
|
|
expect(imageCache.containsKey(provider), true);
|
|
|
|
stream.addListener(listener);
|
|
expect(isSync, true);
|
|
|
|
expect(imageCache.liveImageCount, 1);
|
|
expect(imageCache.currentSize, 1);
|
|
expect(imageCache.containsKey(provider), true);
|
|
|
|
SchedulerBinding.instance.scheduleFrame();
|
|
await tester.pump();
|
|
|
|
expect(imageCache.liveImageCount, 1);
|
|
expect(imageCache.currentSize, 1);
|
|
expect(imageCache.containsKey(provider), true);
|
|
stream.removeListener(listener);
|
|
|
|
expect(imageCache.liveImageCount, 0);
|
|
expect(imageCache.currentSize, 1);
|
|
expect(imageCache.containsKey(provider), true);
|
|
expect(provider.loadCallCount, 1);
|
|
},
|
|
);
|
|
|
|
testWidgets('evict an image during precache', (WidgetTester tester) async {
|
|
// This test checks that the live image tracking does not hold on to a
|
|
// pending image that will never complete because it has been evicted from
|
|
// the cache.
|
|
// The scenario may arise in a test harness that is trying to load real
|
|
// images using `tester.runAsync()`, and wants to make sure that widgets
|
|
// under test have not also tried to resolve the image in a FakeAsync zone.
|
|
// The image loaded in the FakeAsync zone will never complete, and the
|
|
// runAsync call wants to make sure it gets a load attempt from the correct
|
|
// zone.
|
|
final bytes = Uint8List.fromList(kTransparentImage);
|
|
final provider = MemoryImage(bytes);
|
|
|
|
await tester.runAsync(() async {
|
|
final futures = <Future<void>>[];
|
|
await tester.pumpWidget(
|
|
Builder(
|
|
builder: (BuildContext context) {
|
|
futures.add(precacheImage(provider, context));
|
|
imageCache.evict(provider);
|
|
futures.add(precacheImage(provider, context));
|
|
return const SizedBox.expand();
|
|
},
|
|
),
|
|
);
|
|
await Future.wait<void>(futures);
|
|
expect(imageCache.statusForKey(provider).keepAlive, true);
|
|
expect(imageCache.statusForKey(provider).live, true);
|
|
|
|
// Schedule a frame to get precacheImage to stop listening.
|
|
SchedulerBinding.instance.scheduleFrame();
|
|
await tester.pump();
|
|
expect(imageCache.statusForKey(provider).keepAlive, true);
|
|
expect(imageCache.statusForKey(provider).live, false);
|
|
|
|
imageCache.clear();
|
|
});
|
|
});
|
|
|
|
testWidgets('errorBuilder - fails on key', (WidgetTester tester) async {
|
|
final errorKey = UniqueKey();
|
|
late Object caughtException;
|
|
await tester.pumpWidget(
|
|
Image(
|
|
image: _FailingImageProvider(failOnObtainKey: true, throws: 'threw', image: image10x10),
|
|
errorBuilder: (BuildContext context, Object error, StackTrace? stackTrace) {
|
|
caughtException = error;
|
|
return SizedBox.expand(key: errorKey);
|
|
},
|
|
),
|
|
);
|
|
|
|
await tester.pump();
|
|
|
|
expect(find.byKey(errorKey), findsOneWidget);
|
|
expect(caughtException.toString(), 'threw');
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('errorBuilder - fails on load', (WidgetTester tester) async {
|
|
final errorKey = UniqueKey();
|
|
late Object caughtException;
|
|
await tester.pumpWidget(
|
|
Image(
|
|
image: _FailingImageProvider(failOnLoad: true, throws: 'threw', image: image10x10),
|
|
errorBuilder: (BuildContext context, Object error, StackTrace? stackTrace) {
|
|
caughtException = error;
|
|
return SizedBox.expand(key: errorKey);
|
|
},
|
|
),
|
|
);
|
|
|
|
await tester.pump();
|
|
|
|
expect(find.byKey(errorKey), findsOneWidget);
|
|
expect(caughtException.toString(), 'threw');
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('no errorBuilder - failure reported to FlutterError', (WidgetTester tester) async {
|
|
await tester.pumpWidget(
|
|
Image(
|
|
image: _FailingImageProvider(failOnLoad: true, throws: 'threw', image: image10x10),
|
|
),
|
|
);
|
|
|
|
await tester.pump();
|
|
|
|
expect(tester.takeException(), 'threw');
|
|
});
|
|
|
|
Future<void> testRotatedImage(WidgetTester tester, bool isAntiAlias) async {
|
|
final Key key = UniqueKey();
|
|
await tester.pumpWidget(
|
|
RepaintBoundary(
|
|
key: key,
|
|
child: Transform.rotate(
|
|
angle: math.pi / 180,
|
|
child: Image.memory(Uint8List.fromList(kBlueRectPng), isAntiAlias: isAntiAlias),
|
|
),
|
|
),
|
|
);
|
|
|
|
// precacheImage is needed, or the image in the golden file will be empty.
|
|
if (!kIsWeb) {
|
|
final Finder allImages = find.byType(Image);
|
|
for (final Element e in allImages.evaluate()) {
|
|
await tester.runAsync(() async {
|
|
final image = e.widget as Image;
|
|
await precacheImage(image.image, e);
|
|
});
|
|
}
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
await expectLater(
|
|
find.byKey(key),
|
|
matchesGoldenFile('rotated_image_${isAntiAlias ? 'aa' : 'noaa'}.png'),
|
|
);
|
|
}
|
|
|
|
testWidgets(
|
|
'Rotated images',
|
|
(WidgetTester tester) async {
|
|
await testRotatedImage(tester, true);
|
|
await testRotatedImage(tester, false);
|
|
},
|
|
skip: kIsWeb, // https://github.com/flutter/flutter/issues/87933.
|
|
);
|
|
|
|
testWidgets(
|
|
'Image opacity',
|
|
(WidgetTester tester) async {
|
|
final Key key = UniqueKey();
|
|
await tester.pumpWidget(
|
|
RepaintBoundary(
|
|
key: key,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
textDirection: TextDirection.ltr,
|
|
children: <Widget>[
|
|
Image.memory(
|
|
Uint8List.fromList(kBlueRectPng),
|
|
opacity: const AlwaysStoppedAnimation<double>(0.25),
|
|
),
|
|
Image.memory(
|
|
Uint8List.fromList(kBlueRectPng),
|
|
opacity: const AlwaysStoppedAnimation<double>(0.5),
|
|
),
|
|
Image.memory(
|
|
Uint8List.fromList(kBlueRectPng),
|
|
opacity: const AlwaysStoppedAnimation<double>(0.75),
|
|
),
|
|
Image.memory(
|
|
Uint8List.fromList(kBlueRectPng),
|
|
opacity: const AlwaysStoppedAnimation<double>(1.0),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
// precacheImage is needed, or the image in the golden file will be empty.
|
|
if (!kIsWeb) {
|
|
final Finder allImages = find.byType(Image);
|
|
for (final Element e in allImages.evaluate()) {
|
|
await tester.runAsync(() async {
|
|
final image = e.widget as Image;
|
|
await precacheImage(image.image, e);
|
|
});
|
|
}
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
await expectLater(find.byKey(key), matchesGoldenFile('transparent_image.png'));
|
|
},
|
|
skip: kIsWeb, // https://github.com/flutter/flutter/issues/87933.
|
|
);
|
|
|
|
testWidgets(
|
|
'Reports image size when painted',
|
|
experimentalLeakTesting: LeakTesting.settings
|
|
.withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter].
|
|
(WidgetTester tester) async {
|
|
late ImageSizeInfo imageSizeInfo;
|
|
var count = 0;
|
|
debugOnPaintImage = (ImageSizeInfo info) {
|
|
count += 1;
|
|
imageSizeInfo = info;
|
|
};
|
|
|
|
final ui.Image image = (await tester.runAsync(
|
|
() => createTestImage(width: 100, height: 100),
|
|
))!;
|
|
final streamCompleter = _TestImageStreamCompleter(
|
|
ImageInfo(image: image, debugLabel: 'test.png'),
|
|
);
|
|
final imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
|
|
|
|
await tester.pumpWidget(
|
|
Center(
|
|
child: SizedBox(height: 50, width: 50, child: Image(image: imageProvider)),
|
|
),
|
|
);
|
|
|
|
expect(count, 1);
|
|
expect(
|
|
imageSizeInfo,
|
|
const ImageSizeInfo(
|
|
source: 'test.png',
|
|
imageSize: Size(100, 100),
|
|
displaySize: Size(150, 150),
|
|
),
|
|
);
|
|
|
|
debugOnPaintImage = null;
|
|
},
|
|
);
|
|
|
|
testWidgets('Disposes image handle when disposed', (WidgetTester tester) async {
|
|
final ui.Image image = (await tester.runAsync(() => createTestImage(cache: false)))!;
|
|
|
|
expect(image.debugGetOpenHandleStackTraces()!.length, 1);
|
|
|
|
final ImageProvider provider = _TestImageProvider(
|
|
streamCompleter: OneFrameImageStreamCompleter(
|
|
Future<ImageInfo>.value(ImageInfo(image: image, debugLabel: '_TestImage')),
|
|
),
|
|
);
|
|
|
|
// creating the provider should not have changed anything, and the provider
|
|
// now owns the handle.
|
|
expect(image.debugGetOpenHandleStackTraces()!.length, 1);
|
|
|
|
await tester.pumpWidget(Image(image: provider));
|
|
|
|
// Image widget + 1, render object + 1
|
|
expect(image.debugGetOpenHandleStackTraces()!.length, 3);
|
|
|
|
await tester.pumpWidget(const SizedBox());
|
|
|
|
// Image widget and render object go away
|
|
expect(image.debugGetOpenHandleStackTraces()!.length, 1);
|
|
|
|
await provider.evict();
|
|
|
|
tester.binding.scheduleFrame();
|
|
await tester.pump();
|
|
|
|
// Image cache listener go away and Image stream listeners go away.
|
|
// Image is now at zero.
|
|
expect(image.debugGetOpenHandleStackTraces()!.length, 0);
|
|
}, skip: kIsWeb); // https://github.com/flutter/flutter/issues/87442
|
|
|
|
testWidgets('Keeps stream alive when ticker mode is disabled', (WidgetTester tester) async {
|
|
imageCache.maximumSize = 0;
|
|
final ui.Image image = (await tester.runAsync(() => createTestImage(cache: false)))!;
|
|
final provider = _TestImageProvider();
|
|
provider.complete(image);
|
|
|
|
await tester.pumpWidget(TickerMode(enabled: true, child: Image(image: provider)));
|
|
expect(find.byType(Image), findsOneWidget);
|
|
|
|
await tester.pumpWidget(TickerMode(enabled: false, child: Image(image: provider)));
|
|
expect(find.byType(Image), findsOneWidget);
|
|
|
|
await tester.pumpWidget(TickerMode(enabled: true, child: Image(image: provider)));
|
|
expect(find.byType(Image), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('Keeps stream alive when animations are disabled', (WidgetTester tester) async {
|
|
imageCache.maximumSize = 0;
|
|
final ui.Image image = (await tester.runAsync(() => createTestImage(cache: false)))!;
|
|
final provider = _TestImageProvider();
|
|
provider.complete(image);
|
|
|
|
var disableAnimations = false;
|
|
late StateSetter setState;
|
|
await tester.pumpWidget(
|
|
StatefulBuilder(
|
|
builder: (BuildContext context, StateSetter localSetState) {
|
|
setState = localSetState;
|
|
return MediaQuery(
|
|
data: MediaQueryData(disableAnimations: disableAnimations),
|
|
child: Image(image: provider),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
expect(find.byType(Image), findsOneWidget);
|
|
|
|
setState(() {
|
|
disableAnimations = true;
|
|
});
|
|
await tester.pump();
|
|
expect(find.byType(Image), findsOneWidget);
|
|
|
|
setState(() {
|
|
disableAnimations = false;
|
|
});
|
|
await tester.pump();
|
|
expect(find.byType(Image), findsOneWidget);
|
|
});
|
|
|
|
testWidgets(
|
|
'Load a good image after a bad image was loaded should not call errorBuilder',
|
|
experimentalLeakTesting: LeakTesting.settings
|
|
.withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter].
|
|
(WidgetTester tester) async {
|
|
final errorKey = UniqueKey();
|
|
final ui.Image image = (await tester.runAsync(() => createTestImage()))!;
|
|
final streamCompleter = _TestImageStreamCompleter();
|
|
final imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
|
|
|
|
await tester.pumpWidget(
|
|
Center(
|
|
child: SizedBox(
|
|
height: 50,
|
|
width: 50,
|
|
child: Image(
|
|
image: imageProvider,
|
|
excludeFromSemantics: true,
|
|
errorBuilder: (BuildContext context, Object error, StackTrace? stackTrace) {
|
|
return Container(key: errorKey);
|
|
},
|
|
frameBuilder:
|
|
(BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) {
|
|
return Padding(padding: const EdgeInsets.all(1), child: child);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
// No error widget before loading a invalid image.
|
|
expect(find.byKey(errorKey), findsNothing);
|
|
|
|
// Loading good image succeed
|
|
streamCompleter.setData(
|
|
chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100),
|
|
);
|
|
await tester.pump();
|
|
expect(find.byType(Padding), findsOneWidget);
|
|
|
|
// Loading bad image shows the error widget.
|
|
streamCompleter.setError(exception: 'thrown');
|
|
await tester.pump();
|
|
expect(find.byKey(errorKey), findsOneWidget);
|
|
|
|
// Loading good image shows the image widget instead of the error widget.
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: image));
|
|
await tester.pump();
|
|
expect(find.byType(Padding), findsOneWidget);
|
|
expect(tester.widget<Padding>(find.byType(Padding)).child, isA<RawImage>());
|
|
expect(find.byKey(errorKey), findsNothing);
|
|
},
|
|
);
|
|
|
|
testWidgets(
|
|
'Failed image loads in debug mode',
|
|
(WidgetTester tester) async {
|
|
final Key key = UniqueKey();
|
|
await tester.pumpWidget(
|
|
Center(
|
|
child: RepaintBoundary(
|
|
key: key,
|
|
child: Container(
|
|
width: 150.0,
|
|
height: 50.0,
|
|
decoration: BoxDecoration(
|
|
border: Border.all(width: 2.0, color: const Color(0xFF00FF99)),
|
|
),
|
|
child: Image.asset('missing-asset'),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await expectLater(find.byKey(key), matchesGoldenFile('image_test.missing.1.png'));
|
|
expect(
|
|
tester.takeException().toString(),
|
|
equals(
|
|
'Unable to load asset: "missing-asset".\n'
|
|
'The asset does not exist or has empty data.',
|
|
),
|
|
);
|
|
await tester.pump();
|
|
await expectLater(find.byKey(key), matchesGoldenFile('image_test.missing.2.png'));
|
|
},
|
|
// https://github.com/flutter/flutter/issues/74935 (broken assets not being reported on web)
|
|
skip: kIsWeb,
|
|
);
|
|
|
|
testWidgets('Image.file throws a non-implemented error on web', (WidgetTester tester) async {
|
|
const expectedError =
|
|
'Image.file is not supported on Flutter Web. '
|
|
'Consider using either Image.asset or Image.network instead.';
|
|
final Uri uri = Uri.parse('/home/flutter/dash.png');
|
|
final file = File.fromUri(uri);
|
|
expect(
|
|
() => Image.file(file),
|
|
kIsWeb
|
|
// Web does not support file access, expect AssertionError
|
|
? throwsA(predicate((AssertionError e) => e.message == expectedError))
|
|
// AOT supports file access, expect constructor to succeed
|
|
: isNot(throwsA(anything)),
|
|
);
|
|
});
|
|
|
|
testWidgets(
|
|
'Animated GIFs do not require layout for subsequent frames',
|
|
experimentalLeakTesting: LeakTesting.settings
|
|
.withIgnoredAll(), // The test leaks by design, see [_TestImageStreamCompleter].
|
|
(WidgetTester tester) async {
|
|
final ui.Codec codec = (await tester.runAsync(() {
|
|
return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif));
|
|
}))!;
|
|
|
|
Future<ui.Image> nextFrame() async {
|
|
final ui.FrameInfo frameInfo = (await tester.runAsync(codec.getNextFrame))!;
|
|
return frameInfo.image;
|
|
}
|
|
|
|
final streamCompleter = _TestImageStreamCompleter();
|
|
final imageProvider = _TestImageProvider(streamCompleter: streamCompleter);
|
|
int? lastFrame;
|
|
|
|
await tester.pumpWidget(
|
|
Center(
|
|
child: Image(
|
|
image: imageProvider,
|
|
frameBuilder:
|
|
(BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) {
|
|
lastFrame = frame;
|
|
return child;
|
|
},
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(tester.getSize(find.byType(Image)), Size.zero);
|
|
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame, 0);
|
|
expect(tester.allRenderObjects.whereType<RenderImage>().single.debugNeedsLayout, isFalse);
|
|
expect(tester.allRenderObjects.whereType<RenderImage>().single.debugNeedsPaint, isFalse);
|
|
expect(tester.getSize(find.byType(Image)), const Size(1, 1));
|
|
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
// We only complete the build phase and expect that it does not mark the
|
|
// RenderImage for layout because the new frame has the same dimensions as
|
|
// the old one. We only need to repaint.
|
|
await tester.pump(null, EnginePhase.build);
|
|
expect(lastFrame, 1);
|
|
expect(tester.allRenderObjects.whereType<RenderImage>().single.debugNeedsLayout, isFalse);
|
|
expect(tester.allRenderObjects.whereType<RenderImage>().single.debugNeedsPaint, isTrue);
|
|
expect(tester.getSize(find.byType(Image)), const Size(1, 1));
|
|
|
|
streamCompleter.setData(imageInfo: ImageInfo(image: await nextFrame()));
|
|
await tester.pump();
|
|
expect(lastFrame, 2);
|
|
expect(tester.allRenderObjects.whereType<RenderImage>().single.debugNeedsLayout, isFalse);
|
|
expect(tester.allRenderObjects.whereType<RenderImage>().single.debugNeedsPaint, isFalse);
|
|
expect(tester.getSize(find.byType(Image)), const Size(1, 1));
|
|
|
|
codec.dispose();
|
|
},
|
|
);
|
|
|
|
testWidgets('errorBuilder prevents FlutterError report even if widget is disposed', (
|
|
WidgetTester tester,
|
|
) async {
|
|
// This test verifies that if an errorBuilder is provided, FlutterError.reportError
|
|
// is NOT called, even if the Image widget is removed from the tree before the
|
|
// image load fails. Regression test for https://github.com/flutter/flutter/issues/97077.
|
|
|
|
// 1. Setup: Capture FlutterError reports
|
|
final reportedErrors = <FlutterErrorDetails>[];
|
|
final FlutterExceptionHandler? oldHandler = FlutterError.onError;
|
|
FlutterError.onError = reportedErrors.add;
|
|
addTearDown(() {
|
|
FlutterError.onError = oldHandler;
|
|
}); // Ensure handler is restored
|
|
|
|
final provider = _TestImageProvider();
|
|
final testException = Exception('Network failed');
|
|
final StackTrace testStack = StackTrace.current;
|
|
|
|
Widget buildImage() {
|
|
return Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Image(
|
|
image: provider,
|
|
errorBuilder: (_, _, _) => const SizedBox(width: 10, height: 10),
|
|
),
|
|
);
|
|
}
|
|
|
|
// 2. Pump the widget with the Image.
|
|
await tester.pumpWidget(buildImage());
|
|
expect(find.byType(Image), findsOneWidget);
|
|
expect(reportedErrors, isEmpty); // No errors yet
|
|
|
|
// 3. Remove the Image widget from the tree.
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
expect(find.byType(Image), findsNothing);
|
|
|
|
// 4. Now, make the image provider fail *after* the widget state is disposed.
|
|
provider.fail(testException, testStack);
|
|
|
|
// 5. Allow asynchronous error propagation to complete robustly.
|
|
await tester.pumpAndSettle();
|
|
// Restore the handler now in case `expect`s in step 6 fail.
|
|
FlutterError.onError = oldHandler;
|
|
|
|
// 6. CRITICAL ASSERTION: Verify that no FlutterError was reported via the onError handler
|
|
expect(
|
|
reportedErrors,
|
|
isEmpty,
|
|
reason: 'FlutterError.onError should not be called when an errorBuilder was provided.',
|
|
);
|
|
// Also check takeException as a standard backup.
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets(
|
|
'errorBuilder prevents FlutterError report only if errorBuilder is non-null when widget is disposed',
|
|
(WidgetTester tester) async {
|
|
// This test verifies that if an errorBuilder is provided, FlutterError.reportError
|
|
// is called, only if the errorBuilder stays present when the widget is unmounted.
|
|
|
|
// 1. Setup: Capture FlutterError reports
|
|
final reportedErrors = <FlutterErrorDetails>[];
|
|
final FlutterExceptionHandler? oldHandler = FlutterError.onError;
|
|
FlutterError.onError = reportedErrors.add;
|
|
addTearDown(() {
|
|
FlutterError.onError = oldHandler;
|
|
}); // Ensure handler is restored
|
|
|
|
final provider = _TestImageProvider();
|
|
final testException = Exception('Network failed');
|
|
final StackTrace testStack = StackTrace.current;
|
|
|
|
// Function to build the widget with the Image
|
|
Widget buildImage({required bool hasErrorBuilder}) {
|
|
return Directionality(
|
|
textDirection: TextDirection.ltr,
|
|
child: Image(
|
|
image: provider,
|
|
errorBuilder: hasErrorBuilder
|
|
? (_, _, _) => const SizedBox(width: 10, height: 10)
|
|
: null,
|
|
),
|
|
);
|
|
}
|
|
|
|
// 2. Pump the widget with an errorBuilder
|
|
await tester.pumpWidget(buildImage(hasErrorBuilder: true));
|
|
expect(find.byType(Image), findsOneWidget);
|
|
expect(reportedErrors, isEmpty); // No errors yet
|
|
|
|
// 3. Update the widget with no errorBuilder
|
|
await tester.pumpWidget(buildImage(hasErrorBuilder: false));
|
|
expect(find.byType(Image), findsOneWidget);
|
|
expect(reportedErrors, isEmpty); // No errors yet
|
|
|
|
// 4. Remove the Image widget from the tree.
|
|
await tester.pumpWidget(const SizedBox.shrink());
|
|
expect(find.byType(Image), findsNothing);
|
|
|
|
// 5. Now, make the image provider fail *after* the widget state is disposed.
|
|
provider.fail(testException, testStack);
|
|
|
|
// 5. Allow asynchronous error propagation to complete robustly.
|
|
await tester.pumpAndSettle();
|
|
// Restore the handler now in case `expect`s in step 6 fail.
|
|
FlutterError.onError = oldHandler;
|
|
|
|
// 6. Verify that a FlutterError was reported via the onError handler
|
|
expect(
|
|
reportedErrors,
|
|
isNotEmpty,
|
|
reason:
|
|
'FlutterError.onError should be called when an errorBuilder was not provided eventually.',
|
|
);
|
|
// Also check takeException as a standard backup.
|
|
expect(tester.takeException(), isNull);
|
|
},
|
|
);
|
|
}
|
|
|
|
@immutable
|
|
class _ConfigurationAwareKey {
|
|
const _ConfigurationAwareKey(this.provider, this.configuration);
|
|
|
|
final ImageProvider provider;
|
|
final ImageConfiguration configuration;
|
|
|
|
@override
|
|
bool operator ==(Object other) {
|
|
if (other.runtimeType != runtimeType) {
|
|
return false;
|
|
}
|
|
return other is _ConfigurationAwareKey &&
|
|
other.provider == provider &&
|
|
other.configuration == configuration;
|
|
}
|
|
|
|
@override
|
|
int get hashCode => Object.hash(provider, configuration);
|
|
}
|
|
|
|
class _ConfigurationKeyedTestImageProvider extends _TestImageProvider {
|
|
@override
|
|
Future<_ConfigurationAwareKey> obtainKey(ImageConfiguration configuration) {
|
|
return SynchronousFuture<_ConfigurationAwareKey>(_ConfigurationAwareKey(this, configuration));
|
|
}
|
|
}
|
|
|
|
class _TestImageProvider extends ImageProvider<Object> {
|
|
_TestImageProvider({ImageStreamCompleter? streamCompleter}) {
|
|
_streamCompleter = streamCompleter ?? OneFrameImageStreamCompleter(_completer.future);
|
|
}
|
|
|
|
final Completer<ImageInfo> _completer = Completer<ImageInfo>();
|
|
late ImageStreamCompleter _streamCompleter;
|
|
late ImageConfiguration _lastResolvedConfiguration;
|
|
|
|
bool get loadCalled => _loadCallCount > 0;
|
|
int get loadCallCount => _loadCallCount;
|
|
int _loadCallCount = 0;
|
|
|
|
@override
|
|
Future<Object> obtainKey(ImageConfiguration configuration) {
|
|
return SynchronousFuture<_TestImageProvider>(this);
|
|
}
|
|
|
|
@override
|
|
void resolveStreamForKey(
|
|
ImageConfiguration configuration,
|
|
ImageStream stream,
|
|
Object key,
|
|
ImageErrorListener handleError,
|
|
) {
|
|
_lastResolvedConfiguration = configuration;
|
|
super.resolveStreamForKey(configuration, stream, key, handleError);
|
|
}
|
|
|
|
@override
|
|
ImageStreamCompleter loadImage(Object key, ImageDecoderCallback decode) {
|
|
_loadCallCount += 1;
|
|
return _streamCompleter;
|
|
}
|
|
|
|
void complete(ui.Image image) {
|
|
_completer.complete(ImageInfo(image: image));
|
|
}
|
|
|
|
void fail(Object exception, StackTrace? stackTrace) {
|
|
_completer.completeError(exception, stackTrace);
|
|
}
|
|
|
|
@override
|
|
String toString() => '${describeIdentity(this)}()';
|
|
}
|
|
|
|
/// An [ImageStreamCompleter] that gives access to the added listeners.
|
|
///
|
|
/// Such an access to listeners is hacky,
|
|
/// because it breaks encapsulation by allowing to invoke listeners without
|
|
/// taking care about lifecycle of the created images, that may result in not disposed images.
|
|
///
|
|
/// That's why some tests that use it
|
|
/// are opted out from leak tracking.
|
|
class _TestImageStreamCompleter extends ImageStreamCompleter {
|
|
_TestImageStreamCompleter([this._currentImage]);
|
|
|
|
ImageInfo? _currentImage;
|
|
final Set<ImageStreamListener> listeners = <ImageStreamListener>{};
|
|
|
|
@override
|
|
void addListener(ImageStreamListener listener) {
|
|
listeners.add(listener);
|
|
if (_currentImage != null) {
|
|
listener.onImage(_currentImage!.clone(), true);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void removeListener(ImageStreamListener listener) {
|
|
listeners.remove(listener);
|
|
}
|
|
|
|
void setData({ImageInfo? imageInfo, ImageChunkEvent? chunkEvent}) {
|
|
if (imageInfo != null) {
|
|
_currentImage?.dispose();
|
|
_currentImage = imageInfo;
|
|
}
|
|
final List<ImageStreamListener> localListeners = listeners.toList();
|
|
for (final listener in localListeners) {
|
|
if (imageInfo != null) {
|
|
listener.onImage(imageInfo.clone(), false);
|
|
}
|
|
if (chunkEvent != null && listener.onChunk != null) {
|
|
listener.onChunk!(chunkEvent);
|
|
}
|
|
}
|
|
}
|
|
|
|
void setError({required Object exception, StackTrace? stackTrace}) {
|
|
final List<ImageStreamListener> localListeners = listeners.toList();
|
|
for (final listener in localListeners) {
|
|
listener.onError?.call(exception, stackTrace);
|
|
}
|
|
}
|
|
|
|
void dispose() {
|
|
final List<ImageStreamListener> listenersCopy = listeners.toList();
|
|
listenersCopy.forEach(removeListener);
|
|
}
|
|
}
|
|
|
|
class _DebouncingImageProvider extends ImageProvider<Object> {
|
|
_DebouncingImageProvider(this.imageProvider, this.seenKeys);
|
|
|
|
/// A set of keys that will only get resolved the _first_ time they are seen.
|
|
///
|
|
/// If an ImageProvider produces the same key for two different image
|
|
/// configurations, it should only actually resolve once using this provider.
|
|
/// However, if it does care about image configuration, it should make the
|
|
/// property or properties it cares about part of the key material it
|
|
/// produces.
|
|
final Set<Object> seenKeys;
|
|
final ImageProvider<Object> imageProvider;
|
|
|
|
@override
|
|
void resolveStreamForKey(
|
|
ImageConfiguration configuration,
|
|
ImageStream stream,
|
|
Object key,
|
|
ImageErrorListener handleError,
|
|
) {
|
|
if (seenKeys.add(key)) {
|
|
imageProvider.resolveStreamForKey(configuration, stream, key, handleError);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<Object> obtainKey(ImageConfiguration configuration) =>
|
|
imageProvider.obtainKey(configuration);
|
|
|
|
@override
|
|
ImageStreamCompleter loadImage(Object key, ImageDecoderCallback decode) =>
|
|
imageProvider.loadImage(key, decode);
|
|
}
|
|
|
|
class _FailingImageProvider extends ImageProvider<int> {
|
|
const _FailingImageProvider({
|
|
this.failOnObtainKey = false,
|
|
this.failOnLoad = false,
|
|
required this.throws,
|
|
required this.image,
|
|
}) : assert(failOnLoad || failOnObtainKey);
|
|
|
|
final bool failOnObtainKey;
|
|
final bool failOnLoad;
|
|
final Object throws;
|
|
final ui.Image image;
|
|
|
|
@override
|
|
Future<int> obtainKey(ImageConfiguration configuration) {
|
|
if (failOnObtainKey) {
|
|
throw throws;
|
|
}
|
|
return SynchronousFuture<int>(hashCode);
|
|
}
|
|
|
|
@override
|
|
ImageStreamCompleter loadImage(int key, ImageDecoderCallback decode) {
|
|
if (failOnLoad) {
|
|
throw throws;
|
|
}
|
|
return OneFrameImageStreamCompleter(Future<ImageInfo>.value(ImageInfo(image: image, scale: 0)));
|
|
}
|
|
}
|
|
|
|
/// The different ways of disabling animations.
|
|
enum _DisableMethod {
|
|
/// Use [TickerMode.enabled].
|
|
tickerMode,
|
|
|
|
/// Use [MedaiQueryData.disableAnimations].
|
|
mediaQuery,
|
|
}
|