feat: add a hub for the client's own pages and gate mews:do action links
This commit is contained in:
parent
df705d831f
commit
b311b9f7dd
21 changed files with 966 additions and 404 deletions
|
|
@ -4,6 +4,7 @@ import 'package:actors/actors.dart';
|
|||
import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart' show RenderParagraph;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:mews/application/actors/fetch_actor.dart';
|
||||
|
|
@ -13,7 +14,7 @@ import 'package:mews/database/database.dart';
|
|||
import 'package:mews/l10n/strings.dart';
|
||||
import 'package:mews/mews/document.dart';
|
||||
import 'package:mews/mews/parser.dart';
|
||||
import 'package:mews/application/internal_pages.dart';
|
||||
import 'package:mews/application/pages/mews_urls.dart';
|
||||
import 'package:mews/presentation/app.dart';
|
||||
import 'package:mews/presentation/reader/address_bar.dart';
|
||||
import 'package:mews/presentation/reader/swipe_navigator.dart';
|
||||
|
|
@ -107,6 +108,32 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Taps the run reading [label] inside a rendered paragraph. Internal pages
|
||||
/// put several links in one paragraph, so tapping the widget's center would
|
||||
/// hit whichever run happens to sit in the middle.
|
||||
Future<void> tapRun(WidgetTester tester, String label) async {
|
||||
final paragraph = tester.renderObject<RenderParagraph>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byWidgetPredicate((widget) =>
|
||||
widget is Text &&
|
||||
(widget.textSpan?.toPlainText() ?? widget.data ?? '')
|
||||
.contains(label)),
|
||||
matching: find.byType(RichText),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
final plain = paragraph.text.toPlainText();
|
||||
final start = plain.indexOf(label);
|
||||
final box = paragraph
|
||||
.getBoxesForSelection(TextSelection(
|
||||
baseOffset: start, extentOffset: start + label.length))
|
||||
.first;
|
||||
await tester.tapAt(paragraph.localToGlobal(
|
||||
Offset(box.left + 2, (box.top + box.bottom) / 2)));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('renders a page through the full app', (tester) async {
|
||||
await pumpApp(tester);
|
||||
|
||||
|
|
@ -140,24 +167,27 @@ void main() {
|
|||
expect(find.textContaining('Try again'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the settings page changes presentation and keeps its place',
|
||||
testWidgets('the hub opens the settings page, which applies a choice in place',
|
||||
(tester) async {
|
||||
await pumpApp(tester);
|
||||
|
||||
// Settings opens from the left address bar icon.
|
||||
await tester.tap(find.byIcon(Icons.tune));
|
||||
// The hub opens from the left address bar icon.
|
||||
await tester.tap(find.byIcon(Icons.menu));
|
||||
await tester.pumpAndSettle();
|
||||
expect(deps.browser.currentPage.value?.url, InternalPages.settingsUrl);
|
||||
expect(deps.browser.currentPage.value?.url, MewsUrls.hub);
|
||||
expect(find.text('Mews'), findsOneWidget);
|
||||
expect(find.textContaining('No bookmarks yet'), findsOneWidget);
|
||||
|
||||
// Rendered like a Mews page with the option groups.
|
||||
// Its menu reaches settings, which carries the strip of our own pages.
|
||||
await tapRun(tester, 'Settings');
|
||||
expect(deps.browser.currentPage.value?.url, MewsUrls.settings);
|
||||
expect(find.text('Settings'), findsOneWidget);
|
||||
expect(find.text('Typeface'), findsOneWidget);
|
||||
expect(find.text('Theme'), findsOneWidget);
|
||||
expect(find.text('Colors'), findsOneWidget);
|
||||
expect(find.text('Library'), findsOneWidget);
|
||||
// The buried Library section is gone; the strip replaced it.
|
||||
expect(find.text('Library'), findsNothing);
|
||||
|
||||
// Tapping a theme link applies it and stays on the settings page; the
|
||||
// lower sections scroll into view first.
|
||||
Future<void> scrollTo(Finder target) async {
|
||||
// Search down the page, then back up for targets above.
|
||||
for (var i = 0; i < 20 && target.evaluate().isEmpty; i++) {
|
||||
|
|
@ -172,38 +202,103 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
// The option rows stretch the full column width, so tap on the glyphs
|
||||
// themselves rather than the widget's center.
|
||||
Future<void> tapLink(String label) async {
|
||||
final rect = tester.getRect(find.text(label));
|
||||
await tester.tapAt(rect.topLeft + const Offset(30, 13));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
// Choosing a value applies it and stays put: no new walk history entry.
|
||||
await scrollTo(find.text('Dark'));
|
||||
await tapLink('Dark');
|
||||
await tapRun(tester, 'Dark');
|
||||
expect(deps.settings.themeMode.value, 'dark');
|
||||
expect(deps.browser.currentPage.value?.url, InternalPages.settingsUrl);
|
||||
expect(deps.browser.canGoBack, isFalse);
|
||||
expect(deps.browser.currentPage.value?.url, MewsUrls.settings);
|
||||
expect(deps.browser.canGoBack, isTrue);
|
||||
final beforeSize = deps.browser.currentPage.value;
|
||||
|
||||
await scrollTo(find.text('Warm'));
|
||||
await tapLink('Warm');
|
||||
await tapRun(tester, 'Warm');
|
||||
expect(deps.settings.variant.value, 'warm');
|
||||
|
||||
await scrollTo(find.text('Larger text'));
|
||||
await tapLink('Larger text');
|
||||
await tapRun(tester, 'Larger text');
|
||||
expect(deps.settings.fontScale.value, 1.125);
|
||||
expect(deps.browser.currentPage.value, isNot(same(beforeSize)));
|
||||
|
||||
// The library links navigate and join the walk history.
|
||||
await scrollTo(find.text('History'));
|
||||
await tapLink('History');
|
||||
await tester.pumpAndSettle();
|
||||
expect(deps.browser.currentPage.value?.url, InternalPages.historyUrl);
|
||||
expect(deps.browser.canGoBack, isTrue);
|
||||
// The strip navigates and joins the walk history.
|
||||
await scrollTo(find.textContaining('Bookmarks'));
|
||||
await tapRun(tester, 'History');
|
||||
expect(deps.browser.currentPage.value?.url, MewsUrls.history);
|
||||
|
||||
await deps.browser.back();
|
||||
await tester.pumpAndSettle();
|
||||
expect(deps.browser.currentPage.value?.url, InternalPages.settingsUrl);
|
||||
expect(deps.browser.currentPage.value?.url, MewsUrls.settings);
|
||||
});
|
||||
|
||||
testWidgets('a fetched page cannot run an action link', (tester) async {
|
||||
final stubDeps = AppDependencies(
|
||||
database: MewsDatabase.test(NativeDatabase.memory()),
|
||||
fetch: _FakeFetch({
|
||||
'https://example.com/evil.html': _page('Evil',
|
||||
'<h1>Evil</h1><p><a href="mews:do/set-theme?value=dark">Go</a></p>'),
|
||||
}),
|
||||
strings: strings,
|
||||
);
|
||||
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await stubDeps.browser.open('https://example.com/evil.html');
|
||||
await tester.pumpAndSettle();
|
||||
expect(stubDeps.settings.themeMode.value, 'system');
|
||||
|
||||
// Following the link from a fetched page changes nothing and goes nowhere
|
||||
// (spec 8.1: page content must not act for the reader).
|
||||
await tapRun(tester, 'Go');
|
||||
expect(stubDeps.settings.themeMode.value, 'system');
|
||||
expect(stubDeps.browser.currentPage.value?.url,
|
||||
'https://example.com/evil.html');
|
||||
|
||||
// The same link works from one of our own pages.
|
||||
await stubDeps.browser.open(MewsUrls.settings);
|
||||
await stubDeps.browser.open('mews:do/set-theme?value=dark');
|
||||
expect(stubDeps.settings.themeMode.value, 'dark');
|
||||
expect(stubDeps.browser.currentPage.value?.url, MewsUrls.settings);
|
||||
});
|
||||
|
||||
testWidgets('a form on one of our pages navigates instead of fetching',
|
||||
(tester) async {
|
||||
await pumpApp(tester);
|
||||
|
||||
// The fetch client cannot speak the mews scheme, so a form on an internal
|
||||
// page must be routed before it ever reaches the actor.
|
||||
await deps.browser.open(MewsUrls.bookmarks);
|
||||
await deps.browser.submitForm(MewsUrls.bookmarks, 'get', {'q': 'retro'});
|
||||
await tester.pumpAndSettle();
|
||||
expect(deps.browser.currentPage.value?.url, 'mews:bookmarks?q=retro');
|
||||
expect(deps.browser.error.value, isNull);
|
||||
|
||||
// A form whose action is an action link applies it in place.
|
||||
await deps.browser.open(MewsUrls.settings);
|
||||
await deps.browser.submitForm('mews:do/set-theme', 'get', {'value': 'dark'});
|
||||
await tester.pumpAndSettle();
|
||||
expect(deps.settings.themeMode.value, 'dark');
|
||||
expect(deps.browser.currentPage.value?.url, MewsUrls.settings);
|
||||
expect(deps.browser.error.value, isNull);
|
||||
});
|
||||
|
||||
testWidgets('bare mews: and an unknown name resolve predictably',
|
||||
(tester) async {
|
||||
await pumpApp(tester);
|
||||
|
||||
await deps.browser.open('mews:');
|
||||
await tester.pumpAndSettle();
|
||||
expect(deps.browser.currentPage.value?.url, MewsUrls.hub);
|
||||
|
||||
await deps.browser.open('mews:nonsense');
|
||||
await tester.pumpAndSettle();
|
||||
expect(deps.browser.currentPage.value, isNull);
|
||||
expect(deps.browser.error.value, isA<LoadFailed>());
|
||||
});
|
||||
|
||||
testWidgets('a session with no history starts at the hub', (tester) async {
|
||||
await pumpApp(tester);
|
||||
await deps.browser.restoreLastSession();
|
||||
await tester.pumpAndSettle();
|
||||
expect(deps.browser.currentPage.value?.url, MewsUrls.hub);
|
||||
});
|
||||
|
||||
testWidgets('bookmark and subscribe live on the address bar', (tester) async {
|
||||
|
|
|
|||
11
app/test/flutter_test_config.dart
Normal file
11
app/test/flutter_test_config.dart
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
/// Each test builds its own in-memory database, which is safe because no two
|
||||
/// share an executor, but it trips drift's shared-executor warning and buries
|
||||
/// the rest of the output.
|
||||
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
await testMain();
|
||||
}
|
||||
98
app/test/mews_urls_test.dart
Normal file
98
app/test/mews_urls_test.dart
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:mews/application/pages/mews_urls.dart';
|
||||
|
||||
void main() {
|
||||
group('isInternal', () {
|
||||
test('recognizes the mews scheme only', () {
|
||||
expect(MewsUrls.isInternal('mews:hub'), isTrue);
|
||||
expect(MewsUrls.isInternal('mews:'), isTrue);
|
||||
expect(MewsUrls.isInternal('https://example.test/'), isFalse);
|
||||
expect(MewsUrls.isInternal('not a url at all'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('isAction', () {
|
||||
test('accepts exactly one verb under do/', () {
|
||||
expect(MewsUrls.isAction('mews:do/set-theme?value=dark'), isTrue);
|
||||
expect(MewsUrls.isAction('mews:do/clear-history'), isTrue);
|
||||
});
|
||||
|
||||
test('rejects pages, bare do and deeper paths', () {
|
||||
expect(MewsUrls.isAction('mews:settings'), isFalse);
|
||||
expect(MewsUrls.isAction('mews:do'), isFalse);
|
||||
expect(MewsUrls.isAction('mews:do/a/b'), isFalse);
|
||||
// A fetched page must not reach the dispatcher by mimicking the path.
|
||||
expect(MewsUrls.isAction('https://example.test/do/clear-history'), isFalse);
|
||||
});
|
||||
|
||||
test('verbOf reads the verb back', () {
|
||||
expect(MewsUrls.verbOf('mews:do/set-size?step=up'), 'set-size');
|
||||
expect(MewsUrls.verbOf('mews:settings'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('canonical', () {
|
||||
test('bare mews: is the hub', () {
|
||||
expect(MewsUrls.canonical('mews:'), MewsUrls.hub);
|
||||
});
|
||||
|
||||
test('keeps a query string and drops a fragment', () {
|
||||
expect(MewsUrls.canonical('mews:bookmarks?tag=phones'),
|
||||
'mews:bookmarks?tag=phones');
|
||||
expect(MewsUrls.canonical('mews:settings#colors'), 'mews:settings');
|
||||
});
|
||||
|
||||
test('leaves external addresses alone', () {
|
||||
expect(MewsUrls.canonical('https://example.test/a?b=c'),
|
||||
'https://example.test/a?b=c');
|
||||
});
|
||||
});
|
||||
|
||||
group('action', () {
|
||||
test('builds an address the dispatcher accepts', () {
|
||||
final url = MewsUrls.action('set-theme', {'value': 'dark'});
|
||||
expect(url, 'mews:do/set-theme?value=dark');
|
||||
expect(MewsUrls.isAction(url), isTrue);
|
||||
});
|
||||
|
||||
test('round-trips a url carried inside the query string', () {
|
||||
// Every per-item action names its target this way, so the inner address
|
||||
// must survive encoding untouched.
|
||||
const target = 'https://example.test/p?q=1&r=2';
|
||||
final url = MewsUrls.action('remove-bookmark', {'url': target});
|
||||
expect(Uri.parse(url).queryParameters['url'], target);
|
||||
expect(MewsUrls.isAction(url), isTrue);
|
||||
});
|
||||
|
||||
test('takes no query string when there are no arguments', () {
|
||||
expect(MewsUrls.action('clear-history'), 'mews:do/clear-history');
|
||||
});
|
||||
});
|
||||
|
||||
group('page', () {
|
||||
test('merges over the existing parameters', () {
|
||||
final url = MewsUrls.page(Uri.parse('mews:bookmarks?tag=phones'),
|
||||
{'q': 'retro'});
|
||||
final params = Uri.parse(url).queryParameters;
|
||||
expect(params, {'tag': 'phones', 'q': 'retro'});
|
||||
});
|
||||
|
||||
test('an empty value drops the parameter', () {
|
||||
final url =
|
||||
MewsUrls.page(Uri.parse('mews:bookmarks?tag=phones&q=retro'), {'q': ''});
|
||||
expect(Uri.parse(url).queryParameters, {'tag': 'phones'});
|
||||
});
|
||||
|
||||
test('drops the query string once nothing is left', () {
|
||||
final url = MewsUrls.page(Uri.parse('mews:bookmarks?q=retro'), {'q': ''});
|
||||
expect(url, 'mews:bookmarks');
|
||||
});
|
||||
|
||||
test('survives a value needing escapes', () {
|
||||
final url = MewsUrls.page(Uri.parse(MewsUrls.bookmarks), {'q': 'a b&c=d'});
|
||||
expect(Uri.parse(url).queryParameters['q'], 'a b&c=d');
|
||||
expect(MewsUrls.canonical(url), url);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -25,8 +25,8 @@ void main() {
|
|||
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('Enter a web address'), findsOneWidget);
|
||||
// Settings opens from the left icon; no floating button remains.
|
||||
expect(find.byIcon(Icons.tune), findsOneWidget);
|
||||
// The hub opens from the left icon; no floating button remains.
|
||||
expect(find.byIcon(Icons.menu), findsOneWidget);
|
||||
expect(find.byType(FloatingActionButton), findsNothing);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue