232 lines
8.8 KiB
Dart
232 lines
8.8 KiB
Dart
|
|
import 'dart:io' show File;
|
||
|
|
|
||
|
|
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/pages/mews_urls.dart';
|
||
|
|
import 'package:mews/database/database.dart';
|
||
|
|
import 'package:mews/l10n/strings.dart';
|
||
|
|
import 'package:mews/mews/document.dart' show MewsEntry;
|
||
|
|
import 'package:mews/presentation/app.dart';
|
||
|
|
|
||
|
|
import 'fake_fetch.dart';
|
||
|
|
|
||
|
|
void main() {
|
||
|
|
late MewsDatabase db;
|
||
|
|
late Strings strings;
|
||
|
|
final shared = <String>[];
|
||
|
|
|
||
|
|
setUp(() async {
|
||
|
|
db = MewsDatabase.test(NativeDatabase.memory());
|
||
|
|
strings = Strings();
|
||
|
|
strings.loadSource(File('locales/en-US/main.ftl').readAsStringSync());
|
||
|
|
});
|
||
|
|
|
||
|
|
tearDown(() async {
|
||
|
|
await db.close();
|
||
|
|
shared.clear();
|
||
|
|
});
|
||
|
|
|
||
|
|
Future<AppDependencies> pumpApp(WidgetTester tester) async {
|
||
|
|
final deps = AppDependencies(
|
||
|
|
database: db,
|
||
|
|
fetch: FakeFetch(const {}),
|
||
|
|
strings: strings,
|
||
|
|
share: (text) async => shared.add(text),
|
||
|
|
);
|
||
|
|
await tester.pumpWidget(MewsApp(deps: deps, strings: strings));
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
return deps;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Taps the run reading [label] inside a rendered paragraph: several links
|
||
|
|
/// share one paragraph, so the widget's center can miss the glyphs. Pass
|
||
|
|
/// [within] to pick the paragraph when several carry the label.
|
||
|
|
Future<void> tapRun(WidgetTester tester, String label, {String? within}) async {
|
||
|
|
final text = within ?? label;
|
||
|
|
final paragraphFinder = find.byWidgetPredicate((widget) =>
|
||
|
|
widget is Text &&
|
||
|
|
(widget.textSpan?.toPlainText() ?? widget.data ?? '').contains(text));
|
||
|
|
// The row may sit below the fold; its glyphs must be on screen to tap.
|
||
|
|
await tester.ensureVisible(paragraphFinder.first);
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
final paragraph = tester.renderObject<RenderParagraph>(
|
||
|
|
find.descendant(of: paragraphFinder, 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('the hub counts the kept quotations', (tester) async {
|
||
|
|
final deps = await pumpApp(tester);
|
||
|
|
await deps.browser.restoreLastSession();
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
expect(find.textContaining('No quotations yet'), findsOneWidget);
|
||
|
|
|
||
|
|
await db.addQuotation(
|
||
|
|
quote: 'A line worth keeping.',
|
||
|
|
pageUrl: 'https://example.com/a.html');
|
||
|
|
await deps.browser.reload();
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
expect(find.textContaining('1 quotation'), findsOneWidget);
|
||
|
|
|
||
|
|
await db.addQuotation(
|
||
|
|
quote: 'Another line.',
|
||
|
|
pageUrl: 'https://example.com/b.html');
|
||
|
|
await deps.browser.reload();
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
expect(find.textContaining('2 quotations'), findsOneWidget);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('the page lists kept text with its citation and actions',
|
||
|
|
(tester) async {
|
||
|
|
await db.addQuotation(
|
||
|
|
quote: 'A line worth keeping.',
|
||
|
|
pageUrl: 'https://example.com/a.html',
|
||
|
|
pageTitle: 'Alpha',
|
||
|
|
author: 'A. Author');
|
||
|
|
await db.addQuotation(
|
||
|
|
quote: 'Another, plainer line.',
|
||
|
|
pageUrl: 'https://example.com/b.html');
|
||
|
|
final deps = await pumpApp(tester);
|
||
|
|
await deps.browser.open(MewsUrls.quotations);
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
|
||
|
|
expect(find.textContaining('A line worth keeping.'), findsOneWidget);
|
||
|
|
// The author the page named stands behind its words; a page that gave
|
||
|
|
// neither author nor title stands behind its address.
|
||
|
|
expect(find.textContaining('A. Author'), findsOneWidget);
|
||
|
|
expect(find.textContaining('example.com'), findsWidgets);
|
||
|
|
expect(find.textContaining('2 quotations'), findsOneWidget);
|
||
|
|
// The strip and the title both carry the page.
|
||
|
|
expect(find.text('Quotations'), findsWidgets);
|
||
|
|
// Each row carries its actions, in the paragraph of its row.
|
||
|
|
expect(find.textContaining('Share'), findsNWidgets(2));
|
||
|
|
expect(find.textContaining('Remove quotation'), findsNWidgets(2));
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('a search narrows the page and says so when nothing matches',
|
||
|
|
(tester) async {
|
||
|
|
await db.addQuotation(
|
||
|
|
quote: 'A line worth keeping.',
|
||
|
|
pageUrl: 'https://example.com/a.html',
|
||
|
|
author: 'A. Author');
|
||
|
|
await db.addQuotation(
|
||
|
|
quote: 'Another, plainer line.',
|
||
|
|
pageUrl: 'https://example.com/b.html');
|
||
|
|
final deps = await pumpApp(tester);
|
||
|
|
|
||
|
|
await deps.browser.open('mews:quotations?q=plain');
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
expect(find.textContaining('plainer line'), findsOneWidget);
|
||
|
|
expect(find.textContaining('A line worth keeping.'), findsNothing);
|
||
|
|
|
||
|
|
// The search matches the author and the address as well.
|
||
|
|
await deps.browser.open('mews:quotations?q=Author');
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
expect(find.textContaining('A line worth keeping.'), findsOneWidget);
|
||
|
|
|
||
|
|
await deps.browser.open('mews:quotations?q=nowhere');
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
expect(find.textContaining('No quotations match your search'),
|
||
|
|
findsOneWidget);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('sorting by page reorders the rows', (tester) async {
|
||
|
|
await db.addQuotation(
|
||
|
|
quote: 'A line worth keeping.',
|
||
|
|
pageUrl: 'https://example.com/zebra.html',
|
||
|
|
pageTitle: 'Zebra');
|
||
|
|
await db.addQuotation(
|
||
|
|
quote: 'Another, plainer line.',
|
||
|
|
pageUrl: 'https://example.com/ant.html',
|
||
|
|
pageTitle: 'Ant');
|
||
|
|
final deps = await pumpApp(tester);
|
||
|
|
await deps.browser.open('mews:quotations?sort=page');
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
|
||
|
|
// Ordered by their page, Ant comes before Zebra.
|
||
|
|
final rows = [
|
||
|
|
for (final text in ['Ant', 'Zebra'])
|
||
|
|
tester.getTopLeft(find.textContaining(text).first).dy,
|
||
|
|
];
|
||
|
|
expect(rows.first, lessThan(rows.last));
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('a quotation can be removed from its row', (tester) async {
|
||
|
|
await db.addQuotation(
|
||
|
|
quote: 'A line worth keeping.',
|
||
|
|
pageUrl: 'https://example.com/a.html');
|
||
|
|
final deps = await pumpApp(tester);
|
||
|
|
await deps.browser.open(MewsUrls.quotations);
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
expect(find.textContaining('A line worth keeping.'), findsOneWidget);
|
||
|
|
|
||
|
|
await tapRun(tester, 'Remove quotation');
|
||
|
|
expect(find.textContaining('A line worth keeping.'), findsNothing);
|
||
|
|
expect(find.textContaining('No quotations yet'), findsOneWidget);
|
||
|
|
});
|
||
|
|
|
||
|
|
testWidgets('sharing carries the quotation and its citation', (tester) async {
|
||
|
|
await db.addQuotation(
|
||
|
|
quote: 'A line worth keeping.',
|
||
|
|
pageUrl: 'https://example.com/a.html',
|
||
|
|
pageTitle: 'Alpha',
|
||
|
|
author: 'A. Author');
|
||
|
|
final deps = await pumpApp(tester);
|
||
|
|
await deps.browser.open(MewsUrls.quotations);
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
|
||
|
|
await tapRun(tester, 'Share');
|
||
|
|
expect(shared, hasLength(1));
|
||
|
|
expect(shared.single, contains('A line worth keeping.'));
|
||
|
|
// APA-shaped: the author, the no-date marker, the title, the site and
|
||
|
|
// the address.
|
||
|
|
expect(shared.single,
|
||
|
|
contains('A. Author (n.d.). Alpha. example.com. https://example.com/a.html.'));
|
||
|
|
|
||
|
|
// A page the reader's subscriptions know by name: the citation names
|
||
|
|
// the site's own title after the article, instead of the host.
|
||
|
|
await db.addSubscription(
|
||
|
|
'https://example.com/index.html', 'Signal Bars', null);
|
||
|
|
await db.addEntries('https://example.com/index.html', [
|
||
|
|
MewsEntry(
|
||
|
|
url: 'https://example.com/retro.html',
|
||
|
|
date: DateTime(2026, 10, 8),
|
||
|
|
title: 'Collecting retro phones'),
|
||
|
|
]);
|
||
|
|
await db.addQuotation(
|
||
|
|
quote: 'A retro line.',
|
||
|
|
pageUrl: 'https://example.com/retro.html',
|
||
|
|
pageTitle: 'Collecting retro phones');
|
||
|
|
await deps.browser.open(MewsUrls.hub);
|
||
|
|
await deps.browser.open(MewsUrls.quotations);
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
// The row names the site the text came from, not just the article.
|
||
|
|
expect(find.textContaining('Signal Bars'), findsOneWidget);
|
||
|
|
await tapRun(tester, 'Share', within: 'A retro line.');
|
||
|
|
expect(shared.last, contains('Collecting retro phones (n.d.). Signal Bars. https://example.com/retro.html.'));
|
||
|
|
|
||
|
|
// A page without an author leads with its title.
|
||
|
|
await db.addQuotation(
|
||
|
|
quote: 'A line without an author.',
|
||
|
|
pageUrl: 'https://example.com/b.html',
|
||
|
|
pageTitle: 'Bravo');
|
||
|
|
await deps.browser.open(MewsUrls.hub);
|
||
|
|
await deps.browser.open(MewsUrls.quotations);
|
||
|
|
await tester.pumpAndSettle();
|
||
|
|
await tapRun(tester, 'Share', within: 'A line without an author.');
|
||
|
|
expect(shared.last,
|
||
|
|
contains('Bravo (n.d.). example.com. https://example.com/b.html.'));
|
||
|
|
});
|
||
|
|
}
|