chore: Halkyon Learn moves into apps/learn

Its server, client, app and curator, and the tools that write its
assets (maps, Anki fixtures, icons), as they are; paths are fixed in
the commits that follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-19 20:08:06 +02:00
commit 649499be23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 0 additions and 0 deletions

View file

@ -0,0 +1,230 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_note_gateway.dart';
import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/memory_asset_file_cache.dart';
Uint8List pdf(String text) => Uint8List.fromList(utf8.encode('%PDF-1.7 $text'));
void main() {
late AssetRepository assets;
late PdfSourceRepository pdfs;
late PdfDeletions deletions;
late CardRepository cards;
late NoteRepository notes;
late PdfListBloc bloc;
setUp(() {
assets = AssetRepository(
FakeAssetGateway(),
FakeAssetServerGateway(),
MemoryAssetFileCache(),
);
pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
deletions = PdfDeletions(pdfs, undoWindow: const Duration(hours: 1));
cards = CardRepository(FakeCardGateway());
notes = NoteRepository(FakeNoteGateway());
});
tearDown(() async {
await bloc.close();
await deletions.dispose();
await pdfs.dispose();
await cards.dispose();
await notes.dispose();
await assets.dispose();
});
void start() => bloc = PdfListBloc(
pdfs,
deletions,
cards,
notes,
assets,
'japanese',
)..add(const PdfListEventStarted());
Future<PdfListState> until(bool Function(PdfListState) test) =>
test(bloc.state)
? Future.value(bloc.state)
: bloc.stream.firstWhere(test);
Future<String> add(String name, [String text = '']) async =>
(await pdfs.import(
'japanese',
bytes: pdf(text.isEmpty ? name : text),
fileName: '$name.pdf',
) as PdfImported).id;
test("lists the studyset's PDFs with their cards, notes, size and how far "
'they are read', () async {
final grammar = await add('Grammar', 'a longer grammar file');
await add('Kana');
await pdfs.reached(grammar, 7);
await cards.create(
'japanese',
CardContent(
front: RichContent.plain('は'),
back: RichContent.plain('topic'),
source: (pdfId: grammar, page: 3),
),
);
await notes.create(
'japanese',
NoteContent(
body: RichContent.plain('p. 3'),
source: (pdfId: grammar, page: 3),
),
);
start();
final state = await until(
(s) => s.rows.any(
(row) =>
row.cards == 1 &&
row.notes == 1 &&
row.byteSize != null &&
row.furthestPage != null,
),
);
// In the person's own order by default: as added, until reordered.
expect(
[for (final row in state.rows) row.source.content.title],
[
'Grammar',
'Kana',
],
);
final row = state.rows.first;
expect(row.cards, 1);
expect(row.notes, 1);
expect(row.furthestPage, 7);
expect(row.byteSize, pdf('a longer grammar file').length);
});
test('sorts by name, age or size, as chosen', () async {
await add('b', 'b');
await add('a', 'a much larger file');
await add('c', 'c');
start();
await until(
(s) => s.rows.length == 3 && s.rows.every((r) => r.byteSize != null),
);
List<String> titles() => [
for (final row in bloc.state.rows) row.source.content.title,
];
bloc.add(const PdfListEventSorted(PdfSort.name));
await until((s) => s.sort == PdfSort.name);
expect(titles(), ['a', 'b', 'c']);
bloc.add(const PdfListEventSorted(PdfSort.oldest));
await until((s) => s.sort == PdfSort.oldest);
expect(titles(), ['b', 'a', 'c']);
bloc.add(const PdfListEventSorted(PdfSort.largest));
await until((s) => s.sort == PdfSort.largest);
expect(titles().first, 'a');
bloc.add(const PdfListEventSorted(PdfSort.newest));
await until((s) => s.sort == PdfSort.newest);
expect(titles(), ['c', 'a', 'b']);
});
test("moves a PDF in the person's own order, which the other orders "
'leave be', () async {
await add('b', 'b');
await add('a', 'a');
final c = await add('c', 'c');
start();
await until((s) => s.rows.length == 3);
List<String> titles() => [
for (final row in bloc.state.rows) row.source.content.title,
];
bloc.add(PdfListEventMoveRequested(c, 0));
await until((s) => s.rows.first.source.id == c);
expect(titles(), ['c', 'b', 'a']);
bloc.add(const PdfListEventSorted(PdfSort.oldest));
await until((s) => s.sort == PdfSort.oldest);
expect(titles(), ['b', 'a', 'c']);
});
test('leaves out a PDF whose deletion can still be undone', () async {
final kana = await add('Kana');
start();
await until((s) => s.rows.length == 1);
deletions.delete(kana);
await until((s) => s.rows.isEmpty);
deletions.undo(kana);
await until((s) => s.rows.length == 1);
});
group('importing', () {
test(
'says what came of it, a duplicate offered to be added anyway',
() async {
await add('Grammar', 'grammar');
start();
await until((s) => s.rows.length == 1);
bloc.add(
PdfListEventImportRequested(bytes: pdf('grammar'), fileName: 'g.pdf'),
);
final duplicate = await until((s) => s.importOutcome != null);
expect(duplicate.importOutcome!.result, isA<PdfAlreadyThere>());
expect(duplicate.importOutcome!.title, 'g');
bloc.add(const PdfListEventImportedAgain());
final again = await until(
(s) => s.importOutcome?.result is PdfImported && s.rows.length == 2,
);
expect(again.importing, isFalse);
},
);
test('refuses what is no PDF', () async {
start();
await until((s) => s.loaded);
bloc.add(
PdfListEventImportRequested(
bytes: Uint8List.fromList(utf8.encode('text')),
fileName: 'notes.pdf',
),
);
final state = await until((s) => s.importOutcome != null);
expect(state.importOutcome!.result, const PdfRefused(PdfRefusal.notPdf));
expect(state.rows, isEmpty);
});
});
test('renames, sets a deadline, marks as read and starts afresh', () async {
final kana = await add('Kana');
await pdfs.setPageCount(kana, 40);
start();
await until((s) => s.rows.length == 1);
bloc.add(PdfListEventRenamed(kana, 'Hiragana'));
await until((s) => s.rows.single.source.content.title == 'Hiragana');
bloc.add(PdfListEventFinished(kana));
await until((s) => s.rows.single.furthestPage == 40);
bloc.add(PdfListEventReadingReset(kana));
await until((s) => s.rows.single.furthestPage == null);
});
}

