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 = ''' 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; setUp(() async { db = MewsDatabase.test(NativeDatabase.memory()); deps = AppDependencies(database: db); }); tearDown(() async { await db.close(); }); Future pumpApp(WidgetTester tester) async { final strings = Strings(); strings.loadSource(File('locales/en-US/main.ftl').readAsStringSync()); 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('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. await tester.tap(find.text('Atkinson Hyperlegible')); await tester.pumpAndSettle(); await tester.tap(find.byIcon(Icons.add)); await tester.pumpAndSettle(); await tester.tap(find.text('Dark')); await tester.pumpAndSettle(); 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. final sheet = find.byType(Scrollable).last; 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( '', '

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(); Fob fob() => tester.widget(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', '

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), ); final strings = Strings(); strings.loadSource(File('locales/en-US/main.ftl').readAsStringSync()); 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); final center = tester.getCenter(find.byType(SwipeNavigator)); final gesture = await tester.startGesture(center); await gesture.moveBy(const Offset(400, 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('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(); }