feat: store subscription posts with a read mark and split the library controller

This commit is contained in:
randogoth 2026-10-11 13:12:48 +03:00
parent b311b9f7dd
commit 953ae798eb
15 changed files with 1787 additions and 386 deletions

View file

@ -312,13 +312,13 @@ void main() {
expect(find.byIcon(Icons.notifications_outlined), findsOneWidget);
await tester.tap(find.byIcon(Icons.notifications_outlined));
await tester.pumpAndSettle();
expect(deps.library.isSubscribed('https://example.com/index.html'), isTrue);
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.library.isBookmarked('https://example.com/index.html'), isTrue);
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.
@ -464,10 +464,12 @@ void main() {
expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/a.html');
// Bookmarks and subscriptions get the same treatment.
await stubDeps.library.load();
await stubDeps.library.toggleBookmark('https://example.com/b.html', 'Page B');
await stubDeps.library.toggleSubscription(
'https://example.com/a.html', 'Page A', const []);
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);
@ -518,23 +520,37 @@ void main() {
debugDefaultTargetPlatformOverride = null;
});
testWidgets('reports new posts once per page', (tester) async {
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');
deps.library.subscribedUrls.value = {'https://example.com/index.html'};
deps.library.newEntries.value = {
'https://example.com/index.html': [
MewsEntry(
url: 'https://example.com/2026-10-08-retro.html',
date: DateTime(2026, 10, 8),
title: 'Collecting retro phones'),
],
};
deps.browser.currentPage.value = page;
await deps.subscriptions.toggle(page);
await deps.browser.open(MewsUrls.subscriptions);
await tester.pumpAndSettle();
expect(find.textContaining('unread post'), findsNothing);
expect(find.textContaining('1 new post'), findsOneWidget);
// 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);
});
}

View file