View file

@ -0,0 +1,111 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_reader_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_pdf_engine.dart';
import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/memory_asset_file_cache.dart';
Uint8List pdf(String text) => Uint8List.fromList(utf8.encode('%PDF-1.7 $text'));
void main() {
late MemoryAssetFileCache files;
late AssetRepository assets;
late PdfSourceRepository pdfs;
late FakePdfEngine engine;
late PdfReaderBloc bloc;
setUp(() {
files = MemoryAssetFileCache();
assets = AssetRepository(
FakeAssetGateway(),
FakeAssetServerGateway(),
files,
);
pdfs = PdfSourceRepository(FakePdfSourceGateway(), assets);
engine = FakePdfEngine();
});
tearDown(() async {
await bloc.close();
await pdfs.dispose();
await assets.dispose();
});
Future<String> add(String text) async => (await pdfs.import(
'japanese',
bytes: pdf(text),
fileName: 'g.pdf',
) as PdfImported).id;
Future<PdfReaderState> open(String id, {int? page}) {
bloc = PdfReaderBloc(pdfs, assets, engine, id, page: page)
..add(const PdfReaderEventStarted());
return bloc.stream.firstWhere((s) => s is! PdfReaderStateLoading);
}
test('opens the PDF, notes its page count and chapters, and starts on '
'its first page', () async {
final id = await add('pages:40 chapter:1:Kana; chapter:12:Particles;');
final ready = await open(id) as PdfReaderStateReady;
expect(ready.document.pageCount, 40);
expect(ready.chapters, [
(title: 'Kana', page: 1),
(title: 'Particles', page: 12),
]);
expect(ready.startPage, 1);
expect((await pdfs.byId(id))!.content.pageCount, 40);
});
test('resumes at the furthest page read, unless a page is asked for or '
'it was read to the end', () async {
final id = await add('pages:40');
await pdfs.reached(id, 17);
expect((await open(id) as PdfReaderStateReady).startPage, 17);
await bloc.close();
expect((await open(id, page: 3) as PdfReaderStateReady).startPage, 3);
await bloc.close();
await pdfs.reached(id, 40);
expect((await open(id) as PdfReaderStateReady).startPage, 1);
});
test('records each page shown, the furthest kept', () async {
final id = await add('pages:40');
await open(id);
bloc
..add(const PdfReaderEventPageShown(5))
..add(const PdfReaderEventPageShown(2));
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
expect(await pdfs.furthestPages(), {id: 5});
});
test("says when the file isn't on this device, or can't be read, or the "
'PDF is gone', () async {
final id = await add('pages:4');
files.files.clear();
expect(await open(id), const PdfReaderStateMissing());
await bloc.close();
final broken = (await pdfs.import(
'japanese',
bytes: pdf('fine'),
fileName: 'b.pdf',
) as PdfImported).id;
engine.failNext = true;
expect(await open(broken), const PdfReaderStateUnreadable());
await bloc.close();
expect(await open('nowhere'), const PdfReaderStateGone());
});
}

View file

@ -0,0 +1,109 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
void main() {
ByteData bytes(Object json) =>
ByteData.sublistView(utf8.encode(jsonEncode(json)));
const content = PdfSourceContent(
title: '日本語の文法',
fileName: '日本語の文法.pdf',
assetId: 'file-1',
);
test('a PDF payload survives a round trip, Japanese included', () {
expect(decodePdfSourcePayload(encodePdfSourcePayload(content)), content);
});
test('keeps the page count and a deadline once there are some', () {
final full = content
.withPageCount(240)
.withDeadline(
const ReadingDeadline(
targetPage: 120,
due: (year: 2026, month: 10, day: 31),
start: (year: 2026, month: 10, day: 5),
),
);
expect(decodePdfSourcePayload(encodePdfSourcePayload(full)), full);
expect(
decodePayload(
encodePdfSourcePayload(full),
newestSchema: pdfSourceSchemaVersion,
)['deadline'],
{'targetPage': 120, 'due': '2026-10-31', 'start': '2026-10-05'},
);
});
test('refuses a schema newer than this app knows', () {
expect(
() => decodePdfSourcePayload(
bytes({
'schemaVersion': pdfSourceSchemaVersion + 1,
'title': 'x',
'fileName': 'x.pdf',
'asset': 'a',
}),
),
throwsFormatException,
);
});
test('refuses what is not a PDF payload, a blanked one included', () {
expect(
() => decodePdfSourcePayload(redactedPayload),
throwsFormatException,
);
for (final json in [
{'schemaVersion': 1, 'title': 'x'},
{
'schemaVersion': 1,
'title': 'x',
'fileName': 'x.pdf',
'asset': 'a',
'pageCount': 0,
},
{
'schemaVersion': 1,
'title': 'x',
'fileName': 'x.pdf',
'asset': 'a',
'deadline': {'targetPage': 3, 'due': 'soon'},
},
]) {
expect(
() => decodePdfSourcePayload(bytes(json)),
throwsFormatException,
reason: '$json',
);
}
});
test('titles a PDF by its file name without .pdf', () {
expect(titleFromFileName('Genki I.PDF'), 'Genki I');
expect(titleFromFileName(' notes '), 'notes');
});
test('counts days between calendar days, across months and years', () {
expect(
daysBetween(
(year: 2026, month: 12, day: 30),
(year: 2027, month: 1, day: 2),
),
3,
);
expect(
daysBetween(
(year: 2026, month: 10, day: 5),
(year: 2026, month: 10, day: 3),
),
-2,
);
});
}

View file

