| // Copyright 2013 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| @TestOn('chrome || firefox') |
| |
| import 'dart:async'; |
| import 'dart:html' as html; |
| import 'dart:js_util' as js_util; |
| |
| import 'package:test/bootstrap/browser.dart'; |
| import 'package:test/test.dart'; |
| import 'package:ui/src/engine.dart'; |
| import 'package:ui/ui.dart' as ui; |
| |
| import '../../matchers.dart'; |
| |
| void main() { |
| internalBootstrapBrowserTest(() => testMain); |
| } |
| |
| void testMain() { |
| setUpAll(() { |
| ui.webOnlyInitializeEngine(); |
| }); |
| |
| group('SceneBuilder', () { |
| test('pushOffset implements surface lifecycle', () { |
| testLayerLifeCycle((ui.SceneBuilder sceneBuilder, ui.EngineLayer? oldLayer) { |
| return sceneBuilder.pushOffset(10, 20, oldLayer: oldLayer as ui.OffsetEngineLayer?); |
| }, () { |
| return '''<s><flt-offset></flt-offset></s>'''; |
| }); |
| }); |
| |
| test('pushTransform implements surface lifecycle', () { |
| testLayerLifeCycle((ui.SceneBuilder sceneBuilder, ui.EngineLayer? oldLayer) { |
| return sceneBuilder.pushTransform( |
| (Matrix4.identity()..scale(html.window.devicePixelRatio as double)).toFloat64()); |
| }, () { |
| return '''<s><flt-transform></flt-transform></s>'''; |
| }); |
| }); |
| |
| test('pushClipRect implements surface lifecycle', () { |
| testLayerLifeCycle((ui.SceneBuilder sceneBuilder, ui.EngineLayer? oldLayer) { |
| return sceneBuilder.pushClipRect(const ui.Rect.fromLTRB(10, 20, 30, 40), |
| oldLayer: oldLayer as ui.ClipRectEngineLayer?); |
| }, () { |
| return ''' |
| <s> |
| <clip><clip-i></clip-i></clip> |
| </s> |
| '''; |
| }); |
| }); |
| |
| test('pushClipRRect implements surface lifecycle', () { |
| testLayerLifeCycle((ui.SceneBuilder sceneBuilder, ui.EngineLayer? oldLayer) { |
| return sceneBuilder.pushClipRRect( |
| ui.RRect.fromLTRBR(10, 20, 30, 40, const ui.Radius.circular(3)), |
| oldLayer: oldLayer as ui.ClipRRectEngineLayer?, |
| clipBehavior: ui.Clip.none); |
| }, () { |
| return ''' |
| <s> |
| <rclip><clip-i></clip-i></rclip> |
| </s> |
| '''; |
| }); |
| }); |
| |
| test('pushClipPath implements surface lifecycle', () { |
| testLayerLifeCycle((ui.SceneBuilder sceneBuilder, ui.EngineLayer? oldLayer) { |
| final ui.Path path = ui.Path()..addRect(const ui.Rect.fromLTRB(10, 20, 30, 40)); |
| return sceneBuilder.pushClipPath(path, oldLayer: oldLayer as ui.ClipPathEngineLayer?); |
| }, () { |
| return ''' |
| <s> |
| <flt-clippath> |
| <svg><defs><clipPath><path></path></clipPath></defs></svg> |
| </flt-clippath> |
| </s> |
| '''; |
| }); |
| }); |
| |
| test('pushOpacity implements surface lifecycle', () { |
| testLayerLifeCycle((ui.SceneBuilder sceneBuilder, ui.EngineLayer? oldLayer) { |
| return sceneBuilder.pushOpacity(10, oldLayer: oldLayer as ui.OpacityEngineLayer?); |
| }, () { |
| return '''<s><o></o></s>'''; |
| }); |
| }); |
| |
| test('pushPhysicalShape implements surface lifecycle', () { |
| testLayerLifeCycle((ui.SceneBuilder sceneBuilder, ui.EngineLayer? oldLayer) { |
| final ui.Path path = ui.Path()..addRect(const ui.Rect.fromLTRB(10, 20, 30, 40)); |
| return sceneBuilder.pushPhysicalShape( |
| path: path, |
| elevation: 2, |
| color: const ui.Color.fromRGBO(0, 0, 0, 1), |
| shadowColor: const ui.Color.fromRGBO(0, 0, 0, 1), |
| oldLayer: oldLayer as ui.PhysicalShapeEngineLayer?, |
| ); |
| }, () { |
| return '''<s><pshape><clip-i></clip-i></pshape></s>'''; |
| }); |
| }); |
| |
| test('pushBackdropFilter implements surface lifecycle', () { |
| testLayerLifeCycle((ui.SceneBuilder sceneBuilder, ui.EngineLayer? oldLayer) { |
| return sceneBuilder.pushBackdropFilter( |
| ui.ImageFilter.blur(sigmaX: 1.0, sigmaY: 1.0), |
| oldLayer: oldLayer as ui.BackdropFilterEngineLayer?, |
| ); |
| }, () { |
| return '<s><flt-backdrop>' |
| '<flt-backdrop-filter></flt-backdrop-filter>' |
| '<flt-backdrop-interior></flt-backdrop-interior>' |
| '</flt-backdrop></s>'; |
| }); |
| }); |
| }); |
| |
| group('parent child lifecycle', () { |
| test( |
| 'build, retain, update, and applyPaint are called the right number of times', |
| () { |
| final PersistedScene scene1 = PersistedScene(null); |
| final PersistedClipRect clip1 = |
| PersistedClipRect(null, const ui.Rect.fromLTRB(10, 10, 20, 20), |
| ui.Clip.antiAlias); |
| final PersistedOpacity opacity = PersistedOpacity(null, 100, ui.Offset.zero); |
| final MockPersistedPicture picture = MockPersistedPicture(); |
| |
| scene1.appendChild(clip1); |
| clip1.appendChild(opacity); |
| opacity.appendChild(picture); |
| |
| expect(picture.retainCount, 0); |
| expect(picture.buildCount, 0); |
| expect(picture.updateCount, 0); |
| expect(picture.applyPaintCount, 0); |
| |
| scene1.preroll(PrerollSurfaceContext()); |
| scene1.build(); |
| commitScene(scene1); |
| expect(picture.retainCount, 0); |
| expect(picture.buildCount, 1); |
| expect(picture.updateCount, 0); |
| expect(picture.applyPaintCount, 1); |
| |
| // The second scene graph retains the opacity, but not the clip. However, |
| // because the clip didn't change no repaints should happen. |
| final PersistedScene scene2 = PersistedScene(scene1); |
| final PersistedClipRect clip2 = |
| PersistedClipRect(clip1, const ui.Rect.fromLTRB(10, 10, 20, 20), |
| ui.Clip.antiAlias); |
| clip1.state = PersistedSurfaceState.pendingUpdate; |
| scene2.appendChild(clip2); |
| opacity.state = PersistedSurfaceState.pendingRetention; |
| clip2.appendChild(opacity); |
| |
| scene2.preroll(PrerollSurfaceContext()); |
| scene2.update(scene1); |
| commitScene(scene2); |
| expect(picture.retainCount, 1); |
| expect(picture.buildCount, 1); |
| expect(picture.updateCount, 0); |
| expect(picture.applyPaintCount, 1); |
| |
| // The third scene graph retains the opacity, and produces a new clip. |
| // This should cause the picture to repaint despite being retained. |
| final PersistedScene scene3 = PersistedScene(scene2); |
| final PersistedClipRect clip3 = |
| PersistedClipRect(clip2, const ui.Rect.fromLTRB(10, 10, 50, 50), |
| ui.Clip.antiAlias); |
| clip2.state = PersistedSurfaceState.pendingUpdate; |
| scene3.appendChild(clip3); |
| opacity.state = PersistedSurfaceState.pendingRetention; |
| clip3.appendChild(opacity); |
| |
| scene3.preroll(PrerollSurfaceContext()); |
| scene3.update(scene2); |
| commitScene(scene3); |
| expect(picture.retainCount, 2); |
| expect(picture.buildCount, 1); |
| expect(picture.updateCount, 0); |
| expect(picture.applyPaintCount, 2); |
| }, // TODO(nurhan): https://github.com/flutter/flutter/issues/46638 |
| skip: browserEngine == BrowserEngine.firefox); |
| }); |
| |
| group('Compositing order', () { |
| // Regression test for https://github.com/flutter/flutter/issues/55058 |
| // |
| // When BitmapCanvas uses multiple elements to paint, the very first |
| // canvas needs to have a -1 zIndex so it can preserve compositing order. |
| test('Canvas element should retain -1 zIndex after update', () async { |
| final SurfaceSceneBuilder builder = SurfaceSceneBuilder(); |
| final ui.Picture picture1 = _drawPicture(); |
| final ui.ClipRectEngineLayer oldLayer = builder.pushClipRect( |
| const ui.Rect.fromLTRB(10, 10, 300, 300), |
| ); |
| builder.addPicture(ui.Offset.zero, picture1); |
| builder.pop(); |
| |
| final html.Element content = builder.build().webOnlyRootElement!; |
| expect(content.querySelector('canvas')!.style.zIndex, '-1'); |
| |
| // Force update to scene which will utilize reuse code path. |
| final SurfaceSceneBuilder builder2 = SurfaceSceneBuilder(); |
| builder2.pushClipRect( |
| const ui.Rect.fromLTRB(5, 10, 300, 300), |
| oldLayer: oldLayer |
| ); |
| final ui.Picture picture2 = _drawPicture(); |
| builder2.addPicture(ui.Offset.zero, picture2); |
| builder2.pop(); |
| |
| final html.Element contentAfterReuse = builder2.build().webOnlyRootElement!; |
| expect(contentAfterReuse.querySelector('canvas')!.style.zIndex, '-1'); |
| }); |
| |
| test('Multiple canvas elements should retain zIndex after update', () async { |
| final SurfaceSceneBuilder builder = SurfaceSceneBuilder(); |
| final ui.Picture picture1 = _drawPathImagePath(); |
| final ui.ClipRectEngineLayer oldLayer = builder.pushClipRect( |
| const ui.Rect.fromLTRB(10, 10, 300, 300), |
| ); |
| builder.addPicture(ui.Offset.zero, picture1); |
| builder.pop(); |
| |
| final html.Element content = builder.build().webOnlyRootElement!; |
| html.document.body!.append(content); |
| expect(content.querySelector('canvas')!.style.zIndex, '-1'); |
| |
| // Force update to scene which will utilize reuse code path. |
| final SurfaceSceneBuilder builder2 = SurfaceSceneBuilder(); |
| builder2.pushClipRect( |
| const ui.Rect.fromLTRB(5, 10, 300, 300), |
| oldLayer: oldLayer |
| ); |
| final ui.Picture picture2 = _drawPathImagePath(); |
| builder2.addPicture(ui.Offset.zero, picture2); |
| builder2.pop(); |
| |
| final html.Element contentAfterReuse = builder2.build().webOnlyRootElement!; |
| final List<html.CanvasElement> list = |
| contentAfterReuse.querySelectorAll('canvas'); |
| expect(list[0].style.zIndex, '-1'); |
| expect(list[1].style.zIndex, ''); |
| }); |
| }); |
| |
| /// Verify elementCache is passed during update to reuse existing |
| /// image elements. |
| test('Should retain same image element', () async { |
| final SurfaceSceneBuilder builder = SurfaceSceneBuilder(); |
| final ui.Picture picture1 = _drawPathImagePath(); |
| final ui.ClipRectEngineLayer oldLayer = builder.pushClipRect( |
| const ui.Rect.fromLTRB(10, 10, 300, 300), |
| ); |
| builder.addPicture(ui.Offset.zero, picture1); |
| builder.pop(); |
| |
| final html.Element content = builder.build().webOnlyRootElement!; |
| html.document.body!.append(content); |
| List<html.ImageElement> list = content.querySelectorAll('img'); |
| for (final html.ImageElement image in list) { |
| image.alt = 'marked'; |
| } |
| |
| // Force update to scene which will utilize reuse code path. |
| final SurfaceSceneBuilder builder2 = SurfaceSceneBuilder(); |
| builder2.pushClipRect( |
| const ui.Rect.fromLTRB(5, 10, 300, 300), |
| oldLayer: oldLayer |
| ); |
| final ui.Picture picture2 = _drawPathImagePath(); |
| builder2.addPicture(ui.Offset.zero, picture2); |
| builder2.pop(); |
| |
| final html.Element contentAfterReuse = builder2.build().webOnlyRootElement!; |
| list = contentAfterReuse.querySelectorAll('img'); |
| for (final html.ImageElement image in list) { |
| expect(image.alt, 'marked'); |
| } |
| expect(list.length, 1); |
| }); |
| |
| PersistedPicture? findPictureSurfaceChild(PersistedContainerSurface parent) { |
| PersistedPicture? pictureSurface; |
| parent.visitChildren((PersistedSurface child) { |
| pictureSurface = child as PersistedPicture; |
| }); |
| return pictureSurface; |
| } |
| |
| test('skips painting picture when picture fully clipped out', () async { |
| final ui.Picture picture = _drawPicture(); |
| |
| // Picture not clipped out, so we should see a `<flt-canvas>` |
| { |
| final SurfaceSceneBuilder builder = SurfaceSceneBuilder(); |
| builder.pushOffset(0, 0); |
| builder.addPicture(ui.Offset.zero, picture); |
| builder.pop(); |
| final html.Element content = builder.build().webOnlyRootElement!; |
| expect(content.querySelectorAll('flt-picture').single.children, isNotEmpty); |
| } |
| |
| // Picture fully clipped out, so we should not see a `<flt-canvas>` |
| { |
| final SurfaceSceneBuilder builder = SurfaceSceneBuilder(); |
| builder.pushOffset(0, 0); |
| final PersistedContainerSurface clip = builder.pushClipRect(const ui.Rect.fromLTRB(1000, 1000, 2000, 2000)) as PersistedContainerSurface; |
| builder.addPicture(ui.Offset.zero, picture); |
| builder.pop(); |
| builder.pop(); |
| final html.Element content = builder.build().webOnlyRootElement!; |
| expect(content.querySelectorAll('flt-picture').single.children, isEmpty); |
| expect(findPictureSurfaceChild(clip)!.canvas, isNull); |
| } |
| }); |
| |
| test('does not skip painting picture when picture is ' |
| 'inside transform with offset', () async { |
| final ui.Picture picture = _drawPicture(); |
| // Picture should not be clipped out since transform will offset it to 500,500 |
| final SurfaceSceneBuilder builder = SurfaceSceneBuilder(); |
| builder.pushOffset(0, 0); |
| builder.pushClipRect(const ui.Rect.fromLTRB(0, 0, 1000, 1000)) as PersistedContainerSurface; |
| builder.pushTransform((Matrix4.identity()..scale(0.5, 0.5)).toFloat64()); |
| builder.addPicture(const ui.Offset(1000, 1000), picture); |
| builder.pop(); |
| builder.pop(); |
| builder.pop(); |
| final html.Element content = builder.build().webOnlyRootElement!; |
| expect(content.querySelectorAll('flt-picture').single.children, isNotEmpty); |
| }); |
| |
| test('does not skip painting picture when picture is ' |
| 'inside transform', () async { |
| final ui.Picture picture = _drawPicture(); |
| // Picture should not be clipped out since transform will offset it to 500,500 |
| final SurfaceSceneBuilder builder = SurfaceSceneBuilder(); |
| builder.pushOffset(0, 0); |
| builder.pushClipRect(const ui.Rect.fromLTRB(0, 0, 1000, 1000)) as PersistedContainerSurface; |
| builder.pushTransform((Matrix4.identity()..scale(0.5, 0.5)).toFloat64()); |
| builder.pushOffset(1000, 1000); |
| builder.addPicture(ui.Offset.zero, picture); |
| builder.pop(); |
| builder.pop(); |
| builder.pop(); |
| final html.Element content = builder.build().webOnlyRootElement!; |
| expect(content.querySelectorAll('flt-picture').single.children, isNotEmpty); |
| }); |
| |
| test( |
| 'skips painting picture when picture fully clipped out with' |
| ' transform and offset', () async { |
| final ui.Picture picture = _drawPicture(); |
| // Picture should be clipped out since transform will offset it to 500,500 |
| final SurfaceSceneBuilder builder = SurfaceSceneBuilder(); |
| builder.pushOffset(50, 50); |
| builder.pushClipRect( |
| const ui.Rect.fromLTRB(0, 0, 1000, 1000)) as PersistedContainerSurface; |
| builder.pushTransform((Matrix4.identity() |
| ..scale(2, 2)).toFloat64()); |
| builder.pushOffset(500, 500); |
| builder.addPicture(ui.Offset.zero, picture); |
| builder.pop(); |
| builder.pop(); |
| builder.pop(); |
| builder.pop(); |
| final html.Element content = builder |
| .build() |
| .webOnlyRootElement!; |
| expect(content |
| .querySelectorAll('flt-picture') |
| .single |
| .children, isEmpty); |
| }); |
| |
| test('releases old canvas when picture is fully clipped out after addRetained', () async { |
| final ui.Picture picture = _drawPicture(); |
| |
| // Frame 1: picture visible |
| final SurfaceSceneBuilder builder1 = SurfaceSceneBuilder(); |
| final PersistedOffset offset1 = builder1.pushOffset(0, 0) as PersistedOffset; |
| builder1.addPicture(ui.Offset.zero, picture); |
| builder1.pop(); |
| final html.Element content1 = builder1.build().webOnlyRootElement!; |
| expect(content1.querySelectorAll('flt-picture').single.children, isNotEmpty); |
| expect(findPictureSurfaceChild(offset1)!.canvas, isNotNull); |
| |
| // Frame 2: picture is clipped out after an update |
| final SurfaceSceneBuilder builder2 = SurfaceSceneBuilder(); |
| final PersistedOffset offset2 = builder2.pushOffset(-10000, -10000, oldLayer: offset1) as PersistedOffset; |
| builder2.addPicture(ui.Offset.zero, picture); |
| builder2.pop(); |
| final html.Element content = builder2.build().webOnlyRootElement!; |
| expect(content.querySelectorAll('flt-picture').single.children, isEmpty); |
| expect(findPictureSurfaceChild(offset2)!.canvas, isNull); |
| }); |
| |
| test('releases old canvas when picture is fully clipped out after addRetained', () async { |
| final ui.Picture picture = _drawPicture(); |
| |
| // Frame 1: picture visible |
| final SurfaceSceneBuilder builder1 = SurfaceSceneBuilder(); |
| final PersistedOffset offset1 = builder1.pushOffset(0, 0) as PersistedOffset; |
| final PersistedOffset subOffset1 = builder1.pushOffset(0, 0) as PersistedOffset; |
| builder1.addPicture(ui.Offset.zero, picture); |
| builder1.pop(); |
| builder1.pop(); |
| final html.Element content1 = builder1.build().webOnlyRootElement!; |
| expect(content1.querySelectorAll('flt-picture').single.children, isNotEmpty); |
| expect(findPictureSurfaceChild(subOffset1)!.canvas, isNotNull); |
| |
| // Frame 2: picture is clipped out after addRetained |
| final SurfaceSceneBuilder builder2 = SurfaceSceneBuilder(); |
| builder2.pushOffset(-10000, -10000, oldLayer: offset1); |
| |
| // Even though the child offset is added as retained, the parent |
| // is updated with a value that causes the picture to move out of |
| // the clipped area. We should see the canvas being released. |
| builder2.addRetained(subOffset1); |
| builder2.pop(); |
| final html.Element content = builder2.build().webOnlyRootElement!; |
| expect(content.querySelectorAll('flt-picture').single.children, isEmpty); |
| expect(findPictureSurfaceChild(subOffset1)!.canvas, isNull); |
| }); |
| |
| test('auto-pops pushed layers', () async { |
| final ui.Picture picture = _drawPicture(); |
| final SurfaceSceneBuilder builder = SurfaceSceneBuilder(); |
| builder.pushOffset(0, 0); |
| builder.pushOffset(0, 0); |
| builder.pushOffset(0, 0); |
| builder.pushOffset(0, 0); |
| builder.pushOffset(0, 0); |
| builder.addPicture(ui.Offset.zero, picture); |
| |
| // Intentionally pop fewer layers than we pushed |
| builder.pop(); |
| builder.pop(); |
| builder.pop(); |
| |
| // Expect as many layers as we pushed (not popped). |
| final html.Element content = builder.build().webOnlyRootElement!; |
| expect(content.querySelectorAll('flt-offset'), hasLength(5)); |
| }); |
| |
| test('updates child lists efficiently', () async { |
| // Pushes a single child that renders one character. |
| // |
| // If the character is a number, pushes an offset layer. Otherwise, pushes |
| // an offset layer. Test cases use this to control how layers are reused. |
| // Layers of the same type can be reused even if they are not explicitly |
| // updated. Conversely, layers of different types are never reused. |
| ui.EngineLayer pushChild(SurfaceSceneBuilder builder, String char, {ui.EngineLayer? oldLayer}) { |
| // Numbers use opacity layers, letters use offset layers. This is used to |
| // control DOM reuse. Layers of the same type can reuse DOM nodes from other |
| // dropped layers. |
| final bool useOffset = int.tryParse(char) == null; |
| final EnginePictureRecorder recorder = EnginePictureRecorder(); |
| final RecordingCanvas canvas = recorder.beginRecording(const ui.Rect.fromLTRB(0, 0, 400, 400)); |
| final DomParagraph paragraph = (DomParagraphBuilder(EngineParagraphStyle())..addText(char)).build() as DomParagraph; |
| paragraph.layout(const ui.ParagraphConstraints(width: 1000)); |
| canvas.drawParagraph(paragraph, ui.Offset.zero); |
| final ui.EngineLayer newLayer = useOffset |
| ? builder.pushOffset(0, 0, oldLayer: oldLayer == null ? null : oldLayer as ui.OffsetEngineLayer) |
| : builder.pushOpacity(100, oldLayer: oldLayer == null ? null : oldLayer as ui.OpacityEngineLayer); |
| builder.addPicture(ui.Offset.zero, recorder.endRecording()); |
| builder.pop(); |
| return newLayer; |
| } |
| |
| // Maps letters to layers used to render them in the last frame, used to |
| // supply `oldLayer` to guarantee update. |
| final Map<String, ui.EngineLayer> renderedLayers = <String, ui.EngineLayer>{}; |
| |
| // Pump an empty scene to reset it, otherwise the first frame will attempt |
| // to diff left-overs from a previous test, which results in unpredictable |
| // DOM mutations. |
| window.render(SurfaceSceneBuilder().build()); |
| |
| // Renders a `string` by breaking it up into individual characters and |
| // rendering each character into its own layer. |
| Future<void> testCase(String string, String description, { int deletions = 0, int additions = 0, int moves = 0 }) { |
| final Set<html.Node> actualDeletions = <html.Node>{}; |
| final Set<html.Node> actualAdditions = <html.Node>{}; |
| |
| // Watches DOM mutations and counts deletions and additions to the child |
| // list of the `<flt-scene>` element. |
| final html.MutationObserver observer = html.MutationObserver((List<dynamic> mutations, _) { |
| for (final html.MutationRecord record in mutations.cast<html.MutationRecord>()) { |
| actualDeletions.addAll(record.removedNodes!); |
| actualAdditions.addAll(record.addedNodes!); |
| } |
| }); |
| observer.observe(SurfaceSceneBuilder.debugLastFrameScene!.rootElement!, childList: true); |
| |
| final SurfaceSceneBuilder builder = SurfaceSceneBuilder(); |
| for (int i = 0; i < string.length; i++) { |
| final String char = string[i]; |
| renderedLayers[char] = pushChild(builder, char, oldLayer: renderedLayers[char]); |
| } |
| final SurfaceScene scene = builder.build(); |
| final List<html.Element> pTags = scene.webOnlyRootElement!.querySelectorAll('p'); |
| expect(pTags, hasLength(string.length)); |
| expect( |
| scene.webOnlyRootElement!.querySelectorAll('p').map((html.Element p) => p.innerText).join(''), |
| string, |
| ); |
| renderedLayers.removeWhere((String key, ui.EngineLayer value) => !string.contains(key)); |
| |
| // Inject a zero-duration timer to allow mutation observers to receive notification. |
| return Future<void>.delayed(Duration.zero).then((_) { |
| observer.disconnect(); |
| |
| // Nodes that are removed then added are classified as "moves". |
| final int actualMoves = actualAdditions.intersection(actualDeletions).length; |
| // Compare all at once instead of one by one because when it fails, it's |
| // much more useful to see all numbers, not just the one that failed to |
| // match. |
| expect( |
| <String, int>{ |
| 'additions': actualAdditions.length - actualMoves, |
| 'deletions': actualDeletions.length - actualMoves, |
| 'moves': actualMoves, |
| }, |
| <String, int>{ |
| 'additions': additions, |
| 'deletions': deletions, |
| 'moves': moves, |
| }, |
| ); |
| }); |
| } |
| |
| // Adding |
| await testCase('', 'noop'); |
| await testCase('', 'noop'); |
| await testCase('be', 'zero-to-many', additions: 2); |
| await testCase('bcde', 'insert in the middle', additions: 2); |
| await testCase('abcde', 'prepend', additions: 1); |
| await testCase('abcdef', 'append', additions: 1); |
| |
| // Moving |
| await testCase('fbcdea', 'swap at ends', moves: 2); |
| await testCase('fecdba', 'swap in the middle', moves: 2); |
| await testCase('fedcba', 'swap adjacent in one move', moves: 1); |
| await testCase('fedcba', 'non-empty noop'); |
| await testCase('afedcb', 'shift right by 1', moves: 1); |
| await testCase('fedcba', 'shift left by 1', moves: 1); |
| await testCase('abcdef', 'reverse', moves: 5); |
| await testCase('efabcd', 'shift right by 2', moves: 2); |
| await testCase('abcdef', 'shift left by 2', moves: 2); |
| |
| // Scrolling without DOM reuse (numbers and letters use different types of layers) |
| await testCase('9abcde', 'scroll right by 1', additions: 1, deletions: 1); |
| await testCase('789abc', 'scroll right by 2', additions: 2, deletions: 2); |
| await testCase('89abcd', 'scroll left by 1', additions: 1, deletions: 1); |
| await testCase('abcdef', 'scroll left by 2', additions: 2, deletions: 2); |
| |
| // Scrolling with DOM reuse |
| await testCase('zabcde', 'scroll right by 1', moves: 1); |
| await testCase('xyzabc', 'scroll right by 2', moves: 2); |
| await testCase('yzabcd', 'scroll left by 1', moves: 1); |
| await testCase('abcdef', 'scroll left by 2', moves: 2); |
| |
| // Removing |
| await testCase('bcdef', 'remove as start', deletions: 1); |
| await testCase('bcde', 'remove as end', deletions: 1); |
| await testCase('be', 'remove in the middle', deletions: 2); |
| await testCase('', 'remove all', deletions: 2); |
| }); |
| |
| test('Canvas should allocate fewer pixels when zoomed out', () async { |
| final SurfaceSceneBuilder builder = SurfaceSceneBuilder(); |
| final ui.Picture picture1 = _drawPicture(); |
| builder.pushClipRect(const ui.Rect.fromLTRB(10, 10, 300, 300)); |
| builder.addPicture(ui.Offset.zero, picture1); |
| builder.pop(); |
| |
| final html.Element content = builder.build().webOnlyRootElement!; |
| final html.CanvasElement canvas = content.querySelector('canvas')! as html.CanvasElement; |
| final int unscaledWidth = canvas.width!; |
| final int unscaledHeight = canvas.height!; |
| |
| // Force update to scene which will utilize reuse code path. |
| final SurfaceSceneBuilder builder2 = SurfaceSceneBuilder(); |
| builder2.pushOffset(0, 0); |
| builder2.pushTransform(Matrix4.identity().scaled(0.5, 0.5).toFloat64()); |
| builder2.pushClipRect( |
| const ui.Rect.fromLTRB(10, 10, 300, 300), |
| ); |
| builder2.addPicture(ui.Offset.zero, picture1); |
| builder2.pop(); |
| builder2.pop(); |
| builder2.pop(); |
| |
| final html.Element contentAfterScale = builder2.build().webOnlyRootElement!; |
| final html.CanvasElement canvas2 = contentAfterScale.querySelector('canvas')! as html.CanvasElement; |
| // Although we are drawing same picture, due to scaling the new canvas |
| // should have fewer pixels. |
| expect(canvas2.width! < unscaledWidth, isTrue); |
| expect(canvas2.height! < unscaledHeight, isTrue); |
| }); |
| |
| test('Canvas should allocate more pixels when zoomed in', () async { |
| final SurfaceSceneBuilder builder = SurfaceSceneBuilder(); |
| final ui.Picture picture1 = _drawPicture(); |
| builder.pushClipRect(const ui.Rect.fromLTRB(10, 10, 300, 300)); |
| builder.addPicture(ui.Offset.zero, picture1); |
| builder.pop(); |
| |
| final html.Element content = builder.build().webOnlyRootElement!; |
| final html.CanvasElement canvas = content.querySelector('canvas')! as html.CanvasElement; |
| final int unscaledWidth = canvas.width!; |
| final int unscaledHeight = canvas.height!; |
| |
| // Force update to scene which will utilize reuse code path. |
| final SurfaceSceneBuilder builder2 = SurfaceSceneBuilder(); |
| builder2.pushOffset(0, 0); |
| builder2.pushTransform(Matrix4.identity().scaled(2, 2).toFloat64()); |
| builder2.pushClipRect( |
| const ui.Rect.fromLTRB(10, 10, 300, 300), |
| ); |
| builder2.addPicture(ui.Offset.zero, picture1); |
| builder2.pop(); |
| builder2.pop(); |
| builder2.pop(); |
| |
| final html.Element contentAfterScale = builder2.build().webOnlyRootElement!; |
| final html.CanvasElement canvas2 = contentAfterScale.querySelector('canvas')! as html.CanvasElement; |
| // Although we are drawing same picture, due to scaling the new canvas |
| // should have more pixels. |
| expect(canvas2.width! > unscaledWidth, isTrue); |
| expect(canvas2.height! > unscaledHeight, isTrue); |
| }); |
| |
| test('Should recycle canvas once', () async { |
| final SurfaceSceneBuilder builder = SurfaceSceneBuilder(); |
| final ui.Picture picture1 = _drawPicture(); |
| final ui.ClipRectEngineLayer oldLayer = builder.pushClipRect( |
| const ui.Rect.fromLTRB(10, 10, 300, 300), |
| ); |
| builder.addPicture(ui.Offset.zero, picture1); |
| builder.pop(); |
| builder.build(); |
| |
| // Force update to scene which will utilize reuse code path. |
| final SurfaceSceneBuilder builder2 = SurfaceSceneBuilder(); |
| final ui.ClipRectEngineLayer oldLayer2 = builder2.pushClipRect( |
| const ui.Rect.fromLTRB(5, 10, 300, 300), |
| oldLayer: oldLayer |
| ); |
| builder2.addPicture(ui.Offset.zero, _drawEmptyPicture()); |
| builder2.pop(); |
| |
| final html.Element contentAfterReuse = builder2.build().webOnlyRootElement!; |
| expect(contentAfterReuse, isNotNull); |
| |
| final SurfaceSceneBuilder builder3 = SurfaceSceneBuilder(); |
| builder3.pushClipRect( |
| const ui.Rect.fromLTRB(25, 10, 300, 300), |
| oldLayer: oldLayer2 |
| ); |
| builder3.addPicture(ui.Offset.zero, _drawEmptyPicture()); |
| builder3.pop(); |
| // This build will crash if canvas gets recycled twice. |
| final html.Element contentAfterReuse2 = builder3.build().webOnlyRootElement!; |
| expect(contentAfterReuse2, isNotNull); |
| }); |
| } |
| |
| typedef TestLayerBuilder = ui.EngineLayer Function( |
| ui.SceneBuilder sceneBuilder, ui.EngineLayer? oldLayer); |
| typedef ExpectedHtmlGetter = String Function(); |
| |
| void testLayerLifeCycle( |
| TestLayerBuilder layerBuilder, ExpectedHtmlGetter expectedHtmlGetter) { |
| // Force scene builder to start from scratch. This guarantees that the first |
| // scene starts from the "build" phase. |
| SurfaceSceneBuilder.debugForgetFrameScene(); |
| |
| // Build: builds a brand new layer. |
| SurfaceSceneBuilder sceneBuilder = SurfaceSceneBuilder(); |
| final ui.EngineLayer layer1 = layerBuilder(sceneBuilder, null); |
| final Type surfaceType = layer1.runtimeType; |
| sceneBuilder.pop(); |
| |
| SceneTester tester = SceneTester(sceneBuilder.build()); |
| tester.expectSceneHtml(expectedHtmlGetter()); |
| |
| PersistedSurface findSurface() { |
| return enumerateSurfaces() |
| .where((PersistedSurface s) => s.runtimeType == surfaceType) |
| .single; |
| } |
| |
| final PersistedSurface surface1 = findSurface(); |
| final html.Element surfaceElement1 = surface1.rootElement!; |
| |
| // Retain: reuses a layer as is along with its DOM elements. |
| sceneBuilder = SurfaceSceneBuilder(); |
| sceneBuilder.addRetained(layer1); |
| |
| tester = SceneTester(sceneBuilder.build()); |
| tester.expectSceneHtml(expectedHtmlGetter()); |
| |
| final PersistedSurface surface2 = findSurface(); |
| final html.Element surfaceElement2 = surface2.rootElement!; |
| |
| expect(surface2, same(surface1)); |
| expect(surfaceElement2, same(surfaceElement1)); |
| |
| // Reuse: reuses a layer's DOM elements by matching it. |
| sceneBuilder = SurfaceSceneBuilder(); |
| final ui.EngineLayer layer3 = layerBuilder(sceneBuilder, layer1); |
| sceneBuilder.pop(); |
| expect(layer3, isNot(same(layer1))); |
| tester = SceneTester(sceneBuilder.build()); |
| tester.expectSceneHtml(expectedHtmlGetter()); |
| |
| final PersistedSurface surface3 = findSurface(); |
| expect(surface3, same(layer3)); |
| final html.Element surfaceElement3 = surface3.rootElement!; |
| expect(surface3, isNot(same(surface2))); |
| expect(surfaceElement3, isNotNull); |
| expect(surfaceElement3, same(surfaceElement2)); |
| |
| // Recycle: discards all the layers. |
| sceneBuilder = SurfaceSceneBuilder(); |
| tester = SceneTester(sceneBuilder.build()); |
| tester.expectSceneHtml('<s></s>'); |
| |
| expect(surface3.rootElement, isNull); // offset3 should be recycled. |
| |
| // Retain again: the framework should be able to request that a layer is added |
| // as retained even after it has been recycled. In this case the |
| // engine would "rehydrate" the layer with new DOM elements. |
| sceneBuilder = SurfaceSceneBuilder(); |
| sceneBuilder.addRetained(layer3); |
| tester = SceneTester(sceneBuilder.build()); |
| tester.expectSceneHtml(expectedHtmlGetter()); |
| expect(surface3.rootElement, isNotNull); // offset3 should be rehydrated. |
| |
| // Make sure we clear retained surface list. |
| expect(retainedSurfaces, isEmpty); |
| } |
| |
| class MockPersistedPicture extends PersistedPicture { |
| factory MockPersistedPicture() { |
| final EnginePictureRecorder recorder = EnginePictureRecorder(); |
| // Use the largest cull rect so that layer clips are effective. The tests |
| // rely on this. |
| recorder.beginRecording(ui.Rect.largest).drawPaint(SurfacePaint()); |
| return MockPersistedPicture._(recorder.endRecording()); |
| } |
| |
| MockPersistedPicture._(EnginePicture picture) : super(0, 0, picture, 0); |
| |
| int retainCount = 0; |
| int buildCount = 0; |
| int updateCount = 0; |
| int applyPaintCount = 0; |
| |
| final BitmapCanvas _fakeCanvas = BitmapCanvas(const ui.Rect.fromLTRB(0, 0, 10, 10), RenderStrategy()); |
| |
| @override |
| EngineCanvas get canvas { |
| return _fakeCanvas; |
| } |
| |
| @override |
| double matchForUpdate(PersistedPicture existingSurface) { |
| return identical(existingSurface.picture, picture) ? 0.0 : 1.0; |
| } |
| |
| @override |
| Matrix4 get localTransformInverse => Matrix4.identity(); |
| |
| @override |
| void build() { |
| super.build(); |
| buildCount++; |
| } |
| |
| @override |
| void retain() { |
| super.retain(); |
| retainCount++; |
| } |
| |
| @override |
| void applyPaint(EngineCanvas? oldCanvas) { |
| applyPaintCount++; |
| } |
| |
| @override |
| void update(PersistedPicture oldSurface) { |
| super.update(oldSurface); |
| updateCount++; |
| } |
| |
| @override |
| int get bitmapPixelCount => 0; |
| } |
| |
| /// Draw 4 circles within 50, 50, 120, 120 bounds |
| ui.Picture _drawPicture() { |
| const double offsetX = 50; |
| const double offsetY = 50; |
| final EnginePictureRecorder recorder = EnginePictureRecorder(); |
| final RecordingCanvas canvas = |
| recorder.beginRecording(const ui.Rect.fromLTRB(0, 0, 400, 400)); |
| final ui.Shader gradient = ui.Gradient.radial( |
| const ui.Offset(100, 100), 50, |
| const <ui.Color>[ |
| ui.Color.fromARGB(255, 0, 0, 0), |
| ui.Color.fromARGB(255, 0, 0, 255), |
| ], |
| ); |
| canvas.drawCircle( |
| const ui.Offset(offsetX + 10, offsetY + 10), 10, |
| SurfacePaint() |
| ..style = ui.PaintingStyle.fill |
| ..shader = gradient); |
| canvas.drawCircle( |
| const ui.Offset(offsetX + 60, offsetY + 10), |
| 10, |
| SurfacePaint() |
| ..style = ui.PaintingStyle.fill |
| ..color = const ui.Color.fromRGBO(255, 0, 0, 1)); |
| canvas.drawCircle( |
| const ui.Offset(offsetX + 10, offsetY + 60), |
| 10, |
| SurfacePaint() |
| ..style = ui.PaintingStyle.fill |
| ..color = const ui.Color.fromRGBO(0, 255, 0, 1)); |
| canvas.drawCircle( |
| const ui.Offset(offsetX + 60, offsetY + 60), |
| 10, |
| SurfacePaint() |
| ..style = ui.PaintingStyle.fill |
| ..color = const ui.Color.fromRGBO(0, 0, 255, 1)); |
| return recorder.endRecording(); |
| } |
| |
| EnginePicture _drawEmptyPicture() { |
| final EnginePictureRecorder recorder = EnginePictureRecorder(); |
| recorder.beginRecording(const ui.Rect.fromLTRB(0, 0, 400, 400)); |
| return recorder.endRecording(); |
| } |
| |
| EnginePicture _drawPathImagePath() { |
| const double offsetX = 50; |
| const double offsetY = 50; |
| final EnginePictureRecorder recorder = EnginePictureRecorder(); |
| final RecordingCanvas canvas = |
| recorder.beginRecording(const ui.Rect.fromLTRB(0, 0, 400, 400)); |
| final ui.Shader gradient = ui.Gradient.radial( |
| const ui.Offset(100, 100), 50, |
| const <ui.Color>[ |
| ui.Color.fromARGB(255, 0, 0, 0), |
| ui.Color.fromARGB(255, 0, 0, 255), |
| ], |
| ); |
| canvas.drawCircle( |
| const ui.Offset(offsetX + 10, offsetY + 10), 10, |
| SurfacePaint() |
| ..style = ui.PaintingStyle.fill |
| ..shader = gradient); |
| canvas.drawCircle( |
| const ui.Offset(offsetX + 60, offsetY + 10), |
| 10, |
| SurfacePaint() |
| ..style = ui.PaintingStyle.fill |
| ..color = const ui.Color.fromRGBO(255, 0, 0, 1)); |
| canvas.drawCircle( |
| const ui.Offset(offsetX + 10, offsetY + 60), |
| 10, |
| SurfacePaint() |
| ..style = ui.PaintingStyle.fill |
| ..color = const ui.Color.fromRGBO(0, 255, 0, 1)); |
| canvas.drawImage(createTestImage(), const ui.Offset(0, 0), SurfacePaint()); |
| canvas.drawCircle( |
| const ui.Offset(offsetX + 10, offsetY + 10), 10, |
| SurfacePaint() |
| ..style = ui.PaintingStyle.fill |
| ..shader = gradient); |
| canvas.drawCircle( |
| const ui.Offset(offsetX + 60, offsetY + 60), |
| 10, |
| SurfacePaint() |
| ..style = ui.PaintingStyle.fill |
| ..color = const ui.Color.fromRGBO(0, 0, 255, 1)); |
| return recorder.endRecording(); |
| } |
| |
| HtmlImage createTestImage({int width = 100, int height = 50}) { |
| final html.CanvasElement canvas = |
| html.CanvasElement(width: width, height: height); |
| final html.CanvasRenderingContext2D ctx = canvas.context2D; |
| ctx.fillStyle = '#E04040'; |
| ctx.fillRect(0, 0, 33, 50); |
| ctx.fill(); |
| ctx.fillStyle = '#40E080'; |
| ctx.fillRect(33, 0, 33, 50); |
| ctx.fill(); |
| ctx.fillStyle = '#2040E0'; |
| ctx.fillRect(66, 0, 33, 50); |
| ctx.fill(); |
| final html.ImageElement imageElement = html.ImageElement(); |
| imageElement.src = js_util.callMethod(canvas, 'toDataURL', <dynamic>[]); |
| return HtmlImage(imageElement, width, height); |
| } |