the-mews/app/test/reader_widget_test.dart

298 lines
9.7 KiB
Dart
Raw Normal View History

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<MewsApp> _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 = <String>[];
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<void> 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<Uint8List?> 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> _strings() async {
final strings = Strings();
strings.loadSource(File('locales/en-US/main.ftl').readAsStringSync());
return strings;
}