@ -0,0 +1,266 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/memory_asset_file_cache.dart';
/// A PDF's bytes: its signature, then [text].
Uint8List pdf(String text) => Uint8List.fromList(utf8.encode('%PDF-1.7 $text'));
void main() {
late FakePdfSourceGateway gateway;
late FakeAssetGateway assetGateway;
late AssetRepository assets;
late PdfSourceRepository pdfs;
setUp(() {
gateway = FakePdfSourceGateway();
assetGateway = FakeAssetGateway();
assets = AssetRepository(
assetGateway,
FakeAssetServerGateway(),
MemoryAssetFileCache(),
);
pdfs = PdfSourceRepository(gateway, assets);
});
tearDown(() async {
await pdfs.dispose();
await assets.dispose();
});
group('importing', () {
test("adds a PDF titled by its file's name, its file kept as the "
"person's", () async {
final added = await pdfs.import(
'japanese',
bytes: pdf('grammar'),
fileName: 'Grammar Guide.pdf',
);
final source = (await pdfs.sourcesOf('japanese')).single;
expect(added, PdfImported(source.id));
expect(source.content.title, 'Grammar Guide');
expect(source.content.fileName, 'Grammar Guide.pdf');
expect(source.content.pageCount, isNull);
final asset = assetGateway.rows.single;
expect(source.content.assetId, asset.id);
expect(asset.mimeType, 'application/pdf');
});
test('refuses what is no PDF, and a PDF over 50 MB', () async {
expect(
await pdfs.import(
'japanese',
bytes: Uint8List.fromList(utf8.encode('not a pdf')),
fileName: 'notes.pdf',
),
const PdfRefused(PdfRefusal.notPdf),
);
final huge = Uint8List(PdfSourceRepository.maxBytes + 1)
..setAll(0, utf8.encode('%PDF-'));
expect(
await pdfs.import('japanese', bytes: huge, fileName: 'huge.pdf'),
const PdfRefused(PdfRefusal.tooLarge),
);
expect(await pdfs.sourcesOf('japanese'), isEmpty);
expect(assetGateway.rows, isEmpty);
});
test('says when the studyset holds the same file already, and adds it '
'again only when asked to', () async {
final first = await pdfs.import(
'japanese',
bytes: pdf('grammar'),
fileName: 'a.pdf',
) as PdfImported;
expect(
await pdfs.import('japanese', bytes: pdf('grammar'), fileName: 'b.pdf'),
PdfAlreadyThere(first.id),
);
expect(
await pdfs.import(
'japanese',
bytes: pdf('grammar'),
fileName: 'b.pdf',
evenIfThere: true,
),
isA<PdfImported>(),
);
// Another studyset may hold it too, from the same file.
expect(
await pdfs.import('korean', bytes: pdf('grammar'), fileName: 'a.pdf'),
isA<PdfImported>(),
);
expect(await pdfs.sourcesOf('japanese'), hasLength(2));
expect(assetGateway.rows, hasLength(1));
});
});
test('keeps a new title, the page count and a deadline', () async {
final id = (await pdfs.import(
'japanese',
bytes: pdf('g'),
fileName: 'g.pdf',
) as PdfImported).id;
const deadline = ReadingDeadline(
targetPage: 80,
due: (year: 2026, month: 10, day: 31),
);
await pdfs.rename(id, ' Tae Kim ');
await pdfs.setPageCount(id, 240);
await pdfs.setDeadline(id, deadline);
final content = (await pdfs.byId(id))!.content;
expect(content.title, 'Tae Kim');
expect(content.pageCount, 240);
expect(content.deadline, deadline);
await pdfs.setDeadline(id, null);
expect((await pdfs.byId(id))!.content.deadline, isNull);
});
test('lists PDFs in their order: as added, PDFs from before there was one '
'too, until moved', () async {
// From before positions: all at a0, in the order they were added.
gateway
..receiveRemote(
id: 'pdf-0901',
studysetId: 'japanese',
payload: encodePdfSourcePayload(
const PdfSourceContent(title: 'old 1', fileName: '', assetId: ''),
),
)
..receiveRemote(
id: 'pdf-0902',
studysetId: 'japanese',
payload: encodePdfSourcePayload(
const PdfSourceContent(title: 'old 2', fileName: '', assetId: ''),
),
);
final added = await pdfs.import(
'japanese',
bytes: pdf('new'),
fileName: 'new.pdf',
);
Future<List<String>> titles() async => [
for (final source in await pdfs.sourcesOf('japanese'))
source.content.title,
];
expect(await titles(), ['old 1', 'old 2', 'new']);
await pdfs.move('japanese', (added as PdfImported).id, toIndex: 0);
expect(await titles(), ['new', 'old 1', 'old 2']);
await pdfs.move('japanese', 'pdf-0901', toIndex: 2);
expect(await titles(), ['new', 'old 2', 'old 1']);
});
test("leaves out a PDF it can't read, and counts the others by "
'studyset', () async {
await pdfs.import('japanese', bytes: pdf('a'), fileName: 'a.pdf');
await pdfs.import('japanese', bytes: pdf('b'), fileName: 'b.pdf');
await pdfs.import('korean', bytes: pdf('c'), fileName: 'c.pdf');
gateway.receiveRemote(
id: 'pdf-9999',
studysetId: 'japanese',
payload: encodePayload(pdfSourceSchemaVersion + 1, {}),
);
expect(await pdfs.sourcesOf('japanese'), hasLength(2));
expect(await pdfs.countsByStudyset(), {'japanese': 2, 'korean': 1});
});
group('deleting', () {
test('blanks the PDF before it goes, with its reading, and its file '
'unless another PDF has it', () async {
final a = (await pdfs.import(
'japanese',
bytes: pdf('a'),
fileName: 'a.pdf',
) as PdfImported).id;
final copy = (await pdfs.import(
'korean',
bytes: pdf('a'),
fileName: 'a.pdf',
) as PdfImported).id;
final b = (await pdfs.import(
'japanese',
bytes: pdf('b'),
fileName: 'b.pdf',
) as PdfImported).id;
await pdfs.reached(a, 3);
await pdfs.delete(a);
expect(gateway.deletions, ['blank $a', 'delete $a']);
expect(await pdfs.furthestPages(), isEmpty);
// The copy in Korean still has the file.
expect(assetGateway.rows, hasLength(2));
await pdfs.delete(copy);
await pdfs.delete(b);
expect(assetGateway.rows, isEmpty);
expect(assetGateway.deleted, hasLength(2));
});
test('every PDF of a studyset goes before it does', () async {
await pdfs.import('japanese', bytes: pdf('a'), fileName: 'a.pdf');
await pdfs.import('japanese', bytes: pdf('b'), fileName: 'b.pdf');
await pdfs.import('korean', bytes: pdf('c'), fileName: 'c.pdf');
await pdfs.deleteAllOf('japanese');
expect(await pdfs.sourcesOf('japanese'), isEmpty);
expect(await pdfs.sourcesOf('korean'), hasLength(1));
});
});
group('reading', () {
late String id;
setUp(() async {
id = (await pdfs.import(
'japanese',
bytes: pdf('a'),
fileName: 'a.pdf',
) as PdfImported).id;
await pdfs.setPageCount(id, 100);
});
test('remembers the furthest page reached, writing only when it '
'grows', () async {
await withClock(Clock.fixed(DateTime.utc(2026, 10, 3, 9)), () async {
await pdfs.reached(id, 5);
await pdfs.reached(id, 3);
await pdfs.reached(id, 5);
await pdfs.reached(id, 9);
});
expect(await pdfs.furthestPages(), {id: 9});
expect(gateway.readingRows.map((row) => row.page), [5, 9]);
});
test('a fresh start forgets the pages read; finishing reaches the '
'last', () async {
await pdfs.reached(id, 40);
await pdfs.resetReading(id);
expect(await pdfs.furthestPages(), isEmpty);
await pdfs.reached(id, 2);
expect(await pdfs.furthestPages(), {id: 2});
await pdfs.finish(id);
expect(await pdfs.furthestPages(), {id: 100});
});
});
}

