the-mews/app/test/app_widget_test.dart

424 lines
16 KiB
Dart
Raw Normal View History

import 'dart:io' show File;
import 'package:actors/actors.dart';
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mews/application/actors/fetch_actor.dart';
import 'package:mews/application/controllers/browser_controller.dart'
show BrokenPage, LoadFailed;
import 'package:mews/application/controllers/reader_settings_controller.dart'
show ReaderSettingsController;
import 'package:mews/database/database.dart';
import 'package:mews/l10n/strings.dart';
import 'package:mews/mews/document.dart';
import 'package:mews/mews/parser.dart';
import 'package:mews/presentation/app.dart';
import 'package:mews/presentation/reader/address_bar.dart';
import 'package:mews/presentation/reader/fob.dart';
import 'package:mews/presentation/reader/reader_screen.dart';
import 'package:mews/presentation/reader/settings_sheet.dart';
import 'package:mews/presentation/reader/swipe_navigator.dart';
const pageSource = '''
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>Signal Bars</title>
<meta name="mews-profile" content="0.1" />
</head>
<body class="mews-green">
<h1>Signal Bars</h1>
<p>Notes on old phones.</p>
<ul>
<li><a href="2026-10-08-retro.html">2026-10-08 Collecting retro phones</a></li>
<li><a href="2026-09-21-first.html">2026-09-21 First post</a></li>
</ul>
<form action="/sign" method="post">
<label for="name">Your name</label>
<input type="text" id="name" name="name" />
<input type="submit" value="Sign guestbook" />
</form>
</body>
</html>
''';
/// Serves pages from an in-memory map without network or isolates, so
/// navigation tests are deterministic. Cached pages parse back from their
/// cached source, like the real actor does for revalidation.
class _FakeFetch implements Actor<FetchCommand, FetchResult> {
_FakeFetch(this.sources);
final Map<String, String> sources;
@override
Future<FetchResult> send(FetchCommand message) async {
return switch (message) {
ParseSource(:final url, :final source) => _parse(url, source),
FetchPage(:final url, :final cachedSource) => cachedSource != null
? _parse(url, cachedSource)
: sources[url] != null
? _parse(url, sources[url]!)
: const FetchNetworkError(),
_ => const FetchNetworkError(),
};
}
FetchResult _parse(String url, String source) {
try {
return FetchPageOk(page: parseMewsPage(source: source, url: url), source: source);
} on MewsParseException catch (e) {
return FetchParseError(e.message, source);
}
}
@override
Future<void> close() async {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
String _page(String title, String body) => '''
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>$title</title><meta name="mews-profile" content="0.1" /></head>
<body>$body</body>
</html>
''';
void main() {
late MewsDatabase db;
late AppDependencies deps;
late Strings strings;
setUp(() async {
db = MewsDatabase.test(NativeDatabase.memory());
strings = Strings();
strings.loadSource(File('locales/en-US/main.ftl').readAsStringSync());
deps = AppDependencies(database: db, strings: strings);
});
tearDown(() async {
await db.close();
});
/// Pumps the full app for [deps]; library pages get real strings.
Future<void> pumpApp(WidgetTester tester) async {
await tester.pumpWidget(MewsApp(deps: deps, strings: strings));
await tester.pumpAndSettle();
}
testWidgets('renders a page through the full app', (tester) async {
await pumpApp(tester);
final page = parseSource(pageSource, 'https://example.com/index.html');
deps.browser.currentPage.value = page;
await tester.pumpAndSettle();
expect(find.text('Signal Bars'), findsWidgets);
expect(find.textContaining('Collecting retro phones'), findsOneWidget);
expect(find.textContaining('Sign guestbook'), findsOneWidget);
});
testWidgets('shows the broken-page error and its text view', (tester) async {
await pumpApp(tester);
deps.browser.currentPage.value = null;
deps.browser.error.value = const BrokenPage('<p>Half a page &amp; more');
await tester.pumpAndSettle();
expect(find.textContaining('markup error'), findsOneWidget);
await tester.tap(find.textContaining('Show page text'));
await tester.pumpAndSettle();
expect(find.textContaining('Half a page & more'), findsOneWidget);
});
testWidgets('shows the load-failed error page', (tester) async {
await pumpApp(tester);
deps.browser.error.value = const LoadFailed();
await tester.pumpAndSettle();
expect(find.textContaining('could not be loaded'), findsOneWidget);
expect(find.textContaining('Try again'), findsOneWidget);
});
testWidgets('settings sheet changes presentation and toggles library',
(tester) async {
await pumpApp(tester);
final page = parseSource(pageSource, 'https://example.com/index.html');
deps.browser.currentPage.value = page;
await tester.pumpAndSettle();
showReaderSettingsSheet(tester.element(find.byType(ReaderScreen)));
await tester.pumpAndSettle();
// Typeface, size, theme, colors; the lower sections need scrolling.
final sheet = find.byType(Scrollable).last;
await tester.tap(find.text('Atkinson Hyperlegible'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(find.text('Dark'), 200, scrollable: sheet);
await tester.tap(find.text('Dark'));
await tester.pumpAndSettle();
await tester.scrollUntilVisible(find.text('Warm'), 200, scrollable: sheet);
await tester.tap(find.text('Warm'));
await tester.pumpAndSettle();
expect(deps.settings.fontScale.value, 1.125);
expect(deps.settings.themeMode.value, 'dark');
expect(deps.settings.variant.value, 'warm');
// Bookmark and subscribe the current page; both tiles sit at the bottom
// of the sheet, so scroll the sheet down to the end first.
await tester.drag(sheet, const Offset(0, -1000));
await tester.pumpAndSettle();
await tester.tap(find.text('Add bookmark'));
await tester.pumpAndSettle();
expect(deps.library.isBookmarked('https://example.com/index.html'), isTrue);
await tester.tap(find.text('Subscribe to this site'));
await tester.pumpAndSettle();
expect(deps.library.isSubscribed('https://example.com/index.html'), isTrue);
// Settings survive a reload of the controller.
final reloaded = ReaderSettingsController(db);
await reloaded.load();
expect(reloaded.themeMode.value, 'dark');
});
testWidgets('the FOB hides on scroll down and returns on scroll up',
(tester) async {
await pumpApp(tester);
// A page long enough to scroll.
final tall = pageSource.replaceFirst(
'</body>',
'<p>Padding paragraph to make the page scroll.</p>' * 80 + '</body>');
final page = parseSource(tall, 'https://example.com/index.html');
deps.browser.currentPage.value = page;
await tester.pumpAndSettle();
Fob fob() => tester.widget<Fob>(find.byType(Fob));
expect(fob().visible, isTrue);
// Scrolling down hides the address bar and the FOB.
await tester.drag(find.byType(ListView), const Offset(0, -300));
await tester.pumpAndSettle();
expect(fob().visible, isFalse);
expect(find.byType(AddressBar), findsNothing);
// Scrolling up brings both back.
await tester.drag(find.byType(ListView), const Offset(0, 300));
await tester.pumpAndSettle();
expect(fob().visible, isTrue);
expect(find.byType(AddressBar), findsOneWidget);
});
testWidgets('swiping back shows the previous page during the drag',
(tester) async {
final sources = {
'https://example.com/a.html':
_page('Page A', '<h1>Page A</h1><p>The first pages content.</p>'),
'https://example.com/b.html':
_page('Page B', '<h1>Page B</h1><p>The second pages content.</p>'),
};
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: _FakeFetch(sources),
strings: strings,
);
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
await tester.pumpAndSettle();
await stubDeps.browser.open('https://example.com/a.html');
await stubDeps.browser.open('https://example.com/b.html');
// The swipe neighbor is parsed in the background.
await _pumpUntil(tester, () => stubDeps.browser.backPage.value != null);
// Swipes begin at the screen edge; mid-page drags select text. Small
// steps keep the drag recognizer's update pacing in the test environment.
final center = tester.getCenter(find.byType(SwipeNavigator));
final gesture = await tester.startGesture(Offset(10, center.dy));
for (var i = 0; i < 6; i++) {
await gesture.moveBy(const Offset(70, 0));
await tester.pump();
}
// The real previous page is on screen mid-swipe, not a placeholder.
expect(find.text('Page A'), findsOneWidget);
expect(find.textContaining('The first pages content'), findsOneWidget);
await gesture.up();
await tester.pumpAndSettle();
expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/a.html');
// Settled on the page itself; the address bar follows.
expect(find.textContaining('The first pages content'), findsOneWidget);
expect(find.text('https://example.com/a.html'), findsOneWidget);
});
testWidgets('the chrome follows the selected colors', (tester) async {
await pumpApp(tester);
await deps.settings.setThemeMode('light');
await deps.settings.setVariant('warm');
await tester.pumpAndSettle();
MaterialApp app() => tester.widget<MaterialApp>(find.byType(MaterialApp));
// Warm light palette on the light theme, FOB included.
expect(app().theme!.colorScheme.surface, const Color(0xfffbf4e8));
expect(app().theme!.colorScheme.primary, const Color(0xff8a3d14));
expect(app().theme!.floatingActionButtonTheme.backgroundColor,
const Color(0xff8a3d14));
expect(app().theme!.textSelectionTheme.selectionColor,
const Color(0xff8a3d14).withValues(alpha: 0.3));
await deps.settings.setThemeMode('dark');
await deps.settings.setVariant('green');
await tester.pumpAndSettle();
// Green dark palette on the dark theme.
expect(app().darkTheme!.colorScheme.surface, const Color(0xff161b16));
expect(app().darkTheme!.colorScheme.primary, const Color(0xff9fd39a));
expect(app().darkTheme!.floatingActionButtonTheme.backgroundColor,
const Color(0xff9fd39a));
});
testWidgets('page text is selectable', (tester) async {
await pumpApp(tester);
final page = parseSource(pageSource, 'https://example.com/index.html');
deps.browser.currentPage.value = page;
await tester.pumpAndSettle();
expect(find.byType(SelectionArea), findsOneWidget);
});
testWidgets('falls back to the wap subdomain before showing an error',
(tester) async {
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: _FakeFetch({
'https://wap.example.com/':
_page('Wap site', '<h1>Wap site</h1><p>Content lives here.</p>'),
}),
strings: strings,
);
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
await tester.pumpAndSettle();
// The address as typed has no page; the wap. equivalent does.
await stubDeps.browser.open('https://example.com/');
expect(stubDeps.browser.currentPage.value?.url, 'https://wap.example.com/');
await tester.pumpAndSettle();
expect(find.textContaining('Content lives here'), findsOneWidget);
// Addresses that work nowhere keep showing the error.
await stubDeps.browser.open('https://missing.example.org/');
expect(stubDeps.browser.currentPage.value, isNull);
expect(stubDeps.browser.error.value, isA<LoadFailed>());
});
testWidgets('library pages render as Mews pages and join the walk history',
(tester) async {
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: _FakeFetch({
'https://example.com/a.html':
_page('Page A', '<h1>Page A</h1><p>The first page.</p>'),
'https://example.com/b.html':
_page('Page B', '<h1>Page B</h1><p>The second page.</p>'),
}),
strings: strings,
);
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
await tester.pumpAndSettle();
await stubDeps.browser.open('https://example.com/a.html');
await stubDeps.browser.open('https://example.com/b.html');
await stubDeps.browser.open('mews:history');
await tester.pumpAndSettle();
// Rendered like any Mews page, with dated links to both visits.
expect(stubDeps.browser.currentPage.value?.url, 'mews:history');
expect(find.text('History'), findsOneWidget);
expect(find.textContaining('Page A'), findsOneWidget);
expect(find.textContaining('Page B'), findsOneWidget);
// Part of the walk: back returns to page B, forward to history again.
await stubDeps.browser.back();
await tester.pumpAndSettle();
expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/b.html');
await stubDeps.browser.forward();
await tester.pumpAndSettle();
expect(stubDeps.browser.currentPage.value?.url, 'mews:history');
// A history link navigates like any other link.
await tester.tap(find.textContaining('Page A'));
await tester.pumpAndSettle();
expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/a.html');
// Bookmarks and subscriptions get the same treatment.
await stubDeps.library.load();
await stubDeps.library.toggleBookmark('https://example.com/b.html', 'Page B');
await stubDeps.library.toggleSubscription(
'https://example.com/a.html', 'Page A', const []);
await stubDeps.browser.open('mews:bookmarks');
await tester.pumpAndSettle();
expect(find.text('Bookmarks'), findsOneWidget);
expect(find.textContaining('Page B'), findsOneWidget);
await stubDeps.browser.open('mews:subscriptions');
await tester.pumpAndSettle();
expect(find.text('Subscriptions'), findsOneWidget);
expect(find.textContaining('Page A'), findsOneWidget);
});
testWidgets('the settings sheet opens the library pages', (tester) async {
await pumpApp(tester);
showReaderSettingsSheet(tester.element(find.byType(ReaderScreen)));
await tester.pumpAndSettle();
await tester.tap(find.text('History'));
await tester.pumpAndSettle();
expect(deps.browser.currentPage.value?.url, 'mews:history');
expect(find.text('History'), findsOneWidget);
});
testWidgets('reports new posts once per page', (tester) async {
await pumpApp(tester);
final page = parseSource(pageSource, 'https://example.com/index.html');
deps.library.subscribedUrls.value = {'https://example.com/index.html'};
deps.library.newEntries.value = {
'https://example.com/index.html': [
MewsEntry(
url: 'https://example.com/2026-10-08-retro.html',
date: DateTime(2026, 10, 8),
title: 'Collecting retro phones'),
],
};
deps.browser.currentPage.value = page;
await tester.pumpAndSettle();
expect(find.textContaining('1 new post'), findsOneWidget);
});
}
MewsPage parseSource(String source, String url) =>
parseMewsPage(source: source, url: url);
/// Pumps in short bursts until [condition] holds; fails the test on timeout.
Future<void> _pumpUntil(WidgetTester tester, bool Function() condition,
{Duration timeout = const Duration(seconds: 5)}) async {
final end = DateTime.now().add(timeout);
while (!condition()) {
if (DateTime.now().isAfter(end)) {
fail('condition not met within timeout');
}
await tester.pump(const Duration(milliseconds: 20));
}
await tester.pumpAndSettle();
}