import 'dart:io' show File; import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride; import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show RenderParagraph; import 'package:flutter_test/flutter_test.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/pages/mews_urls.dart'; import 'package:mews/presentation/app.dart'; import 'package:mews/presentation/reader/address_bar.dart'; import 'package:mews/presentation/reader/swipe_navigator.dart'; import 'fake_fetch.dart'; const pageSource = ''' Signal Bars

Signal Bars

Notes on old phones.

'''; 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, fetch: FakeFetch(const {}), 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(); } /// Taps the run reading [label] inside a rendered paragraph. Internal pages /// put several links in one paragraph, so tapping the widget's center would /// hit whichever run happens to sit in the middle. Future tapRun(WidgetTester tester, String label) async { final paragraph = tester.renderObject( find .descendant( of: find.byWidgetPredicate((widget) => widget is Text && (widget.textSpan?.toPlainText() ?? widget.data ?? '') .contains(label)), matching: find.byType(RichText), ) .first, ); final plain = paragraph.text.toPlainText(); final start = plain.indexOf(label); final box = paragraph .getBoxesForSelection(TextSelection( baseOffset: start, extentOffset: start + label.length)) .first; await tester.tapAt(paragraph.localToGlobal( Offset(box.left + 2, (box.top + box.bottom) / 2))); 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 hub opens the settings page, which applies a choice in place', (tester) async { await pumpApp(tester); // The hub opens from the left address bar icon. await tester.tap(find.byIcon(Icons.menu)); await tester.pumpAndSettle(); expect(deps.browser.currentPage.value?.url, MewsUrls.hub); expect(find.text('Mews'), findsOneWidget); expect(find.textContaining('No bookmarks yet'), findsOneWidget); // Its menu reaches settings, which carries the strip of our own pages. await tapRun(tester, 'Settings'); expect(deps.browser.currentPage.value?.url, MewsUrls.settings); expect(find.text('Settings'), findsOneWidget); expect(find.text('Typeface'), findsOneWidget); expect(find.text('Theme'), findsOneWidget); expect(find.text('Colors'), findsOneWidget); // The buried Library section is gone; the strip replaced it. expect(find.text('Library'), findsNothing); 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(); } // Choosing a value applies it and stays put: no new walk history entry. await scrollTo(find.text('Dark')); await tapRun(tester, 'Dark'); expect(deps.settings.themeMode.value, 'dark'); expect(deps.browser.currentPage.value?.url, MewsUrls.settings); expect(deps.browser.canGoBack, isTrue); final beforeSize = deps.browser.currentPage.value; await scrollTo(find.text('Warm')); await tapRun(tester, 'Warm'); expect(deps.settings.variant.value, 'warm'); await scrollTo(find.text('Larger text')); await tapRun(tester, 'Larger text'); expect(deps.settings.fontScale.value, 1.125); expect(deps.browser.currentPage.value, isNot(same(beforeSize))); // The strip navigates and joins the walk history. await scrollTo(find.textContaining('Bookmarks')); await tapRun(tester, 'History'); expect(deps.browser.currentPage.value?.url, MewsUrls.history); await deps.browser.back(); await tester.pumpAndSettle(); expect(deps.browser.currentPage.value?.url, MewsUrls.settings); }); testWidgets('a fetched page cannot run an action link', (tester) async { final stubDeps = AppDependencies( database: MewsDatabase.test(NativeDatabase.memory()), fetch: FakeFetch({ 'https://example.com/evil.html': _page('Evil', '

Evil

Go

