feat: check every subscription in the background and add the posts page

This commit is contained in:
randogoth 2026-10-11 13:20:00 +03:00
parent 953ae798eb
commit 1b426ccd8b
15 changed files with 688 additions and 58 deletions

View file

@ -1,13 +1,11 @@
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/rendering.dart' show RenderParagraph;
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';
@ -19,6 +17,8 @@ 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 = '''
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
@ -42,42 +42,6 @@ const pageSource = '''
</html>
''';
/// Serves pages from an in-memory map without network or isolates, so
/// navigation tests are deterministic. Cached pages parse back from their
/// cached source, like the real actor does for revalidation.
class _FakeFetch implements Actor<FetchCommand, FetchResult> {
_FakeFetch(this.sources);
final Map<String, String> sources;
@override
Future<FetchResult> send(FetchCommand message) async {
return switch (message) {
ParseSource(:final url, :final source) => _parse(url, source),
FetchPage(:final url, :final cachedSource) => cachedSource != null
? _parse(url, cachedSource)
: sources[url] != null
? _parse(url, sources[url]!)
: const FetchNetworkError(),
_ => const FetchNetworkError(),
};
}
FetchResult _parse(String url, String source) {
try {
return FetchPageOk(page: parseMewsPage(source: source, url: url), source: source);
} on MewsParseException catch (e) {
return FetchParseError(e.message, source);
}
}
@override
Future<void> close() async {}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
String _page(String title, String body) => '''
<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
@ -232,7 +196,7 @@ void main() {
testWidgets('a fetched page cannot run an action link', (tester) async {
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: _FakeFetch({
fetch: FakeFetch({
'https://example.com/evil.html': _page('Evil',
'<h1>Evil</h1><p><a href="mews:do/set-theme?value=dark">Go</a></p>'),
}),
@ -365,7 +329,7 @@ void main() {
};
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: _FakeFetch(sources),
fetch: FakeFetch(sources),
strings: strings,
);
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
@ -402,7 +366,7 @@ void main() {
(tester) async {
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: _FakeFetch({
fetch: FakeFetch({
'https://wap.example.com/':
_page('Wap site', '<h1>Wap site</h1><p>Content lives here.</p>'),
}),
@ -427,7 +391,7 @@ void main() {
(tester) async {
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: _FakeFetch({
fetch: FakeFetch({
'https://example.com/a.html':
_page('Page A', '<h1>Page A</h1><p>The first page.</p>'),
'https://example.com/b.html':
@ -485,7 +449,7 @@ void main() {
(tester) async {
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: _FakeFetch({
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':
@ -520,6 +484,85 @@ void main() {
debugDefaultTargetPlatformOverride = null;
});
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);
@ -569,3 +612,18 @@ Future<void> _pumpUntil(WidgetTester tester, bool Function() condition,
}
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>
''';