diff --git a/packages/flutter/lib/src/widgets/asset_vendor.dart b/packages/flutter/lib/src/widgets/asset_vendor.dart index 9422070601..7c1123b510 100644 --- a/packages/flutter/lib/src/widgets/asset_vendor.dart +++ b/packages/flutter/lib/src/widgets/asset_vendor.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:collection'; import 'dart:convert'; +import 'dart:ui' as ui show Image; import 'package:flutter/services.dart'; import 'package:mojo/core.dart' as core; @@ -39,25 +40,33 @@ class _ResolvingAssetBundle extends CachingAssetBundle { } } +/// Abstraction for reading images out of a Mojo data pipe. +/// +/// Useful for mocking purposes in unit tests. +typedef Future ImageDecoder(core.MojoDataPipeConsumer pipe); + // Asset bundle that understands how specific asset keys represent image scale. class _ResolutionAwareAssetBundle extends _ResolvingAssetBundle { _ResolutionAwareAssetBundle({ AssetBundle bundle, - _ResolutionAwareAssetResolver resolver + _ResolutionAwareAssetResolver resolver, + ImageDecoder imageDecoder }) : super( bundle: bundle, resolver: resolver - ); + ), _imageDecoder = imageDecoder; _ResolutionAwareAssetResolver get resolver => super.resolver; + final ImageDecoder _imageDecoder; + Future fetchImage(String key) async { core.MojoDataPipeConsumer pipe = await load(key); // At this point the key should be in our key cache, and the image // resource should be in our image cache double scale = resolver.getScale(keyCache[key]); return new ImageInfo( - image: await decodeImageFromDataPipe(pipe), + image: await _imageDecoder(pipe), scale: scale ); } @@ -183,12 +192,14 @@ class AssetVendor extends StatefulComponent { Key key, this.bundle, this.devicePixelRatio, - this.child + this.child, + this.imageDecoder: decodeImageFromDataPipe }) : super(key: key); final AssetBundle bundle; final double devicePixelRatio; final Widget child; + final ImageDecoder imageDecoder; _AssetVendorState createState() => new _AssetVendorState(); @@ -207,6 +218,7 @@ class _AssetVendorState extends State { void _initBundle() { _bundle = new _ResolutionAwareAssetBundle( bundle: config.bundle, + imageDecoder: config.imageDecoder, resolver: new _ResolutionAwareAssetResolver( bundle: config.bundle, devicePixelRatio: config.devicePixelRatio diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart index 43a0abb66f..58247e763f 100644 --- a/packages/flutter/lib/src/widgets/binding.dart +++ b/packages/flutter/lib/src/widgets/binding.dart @@ -26,14 +26,12 @@ class BindingObserver { /// This is the glue that binds the framework to the Flutter engine. class WidgetFlutterBinding extends BindingBase with Scheduler, Gesturer, MojoShell, Renderer { - WidgetFlutterBinding._(); - /// Creates and initializes the WidgetFlutterBinding. This constructor is /// idempotent; calling it a second time will just return the /// previously-created instance. static WidgetFlutterBinding ensureInitialized() { if (_instance == null) - new WidgetFlutterBinding._(); + new WidgetFlutterBinding(); return _instance; } diff --git a/packages/flutter/test/widget/asset_vendor_test.dart b/packages/flutter/test/widget/asset_vendor_test.dart new file mode 100644 index 0000000000..7bacc19cdc --- /dev/null +++ b/packages/flutter/test/widget/asset_vendor_test.dart @@ -0,0 +1,225 @@ +// Copyright 2016 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:ui' as ui show Image, hashValues; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:mojo/core.dart' as core; +import 'package:test/test.dart'; + +class TestImage extends ui.Image { + TestImage(this.scale); + final double scale; + int get width => (48*scale).floor(); + int get height => (48*scale).floor(); + void dispose() { } +} + +class TestMojoDataPipeConsumer extends core.MojoDataPipeConsumer { + TestMojoDataPipeConsumer(this.scale) : super(null); + final double scale; +} + +String testManifest = ''' +{ + "assets/image.png" : [ + "assets/1.5x/image.png", + "assets/2.0x/image.png", + "assets/3.0x/image.png", + "assets/4.0x/image.png" + ] +} +'''; + +class TestAssetBundle extends AssetBundle { + // Image loading logic routes through load(key) + ImageResource loadImage(String key) => null; + Future loadString(String key) { + if (key == 'AssetManifest.json') + return (new Completer()..complete(testManifest)).future; + return null; + } + Future load(String key) { + core.MojoDataPipeConsumer pipe = null; + switch (key) { + case 'assets/image.png': + pipe = new TestMojoDataPipeConsumer(1.0); + break; + case 'assets/1.5x/image.png': + pipe = new TestMojoDataPipeConsumer(1.5); + break; + case 'assets/2.0x/image.png': + pipe = new TestMojoDataPipeConsumer(2.0); + break; + case 'assets/3.0x/image.png': + pipe = new TestMojoDataPipeConsumer(3.0); + break; + case 'assets/4.0x/image.png': + pipe = new TestMojoDataPipeConsumer(4.0); + break; + } + return (new Completer()..complete(pipe)).future; + } + String toString() => '$runtimeType@$hashCode()'; +} + +Future testDecodeImageFromDataPipe(core.MojoDataPipeConsumer pipe) { + TestMojoDataPipeConsumer testPipe = pipe as TestMojoDataPipeConsumer; + assert(testPipe != null); + ui.Image image = new TestImage(testPipe.scale); + return (new Completer()..complete(image)).future; +} + +Widget buildImageAtRatio(String image, Key key, double ratio, bool inferSize) { + const double windowSize = 500.0; // 500 logical pixels + const double imageSize = 200.0; // 200 logical pixels + + return new MediaQuery( + data: new MediaQueryData( + size: const Size(windowSize, windowSize), + devicePixelRatio: ratio, + padding: const EdgeDims.all(0.0) + ), + child: new AssetVendor( + bundle: new TestAssetBundle(), + devicePixelRatio: ratio, + imageDecoder: testDecodeImageFromDataPipe, + child: new Center( + child: inferSize ? + new AssetImage( + key: key, + name: image + ) : + new AssetImage( + key: key, + name: image, + height: imageSize, + width: imageSize, + fit: ImageFit.fill + ) + ) + ) + ); +} + +RenderImage getRenderImage(tester, Key key) { + return tester.findElementByKey(key).renderObject as RenderImage; +} + +TestImage getTestImage(tester, Key key) { + return getRenderImage(tester, key).image as TestImage; +} + +void pumpTreeToLayout(WidgetTester tester, Widget widget) { + Duration pumpDuration = const Duration(milliseconds: 0); + EnginePhase pumpPhase = EnginePhase.layout; + tester.pumpWidget(widget, pumpDuration, pumpPhase); +} + +void main() { + String image = 'assets/image.png'; + + test('Image for device pixel ratio 1.0', () { + const double ratio = 1.0; + testWidgets((WidgetTester tester) { + Key key = new GlobalKey(); + pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, false)); + expect(getRenderImage(tester, key).size, const Size(200.0, 200.0)); + expect(getTestImage(tester, key).scale, 1.0); + key = new GlobalKey(); + pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, true)); + expect(getRenderImage(tester, key).size, const Size(48.0, 48.0)); + expect(getTestImage(tester, key).scale, 1.0); + }); + }); + + test('Image for device pixel ratio 0.5', () { + const double ratio = 0.5; + testWidgets((WidgetTester tester) { + Key key = new GlobalKey(); + pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, false)); + expect(getRenderImage(tester, key).size, const Size(200.0, 200.0)); + expect(getTestImage(tester, key).scale, 1.0); + key = new GlobalKey(); + pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, true)); + expect(getRenderImage(tester, key).size, const Size(48.0, 48.0)); + expect(getTestImage(tester, key).scale, 1.0); + }); + }); + + test('Image for device pixel ratio 1.5', () { + const double ratio = 1.5; + testWidgets((WidgetTester tester) { + Key key = new GlobalKey(); + pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, false)); + expect(getRenderImage(tester, key).size, const Size(200.0, 200.0)); + expect(getTestImage(tester, key).scale, 1.5); + key = new GlobalKey(); + pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, true)); + expect(getRenderImage(tester, key).size, const Size(48.0, 48.0)); + expect(getTestImage(tester, key).scale, 1.5); + }); + }); + + test('Image for device pixel ratio 1.75', () { + const double ratio = 1.75; + testWidgets((WidgetTester tester) { + Key key = new GlobalKey(); + pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, false)); + expect(getRenderImage(tester, key).size, const Size(200.0, 200.0)); + expect(getTestImage(tester, key).scale, 1.5); + key = new GlobalKey(); + pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, true)); + expect(getRenderImage(tester, key).size, const Size(48.0, 48.0)); + expect(getTestImage(tester, key).scale, 1.5); + }); + }); + + test('Image for device pixel ratio 2.3', () { + const double ratio = 2.3; + testWidgets((WidgetTester tester) { + Key key = new GlobalKey(); + pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, false)); + expect(getRenderImage(tester, key).size, const Size(200.0, 200.0)); + expect(getTestImage(tester, key).scale, 2.0); + key = new GlobalKey(); + pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, true)); + expect(getRenderImage(tester, key).size, const Size(48.0, 48.0)); + expect(getTestImage(tester, key).scale, 2.0); + }); + }); + + test('Image for device pixel ratio 3.7', () { + const double ratio = 3.7; + testWidgets((WidgetTester tester) { + Key key = new GlobalKey(); + pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, false)); + expect(getRenderImage(tester, key).size, const Size(200.0, 200.0)); + expect(getTestImage(tester, key).scale, 4.0); + key = new GlobalKey(); + pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, true)); + expect(getRenderImage(tester, key).size, const Size(48.0, 48.0)); + expect(getTestImage(tester, key).scale, 4.0); + }); + }); + + test('Image for device pixel ratio 5.1', () { + const double ratio = 5.1; + testWidgets((WidgetTester tester) { + Key key = new GlobalKey(); + pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, false)); + expect(getRenderImage(tester, key).size, const Size(200.0, 200.0)); + expect(getTestImage(tester, key).scale, 4.0); + key = new GlobalKey(); + pumpTreeToLayout(tester, buildImageAtRatio(image, key, ratio, true)); + expect(getRenderImage(tester, key).size, const Size(48.0, 48.0)); + expect(getTestImage(tester, key).scale, 4.0); + }); + }); + +} diff --git a/packages/flutter_test/lib/src/instrumentation.dart b/packages/flutter_test/lib/src/instrumentation.dart index effb36f328..b92940fcad 100644 --- a/packages/flutter_test/lib/src/instrumentation.dart +++ b/packages/flutter_test/lib/src/instrumentation.dart @@ -15,7 +15,8 @@ typedef Point SizeToPointFunction(Size size); /// This class provides hooks for accessing the rendering tree and dispatching /// fake tap/drag/etc. events. class Instrumentation { - Instrumentation() : binding = WidgetFlutterBinding.ensureInitialized(); + Instrumentation({ WidgetFlutterBinding binding }) + : this.binding = binding ?? WidgetFlutterBinding.ensureInitialized(); final WidgetFlutterBinding binding; diff --git a/packages/flutter_test/lib/src/widget_tester.dart b/packages/flutter_test/lib/src/widget_tester.dart index de59ac3a1e..42c803159f 100644 --- a/packages/flutter_test/lib/src/widget_tester.dart +++ b/packages/flutter_test/lib/src/widget_tester.dart @@ -12,15 +12,69 @@ import 'package:flutter/widgets.dart'; import 'instrumentation.dart'; +/// Enumeration of possible phases to reach in pumpWidget. +enum EnginePhase { + layout, + compositingBits, + paint, + composite, + flushSemantics, + sendSemanticsTree +} -/// Helper class for fluter tests providing fake async. +class _SteppedWidgetFlutterBinding extends WidgetFlutterBinding { + + /// Creates and initializes the binding. This constructor is + /// idempotent; calling it a second time will just return the + /// previously-created instance. + static WidgetFlutterBinding ensureInitialized() { + if (WidgetFlutterBinding.instance == null) + new _SteppedWidgetFlutterBinding(); + return WidgetFlutterBinding.instance; + } + + EnginePhase phase = EnginePhase.sendSemanticsTree; + + // Pump the rendering pipeline up to the given phase. + void beginFrame() { + buildDirtyElements(); + _beginFrame(); + Element.finalizeTree(); + } + + // Cloned from Renderer.beginFrame() but with early-exit semantics. + void _beginFrame() { + assert(renderView != null); + RenderObject.flushLayout(); + if (phase == EnginePhase.layout) + return; + RenderObject.flushCompositingBits(); + if (phase == EnginePhase.compositingBits) + return; + RenderObject.flushPaint(); + if (phase == EnginePhase.paint) + return; + renderView.compositeFrame(); // this sends the bits to the GPU + if (phase == EnginePhase.composite) + return; + if (SemanticsNode.hasListeners) { + RenderObject.flushSemantics(); + if (phase == EnginePhase.flushSemantics) + return; + SemanticsNode.sendSemanticsTree(); + } + } +} + +/// Helper class for flutter tests providing fake async. /// /// This class extends Instrumentation to also abstract away the beginFrame /// and async/clock access to allow writing tests which depend on the passage /// of time without actually moving the clock forward. class WidgetTester extends Instrumentation { WidgetTester._(FakeAsync async) - : async = async, + : super(binding: _SteppedWidgetFlutterBinding.ensureInitialized()), + async = async, clock = async.getClock(new DateTime.utc(2015, 1, 1)) { timeDilation = 1.0; ui.window.onBeginFrame = null; @@ -32,7 +86,18 @@ class WidgetTester extends Instrumentation { /// Calls [runApp()] with the given widget, then triggers a frame sequent and /// flushes microtasks, by calling [pump()] with the same duration (if any). - void pumpWidget(Widget widget, [ Duration duration ]) { + /// The supplied EnginePhase is the final phase reached during the pump pass; + /// if not supplied, the whole pass is executed. + void pumpWidget(Widget widget, [ Duration duration, EnginePhase phase ]) { + if (binding is _SteppedWidgetFlutterBinding) { + // Some tests call WidgetFlutterBinding.ensureInitialized() manually, so + // we can't actually be sure we have a stepped binding. + _SteppedWidgetFlutterBinding steppedBinding = binding; + steppedBinding.phase = phase ?? EnginePhase.sendSemanticsTree; + } else { + // Can't step to a given phase in that case + assert(phase == null); + } runApp(widget); pump(duration); }