@ -2,7 +2,9 @@ import 'package:drift/drift.dart' show Value;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mews/application/controllers/bookmarks_controller.dart';
import 'package:mews/application/controllers/reader_settings_controller.dart';
import 'package:mews/application/controllers/subscriptions_controller.dart';
import 'package:mews/database/database.dart';
import 'package:mews/mews/document.dart';
@ -45,44 +47,89 @@ void main() {
});
test('bookmarks toggle and persist', () async {
final library = LibraryController(db);
await library.load();
await library.toggleBookmark('https://example.com/', 'Example');
expect(library.isBookmarked('https://example.com/'), isTrue);
final bookmarks = BookmarksController(db);
await bookmarks.load();
await bookmarks.toggle('https://example.com/', 'Example');
expect(bookmarks.isBookmarked('https://example.com/'), isTrue);
final reloaded = LibraryController(db);
final reloaded = BookmarksController(db);
await reloaded.load();
expect(reloaded.isBookmarked('https://example.com/'), isTrue);
await reloaded.toggleBookmark('https://example.com/', 'Example');
await reloaded.toggle('https://example.com/', 'Example');
expect(reloaded.isBookmarked('https://example.com/'), isFalse);
});
test('subscription refresh reports only unseen entries', () async {
final library = LibraryController(db);
await library.load();
test('subscribing takes on the back catalogue without flagging it', () async {
final subscriptions = SubscriptionsController(db);
await subscriptions.load();
final firstPost = MewsEntry(
url: 'https://example.com/a.html', date: DateTime(2026, 9, 21), title: 'First post');
await library.toggleSubscription(
'https://example.com/', 'Example', [firstPost]);
expect(library.isSubscribed('https://example.com/'), isTrue);
url: 'https://example.com/a.html',
date: DateTime(2026, 9, 21),
title: 'First post');
await subscriptions.toggle(_page('https://example.com/', [firstPost]));
expect(subscriptions.isSubscribed('https://example.com/'), isTrue);
// Nothing new yet.
await library.refreshSubscription(_page('https://example.com/', [firstPost]));
expect(library.newEntries.value['https://example.com/'], isNull);
// The post was stored, but read, so nothing is waiting for the reader.
expect(await db.unreadCount(), 0);
// A new post appears.
// Opening the page again finds nothing new.
expect(await subscriptions.refreshSite(_page('https://example.com/', [firstPost])),
isEmpty);
// A later post is new, and stays unread until the reader opens it.
final newPost = MewsEntry(
url: 'https://example.com/b.html', date: DateTime(2026, 10, 8), title: 'Second post');
await library.refreshSubscription(_page('https://example.com/', [newPost, firstPost]));
final fresh = library.newEntries.value['https://example.com/']!;
expect(fresh, hasLength(1));
expect(fresh.single.url, 'https://example.com/b.html');
url: 'https://example.com/b.html',
date: DateTime(2026, 10, 8),
title: 'Second post');
final fresh = await subscriptions
.refreshSite(_page('https://example.com/', [newPost, firstPost]));
expect(fresh.map((e) => e.url), ['https://example.com/b.html']);
expect(await db.unreadCount(siteUrl: 'https://example.com/'), 1);
// The refresh stored it; a repeat refresh reports nothing.
await library.refreshSubscription(_page('https://example.com/', [newPost, firstPost]));
expect(library.newEntries.value['https://example.com/'], isNull);
// Finding it a second time does not report it again.
expect(
await subscriptions
.refreshSite(_page('https://example.com/', [newPost, firstPost])),
isEmpty);
expect(await db.unreadCount(), 1);
});
test('unread posts survive a restart', () async {
final subscriptions = SubscriptionsController(db);
await subscriptions.load();
await subscriptions.toggle(_page('https://example.com/', const []));
await subscriptions.refreshSite(_page('https://example.com/', [
MewsEntry(
url: 'https://example.com/a.html',
date: DateTime(2026, 9, 21),
title: 'First post'),
]));
// 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);
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);
await subscriptions.load();
await subscriptions.toggle(_page('https://example.com/', const []));
await subscriptions.refreshSite(_page('https://example.com/', [
MewsEntry(
url: 'https://example.com/a.html',
date: DateTime(2026, 9, 21),
title: 'First post'),
]));
expect(await db.unreadCount(), 1);
await subscriptions.remove('https://example.com/');
expect(subscriptions.isSubscribed('https://example.com/'), isFalse);
expect(await db.unreadCount(), 0);
});
test('caches pages for offline reopening', () async {

139
app/test/database_test.dart Normal file
View file

@ -0,0 +1,139 @@
import 'dart:io';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:mews/database/database.dart';
import 'package:mews/mews/document.dart';
MewsEntry _entry(String url, {String title = 'A post', int day = 8}) =>
MewsEntry(url: url, date: DateTime(2026, 10, day), title: title);
void main() {
late MewsDatabase db;
setUp(() {
db = MewsDatabase.test(NativeDatabase.memory());
});
tearDown(() async {
await db.close();
});
test('a page can only be bookmarked once', () async {
await db.addBookmark('https://example.com/', 'Example');
await db.addBookmark('https://example.com/', 'Example again');
expect(await db.allBookmarks(), hasLength(1));
expect(await db.countBookmarks(), 1);
});
test('a site can only be subscribed to once', () async {
await db.addSubscription('https://example.com/', 'Example', null);
await db.addSubscription('https://example.com/', 'Example', null);
expect(await db.allSubscriptions(), hasLength(1));
expect(await db.countSubscriptions(), 1);
});
test('finding a post again keeps its read mark', () async {
const site = 'https://example.com/';
await db.addSubscription(site, 'Example', null);
await db.addEntries(site, [_entry('https://example.com/a.html')]);
expect(await db.unreadCount(), 1);
await db.markEntryRead('https://example.com/a.html');
expect(await db.unreadCount(), 0);
// A refresh sees the same post again; it must not come back unread.
final fresh = await db.addEntries(site, [_entry('https://example.com/a.html')]);
expect(fresh, isEmpty);
expect(await db.unreadCount(), 0);
});
test('unread counts split by site', () async {
await db.addEntries('https://a.example/', [
_entry('https://a.example/one.html'),
_entry('https://a.example/two.html', day: 9),
]);
await db.addEntries('https://b.example/', [_entry('https://b.example/one.html')]);
expect(await db.unreadCount(), 3);
expect(await db.unreadCount(siteUrl: 'https://a.example/'), 2);
expect(await db.unreadCounts(),
{'https://a.example/': 2, 'https://b.example/': 1});
});
test('describing a site leaves the fields it is not given', () async {
const site = 'https://example.com/';
await db.addSubscription(site, 'Signal Bars', null);
// The old single update method blanked the title whenever a refresh only
// had a feed address to record.
await db.describeSubscription(site, atomHref: 'https://example.com/feed.xml');
final row = (await db.allSubscriptions()).single;
expect(row.title, 'Signal Bars');
expect(row.atomHref, 'https://example.com/feed.xml');
});
test('checking a site records the time without touching its posts', () async {
const site = 'https://example.com/';
await db.addSubscription(site, 'Example', null);
await db.addEntries(site, [_entry('https://example.com/a.html')]);
final checkedAt = DateTime(2026, 10, 11, 9, 30);
await db.touchSubscription(site, checkedAt);
expect((await db.allSubscriptions()).single.lastCheckedAt, checkedAt);
expect(await db.unreadCount(), 1);
});
test('history counts addresses rather than visits', () async {
await db.recordVisit('https://example.com/a.html', 'A');
await db.recordVisit('https://example.com/a.html', 'A');
await db.recordVisit('https://example.com/b.html', 'B');
expect(await db.countHistory(), 2);
});
test('an older database is recreated rather than migrated', () async {
final dir = Directory.systemTemp.createTempSync('mews-schema');
addTearDown(() => dir.deleteSync(recursive: true));
final file = File(p.join(dir.path, 'mews.sqlite'));
final before = MewsDatabase.test(NativeDatabase(file));
await before.addBookmark('https://example.com/', 'Example');
await before.addEntries(
'https://example.com/', [_entry('https://example.com/a.html')]);
// Make the file look like the shipped version 1: the posts and tags
// tables did not exist, and the version counter said 1.
await before.customStatement('DROP TABLE feed_entries');
await before.customStatement('DROP TABLE item_tags');
await before.customStatement('PRAGMA user_version = 1');
await before.close();
// Opening it now runs the destructive upgrade: the reader loses their
// local data, which is the agreed trade before release, but the app must
// come up rather than fail on a table that was never there.
final after = MewsDatabase.test(NativeDatabase(file));
addTearDown(after.close);
expect(await after.allBookmarks(), isEmpty);
expect(await after.unreadCount(), 0);
// The new schema works afterwards.
await after.addBookmark('https://example.com/', 'Example');
await after.addEntries(
'https://example.com/', [_entry('https://example.com/a.html')]);
expect(await after.countBookmarks(), 1);
expect(await after.unreadCount(), 1);
});
test('tags follow the item they are on', () async {
const url = 'https://example.com/';
await db.addBookmark(url, 'Example');
await db.addTag(TagKind.bookmark, url, 'phones');
await db.addTag(TagKind.bookmark, url, 'phones');
expect(await db.tagsOf(TagKind.bookmark, url), ['phones']);
// Removing the bookmark takes its tags with it.
await db.removeBookmark(url);
expect(await db.tagsOf(TagKind.bookmark, url), isEmpty);
});
}