the-mews/app/test/controllers_test.dart

256 lines
9.1 KiB
Dart
Raw Normal View History

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 '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('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',
);
/// 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>
''';