2026-10-11 09:33:25 +03:00
|
|
|
import 'package:drift/drift.dart' show Value;
|
|
|
|
|
import 'package:drift/native.dart';
|
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
|
|
2026-10-11 13:12:48 +03:00
|
|
|
import 'package:mews/application/controllers/bookmarks_controller.dart';
|
2026-10-11 09:33:25 +03:00
|
|
|
import 'package:mews/application/controllers/reader_settings_controller.dart';
|
2026-10-11 13:12:48 +03:00
|
|
|
import 'package:mews/application/controllers/subscriptions_controller.dart';
|
2026-10-11 09:33:25 +03:00
|
|
|
import 'package:mews/database/database.dart';
|
|
|
|
|
import 'package:mews/mews/document.dart';
|
2026-10-11 13:35:19 +03:00
|
|
|
import 'package:mews/mews/parser.dart';
|
2026-10-11 09:33:25 +03:00
|
|
|
|
2026-10-11 13:20:00 +03:00
|
|
|
import 'fake_fetch.dart';
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
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 {
|
2026-10-11 13:12:48 +03:00
|
|
|
final bookmarks = BookmarksController(db);
|
|
|
|
|
await bookmarks.load();
|
|
|
|
|
await bookmarks.toggle('https://example.com/', 'Example');
|
|
|
|
|
expect(bookmarks.isBookmarked('https://example.com/'), isTrue);
|
2026-10-11 09:33:25 +03:00
|
|
|
|
2026-10-11 13:12:48 +03:00
|
|
|
final reloaded = BookmarksController(db);
|
2026-10-11 09:33:25 +03:00
|
|
|
await reloaded.load();
|
|
|
|
|
expect(reloaded.isBookmarked('https://example.com/'), isTrue);
|
|
|
|
|
|
2026-10-11 13:12:48 +03:00
|
|
|
await reloaded.toggle('https://example.com/', 'Example');
|
2026-10-11 09:33:25 +03:00
|
|
|
expect(reloaded.isBookmarked('https://example.com/'), isFalse);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-11 13:12:48 +03:00
|
|
|
test('subscribing takes on the back catalogue without flagging it', () async {
|
2026-10-11 13:20:00 +03:00
|
|
|
final subscriptions = SubscriptionsController(db: db, fetch: FakeFetch(const {}));
|
2026-10-11 13:12:48 +03:00
|
|
|
await subscriptions.load();
|
2026-10-11 09:33:25 +03:00
|
|
|
|
|
|
|
|
final firstPost = MewsEntry(
|
2026-10-11 13:12:48 +03:00
|
|
|
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);
|
2026-10-11 09:33:25 +03:00
|
|
|
|
2026-10-11 13:12:48 +03:00
|
|
|
// The post was stored, but read, so nothing is waiting for the reader.
|
|
|
|
|
expect(await db.unreadCount(), 0);
|
2026-10-11 09:33:25 +03:00
|
|
|
|
2026-10-11 13:12:48 +03:00
|
|
|
// 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.
|
2026-10-11 09:33:25 +03:00
|
|
|
final newPost = MewsEntry(
|
2026-10-11 13:12:48 +03:00
|
|
|
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 {
|
2026-10-11 13:20:00 +03:00
|
|
|
final subscriptions = SubscriptionsController(db: db, fetch: FakeFetch(const {}));
|
2026-10-11 13:12:48 +03:00
|
|
|
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.
|
2026-10-11 13:20:00 +03:00
|
|
|
final restarted = SubscriptionsController(db: db, fetch: FakeFetch(const {}));
|
2026-10-11 13:12:48 +03:00
|
|
|
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 {
|
2026-10-11 13:20:00 +03:00
|
|
|
final subscriptions = SubscriptionsController(db: db, fetch: FakeFetch(const {}));
|
2026-10-11 13:12:48 +03:00
|
|
|
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);
|
2026-10-11 09:33:25 +03:00
|
|
|
});
|
|
|
|
|
|
2026-10-11 13:20:00 +03:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-11 13:35:19 +03:00
|
|
|
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 = '''
|
|
|
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
|
|
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
|
|
|
<head>
|
|
|
|
|
<title>Feed Only</title>
|
|
|
|
|
<meta name="mews-profile" content="0.1" />
|
|
|
|
|
<link rel="alternate" type="application/atom+xml" href="feed.xml" />
|
|
|
|
|
</head>
|
|
|
|
|
<body><h1>Feed Only</h1><p>Nothing listed here.</p></body>
|
|
|
|
|
</html>
|
|
|
|
|
''';
|
|
|
|
|
String feedWith(List<(String, String)> entries) => '''
|
|
|
|
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
|
|
|
${entries.map((e) => '<entry><title>${e.$2}</title><link rel="alternate" href="${e.$1}" /><updated>2026-10-08T09:30:00Z</updated></entry>').join()}
|
|
|
|
|
</feed>
|
|
|
|
|
''';
|
|
|
|
|
|
|
|
|
|
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 = '''
|
|
|
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
|
|
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
|
|
|
<head>
|
|
|
|
|
<title>Both</title>
|
|
|
|
|
<meta name="mews-profile" content="0.1" />
|
|
|
|
|
<link rel="alternate" type="application/atom+xml" href="feed.xml" />
|
|
|
|
|
</head>
|
|
|
|
|
<body><h1>Both</h1><ul>
|
|
|
|
|
<li><a href="one.html">2026-10-08 The only post</a></li>
|
|
|
|
|
</ul></body>
|
|
|
|
|
</html>
|
|
|
|
|
''';
|
|
|
|
|
// 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':
|
|
|
|
|
'<feed xmlns="http://www.w3.org/2005/Atom"><entry><title>Duplicate</title>'
|
|
|
|
|
'<link rel="alternate" href="one.html" /><updated>2026-10-08T09:30:00Z</updated>'
|
|
|
|
|
'</entry></feed>',
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
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<MewsEntry> entries) => MewsPage(
|
|
|
|
|
url: url,
|
|
|
|
|
body: const [],
|
|
|
|
|
lang: 'en',
|
|
|
|
|
direction: TextDirection.ltr,
|
|
|
|
|
variant: MewsVariant.plain,
|
|
|
|
|
entries: entries,
|
|
|
|
|
specVersion: '0.1',
|
|
|
|
|
);
|
2026-10-11 13:20:00 +03:00
|
|
|
|
|
|
|
|
/// 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>
|
|
|
|
|
''';
|