View file

@ -0,0 +1,65 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/deadline_status.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
void main() {
const today = (year: 2026, month: 10, day: 3);
DeadlineStatus status({
int target = 100,
CalendarDay due = (year: 2026, month: 10, day: 12),
CalendarDay? start,
int furthest = 0,
}) => DeadlineStatus.of(
ReadingDeadline(targetPage: target, due: due, start: start),
furthestPage: furthest,
today: today,
);
test('spreads the pages left over the days left, today and the due day '
'included', () {
final ten = status(furthest: 40);
expect(ten.pagesLeft, 60);
expect(ten.daysLeft, 10);
expect(ten.pagesPerDay, 6);
expect(ten.pageToReachToday, 46);
expect(ten.fraction, closeTo(0.4, 1e-9));
expect(ten.state, DeadlineState.onTrack);
});
test('rounds the pace up, so the deadline is met', () {
expect(status(target: 11, due: today).pagesPerDay, 11);
expect(
status(target: 11, due: (year: 2026, month: 10, day: 4)).pagesPerDay,
6,
);
});
test('is met once the target page is reached, whatever the day', () {
final met = status(furthest: 100, due: (year: 2026, month: 9, day: 1));
expect(met.state, DeadlineState.met);
expect(met.pagesLeft, 0);
expect(met.fraction, 1);
});
test('is due today on its last day, and overdue after it', () {
expect(status(due: today, furthest: 90).state, DeadlineState.dueToday);
expect(status(due: today, furthest: 90).pagesPerDay, 10);
final passed = status(due: (year: 2026, month: 10, day: 1), furthest: 90);
expect(passed.state, DeadlineState.overdue);
expect(passed.pagesLeft, 10);
expect(passed.pagesPerDay, 10);
});
test('is upcoming before its start, the pace counted from the start', () {
final upcoming = status(start: (year: 2026, month: 10, day: 8));
expect(upcoming.state, DeadlineState.upcoming);
expect(upcoming.daysUntilStart, 5);
expect(upcoming.pagesPerDay, 20);
// Nothing to read yet today.
expect(upcoming.pageToReachToday, 0);
});
}

View file

@ -0,0 +1,85 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/deadline_status.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/reading_deadlines.dart';
void main() {
const today = (year: 2026, month: 10, day: 3);
PdfSource pdf(
String id, {
String studysetId = 'japanese',
int target = 100,
CalendarDay due = (year: 2026, month: 10, day: 20),
CalendarDay? start,
bool deadline = true,
}) => PdfSource(
id: id,
studysetId: studysetId,
content: PdfSourceContent(
title: id,
fileName: '$id.pdf',
assetId: 'a-$id',
pageCount: 200,
deadline: deadline
? ReadingDeadline(targetPage: target, due: due, start: start)
: null,
),
);
test('lists running deadlines, the fewest days left first, and upcoming '
'ones, the soonest start first; met ones and archived studysets '
'left out', () {
final deadlines = readingDeadlinesOf(
[
pdf('later'),
pdf('soon', due: (year: 2026, month: 10, day: 5)),
pdf('met', target: 10),
pdf('none', deadline: false),
pdf('archived', studysetId: 'old'),
pdf(
'next month',
start: (year: 2026, month: 11, day: 1),
due: (year: 2026, month: 11, day: 30),
),
pdf(
'next week',
start: (year: 2026, month: 10, day: 10),
due: (year: 2026, month: 11, day: 30),
),
],
furthest: {'met': 10, 'soon': 50},
today: today,
resting: {'old'},
);
expect(
[for (final (:pdf, status: _) in deadlines.current) pdf.id],
[
'soon',
'later',
],
);
expect(deadlines.current.first.status.state, DeadlineState.onTrack);
expect(
[for (final (:pdf, status: _) in deadlines.upcoming) pdf.id],
[
'next week',
'next month',
],
);
expect(deadlines.isEmpty, isFalse);
});
test('is empty without any running or upcoming deadline', () {
expect(
readingDeadlinesOf(
[pdf('met', target: 1)],
furthest: {'met': 1},
today: today,
resting: const {},
).isEmpty,
isTrue,
);
});
}

View file

