feat: add quotations from marked text, with citations and sharing
This commit is contained in:
parent
0e2027c9e2
commit
e6e84fdc01
25 changed files with 1558 additions and 4 deletions
|
|
@ -1185,6 +1185,63 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('1 unread post'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('marked text can be kept as a quotation', (tester) async {
|
||||
final source = '''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title>Cats</title>
|
||||
<meta name="mews-profile" content="0.1" />
|
||||
<meta name="author" content="A. Author" />
|
||||
</head>
|
||||
<body><p>The finest sentence about cats ever written.</p></body>
|
||||
</html>
|
||||
''';
|
||||
final stubDeps = AppDependencies(
|
||||
database: MewsDatabase.test(NativeDatabase.memory()),
|
||||
fetch: FakeFetch({'https://example.com/cats.html': source}),
|
||||
strings: strings,
|
||||
);
|
||||
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await stubDeps.browser.open('https://example.com/cats.html');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// A long press on the word 'written' selects it and shows the toolbar.
|
||||
// The press lands on the glyphs: a wrapped line's geometric center can
|
||||
// sit between the lines, where no word is. The word is on the page's
|
||||
// second line, where the toolbar fits above it; on the first line the
|
||||
// toolbar would open past the top of the screen.
|
||||
final paragraph = tester.renderObject<RenderParagraph>(
|
||||
find.descendant(
|
||||
of: find.textContaining('finest sentence'),
|
||||
matching: find.byType(RichText)));
|
||||
final plain = paragraph.text.toPlainText();
|
||||
final word = plain.indexOf('written');
|
||||
final box = paragraph
|
||||
.getBoxesForSelection(
|
||||
TextSelection(baseOffset: word, extentOffset: word + 7))
|
||||
.first;
|
||||
final onWord = paragraph.localToGlobal(
|
||||
Offset((box.left + box.right) / 2, (box.top + box.bottom) / 2));
|
||||
final gesture = await tester.startGesture(onWord);
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Save as quotation'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The selection kept its page: address, title and the author the page's
|
||||
// metadata named.
|
||||
final kept = await stubDeps.db.quotationsList();
|
||||
expect(kept, hasLength(1));
|
||||
expect(kept.single.pageUrl, 'https://example.com/cats.html');
|
||||
expect(kept.single.pageTitle, 'Cats');
|
||||
expect(kept.single.author, 'A. Author');
|
||||
expect(kept.single.quote, contains('written'));
|
||||
});
|
||||
}
|
||||
|
||||
MewsPage parseSource(String source, String url) =>
|
||||
|
|
|
|||
|
|
@ -231,4 +231,61 @@ void main() {
|
|||
expect(await db.allClientCertificates(), isEmpty);
|
||||
expect(await db.bindingFor('capsule.example', 1965), isNull);
|
||||
});
|
||||
test('quotations are saved with their page and narrow by a search', () async {
|
||||
await db.addQuotation(
|
||||
quote: 'A fine sentence about cats.',
|
||||
pageUrl: 'https://example.com/cats.html',
|
||||
pageTitle: 'On cats',
|
||||
author: 'A. Author',
|
||||
);
|
||||
await db.addQuotation(
|
||||
quote: 'A dog is a dog is a dog.',
|
||||
pageUrl: 'https://example.com/dogs.html',
|
||||
);
|
||||
expect(await db.countQuotations(), 2);
|
||||
|
||||
// Saved within the same clock tick, the id tiebreak keeps the order they
|
||||
// were saved in; the page's title and author ride along.
|
||||
final kept = await db.quotationsList();
|
||||
expect(kept.first.pageUrl, 'https://example.com/cats.html');
|
||||
expect(kept.first.pageTitle, 'On cats');
|
||||
expect(kept.first.author, 'A. Author');
|
||||
|
||||
// A search matches the quoted text, the title, the author or the
|
||||
// address; it does not have to know which one it hit.
|
||||
expect((await db.quotationsList(query: 'cats')).length, 1);
|
||||
expect((await db.quotationsList(query: 'Author')).single.pageUrl,
|
||||
'https://example.com/cats.html');
|
||||
expect((await db.quotationsList(query: 'dogs.html')).single.quote,
|
||||
'A dog is a dog is a dog.');
|
||||
|
||||
// Ordered by page, a page without a title sorts under its address.
|
||||
final byPage = await db.quotationsList(sort: QuotationSort.page);
|
||||
expect(byPage.first.pageUrl, 'https://example.com/cats.html');
|
||||
|
||||
// A quotation of a page the reader's site lists as a post carries the
|
||||
// site's own title, so its citation can name it.
|
||||
await db.addSubscription('https://example.com/index.html', 'Signal Bars', null);
|
||||
await db.addEntries('https://example.com/index.html',
|
||||
[_entry('https://example.com/2026-10-08-retro.html')]);
|
||||
await db.addQuotation(
|
||||
quote: 'The finest retro handsets.',
|
||||
pageUrl: 'https://example.com/2026-10-08-retro.html',
|
||||
pageTitle: 'Collecting retro phones',
|
||||
);
|
||||
final post = (await db.quotationsList())
|
||||
.firstWhere((q) => q.pageUrl.contains('retro'));
|
||||
expect(post.siteTitle, 'Signal Bars');
|
||||
expect(await db.siteTitleOf('https://example.com/index.html'),
|
||||
'Signal Bars');
|
||||
expect(await db.siteTitleOf('https://unknown.example/'), isNull);
|
||||
|
||||
// One quotation by its id, for the actions that share or remove it.
|
||||
final id = kept.last.id;
|
||||
expect((await db.quotationById(id))?.quote, 'A dog is a dog is a dog.');
|
||||
await db.removeQuotation(id);
|
||||
expect(await db.quotationById(id), isNull);
|
||||
// The retro quotation from the site-title check remains.
|
||||
expect(await db.countQuotations(), 2);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
231
app/test/quotations_page_test.dart
Normal file
231
app/test/quotations_page_test.dart
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
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.'));
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue