diff --git a/packages/devtools_app/lib/src/charts/flutter/flame_chart.dart b/packages/devtools_app/lib/src/charts/flutter/flame_chart.dart new file mode 100644 index 00000000000..760edd0fac2 --- /dev/null +++ b/packages/devtools_app/lib/src/charts/flutter/flame_chart.dart @@ -0,0 +1,91 @@ +// Copyright 2019 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 'package:flutter/material.dart'; + +import '../../flutter/common_widgets.dart'; +import '../../ui/colors.dart'; +import '../../ui/fake_flutter/_real_flutter.dart'; + +const double rowPadding = 2.0; +const double rowHeight = 25.0; +const double rowHeightWithPadding = rowHeight + rowPadding; +const double sectionSpacing = 15.0; +const double topOffset = rowHeightWithPadding; +const double sideInset = 70.0; + +class FlameChartRow { + const FlameChartRow({ + @required this.nodes, + @required this.index, + }); + + final List nodes; + final int index; +} + +class FlameChartNode extends StatelessWidget { + const FlameChartNode({ + Key key, + @required this.text, + @required this.tooltip, + @required this.rect, + @required this.backgroundColor, + @required this.textColor, + @required this.data, + @required this.selected, + @required this.onSelected, + }) : super(key: key); + + FlameChartNode.sectionLabel({ + Key key, + @required this.text, + @required this.textColor, + @required this.backgroundColor, + @required double top, + @required double width, + }) : rect = Rect.fromLTRB(rowPadding, top, width, top + rowHeight), + tooltip = '', + data = null, + selected = false, + onSelected = ((_) {}); + + static const _selectedNodeColor = mainUiColorSelectedLight; + + final Rect rect; + final String text; + final String tooltip; + final Color backgroundColor; + final Color textColor; + final T data; + final bool selected; + final void Function(T) onSelected; + + @override + Widget build(BuildContext context) { + return Positioned.fromRect( + rect: rect, + child: Tooltip( + message: tooltip, + waitDuration: tooltipWait, + preferBelow: false, + child: InkWell( + onTap: () => onSelected(data), + child: Container( + padding: const EdgeInsets.only(left: 6.0), + alignment: Alignment.centerLeft, + color: selected ? _selectedNodeColor : backgroundColor, + child: Text( + text, + textAlign: TextAlign.left, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: selected ? Colors.black : textColor, + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/devtools_app/lib/src/flutter/common_widgets.dart b/packages/devtools_app/lib/src/flutter/common_widgets.dart index 193306611d3..35c6822f824 100644 --- a/packages/devtools_app/lib/src/flutter/common_widgets.dart +++ b/packages/devtools_app/lib/src/flutter/common_widgets.dart @@ -7,6 +7,8 @@ import 'package:flutter_widgets/flutter_widgets.dart'; import '../framework/framework_core.dart'; +const tooltipWait = Duration(milliseconds: 500); + /// Convenience [Divider] with [Padding] that provides a good divider in forms. class PaddedDivider extends StatelessWidget { const PaddedDivider({ diff --git a/packages/devtools_app/lib/src/timeline/flutter/timeline_flame_chart.dart b/packages/devtools_app/lib/src/timeline/flutter/timeline_flame_chart.dart index 40b346d0f92..7c1b1decca5 100644 --- a/packages/devtools_app/lib/src/timeline/flutter/timeline_flame_chart.dart +++ b/packages/devtools_app/lib/src/timeline/flutter/timeline_flame_chart.dart @@ -1,17 +1,326 @@ // Copyright 2019 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:math' as math; import 'package:flutter/material.dart'; +import '../../charts/flutter/flame_chart.dart'; +import '../../flutter/auto_dispose_mixin.dart'; +import '../../flutter/controllers.dart'; +import '../../ui/colors.dart'; +import '../../ui/theme.dart'; +import '../../utils.dart'; +import '../timeline_controller.dart'; +import '../timeline_model.dart'; + class TimelineFlameChart extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - color: Colors.black26, - child: const Center( - child: Text('TODO Flame Chart'), - ), + final controller = Controllers.of(context).timeline; + return LayoutBuilder(builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).focusColor), + ), + child: controller.timelineMode == TimelineMode.frameBased + ? FrameBasedTimelineFlameChart( + controller.frameBasedTimeline.data.selectedFrame, + width: constraints.maxWidth, + height: math.max( + constraints.maxHeight, + _frameBasedTimelineChartHeight(controller), + ), + selectionProvider: () => + controller.frameBasedTimeline.data.selectedEvent, + onSelection: (e) => controller.selectTimelineEvent(e), + ) + // TODO(kenz): implement full timeline flame chart. + : Container( + color: Colors.black26, + child: const Center( + child: Text('TODO Full Timeline Flame Chart'), + ), + ), + ), + ); + }); + } + + double _frameBasedTimelineChartHeight(TimelineController controller) { + return (controller.frameBasedTimeline.data.displayDepth + 2) * + rowHeightWithPadding + + sectionSpacing; + } +} + +// TODO(kenz): Abstract core flame chart logic for use in other flame charts. +class FrameBasedTimelineFlameChart extends StatefulWidget { + FrameBasedTimelineFlameChart( + this.data, { + @required this.height, + @required double width, + @required this.selectionProvider, + @required this.onSelection, + }) : duration = data.time.duration, + startInset = sideInset, + totalStartingWidth = width; + + final TimelineFrame data; + + final Duration duration; + + final double startInset; + + final double totalStartingWidth; + + final double height; + + final TimelineEvent Function() selectionProvider; + + final void Function(TimelineEvent event) onSelection; + + double get startingContentWidth => + totalStartingWidth - startInset - sideInset; + + @override + FrameBasedTimelineFlameChartState createState() => + FrameBasedTimelineFlameChartState(); +} + +class FrameBasedTimelineFlameChartState + extends State with AutoDisposeMixin { + static const startingScrollPosition = 0.0; + ScrollController _scrollControllerX; + ScrollController _scrollControllerY; + double scrollOffsetX = startingScrollPosition; + double scrollOffsetY = startingScrollPosition; + + List rows; + + TimelineController _controller; + + int get gpuSectionStartRow => widget.data.uiEventFlow.depth; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _controller = Controllers.of(context).timeline; + autoDispose(_controller.onSelectedTimelineEvent.listen((_) { + setState(() {}); + })); + } + + @override + void didUpdateWidget(FrameBasedTimelineFlameChart oldWidget) { + if (oldWidget.data != widget.data) { + _scrollControllerX.jumpTo(startingScrollPosition); + _scrollControllerY.jumpTo(startingScrollPosition); + } + super.didUpdateWidget(oldWidget); + } + + @override + void initState() { + super.initState(); + + // TODO(kenz): improve this so we are not rebuilding on every scroll. + _scrollControllerX = ScrollController() + ..addListener(() { + setState(() { + scrollOffsetX = _scrollControllerX.offset; + }); + }); + + _scrollControllerY = ScrollController() + ..addListener(() { + setState(() { + scrollOffsetY = _scrollControllerY.offset; + }); + }); + } + + @override + void dispose() { + _scrollControllerX.dispose(); + _scrollControllerY.dispose(); + // TODO(kenz): dispose [_controller] here. + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return Scrollbar( + child: SingleChildScrollView( + controller: _scrollControllerX, + scrollDirection: Axis.horizontal, + child: Scrollbar( + child: SingleChildScrollView( + controller: _scrollControllerY, + scrollDirection: Axis.vertical, + child: _flameChartBody(constraints), + ), + ), + ), + ); + }, ); } + + Widget _flameChartBody(BoxConstraints constraints) { + final width = math.max(constraints.maxWidth, widget.totalStartingWidth); + final height = math.max(constraints.maxHeight, widget.height); + + // TODO(kenz): rewrite this using slivers instead of a stack. + return Stack( + children: [ + Container( + width: width, + height: height, + ), + ..._nodesInViewport(constraints), // pick what to show + ], + ); + } + + List _nodesInViewport(BoxConstraints constraints) { + // TODO(kenz): is creating all the FlameChartNode objects expensive even if + // we won't add them to the view? We create all the FlameChartNode objects + // and place them in FlameChart rows, but we only add [nodesInViewport] to + // the widget tree. + _buildFlameChartElements(); + + // TODO(kenz): Use binary search method we use in html full timeline here. + final nodesInViewport = []; + for (var row in rows) { + for (var node in row.nodes) { + final fitsHorizontally = node.rect.right >= scrollOffsetX && + node.rect.left - scrollOffsetX <= constraints.maxWidth; + final fitsVertically = node.rect.bottom >= scrollOffsetY && + node.rect.top - scrollOffsetY <= constraints.maxHeight; + if (fitsHorizontally && fitsVertically) { + nodesInViewport.add(node); + } + } + } + return nodesInViewport; + } + + // TODO(kenz): when optimizing this code, consider passing in the viewport + // to only construct FlameChartNode elements that are in view. + void _buildFlameChartElements() { + _resetColorOffsets(); + + rows = List.generate( + widget.data.uiEventFlow.depth + widget.data.gpuEventFlow.depth, + (i) => FlameChartRow(nodes: [], index: i), + ); + final int frameStartOffset = widget.data.time.start.inMicroseconds; + + double getTopForRow(int row) { + // This accounts for the section spacing between the UI events and the GPU + // events. + final additionalPadding = + row >= gpuSectionStartRow ? sectionSpacing : 0.0; + return row * rowHeightWithPadding + topOffset + additionalPadding; + } + + // Pixels per microsecond in order to fit the entire frame in view. + final double pxPerMicro = + widget.startingContentWidth / widget.data.time.duration.inMicroseconds; + + // Add UI section label. + final uiSectionLabel = FlameChartNode.sectionLabel( + text: 'UI', + textColor: Colors.black, + backgroundColor: mainUiColor, + top: getTopForRow(0), + width: 28.0, + ); + rows[0].nodes.add(uiSectionLabel); + + // Add GPU section label. + final gpuSectionLabel = FlameChartNode.sectionLabel( + text: 'GPU', + textColor: Colors.white, + backgroundColor: mainGpuColor, + top: getTopForRow(gpuSectionStartRow), + width: 42.0, + ); + rows[gpuSectionStartRow].nodes.add(gpuSectionLabel); + + void createChartNodes(TimelineEvent event, int row) { + // Do not round these values. Rounding the left could cause us to have + // inaccurately placed events on the chart. Rounding the width could cause + // us to lose very small events if the width rounds to zero. + final double left = + (event.time.start.inMicroseconds - frameStartOffset) * pxPerMicro + + widget.startInset; + final double right = + (event.time.end.inMicroseconds - frameStartOffset) * pxPerMicro + + widget.startInset; + final top = getTopForRow(row); + final backgroundColor = + event.isUiEvent ? _nextUiColor() : _nextGpuColor(); + + final node = FlameChartNode( + key: Key('${event.name} ${event.time.start.inMicroseconds}'), + text: event.name, + tooltip: '${event.name} - ${msText(event.time.duration)}', + rect: Rect.fromLTRB(left, top, right, top + rowHeight), + backgroundColor: backgroundColor, + textColor: event.isUiEvent + ? ThemedColor.fromSingleColor(Colors.black) + : ThemedColor.fromSingleColor(contrastForegroundWhite), + data: event, + selected: event == widget.selectionProvider(), + onSelected: (dynamic event) => widget.onSelection(event), + ); + + rows[row].nodes.add(node); + + for (TimelineEvent child in event.children) { + createChartNodes( + child, + row + 1, + ); + } + } + + createChartNodes(widget.data.uiEventFlow, 0); + createChartNodes(widget.data.gpuEventFlow, gpuSectionStartRow); + } + + double get calculatedContentWidth { + // The farthest right node in the graph will either be the root UI event or + // the root GPU event. + return math.max(rows[gpuSectionStartRow].nodes.last.rect.right, + rows[gpuSectionStartRow].nodes.last.rect.right) - + widget.startInset; + } +} + +int _uiColorOffset = 0; + +Color _nextUiColor() { + final color = uiColorPalette[_uiColorOffset % uiColorPalette.length]; + _uiColorOffset++; + return color; +} + +int _gpuColorOffset = 0; + +Color _nextGpuColor() { + final color = gpuColorPalette[_gpuColorOffset % gpuColorPalette.length]; + _gpuColorOffset++; + return color; +} + +void _resetColorOffsets() { + _uiColorOffset = 0; + _gpuColorOffset = 0; } diff --git a/packages/devtools_app/lib/src/timeline/flutter/timeline_screen.dart b/packages/devtools_app/lib/src/timeline/flutter/timeline_screen.dart index d24d790d3ed..1c271330998 100644 --- a/packages/devtools_app/lib/src/timeline/flutter/timeline_screen.dart +++ b/packages/devtools_app/lib/src/timeline/flutter/timeline_screen.dart @@ -86,16 +86,18 @@ class TimelineScreenBodyState extends State { ), if (controller.timelineMode == TimelineMode.frameBased) const FlutterFramesChart(), - Expanded( - child: Split( - axis: Axis.vertical, - firstChild: TimelineFlameChart(), - // TODO(kenz): use StreamBuilder to get selected event from - // controller once data is hooked up. - secondChild: EventDetails(stubAsyncEvent), - initialFirstFraction: 0.6, + if (controller.timelineMode == TimelineMode.full || + controller.frameBasedTimeline.data?.selectedFrame != null) + Expanded( + child: Split( + axis: Axis.vertical, + firstChild: TimelineFlameChart(), + // TODO(kenz): use StreamBuilder to get selected event from + // controller once data is hooked up. + secondChild: EventDetails(stubAsyncEvent), + initialFirstFraction: 0.6, + ), ), - ), ], ); } diff --git a/packages/devtools_app/lib/src/ui/service_extension_elements.dart b/packages/devtools_app/lib/src/ui/service_extension_elements.dart index d1f22186ce7..a0b38677ef3 100644 --- a/packages/devtools_app/lib/src/ui/service_extension_elements.dart +++ b/packages/devtools_app/lib/src/ui/service_extension_elements.dart @@ -182,7 +182,7 @@ class RegisteredServiceExtensionButton { final RegisteredServiceDescription serviceDescription; final VoidAsyncFunction action; - final VoidFunctionWithArg errorAction; + final void Function(dynamic arg) errorAction; PButton button; void _click() async { diff --git a/packages/devtools_app/lib/src/utils.dart b/packages/devtools_app/lib/src/utils.dart index 1bf7ea3a5f9..300aec280be 100644 --- a/packages/devtools_app/lib/src/utils.dart +++ b/packages/devtools_app/lib/src/utils.dart @@ -199,10 +199,6 @@ typedef VoidFunction = void Function(); /// future. typedef VoidAsyncFunction = Future Function(); -/// A typedef to represent a function taking a single argument and with no -/// return value. -typedef VoidFunctionWithArg = void Function(dynamic arg); - /// Batch up calls to the given closure. Repeated calls to [invoke] will /// overwrite the closure to be called. We'll delay at least [minDelay] before /// calling the closure, but will not delay more than [maxDelay]. diff --git a/packages/devtools_app/test/flutter/controllers_test.dart b/packages/devtools_app/test/flutter/controllers_test.dart index 5c471f009b7..3e32d2c903e 100644 --- a/packages/devtools_app/test/flutter/controllers_test.dart +++ b/packages/devtools_app/test/flutter/controllers_test.dart @@ -19,6 +19,8 @@ void main() { setUp(() async { await ensureInspectorDependencies(); final serviceManager = FakeServiceManager(useFakeService: true); + when(serviceManager.connectedApp.isDartWebApp) + .thenAnswer((_) => Future.value(false)); setGlobal(ServiceConnectionManager, serviceManager); }); @@ -81,15 +83,3 @@ void main() { }); }); } - -class TestProvidedControllers extends Fake implements ProvidedControllers { - TestProvidedControllers() { - disposed[this] = false; - } - @override - void dispose() { - disposed[this] = true; - } -} - -final disposed = {}; diff --git a/packages/devtools_app/test/flutter/initializer_test.dart b/packages/devtools_app/test/flutter/initializer_test.dart index 8900d5c95e6..1315ef54695 100644 --- a/packages/devtools_app/test/flutter/initializer_test.dart +++ b/packages/devtools_app/test/flutter/initializer_test.dart @@ -8,6 +8,7 @@ import 'package:devtools_app/src/globals.dart'; import 'package:devtools_app/src/service_manager.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; import '../support/mocks.dart'; @@ -18,9 +19,12 @@ void main() { const Key initializedKey = Key('initialized'); setUp(() async { await ensureInspectorDependencies(); + final serviceManager = FakeServiceManager(useFakeService: true); + when(serviceManager.connectedApp.isDartWebApp) + .thenAnswer((_) => Future.value(false)); setGlobal( ServiceConnectionManager, - FakeServiceManager(useFakeService: true), + serviceManager, ); app = MaterialApp( diff --git a/packages/devtools_app/test/flutter/timeline_flame_chart_test.dart b/packages/devtools_app/test/flutter/timeline_flame_chart_test.dart new file mode 100644 index 00000000000..b7695c319ff --- /dev/null +++ b/packages/devtools_app/test/flutter/timeline_flame_chart_test.dart @@ -0,0 +1,59 @@ +// Copyright 2019 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 'package:devtools_app/src/globals.dart'; +import 'package:devtools_app/src/service_manager.dart'; +import 'package:devtools_app/src/timeline/flutter/timeline_flame_chart.dart'; +import 'package:devtools_app/src/timeline/flutter/timeline_screen.dart'; +import 'package:devtools_app/src/timeline/timeline_controller.dart'; +import 'package:devtools_testing/support/timeline_test_data.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import '../support/mocks.dart'; +import 'wrappers.dart'; + +void main() { + FakeServiceManager fakeServiceManager; + + group('TimelineFlameChart', () { + setUp(() async { + fakeServiceManager = FakeServiceManager(useFakeService: true); + setGlobal(ServiceConnectionManager, fakeServiceManager); + when(serviceManager.connectedApp.isDartWebApp) + .thenAnswer((_) => Future.value(false)); + }); + + testWidgets('builds frame based timeline', (WidgetTester tester) async { + // Set a wide enough screen width that we do not run into overflow. + await setWindowSize(const Size(1599.0, 1000.0)); + + final mockData = MockFrameBasedTimelineData(); + when(mockData.displayDepth).thenReturn(8); + when(mockData.selectedFrame).thenReturn(testFrame); + final controllerWithData = TimelineController() + ..frameBasedTimeline.data = mockData; + await tester.pumpWidget(wrapWithProvidedController( + TimelineScreenBody(), + timelineController: controllerWithData, + )); + expect(find.byType(FrameBasedTimelineFlameChart), findsOneWidget); + expect(find.text('TODO Full Timeline Flame Chart'), findsNothing); + }); + + testWidgets('builds full timeline', (WidgetTester tester) async { + // Set a wide enough screen width that we do not run into overflow. + await setWindowSize(const Size(1599.0, 1000.0)); + + await tester.pumpWidget(wrapWithProvidedController( + TimelineScreenBody(), + timelineController: TimelineController() + ..timelineMode = TimelineMode.full, + )); + expect(find.byType(FrameBasedTimelineFlameChart), findsNothing); + expect(find.text('TODO Full Timeline Flame Chart'), findsOneWidget); + }); + }); +} diff --git a/packages/devtools_app/test/flutter/timeline_screen_test.dart b/packages/devtools_app/test/flutter/timeline_screen_test.dart index dd6849dbe27..10fd9348bbf 100644 --- a/packages/devtools_app/test/flutter/timeline_screen_test.dart +++ b/packages/devtools_app/test/flutter/timeline_screen_test.dart @@ -2,6 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +@TestOn('vm') + +import 'package:devtools_app/src/flutter/initializer.dart'; import 'package:devtools_app/src/flutter/split.dart'; import 'package:devtools_app/src/globals.dart'; import 'package:devtools_app/src/service_manager.dart'; @@ -10,6 +13,8 @@ import 'package:devtools_app/src/timeline/flutter/flutter_frames_chart.dart'; import 'package:devtools_app/src/timeline/flutter/timeline_flame_chart.dart'; import 'package:devtools_app/src/timeline/flutter/timeline_screen.dart'; import 'package:devtools_app/src/timeline/timeline_controller.dart'; +import 'package:devtools_app/src/ui/fake_flutter/_real_flutter.dart'; +import 'package:devtools_testing/support/timeline_test_data.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; @@ -22,7 +27,8 @@ void main() { FakeServiceManager fakeServiceManager; group('TimelineScreen', () { - setUp(() { + setUp(() async { + await ensureInspectorDependencies(); fakeServiceManager = FakeServiceManager(useFakeService: true); setGlobal(ServiceConnectionManager, fakeServiceManager); when(serviceManager.connectedApp.isDartWebApp) @@ -35,28 +41,42 @@ void main() { expect(find.text('Timeline'), findsOneWidget); }); - testWidgets('builds proper content for timeline modes', - (WidgetTester tester) async { + testWidgets('builds proper content for state', (WidgetTester tester) async { // Set a wide enough screen width that we do not run into overflow. await setWindowSize(const Size(1599.0, 1000.0)); - await tester.pumpWidget(wrap(TimelineScreenBody())); + await tester.pumpWidget(wrapWithProvidedController( + TimelineScreenBody(), + timelineController: TimelineController(), + )); expect(find.byType(TimelineScreenBody), findsOneWidget); final TimelineScreenBodyState state = tester.state(find.byType(TimelineScreenBody)); - // Verify the state of the splitter. final splitFinder = find.byType(Split); - expect(splitFinder, findsOneWidget); - final Split splitter = tester.widget(splitFinder); - expect(splitter.initialFirstFraction, equals(0.6)); // Verify TimelineMode.frameBased content. expect(state.controller.timelineMode, equals(TimelineMode.frameBased)); + expect(splitFinder, findsNothing); expect(find.text('Pause'), findsOneWidget); expect(find.text('Resume'), findsOneWidget); expect(find.text('Record'), findsNothing); expect(find.text('Stop'), findsNothing); expect(find.byType(FlutterFramesChart), findsOneWidget); + expect(find.byType(TimelineFlameChart), findsNothing); + expect(find.byType(EventDetails), findsNothing); + + // Add a selected frame and ensure the flame chart and event details + // section appear. + final mockData = MockFrameBasedTimelineData(); + when(mockData.displayDepth).thenReturn(8); + when(mockData.selectedFrame).thenReturn(testFrame); + final controllerWithData = TimelineController() + ..frameBasedTimeline.data = mockData; + await tester.pumpWidget(wrapWithProvidedController( + TimelineScreenBody(), + timelineController: controllerWithData, + )); + expect(find.byType(FlutterFramesChart), findsOneWidget); expect(find.byType(TimelineFlameChart), findsOneWidget); expect(find.byType(EventDetails), findsOneWidget); @@ -73,6 +93,11 @@ void main() { expect(find.byType(FlutterFramesChart), findsNothing); expect(find.byType(TimelineFlameChart), findsOneWidget); expect(find.byType(EventDetails), findsOneWidget); + + // Verify the state of the splitter. + expect(splitFinder, findsOneWidget); + final Split splitter = tester.widget(splitFinder); + expect(splitter.initialFirstFraction, equals(0.6)); }); }); } diff --git a/packages/devtools_app/test/flutter/wrappers.dart b/packages/devtools_app/test/flutter/wrappers.dart index d6b20ceccf2..4321c33e68a 100644 --- a/packages/devtools_app/test/flutter/wrappers.dart +++ b/packages/devtools_app/test/flutter/wrappers.dart @@ -2,11 +2,16 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:devtools_app/src/flutter/controllers.dart'; import 'package:devtools_app/src/flutter/theme.dart'; +import 'package:devtools_app/src/logging/logging_controller.dart'; +import 'package:devtools_app/src/timeline/timeline_controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../support/mocks.dart'; + /// Wraps [widget] with the build context it needs to load in a test. /// /// This includes a [MaterialApp] to provide context like [Theme.of]. @@ -19,6 +24,27 @@ Widget wrap(Widget widget) { ); } +Widget wrapWithProvidedController( + Widget widget, { + LoggingController loggingController, + TimelineController timelineController, +}) { + return MaterialApp( + theme: themeFor(isDarkTheme: false), + home: Material( + child: Controllers.overridden( + overrideProviders: () { + return ProvidedControllers( + logging: loggingController ?? MockLoggingController(), + timeline: timelineController ?? MockTimelineController(), + ); + }, + child: widget, + ), + ), + ); +} + /// Sets the size of the app window under test to [windowSize]. /// /// This must be reset on after each test invocation that calls diff --git a/packages/devtools_app/test/support/mocks.dart b/packages/devtools_app/test/support/mocks.dart index 1d4a43c75ad..4fa2f2a0e11 100644 --- a/packages/devtools_app/test/support/mocks.dart +++ b/packages/devtools_app/test/support/mocks.dart @@ -5,9 +5,13 @@ import 'dart:async'; import 'package:devtools_app/src/connected_app.dart'; +import 'package:devtools_app/src/flutter/controllers.dart'; +import 'package:devtools_app/src/logging/logging_controller.dart'; import 'package:devtools_app/src/service_extensions.dart' as extensions; import 'package:devtools_app/src/service_manager.dart'; import 'package:devtools_app/src/stream_value_listenable.dart'; +import 'package:devtools_app/src/timeline/timeline_controller.dart'; +import 'package:devtools_app/src/timeline/timeline_model.dart'; import 'package:devtools_app/src/ui/fake_flutter/fake_flutter.dart'; import 'package:devtools_app/src/vm_service_wrapper.dart'; import 'package:meta/meta.dart'; @@ -21,6 +25,9 @@ class FakeServiceManager extends Fake implements ServiceConnectionManager { @override final VmServiceWrapper service; + @override + final Completer serviceAvailable = Completer()..complete(); + @override final ConnectedApp connectedApp = MockConnectedApp(); @@ -53,13 +60,13 @@ class FakeServiceManager extends Fake implements ServiceConnectionManager { } class FakeVmService extends Fake implements VmServiceWrapper { - final flags = { + final _flags = { 'flags': [], }; @override Future setFlag(String name, String value) { - final List flags = this.flags['flags']; + final List flags = _flags['flags']; final existingFlag = flags.firstWhere((f) => f.name == name, orElse: () => null); if (existingFlag != null) { @@ -76,7 +83,22 @@ class FakeVmService extends Fake implements VmServiceWrapper { } @override - Future getFlagList() => Future.value(FlagList.parse(flags)); + Future getFlagList() => Future.value(FlagList.parse(_flags)); + + final _vmTimelineFlags = { + 'type': 'TimelineFlags', + 'recordedStreams': [], + }; + + @override + Future setVMTimelineFlags(List recordedStreams) async { + _vmTimelineFlags['recordedStreams'] = recordedStreams; + return Future.value(Success()); + } + + @override + Future getVMTimelineFlags() => + Future.value(TimelineFlags.parse(_vmTimelineFlags)); @override Stream onEvent(String streamName) => const Stream.empty(); @@ -105,6 +127,13 @@ class MockVmService extends Mock implements VmServiceWrapper {} class MockConnectedApp extends Mock implements ConnectedApp {} +class MockLoggingController extends Mock implements LoggingController {} + +class MockTimelineController extends Mock implements TimelineController {} + +class MockFrameBasedTimelineData extends Mock + implements FrameBasedTimelineData {} + /// Fake that simplifies writing UI tests that depend on the /// ServiceExtensionManager. // TODO(jacobr): refactor ServiceExtensionManager so this fake can reuse more @@ -380,3 +409,15 @@ StreamController _getStreamController( ); return streamControllers[name]; } + +class TestProvidedControllers extends Fake implements ProvidedControllers { + TestProvidedControllers() { + disposed[this] = false; + } + @override + void dispose() { + disposed[this] = true; + } +} + +final disposed = {};