feat: check every subscription in the background and add the posts page
This commit is contained in:
parent
953ae798eb
commit
1b426ccd8b
15 changed files with 688 additions and 58 deletions
|
|
@ -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>
|
||||
''';
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import 'package:mews/application/controllers/subscriptions_controller.dart';
|
|||
import 'package:mews/database/database.dart';
|
||||
import 'package:mews/mews/document.dart';
|
||||
|
||||
import 'fake_fetch.dart';
|
||||
|
||||
void main() {
|
||||
late MewsDatabase db;
|
||||
|
||||
|
|
@ -61,7 +63,7 @@ void main() {
|
|||
});
|
||||
|
||||
test('subscribing takes on the back catalogue without flagging it', () async {
|
||||
final subscriptions = SubscriptionsController(db);
|
||||
final subscriptions = SubscriptionsController(db: db, fetch: FakeFetch(const {}));
|
||||
await subscriptions.load();
|
||||
|
||||
final firstPost = MewsEntry(
|
||||
|
|
@ -97,7 +99,7 @@ void main() {
|
|||
});
|
||||
|
||||
test('unread posts survive a restart', () async {
|
||||
final subscriptions = SubscriptionsController(db);
|
||||
final subscriptions = SubscriptionsController(db: db, fetch: FakeFetch(const {}));
|
||||
await subscriptions.load();
|
||||
await subscriptions.toggle(_page('https://example.com/', const []));
|
||||
await subscriptions.refreshSite(_page('https://example.com/', [
|
||||
|
|
@ -109,14 +111,14 @@ void main() {
|
|||
|
||||
// A fresh controller over the same database sees the same unread post,
|
||||
// which the old in-memory count could not do.
|
||||
final restarted = SubscriptionsController(db);
|
||||
final restarted = SubscriptionsController(db: db, fetch: FakeFetch(const {}));
|
||||
await restarted.load();
|
||||
expect(restarted.isSubscribed('https://example.com/'), isTrue);
|
||||
expect(await db.unreadCount(), 1);
|
||||
});
|
||||
|
||||
test('leaving a site takes its posts with it', () async {
|
||||
final subscriptions = SubscriptionsController(db);
|
||||
final subscriptions = SubscriptionsController(db: db, fetch: FakeFetch(const {}));
|
||||
await subscriptions.load();
|
||||
await subscriptions.toggle(_page('https://example.com/', const []));
|
||||
await subscriptions.refreshSite(_page('https://example.com/', [
|
||||
|
|
@ -132,6 +134,79 @@ void main() {
|
|||
expect(await db.unreadCount(), 0);
|
||||
});
|
||||
|
||||
test('a check finds new posts on every subscribed site', () async {
|
||||
const siteA = 'https://a.example/';
|
||||
const siteB = 'https://b.example/';
|
||||
final sources = {
|
||||
siteA: _entriesPage('Site A', const []),
|
||||
siteB: _entriesPage('Site B', const []),
|
||||
};
|
||||
final subscriptions =
|
||||
SubscriptionsController(db: db, fetch: FakeFetch(sources));
|
||||
await subscriptions.load();
|
||||
await subscriptions.toggle(_page(siteA, const []));
|
||||
await subscriptions.toggle(_page(siteB, const []));
|
||||
|
||||
// Both sites publish something after the reader subscribed.
|
||||
sources[siteA] = _entriesPage('Site A', const [
|
||||
('2026-10-09', 'a-one.html', 'A newer post'),
|
||||
]);
|
||||
sources[siteB] = _entriesPage('Site B', const [
|
||||
('2026-10-10', 'b-one.html', 'Another post'),
|
||||
('2026-10-09', 'b-two.html', 'And one more'),
|
||||
]);
|
||||
|
||||
await subscriptions.refreshAll();
|
||||
expect(await db.unreadCount(), 3);
|
||||
expect(await db.unreadCount(siteUrl: siteA), 1);
|
||||
expect(subscriptions.refreshing.value, isFalse);
|
||||
|
||||
// Every site was recorded as checked.
|
||||
for (final site in await db.allSubscriptions()) {
|
||||
expect(site.lastCheckedAt, isNotNull);
|
||||
}
|
||||
});
|
||||
|
||||
test('one unreachable site does not stop the rest of the check', () async {
|
||||
const reachable = 'https://a.example/';
|
||||
const unreachable = 'https://down.example/';
|
||||
final subscriptions = SubscriptionsController(
|
||||
db: db,
|
||||
// Only the first site answers; the other has no source at all.
|
||||
fetch: FakeFetch({
|
||||
reachable: _entriesPage('Site A', const [
|
||||
('2026-10-09', 'a-one.html', 'A newer post'),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
await subscriptions.load();
|
||||
await subscriptions.toggle(_page(unreachable, const []));
|
||||
await subscriptions.toggle(_page(reachable, const []));
|
||||
|
||||
await subscriptions.refreshAll();
|
||||
expect(await db.unreadCount(siteUrl: reachable), 1);
|
||||
expect(subscriptions.refreshing.value, isFalse);
|
||||
// The site that could not be reached is still marked as checked.
|
||||
final down = (await db.allSubscriptions())
|
||||
.firstWhere((s) => s.url == unreachable);
|
||||
expect(down.lastCheckedAt, isNotNull);
|
||||
});
|
||||
|
||||
test('a check already running is not started again', () async {
|
||||
final subscriptions = SubscriptionsController(
|
||||
db: db,
|
||||
fetch: FakeFetch({'https://a.example/': _entriesPage('Site A', const [])}),
|
||||
);
|
||||
await subscriptions.load();
|
||||
await subscriptions.toggle(_page('https://a.example/', const []));
|
||||
|
||||
var redraws = 0;
|
||||
subscriptions.onChanged = () => redraws++;
|
||||
await Future.wait([subscriptions.refreshAll(), subscriptions.refreshAll()]);
|
||||
// One pass: it starts and finishes once, so two redraws, not four.
|
||||
expect(redraws, 2);
|
||||
});
|
||||
|
||||
test('caches pages for offline reopening', () async {
|
||||
await db.saveCachedPage(CachedPagesCompanion.insert(
|
||||
url: 'https://example.com/page.html',
|
||||
|
|
@ -161,3 +236,20 @@ MewsPage _page(String url, List<MewsEntry> entries) => MewsPage(
|
|||
entries: entries,
|
||||
specVersion: '0.1',
|
||||
);
|
||||
|
||||
/// 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>
|
||||
''';
|
||||
|
|
|
|||
45
app/test/fake_fetch.dart
Normal file
45
app/test/fake_fetch.dart
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import 'package:actors/actors.dart';
|
||||
|
||||
import 'package:mews/application/actors/fetch_actor.dart';
|
||||
import 'package:mews/mews/parser.dart';
|
||||
|
||||
/// Serves pages from an in-memory map without network or isolates, so
|
||||
/// navigation tests are deterministic. [sources] wins over the cached source
|
||||
/// a caller offers, the way a server answering 200 does, so a test can change
|
||||
/// what a site publishes between two fetches. An address with no source is
|
||||
/// unreachable, and the cached copy stands in for it if there is one.
|
||||
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) => switch ((
|
||||
sources[url],
|
||||
cachedSource,
|
||||
)) {
|
||||
(final String fresh, _) => _parse(url, fresh),
|
||||
(null, final String cached) => _parse(url, cached),
|
||||
_ => 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue