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'; import 'package:mews/mews/parser.dart'; import 'fake_fetch.dart'; void main() { late MewsDatabase db; setUp(() { db = MewsDatabase.test(NativeDatabase.memory()); }); tearDown(() async { await db.close(); }); test('settings round-trip through the database', () async { final settings = ReaderSettingsController(db); await settings.setFont('Atkinson Hyperlegible'); await settings.increaseSize(); await settings.setThemeMode('dark'); await settings.setVariant('warm'); final reloaded = ReaderSettingsController(db); await reloaded.load(); expect(reloaded.font.value, 'Atkinson Hyperlegible'); expect(reloaded.fontScale.value, 1.125); expect(reloaded.themeMode.value, 'dark'); expect(reloaded.variant.value, 'warm'); }); test('text size stays within its limits', () async { final settings = ReaderSettingsController(db); for (var i = 0; i < 30; i++) { await settings.increaseSize(); } expect(settings.fontScale.value, ReaderSettingsController.maxScale); for (var i = 0; i < 40; i++) { await settings.decreaseSize(); } expect(settings.fontScale.value, ReaderSettingsController.minScale); }); test('bookmarks toggle and persist', () async { final bookmarks = BookmarksController(db); await bookmarks.load(); await bookmarks.toggle('https://example.com/', 'Example'); expect(bookmarks.isBookmarked('https://example.com/'), isTrue); final reloaded = BookmarksController(db); await reloaded.load(); expect(reloaded.isBookmarked('https://example.com/'), isTrue); await reloaded.toggle('https://example.com/', 'Example'); expect(reloaded.isBookmarked('https://example.com/'), isFalse); }); test('subscribing takes on the back catalogue without flagging it', () async { final subscriptions = SubscriptionsController(db: db, fetch: FakeFetch(const {})); await subscriptions.load(); final firstPost = MewsEntry( 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); // The post was stored, but read, so nothing is waiting for the reader. expect(await db.unreadCount(), 0); // 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'); 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); // 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: db, fetch: FakeFetch(const {})); 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: 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: db, fetch: FakeFetch(const {})); 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('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('a site publishing only through a feed still reports posts', () async { const site = 'https://feedonly.example/'; const feed = 'https://feedonly.example/feed.xml'; // A page with no dated links, but it declares a feed (spec 6.2). const page = ''' Feed Only

Feed Only

Nothing listed here.

'''; String feedWith(List<(String, String)> entries) => ''' ${entries.map((e) => '${e.$2}2026-10-08T09:30:00Z').join()} '''; final feeds = {feed: feedWith(const [('old.html', 'An older post')])}; final subscriptions = SubscriptionsController( db: db, fetch: FakeFetch({site: page}, feeds: feeds), ); await subscriptions.load(); await subscriptions.toggle(parseMewsPage(source: page, url: site)); // What the feed already listed is the back catalogue, not news. expect(await db.unreadCount(), 0); expect((await db.allSubscriptions()).single.atomHref, feed); // A post added to the feed later is new. feeds[feed] = feedWith(const [ ('new.html', 'A newer post'), ('old.html', 'An older post'), ]); await subscriptions.refreshAll(); expect(await db.unreadCount(), 1); final entries = await db.riverEntries(unreadOnly: true); expect(entries.single.title, 'A newer post'); // Checking again reports nothing, because an address identifies a post. await subscriptions.refreshAll(); expect(await db.unreadCount(), 1); }); test('a page listing dated links is not fetched twice', () async { const site = 'https://both.example/'; const page = ''' Both

Both

'''; // The feed would answer, but a page that lists its own posts is enough, // so nothing asks for it. final subscriptions = SubscriptionsController( db: db, fetch: FakeFetch({site: page}, feeds: const { 'https://both.example/feed.xml': 'Duplicate' '2026-10-08T09:30:00Z' '', }), ); await subscriptions.load(); await subscriptions.toggle(parseMewsPage(source: page, url: site)); await subscriptions.refreshAll(); // One post, under the title the page gave it. final entries = await db.riverEntries(); expect(entries, hasLength(1)); expect(entries.single.title, 'The only post'); }); test('caches pages for offline reopening', () async { await db.saveCachedPage(CachedPagesCompanion.insert( url: 'https://example.com/page.html', source: 'html', title: const Value('Page'), etag: const Value('"v1"'), )); final cached = await db.cachedPage('https://example.com/page.html'); expect(cached!.source, 'html'); expect(cached.etag, '"v1"'); expect(cached.title, 'Page'); }); test('records history and returns the last visited page', () async { await db.recordVisit('https://a.example/', 'A'); await db.recordVisit('https://b.example/', 'B'); expect(await db.lastVisitedUrl(), 'https://b.example/'); }); } MewsPage _page(String url, List entries) => MewsPage( url: url, body: const [], lang: 'en', direction: TextDirection.ltr, variant: MewsVariant.plain, 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) => ''' $title

$title

''';