feat: build v1 Mews client: reader UI, gestures, Drift store, fetch actor
This commit is contained in:
parent
d5a0fcbf48
commit
01a36e8aee
56 changed files with 7287 additions and 2 deletions
116
app/test/controllers_test.dart
Normal file
116
app/test/controllers_test.dart
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:mews/application/controllers/reader_settings_controller.dart';
|
||||
import 'package:mews/database/database.dart';
|
||||
import 'package:mews/mews/document.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 library = LibraryController(db);
|
||||
await library.load();
|
||||
await library.toggleBookmark('https://example.com/', 'Example');
|
||||
expect(library.isBookmarked('https://example.com/'), isTrue);
|
||||
|
||||
final reloaded = LibraryController(db);
|
||||
await reloaded.load();
|
||||
expect(reloaded.isBookmarked('https://example.com/'), isTrue);
|
||||
|
||||
await reloaded.toggleBookmark('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();
|
||||
|
||||
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);
|
||||
|
||||
// Nothing new yet.
|
||||
await library.refreshSubscription(_page('https://example.com/', [firstPost]));
|
||||
expect(library.newEntries.value['https://example.com/'], isNull);
|
||||
|
||||
// A new post appears.
|
||||
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');
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
167
app/test/parser_test.dart
Normal file
167
app/test/parser_test.dart
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:mews/mews/document.dart';
|
||||
import 'package:mews/mews/parser.dart';
|
||||
|
||||
const conformingPage = '''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
|
||||
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||
<head>
|
||||
<title>Signal Bars</title>
|
||||
<meta name="mews-profile" content="0.1" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="description" content="Notes on old phones." />
|
||||
<link rel="alternate" type="application/atom+xml" href="feed.xml" title="Posts" />
|
||||
</head>
|
||||
<body class="mews-green">
|
||||
<h1>Signal Bars</h1>
|
||||
<p>Notes on <em>old phones</em> and <a href="about.html">the about page</a>.</p>
|
||||
<ul>
|
||||
<li><a href="2026-10-08-retro-phones.html">2026-10-08 Collecting retro phones</a></li>
|
||||
<li><a href="2026-09-21-first-post.html">2026-09-21 First post</a></li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
''';
|
||||
|
||||
void main() {
|
||||
test('parses a conforming Mews page', () {
|
||||
final page = parseMewsPage(source: conformingPage, url: 'https://example.com/index.html');
|
||||
|
||||
expect(page.isMews, isTrue);
|
||||
expect(page.specVersion, '0.1');
|
||||
expect(page.title, 'Signal Bars');
|
||||
expect(page.description, 'Notes on old phones.');
|
||||
expect(page.variant, MewsVariant.green);
|
||||
expect(page.lang, 'en');
|
||||
|
||||
expect(page.entries, hasLength(2));
|
||||
expect(page.entries.first.url, 'https://example.com/2026-10-08-retro-phones.html');
|
||||
expect(page.entries.first.title, 'Collecting retro phones');
|
||||
expect(page.entries.first.date, DateTime(2026, 10, 8));
|
||||
// Entries are listed newest first.
|
||||
expect(page.entries.last.date, DateTime(2026, 9, 21));
|
||||
|
||||
expect(page.body, hasLength(3));
|
||||
expect(page.body[0], isA<HeadingBlock>());
|
||||
final paragraph = page.body[1] as ParagraphBlock;
|
||||
expect(paragraph.inline, hasLength(5));
|
||||
final link = paragraph.inline[3] as MewsLink;
|
||||
expect(link.href, 'https://example.com/about.html');
|
||||
});
|
||||
|
||||
test('rejects a page that is not well-formed', () {
|
||||
expect(
|
||||
() => parseMewsPage(
|
||||
source: '<html><head><title>x</title></head><body><p>oops</body></html>',
|
||||
url: 'https://example.com/'),
|
||||
throwsA(isA<MewsParseException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('treats a page without the conformance marker as non-Mews', () {
|
||||
final source = '''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title>Plain</title></head>
|
||||
<body><p>Just XHTML.</p></body>
|
||||
</html>
|
||||
''';
|
||||
final page = parseMewsPage(source: source, url: 'https://example.com/plain.html');
|
||||
expect(page.isMews, isFalse);
|
||||
expect(page.specVersion, isNull);
|
||||
expect(page.variant, MewsVariant.plain);
|
||||
});
|
||||
|
||||
test('resolves relative URLs against the page', () {
|
||||
final page = parseMewsPage(source: conformingPage, url: 'https://example.com/posts/index.html');
|
||||
final paragraph = page.body[1] as ParagraphBlock;
|
||||
final link = paragraph.inline[3] as MewsLink;
|
||||
expect(link.href, 'https://example.com/posts/about.html');
|
||||
});
|
||||
|
||||
test('allows same-site images and refuses off-site and data URIs', () {
|
||||
final source = '''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title>Pics</title></head>
|
||||
<body>
|
||||
<p><img src="/cat.png" alt="A cat" /> and <img src="https://img.example.com/dog.png" alt="A dog" /></p>
|
||||
<p><img src="https://other.example.net/bird.png" alt="A bird" /></p>
|
||||
<p><img src="data:image/png;base64,AAAA" alt="Inline" /></p>
|
||||
</body>
|
||||
</html>
|
||||
''';
|
||||
final page = parseMewsPage(source: source, url: 'https://example.com/pics.html');
|
||||
final images = page.body.whereType<ImageBlock>().toList();
|
||||
expect(images, hasLength(4));
|
||||
expect(images[0].src, 'https://example.com/cat.png');
|
||||
expect(images[0].allowed, isTrue);
|
||||
expect(images[1].src, 'https://img.example.com/dog.png');
|
||||
expect(images[1].allowed, isTrue, reason: 'subdomains share the registered domain');
|
||||
expect(images[2].allowed, isFalse, reason: 'another site');
|
||||
expect(images[3].allowed, isFalse, reason: 'data URI');
|
||||
});
|
||||
|
||||
test('drops scripts and styles entirely', () {
|
||||
final source = '''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title>Spooky</title><script>alert(1)</script></head>
|
||||
<body><p>Keep this.</p><script>var x = 1;</script><style>p{color:red}</style></body>
|
||||
</html>
|
||||
''';
|
||||
final page = parseMewsPage(source: source, url: 'https://example.com/spooky.html');
|
||||
expect(page.body, hasLength(1));
|
||||
final paragraph = page.body[0] as ParagraphBlock;
|
||||
expect((paragraph.inline[0] as MewsText).text, 'Keep this.');
|
||||
});
|
||||
|
||||
test('honors dir=rtl', () {
|
||||
final source = '''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" dir="rtl" lang="he">
|
||||
<head><title>Shalom</title></head>
|
||||
<body><p>Text.</p></body>
|
||||
</html>
|
||||
''';
|
||||
final page = parseMewsPage(source: source, url: 'https://example.com/he.html');
|
||||
expect(page.direction, TextDirection.rtl);
|
||||
});
|
||||
|
||||
test('builds form fields with labels', () {
|
||||
final source = '''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title>Guestbook</title></head>
|
||||
<body>
|
||||
<form action="/sign" method="post">
|
||||
<label for="name">Your name</label>
|
||||
<input type="text" id="name" name="name" />
|
||||
<input type="hidden" name="page" value="1" />
|
||||
<input type="submit" value="Sign guestbook" />
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
''';
|
||||
final page = parseMewsPage(source: source, url: 'https://example.com/guest.html');
|
||||
final form = page.body.single as FormBlock;
|
||||
expect(form.action, 'https://example.com/sign');
|
||||
expect(form.method, 'post');
|
||||
final nameField = form.fields[0] as MewsTextField;
|
||||
expect(nameField.name, 'name');
|
||||
expect(nameField.label, 'Your name');
|
||||
final hidden = form.fields[1] as MewsHiddenField;
|
||||
expect(hidden.value, '1');
|
||||
final submit = form.fields[2] as MewsSubmitField;
|
||||
expect(submit.label, 'Sign guestbook');
|
||||
});
|
||||
|
||||
test('mewsPlainText recovers text from broken markup', () {
|
||||
const broken = '<html><head><title>Half</title></head><body><p>Visible & intact.</p>';
|
||||
final text = mewsPlainText(broken);
|
||||
expect(text, contains('Visible & intact.'));
|
||||
});
|
||||
}
|
||||
88
app/test/reader_widget_test.dart
Normal file
88
app/test/reader_widget_test.dart
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import 'dart:io' show File;
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:mews/database/database.dart';
|
||||
import 'package:mews/l10n/strings.dart';
|
||||
import 'package:mews/mews/document.dart';
|
||||
import 'package:mews/presentation/app.dart';
|
||||
import 'package:mews/presentation/reader/fob.dart';
|
||||
import 'package:mews/presentation/reader/mews_page_view.dart';
|
||||
import 'package:mews/presentation/reader/palettes.dart';
|
||||
|
||||
Future<MewsApp> _app() async {
|
||||
final strings = Strings();
|
||||
await strings.load();
|
||||
final deps = AppDependencies(database: MewsDatabase.test(NativeDatabase.memory()));
|
||||
return MewsApp(deps: deps, strings: strings);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('starts with the address bar and no FOB', (tester) async {
|
||||
await tester.pumpWidget(await _app());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('Enter a web address'), findsOneWidget);
|
||||
// The FOB only appears once the reader scrolls a page; without a page it
|
||||
// stays invisible and untouchable.
|
||||
final fob = tester.widget<Fob>(find.byType(Fob));
|
||||
expect(fob.visible, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('renders a page and follows links', (tester) async {
|
||||
final page = MewsPage(
|
||||
url: 'https://example.com/index.html',
|
||||
title: 'Test page',
|
||||
lang: 'en',
|
||||
direction: TextDirection.ltr,
|
||||
variant: MewsVariant.plain,
|
||||
specVersion: '0.1',
|
||||
entries: const [],
|
||||
body: const [
|
||||
HeadingBlock(1, [MewsText('A heading')]),
|
||||
ParagraphBlock([MewsText('Read on for more.')]),
|
||||
ParagraphBlock([
|
||||
MewsLink('https://example.com/next.html', [MewsText('the next page')]),
|
||||
]),
|
||||
],
|
||||
);
|
||||
|
||||
String? opened;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: StringsScope(
|
||||
strings: (await _strings()),
|
||||
child: MewsPageView(
|
||||
page: page,
|
||||
palette: paletteFor(
|
||||
pageVariant: MewsVariant.plain, readerVariant: 'site', dark: false),
|
||||
fontFamily: 'Atkinson Hyperlegible',
|
||||
fontScale: 1.0,
|
||||
visitedUrls: const {},
|
||||
onLinkTap: (url) => opened = url,
|
||||
onFormSubmit: (_, _) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('A heading'), findsOneWidget);
|
||||
expect(find.textContaining('the next page'), findsOneWidget);
|
||||
|
||||
// The paragraph Text stretches the full column width, so tap on the
|
||||
// glyphs of the link itself, not the widget's center.
|
||||
final rect = tester.getRect(find.textContaining('the next page'));
|
||||
await tester.tapAt(rect.topLeft + const Offset(30, 13));
|
||||
await tester.pump();
|
||||
expect(opened, 'https://example.com/next.html');
|
||||
});
|
||||
}
|
||||
|
||||
Future<Strings> _strings() async {
|
||||
final strings = Strings();
|
||||
strings.loadSource(File('locales/en-US/main.ftl').readAsStringSync());
|
||||
return strings;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue