import 'dart:io' show File; import 'package:actors/actors.dart'; import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride; 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/database/database.dart'; import 'package:mews/l10n/strings.dart'; import 'package:mews/mews/document.dart'; import 'package:mews/mews/parser.dart'; import 'package:mews/application/internal_pages.dart'; import 'package:mews/presentation/app.dart'; import 'package:mews/presentation/reader/address_bar.dart'; import 'package:mews/presentation/reader/swipe_navigator.dart'; const pageSource = ''' Signal Bars

Signal Bars

Notes on old phones.

'''; /// 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 { _FakeFetch(this.sources); final Map sources; @override Future 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 close() async {} @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } String _page(String title, String body) => ''' $title $body '''; 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 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('

Half a page & 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('the settings page changes presentation and keeps its place', (tester) async { await pumpApp(tester); // Settings opens from the left address bar icon. await tester.tap(find.byIcon(Icons.tune)); await tester.pumpAndSettle(); expect(deps.browser.currentPage.value?.url, InternalPages.settingsUrl); // Rendered like a Mews page with the option groups. expect(find.text('Settings'), findsOneWidget); expect(find.text('Typeface'), findsOneWidget); expect(find.text('Theme'), findsOneWidget); expect(find.text('Colors'), findsOneWidget); expect(find.text('Library'), findsOneWidget); // Tapping a theme link applies it and stays on the settings page; the // lower sections scroll into view first. Future scrollTo(Finder target) async { // Search down the page, then back up for targets above. for (var i = 0; i < 20 && target.evaluate().isEmpty; i++) { await tester.drag(find.byType(ListView), const Offset(0, -200)); await tester.pumpAndSettle(); } for (var i = 0; i < 40 && target.evaluate().isEmpty; i++) { await tester.drag(find.byType(ListView), const Offset(0, 200)); await tester.pumpAndSettle(); } await tester.ensureVisible(target); await tester.pumpAndSettle(); } // The option rows stretch the full column width, so tap on the glyphs // themselves rather than the widget's center. Future tapLink(String label) async { final rect = tester.getRect(find.text(label)); await tester.tapAt(rect.topLeft + const Offset(30, 13)); await tester.pumpAndSettle(); } await scrollTo(find.text('Dark')); await tapLink('Dark'); expect(deps.settings.themeMode.value, 'dark'); expect(deps.browser.currentPage.value?.url, InternalPages.settingsUrl); expect(deps.browser.canGoBack, isFalse); await scrollTo(find.text('Warm')); await tapLink('Warm'); expect(deps.settings.variant.value, 'warm'); await scrollTo(find.text('Larger text')); await tapLink('Larger text'); expect(deps.settings.fontScale.value, 1.125); // The library links navigate and join the walk history. await scrollTo(find.text('History')); await tapLink('History'); await tester.pumpAndSettle(); expect(deps.browser.currentPage.value?.url, InternalPages.historyUrl); expect(deps.browser.canGoBack, isTrue); await deps.browser.back(); await tester.pumpAndSettle(); expect(deps.browser.currentPage.value?.url, InternalPages.settingsUrl); }); testWidgets('bookmark and subscribe live on the address bar', (tester) async { await pumpApp(tester); // A page with dated entries can be subscribed to. final page = parseSource(pageSource, 'https://example.com/index.html'); deps.browser.currentPage.value = page; await tester.pumpAndSettle(); expect(find.byIcon(Icons.notifications_outlined), findsOneWidget); await tester.tap(find.byIcon(Icons.notifications_outlined)); await tester.pumpAndSettle(); expect(deps.library.isSubscribed('https://example.com/index.html'), isTrue); expect(find.byIcon(Icons.notifications_active), findsOneWidget); expect(find.byIcon(Icons.star_border), findsOneWidget); await tester.tap(find.byIcon(Icons.star_border)); await tester.pumpAndSettle(); expect(deps.library.isBookmarked('https://example.com/index.html'), isTrue); expect(find.byIcon(Icons.star), findsOneWidget); // Pages with neither Atom feed nor dated links offer no subscription. final plain = parseSource( _page('Plain', '

Plain

No entries here.

'), 'https://example.com/plain.html'); deps.browser.currentPage.value = plain; await tester.pumpAndSettle(); expect(find.byIcon(Icons.notifications_outlined), findsNothing); }); testWidgets('the address bar hides on scroll down and returns on scroll up', (tester) async { await pumpApp(tester); // A page long enough to scroll. final tall = pageSource.replaceFirst( '', '

Padding paragraph to make the page scroll.

' * 80 + ''); final page = parseSource(tall, 'https://example.com/index.html'); deps.browser.currentPage.value = page; await tester.pumpAndSettle(); expect(find.byType(AddressBar), findsOneWidget); // Scrolling down hides the address bar. await tester.drag(find.byType(ListView), const Offset(0, -300)); await tester.pumpAndSettle(); expect(find.byType(AddressBar), findsNothing); // Scrolling up brings it back. await tester.drag(find.byType(ListView), const Offset(0, 300)); await tester.pumpAndSettle(); 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', '

Page A

The first pages content.

'), 'https://example.com/b.html': _page('Page B', '

Page B

The second pages content.

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

Wap site

Content lives here.

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

Page A

The first page.

'), 'https://example.com/b.html': _page('Page B', '

Page B

The second page.

'), }), 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('prev and next buttons navigate on pointer platforms', (tester) async { final stubDeps = AppDependencies( database: MewsDatabase.test(NativeDatabase.memory()), fetch: _FakeFetch({ 'https://example.com/a.html': _page('Page A', '

Page A

The first pages content.

'), 'https://example.com/b.html': _page('Page B', '

Page B

The second pages content.

'), }), strings: strings, ); // Test bindings default to android; a Linux host shows the buttons. debugDefaultTargetPlatformOverride = TargetPlatform.linux; await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings)); await tester.pumpAndSettle(); final back = find.byIcon(Icons.arrow_back); final forward = find.byIcon(Icons.arrow_forward); expect(back, findsOneWidget); expect(forward, findsOneWidget); await stubDeps.browser.open('https://example.com/a.html'); await stubDeps.browser.open('https://example.com/b.html'); await tester.pumpAndSettle(); await tester.tap(back); await tester.pumpAndSettle(); expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/a.html'); await tester.tap(forward); await tester.pumpAndSettle(); expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/b.html'); // The invariant check runs before test tear-downs, so reset by hand. debugDefaultTargetPlatformOverride = null; }); 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 _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(); }