@ -0,0 +1,352 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/content_tab_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_file_picker.dart';
import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/memory_asset_file_cache.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
Uint8List pdf(String text) => Uint8List.fromList(utf8.encode('%PDF-1.7 $text'));
void main() {
late FakeStudysetGateway studysets;
late FakePdfSourceGateway pdfGateway;
late FakeAssetGateway assetGateway;
late FakeFilePicker files;
late MemoryAssetFileCache cache;
setUp(() {
studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
);
pdfGateway = FakePdfSourceGateway();
assetGateway = FakeAssetGateway();
files = FakeFilePicker();
cache = MemoryAssetFileCache();
});
/// A repository of the test's own on the app's fake gateways, to store
/// PDFs before the app reads them.
PdfSourceRepository repository() {
final assets = AssetRepository(
assetGateway,
FakeAssetServerGateway(),
cache,
);
final pdfs = PdfSourceRepository(pdfGateway, assets);
addTearDown(() async {
await pdfs.dispose();
await assets.dispose();
});
return pdfs;
}
Future<void> showApp(WidgetTester tester) async {
tester.view.physicalSize = const Size(1200, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
pdfGateway: pdfGateway,
assetGateway: assetGateway,
assetFiles: cache,
files: files,
),
location: RoutePaths.studyset('japanese'),
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
/// A menu item acts in the frame after its menu closes.
Future<void> choose(WidgetTester tester, String item) async {
await tester.tap(find.text(item));
await tester.pump();
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
Future<void> upload(WidgetTester tester, String name, Uint8List bytes) async {
files.next = (name: name, bytes: bytes);
await tester.tap(find.text('Add material'));
await tester.pumpAndSettle();
await choose(tester, 'Upload a PDF');
}
testWidgets('a PDF uploaded from Add material shows in Files, titled by '
'its name', (tester) async {
await showApp(tester);
await upload(tester, 'Grammar Guide.pdf', pdf('grammar'));
expect(files.asked.single, ['pdf']);
expect(find.text('Added "Grammar Guide".'), findsOneWidget);
expect(shownPart(tester), StudysetPart.material);
expect(find.text('Grammar Guide'), findsOneWidget);
});
testWidgets('a file the studyset holds already asks first; a copy is added '
'only when asked for', (tester) async {
await repository().import(
'japanese',
bytes: pdf('grammar'),
fileName: 'Grammar.pdf',
);
await showApp(tester);
await upload(tester, 'Copy.pdf', pdf('grammar'));
expect(find.text('PDF already here'), findsOneWidget);
await tester.tap(find.text('Keep the one there'));
await tester.pumpAndSettle();
expect(await pdfGateway.sources(), hasLength(1));
await upload(tester, 'Copy.pdf', pdf('grammar'));
await tester.tap(find.text('Add a copy'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(await pdfGateway.sources(), hasLength(2));
expect(find.text('Copy'), findsOneWidget);
});
testWidgets("a file that isn't a PDF is refused, and says so", (
tester,
) async {
await showApp(tester);
await upload(tester, 'notes.pdf', Uint8List.fromList(utf8.encode('text')));
expect(find.text("This file isn't a PDF."), findsOneWidget);
expect(await pdfGateway.sources(), isEmpty);
});
testWidgets('a row shows its pages, how far it is read and its deadline; '
'a studyset of PDFs only opens on them', (tester) async {
final pdfs = repository();
final id = (await pdfs.import(
'japanese',
bytes: pdf('grammar'),
fileName: 'Grammar.pdf',
) as PdfImported).id;
await pdfs.setPageCount(id, 240);
await pdfs.reached(id, 60);
await pdfs.setDeadline(
id,
const ReadingDeadline(
targetPage: 120,
due: (year: 2026, month: 10, day: 12),
),
);
await withClock(Clock.fixed(DateTime(2026, 10, 3, 9)), () async {
await showApp(tester);
});
expect(shownPart(tester), StudysetPart.material);
expect(find.textContaining('240 pages'), findsOneWidget);
expect(find.text('25% read'), findsOneWidget);
expect(find.text('6/day to reach page 120 in 10 days'), findsOneWidget);
});
testWidgets('sorts by name from the Sort menu, in the row of the tabs', (
tester,
) async {
final pdfs = repository();
for (final name in ['b', 'c', 'a']) {
await pdfs.import('japanese', bytes: pdf(name), fileName: '$name.pdf');
}
await showApp(tester);
double top(String title) => tester.getTopLeft(find.text(title)).dy;
// In the person's own order by default: as added, until reordered.
expect(top('b'), lessThan(top('c')));
expect(top('c'), lessThan(top('a')));
final sort = find.byTooltip('Sort').hitTestable();
expect(
tester.getCenter(sort).dy,
moreOrLessEquals(tester.getCenter(find.byType(TabBar)).dy, epsilon: 4),
);
await tester.tap(sort);
await tester.pumpAndSettle();
await choose(tester, 'Name (A–Z)');
expect(top('a'), lessThan(top('b')));
expect(top('b'), lessThan(top('c')));
});
testWidgets('Reorder beside the files shows the custom order, whatever the '
'files were sorted by, and drags a file elsewhere in it, until '
'Done', (tester) async {
final pdfs = repository();
for (final name in ['b', 'a']) {
await pdfs.import('japanese', bytes: pdf(name), fileName: '$name.pdf');
}
await showApp(tester);
await tester.tap(find.byTooltip('Sort').hitTestable());
await tester.pumpAndSettle();
await choose(tester, 'Name (A–Z)');
double top(String title) => tester.getTopLeft(find.text(title)).dy;
expect(top('a'), lessThan(top('b')));
// With its word where there is room, else its icon.
final reorder = find.text('Reorder').hitTestable();
await tester.tap(
reorder.evaluate().isNotEmpty
? reorder
: find.byTooltip('Reorder files').hitTestable(),
);
await tester.pumpAndSettle();
expect(find.byTooltip('Drag to reorder'), findsNWidgets(2));
expect(find.byTooltip('Sort').hitTestable(), findsNothing);
expect(top('b'), lessThan(top('a')));
// The first file, dragged below the second.
final row = tester.getSize(find.widgetWithText(ListTile, 'b'));
final drag = await tester.startGesture(
tester.getCenter(find.byTooltip('Drag to reorder').first),
);
for (var step = 0; step < 6; step++) {
await drag.moveBy(Offset(0, row.height * 1.5 / 6));
await tester.pump(const Duration(milliseconds: 50));
}
await drag.up();
await tester.pumpAndSettle();
expect(
[
for (final source in await pdfs.sourcesOf('japanese'))
source.content.title,
],
['a', 'b'],
);
final done = find.text('Done').hitTestable();
await tester.tap(
done.evaluate().isNotEmpty ? done : find.byTooltip('Done').hitTestable(),
);
await tester.pumpAndSettle();
expect(find.byTooltip('Drag to reorder'), findsNothing);
expect(find.byTooltip('Sort').hitTestable(), findsOneWidget);
expect(top('a'), lessThan(top('b')));
});
testWidgets('renames, and deletes with a moment to undo', (tester) async {
await repository().import(
'japanese',
bytes: pdf('kana'),
fileName: 'kana.pdf',
);
await showApp(tester);
await tester.tap(find.byTooltip('Actions'));
await tester.pumpAndSettle();
await choose(tester, 'Rename');
await tester.enterText(find.byType(TextField), 'Hiragana');
await tester.tap(find.text('Save'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Hiragana'), findsOneWidget);
await tester.tap(find.byTooltip('Actions'));
await tester.pumpAndSettle();
await choose(tester, 'Delete');
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Hiragana'), findsNothing);
expect(find.text('PDF deleted'), findsOneWidget);
await tester.tap(find.text('Undo'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Hiragana'), findsOneWidget);
expect(pdfGateway.deletions, isEmpty);
});
testWidgets("a PDF's More info shows its ids and its file, as other "
"things' More info does", (tester) async {
await repository().import(
'japanese',
bytes: pdf('kana'),
fileName: 'kana.pdf',
);
await showApp(tester);
await tester.tap(find.byTooltip('Actions'));
await tester.pumpAndSettle();
await choose(tester, 'More info');
expect(find.text('Item ID'), findsOneWidget);
expect(find.text('japanese'), findsOneWidget);
expect(find.text('File'), findsOneWidget);
expect(find.text('kana.pdf'), findsOneWidget);
expect(find.text('Pages'), findsOneWidget);
});
testWidgets('a deadline may be to finish a chapter, read from the PDF', (
tester,
) async {
final pdfs = repository();
final id = (await pdfs.import(
'japanese',
bytes: pdf('pages:40 chapter:1:Kana; chapter:5:Particles;'),
fileName: 'g.pdf',
) as PdfImported).id;
await pdfs.setPageCount(id, 40);
await showApp(tester);
await tester.tap(find.byTooltip('Actions'));
await tester.pumpAndSettle();
await choose(tester, 'Set reading deadline');
await tester.tap(find.text('Or finish a chapter'));
await tester.pumpAndSettle();
await tester.tap(find.text('Kana · to p.4').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Save deadline'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect((await pdfs.byId(id))!.content.deadline!.targetPage, 4);
});
testWidgets('a deadline set from the row shows its pace', (tester) async {
final pdfs = repository();
final id = (await pdfs.import(
'japanese',
bytes: pdf('k'),
fileName: 'k.pdf',
) as PdfImported).id;
await pdfs.setPageCount(id, 40);
await showApp(tester);
await tester.tap(find.byTooltip('Actions'));
await tester.pumpAndSettle();
await choose(tester, 'Set reading deadline');
expect(find.text('Reach page (of 40)'), findsOneWidget);
await tester.tap(find.text('Save deadline'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
final deadline = (await pdfs.byId(id))!.content.deadline!;
expect(deadline.targetPage, 40);
expect(find.textContaining('/day to reach page 40'), findsOneWidget);
});
}

View file

@ -0,0 +1,452 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_capture.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:material_ui/material_ui.dart' hide Card;
import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_note_gateway.dart';
import '../../../support/fake_pdf_engine.dart';
import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/fake_tag_gateway.dart';
import '../../../support/memory_asset_file_cache.dart';
import '../../../support/memory_device_settings_store.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
void main() {
late FakeStudysetGateway studysets;
late FakePdfSourceGateway pdfGateway;
late FakeAssetGateway assetGateway;
late MemoryAssetFileCache files;
late FakeCardGateway cardGateway;
late FakeNoteGateway noteGateway;
late FakeTagGateway tagGateway;
late FakePdfEngine engine;
setUp(() {
studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
);
pdfGateway = FakePdfSourceGateway();
assetGateway = FakeAssetGateway();
files = MemoryAssetFileCache();
cardGateway = FakeCardGateway();
noteGateway = FakeNoteGateway();
tagGateway = FakeTagGateway();
engine = FakePdfEngine();
});
/// A PDF of 40 pages in two chapters, stored before the app starts.
Future<String> stored({int? readTo}) async {
final assets = AssetRepository(
assetGateway,
FakeAssetServerGateway(),
files,
);
final pdfs = PdfSourceRepository(pdfGateway, assets);
addTearDown(() async {
await pdfs.dispose();
await assets.dispose();
});
final id = (await pdfs.import(
'japanese',
bytes: Uint8List.fromList(
utf8.encode(
'%PDF-1.7 pages:40 chapter:1:Kana; chapter:5:Particles;',
),
),
fileName: 'Grammar.pdf',
) as PdfImported).id;
if (readTo != null) {
await pdfs.reached(id, readTo);
}
return id;
}
Future<void> read(
WidgetTester tester,
String id, {
Size size = const Size(1400, 1000),
MemoryDeviceSettingsStore? settings,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
pdfGateway: pdfGateway,
assetGateway: assetGateway,
assetFiles: files,
cardGateway: cardGateway,
noteGateway: noteGateway,
tagGateway: tagGateway,
pdfEngine: engine,
deviceSettingsStore: settings,
deviceSettings: await settings?.read() ?? const DeviceSettings(),
),
location: RoutePaths.pdf(id),
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
Future<Map<String, int>> furthest() {
final pdfs = PdfSourceRepository(
pdfGateway,
AssetRepository(assetGateway, FakeAssetServerGateway(), files),
);
addTearDown(pdfs.dispose);
return pdfs.furthestPages();
}
testWidgets('opens on its first page with its chapter; the counter turns '
'the pages, and reading is kept', (tester) async {
final id = await stored();
await read(tester, id);
expect(find.text('Page 1 of 40'), findsOneWidget);
expect(find.text('Kana'), findsOneWidget);
expect(find.text('/ 40'), findsOneWidget);
await tester.enterText(find.widgetWithText(TextField, '1'), '6');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(find.text('Page 6 of 40'), findsOneWidget);
expect(find.text('Particles'), findsOneWidget);
await tester.tap(find.byTooltip('Next page'));
await tester.pumpAndSettle();
expect(find.text('Page 7 of 40'), findsOneWidget);
expect(await furthest(), {id: 7});
});
testWidgets('the divider beside the pages follows the pointer, and the '
'share of the room it leaves is kept for every PDF', (tester) async {
final id = await stored();
final settings = MemoryDeviceSettingsStore();
await read(tester, id, settings: settings);
final handle = find.byWidgetPredicate(
(widget) =>
widget is MouseRegion &&
widget.cursor == SystemMouseCursors.resizeColumn,
);
// The window less the rail, 57 wide, and the handle, 16.
const room = 1400.0 - 57 - 16;
expect(tester.getTopLeft(handle).dx, 1400 - 16 - 360);
await tester.drag(
handle,
const Offset(-200, 0),
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
expect(tester.getTopLeft(handle).dx, 1400 - 16 - 560);
expect(settings.writes.last.pdfSidebarShare, closeTo(560 / room, 1e-9));
// Opened again in a narrower window, the sidebar takes the same share.
await tester.pumpWidget(const SizedBox());
await read(tester, id, size: const Size(1200, 1000), settings: settings);
const narrower = 1200.0 - 57 - 16;
expect(
tester.getTopLeft(handle).dx,
closeTo(1200 - 16 - 560 / room * narrower, 1e-6),
);
});
testWidgets('opens where reading left off', (tester) async {
final id = await stored(readTo: 12);
await read(tester, id);
expect(find.text('Page 12 of 40'), findsOneWidget);
});
testWidgets('searches the text, from match to match, and zooms', (
tester,
) async {
final id = await stored();
await read(tester, id);
await tester.tap(find.byTooltip('Search PDF'));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextField, 'Find in PDF'), 'は');
await tester.pump(const Duration(seconds: 1));
expect(engine.viewers.last.searched, ['は']);
expect(find.text('1 of 3'), findsOneWidget);
await tester.tap(find.byTooltip('Next match'));
await tester.pump();
expect(find.text('2 of 3'), findsOneWidget);
await tester.tap(find.byTooltip('Zoom in'));
await tester.pump();
expect(find.text('125%'), findsOneWidget);
});
testWidgets('selected text goes to a new card, which is tied to its page '
'and tagged with its chapter', (tester) async {
final id = await stored();
await read(tester, id);
await tester.enterText(find.widgetWithText(TextField, '1'), '5');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
engine.select('は marks\nthe topic');
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, 'Question'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(
find.textContaining('は marks the topic', findRichText: true),
findsOneWidget,
);
// A card is created once it has its answer too, as in the composer.
expect(
tester
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Create'))
.onPressed,
isNull,
);
engine.select('the topic particle');
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, 'Answer'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Create'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Flashcard created'), findsOneWidget);
final cards = CardRepository(cardGateway);
final tags = TagRepository(tagGateway);
addTearDown(cards.dispose);
addTearDown(tags.dispose);
final card = (await cards.cardsOf('japanese')).single;
expect(card.content.source, (pdfId: id, page: 5));
expect(card.content.front.plainText, 'は marks the topic');
expect(card.content.back.plainText, 'the topic particle');
expect((await tags.read()).of(card.id).single.name, 'Particles');
expect(find.text('Page 5 · 1'), findsOneWidget);
});
testWidgets("the page's note is written in the Notes tab and kept for "
'that page', (tester) async {
final id = await stored();
await read(tester, id);
await tester.tap(find.text('Notes'));
await tester.pumpAndSettle();
final editorState = tester
.widget<AppFlowyEditor>(
find.descendant(
of: find.byType(RichContentEditor),
matching: find.byType(AppFlowyEditor),
),
)
.editorState;
final transaction = editorState.transaction
..insertText(editorState.getNodeAtPath([0])!, 0, 'ね is a softener');
await editorState.apply(transaction);
await tester.pump(const Duration(seconds: 2));
await tester.pumpAndSettle();
expect(find.text('Saved'), findsOneWidget);
final notes = NoteRepository(noteGateway);
addTearDown(notes.dispose);
final note = (await notes.notesOf('japanese')).single;
expect(note.content.source, (pdfId: id, page: 1));
expect(note.content.body.plainText, 'ね is a softener');
});
testWidgets('the format bar beside a PDF ends with a screenshot button, and '
'the card is written as in the composer, its kind chosen', (
tester,
) async {
final id = await stored();
await read(tester, id);
await tester.tap(find.text('Create flashcard'));
await tester.pumpAndSettle();
expect(find.widgetWithText(ChoiceChip, 'Vocabulary'), findsOneWidget);
await tester.tap(find.byTooltip('Screenshot of the page'));
await tester.pumpAndSettle();
expect(find.text('Cancel screenshot'), findsOneWidget);
await tester.tap(find.text('Cancel screenshot'));
await tester.pumpAndSettle();
expect(find.text('Cancel screenshot'), findsNothing);
});
testWidgets('a screenshot of a region of the page goes into the card as '
'an image that knows its page, which a click selects', (tester) async {
final id = await stored();
await read(tester, id);
await tester.tap(find.text('Create flashcard'));
await tester.pumpAndSettle();
final question = find.descendant(
of: find.byType(RichContentEditor).first,
matching: find.byType(AppFlowyEditor),
);
await tester.tap(question);
await tester.pump(const Duration(seconds: 1));
await tester.tap(find.byTooltip('Insert').first);
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Screenshot of the page'));
await tester.pumpAndSettle();
await tester.tap(find.text('Screenshot of the page'));
await tester.pumpAndSettle();
expect(find.text('Cancel screenshot'), findsOneWidget);
final overlay = tester.getTopLeft(find.byType(PdfCaptureOverlay));
await tester.dragFrom(
overlay + const Offset(70, 100),
const Offset(140, 200),
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(engine.rendered.single.page, 1);
expect(
engine.rendered.single.region.left,
closeTo(70 / 700, 0.01),
);
final editorState = tester.widget<AppFlowyEditor>(question).editorState;
final image = editorState.document.root.children.first;
expect(image.type, 'image');
expect(image.attributes[imagePdfKey], id);
expect(image.attributes[imagePageKey], 1);
expect(find.text('Screenshot saved'), findsOneWidget);
// Taller than the field, the image lets it scroll; a click on it once
// made the editor scroll up and down for ever.
await tester.tap(
find.descendant(
of: find.byType(RichContentEditor).first,
matching: find.byType(Image),
),
);
for (var frame = 0; frame < 5; frame++) {
await tester.pump(const Duration(milliseconds: 100));
}
expect(editorState.selection?.start.path, image.path);
});
testWidgets('on a phone, the pages fill the screen, the panels open from '
'the bottom', (tester) async {
final id = await stored();
await read(tester, id, size: const Size(412, 915));
expect(find.text('Page 1 of 40'), findsOneWidget);
expect(find.byTooltip('Zoom in'), findsNothing);
await tester.tap(find.byTooltip('Expand'));
await tester.pumpAndSettle();
expect(find.text('Create flashcard'), findsOneWidget);
expect(find.byTooltip('Collapse'), findsOneWidget);
});
testWidgets('a card made from a PDF shows its page while studied, and '
'opens the PDF there', (tester) async {
final id = await stored();
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
await cards.create(
'japanese',
CardContent(
front: RichContent.plain('は'),
back: RichContent.plain('topic'),
source: (pdfId: id, page: 5),
),
);
tester.view.physicalSize = const Size(1400, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
pdfGateway: pdfGateway,
assetGateway: assetGateway,
assetFiles: files,
cardGateway: cardGateway,
pdfEngine: engine,
),
location: RoutePaths.study('japanese'),
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
await tester.tap(find.text('Grammar'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Page 5 of 40'), findsOneWidget);
});
testWidgets('reading on past a chapter with cards of its own suggests '
'studying them, that chapter alone', (tester) async {
final id = await stored();
final cards = CardRepository(cardGateway);
final tags = TagRepository(tagGateway);
addTearDown(cards.dispose);
addTearDown(tags.dispose);
final card = await cards.create(
'japanese',
CardContent(
front: RichContent.plain('か'),
back: RichContent.plain('ka'),
source: (pdfId: id, page: 2),
),
);
await tags.setTagsOf(card, {await tags.create('Kana')});
await read(tester, id);
await tester.enterText(find.widgetWithText(TextField, '1'), '4');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(find.textContaining('Finished'), findsNothing);
await tester.tap(find.byTooltip('Next page'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(
find.text('Finished “Kana” — study the card you made?'),
findsOneWidget,
);
await tester.tap(find.text('Study now'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.textContaining('か', findRichText: true), findsOneWidget);
});
testWidgets("a PDF whose file isn't on this device says so", (tester) async {
final id = await stored();
files.files.clear();
await read(tester, id);
expect(find.textContaining("isn't on this device yet"), findsOneWidget);
expect(find.text('Try again'), findsOneWidget);
});
}

View file

@ -0,0 +1,124 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_asset_gateway.dart';
import '../../../support/fake_asset_server_gateway.dart';
import '../../../support/fake_pdf_source_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/memory_asset_file_cache.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
void main() {
late FakePdfSourceGateway pdfGateway;
late FakeAssetGateway assetGateway;
setUp(() {
pdfGateway = FakePdfSourceGateway();
assetGateway = FakeAssetGateway();
});
Future<PdfSourceRepository> repository() async {
final assets = AssetRepository(
assetGateway,
FakeAssetServerGateway(),
MemoryAssetFileCache(),
);
final pdfs = PdfSourceRepository(pdfGateway, assets);
addTearDown(() async {
await pdfs.dispose();
await assets.dispose();
});
return pdfs;
}
Future<String> withDeadline(
PdfSourceRepository pdfs,
String title,
ReadingDeadline deadline,
) async {
final id = (await pdfs.import(
'japanese',
bytes: Uint8List.fromList(utf8.encode('%PDF-1.7 $title')),
fileName: '$title.pdf',
) as PdfImported).id;
await pdfs.setDeadline(id, deadline);
return id;
}
Future<void> showHome(WidgetTester tester, {String? location}) async {
tester.view.physicalSize = const Size(1200, 1400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
),
pdfGateway: pdfGateway,
assetGateway: assetGateway,
),
location: location ?? RoutePaths.home,
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
testWidgets("Home lists the deadlines running, with today's page, and "
'those to come; a tap shows them on the timeline', (tester) async {
await withClock(Clock.fixed(DateTime(2026, 10, 3, 9)), () async {
final pdfs = await repository();
final grammar = await withDeadline(
pdfs,
'Grammar',
const ReadingDeadline(
targetPage: 100,
due: (year: 2026, month: 10, day: 12),
),
);
await pdfs.reached(grammar, 40);
await withDeadline(
pdfs,
'Kanji',
const ReadingDeadline(
targetPage: 50,
due: (year: 2026, month: 11, day: 30),
start: (year: 2026, month: 10, day: 10),
),
);
await showHome(tester);
expect(find.text('Reading deadlines'), findsOneWidget);
expect(find.text('p.40/100 · 10 days left'), findsOneWidget);
expect(find.text('to p.46 today'), findsOneWidget);
expect(find.text('Starts in 7 days'), findsOneWidget);
await tester.tap(find.text('Reading deadlines'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Reading timeline'), findsOneWidget);
expect(find.text('p.40/100'), findsOneWidget);
expect(find.text('Kanji'), findsOneWidget);
});
});
testWidgets('Home shows no deadlines card without any', (tester) async {
await showHome(tester);
expect(find.text('Reading deadlines'), findsNothing);
});
}