'), }), strings: strings, ); await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings)); await tester.pumpAndSettle(); await stubDeps.browser.open('https://example.com/evil.html'); await tester.pumpAndSettle(); expect(stubDeps.settings.themeMode.value, 'system'); // Following the link from a fetched page changes nothing and goes nowhere // (spec 8.1: page content must not act for the reader). await tapRun(tester, 'Go'); expect(stubDeps.settings.themeMode.value, 'system'); expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/evil.html'); // The same link works from one of our own pages. await stubDeps.browser.open(MewsUrls.settings); await stubDeps.browser.open('mews:do/set-theme?value=dark'); expect(stubDeps.settings.themeMode.value, 'dark'); expect(stubDeps.browser.currentPage.value?.url, MewsUrls.settings); }); testWidgets('a form on one of our pages navigates instead of fetching', (tester) async { await pumpApp(tester); // The fetch client cannot speak the mews scheme, so a form on an internal // page must be routed before it ever reaches the actor. await deps.browser.open(MewsUrls.bookmarks); await deps.browser.submitForm(MewsUrls.bookmarks, 'get', {'q': 'retro'}); await tester.pumpAndSettle(); expect(deps.browser.currentPage.value?.url, 'mews:bookmarks?q=retro'); expect(deps.browser.error.value, isNull); // A form whose action is an action link applies it in place. await deps.browser.open(MewsUrls.settings); await deps.browser.submitForm('mews:do/set-theme', 'get', {'value': 'dark'}); await tester.pumpAndSettle(); expect(deps.settings.themeMode.value, 'dark'); expect(deps.browser.currentPage.value?.url, MewsUrls.settings); expect(deps.browser.error.value, isNull); }); testWidgets('choosing a language changes the interface in place', (tester) async { await pumpApp(tester); await deps.browser.open(MewsUrls.settings); await tester.pumpAndSettle(); expect(find.text('Settings'), findsOneWidget); // The language group sits below the fold. Future scrollTo(String label) async { final target = find.textContaining(label); for (var i = 0; i < 30 && target.evaluate().isEmpty; i++) { await tester.drag(find.byType(ListView), const Offset(0, -200)); await tester.pumpAndSettle(); } await tester.ensureVisible(target.first); await tester.pumpAndSettle(); } // Each language is named in its own words, so it can be found from an // interface the reader cannot read. await scrollTo('Deutsch'); final stepsBack = deps.browser.canGoBack; await tapRun(tester, 'Deutsch'); expect(deps.settings.language.value, 'de'); // Applied in place, like the other reader choices. expect(deps.browser.currentPage.value?.url, MewsUrls.settings); expect(deps.browser.canGoBack, stepsBack); expect(find.text('Einstellungen'), findsOneWidget); expect(find.text('Settings'), findsNothing); // Hebrew turns the whole page around. await scrollTo('עברית'); await tapRun(tester, 'עברית'); expect(deps.settings.language.value, 'he'); expect(deps.browser.currentPage.value?.direction, TextDirection.rtl); // Back to English. await scrollTo('English'); await tapRun(tester, 'English'); expect(deps.settings.language.value, 'en-US'); expect(deps.browser.currentPage.value?.direction, TextDirection.ltr); expect(find.text('Settings'), findsOneWidget); }); testWidgets('bare mews: and an unknown name resolve predictably', (tester) async { await pumpApp(tester); await deps.browser.open('mews:'); await tester.pumpAndSettle(); expect(deps.browser.currentPage.value?.url, MewsUrls.hub); await deps.browser.open('mews:nonsense'); await tester.pumpAndSettle(); expect(deps.browser.currentPage.value, isNull); expect(deps.browser.error.value, isA()); }); testWidgets('a session with no history starts at the hub', (tester) async { await pumpApp(tester); await deps.browser.restoreLastSession(); await tester.pumpAndSettle(); expect(deps.browser.currentPage.value?.url, MewsUrls.hub); }); 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.subscriptions.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.bookmarks.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. Rows carry their own // actions now, so tap the title itself rather than the row's middle. await tapRun(tester, 'Page A'); expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/a.html'); // Bookmarks and subscriptions get the same treatment. await stubDeps.bookmarks.load(); await stubDeps.subscriptions.load(); await stubDeps.bookmarks.toggle('https://example.com/b.html', 'Page B'); await stubDeps.subscriptions.toggle(parseSource( _page('Page A', '

Page A

The first page.

