1068 lines
43 KiB
Dart
1068 lines
43 KiB
Dart
import 'dart:io' show File;
|
|
|
|
import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride;
|
|
import 'package:flutter/services.dart' show LogicalKeyboardKey;
|
|
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/mews_page_view.dart' show searchTypingPause;
|
|
import 'package:mews/presentation/reader/swipe_navigator.dart';
|
|
|
|
import 'fake_fetch.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>
|
|
''';
|
|
|
|
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>
|
|
''';
|
|
|
|
/// Long enough for a search box to have narrowed its page.
|
|
final pastTypingPause = searchTypingPause + const Duration(milliseconds: 50);
|
|
|
|
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<void> 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<void> tapRun(WidgetTester tester, String label) async {
|
|
final paragraph = tester.renderObject<RenderParagraph>(
|
|
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('<p>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('a first start gives the directory bookmark once', (tester) async {
|
|
final db = MewsDatabase.test(NativeDatabase.memory());
|
|
addTearDown(db.close);
|
|
|
|
// The first start of the app wires in the directory of sites, named in
|
|
// the reader's language.
|
|
var deps = AppDependencies(
|
|
database: db, fetch: FakeFetch(const {}), strings: strings);
|
|
await deps.start();
|
|
expect(await db.allBookmarks(), hasLength(1));
|
|
final bookmark = (await db.allBookmarks()).single;
|
|
expect(bookmark.url, 'https://mews.page/directory');
|
|
expect(bookmark.title, 'Mews directory');
|
|
expect(deps.bookmarks.isBookmarked(bookmark.url), isTrue);
|
|
|
|
// The reader removes it, as any bookmark can be removed.
|
|
await deps.bookmarks.toggle(bookmark.url, bookmark.title);
|
|
expect(deps.bookmarks.isBookmarked(bookmark.url), isFalse);
|
|
|
|
// A later start does not give it again: what the reader removed stays
|
|
// removed.
|
|
deps = AppDependencies(
|
|
database: db, fetch: FakeFetch(const {}), strings: strings);
|
|
await deps.start();
|
|
expect(await db.allBookmarks(), isEmpty);
|
|
});
|
|
|
|
testWidgets('a post a refresh finds arrives unread on the posts page',
|
|
(tester) async {
|
|
// The site's index, as gemtext with dated links (spec 6.1).
|
|
var index = '''
|
|
# A capsule
|
|
|
|
=> a.gmi 2026-10-08 Post a
|
|
=> b.gmi 2026-09-21 Post b
|
|
''';
|
|
final sources = {'gemini://capsule.example': index};
|
|
final stubDeps = AppDependencies(
|
|
database: MewsDatabase.test(NativeDatabase.memory()),
|
|
fetch: FakeFetch(sources),
|
|
strings: strings,
|
|
);
|
|
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Subscribing takes on what the site already published as read.
|
|
await stubDeps.browser.open('gemini://capsule.example');
|
|
await tester.pumpAndSettle();
|
|
await stubDeps.subscriptions.toggle(stubDeps.browser.currentPage.value!);
|
|
|
|
// The site publishes a new post, and a refresh finds it.
|
|
sources['gemini://capsule.example'] = '''
|
|
# A capsule
|
|
|
|
=> a.gmi 2026-10-08 Post a
|
|
=> b.gmi 2026-09-21 Post b
|
|
=> c.gmi 2026-10-11 New post
|
|
''';
|
|
await stubDeps.subscriptions.refreshAll();
|
|
|
|
// On the posts page the new post carries weight and the back catalogue
|
|
// does not, which is how the page says unread.
|
|
await stubDeps.browser.open('mews:posts');
|
|
await tester.pumpAndSettle();
|
|
|
|
// Whether a row's glyphs carry bold weight somewhere in their spans.
|
|
// TextSpan.visitChildren walks whole subtrees, so this walks the children
|
|
// itself, one level per step.
|
|
bool bold(String label) {
|
|
final text = tester.widget<Text>(find.textContaining(label).first);
|
|
InlineSpan? span = text.textSpan;
|
|
bool weight(InlineSpan span) {
|
|
if (span is TextSpan && span.style?.fontWeight == FontWeight.bold) {
|
|
return true;
|
|
}
|
|
final children = span is TextSpan ? span.children : null;
|
|
if (children == null) return false;
|
|
for (final child in children) {
|
|
if (weight(child)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
return span != null && weight(span);
|
|
}
|
|
|
|
expect(bold('New post'), isTrue, reason: 'a found post is unread');
|
|
expect(bold('Post a'), isFalse, reason: 'the back catalogue was read');
|
|
expect(find.textContaining('1 unread post'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('a web page that will not load can open in a web browser',
|
|
(tester) async {
|
|
final stubDeps = AppDependencies(
|
|
database: MewsDatabase.test(NativeDatabase.memory()),
|
|
fetch: FakeFetch(const {}),
|
|
strings: strings,
|
|
);
|
|
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
|
|
await tester.pumpAndSettle();
|
|
|
|
// A web address that answers nothing: the error offers the way out,
|
|
// since the web can be shown outside this reader.
|
|
await stubDeps.browser.open('https://missing.example.org/');
|
|
await tester.pumpAndSettle();
|
|
expect(stubDeps.browser.error.value, isA<LoadFailed>());
|
|
expect(find.text('Open in a web browser'), findsOneWidget);
|
|
|
|
// A protocol page has nowhere else to go, so only trying again is
|
|
// offered there.
|
|
await stubDeps.browser.open('gemini://missing.example.org/');
|
|
await tester.pumpAndSettle();
|
|
expect(stubDeps.browser.error.value, isA<LoadFailed>());
|
|
expect(find.text('Open in a web browser'), findsNothing);
|
|
});
|
|
|
|
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<void> 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',
|
|
'<h1>Evil</h1><p><a href="mews:do/set-theme?value=dark">Go</a></p>'),
|
|
}),
|
|
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<void> 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<LoadFailed>());
|
|
});
|
|
|
|
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', '<h1>Plain</h1><p>No entries here.</p>'),
|
|
'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(
|
|
'</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();
|
|
|
|
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', '<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('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. 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', '<h1>Page A</h1><p>The first page.</p>'),
|
|
'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', '<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>'),
|
|
}),
|
|
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('the address bar stays on screen on pointer hosts', (tester) async {
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
|
|
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();
|
|
|
|
// Scrolling down leaves the bar where it is: a pointer host has keys to
|
|
// reach it with, so it never hides.
|
|
await tester.drag(find.byType(ListView), const Offset(0, -300));
|
|
await tester.pumpAndSettle();
|
|
expect(find.byType(AddressBar), findsOneWidget);
|
|
|
|
debugDefaultTargetPlatformOverride = null;
|
|
});
|
|
|
|
testWidgets('the keyboard drives the reader on pointer hosts', (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 pages content.</p>'),
|
|
'https://example.com/b.html':
|
|
_page('Page B', '<h1>Page B</h1><p>The second pages content.</p>'),
|
|
}),
|
|
strings: strings,
|
|
);
|
|
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
|
|
|
|
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 tester.pumpAndSettle();
|
|
|
|
// Alt+Left goes back through the walk, Alt+Right forward again.
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft);
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft);
|
|
await tester.pumpAndSettle();
|
|
expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/a.html');
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft);
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
|
|
await tester.pumpAndSettle();
|
|
expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/b.html');
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft);
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
|
|
|
|
// The Mac spellings walk the same way.
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft);
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft);
|
|
await tester.pumpAndSettle();
|
|
expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/a.html');
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.bracketRight);
|
|
await tester.pumpAndSettle();
|
|
expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/b.html');
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft);
|
|
await tester.sendKeyUpEvent(LogicalKeyboardKey.bracketRight);
|
|
|
|
// Ctrl+L hands the focus to the address field, with its text selected.
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
|
|
await tester.sendKeyDownEvent(LogicalKeyboardKey.keyL);
|
|
await tester.pump();
|
|
final field = tester.widget<TextField>(find.byType(TextField).first);
|
|
expect(field.focusNode!.hasFocus, isTrue);
|
|
|
|
// 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 the search box
|
|
// of the rebuilt page with the one they are typing in. The search here
|
|
// matches both addresses, so the list is still the whole of it.
|
|
await tester.enterText(find.byType(TextFormField), 'exam');
|
|
await tester.pump(pastTypingPause);
|
|
await tester.pumpAndSettle();
|
|
final stepsBack = deps.browser.canGoBack;
|
|
await tapRun(tester, 'Remove bookmark');
|
|
expect(find.widgetWithText(TextFormField, 'exam'), findsOneWidget);
|
|
expect(deps.browser.currentPage.value?.url, 'mews:bookmarks?q=exam');
|
|
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('a search box narrows its page as the reader types',
|
|
(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();
|
|
final stepsBack = deps.browser.canGoBack;
|
|
|
|
// Typing narrows the list without the box being submitted, and the query
|
|
// lands in the address as it does on a submit.
|
|
await tester.enterText(find.byType(TextFormField), 'retro');
|
|
await tester.pump(pastTypingPause);
|
|
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);
|
|
|
|
// The box keeps what was typed, and the cursor with it, so the next
|
|
// letter carries on from where the reader left off.
|
|
expect(find.widgetWithText(TextFormField, 'retro'), findsOneWidget);
|
|
await tester.enterText(find.byType(TextFormField), 'signal');
|
|
await tester.pump(pastTypingPause);
|
|
await tester.pumpAndSettle();
|
|
expect(deps.browser.currentPage.value?.url, 'mews:bookmarks?q=signal');
|
|
expect(find.textContaining('Signal bars'), findsOneWidget);
|
|
|
|
// A narrowed search replaces the step of the walk the reader stands on,
|
|
// so two searches are not two steps back.
|
|
expect(deps.browser.canGoBack, stepsBack);
|
|
|
|
// Clearing the search empties the box too, since the page set it.
|
|
await tapRun(tester, 'Clear search');
|
|
await tester.pumpAndSettle();
|
|
expect(deps.browser.currentPage.value?.url, MewsUrls.bookmarks);
|
|
expect(find.widgetWithText(TextFormField, 'signal'), findsNothing);
|
|
expect(find.textContaining('Retro phones'), findsOneWidget);
|
|
expect(find.textContaining('Signal bars'), findsOneWidget);
|
|
});
|
|
|
|
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<List<String>> postTitles() async {
|
|
final titles = <String>[];
|
|
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', '<h1>A post from Beta</h1>');
|
|
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 = '''
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
<head><title>Signal Bars</title><meta name="mews-profile" content="0.1" /></head>
|
|
<body>
|
|
<h1>Signal Bars</h1>
|
|
<ul>
|
|
<li><a href="2026-10-09-new.html">2026-10-09 A newer post</a></li>
|
|
<li><a href="2026-10-08-retro.html">2026-10-08 Collecting retro phones</a></li>
|
|
</ul>
|
|
</body>
|
|
</html>
|
|
''';
|
|
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<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();
|
|
}
|
|
|
|
/// 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) => '''
|
|
<?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>
|
|
<h1>$title</h1>
|
|
<ul>
|
|
${entries.map((e) => ' <li><a href="${e.$2}">${e.$1} ${e.$3}</a></li>').join('\n')}
|
|
</ul>
|
|
</body>
|
|
</html>
|
|
''';
|