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 = '''
Nothing listed here.
'''; String feedWith(List<(String, String)> entries) => '''