'), 'https://example.com/a.html')); 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('bookmarks can be searched and removed from their own page', (tester) async { await pumpApp(tester); await deps.bookmarks.load(); await deps.bookmarks.toggle('https://a.example/retro.html', 'Retro phones'); await deps.bookmarks.toggle('https://b.example/signal.html', 'Signal bars'); await deps.browser.open(MewsUrls.bookmarks); await tester.pumpAndSettle(); expect(find.textContaining('Retro phones'), findsOneWidget); expect(find.textContaining('Signal bars'), findsOneWidget); // Searching narrows the list and the choice lands in the address. await tester.enterText(find.byType(TextFormField), 'retro'); await tester.tap(find.widgetWithText(FilledButton, 'Search')); await tester.pumpAndSettle(); expect(deps.browser.currentPage.value?.url, 'mews:bookmarks?q=retro'); expect(find.textContaining('Retro phones'), findsOneWidget); expect(find.textContaining('Signal bars'), findsNothing); // A search with no matches says so rather than looking empty. await deps.browser.open('mews:bookmarks?q=nothinghere'); await tester.pumpAndSettle(); expect(find.textContaining('No bookmarks match'), findsOneWidget); // Clearing the search brings the whole list back. await deps.browser.open('mews:bookmarks?q=retro'); await tester.pumpAndSettle(); await tapRun(tester, 'Clear search'); expect(deps.browser.currentPage.value?.url, MewsUrls.bookmarks); expect(find.textContaining('Signal bars'), findsOneWidget); // Removing one stays on the page and adds no walk history entry. What the // reader has typed survives, because the renderer matches form state by // position and the search box keeps its place in the page. await tester.enterText(find.byType(TextFormField), 'half typed'); final stepsBack = deps.browser.canGoBack; await tapRun(tester, 'Remove bookmark'); expect(find.widgetWithText(TextFormField, 'half typed'), findsOneWidget); expect(deps.browser.currentPage.value?.url, MewsUrls.bookmarks); expect(deps.browser.canGoBack, stepsBack); expect(find.textContaining('Retro phones'), findsNothing); expect(find.textContaining('Signal bars'), findsOneWidget); expect(deps.bookmarks.isBookmarked('https://a.example/retro.html'), isFalse); }); testWidgets('tags are made by using them and filter the list', (tester) async { await pumpApp(tester); await deps.bookmarks.load(); await deps.bookmarks.toggle('https://a.example/retro.html', 'Retro gear'); await deps.bookmarks.toggle('https://b.example/signal.html', 'Signal bars'); // Nothing is tagged yet. await deps.browser.open(MewsUrls.tags); await tester.pumpAndSettle(); expect(find.textContaining('Tags you add'), findsOneWidget); // Tagging reaches the item from its own row. await deps.browser.open(MewsUrls.bookmarks); await tester.pumpAndSettle(); await tapRun(tester, 'Edit tags'); expect(find.textContaining('Tags for'), findsWidgets); // Adding a tag that does not exist yet is how one is made. await tester.enterText(find.byType(TextFormField), 'phones'); await tester.tap(find.widgetWithText(FilledButton, 'Add tag')); await tester.pumpAndSettle(); expect(find.textContaining('On this item'), findsOneWidget); expect(await deps.db.tagCounts(), contains('phones')); // An empty submit does nothing. await tester.enterText(find.byType(TextFormField), ' '); await tester.tap(find.widgetWithText(FilledButton, 'Add tag')); await tester.pumpAndSettle(); expect((await deps.db.tagCounts()).keys, ['phones']); // The tag now shows on the row and narrows the list when followed. await deps.browser.open(MewsUrls.bookmarks); await tester.pumpAndSettle(); await tapRun(tester, 'phones'); expect(deps.browser.currentPage.value?.url, 'mews:bookmarks?tag=phones'); expect(find.textContaining('Showing bookmarks tagged phones'), findsOneWidget); expect(find.textContaining('Signal bars'), findsNothing); await tapRun(tester, 'Show all bookmarks'); expect(deps.browser.currentPage.value?.url, MewsUrls.bookmarks); expect(find.textContaining('Signal bars'), findsOneWidget); // The index counts what carries the tag. await deps.browser.open(MewsUrls.tags); await tester.pumpAndSettle(); expect(find.textContaining('1 bookmark'), findsOneWidget); // Deleting it everywhere leaves no trace, because a tag only exists while // something carries it. await tapRun(tester, 'Delete this tag everywhere'); expect(find.textContaining('Tags you add'), findsOneWidget); expect(await deps.db.tagCounts(), isEmpty); expect(await deps.db.tagsOf(TagKind.bookmark, 'https://a.example/retro.html'), isEmpty); }); testWidgets('clearing history takes a second step', (tester) async { await pumpApp(tester); await deps.db.recordVisit('https://a.example/one.html', 'Page one'); await deps.db.recordVisit('https://a.example/one.html', 'Page one'); await deps.db.recordVisit('https://b.example/two.html', 'Page two'); await deps.browser.open(MewsUrls.history); await tester.pumpAndSettle(); // A page visited twice is listed once. expect(find.textContaining('Page one'), findsOneWidget); expect(find.textContaining('Page two'), findsOneWidget); // Backing out of the confirmation leaves history alone. await tapRun(tester, 'Clear history'); expect(deps.browser.currentPage.value?.url, 'mews:history?confirm=clear'); expect(find.textContaining("can't undo"), findsOneWidget); await tapRun(tester, 'Keep my history'); expect(deps.browser.currentPage.value?.url, MewsUrls.history); expect(find.textContaining('Page one'), findsOneWidget); // Removing one page leaves the other. The most recent visit heads the // list, so the first row is page two. await tapRun(tester, 'Remove from history'); await tester.pumpAndSettle(); expect(find.textContaining('Page two'), findsNothing); expect(find.textContaining('Page one'), findsOneWidget); // Confirming clears the rest. await tapRun(tester, 'Clear history'); await tapRun(tester, 'Clear history now'); expect(find.textContaining('Pages you visit appear here'), findsOneWidget); }); testWidgets('a site can be left from the subscriptions page', (tester) async { await pumpApp(tester); await deps.subscriptions.load(); await deps.subscriptions .toggle(parseSource(pageSource, 'https://example.com/index.html')); await deps.browser.open(MewsUrls.subscriptions); await tester.pumpAndSettle(); expect(find.textContaining('Signal Bars'), findsOneWidget); await tapRun(tester, 'Stop subscribing'); expect(deps.browser.currentPage.value?.url, MewsUrls.subscriptions); expect(deps.subscriptions.isSubscribed('https://example.com/index.html'), isFalse); expect(find.textContaining('Sites you subscribe to'), findsOneWidget); }); testWidgets('the posts page lists, orders and filters posts', (tester) async { const siteA = 'https://a.example/'; const siteB = 'https://b.example/'; final sources = { siteA: _entriesPage('Alpha', const []), siteB: _entriesPage('Beta', const []), }; 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.subscriptions.load(); await stubDeps.subscriptions.toggle(parseSource(sources[siteA]!, siteA)); await stubDeps.subscriptions.toggle(parseSource(sources[siteB]!, siteB)); // Both sites publish after the reader subscribed, so both posts are new. sources[siteA] = _entriesPage('Alpha', const [ ('2026-10-10', 'newer.html', 'A post from Alpha'), ]); sources[siteB] = _entriesPage('Beta', const [ ('2026-10-11', 'newest.html', 'A post from Beta'), ]); await stubDeps.subscriptions.refreshAll(); await stubDeps.browser.open(MewsUrls.posts); await tester.pumpAndSettle(); expect(find.textContaining('2 unread posts'), findsOneWidget); expect(find.textContaining('A post from Alpha'), findsOneWidget); expect(find.textContaining('A post from Beta'), findsOneWidget); // Newest first by default: Beta's post is dated a day later. Future> postTitles() async { final titles = []; for (final element in find.byType(RichText).evaluate()) { final text = (element.renderObject as RenderParagraph).text.toPlainText(); if (text.contains('A post from')) { titles.add(text.contains('Beta') ? 'Beta' : 'Alpha'); } } return titles; } expect(await postTitles(), ['Beta', 'Alpha']); // Ordering by site puts Alpha first, and the address carries the choice. await tapRun(tester, 'Site'); expect(stubDeps.browser.currentPage.value?.url, 'mews:posts?sort=source'); expect(await postTitles(), ['Alpha', 'Beta']); // Back undoes the ordering, because it is part of the address. await stubDeps.browser.back(); await tester.pumpAndSettle(); expect(stubDeps.browser.currentPage.value?.url, MewsUrls.posts); expect(await postTitles(), ['Beta', 'Alpha']); // Opening a post reads it, and the unread filter then hides it. sources['${siteB}newest.html'] = _page('A post from Beta', '

