feat: PDF sources: read, take notes and cards from pages, deadlines
PDFs live in a studyset's new Files tab: uploaded from Add material (a file the studyset holds already asks first), each row with its pages, cards, notes and size, how far it is read and its reading deadline; renamed, given a deadline (a page or a chapter by a day), marked as read, started afresh, deleted with a moment to undo. The reader (/pdfs/:id) opens where reading left off: on wide windows a rail of tools (search, fit, zoom, fullscreen), a page counter with the chapter, and a sidebar; on phones a top bar, a page scrubber and a sheet. The sidebar holds the PDF's flashcards, by page or chapter, made from selected text or written there, each tied to its page and tagged with its chapter; and the page's note, an ordinary note tied to the page. Screenshots of a region become images that know their page. Cards and screenshots lead back to their page; reading on past a chapter with cards suggests studying it. Home shows the deadlines running and to come, and a timeline of them. Server: pdf_source rows (content in the payload, last writer wins) and an append-only pdf_reading log, the furthest page derived from it so devices never conflict. Files up to 50 MB for PDFs (20 MB for others; a larger file that isn't a PDF is deleted once stored). A deleted PDF takes its file's asset row along unless another PDF has the same file. Card payload 4 and note payload 2 keep the page. The reader draws with pdfrx (PDFium) behind PdfEngine in Dependencies; widget tests use a fake, and integration_test/pdf_engine_test.dart checks the real engine on a device or the desktop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
ec34bb2334
commit
b96db731c1
15 changed files with 34926 additions and 58 deletions
|
|
@ -125,6 +125,20 @@ void main() {
|
|||
expect(content, CardContent(front: text('猫'), back: text('cat')));
|
||||
});
|
||||
|
||||
test('a card tied to a page of a PDF keeps it, whatever its type', () {
|
||||
final content = CardContent(
|
||||
front: text('猫'),
|
||||
back: text('cat'),
|
||||
source: (pdfId: 'grammar-pdf', page: 12),
|
||||
);
|
||||
|
||||
final decoded = decodeCardPayload(encodeCardPayload(content));
|
||||
|
||||
expect(decoded, content);
|
||||
expect(decoded.source, (pdfId: 'grammar-pdf', page: 12));
|
||||
expect(content.normalized.source, (pdfId: 'grammar-pdf', page: 12));
|
||||
});
|
||||
|
||||
test('refuses a type this app does not know', () {
|
||||
expect(
|
||||
() => decodeCardPayload(
|
||||
|
|
|
|||
|
|
@ -201,8 +201,13 @@ void main() {
|
|||
..add(
|
||||
const HomeEventLayoutChanged(
|
||||
HomeLayout(
|
||||
order: [HomeWidget.week, HomeWidget.goal, HomeWidget.activity],
|
||||
hidden: {HomeWidget.activity},
|
||||
order: [
|
||||
HomeWidget.week,
|
||||
HomeWidget.goal,
|
||||
HomeWidget.activity,
|
||||
HomeWidget.deadlines,
|
||||
],
|
||||
hidden: {HomeWidget.activity, HomeWidget.deadlines},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,26 @@ void main() {
|
|||
expect(decodeNotePayload(encodeNotePayload(content)).title, '');
|
||||
});
|
||||
|
||||
test('a note tied to a page of a PDF keeps it; one of the first schema is '
|
||||
'tied to none', () {
|
||||
final content = NoteContent(
|
||||
body: RichContent.plain('は'),
|
||||
source: (pdfId: 'grammar-pdf', page: 12),
|
||||
);
|
||||
|
||||
expect(decodeNotePayload(encodeNotePayload(content)), content);
|
||||
expect(
|
||||
decodeNotePayload(
|
||||
bytes({
|
||||
'schemaVersion': 1,
|
||||
'title': '',
|
||||
'body': RichContent.empty().toJson(),
|
||||
}),
|
||||
).source,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses a schema newer than this app knows', () {
|
||||
expect(
|
||||
() => decodeNotePayload(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
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,
|
||||
),
|
||||
);
|
||||
|
||||
// The newest first.
|
||||
expect(
|
||||
[for (final row in state.rows) row.source.content.title],
|
||||
[
|
||||
'Kana',
|
||||
'Grammar',
|
||||
],
|
||||
);
|
||||
final row = state.rows.last;
|
||||
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');
|
||||
});
|
||||
|
||||
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,231 @@
|
|||
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("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,
|
||||
archived: {'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,
|
||||
archived: const {},
|
||||
).isEmpty,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
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_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');
|
||||
}
|
||||
|
||||
int shownTab(WidgetTester tester) =>
|
||||
tester.widget<TabBar>(find.byType(TabBar)).controller!.index;
|
||||
|
||||
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(shownTab(tester), 3);
|
||||
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(shownTab(tester), 3);
|
||||
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', (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;
|
||||
expect(top('a'), lessThan(top('c')));
|
||||
|
||||
await tester.tap(find.byTooltip('Sort'));
|
||||
await tester.pumpAndSettle();
|
||||
await choose(tester, 'Name (A–Z)');
|
||||
|
||||
expect(top('a'), lessThan(top('b')));
|
||||
expect(top('b'), lessThan(top('c')));
|
||||
});
|
||||
|
||||
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 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,369 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:appflowy_editor/appflowy_editor.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/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/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),
|
||||
}) 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,
|
||||
),
|
||||
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('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,
|
||||
);
|
||||
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((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('a screenshot of a region of the page goes into the card as '
|
||||
'an image that knows its page', (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);
|
||||
});
|
||||
|
||||
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 · p. 5'));
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
@ -103,7 +103,12 @@ void main() {
|
|||
goal: const StudyGoal(period: GoalPeriod.week, target: 150),
|
||||
restDays: {DateTime(2026, 10, 5), DateTime(2026, 12, 24)},
|
||||
home: const HomeLayout(
|
||||
order: [HomeWidget.activity, HomeWidget.goal, HomeWidget.week],
|
||||
order: [
|
||||
HomeWidget.activity,
|
||||
HomeWidget.goal,
|
||||
HomeWidget.deadlines,
|
||||
HomeWidget.week,
|
||||
],
|
||||
hidden: {HomeWidget.week},
|
||||
),
|
||||
);
|
||||
|
|
@ -126,6 +131,7 @@ void main() {
|
|||
expect(settings.home.order, [
|
||||
HomeWidget.activity,
|
||||
HomeWidget.goal,
|
||||
HomeWidget.deadlines,
|
||||
HomeWidget.week,
|
||||
]);
|
||||
expect(settings.home.hidden, isEmpty);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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';
|
||||
|
|
@ -6,6 +7,7 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_pa
|
|||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.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/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
|
|
@ -16,11 +18,15 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_reposit
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
|
||||
import '../../../support/fake_asset_gateway.dart';
|
||||
import '../../../support/fake_asset_server_gateway.dart';
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_learning_path_gateway.dart';
|
||||
import '../../../support/fake_note_gateway.dart';
|
||||
import '../../../support/fake_pdf_source_gateway.dart';
|
||||
import '../../../support/fake_review_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/memory_asset_file_cache.dart';
|
||||
|
||||
void main() {
|
||||
late FakeStudysetGateway gateway;
|
||||
|
|
@ -61,6 +67,14 @@ void main() {
|
|||
cards,
|
||||
notes,
|
||||
paths,
|
||||
PdfSourceRepository(
|
||||
FakePdfSourceGateway(),
|
||||
AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeAssetServerGateway(),
|
||||
MemoryAssetFileCache(),
|
||||
),
|
||||
),
|
||||
undoWindow: undoWindow,
|
||||
);
|
||||
bloc = StudysetListBloc(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
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';
|
||||
|
|
@ -6,15 +10,20 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_pa
|
|||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.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/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
|
||||
import '../../../support/eventually.dart';
|
||||
import '../../../support/fake_asset_gateway.dart';
|
||||
import '../../../support/fake_asset_server_gateway.dart';
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_learning_path_gateway.dart';
|
||||
import '../../../support/fake_note_gateway.dart';
|
||||
import '../../../support/fake_pdf_source_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/memory_asset_file_cache.dart';
|
||||
|
||||
void main() {
|
||||
late FakeStudysetGateway studysetGateway;
|
||||
|
|
@ -25,6 +34,9 @@ void main() {
|
|||
late NoteRepository notes;
|
||||
late FakeLearningPathGateway pathGateway;
|
||||
late LearningPathRepository paths;
|
||||
late FakePdfSourceGateway pdfGateway;
|
||||
late AssetRepository assets;
|
||||
late PdfSourceRepository pdfs;
|
||||
late StudysetDeletions deletions;
|
||||
|
||||
setUp(() {
|
||||
|
|
@ -36,6 +48,13 @@ void main() {
|
|||
notes = NoteRepository(noteGateway);
|
||||
pathGateway = FakeLearningPathGateway();
|
||||
paths = LearningPathRepository(pathGateway);
|
||||
pdfGateway = FakePdfSourceGateway();
|
||||
assets = AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeAssetServerGateway(),
|
||||
MemoryAssetFileCache(),
|
||||
);
|
||||
pdfs = PdfSourceRepository(pdfGateway, assets);
|
||||
});
|
||||
tearDown(() async {
|
||||
await deletions.dispose();
|
||||
|
|
@ -43,6 +62,8 @@ void main() {
|
|||
await cards.dispose();
|
||||
await notes.dispose();
|
||||
await paths.dispose();
|
||||
await pdfs.dispose();
|
||||
await assets.dispose();
|
||||
});
|
||||
|
||||
void open(Duration undoWindow) => deletions = StudysetDeletions(
|
||||
|
|
@ -50,6 +71,7 @@ void main() {
|
|||
cards,
|
||||
notes,
|
||||
paths,
|
||||
pdfs,
|
||||
undoWindow: undoWindow,
|
||||
);
|
||||
|
||||
|
|
@ -59,8 +81,8 @@ void main() {
|
|||
);
|
||||
|
||||
test('a deletion waits for the time to undo, then deletes the whole '
|
||||
'subtree, what is inside first, each with its cards, notes and paths '
|
||||
'blanked', () async {
|
||||
'subtree, what is inside first, each with its cards, notes, paths and '
|
||||
'PDFs blanked', () async {
|
||||
open(const Duration(milliseconds: 50));
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final kana = await studysets.create(
|
||||
|
|
@ -76,6 +98,11 @@ void main() {
|
|||
kana,
|
||||
LearningPathContent.titled('Hiragana'),
|
||||
);
|
||||
final kanaPdf = (await pdfs.import(
|
||||
kana,
|
||||
bytes: Uint8List.fromList(utf8.encode('%PDF-1.7 kana')),
|
||||
fileName: 'kana.pdf',
|
||||
) as PdfImported).id;
|
||||
|
||||
deletions.delete(japanese);
|
||||
expect(deletions.pending, {japanese});
|
||||
|
|
@ -92,6 +119,7 @@ void main() {
|
|||
'blank path $kanaPath',
|
||||
'delete path $kanaPath',
|
||||
]);
|
||||
expect(pdfGateway.deletions, ['blank $kanaPdf', 'delete $kanaPdf']);
|
||||
expect(await studysets.all(), isEmpty);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue