import 'dart:convert' show base64; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'dart:io' show File; import 'dart:typed_data'; import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mews/database/database.dart'; import 'package:mews/l10n/strings.dart'; import 'package:mews/mews/document.dart'; import 'package:mews/presentation/app.dart'; import 'package:mews/presentation/reader/mews_page_view.dart'; import 'package:mews/presentation/reader/palettes.dart'; Future _app() async { final strings = Strings(); await strings.load(); final deps = AppDependencies(database: MewsDatabase.test(NativeDatabase.memory())); return MewsApp(deps: deps, strings: strings); } void main() { testWidgets('starts with the address bar and its icons', (tester) async { await tester.pumpWidget(await _app()); await tester.pumpAndSettle(); expect(find.byType(TextField), findsOneWidget); expect(find.text('Enter an address'), findsOneWidget); // The hub opens from the left icon; no floating button remains. expect(find.byIcon(Icons.menu), findsOneWidget); expect(find.byType(FloatingActionButton), findsNothing); }); testWidgets('renders a page and follows links', (tester) async { final page = MewsPage( url: 'https://example.com/index.html', title: 'Test page', lang: 'en', direction: TextDirection.ltr, variant: MewsVariant.plain, specVersion: '0.1', entries: const [], body: const [ HeadingBlock(1, [MewsText('A heading')]), ParagraphBlock([MewsText('Read on for more.')]), ParagraphBlock([ MewsLink('https://example.com/next.html', [MewsText('the next page')]), ]), ], ); String? opened; await tester.pumpWidget( MaterialApp( home: StringsScope( strings: await _strings(), child: MewsPageView( page: page, palette: paletteFor( pageVariant: MewsVariant.plain, readerVariant: 'site', dark: false), fontFamily: 'Atkinson Hyperlegible', fontScale: 1.0, visitedUrls: const {}, onLinkTap: (url) => opened = url, onFormSubmit: (_, _) {}, ), ), ), ); expect(find.text('A heading'), findsOneWidget); expect(find.textContaining('the next page'), findsOneWidget); // The paragraph Text stretches the full column width, so tap on the // glyphs of the link itself, not the widget's center. final rect = tester.getRect(find.textContaining('the next page')); await tester.tapAt(rect.topLeft + const Offset(30, 13)); await tester.pump(); expect(opened, 'https://example.com/next.html'); }); testWidgets('two forms with one action stay apart', (tester) async { // A web page often carries its search box twice, once per header, both // submitting to the same address. const search = FormBlock( action: 'https://example.com/w/index.php', method: 'get', fields: [MewsTextField(name: 'search', password: false, label: 'Search')], submitLabel: 'Go', ); final page = MewsPage( url: 'https://example.com/article', lang: 'en', direction: TextDirection.ltr, variant: MewsVariant.plain, specVersion: '0.1', entries: const [], body: const [search, ParagraphBlock([MewsText('Between them.')]), search], ); await tester.pumpWidget( MaterialApp( // A form's fields are Material widgets and need a sheet to sit on. home: Scaffold( body: StringsScope( strings: await _strings(), child: MewsPageView( page: page, palette: paletteFor( pageVariant: MewsVariant.plain, readerVariant: 'site', dark: false), fontFamily: 'Atkinson Hyperlegible', fontScale: 1.0, visitedUrls: const {}, onLinkTap: (_) {}, onFormSubmit: (_, _) {}, ), ), ), ), ); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); expect(find.byType(TextField), findsNWidgets(2)); }); testWidgets('a table with ragged rows renders without holes', (tester) async { // A web page's rows rarely match: here a header row of two, a row of // three, and one spanning cell. Flutter's Table would assert on these. final page = MewsPage( url: 'https://example.com/table.html', lang: 'en', direction: TextDirection.ltr, variant: MewsVariant.plain, specVersion: '0.1', entries: const [], body: const [ TableBlock( rows: [ MewsTableRow([ MewsTableCell(header: true, inline: [MewsText('Model')]), MewsTableCell(header: true, inline: [MewsText('Price')]), ]), MewsTableRow([ MewsTableCell(header: false, inline: [MewsText('3310')]), MewsTableCell(header: false, inline: [MewsText('Ten')]), MewsTableCell(header: false, inline: [MewsText('Spare')]), ]), MewsTableRow([ MewsTableCell(header: false, inline: [MewsText('Only one')]), ]), MewsTableRow([]), ], ), ], ); await tester.pumpWidget( MaterialApp( home: StringsScope( strings: await _strings(), child: MewsPageView( page: page, palette: paletteFor( pageVariant: MewsVariant.plain, readerVariant: 'site', dark: false), fontFamily: 'Atkinson Hyperlegible', fontScale: 1.0, visitedUrls: const {}, onLinkTap: (_) {}, onFormSubmit: (_, _) {}, ), ), ), ); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); expect(find.text('Model'), findsOneWidget); expect(find.text('Spare'), findsOneWidget); expect(find.text('Only one'), findsOneWidget); }); testWidgets('Tab walks the links on a page', (tester) async { final page = MewsPage( url: 'https://example.com/index.html', lang: 'en', direction: TextDirection.ltr, variant: MewsVariant.plain, specVersion: '0.1', entries: const [], body: const [ ParagraphBlock([MewsText('Read on for more.')]), ParagraphBlock([ MewsLink('https://example.com/first.html', [MewsText('the first page')]), ]), ParagraphBlock([ MewsLink('https://example.com/second.html', [MewsText('the second page')]), ]), ], ); final opened = []; await tester.pumpWidget( MaterialApp( home: StringsScope( strings: await _strings(), child: MewsPageView( page: page, palette: paletteFor( pageVariant: MewsVariant.plain, readerVariant: 'site', dark: false), fontFamily: 'Atkinson Hyperlegible', fontScale: 1.0, visitedUrls: const {}, onLinkTap: opened.add, onFormSubmit: (_, _) {}, ), ), ), ); // Pressing the page puts the keyboard focus on it. final body = tester.getRect(find.text('Read on for more.')); await tester.tapAt(body.center); await tester.pump(); // Tab highlights each link in reading order, and Enter opens the one // highlighted. Future press(LogicalKeyboardKey key) async { await tester.sendKeyDownEvent(key); await tester.pump(); await tester.sendKeyUpEvent(key); await tester.pump(); } await press(LogicalKeyboardKey.tab); await press(LogicalKeyboardKey.enter); expect(opened, ['https://example.com/first.html']); await press(LogicalKeyboardKey.tab); await press(LogicalKeyboardKey.enter); expect(opened, ['https://example.com/first.html', 'https://example.com/second.html']); // Shift+Tab walks back, and Escape puts the highlight away, so Enter // after it opens nothing. await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); await tester.sendKeyDownEvent(LogicalKeyboardKey.tab); await tester.pump(); await tester.sendKeyUpEvent(LogicalKeyboardKey.tab); await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); await press(LogicalKeyboardKey.enter); expect(opened, ['https://example.com/first.html', 'https://example.com/second.html', 'https://example.com/first.html']); await press(LogicalKeyboardKey.escape); await press(LogicalKeyboardKey.enter); expect(opened, hasLength(3)); }); testWidgets('the scroll follows the Tab walk', (tester) async { final page = MewsPage( url: 'https://example.com/index.html', lang: 'en', direction: TextDirection.ltr, variant: MewsVariant.plain, specVersion: '0.1', entries: const [], body: [ // Enough prose to push the links below the fold. for (var i = 0; i < 40; i++) ParagraphBlock([MewsText('Paragraph $i of the tall page.')]), const ParagraphBlock([ MewsLink('https://example.com/deep.html', [MewsText('a link down below')]), ]), ], ); final scroll = ScrollController(); await tester.pumpWidget( MaterialApp( home: StringsScope( strings: await _strings(), child: MewsPageView( page: page, palette: paletteFor( pageVariant: MewsVariant.plain, readerVariant: 'site', dark: false), fontFamily: 'Atkinson Hyperlegible', fontScale: 1.0, visitedUrls: const {}, onLinkTap: (_) {}, onFormSubmit: (_, _) {}, scrollController: scroll, ), ), ), ); await tester.pumpAndSettle(); expect(scroll.offset, 0); // Pressing the page puts the keyboard focus on it, and the walk carries // the viewport along to whichever link is highlighted. final body = tester.getRect(find.text('Paragraph 0 of the tall page.')); await tester.tapAt(body.center); await tester.pump(); await tester.sendKeyDownEvent(LogicalKeyboardKey.tab); await tester.pump(); await tester.sendKeyUpEvent(LogicalKeyboardKey.tab); await tester.pumpAndSettle(); expect(scroll.offset, greaterThan(0)); }); testWidgets('an image on a capsule arrives over the protocol', (tester) async { // A 1x1 PNG, small enough to spell out. final png = Uint8List.fromList(base64.decode( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==')); final dot = MewsPage( url: 'gemini://capsule.example/dot.png', lang: 'en', direction: TextDirection.ltr, variant: MewsVariant.plain, specVersion: 'gemtext', entries: const [], body: const [ ImageBlock(src: 'gemini://capsule.example/dot.png', alt: 'A dot', allowed: true), ], ); Future load(String url) async => url == 'gemini://capsule.example/dot.png' ? png : null; await tester.pumpWidget( MaterialApp( home: StringsScope( strings: await _strings(), child: MewsPageView( page: dot, palette: paletteFor( pageVariant: MewsVariant.plain, readerVariant: 'site', dark: false), fontFamily: 'Atkinson Hyperlegible', fontScale: 1.0, visitedUrls: const {}, onLinkTap: (_) {}, onFormSubmit: (_, _) {}, fetchImageBytes: load, ), ), ), ); await tester.pumpAndSettle(); // The image itself, decoded from the protocol's bytes. expect(find.byType(Image), findsOneWidget); // An image that never arrives falls back to its alt text, the way a // missing image on the web does. final missing = MewsPage( url: 'gemini://capsule.example/none.png', lang: 'en', direction: TextDirection.ltr, variant: MewsVariant.plain, specVersion: 'gemtext', entries: const [], body: const [ ImageBlock(src: 'gemini://capsule.example/none.png', alt: 'A dot', allowed: true), ], ); await tester.pumpWidget( MaterialApp( home: StringsScope( strings: await _strings(), child: MewsPageView( page: missing, palette: paletteFor( pageVariant: MewsVariant.plain, readerVariant: 'site', dark: false), fontFamily: 'Atkinson Hyperlegible', fontScale: 1.0, visitedUrls: const {}, onLinkTap: (_) {}, onFormSubmit: (_, _) {}, fetchImageBytes: load, ), ), ), ); await tester.pumpAndSettle(); expect(find.byType(Image), findsNothing); expect(find.textContaining('could not be loaded'), findsOneWidget); }); } Future _strings() async { final strings = Strings(); strings.loadSource(File('locales/en-US/main.ftl').readAsStringSync()); return strings; }