A post from Beta

'); await tapRun(tester, 'A post from Beta'); expect(stubDeps.browser.currentPage.value?.url, '${siteB}newest.html'); await stubDeps.browser.open('mews:posts?show=unread'); await tester.pumpAndSettle(); expect(find.textContaining('1 unread post'), findsOneWidget); expect(await postTitles(), ['Alpha']); // Marking it unread again brings it back. await stubDeps.browser.open('mews:posts'); await tester.pumpAndSettle(); await tapRun(tester, 'Mark as unread'); expect(find.textContaining('2 unread posts'), findsOneWidget); }); testWidgets('new posts are counted on the subscriptions page', (tester) async { await pumpApp(tester); // Subscribing takes on what is already published without flagging it. final page = parseSource(pageSource, 'https://example.com/index.html'); await deps.subscriptions.toggle(page); await deps.browser.open(MewsUrls.subscriptions); await tester.pumpAndSettle(); expect(find.textContaining('unread post'), findsNothing); // Opening the page later, with a post that was not there before, counts // it; the count comes from the database, so it outlives the session. const laterSource = ''' Signal Bars

Signal Bars

'''; await deps.subscriptions .refreshSite(parseSource(laterSource, 'https://example.com/index.html')); await deps.browser.open(MewsUrls.subscriptions); await tester.pumpAndSettle(); expect(find.textContaining('1 unread 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(); } /// A Mews page whose list holds dated links (spec 6.1), one per entry given as /// (date, file name, title). String _entriesPage(String title, List<(String, String, String)> entries) => ''' $title

$title

''';