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('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; }