| // 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:io' as io; |
| |
| import 'package:flutter_markdown/flutter_markdown.dart'; |
| import 'package:flutter_test/flutter_test.dart'; |
| import 'package:flutter/gestures.dart'; |
| import 'package:flutter/widgets.dart'; |
| import 'package:flutter/material.dart'; |
| import 'package:mockito/mockito.dart'; |
| |
| void main() { |
| TextTheme textTheme = new Typography(platform: TargetPlatform.android) |
| .black |
| .merge(new TextTheme(body1: new TextStyle(fontSize: 12.0))); |
| |
| testWidgets('Simple string', (WidgetTester tester) async { |
| await tester.pumpWidget(_boilerplate(const MarkdownBody(data: 'Hello'))); |
| |
| final Iterable<Widget> widgets = tester.allWidgets; |
| _expectWidgetTypes( |
| widgets, <Type>[Directionality, MarkdownBody, Column, Wrap, RichText]); |
| _expectTextStrings(widgets, <String>['Hello']); |
| }); |
| |
| testWidgets('Header', (WidgetTester tester) async { |
| await tester.pumpWidget(_boilerplate(const MarkdownBody(data: '# Header'))); |
| |
| final Iterable<Widget> widgets = tester.allWidgets; |
| _expectWidgetTypes( |
| widgets, <Type>[Directionality, MarkdownBody, Column, Wrap, RichText]); |
| _expectTextStrings(widgets, <String>['Header']); |
| }); |
| |
| testWidgets('Empty string', (WidgetTester tester) async { |
| await tester.pumpWidget(_boilerplate(const MarkdownBody(data: ''))); |
| |
| final Iterable<Widget> widgets = tester.allWidgets; |
| _expectWidgetTypes(widgets, <Type>[Directionality, MarkdownBody, Column]); |
| }); |
| |
| testWidgets('Ordered list', (WidgetTester tester) async { |
| await tester.pumpWidget(_boilerplate( |
| const MarkdownBody(data: '1. Item 1\n1. Item 2\n2. Item 3'), |
| )); |
| |
| final Iterable<Widget> widgets = tester.allWidgets; |
| _expectTextStrings(widgets, <String>[ |
| '1.', |
| 'Item 1', |
| '2.', |
| 'Item 2', |
| '3.', |
| 'Item 3', |
| ]); |
| }); |
| |
| testWidgets('Unordered list', (WidgetTester tester) async { |
| await tester.pumpWidget( |
| _boilerplate(const MarkdownBody(data: '- Item 1\n- Item 2\n- Item 3')), |
| ); |
| |
| final Iterable<Widget> widgets = tester.allWidgets; |
| _expectTextStrings(widgets, <String>[ |
| '•', |
| 'Item 1', |
| '•', |
| 'Item 2', |
| '•', |
| 'Item 3', |
| ]); |
| }); |
| |
| testWidgets('Horizontal Rule', (WidgetTester tester) async { |
| await tester.pumpWidget(_boilerplate(const MarkdownBody(data: '-----'))); |
| |
| final Iterable<Widget> widgets = tester.allWidgets; |
| _expectWidgetTypes( |
| widgets, <Type>[Directionality, MarkdownBody, DecoratedBox, SizedBox]); |
| }); |
| |
| testWidgets('Scrollable wrapping', (WidgetTester tester) async { |
| await tester.pumpWidget(_boilerplate(const Markdown(data: ''))); |
| |
| final List<Widget> widgets = tester.allWidgets.toList(); |
| _expectWidgetTypes(widgets.take(3), <Type>[ |
| Directionality, |
| Markdown, |
| ListView, |
| ]); |
| _expectWidgetTypes(widgets.reversed.take(2).toList().reversed, <Type>[ |
| SliverPadding, |
| SliverList, |
| ]); |
| }); |
| |
| group('Links', () { |
| testWidgets('should be tappable', (WidgetTester tester) async { |
| String tapResult; |
| await tester.pumpWidget(_boilerplate(new Markdown( |
| data: '[Link Text](href)', |
| onTapLink: (value) => tapResult = value, |
| ))); |
| |
| final RichText textWidget = |
| tester.allWidgets.firstWhere((Widget widget) => widget is RichText); |
| final TextSpan span = textWidget.text; |
| |
| (span.recognizer as TapGestureRecognizer).onTap(); |
| |
| expect(span.children, null); |
| expect(span.recognizer.runtimeType, equals(TapGestureRecognizer)); |
| expect(tapResult, 'href'); |
| }); |
| |
| testWidgets('should work with nested elements', (WidgetTester tester) async { |
| final List<String> tapResults = <String>[]; |
| await tester.pumpWidget(_boilerplate(new Markdown( |
| data: '[Link `with nested code` Text](href)', |
| onTapLink: (value) => tapResults.add(value), |
| ))); |
| |
| final RichText textWidget = |
| tester.allWidgets.firstWhere((Widget widget) => widget is RichText); |
| final TextSpan span = textWidget.text; |
| |
| final List<Type> gestureRecognizerTypes = <Type>[]; |
| span.visitTextSpan((TextSpan textSpan) { |
| TapGestureRecognizer recognizer = textSpan.recognizer; |
| gestureRecognizerTypes.add(recognizer.runtimeType); |
| recognizer.onTap(); |
| return true; |
| }); |
| |
| expect(span.children.length, 3); |
| expect(gestureRecognizerTypes.length, 3); |
| expect(gestureRecognizerTypes, everyElement(TapGestureRecognizer)); |
| expect(tapResults.length, 3); |
| expect(tapResults, everyElement('href')); |
| }); |
| |
| testWidgets('should work next to other links', (WidgetTester tester) async { |
| final List<String> tapResults = <String>[]; |
| |
| await tester.pumpWidget(_boilerplate(new Markdown( |
| data: '[First Link](firstHref) and [Second Link](secondHref)', |
| onTapLink: (value) => tapResults.add(value), |
| ))); |
| |
| final RichText textWidget = |
| tester.allWidgets.firstWhere((Widget widget) => widget is RichText); |
| final TextSpan span = textWidget.text; |
| |
| final List<Type> gestureRecognizerTypes = <Type>[]; |
| span.visitTextSpan((TextSpan textSpan) { |
| TapGestureRecognizer recognizer = textSpan.recognizer; |
| gestureRecognizerTypes.add(recognizer.runtimeType); |
| recognizer?.onTap(); |
| return true; |
| }); |
| |
| expect(span.children.length, 3); |
| expect(gestureRecognizerTypes, |
| orderedEquals([TapGestureRecognizer, Null, TapGestureRecognizer])); |
| expect(tapResults, orderedEquals(['firstHref', 'secondHref'])); |
| }); |
| }); |
| |
| group('Images', () { |
| setUp(() { |
| // Only needs to be done once since the HttpClient generated by this |
| // override is cached as a static singleton. |
| io.HttpOverrides.global = new TestHttpOverrides(); |
| }); |
| |
| testWidgets('should not interrupt styling', (WidgetTester tester) async { |
| await tester.pumpWidget(_boilerplate(const Markdown( |
| data:'_textbefore  textafter_', |
| ))); |
| |
| final RichText firstTextWidget = |
| tester.allWidgets.firstWhere((Widget widget) => widget is RichText); |
| final Image image = |
| tester.allWidgets.firstWhere((Widget widget) => widget is Image); |
| final NetworkImage networkImage = image.image; |
| final RichText secondTextWidget = |
| tester.allWidgets.lastWhere((Widget widget) => widget is RichText); |
| |
| expect(firstTextWidget.text.text, 'textbefore '); |
| expect(firstTextWidget.text.style.fontStyle, FontStyle.italic); |
| expect(networkImage.url,'http://img'); |
| expect(secondTextWidget.text.text, ' textafter'); |
| expect(secondTextWidget.text.style.fontStyle, FontStyle.italic); |
| }); |
| |
| testWidgets('should work with a link', (WidgetTester tester) async { |
| await tester |
| .pumpWidget(_boilerplate(const Markdown(data: ''))); |
| |
| final Image image = |
| tester.allWidgets.firstWhere((Widget widget) => widget is Image); |
| final NetworkImage networkImage = image.image; |
| expect(networkImage.url, 'https://img'); |
| expect(image.width, 50); |
| expect(image.height, 50); |
| }); |
| |
| testWidgets('local files should be files', (WidgetTester tester) async { |
| await tester |
| .pumpWidget(_boilerplate(const Markdown(data: ''))); |
| |
| final Image image = |
| tester.allWidgets.firstWhere((Widget widget) => widget is Image); |
| expect(image.image is FileImage, isTrue); |
| }); |
| |
| testWidgets('should work with resources', (WidgetTester tester) async { |
| await tester.pumpWidget(_boilerplate( |
| const Markdown(data: ''))); |
| |
| final Image image = |
| tester.allWidgets.firstWhere((Widget widget) => widget is Image); |
| expect(image.image is AssetImage, isTrue); |
| expect((image.image as AssetImage).assetName == 'assets/logo.png', isTrue); |
| }); |
| |
| testWidgets('should work with local image files', (WidgetTester tester) async { |
| await tester |
| .pumpWidget(_boilerplate(const Markdown(data: ''))); |
| |
| final Image image = |
| tester.allWidgets.firstWhere((Widget widget) => widget is Image); |
| final FileImage fileImage = image.image; |
| expect(fileImage.file.path, 'img.png'); |
| expect(image.width, 50); |
| expect(image.height, 50); |
| }); |
| |
| testWidgets('should show properly next to text', (WidgetTester tester) async { |
| await tester |
| .pumpWidget(_boilerplate(const Markdown(data: 'Hello '))); |
| |
| final RichText richText = |
| tester.allWidgets.firstWhere((Widget widget) => widget is RichText); |
| TextSpan textSpan = richText.text; |
| expect(textSpan.text, 'Hello '); |
| expect(textSpan.style, isNotNull); |
| }); |
| |
| testWidgets('should work when nested in a link', (WidgetTester tester) async { |
| final List<String> tapResults = <String>[]; |
| await tester.pumpWidget(_boilerplate(new Markdown( |
| data: '[](href)', |
| onTapLink: (value) => tapResults.add(value), |
| ))); |
| |
| final GestureDetector detector = |
| tester.allWidgets.firstWhere((Widget widget) => widget is GestureDetector); |
| |
| detector.onTap(); |
| |
| expect(tapResults.length, 1); |
| expect(tapResults, everyElement('href')); |
| }); |
| |
| testWidgets('should work when nested in a link with text', (WidgetTester tester) async { |
| final List<String> tapResults = <String>[]; |
| await tester.pumpWidget(_boilerplate(new Markdown( |
| data: '[Text before  text after](href)', |
| onTapLink: (value) => tapResults.add(value), |
| ))); |
| |
| final GestureDetector detector = |
| tester.allWidgets.firstWhere((Widget widget) => widget is GestureDetector); |
| detector.onTap(); |
| |
| final RichText firstTextWidget = |
| tester.allWidgets.firstWhere((Widget widget) => widget is RichText); |
| final TextSpan firstSpan = firstTextWidget.text; |
| (firstSpan.recognizer as TapGestureRecognizer).onTap(); |
| |
| final RichText lastTextWidget = |
| tester.allWidgets.lastWhere((Widget widget) => widget is RichText); |
| final TextSpan lastSpan = lastTextWidget.text; |
| (lastSpan.recognizer as TapGestureRecognizer).onTap(); |
| |
| expect(firstSpan.children, null); |
| expect(firstSpan.text, 'Text before '); |
| expect(firstSpan.recognizer.runtimeType, equals(TapGestureRecognizer)); |
| |
| expect(lastSpan.children, null); |
| expect(lastSpan.text, ' text after'); |
| expect(lastSpan.recognizer.runtimeType, equals(TapGestureRecognizer)); |
| |
| expect(tapResults.length, 3); |
| expect(tapResults, everyElement('href')); |
| }); |
| |
| testWidgets('should work alongside different links', (WidgetTester tester) async { |
| final List<String> tapResults = <String>[]; |
| await tester.pumpWidget(_boilerplate(new Markdown( |
| data: '[Link before](firstHref)[](imageHref)[link after](secondHref)', |
| onTapLink: (value) => tapResults.add(value), |
| ))); |
| |
| final RichText firstTextWidget = |
| tester.allWidgets.firstWhere((Widget widget) => widget is RichText); |
| final TextSpan firstSpan = firstTextWidget.text; |
| (firstSpan.recognizer as TapGestureRecognizer).onTap(); |
| |
| final GestureDetector detector = |
| tester.allWidgets.firstWhere((Widget widget) => widget is GestureDetector); |
| detector.onTap(); |
| |
| final RichText lastTextWidget = |
| tester.allWidgets.lastWhere((Widget widget) => widget is RichText); |
| final TextSpan lastSpan = lastTextWidget.text; |
| (lastSpan.recognizer as TapGestureRecognizer).onTap(); |
| |
| expect(firstSpan.children, null); |
| expect(firstSpan.text, 'Link before'); |
| expect(firstSpan.recognizer.runtimeType, equals(TapGestureRecognizer)); |
| |
| expect(lastSpan.children, null); |
| expect(lastSpan.text, 'link after'); |
| expect(lastSpan.recognizer.runtimeType, equals(TapGestureRecognizer)); |
| |
| expect(tapResults.length, 3); |
| expect(tapResults, ['firstHref', 'imageHref', 'secondHref']); |
| }); |
| }); |
| |
| group('uri data scheme', () { |
| testWidgets('should work with image in uri data scheme', (WidgetTester tester) async { |
| const String imageData = ''; |
| await tester |
| .pumpWidget(_boilerplate(const Markdown(data: imageData))); |
| |
| final Image image = |
| tester.allWidgets.firstWhere((Widget widget) => widget is Image); |
| expect(image.image.runtimeType, MemoryImage); |
| }); |
| |
| testWidgets('should work with base64 text in uri data scheme', (WidgetTester tester) async { |
| const String imageData = ''; |
| await tester |
| .pumpWidget(_boilerplate(const Markdown(data: imageData))); |
| |
| final Text widget = |
| tester.allWidgets.firstWhere((Widget widget) => widget is Text); |
| expect(widget.runtimeType, Text); |
| expect(widget.data, 'Flutter'); |
| }); |
| |
| testWidgets('should work with text in uri data scheme', (WidgetTester tester) async { |
| const String imageData = ''; |
| await tester |
| .pumpWidget(_boilerplate(const Markdown(data: imageData))); |
| |
| final Text widget = |
| tester.allWidgets.firstWhere((Widget widget) => widget is Text); |
| expect(widget.runtimeType, Text); |
| expect(widget.data, 'Hello, Flutter'); |
| }); |
| |
| testWidgets('should work with empty uri data scheme', (WidgetTester tester) async { |
| const String imageData = ''; |
| await tester |
| .pumpWidget(_boilerplate(const Markdown(data: imageData))); |
| |
| final Text widget = |
| tester.allWidgets.firstWhere((Widget widget) => widget is Text); |
| expect(widget.runtimeType, Text); |
| expect(widget.data, ''); |
| }); |
| |
| testWidgets('should work with unsupported mime types of uri data scheme', (WidgetTester tester) async { |
| const String imageData = ''; |
| await tester |
| .pumpWidget(_boilerplate(const Markdown(data: imageData))); |
| |
| final SizedBox widget = |
| tester.allWidgets.firstWhere((Widget widget) => widget is SizedBox); |
| expect(widget.runtimeType, SizedBox); |
| }); |
| }); |
| |
| testWidgets('HTML tag ignored ', (WidgetTester tester) async { |
| final List<String> mdData = <String>[ |
| 'Line 1\n<p>HTML content</p>\nLine 2', |
| 'Line 1\n<!-- HTML\n comment\n ignored --><\nLine 2' |
| ]; |
| |
| for (String mdLine in mdData) { |
| await tester.pumpWidget(_boilerplate(new MarkdownBody(data: mdLine))); |
| |
| final Iterable<Widget> widgets = tester.allWidgets; |
| _expectTextStrings(widgets, <String>['Line 1', 'Line 2']); |
| } |
| }); |
| |
| group('Parser does not convert', () { |
| testWidgets('& to & when parsing', (WidgetTester tester) async { |
| await tester.pumpWidget(_boilerplate(const Markdown(data: '&'))); |
| _expectTextStrings(tester.allWidgets, <String>['&']); |
| }); |
| |
| testWidgets('< to < when parsing', (WidgetTester tester) async { |
| await tester.pumpWidget(_boilerplate(const Markdown(data: '<'))); |
| _expectTextStrings(tester.allWidgets, <String>['<']); |
| }); |
| |
| testWidgets('existing HTML entities when parsing', (WidgetTester tester) async { |
| await tester.pumpWidget(_boilerplate(const Markdown(data: '& © < {'))); |
| _expectTextStrings(tester.allWidgets, <String>['& © < {']); |
| }); |
| }); |
| |
| testWidgets('Changing config - data', (WidgetTester tester) async { |
| await tester.pumpWidget(_boilerplate(const Markdown(data: 'Data1'))); |
| _expectTextStrings(tester.allWidgets, <String>['Data1']); |
| |
| final String stateBefore = _dumpRenderView(); |
| await tester.pumpWidget(_boilerplate(const Markdown(data: 'Data1'))); |
| final String stateAfter = _dumpRenderView(); |
| expect(stateBefore, equals(stateAfter)); |
| |
| await tester.pumpWidget(_boilerplate(const Markdown(data: 'Data2'))); |
| _expectTextStrings(tester.allWidgets, <String>['Data2']); |
| }); |
| |
| testWidgets('Changing config - style', (WidgetTester tester) async { |
| final ThemeData theme = new ThemeData.light().copyWith(textTheme: textTheme); |
| |
| final MarkdownStyleSheet style1 = new MarkdownStyleSheet.fromTheme(theme); |
| final MarkdownStyleSheet style2 = |
| new MarkdownStyleSheet.largeFromTheme(theme); |
| expect(style1, isNot(style2)); |
| |
| await tester.pumpWidget( |
| _boilerplate(new Markdown(data: '# Test', styleSheet: style1)), |
| ); |
| final RichText text1 = tester.widget(find.byType(RichText)); |
| await tester.pumpWidget( |
| _boilerplate(new Markdown(data: '# Test', styleSheet: style2)), |
| ); |
| final RichText text2 = tester.widget(find.byType(RichText)); |
| |
| expect(text1.text, isNot(text2.text)); |
| }); |
| |
| testWidgets('Style equality', (WidgetTester tester) async { |
| final ThemeData theme = new ThemeData.light().copyWith(textTheme: textTheme); |
| |
| final MarkdownStyleSheet style1 = new MarkdownStyleSheet.fromTheme(theme); |
| final MarkdownStyleSheet style2 = new MarkdownStyleSheet.fromTheme(theme); |
| expect(style1, equals(style2)); |
| expect(style1.hashCode, equals(style2.hashCode)); |
| }); |
| } |
| |
| void _expectWidgetTypes(Iterable<Widget> widgets, List<Type> expected) { |
| final List<Type> actual = widgets.map((Widget w) => w.runtimeType).toList(); |
| expect(actual, expected); |
| } |
| |
| void _expectTextStrings(Iterable<Widget> widgets, List<String> strings) { |
| int currentString = 0; |
| for (Widget widget in widgets) { |
| if (widget is RichText) { |
| final TextSpan span = widget.text; |
| final String text = _extractTextFromTextSpan(span); |
| expect(text, equals(strings[currentString])); |
| currentString += 1; |
| } |
| } |
| } |
| |
| String _extractTextFromTextSpan(TextSpan span) { |
| String text = span.text ?? ''; |
| if (span.children != null) { |
| for (TextSpan child in span.children) { |
| text += _extractTextFromTextSpan(child); |
| } |
| } |
| return text; |
| } |
| |
| String _dumpRenderView() { |
| return WidgetsBinding.instance.renderViewElement.toStringDeep().replaceAll( |
| new RegExp(r'SliverChildListDelegate#\d+', multiLine: true), |
| 'SliverChildListDelegate'); |
| } |
| |
| /// Wraps a widget with a left-to-right [Directionality] for tests. |
| Widget _boilerplate(Widget child) { |
| return new Directionality( |
| textDirection: TextDirection.ltr, |
| child: child, |
| ); |
| } |
| |
| class MockHttpClient extends Mock implements io.HttpClient {} |
| class MockHttpClientRequest extends Mock implements io.HttpClientRequest {} |
| class MockHttpClientResponse extends Mock implements io.HttpClientResponse {} |
| class MockHttpHeaders extends Mock implements io.HttpHeaders {} |
| |
| class TestHttpOverrides extends io.HttpOverrides { |
| io.HttpClient createHttpClient(io.SecurityContext context) { |
| return createMockImageHttpClient(context); |
| } |
| } |
| |
| // Returns a mock HTTP client that responds with an image to all requests. |
| MockHttpClient createMockImageHttpClient(io.SecurityContext _) { |
| final MockHttpClient client = new MockHttpClient(); |
| final MockHttpClientRequest request = new MockHttpClientRequest(); |
| final MockHttpClientResponse response = new MockHttpClientResponse(); |
| final MockHttpHeaders headers = new MockHttpHeaders(); |
| |
| when(client.getUrl(any)).thenAnswer((_) => new Future<io.HttpClientRequest>.value(request)); |
| when(request.headers).thenReturn(headers); |
| when(request.close()).thenAnswer((_) => new Future<io.HttpClientResponse>.value(response)); |
| when(response.contentLength).thenReturn(_transparentImage.length); |
| when(response.statusCode).thenReturn(io.HttpStatus.ok); |
| when(response.listen(any)).thenAnswer((Invocation invocation) { |
| final void Function(List<int>) onData = invocation.positionalArguments[0]; |
| final void Function() onDone = invocation.namedArguments[#onDone]; |
| final void Function(Object, [StackTrace]) onError = invocation.namedArguments[#onError]; |
| final bool cancelOnError = invocation.namedArguments[#cancelOnError]; |
| |
| return new Stream<List<int>>.fromIterable(<List<int>>[_transparentImage]) |
| .listen(onData, onDone: onDone, onError: onError, cancelOnError: cancelOnError); |
| }); |
| |
| return client; |
| } |
| |
| const List<int> _transparentImage = const <int>[ |
| 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, |
| 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, |
| 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, |
| 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, |
| 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, |
| ]; |