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:
parent
11abd9afce
commit
649499be23
9 changed files with 0 additions and 0 deletions
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue