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
19 changed files with 0 additions and 0 deletions
|
|
@ -0,0 +1,252 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:fake_async/fake_async.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/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/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_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_gateway.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/studyset/bloc/exam_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.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 '../../../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_path_answer_gateway.dart';
|
||||
import '../../../support/fake_pdf_source_gateway.dart';
|
||||
import '../../../support/fake_review_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/fake_tag_gateway.dart';
|
||||
import '../../../support/memory_asset_file_cache.dart';
|
||||
|
||||
/// A [FakeReviewGateway] counting how often it reads cards' reviews: the
|
||||
/// progress with an exam chosen reads them once each time it is read; the
|
||||
/// plans never do. (The cards themselves come from the card repository's
|
||||
/// cache while none changes.)
|
||||
class _CountingReviewGateway() extends FakeReviewGateway {
|
||||
int reads = 0;
|
||||
|
||||
@override
|
||||
Future<List<ReviewRow>> reviewsOf(Set<String> cardIds) {
|
||||
reads++;
|
||||
return super.reviewsOf(cardIds);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
final today = DateTime(2026, 10, 14, 9);
|
||||
final exam = ExamChoice(plan: 'auto:jlpt:1', date: DateTime(2026, 12, 6));
|
||||
late _CountingReviewGateway reviewGateway;
|
||||
late StudysetRepository studysets;
|
||||
late CardRepository cards;
|
||||
late TagRepository tags;
|
||||
late ReviewRepository reviews;
|
||||
late LearningPathRepository paths;
|
||||
late PathProgressRepository pathProgress;
|
||||
late String japanese;
|
||||
late String neko;
|
||||
late String inu;
|
||||
late ExamCubit cubit;
|
||||
|
||||
/// Runs [steps] on the fake clock until nothing waits but timers.
|
||||
void run(FakeAsync async, Future<void> Function() steps) {
|
||||
unawaited(steps());
|
||||
async.flushMicrotasks();
|
||||
}
|
||||
|
||||
/// A Japanese studyset preparing for [choice], holding 猫 and 犬 tagged
|
||||
/// JLPT-N5 and 本 untagged, and its cubit once its first reading is done
|
||||
/// and every change has settled.
|
||||
void start(FakeAsync async, {ExamChoice? choice}) {
|
||||
reviewGateway = _CountingReviewGateway();
|
||||
studysets = StudysetRepository(FakeStudysetGateway());
|
||||
cards = CardRepository(FakeCardGateway());
|
||||
paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
pathProgress = PathProgressRepository(FakePathAnswerGateway());
|
||||
tags = TagRepository(
|
||||
FakeTagGateway(),
|
||||
cardChanges: cards.changes,
|
||||
pathChanges: paths.changes,
|
||||
);
|
||||
reviews = ReviewRepository(reviewGateway);
|
||||
final assets = AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeAssetServerGateway(),
|
||||
MemoryAssetFileCache(),
|
||||
);
|
||||
run(async, () async {
|
||||
japanese = await studysets.create(
|
||||
StudysetContent.titled('Japanese').withExam(choice),
|
||||
);
|
||||
Future<String> card(String front) => cards.create(
|
||||
japanese,
|
||||
CardContent(
|
||||
front: RichContent.plain(front),
|
||||
back: RichContent.plain('back'),
|
||||
),
|
||||
);
|
||||
neko = await card('猫');
|
||||
inu = await card('犬');
|
||||
await card('本');
|
||||
final n5 = await tags.create('JLPT-N5');
|
||||
await tags.setTagsOf(neko, {n5});
|
||||
await tags.setTagsOf(inu, {n5});
|
||||
});
|
||||
cubit = ExamCubit(
|
||||
studysets: studysets,
|
||||
cards: cards,
|
||||
tags: tags,
|
||||
reviews: reviews,
|
||||
paths: paths,
|
||||
pathProgress: pathProgress,
|
||||
pdfs: PdfSourceRepository(FakePdfSourceGateway(), assets),
|
||||
studysetId: japanese,
|
||||
);
|
||||
async.elapse(ExamCubit.settle * 2);
|
||||
}
|
||||
|
||||
void review(FakeAsync async, String cardId) => run(
|
||||
async,
|
||||
() => reviews.record(
|
||||
cardId: cardId,
|
||||
rating: Rating.good,
|
||||
reviewedAt: clock.now(),
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
|
||||
test('reads no progress when a review is recorded without an exam '
|
||||
'chosen', () {
|
||||
fakeAsync((async) {
|
||||
start(async);
|
||||
expect(cubit.state.plans, const [
|
||||
ExamPlan(id: 'auto:jlpt:1', name: 'JLPT N5', tags: ['JLPT-N5']),
|
||||
]);
|
||||
final reads = reviewGateway.reads;
|
||||
|
||||
review(async, neko);
|
||||
async.elapse(ExamCubit.settle * 2);
|
||||
|
||||
expect(reviewGateway.reads, reads);
|
||||
unawaited(cubit.close());
|
||||
async.flushMicrotasks();
|
||||
}, initialTime: today);
|
||||
});
|
||||
|
||||
test('shows the exam chosen at once; with one, reads the progress alone '
|
||||
'once a burst of ratings has settled, and nothing once closed', () {
|
||||
fakeAsync((async) {
|
||||
start(async);
|
||||
|
||||
run(async, () => cubit.choose(exam.plan, exam.date));
|
||||
expect(cubit.state.choice, exam);
|
||||
expect(cubit.state.progress?.newCards, 2);
|
||||
final reads = reviewGateway.reads;
|
||||
|
||||
review(async, neko);
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
review(async, inu);
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(reviewGateway.reads, reads, reason: 'still settling');
|
||||
async.elapse(ExamCubit.settle);
|
||||
|
||||
// The progress read once for both ratings.
|
||||
expect(reviewGateway.reads, reads + 1);
|
||||
expect(cubit.state.progress?.newCards, 0);
|
||||
|
||||
review(async, neko);
|
||||
unawaited(cubit.close());
|
||||
async.elapse(ExamCubit.settle * 2);
|
||||
expect(reviewGateway.reads, reads + 1);
|
||||
}, initialTime: today);
|
||||
});
|
||||
|
||||
test('a learning path tagged with a level offers its plan and counts '
|
||||
'towards it, as cards do; the cards due are counted', () {
|
||||
fakeAsync((async) {
|
||||
start(async, choice: exam);
|
||||
expect(cubit.state.progress?.due, 0);
|
||||
|
||||
run(async, () async {
|
||||
final particles = await paths.createPath(
|
||||
japanese,
|
||||
LearningPathContent.titled('Particles'),
|
||||
);
|
||||
final step = await paths.createStep(
|
||||
particles,
|
||||
StepKind.info,
|
||||
StepContent.empty(StepKind.info),
|
||||
);
|
||||
await paths.createStep(
|
||||
particles,
|
||||
StepKind.info,
|
||||
StepContent.empty(StepKind.info),
|
||||
);
|
||||
await pathProgress.record(step.stepId, AnswerOutcome.continued);
|
||||
await tags.setTagsOfPath(particles, {await tags.create('JLPT-N4')});
|
||||
// An untagged path outside the plan counts for nothing.
|
||||
await paths.createPath(japanese, LearningPathContent.titled('Kanji'));
|
||||
});
|
||||
review(async, neko);
|
||||
async.elapse(ExamCubit.settle);
|
||||
|
||||
expect(
|
||||
cubit.state.plans?.map((plan) => plan.name),
|
||||
['JLPT N5', 'JLPT N4'],
|
||||
);
|
||||
expect(cubit.state.progress?.steps, 0, reason: 'N5 takes no N4 path');
|
||||
|
||||
run(async, () => cubit.choose('auto:jlpt:2', exam.date));
|
||||
async.elapse(ExamCubit.settle);
|
||||
expect(cubit.state.progress?.steps, 2);
|
||||
expect(cubit.state.progress?.stepsDone, 1);
|
||||
|
||||
// Studied once, 猫 is due again three days on; choosing the exam
|
||||
// again reads it anew.
|
||||
async.elapse(const Duration(days: 3));
|
||||
run(async, () => cubit.choose(exam.plan, exam.date));
|
||||
async.elapse(ExamCubit.settle);
|
||||
expect(cubit.state.progress?.due, 1);
|
||||
unawaited(cubit.close());
|
||||
async.flushMicrotasks();
|
||||
}, initialTime: today);
|
||||
});
|
||||
|
||||
test('reads the plans again once a change of tags has settled', () {
|
||||
fakeAsync((async) {
|
||||
start(async, choice: exam);
|
||||
|
||||
run(async, () async {
|
||||
await tags.setTagsOf(inu, {await tags.create('JLPT-N4')});
|
||||
});
|
||||
async.elapse(ExamCubit.settle);
|
||||
|
||||
expect(cubit.state.plans, const [
|
||||
ExamPlan(id: 'auto:jlpt:1', name: 'JLPT N5', tags: ['JLPT-N5']),
|
||||
ExamPlan(
|
||||
id: 'auto:jlpt:2',
|
||||
name: 'JLPT N4',
|
||||
tags: ['JLPT-N5', 'JLPT-N4'],
|
||||
),
|
||||
]);
|
||||
expect(cubit.state.progress?.cards, 1);
|
||||
unawaited(cubit.close());
|
||||
async.flushMicrotasks();
|
||||
}, initialTime: today);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
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/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.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 'package:halkyon_learn_flutter/src/feature/studyset/bloc/material_progress_cubit.dart';
|
||||
|
||||
import '../../../support/fake_asset_gateway.dart';
|
||||
import '../../../support/fake_asset_server_gateway.dart';
|
||||
import '../../../support/fake_learning_path_gateway.dart';
|
||||
import '../../../support/fake_path_answer_gateway.dart';
|
||||
import '../../../support/fake_pdf_source_gateway.dart';
|
||||
import '../../../support/memory_asset_file_cache.dart';
|
||||
|
||||
void main() {
|
||||
late LearningPathRepository paths;
|
||||
late PathProgressRepository progress;
|
||||
late FakePdfSourceGateway pdfGateway;
|
||||
late PdfSourceRepository pdfs;
|
||||
|
||||
setUp(() {
|
||||
paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
progress = PathProgressRepository(FakePathAnswerGateway());
|
||||
pdfGateway = FakePdfSourceGateway();
|
||||
pdfs = PdfSourceRepository(
|
||||
pdfGateway,
|
||||
AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeAssetServerGateway(),
|
||||
MemoryAssetFileCache(),
|
||||
),
|
||||
);
|
||||
addTearDown(paths.dispose);
|
||||
addTearDown(progress.dispose);
|
||||
addTearDown(pdfs.dispose);
|
||||
});
|
||||
|
||||
/// A path in [studysetId] with [steps] steps, the first [done] of them
|
||||
/// done.
|
||||
Future<void> path(
|
||||
String studysetId, {
|
||||
required int steps,
|
||||
int done = 0,
|
||||
}) async {
|
||||
final path = await paths.createPath(
|
||||
studysetId,
|
||||
LearningPathContent.titled('Path'),
|
||||
);
|
||||
for (var i = 0; i < steps; i++) {
|
||||
final step = await paths.createStep(
|
||||
path,
|
||||
StepKind.info,
|
||||
StepContent.empty(StepKind.info),
|
||||
);
|
||||
if (i < done) {
|
||||
await progress.record(step.stepId, AnswerOutcome.continued);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF [id] in [studysetId] of [pages] pages, read to [read].
|
||||
Future<void> pdf(
|
||||
String id,
|
||||
String studysetId, {
|
||||
int? pages,
|
||||
int read = 0,
|
||||
}) async {
|
||||
pdfGateway.receiveRemote(
|
||||
id: id,
|
||||
studysetId: studysetId,
|
||||
payload: encodePdfSourcePayload(
|
||||
PdfSourceContent(
|
||||
title: id,
|
||||
fileName: '$id.pdf',
|
||||
assetId: 'asset-$id',
|
||||
pageCount: pages,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (read > 0) {
|
||||
await pdfs.reached(id, read);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the cubit for [studysetIds] reads first, and the cubit.
|
||||
Future<(MaterialProgressState, MaterialProgressCubit)> read(
|
||||
List<String> studysetIds,
|
||||
) async {
|
||||
final cubit = MaterialProgressCubit(paths, progress, pdfs, studysetIds);
|
||||
addTearDown(cubit.close);
|
||||
final first = (await cubit.stream.firstWhere((state) => state != null))!;
|
||||
return (first, cubit);
|
||||
}
|
||||
|
||||
test('counts the steps done and the paths finished across the studyset '
|
||||
'and its topics, and no other studyset', () async {
|
||||
await path('japanese', steps: 3, done: 1);
|
||||
await path('grammar', steps: 2, done: 2);
|
||||
await path('french', steps: 5, done: 5);
|
||||
|
||||
final (progress, _) = await read(['japanese', 'grammar']);
|
||||
|
||||
expect(progress.steps, 5);
|
||||
expect(progress.stepsDone, 3);
|
||||
expect(progress.paths, 2);
|
||||
expect(progress.pathsDone, 1);
|
||||
});
|
||||
|
||||
test('lists the PDFs not read to their last page, those begun first, '
|
||||
'else in the order of the studysets', () async {
|
||||
await pdf('genki', 'japanese', pages: 300);
|
||||
await pdf('done', 'japanese', pages: 10, read: 10);
|
||||
await pdf('tobira', 'grammar', pages: 200, read: 12);
|
||||
// Never opened: how many pages it has is unknown.
|
||||
await pdf('unopened', 'grammar');
|
||||
await pdf('french', 'french', pages: 5, read: 1);
|
||||
|
||||
final (progress, _) = await read(['japanese', 'grammar']);
|
||||
|
||||
expect(progress.pdfs, 4);
|
||||
expect(
|
||||
[
|
||||
for (final (:pdf, :furthestPage) in progress.unfinished)
|
||||
(pdf.id, furthestPage),
|
||||
],
|
||||
[('tobira', 12), ('genki', 0), ('unopened', 0)],
|
||||
);
|
||||
});
|
||||
|
||||
test('reads again as pages are read and steps done', () async {
|
||||
await pdf('genki', 'japanese', pages: 2);
|
||||
await path('japanese', steps: 1);
|
||||
final (_, cubit) = await read(['japanese']);
|
||||
|
||||
await pdfs.reached('genki', 2);
|
||||
await expectLater(
|
||||
cubit.stream,
|
||||
emitsThrough(
|
||||
isA<MaterialProgressState>().having(
|
||||
(p) => p.unfinished,
|
||||
'unfinished',
|
||||
isEmpty,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final step = (await paths.stepsOf(
|
||||
(await paths.pathsOf('japanese')).single.id,
|
||||
)).single;
|
||||
await progress.record(step.id, AnswerOutcome.continued);
|
||||
await expectLater(
|
||||
cubit.stream,
|
||||
emitsThrough(
|
||||
isA<MaterialProgressState>().having(
|
||||
(p) => p.pathsDone,
|
||||
'paths done',
|
||||
1,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_test/flutter_test.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/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/review.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_activity_cubit.dart';
|
||||
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_review_gateway.dart';
|
||||
|
||||
void main() {
|
||||
late CardRepository cards;
|
||||
late ReviewRepository reviews;
|
||||
|
||||
setUp(() {
|
||||
cards = CardRepository(FakeCardGateway());
|
||||
reviews = ReviewRepository(FakeReviewGateway());
|
||||
addTearDown(cards.dispose);
|
||||
addTearDown(reviews.dispose);
|
||||
});
|
||||
|
||||
Future<String> card(String studysetId) => cards.create(
|
||||
studysetId,
|
||||
CardContent(
|
||||
front: RichContent.plain('猫'),
|
||||
back: RichContent.plain('cat'),
|
||||
),
|
||||
);
|
||||
|
||||
/// The first activity read for [studysetIds].
|
||||
Future<StudysetActivityState> read(List<String> studysetIds) async {
|
||||
final cubit = StudysetActivityCubit(cards, reviews, studysetIds);
|
||||
addTearDown(cubit.close);
|
||||
return (await cubit.stream.firstWhere((state) => state != null))!;
|
||||
}
|
||||
|
||||
test('nothing studied yet: no studying, and no time this week', () async {
|
||||
await card('japanese');
|
||||
|
||||
final activity = await read(['japanese']);
|
||||
|
||||
expect(activity.studied, isFalse);
|
||||
expect(activity.week, List.filled(7, Duration.zero));
|
||||
});
|
||||
|
||||
test('sums each day of the last seven, today last, across the studyset '
|
||||
'and what is inside it, a card at most a minute; a fresh start is no studying', () async {
|
||||
final now = DateTime(2026, 10, 12, 15);
|
||||
final own = await card('japanese');
|
||||
final inTopic = await card('grammar');
|
||||
final elsewhere = await card('french');
|
||||
Future<void> review(
|
||||
String cardId,
|
||||
DateTime at,
|
||||
int seconds, {
|
||||
StudyMode mode = StudyMode.review,
|
||||
}) => reviews.record(
|
||||
cardId: cardId,
|
||||
rating: Rating.good,
|
||||
reviewedAt: at,
|
||||
duration: Duration(seconds: seconds),
|
||||
mode: mode,
|
||||
);
|
||||
await review(own, now, 20);
|
||||
await review(inTopic, now.subtract(const Duration(hours: 2)), 10);
|
||||
await review(own, now.subtract(const Duration(days: 1)), 30);
|
||||
// Ten minutes on one card count as one.
|
||||
await review(own, now.subtract(const Duration(days: 1)), 600);
|
||||
await review(own, now.subtract(const Duration(days: 6)), 40);
|
||||
// A week ago and more, another studyset's, a fresh start: none counts.
|
||||
await review(own, now.subtract(const Duration(days: 7)), 50);
|
||||
await review(elsewhere, now, 6);
|
||||
await review(own, now, 7, mode: StudyMode.reset);
|
||||
|
||||
final activity = await withClock(
|
||||
Clock.fixed(now),
|
||||
() => read(['japanese', 'grammar']),
|
||||
);
|
||||
|
||||
expect(activity.studied, isTrue);
|
||||
expect(activity.week, [
|
||||
const Duration(seconds: 40),
|
||||
Duration.zero,
|
||||
Duration.zero,
|
||||
Duration.zero,
|
||||
Duration.zero,
|
||||
const Duration(seconds: 90),
|
||||
const Duration(seconds: 30),
|
||||
]);
|
||||
});
|
||||
|
||||
test('only a fresh start is no studying', () async {
|
||||
final own = await card('japanese');
|
||||
await reviews.record(
|
||||
cardId: own,
|
||||
rating: Rating.good,
|
||||
reviewedAt: clock.now(),
|
||||
duration: const Duration(minutes: 1),
|
||||
mode: StudyMode.reset,
|
||||
);
|
||||
|
||||
expect((await read(['japanese'])).studied, isFalse);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,660 @@
|
|||
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/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/learning_path/data/learning_path_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_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/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 'package:halkyon_learn_flutter/src/feature/settings/data/settings_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';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_progress_repository.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 '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_path_answer_gateway.dart';
|
||||
import '../../../support/fake_pdf_source_gateway.dart';
|
||||
import '../../../support/fake_review_gateway.dart';
|
||||
import '../../../support/fake_settings_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/memory_asset_file_cache.dart';
|
||||
|
||||
void main() {
|
||||
late FakeStudysetGateway gateway;
|
||||
late StudysetRepository studysets;
|
||||
late FakeCardGateway cardGateway;
|
||||
late CardRepository cards;
|
||||
late NoteRepository notes;
|
||||
late LearningPathRepository paths;
|
||||
late ReviewRepository reviews;
|
||||
late StudysetListBloc bloc;
|
||||
late FakeSettingsGateway settingsGateway;
|
||||
late SettingsRepository settings;
|
||||
late FakePdfSourceGateway pdfGateway;
|
||||
late PdfSourceRepository pdfs;
|
||||
late PathProgressRepository pathProgress;
|
||||
late StudysetProgressRepository progressOfAll;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeStudysetGateway();
|
||||
studysets = StudysetRepository(gateway);
|
||||
cardGateway = FakeCardGateway();
|
||||
cards = CardRepository(cardGateway);
|
||||
notes = NoteRepository(FakeNoteGateway());
|
||||
paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
reviews = ReviewRepository(FakeReviewGateway());
|
||||
pathProgress = PathProgressRepository(FakePathAnswerGateway());
|
||||
settingsGateway = FakeSettingsGateway();
|
||||
settings = SettingsRepository(settingsGateway);
|
||||
pdfGateway = FakePdfSourceGateway();
|
||||
pdfs = PdfSourceRepository(
|
||||
pdfGateway,
|
||||
AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeAssetServerGateway(),
|
||||
MemoryAssetFileCache(),
|
||||
),
|
||||
);
|
||||
progressOfAll = StudysetProgressRepository(
|
||||
studysets: studysets,
|
||||
cards: cards,
|
||||
notes: notes,
|
||||
paths: paths,
|
||||
pdfs: pdfs,
|
||||
reviews: reviews,
|
||||
settings: settings,
|
||||
);
|
||||
});
|
||||
tearDown(() async {
|
||||
await bloc.close();
|
||||
await progressOfAll.dispose();
|
||||
await studysets.dispose();
|
||||
await cards.dispose();
|
||||
await notes.dispose();
|
||||
await paths.dispose();
|
||||
await settings.dispose();
|
||||
await pdfs.dispose();
|
||||
await pathProgress.dispose();
|
||||
});
|
||||
|
||||
/// Waits, a moment at a time, until [done].
|
||||
Future<void> until(Future<bool> Function() done) async {
|
||||
for (var tries = 0; tries < 100; tries++) {
|
||||
if (await done()) {
|
||||
return;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
fail('Never came about');
|
||||
}
|
||||
|
||||
/// Opens the library, or inside the studyset [parentId] its topics.
|
||||
void start({String? parentId}) {
|
||||
bloc = StudysetListBloc(
|
||||
studysets,
|
||||
progressOfAll,
|
||||
cards,
|
||||
paths,
|
||||
pdfs,
|
||||
reviews,
|
||||
pathProgress,
|
||||
parentId: parentId,
|
||||
)..add(const StudysetListEventStarted());
|
||||
}
|
||||
|
||||
/// The next state that lists [titles], each studyset with its progress.
|
||||
Future<StudysetListStateLoaded> listing(List<String> titles) async {
|
||||
bool matches(StudysetListState state) =>
|
||||
state is StudysetListStateLoaded &&
|
||||
state.studysets.map((studyset) => studyset.content.title).join('|') ==
|
||||
titles.join('|') &&
|
||||
state.studysets.every(
|
||||
(studyset) => state.progress.containsKey(studyset.id),
|
||||
);
|
||||
if (matches(bloc.state)) {
|
||||
return bloc.state as StudysetListStateLoaded;
|
||||
}
|
||||
return await bloc.stream.firstWhere(matches) as StudysetListStateLoaded;
|
||||
}
|
||||
|
||||
CardContent newCard() => CardContent(
|
||||
front: RichContent.empty(),
|
||||
back: RichContent.empty(),
|
||||
);
|
||||
|
||||
test("lists the studysets, each with its cards' progress", () async {
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final german = await studysets.create(StudysetContent.titled('German'));
|
||||
await cards.create(japanese, newCard());
|
||||
|
||||
start();
|
||||
// German changed last: it was created after Japanese.
|
||||
final state = await listing(['German', 'Japanese']);
|
||||
|
||||
expect(state.progress, {
|
||||
japanese: const StudyProgress(cards: 1, due: 1, reviewsDue: 0, mature: 0),
|
||||
german: const StudyProgress(cards: 0, due: 0, reviewsDue: 0, mature: 0),
|
||||
});
|
||||
});
|
||||
|
||||
test("counts a studyset's new cards as its study options let it start "
|
||||
"them, and others' as the person's settings do", () async {
|
||||
final japanese = await studysets.create(
|
||||
StudysetContent.titled(
|
||||
'Japanese',
|
||||
).withStudyOptions(const StudysetStudyOptions(newPerDay: 2)),
|
||||
);
|
||||
final german = await studysets.create(StudysetContent.titled('German'));
|
||||
for (var i = 0; i < 4; i++) {
|
||||
await cards.create(japanese, newCard());
|
||||
await cards.create(german, newCard());
|
||||
}
|
||||
await settings.update(
|
||||
(current) => current.withStudy(current.study.copyWith(newPerDay: 3)),
|
||||
);
|
||||
|
||||
start();
|
||||
final state = await listing(['German', 'Japanese']);
|
||||
|
||||
expect(state.progress[japanese]?.due, 2);
|
||||
expect(state.progress[german]?.due, 3);
|
||||
});
|
||||
|
||||
test('counts a card written by hand as due on its written schedule, as a '
|
||||
'session studies it', () async {
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final kanji = await cards.create(
|
||||
japanese,
|
||||
CardContent(
|
||||
type: CardType.character,
|
||||
front: RichContent.plain('猫'),
|
||||
back: RichContent.empty(),
|
||||
meanings: const ['cat'],
|
||||
),
|
||||
);
|
||||
// Recalled easily front to back just now: not due that way for days;
|
||||
// never written by hand.
|
||||
await reviews.record(
|
||||
cardId: kanji,
|
||||
rating: Rating.easy,
|
||||
reviewedAt: clock.now(),
|
||||
duration: const Duration(seconds: 3),
|
||||
);
|
||||
|
||||
start();
|
||||
expect((await listing(['Japanese'])).progress[japanese]?.due, 0);
|
||||
await bloc.close();
|
||||
|
||||
await settings.update(
|
||||
(current) => current.withStudy(current.study.copyWith(writeByHand: true)),
|
||||
);
|
||||
start();
|
||||
// Worked out again as the settings change.
|
||||
await until(
|
||||
() async => switch (bloc.state) {
|
||||
StudysetListStateLoaded(:final progress) =>
|
||||
progress[japanese]?.due == 1,
|
||||
_ => false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves a card buried today out of what is due', () async {
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final buried = await cards.create(japanese, newCard());
|
||||
await cards.create(japanese, newCard());
|
||||
await cards.bury(buried, clock.now());
|
||||
|
||||
start();
|
||||
final state = await listing(['Japanese']);
|
||||
|
||||
expect(state.progress[japanese]?.cards, 2);
|
||||
expect(state.progress[japanese]?.due, 1);
|
||||
});
|
||||
|
||||
test("counts what is inside a studyset, but for archived studysets' "
|
||||
'cards, which rest', () async {
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final kana = await studysets.create(
|
||||
StudysetContent.titled('Kana'),
|
||||
parentId: japanese,
|
||||
);
|
||||
final kanji = await studysets.create(
|
||||
StudysetContent.titled('Kanji'),
|
||||
parentId: japanese,
|
||||
);
|
||||
await studysets.archive(kanji);
|
||||
await cards.create(japanese, newCard());
|
||||
await cards.create(kana, newCard());
|
||||
await cards.create(kanji, newCard());
|
||||
|
||||
start();
|
||||
final state = await listing(['Japanese']);
|
||||
|
||||
expect(
|
||||
state.progress[japanese],
|
||||
const StudyProgress(cards: 2, due: 2, reviewsDue: 0, mature: 0),
|
||||
);
|
||||
});
|
||||
|
||||
test('lists the starred studysets first', () async {
|
||||
await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final german = await studysets.create(StudysetContent.titled('German'));
|
||||
await studysets.star(german);
|
||||
|
||||
start();
|
||||
|
||||
await listing(['German', 'Japanese']);
|
||||
});
|
||||
|
||||
test(
|
||||
'lists the most recently changed first, after the starred ones',
|
||||
() async {
|
||||
DateTime at(int hour) => DateTime.utc(2026, 10, 1, hour);
|
||||
gateway
|
||||
..receiveRemoteChange(
|
||||
'old',
|
||||
encodeStudysetPayload(StudysetContent.titled('Old')),
|
||||
)
|
||||
..receiveRemoteChange(
|
||||
'earlier',
|
||||
encodeStudysetPayload(StudysetContent.titled('Earlier')),
|
||||
updatedAt: at(8),
|
||||
)
|
||||
..receiveRemoteChange(
|
||||
'starred',
|
||||
encodeStudysetPayload(StudysetContent.titled('Starred')),
|
||||
starredAt: at(7),
|
||||
updatedAt: at(7),
|
||||
)
|
||||
..receiveRemoteChange(
|
||||
'later',
|
||||
encodeStudysetPayload(StudysetContent.titled('Later')),
|
||||
updatedAt: at(9),
|
||||
);
|
||||
|
||||
start();
|
||||
|
||||
await listing(['Starred', 'Later', 'Earlier', 'Old']);
|
||||
},
|
||||
);
|
||||
|
||||
test('lists only the top level', () async {
|
||||
await studysets.create(StudysetContent.titled('Japanese'));
|
||||
gateway.receiveRemoteChange(
|
||||
'kana',
|
||||
encodeStudysetPayload(StudysetContent.titled('Kana')),
|
||||
parentId: 'studyset-1',
|
||||
);
|
||||
|
||||
start();
|
||||
|
||||
await listing(['Japanese']);
|
||||
});
|
||||
|
||||
test('follows what is created, and the progress as cards change', () async {
|
||||
start();
|
||||
await listing([]);
|
||||
|
||||
bloc.add(
|
||||
StudysetListEventCreateRequested(StudysetContent.titled('Japanese')),
|
||||
);
|
||||
final created = await listing(['Japanese']);
|
||||
final id = created.studysets.single.id;
|
||||
expect(created.progress[id]?.cards, 0);
|
||||
|
||||
await cards.create(id, newCard());
|
||||
|
||||
await bloc.stream.firstWhere(
|
||||
(state) =>
|
||||
state is StudysetListStateLoaded && state.progress[id]?.cards == 1,
|
||||
);
|
||||
});
|
||||
|
||||
test('counts the notes of a studyset with what is inside it, as notes '
|
||||
'change', () async {
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final kana = await studysets.create(
|
||||
StudysetContent.titled('Kana'),
|
||||
parentId: japanese,
|
||||
);
|
||||
await notes.create(japanese, NoteContent(body: RichContent.empty()));
|
||||
start();
|
||||
final listed = await listing(['Japanese']);
|
||||
expect(listed.progress[japanese]?.notes, 1);
|
||||
|
||||
await notes.create(kana, NoteContent(body: RichContent.empty()));
|
||||
|
||||
await bloc.stream.firstWhere(
|
||||
(state) =>
|
||||
state is StudysetListStateLoaded &&
|
||||
state.progress[japanese]?.notes == 2,
|
||||
);
|
||||
});
|
||||
|
||||
test('counts the files of a studyset with what is inside it, as they '
|
||||
'change', () async {
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final kana = await studysets.create(
|
||||
StudysetContent.titled('Kana'),
|
||||
parentId: japanese,
|
||||
);
|
||||
ByteData pdf(String title) => encodePdfSourcePayload(
|
||||
PdfSourceContent(title: title, fileName: '$title.pdf', assetId: 'a'),
|
||||
);
|
||||
pdfGateway.receiveRemote(
|
||||
id: 'pdf-1',
|
||||
studysetId: japanese,
|
||||
payload: pdf('Genki'),
|
||||
);
|
||||
start();
|
||||
expect((await listing(['Japanese'])).progress[japanese]?.files, 1);
|
||||
|
||||
pdfGateway.receiveRemote(
|
||||
id: 'pdf-2',
|
||||
studysetId: kana,
|
||||
payload: pdf('Kana'),
|
||||
);
|
||||
|
||||
await bloc.stream.firstWhere(
|
||||
(state) =>
|
||||
state is StudysetListStateLoaded &&
|
||||
state.progress[japanese]?.files == 2,
|
||||
);
|
||||
});
|
||||
|
||||
test('counts the paths of a studyset with what is inside it, as paths '
|
||||
'change', () async {
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final kana = await studysets.create(
|
||||
StudysetContent.titled('Kana'),
|
||||
parentId: japanese,
|
||||
);
|
||||
start();
|
||||
await listing(['Japanese']);
|
||||
|
||||
await paths.createPath(kana, LearningPathContent.titled('Hiragana'));
|
||||
await paths.createPath(japanese, LearningPathContent.titled('Particles'));
|
||||
|
||||
await bloc.stream.firstWhere(
|
||||
(state) =>
|
||||
state is StudysetListStateLoaded &&
|
||||
state.progress[japanese]?.paths == 2,
|
||||
);
|
||||
});
|
||||
|
||||
test("changes a studyset's content", () async {
|
||||
final id = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
start();
|
||||
await listing(['Japanese']);
|
||||
|
||||
bloc.add(
|
||||
StudysetListEventContentChanged(id, StudysetContent.titled('日本語')),
|
||||
);
|
||||
|
||||
await listing(['日本語']);
|
||||
});
|
||||
|
||||
test('says so when the studysets cannot be read, then recovers', () async {
|
||||
gateway.failReads = Exception('Disk unavailable');
|
||||
start();
|
||||
|
||||
await expectLater(bloc.stream, emits(const StudysetListStateFailed()));
|
||||
gateway.failReads = null;
|
||||
await studysets.create(StudysetContent.titled('Japanese'));
|
||||
|
||||
await listing(['Japanese']);
|
||||
});
|
||||
|
||||
test('stars a studyset and takes the star away', () async {
|
||||
await studysets.create(StudysetContent.titled('Japanese'));
|
||||
start();
|
||||
final studyset = (await listing(['Japanese'])).studysets.single;
|
||||
|
||||
bloc.add(StudysetListEventStarToggled(studyset));
|
||||
final starred = await bloc.stream.firstWhere(
|
||||
(state) =>
|
||||
state is StudysetListStateLoaded && state.studysets.single.isStarred,
|
||||
);
|
||||
|
||||
bloc.add(
|
||||
StudysetListEventStarToggled(
|
||||
(starred as StudysetListStateLoaded).studysets.single,
|
||||
),
|
||||
);
|
||||
await bloc.stream.firstWhere(
|
||||
(state) =>
|
||||
state is StudysetListStateLoaded && !state.studysets.single.isStarred,
|
||||
);
|
||||
});
|
||||
|
||||
test('archives a studyset and takes it out again', () async {
|
||||
final id = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
start();
|
||||
await listing(['Japanese']);
|
||||
|
||||
bloc.add(StudysetListEventArchiveRequested(id));
|
||||
await bloc.stream.firstWhere(
|
||||
(state) =>
|
||||
state is StudysetListStateLoaded && state.studysets.single.isArchived,
|
||||
);
|
||||
bloc.add(StudysetListEventUnarchiveRequested(id));
|
||||
|
||||
await bloc.stream.firstWhere(
|
||||
(state) =>
|
||||
state is StudysetListStateLoaded &&
|
||||
!state.studysets.single.isArchived,
|
||||
);
|
||||
});
|
||||
|
||||
test('a deleted studyset leaves the list for Recently deleted, and '
|
||||
'comes back restored', () async {
|
||||
final id = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
start();
|
||||
await listing(['Japanese']);
|
||||
|
||||
await studysets.markDeleted(id);
|
||||
final waiting = await bloc.stream.firstWhere(
|
||||
(state) =>
|
||||
state is StudysetListStateLoaded &&
|
||||
state.studysets.isEmpty &&
|
||||
state.deleted.isNotEmpty,
|
||||
);
|
||||
expect(
|
||||
(waiting as StudysetListStateLoaded).deleted.single.content.title,
|
||||
'Japanese',
|
||||
);
|
||||
|
||||
bloc.add(StudysetListEventRestoreRequested(id));
|
||||
await listing(['Japanese']);
|
||||
expect(
|
||||
bloc.state,
|
||||
isA<StudysetListStateLoaded>().having(
|
||||
(state) => state.deleted,
|
||||
'deleted',
|
||||
isEmpty,
|
||||
),
|
||||
);
|
||||
expect(gateway.deleted, isEmpty);
|
||||
});
|
||||
|
||||
group('inside a studyset', () {
|
||||
test('lists its children in their order, each with its progress, and '
|
||||
"works out the studyset's own", () async {
|
||||
final japanese = await studysets.create(
|
||||
StudysetContent.titled('Japanese'),
|
||||
);
|
||||
final kanji = await studysets.create(
|
||||
StudysetContent.titled('Kanji'),
|
||||
parentId: japanese,
|
||||
);
|
||||
final kana = await studysets.create(
|
||||
StudysetContent.titled('Kana'),
|
||||
parentId: japanese,
|
||||
);
|
||||
await studysets.reorder(kana, toIndex: 0);
|
||||
await cards.create(japanese, newCard());
|
||||
await cards.create(kana, newCard());
|
||||
|
||||
start(parentId: japanese);
|
||||
final state = await listing(['Kana', 'Kanji']);
|
||||
|
||||
expect(state.progress, {
|
||||
kana: const StudyProgress(cards: 1, due: 1, reviewsDue: 0, mature: 0),
|
||||
kanji: const StudyProgress(cards: 0, due: 0, reviewsDue: 0, mature: 0),
|
||||
japanese: const StudyProgress(
|
||||
cards: 2,
|
||||
due: 2,
|
||||
reviewsDue: 0,
|
||||
mature: 0,
|
||||
),
|
||||
});
|
||||
expect(state.studysets.map((studyset) => studyset.id), [kana, kanji]);
|
||||
});
|
||||
|
||||
test('moves a studyset somewhere else', () async {
|
||||
final japanese = await studysets.create(
|
||||
StudysetContent.titled('Japanese'),
|
||||
);
|
||||
final kana = await studysets.create(
|
||||
StudysetContent.titled('Kana'),
|
||||
parentId: japanese,
|
||||
);
|
||||
start(parentId: japanese);
|
||||
await listing(['Kana']);
|
||||
|
||||
bloc.add(StudysetListEventMoveRequested(kana, parentId: null));
|
||||
|
||||
await listing([]);
|
||||
expect(topLevel(await studysets.all()), hasLength(2));
|
||||
});
|
||||
|
||||
test('creates studysets inside it and reorders them', () async {
|
||||
final japanese = await studysets.create(
|
||||
StudysetContent.titled('Japanese'),
|
||||
);
|
||||
start(parentId: japanese);
|
||||
await listing([]);
|
||||
|
||||
bloc
|
||||
..add(StudysetListEventCreateRequested(StudysetContent.titled('Kana')))
|
||||
..add(
|
||||
StudysetListEventCreateRequested(StudysetContent.titled('Kanji')),
|
||||
);
|
||||
final created = await listing(['Kana', 'Kanji']);
|
||||
bloc.add(
|
||||
StudysetListEventReorderRequested(
|
||||
created.studysets.last.id,
|
||||
toIndex: 0,
|
||||
),
|
||||
);
|
||||
|
||||
await listing(['Kanji', 'Kana']);
|
||||
});
|
||||
});
|
||||
|
||||
test('resetting starts the progress of a studyset and what is inside it '
|
||||
'afresh; an undo takes it back', () async {
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final kanji = await studysets.create(
|
||||
StudysetContent.titled('Kanji'),
|
||||
parentId: japanese,
|
||||
);
|
||||
for (final studyset in [japanese, kanji]) {
|
||||
final id = await cards.create(studyset, newCard());
|
||||
await reviews.record(
|
||||
cardId: id,
|
||||
rating: Rating.easy,
|
||||
reviewedAt: DateTime.now().toUtc(),
|
||||
duration: Duration.zero,
|
||||
);
|
||||
}
|
||||
start();
|
||||
Future<int> dueIn(String id) async => (await bloc.stream.firstWhere(
|
||||
(s) => s is StudysetListStateLoaded && s.progress.containsKey(id),
|
||||
) as StudysetListStateLoaded).progress[id]!.due;
|
||||
await listing(['Japanese']);
|
||||
|
||||
bloc.add(StudysetListEventResetRequested(japanese));
|
||||
expect(await dueIn(japanese), 2);
|
||||
|
||||
bloc.add(const StudysetListEventResetUndone());
|
||||
expect(await dueIn(japanese), 0);
|
||||
});
|
||||
|
||||
test('resetting starts reading the PDFs and the learning paths afresh '
|
||||
'too, as chosen, and says what has progress; an undo takes all of '
|
||||
'it back', () async {
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
await cards.create(japanese, newCard());
|
||||
pdfGateway.receiveRemote(
|
||||
id: 'pdf-1',
|
||||
studysetId: japanese,
|
||||
payload: encodePdfSourcePayload(
|
||||
const PdfSourceContent(
|
||||
title: 'Genki',
|
||||
fileName: 'Genki.pdf',
|
||||
assetId: 'a',
|
||||
),
|
||||
),
|
||||
);
|
||||
await pdfs.reached('pdf-1', 12);
|
||||
final path = await paths.createPath(
|
||||
japanese,
|
||||
LearningPathContent.titled('Kana'),
|
||||
);
|
||||
final step = await paths.createStep(
|
||||
path,
|
||||
StepKind.info,
|
||||
StepContent.empty(StepKind.info),
|
||||
);
|
||||
await pathProgress.record(step.stepId, AnswerOutcome.continued);
|
||||
start();
|
||||
await listing(['Japanese']);
|
||||
|
||||
expect(await bloc.progressParts(japanese), (cards: 1, pdfs: 1, paths: 1));
|
||||
|
||||
bloc.add(
|
||||
StudysetListEventResetRequested(japanese, cards: false, pdfs: true),
|
||||
);
|
||||
await until(() async => (await pdfs.furthestPages())['pdf-1'] == null);
|
||||
expect(await bloc.progressParts(japanese), (cards: 1, pdfs: 0, paths: 1));
|
||||
|
||||
bloc.add(const StudysetListEventResetUndone());
|
||||
await until(() async => (await pdfs.furthestPages())['pdf-1'] == 12);
|
||||
|
||||
bloc.add(
|
||||
StudysetListEventResetRequested(japanese, cards: false, paths: true),
|
||||
);
|
||||
await until(
|
||||
() async => (await bloc.progressParts(japanese)).paths == 0,
|
||||
);
|
||||
expect((await pdfs.furthestPages())['pdf-1'], 12);
|
||||
bloc.add(const StudysetListEventResetUndone());
|
||||
await until(
|
||||
() async => (await bloc.progressParts(japanese)).paths == 1,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
import 'dart:async';
|
||||
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/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/learning_path/data/learning_path_repository.dart';
|
||||
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;
|
||||
late StudysetRepository studysets;
|
||||
late FakeCardGateway cardGateway;
|
||||
late CardRepository cards;
|
||||
late FakeNoteGateway noteGateway;
|
||||
late NoteRepository notes;
|
||||
late FakeLearningPathGateway pathGateway;
|
||||
late LearningPathRepository paths;
|
||||
late FakePdfSourceGateway pdfGateway;
|
||||
late AssetRepository assets;
|
||||
late PdfSourceRepository pdfs;
|
||||
late StudysetDeletions deletions;
|
||||
|
||||
setUp(() {
|
||||
studysetGateway = FakeStudysetGateway();
|
||||
studysets = StudysetRepository(studysetGateway);
|
||||
cardGateway = FakeCardGateway();
|
||||
cards = CardRepository(cardGateway);
|
||||
noteGateway = FakeNoteGateway();
|
||||
notes = NoteRepository(noteGateway);
|
||||
pathGateway = FakeLearningPathGateway();
|
||||
paths = LearningPathRepository(pathGateway);
|
||||
pdfGateway = FakePdfSourceGateway();
|
||||
assets = AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeAssetServerGateway(),
|
||||
MemoryAssetFileCache(),
|
||||
);
|
||||
pdfs = PdfSourceRepository(pdfGateway, assets);
|
||||
});
|
||||
tearDown(() async {
|
||||
await deletions.dispose();
|
||||
await studysets.dispose();
|
||||
await cards.dispose();
|
||||
await notes.dispose();
|
||||
await paths.dispose();
|
||||
await pdfs.dispose();
|
||||
await assets.dispose();
|
||||
});
|
||||
|
||||
void open() =>
|
||||
deletions = StudysetDeletions(studysets, cards, notes, paths, pdfs);
|
||||
|
||||
Future<String> card(String studysetId) => cards.create(
|
||||
studysetId,
|
||||
CardContent(front: RichContent.empty(), back: RichContent.empty()),
|
||||
);
|
||||
|
||||
final deletedAt = DateTime.utc(2026, 10, 12, 9);
|
||||
|
||||
/// Deletes the studyset [id] at [deletedAt].
|
||||
Future<void> deleteAt(String id) =>
|
||||
withClock(Clock.fixed(deletedAt), () => deletions.delete(id));
|
||||
|
||||
/// Checks for expired deletions [after] the deletion.
|
||||
Future<void> checkAfter(Duration after) => withClock(
|
||||
Clock.fixed(deletedAt.add(after)),
|
||||
deletions.deleteExpired,
|
||||
);
|
||||
|
||||
test('a deleted studyset waits 14 days, restorable, and is then deleted '
|
||||
'for good with its whole subtree, what is inside first, each with its '
|
||||
'cards, notes, paths and PDFs blanked', () async {
|
||||
open();
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final kana = await studysets.create(
|
||||
StudysetContent.titled('Kana'),
|
||||
parentId: japanese,
|
||||
);
|
||||
final kanaCard = await card(kana);
|
||||
final kanaNote = await notes.create(
|
||||
kana,
|
||||
NoteContent(body: RichContent.empty()),
|
||||
);
|
||||
final kanaPath = await paths.createPath(
|
||||
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;
|
||||
|
||||
await deleteAt(japanese);
|
||||
expect(await studysets.all(), isEmpty);
|
||||
await checkAfter(const Duration(days: 13, hours: 23));
|
||||
expect(studysetGateway.deleted, isEmpty);
|
||||
expect(cardGateway.deletions, isEmpty);
|
||||
|
||||
await checkAfter(const Duration(days: 14));
|
||||
expect(studysetGateway.deleted, [kana, japanese]);
|
||||
expect(cardGateway.deletions, ['blank $kanaCard', 'delete $kanaCard']);
|
||||
expect(noteGateway.deletions, [
|
||||
'blank ${kanaNote.noteId}',
|
||||
'delete ${kanaNote.noteId}',
|
||||
]);
|
||||
expect(pathGateway.deletions, [
|
||||
'blank path $kanaPath',
|
||||
'delete path $kanaPath',
|
||||
]);
|
||||
expect(pdfGateway.deletions, ['blank $kanaPdf', 'delete $kanaPdf']);
|
||||
expect(await studysets.withDeleted(), isEmpty);
|
||||
});
|
||||
|
||||
test('a studyset restored in time stays', () async {
|
||||
open();
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
|
||||
await deleteAt(japanese);
|
||||
await deletions.restore(japanese);
|
||||
await checkAfter(const Duration(days: 30));
|
||||
|
||||
expect(studysetGateway.deleted, isEmpty);
|
||||
expect(await studysets.all(), hasLength(1));
|
||||
});
|
||||
|
||||
test('Delete now deletes for good at once', () async {
|
||||
open();
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final greek = await studysets.create(StudysetContent.titled('Greek'));
|
||||
|
||||
await deleteAt(japanese);
|
||||
await deletions.deleteForGood(japanese);
|
||||
|
||||
expect(studysetGateway.deleted, [japanese]);
|
||||
expect([for (final s in await studysets.withDeleted()) s.id], [greek]);
|
||||
});
|
||||
|
||||
test('a studyset deleted inside one deleted later goes on its own '
|
||||
'clock, and with the other at the latest', () async {
|
||||
open();
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final kana = await studysets.create(
|
||||
StudysetContent.titled('Kana'),
|
||||
parentId: japanese,
|
||||
);
|
||||
|
||||
await deleteAt(kana);
|
||||
await withClock(
|
||||
Clock.fixed(deletedAt.add(const Duration(days: 3))),
|
||||
() => deletions.delete(japanese),
|
||||
);
|
||||
await checkAfter(const Duration(days: 14));
|
||||
expect(studysetGateway.deleted, isEmpty);
|
||||
await checkAfter(const Duration(days: 17));
|
||||
expect(studysetGateway.deleted, [kana, japanese]);
|
||||
});
|
||||
|
||||
test('checks as it starts and whenever told to, as after a sync', () async {
|
||||
open();
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
await deleteAt(japanese);
|
||||
final syncs = StreamController<void>();
|
||||
addTearDown(syncs.close);
|
||||
|
||||
var now = deletedAt.add(const Duration(days: 1));
|
||||
await withClock(Clock(() => now), () async {
|
||||
deletions.deleteExpiredOn(syncs.stream);
|
||||
await pumpEventQueue();
|
||||
expect(studysetGateway.deleted, isEmpty);
|
||||
|
||||
now = deletedAt.add(const Duration(days: 15));
|
||||
syncs.add(null);
|
||||
await eventually(() => studysetGateway.deleted.isNotEmpty);
|
||||
});
|
||||
expect(studysetGateway.deleted, [japanese]);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
|
||||
void main() {
|
||||
ByteData bytes(Object json) =>
|
||||
ByteData.sublistView(utf8.encode(jsonEncode(json)));
|
||||
|
||||
Map<String, Object?> json(ByteData payload) =>
|
||||
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
|
||||
as Map<String, Object?>;
|
||||
|
||||
final description = RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{'insert': 'ひらがな'},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
group('The studyset payload', () {
|
||||
test('survives a round trip with everything set, Japanese included', () {
|
||||
final content = StudysetContent(
|
||||
title: '日本語',
|
||||
description: description,
|
||||
secondaryTitle: 'JLPT N5',
|
||||
colorName: 'cobalt',
|
||||
license: 'CC-BY-SA-4.0',
|
||||
licenseHolder: 'Tae Kim',
|
||||
licenseYear: 2006,
|
||||
frontLanguage: 'ja',
|
||||
backLanguage: 'en',
|
||||
);
|
||||
|
||||
expect(decodeStudysetPayload(encodeStudysetPayload(content)), content);
|
||||
});
|
||||
|
||||
test('leaves out what is not given', () {
|
||||
final payload = encodeStudysetPayload(
|
||||
StudysetContent(title: 'Japanese', description: RichContent.empty()),
|
||||
);
|
||||
|
||||
expect(json(payload).keys, {'schemaVersion', 'title', 'description'});
|
||||
});
|
||||
|
||||
test('names its schema version: the seventh, which exam plans need', () {
|
||||
final payload = encodeStudysetPayload(
|
||||
StudysetContent(
|
||||
title: 'Japanese',
|
||||
description: RichContent.empty(),
|
||||
bibliography: const [Source(title: 'A Guide')],
|
||||
),
|
||||
);
|
||||
|
||||
expect(studysetSchemaVersion, 7);
|
||||
expect(json(payload)['schemaVersion'], studysetSchemaVersion);
|
||||
});
|
||||
|
||||
test('without study options, a bibliography or exam plans writes the '
|
||||
'seventh schema all the same', () {
|
||||
final payload = encodeStudysetPayload(
|
||||
StudysetContent(title: 'Japanese', description: RichContent.empty()),
|
||||
);
|
||||
|
||||
expect(json(payload)['schemaVersion'], 7);
|
||||
expect(json(payload).keys, isNot(contains('study')));
|
||||
expect(json(payload).keys, isNot(contains('bibliography')));
|
||||
expect(json(payload).keys, isNot(contains('examPlans')));
|
||||
expect(json(payload).keys, isNot(contains('exam')));
|
||||
});
|
||||
|
||||
test('keeps the exam plans in their order, and the exam chosen', () {
|
||||
final content = StudysetContent(
|
||||
title: 'Japanese',
|
||||
description: description,
|
||||
examPlans: const [
|
||||
ExamPlan(id: 'final', name: 'Final exam', topics: ['kana', 'verbs']),
|
||||
ExamPlan(id: 'mock', name: 'Mock test', tags: ['JLPT-N5']),
|
||||
],
|
||||
exam: ExamChoice(plan: 'final', date: DateTime(2026, 12, 6)),
|
||||
);
|
||||
final payload = encodeStudysetPayload(content);
|
||||
|
||||
expect(decodeStudysetPayload(payload), content);
|
||||
expect(json(payload)['exam'], {'plan': 'final', 'date': '2026-12-06'});
|
||||
});
|
||||
|
||||
test('leaves out exam plans and an exam it cannot read', () {
|
||||
final content = decodeStudysetPayload(
|
||||
bytes({
|
||||
'schemaVersion': 7,
|
||||
'title': 'Japanese',
|
||||
'description': description.toJson(),
|
||||
'examPlans': [
|
||||
{'name': 'No id'},
|
||||
{
|
||||
'id': 'mock',
|
||||
'name': 'Mock test',
|
||||
'tags': ['JLPT-N5', 3],
|
||||
},
|
||||
],
|
||||
'exam': {'plan': 'mock', 'date': 'soon'},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(content.examPlans, const [
|
||||
ExamPlan(id: 'mock', name: 'Mock test', tags: ['JLPT-N5']),
|
||||
]);
|
||||
expect(content.exam, isNull);
|
||||
});
|
||||
|
||||
test('keeps the bibliography in its order', () {
|
||||
final content = StudysetContent(
|
||||
title: 'Japanese',
|
||||
description: description,
|
||||
bibliography: const [
|
||||
Source(
|
||||
authors: 'Tae Kim',
|
||||
year: '2014',
|
||||
title: 'A Guide to Japanese Grammar',
|
||||
isbn: '978-1-4499-5422-3',
|
||||
),
|
||||
Source(raw: 'Banno, E. (2020). Genki I. The Japan Times.'),
|
||||
],
|
||||
);
|
||||
|
||||
expect(decodeStudysetPayload(encodeStudysetPayload(content)), content);
|
||||
});
|
||||
|
||||
test('refuses a malformed bibliography', () {
|
||||
for (final bibliography in [
|
||||
'Kim 2014',
|
||||
['Kim 2014'],
|
||||
[
|
||||
{'title': false},
|
||||
],
|
||||
]) {
|
||||
expect(
|
||||
() => decodeStudysetPayload(
|
||||
bytes({
|
||||
'schemaVersion': 4,
|
||||
'title': 'x',
|
||||
'description': RichContent.empty().toJson(),
|
||||
'bibliography': bibliography,
|
||||
}),
|
||||
),
|
||||
throwsFormatException,
|
||||
reason: '$bibliography',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the study options', () {
|
||||
final content = StudysetContent(
|
||||
title: 'Japanese',
|
||||
description: description,
|
||||
studyOptions: const StudysetStudyOptions(
|
||||
newPerDay: 0,
|
||||
reviewsPerDay: 150,
|
||||
order: NewCardOrder.mixed,
|
||||
),
|
||||
);
|
||||
|
||||
expect(decodeStudysetPayload(encodeStudysetPayload(content)), content);
|
||||
});
|
||||
|
||||
test('reads an order it does not know as reviews first', () {
|
||||
final payload = bytes({
|
||||
'schemaVersion': 3,
|
||||
'title': 'Japanese',
|
||||
'description': description.toJson(),
|
||||
'study': {'newPerDay': 5, 'reviewsPerDay': 0, 'order': 'sideways'},
|
||||
});
|
||||
|
||||
expect(
|
||||
decodeStudysetPayload(payload).studyOptions,
|
||||
const StudysetStudyOptions(newPerDay: 5),
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses malformed study options', () {
|
||||
final payload = bytes({
|
||||
'schemaVersion': 3,
|
||||
'title': 'Japanese',
|
||||
'description': description.toJson(),
|
||||
'study': {'newPerDay': 'many'},
|
||||
});
|
||||
|
||||
expect(() => decodeStudysetPayload(payload), throwsFormatException);
|
||||
});
|
||||
|
||||
test('reads the first schema, which had only a title and a '
|
||||
'description', () {
|
||||
final payload = bytes({
|
||||
'schemaVersion': 1,
|
||||
'title': 'Japanese',
|
||||
'description': description.toJson(),
|
||||
});
|
||||
|
||||
expect(
|
||||
decodeStudysetPayload(payload),
|
||||
StudysetContent(title: 'Japanese', description: description),
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses a field of the wrong kind', () {
|
||||
final payload = bytes({
|
||||
'schemaVersion': 2,
|
||||
'title': 'Japanese',
|
||||
'description': description.toJson(),
|
||||
'licenseYear': 'long ago',
|
||||
});
|
||||
|
||||
expect(() => decodeStudysetPayload(payload), throwsFormatException);
|
||||
});
|
||||
|
||||
test('refuses a schema newer than this app knows', () {
|
||||
final payload = bytes({
|
||||
'schemaVersion': studysetSchemaVersion + 1,
|
||||
'title': 'From the future',
|
||||
'description': RichContent.empty().toJson(),
|
||||
});
|
||||
|
||||
expect(() => decodeStudysetPayload(payload), throwsFormatException);
|
||||
});
|
||||
|
||||
test('refuses what is not a studyset payload', () {
|
||||
expect(
|
||||
() => decodeStudysetPayload(redactedPayload),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => decodeStudysetPayload(bytes({'title': 'no version'})),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
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/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/learning_path/data/learning_path_repository.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/settings/data/settings_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';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_progress_repository.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/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_settings_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/memory_asset_file_cache.dart';
|
||||
|
||||
void main() {
|
||||
late StudysetRepository studysets;
|
||||
late CardRepository cards;
|
||||
late ReviewRepository reviews;
|
||||
late StudysetProgressRepository progress;
|
||||
|
||||
setUp(() {
|
||||
studysets = StudysetRepository(FakeStudysetGateway());
|
||||
cards = CardRepository(FakeCardGateway());
|
||||
reviews = ReviewRepository(FakeReviewGateway());
|
||||
final notes = NoteRepository(FakeNoteGateway());
|
||||
final paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
final settings = SettingsRepository(FakeSettingsGateway());
|
||||
final pdfs = PdfSourceRepository(
|
||||
FakePdfSourceGateway(),
|
||||
AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeAssetServerGateway(),
|
||||
MemoryAssetFileCache(),
|
||||
),
|
||||
);
|
||||
progress = StudysetProgressRepository(
|
||||
studysets: studysets,
|
||||
cards: cards,
|
||||
notes: notes,
|
||||
paths: paths,
|
||||
pdfs: pdfs,
|
||||
reviews: reviews,
|
||||
settings: settings,
|
||||
);
|
||||
addTearDown(() async {
|
||||
await progress.dispose();
|
||||
await studysets.dispose();
|
||||
await cards.dispose();
|
||||
await reviews.dispose();
|
||||
await notes.dispose();
|
||||
await paths.dispose();
|
||||
await settings.dispose();
|
||||
await pdfs.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
Future<String> card(String studysetId) => cards.create(
|
||||
studysetId,
|
||||
CardContent(front: RichContent.plain('猫'), back: RichContent.plain('cat')),
|
||||
);
|
||||
|
||||
/// Everything [progress] hands one following it, as it comes.
|
||||
List<Map<String, StudyProgress>> follow() {
|
||||
final seen = <Map<String, StudyProgress>>[];
|
||||
final subscription = progress.watch().listen(seen.add);
|
||||
addTearDown(subscription.cancel);
|
||||
return seen;
|
||||
}
|
||||
|
||||
test('works out every studyset with what is inside it, once for all '
|
||||
'following it; one following later gets it at once, worked out '
|
||||
'no more', () async {
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final kana = await studysets.create(
|
||||
StudysetContent.titled('Kana'),
|
||||
parentId: japanese,
|
||||
);
|
||||
await card(japanese);
|
||||
await card(kana);
|
||||
|
||||
final first = follow();
|
||||
await pumpEventQueue();
|
||||
expect(first, hasLength(1));
|
||||
expect(first.single[japanese]?.cards, 2);
|
||||
expect(first.single[kana]?.cards, 1);
|
||||
|
||||
final second = follow();
|
||||
await pumpEventQueue();
|
||||
expect(second, [first.single]);
|
||||
expect(first, hasLength(1), reason: 'nothing changed');
|
||||
});
|
||||
|
||||
test('works it out again after a change of cards or reviews, but not when '
|
||||
'the same studysets are handed again', () async {
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final neko = await card(japanese);
|
||||
final seen = follow();
|
||||
await pumpEventQueue();
|
||||
expect(seen.last[japanese]?.due, 1);
|
||||
|
||||
await studysets.reread();
|
||||
await pumpEventQueue();
|
||||
expect(seen, hasLength(1));
|
||||
|
||||
await reviews.record(
|
||||
cardId: neko,
|
||||
rating: Rating.easy,
|
||||
reviewedAt: clock.now(),
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
expect(seen.last[japanese]?.due, 0);
|
||||
|
||||
await card(japanese);
|
||||
await pumpEventQueue();
|
||||
expect(seen.last[japanese]?.cards, 2);
|
||||
});
|
||||
|
||||
test('one following it again after nobody did gets it as things are '
|
||||
'now', () async {
|
||||
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||
final library = progress.watch().listen((_) {});
|
||||
await pumpEventQueue();
|
||||
await library.cancel();
|
||||
|
||||
await card(japanese);
|
||||
await pumpEventQueue();
|
||||
|
||||
final seen = follow();
|
||||
await pumpEventQueue();
|
||||
expect(seen, hasLength(1));
|
||||
expect(seen.single[japanese]?.cards, 1);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,373 @@
|
|||
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/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
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_studyset_gateway.dart';
|
||||
|
||||
void main() {
|
||||
late FakeStudysetGateway gateway;
|
||||
late StudysetRepository repository;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeStudysetGateway();
|
||||
repository = StudysetRepository(gateway);
|
||||
});
|
||||
tearDown(() => repository.dispose());
|
||||
|
||||
StudysetContent titled(String title) =>
|
||||
StudysetContent(title: title, description: RichContent.empty());
|
||||
|
||||
Iterable<String> titles(List<Studyset> studysets) =>
|
||||
studysets.map((studyset) => studyset.content.title);
|
||||
|
||||
test('lists what is stored, then every change', () async {
|
||||
await repository.create(titled('Japanese'));
|
||||
final lists = repository.watchAll();
|
||||
|
||||
final expectation = expectLater(
|
||||
lists.map(titles),
|
||||
emitsInOrder([
|
||||
['Japanese'],
|
||||
['Japanese', 'Kanji'],
|
||||
]),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await repository.create(titled('Kanji'));
|
||||
await expectation;
|
||||
});
|
||||
|
||||
test('one more following them gets the studysets as last read at once, '
|
||||
'read no more and handed to nobody else again', () async {
|
||||
await repository.create(titled('Japanese'));
|
||||
final first = <List<Studyset>>[];
|
||||
final library = repository.watchAll().listen(first.add);
|
||||
addTearDown(library.cancel);
|
||||
await pumpEventQueue();
|
||||
final reads = gateway.reads;
|
||||
|
||||
final second = <List<Studyset>>[];
|
||||
final screen = repository.watchAll().listen(second.add);
|
||||
addTearDown(screen.cancel);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(second.map(titles), [
|
||||
['Japanese'],
|
||||
]);
|
||||
expect(first, hasLength(1));
|
||||
expect(gateway.reads, reads);
|
||||
});
|
||||
|
||||
test('reads the studysets on this device once', () async {
|
||||
await repository.create(titled('Japanese'));
|
||||
await repository.create(titled('Kanji'));
|
||||
|
||||
expect(titles(await repository.all()), ['Japanese', 'Kanji']);
|
||||
});
|
||||
|
||||
test('creates studysets at the top level, neither starred nor '
|
||||
'archived', () async {
|
||||
final content = StudysetContent(
|
||||
title: 'Japanese',
|
||||
description: RichContent.empty(),
|
||||
secondaryTitle: 'JLPT N5',
|
||||
colorName: 'cobalt',
|
||||
);
|
||||
|
||||
final createdAt = DateTime.utc(2026, 10, 1, 8);
|
||||
final id = await withClock(
|
||||
Clock.fixed(createdAt),
|
||||
() => repository.create(content),
|
||||
);
|
||||
|
||||
expect(await repository.watchAll().first, [
|
||||
Studyset(id: id, content: content, updatedAt: createdAt),
|
||||
]);
|
||||
});
|
||||
|
||||
test('stores changed content', () async {
|
||||
final changedAt = DateTime.utc(2026, 10, 1, 9);
|
||||
final id = await repository.create(titled('Japanese'));
|
||||
final changed = StudysetContent(
|
||||
title: '日本語',
|
||||
description: RichContent({
|
||||
'document': {'type': 'page'},
|
||||
'note': 'changed',
|
||||
}),
|
||||
licenseYear: 2026,
|
||||
);
|
||||
|
||||
await withClock(
|
||||
Clock.fixed(changedAt),
|
||||
() => repository.updateContent(id, changed),
|
||||
);
|
||||
|
||||
expect(await repository.watchAll().first, [
|
||||
Studyset(id: id, content: changed, updatedAt: changedAt),
|
||||
]);
|
||||
});
|
||||
|
||||
test('stars and unstars, writing nothing but the star', () async {
|
||||
final starredAt = DateTime.utc(2026, 10, 1, 9);
|
||||
final id = await repository.create(titled('Japanese'));
|
||||
|
||||
await withClock(Clock.fixed(starredAt), () => repository.star(id));
|
||||
expect((await repository.all()).single.starredAt, starredAt);
|
||||
|
||||
await repository.unstar(id);
|
||||
expect((await repository.all()).single.isStarred, isFalse);
|
||||
expect(gateway.writes, hasLength(1), reason: 'the payload only once');
|
||||
});
|
||||
|
||||
test('archives and unarchives, writing nothing but the archive '
|
||||
'date', () async {
|
||||
final archivedAt = DateTime.utc(2026, 10, 1, 9);
|
||||
final id = await repository.create(titled('Japanese'));
|
||||
|
||||
await withClock(Clock.fixed(archivedAt), () => repository.archive(id));
|
||||
expect((await repository.all()).single.archivedAt, archivedAt);
|
||||
|
||||
await repository.unarchive(id);
|
||||
expect((await repository.all()).single.isArchived, isFalse);
|
||||
expect(gateway.writes, hasLength(1), reason: 'the payload only once');
|
||||
});
|
||||
|
||||
test('every change stamps when it happened', () async {
|
||||
DateTime at(int hour) => DateTime.utc(2026, 10, 1, hour);
|
||||
Future<DateTime?> updatedAt() async =>
|
||||
(await repository.all()).single.updatedAt;
|
||||
|
||||
final id = await withClock(
|
||||
Clock.fixed(at(8)),
|
||||
() => repository.create(titled('Japanese')),
|
||||
);
|
||||
expect(await updatedAt(), at(8));
|
||||
|
||||
await withClock(
|
||||
Clock.fixed(at(9)),
|
||||
() => repository.updateContent(id, titled('日本語')),
|
||||
);
|
||||
expect(await updatedAt(), at(9));
|
||||
|
||||
await withClock(Clock.fixed(at(10)), () => repository.star(id));
|
||||
expect(await updatedAt(), at(10));
|
||||
|
||||
await withClock(Clock.fixed(at(11)), () => repository.archive(id));
|
||||
expect(await updatedAt(), at(11));
|
||||
});
|
||||
|
||||
group('in the tree', () {
|
||||
Future<List<String>> childrenTitles(String parentId) async => [
|
||||
for (final child in childrenOf(parentId, await repository.all()))
|
||||
child.content.title,
|
||||
];
|
||||
|
||||
test('creates a sub-studyset after its siblings', () async {
|
||||
final japanese = await repository.create(titled('Japanese'));
|
||||
await repository.create(titled('Kana'), parentId: japanese);
|
||||
await repository.create(titled('Kanji'), parentId: japanese);
|
||||
|
||||
expect(await childrenTitles(japanese), ['Kana', 'Kanji']);
|
||||
});
|
||||
|
||||
test('moves a studyset into another, after its children, writing only '
|
||||
'its place', () async {
|
||||
final japanese = await repository.create(titled('Japanese'));
|
||||
await repository.create(titled('Kana'), parentId: japanese);
|
||||
final kanji = await repository.create(titled('Kanji'));
|
||||
final payloadWrites = gateway.writes.length;
|
||||
|
||||
await repository.move(kanji, parentId: japanese);
|
||||
|
||||
expect(await childrenTitles(japanese), ['Kana', 'Kanji']);
|
||||
expect(gateway.writes, hasLength(payloadWrites));
|
||||
});
|
||||
|
||||
test('moves a studyset to the top level', () async {
|
||||
final japanese = await repository.create(titled('Japanese'));
|
||||
final kana = await repository.create(titled('Kana'), parentId: japanese);
|
||||
|
||||
await repository.move(kana, parentId: null);
|
||||
|
||||
expect(
|
||||
titles(topLevel(await repository.all())),
|
||||
unorderedEquals(['Japanese', 'Kana']),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'refuses to move a studyset into itself or anything inside it',
|
||||
() async {
|
||||
final japanese = await repository.create(titled('Japanese'));
|
||||
final kana = await repository.create(
|
||||
titled('Kana'),
|
||||
parentId: japanese,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
repository.move(japanese, parentId: japanese),
|
||||
throwsArgumentError,
|
||||
);
|
||||
await expectLater(
|
||||
repository.move(japanese, parentId: kana),
|
||||
throwsArgumentError,
|
||||
);
|
||||
expect(await childrenTitles(japanese), ['Kana']);
|
||||
},
|
||||
);
|
||||
|
||||
test('reorders a studyset among its siblings', () async {
|
||||
final japanese = await repository.create(titled('Japanese'));
|
||||
await repository.create(titled('Kana'), parentId: japanese);
|
||||
await repository.create(titled('Kanji'), parentId: japanese);
|
||||
final grammar = await repository.create(
|
||||
titled('Grammar'),
|
||||
parentId: japanese,
|
||||
);
|
||||
|
||||
await repository.reorder(grammar, toIndex: 0);
|
||||
|
||||
expect(await childrenTitles(japanese), ['Grammar', 'Kana', 'Kanji']);
|
||||
});
|
||||
|
||||
test('studysets from before they had an order, all at one position, '
|
||||
'make room for one moved among them; those whose deletion waits '
|
||||
'count for nothing', () async {
|
||||
final japanese = await repository.create(titled('Japanese'));
|
||||
for (final (id, title) in [
|
||||
('s1', 'A'),
|
||||
('s2', 'B'),
|
||||
('s3', 'C'),
|
||||
('s4', 'D'),
|
||||
]) {
|
||||
gateway.receiveRemoteChange(
|
||||
id,
|
||||
encodeStudysetPayload(titled(title)),
|
||||
parentId: japanese,
|
||||
);
|
||||
}
|
||||
|
||||
await repository.reorder('s4', toIndex: 1);
|
||||
expect(await childrenTitles(japanese), ['A', 'D', 'B', 'C']);
|
||||
|
||||
// B waits in Recently deleted: the list shows A, D, C, and A goes
|
||||
// after C; restored, B is back where it was.
|
||||
await repository.markDeleted('s2');
|
||||
await repository.reorder('s1', toIndex: 2);
|
||||
expect(await childrenTitles(japanese), ['D', 'C', 'A']);
|
||||
await repository.restore('s2');
|
||||
expect(await childrenTitles(japanese), ['D', 'B', 'C', 'A']);
|
||||
});
|
||||
});
|
||||
|
||||
test('a deleted studyset waits in Recently deleted with everything '
|
||||
'inside it, out of every other list, until restored', () async {
|
||||
final japanese = await repository.create(titled('Japanese'));
|
||||
final kana = await repository.create(titled('Kana'), parentId: japanese);
|
||||
final greek = await repository.create(titled('Greek'));
|
||||
final deleted = <List<String>>[];
|
||||
final live = <List<String>>[];
|
||||
final subscriptions = [
|
||||
repository.watchDeleted().listen(
|
||||
(studysets) => deleted.add([for (final s in studysets) s.id]),
|
||||
),
|
||||
repository.watchAll().listen(
|
||||
(studysets) => live.add([for (final s in studysets) s.id]),
|
||||
),
|
||||
];
|
||||
addTearDown(() => Future.wait([for (final s in subscriptions) s.cancel()]));
|
||||
|
||||
await withClock(Clock.fixed(DateTime.utc(2026, 10, 12)), () async {
|
||||
await repository.markDeleted(kana);
|
||||
await repository.markDeleted(japanese);
|
||||
});
|
||||
await pumpEventQueue();
|
||||
expect([for (final s in await repository.all()) s.id], [greek]);
|
||||
expect(live.last, [greek]);
|
||||
// Kana waits inside Japanese, which brings it back.
|
||||
expect(deleted.last, [japanese]);
|
||||
expect(
|
||||
(await repository.withDeleted())
|
||||
.firstWhere((s) => s.id == japanese)
|
||||
.deletedAt,
|
||||
DateTime.utc(2026, 10, 12),
|
||||
);
|
||||
|
||||
await repository.restore(japanese);
|
||||
await pumpEventQueue();
|
||||
expect(live.last, [japanese, greek]);
|
||||
expect(deleted.last, [kana]);
|
||||
});
|
||||
|
||||
test('blanks the content before deleting', () async {
|
||||
final id = await repository.create(titled('Secret'));
|
||||
|
||||
await repository.delete(id);
|
||||
|
||||
expect(gateway.writes.last, (id, redactedPayload));
|
||||
expect(gateway.deleted, [id]);
|
||||
expect(await repository.watchAll().first, isEmpty);
|
||||
});
|
||||
|
||||
test('shows studysets that arrive from other devices', () async {
|
||||
final lists = repository.watchAll();
|
||||
final expectation = expectLater(
|
||||
lists.map(titles),
|
||||
emitsInOrder([
|
||||
<String>[],
|
||||
['From the laptop'],
|
||||
]),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
gateway.receiveRemoteChange(
|
||||
'laptop-1',
|
||||
encodeStudysetPayload(titled('From the laptop')),
|
||||
);
|
||||
await expectation;
|
||||
});
|
||||
|
||||
test('reports a failed read and recovers with the next change', () async {
|
||||
await repository.create(titled('Japanese'));
|
||||
gateway.failReads = Exception('Disk unavailable');
|
||||
final lists = repository.watchAll();
|
||||
final expectation = expectLater(
|
||||
lists.map(titles),
|
||||
emitsInOrder([
|
||||
emitsError(isException),
|
||||
['Japanese', 'Kanji'],
|
||||
]),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
gateway.failReads = null;
|
||||
await repository.create(titled('Kanji'));
|
||||
await expectation;
|
||||
});
|
||||
|
||||
test('skips studysets this app version cannot read', () async {
|
||||
await repository.create(titled('Readable'));
|
||||
gateway.receiveRemoteChange(
|
||||
'future-1',
|
||||
ByteData.sublistView(
|
||||
utf8.encode(
|
||||
jsonEncode({
|
||||
'schemaVersion': studysetSchemaVersion + 1,
|
||||
'title': 'From a newer app',
|
||||
'description': RichContent.empty().toJson(),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(titles(await repository.watchAll().first), ['Readable']);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||
|
||||
import '../../../support/memory_studyset_tab_store.dart';
|
||||
|
||||
void main() {
|
||||
late Directory folder;
|
||||
|
||||
setUp(() async {
|
||||
folder = await Directory.systemTemp.createTemp('studyset_tabs');
|
||||
});
|
||||
tearDown(() => folder.delete(recursive: true));
|
||||
|
||||
test('keeps the tab each studyset was left on, in a file that a new '
|
||||
'session reads again', () async {
|
||||
final file = File('${folder.path}/studyset_tabs.json');
|
||||
final tabs = StudysetTabs(FileStudysetTabStore(file));
|
||||
await tabs.open();
|
||||
expect(tabs.of('japanese'), isNull);
|
||||
|
||||
await tabs.keep('japanese', 'paths');
|
||||
await tabs.keep('spanish', 'notes');
|
||||
await tabs.keep('japanese', 'flashcards');
|
||||
|
||||
final later = StudysetTabs(FileStudysetTabStore(file));
|
||||
await later.open();
|
||||
expect(later.of('japanese'), 'flashcards');
|
||||
expect(later.of('spanish'), 'notes');
|
||||
});
|
||||
|
||||
test('reads an unreadable file as no tabs kept', () async {
|
||||
final file = File('${folder.path}/studyset_tabs.json')
|
||||
..writeAsStringSync('{not json');
|
||||
final tabs = StudysetTabs(FileStudysetTabStore(file));
|
||||
await tabs.open();
|
||||
|
||||
expect(tabs.of('japanese'), isNull);
|
||||
});
|
||||
|
||||
test('writes only when the tab changes', () async {
|
||||
final kept = {'japanese': 'paths'};
|
||||
final store = MemoryStudysetTabStore(kept);
|
||||
final tabs = StudysetTabs(store);
|
||||
await tabs.open();
|
||||
|
||||
await tabs.keep('japanese', 'paths');
|
||||
expect(store.kept, same(kept));
|
||||
await tabs.keep('japanese', 'notes');
|
||||
expect(store.kept, {'japanese': 'notes'});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
|
||||
|
||||
void main() {
|
||||
group('Plans offered from level tags', () {
|
||||
test('offer every level up to the highest present, each taking the '
|
||||
'levels below it, whatever the case of the tags', () {
|
||||
final plans = automaticExamPlans(['JLPT-N5', 'jlpt-n3', 'noun']);
|
||||
|
||||
expect(plans, const [
|
||||
ExamPlan(id: 'auto:jlpt:1', name: 'JLPT N5', tags: ['JLPT-N5']),
|
||||
ExamPlan(id: 'auto:jlpt:2', name: 'JLPT N4', tags: ['JLPT-N5']),
|
||||
ExamPlan(
|
||||
id: 'auto:jlpt:3',
|
||||
name: 'JLPT N3',
|
||||
tags: ['JLPT-N5', 'JLPT-N3'],
|
||||
),
|
||||
]);
|
||||
expect(plans.every((plan) => plan.automatic), isTrue);
|
||||
});
|
||||
|
||||
test('come for HSK and Korean levels too, and none without level '
|
||||
'tags', () {
|
||||
expect(
|
||||
[
|
||||
for (final plan in automaticExamPlans(['HSK-7-9', '중급'])) plan.name,
|
||||
],
|
||||
['HSK 7–9', 'Korean 중급'],
|
||||
);
|
||||
expect(automaticExamPlans(['noun', 'verb']), isEmpty);
|
||||
});
|
||||
|
||||
test('leave out the levels below the lowest present, which would cover '
|
||||
'nothing', () {
|
||||
expect(automaticExamPlans(['HSK-3', 'HSK-5']), const [
|
||||
ExamPlan(id: 'auto:hsk:3', name: 'HSK 3', tags: ['HSK-3']),
|
||||
ExamPlan(id: 'auto:hsk:4', name: 'HSK 4', tags: ['HSK-3']),
|
||||
ExamPlan(id: 'auto:hsk:5', name: 'HSK 5', tags: ['HSK-3', 'HSK-5']),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('An exam plan', () {
|
||||
test('survives a round trip, and is its author’s', () {
|
||||
const plan = ExamPlan(
|
||||
id: 'final',
|
||||
name: 'Final exam',
|
||||
topics: ['kana'],
|
||||
tags: ['verb'],
|
||||
);
|
||||
|
||||
expect(ExamPlan.fromJson(plan.toJson()), plan);
|
||||
expect(plan.automatic, isFalse);
|
||||
expect(const ExamPlan(id: 'a', name: 'A').toJson(), {
|
||||
'id': 'a',
|
||||
'name': 'A',
|
||||
});
|
||||
});
|
||||
|
||||
test('is null for what is not one', () {
|
||||
expect(ExamPlan.fromJson({'name': 'No id'}), isNull);
|
||||
expect(ExamPlan.fromJson('final'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('The exam chosen', () {
|
||||
test('keeps its day as the calendar has it', () {
|
||||
final choice = ExamChoice(plan: 'final', date: DateTime(2026, 3, 9));
|
||||
|
||||
expect(choice.toJson(), {'plan': 'final', 'date': '2026-03-09'});
|
||||
expect(ExamChoice.fromJson(choice.toJson()), choice);
|
||||
});
|
||||
|
||||
test('is null without a day it can read', () {
|
||||
expect(ExamChoice.fromJson({'plan': 'final', 'date': 'soon'}), isNull);
|
||||
expect(ExamChoice.fromJson({'plan': 'final'}), isNull);
|
||||
});
|
||||
|
||||
test('counts the days left by the calendar, and is past from the day '
|
||||
'after it on', () {
|
||||
final choice = ExamChoice(plan: 'final', date: DateTime(2026, 12, 6));
|
||||
|
||||
expect(choice.daysLeftAt(DateTime(2026, 10, 7, 23)), 60);
|
||||
expect(choice.daysLeftAt(DateTime(2026, 12, 6, 8)), 0);
|
||||
expect(choice.isPastAt(DateTime(2026, 12, 6, 23, 59)), isFalse);
|
||||
expect(choice.isPastAt(DateTime(2026, 12, 7)), isTrue);
|
||||
expect(choice.daysLeftAt(DateTime(2026, 12, 8)), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('The pace to an exam', () {
|
||||
test('spreads what is new so that it is done a week before the exam', () {
|
||||
final pace = examPace(
|
||||
today: DateTime(2026, 10, 14, 21),
|
||||
examDate: DateTime(2026, 12, 6),
|
||||
newCards: 460,
|
||||
stepsLeft: 13,
|
||||
pagesLeft: 0,
|
||||
);
|
||||
|
||||
// 53 days left, 46 of them to learn in: 10 cards a day, and 13 steps
|
||||
// over six and a half weeks.
|
||||
expect(pace, (
|
||||
daysLeft: 53,
|
||||
newPerDay: 10,
|
||||
stepsPerWeek: 2,
|
||||
pagesPerDay: 0,
|
||||
));
|
||||
});
|
||||
|
||||
test('leaves half the days left for reviews when the exam is closer '
|
||||
'than two weeks', () {
|
||||
final pace = examPace(
|
||||
today: DateTime(2026, 10, 14),
|
||||
examDate: DateTime(2026, 10, 24),
|
||||
newCards: 25,
|
||||
stepsLeft: 3,
|
||||
pagesLeft: 101,
|
||||
);
|
||||
|
||||
// 10 days left, 5 of them to learn in.
|
||||
expect(pace, (
|
||||
daysLeft: 10,
|
||||
newPerDay: 5,
|
||||
stepsPerWeek: 3,
|
||||
pagesPerDay: 21,
|
||||
));
|
||||
});
|
||||
|
||||
test('keeps its rate when followed until two weeks are left', () {
|
||||
int newPerDay(DateTime today, int newCards) => examPace(
|
||||
today: today,
|
||||
examDate: DateTime(2026, 12, 6),
|
||||
newCards: newCards,
|
||||
stepsLeft: 0,
|
||||
pagesLeft: 0,
|
||||
).newPerDay;
|
||||
|
||||
// 30 days out, 230 cards over the 23 before the last week.
|
||||
expect(newPerDay(DateTime(2026, 11, 6), 230), 10);
|
||||
// 16 days on, 70 are left, with 14 days to go: not half the rate.
|
||||
expect(newPerDay(DateTime(2026, 11, 22), 70), 10);
|
||||
});
|
||||
|
||||
test('counts calendar days across a change of summer time', () {
|
||||
expect(
|
||||
examPace(
|
||||
today: DateTime(2026, 10, 20),
|
||||
examDate: DateTime(2026, 10, 30),
|
||||
newCards: 0,
|
||||
stepsLeft: 0,
|
||||
pagesLeft: 0,
|
||||
).daysLeft,
|
||||
10,
|
||||
);
|
||||
});
|
||||
|
||||
test('asks for everything at once on the day or after it', () {
|
||||
final pace = examPace(
|
||||
today: DateTime(2026, 12, 8),
|
||||
examDate: DateTime(2026, 12, 6),
|
||||
newCards: 40,
|
||||
stepsLeft: 0,
|
||||
pagesLeft: 0,
|
||||
);
|
||||
|
||||
expect(pace.daysLeft, 0);
|
||||
expect(pace.newPerDay, 40);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_progress.dart';
|
||||
|
||||
void main() {
|
||||
ExamProgress progress({
|
||||
int newCards = 0,
|
||||
int startedToday = 0,
|
||||
int due = 0,
|
||||
}) => (
|
||||
cards: 100,
|
||||
newCards: newCards,
|
||||
startedToday: startedToday,
|
||||
due: due,
|
||||
steps: 0,
|
||||
stepsDone: 0,
|
||||
pages: 0,
|
||||
pagesRead: 0,
|
||||
);
|
||||
|
||||
const ExamPace pace = (
|
||||
daysLeft: 30,
|
||||
newPerDay: 5,
|
||||
stepsPerWeek: 0,
|
||||
pagesPerDay: 0,
|
||||
);
|
||||
|
||||
test('studying for the exam takes every review due and the new cards '
|
||||
'the pace leaves for the day', () {
|
||||
expect(examCardsToStudy(progress(newCards: 40, due: 12), pace), 17);
|
||||
expect(
|
||||
examCardsToStudy(progress(newCards: 40, startedToday: 3, due: 2), pace),
|
||||
4,
|
||||
);
|
||||
expect(
|
||||
examCardsToStudy(progress(newCards: 40, startedToday: 7), pace),
|
||||
0,
|
||||
reason: 'more started today than the pace asks',
|
||||
);
|
||||
expect(
|
||||
examCardsToStudy(progress(newCards: 2, due: 1), pace),
|
||||
3,
|
||||
reason: 'no more new cards than there are',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/license_link.dart';
|
||||
|
||||
void main() {
|
||||
test('links a Creative Commons licence to its deed', () {
|
||||
expect(
|
||||
licenseUrl('CC-BY-SA-4.0'),
|
||||
Uri.parse('https://creativecommons.org/licenses/by-sa/4.0/'),
|
||||
);
|
||||
expect(
|
||||
licenseUrl('CC-BY-NC-SA-3.0-US'),
|
||||
Uri.parse('https://creativecommons.org/licenses/by-nc-sa/3.0/us/'),
|
||||
);
|
||||
expect(
|
||||
licenseUrl('CC0-1.0'),
|
||||
Uri.parse('https://creativecommons.org/publicdomain/zero/1.0/'),
|
||||
);
|
||||
});
|
||||
|
||||
test("links other well-known licences to SPDX's page", () {
|
||||
expect(
|
||||
licenseUrl('MIT'),
|
||||
Uri.parse('https://spdx.org/licenses/MIT.html'),
|
||||
);
|
||||
expect(
|
||||
licenseUrl('AGPL-3.0-or-later'),
|
||||
Uri.parse('https://spdx.org/licenses/AGPL-3.0-or-later.html'),
|
||||
);
|
||||
});
|
||||
|
||||
test('links nothing it does not know', () {
|
||||
expect(licenseUrl('All Rights Reserved'), isNull);
|
||||
expect(licenseUrl('My own licence'), isNull);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/license.dart';
|
||||
|
||||
void main() {
|
||||
test('a licence is kept in one spelling: known ones as known, Creative '
|
||||
'Commons by its identifier, anything else as typed', () {
|
||||
expect(canonicalLicense('Cc By 4.0'), 'CC-BY-4.0');
|
||||
expect(canonicalLicense('cc_by_sa'), 'CC-BY-SA-4.0');
|
||||
expect(canonicalLicense('mit'), 'MIT');
|
||||
expect(canonicalLicense('arr'), 'All Rights Reserved');
|
||||
expect(canonicalLicense('cc by-nc-sa 3.0 us'), 'CC-BY-NC-SA-3.0-US');
|
||||
expect(canonicalLicense(' My own terms '), 'My own terms');
|
||||
expect(canonicalLicense(' '), isNull);
|
||||
expect(canonicalLicense(null), isNull);
|
||||
});
|
||||
|
||||
test("words around a Creative Commons licence don't change it, and a "
|
||||
'name written out is read', () {
|
||||
expect(canonicalLicense('CC BY-SA 4.0 International'), 'CC-BY-SA-4.0');
|
||||
expect(canonicalLicense('CC BY 3.0 Unported'), 'CC-BY-3.0');
|
||||
expect(
|
||||
canonicalLicense(
|
||||
'Creative Commons Attribution-NonCommercial-ShareAlike 4.0 '
|
||||
'International Public License',
|
||||
),
|
||||
'CC-BY-NC-SA-4.0',
|
||||
);
|
||||
expect(canonicalLicense('CC BY 3.0 IGO'), 'CC-BY-3.0-IGO');
|
||||
// 4.0 was never ported to a country.
|
||||
expect(canonicalLicense('CC BY 4.0 DE'), 'CC BY 4.0 DE');
|
||||
});
|
||||
|
||||
test("a licence's meaning is never widened: GPL-3.0 is \"only\", and "
|
||||
'what says neither, or is no licence, stays as typed', () {
|
||||
expect(canonicalLicense('GPL-3.0'), 'GPL-3.0-only');
|
||||
expect(canonicalLicense('agpl 3.0'), 'AGPL-3.0-only');
|
||||
expect(canonicalLicense('GPL-3.0-or-later'), 'GPL-3.0-or-later');
|
||||
expect(canonicalLicense('GPLv3'), 'GPLv3');
|
||||
expect(canonicalLicense('public domain'), 'public domain');
|
||||
});
|
||||
|
||||
test('an older or ported Creative Commons licence has a current version; '
|
||||
'a current one, or another licence, none', () {
|
||||
expect(newerCreativeCommons('CC-BY-NC-SA-3.0-US'), 'CC-BY-NC-SA-4.0');
|
||||
expect(newerCreativeCommons('CC-BY-2.0'), 'CC-BY-4.0');
|
||||
expect(newerCreativeCommons('CC-BY-SA-4.0'), isNull);
|
||||
expect(newerCreativeCommons('CC0-1.0'), isNull);
|
||||
expect(newerCreativeCommons('MIT'), isNull);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
|
||||
Studyset studyset(
|
||||
String id, {
|
||||
String? parent,
|
||||
String position = 'a0',
|
||||
DateTime? archivedAt,
|
||||
}) => Studyset(
|
||||
id: id,
|
||||
content: StudysetContent.titled(id),
|
||||
parentId: parent,
|
||||
position: position,
|
||||
archivedAt: archivedAt,
|
||||
);
|
||||
|
||||
Iterable<String> ids(List<Studyset> studysets) =>
|
||||
studysets.map((studyset) => studyset.id);
|
||||
|
||||
void main() {
|
||||
test('the top level holds the studysets without a parent, in their '
|
||||
'order', () {
|
||||
final all = [
|
||||
studyset('japanese'),
|
||||
studyset('kana', parent: 'japanese'),
|
||||
studyset('german'),
|
||||
];
|
||||
|
||||
expect(ids(topLevel(all)), ['japanese', 'german']);
|
||||
});
|
||||
|
||||
test('a studyset whose parent is gone shows at the top level, with what '
|
||||
'is inside it', () {
|
||||
final all = [
|
||||
studyset('kana', parent: 'deleted'),
|
||||
studyset('hiragana', parent: 'kana'),
|
||||
];
|
||||
|
||||
expect(ids(topLevel(all)), ['kana']);
|
||||
});
|
||||
|
||||
test('studysets moved into each other show at the top level; what is '
|
||||
'inside them stays inside', () {
|
||||
// Two devices moved a into b and b into a at once.
|
||||
final all = [
|
||||
studyset('a', parent: 'b'),
|
||||
studyset('b', parent: 'a'),
|
||||
studyset('c', parent: 'a'),
|
||||
];
|
||||
|
||||
expect(ids(topLevel(all)), ['a', 'b']);
|
||||
});
|
||||
|
||||
group('the children of a studyset', () {
|
||||
test('come in their order; ties go by id', () {
|
||||
final all = [
|
||||
studyset('japanese'),
|
||||
studyset('kanji', parent: 'japanese', position: 'a2'),
|
||||
studyset('kana', parent: 'japanese', position: 'a1'),
|
||||
studyset('grammar', parent: 'japanese', position: 'a1'),
|
||||
studyset('german'),
|
||||
];
|
||||
|
||||
expect(ids(childrenOf('japanese', all)), ['grammar', 'kana', 'kanji']);
|
||||
});
|
||||
});
|
||||
|
||||
group('the subtree of a studyset', () {
|
||||
test('is the studyset, then each child with its own subtree, in '
|
||||
'order', () {
|
||||
final all = [
|
||||
studyset('japanese'),
|
||||
studyset('kana', parent: 'japanese', position: 'a1'),
|
||||
studyset('hiragana', parent: 'kana', position: 'a1'),
|
||||
studyset('katakana', parent: 'kana', position: 'a2'),
|
||||
studyset('kanji', parent: 'japanese', position: 'a2'),
|
||||
studyset('german'),
|
||||
];
|
||||
|
||||
expect(subtreeOf('japanese', all), [
|
||||
'japanese',
|
||||
'kana',
|
||||
'hiragana',
|
||||
'katakana',
|
||||
'kanji',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ends where studysets were moved into each other', () {
|
||||
final all = [
|
||||
studyset('a', parent: 'b'),
|
||||
studyset('b', parent: 'a'),
|
||||
studyset('c', parent: 'a'),
|
||||
];
|
||||
|
||||
expect(subtreeOf('a', all), ['a', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
group('the ancestors of a studyset', () {
|
||||
test('lead from the top level down to its parent', () {
|
||||
final all = [
|
||||
studyset('japanese'),
|
||||
studyset('kana', parent: 'japanese'),
|
||||
studyset('hiragana', parent: 'kana'),
|
||||
];
|
||||
|
||||
expect(ids(ancestorsOf('hiragana', all)), ['japanese', 'kana']);
|
||||
expect(ancestorsOf('japanese', all), isEmpty);
|
||||
});
|
||||
|
||||
test('start at a studyset that shows at the top level for a missing '
|
||||
'parent or a cycle', () {
|
||||
final all = [
|
||||
studyset('kana', parent: 'deleted'),
|
||||
studyset('hiragana', parent: 'kana'),
|
||||
studyset('a', parent: 'b'),
|
||||
studyset('b', parent: 'a'),
|
||||
studyset('c', parent: 'a'),
|
||||
];
|
||||
|
||||
expect(ids(ancestorsOf('hiragana', all)), ['kana']);
|
||||
expect(ids(ancestorsOf('c', all)), ['a']);
|
||||
expect(ancestorsOf('a', all), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('what a session on a studyset studies', () {
|
||||
test('is its subtree in tree order, without archived studysets and '
|
||||
'what is inside them', () {
|
||||
final archived = DateTime.utc(2026, 10);
|
||||
final all = [
|
||||
studyset('japanese'),
|
||||
studyset('kana', parent: 'japanese', position: 'a1'),
|
||||
studyset('hiragana', parent: 'kana'),
|
||||
studyset(
|
||||
'kanji',
|
||||
parent: 'japanese',
|
||||
position: 'a2',
|
||||
archivedAt: archived,
|
||||
),
|
||||
studyset('jouyou', parent: 'kanji'),
|
||||
];
|
||||
|
||||
expect(studiedIn('japanese', all), ['japanese', 'kana', 'hiragana']);
|
||||
expect(studiedIn('kanji', all), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('a studyset without a colour of its own takes the nearest one above '
|
||||
'it', () {
|
||||
Studyset coloured(String id, String? color, {String? parent}) => Studyset(
|
||||
id: id,
|
||||
content: StudysetContent(
|
||||
title: id,
|
||||
description: RichContent.empty(),
|
||||
colorName: color,
|
||||
),
|
||||
parentId: parent,
|
||||
);
|
||||
final all = [
|
||||
coloured('japanese', 'fern'),
|
||||
coloured('kana', null, parent: 'japanese'),
|
||||
coloured('hiragana', null, parent: 'kana'),
|
||||
coloured('kanji', 'plum', parent: 'japanese'),
|
||||
coloured('german', null),
|
||||
];
|
||||
|
||||
expect(colorNameOf('hiragana', all), 'fern');
|
||||
expect(colorNameOf('kanji', all), 'plum');
|
||||
expect(colorNameOf('german', all), isNull);
|
||||
});
|
||||
|
||||
test('a studyset without languages of its own takes those of the nearest '
|
||||
'studyset above it that gives any, both together', () {
|
||||
Studyset speaking(
|
||||
String id, {
|
||||
String? front,
|
||||
String? back,
|
||||
String? parent,
|
||||
}) => Studyset(
|
||||
id: id,
|
||||
content: StudysetContent(
|
||||
title: id,
|
||||
description: RichContent.empty(),
|
||||
frontLanguage: front,
|
||||
backLanguage: back,
|
||||
),
|
||||
parentId: parent,
|
||||
);
|
||||
final all = [
|
||||
speaking('japanese', front: 'ja', back: 'en'),
|
||||
speaking('kana', parent: 'japanese'),
|
||||
speaking('hiragana', parent: 'kana'),
|
||||
// Its own front, and no back: the inherited back doesn't join it.
|
||||
speaking('kanji', front: 'ja', parent: 'japanese'),
|
||||
speaking('german'),
|
||||
];
|
||||
|
||||
expect(languagesOf('hiragana', all), (front: 'ja', back: 'en'));
|
||||
expect(languagesOf('kanji', all), (front: 'ja', back: null));
|
||||
expect(languagesOf('german', all), (front: null, back: null));
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('a change of languages keeps what is shown below: the screen '
|
||||
'goes on as it was', (tester) async {
|
||||
Widget scoped(StudysetLanguages languages) => MaterialApp(
|
||||
home: LanguageScope(languages: languages, child: const _Counter()),
|
||||
);
|
||||
await tester.pumpWidget(scoped((front: 'de', back: 'en')));
|
||||
await tester.tap(find.byType(TextButton));
|
||||
await tester.pump();
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(scoped((front: 'zh-Hant', back: 'en')));
|
||||
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
/// Counts taps: a state that a rebuilt subtree would lose.
|
||||
class const _Counter() extends StatefulWidget {
|
||||
@override
|
||||
State<_Counter> createState() => _CounterState();
|
||||
}
|
||||
|
||||
class _CounterState() extends State<_Counter> {
|
||||
var _count = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => TextButton(
|
||||
onPressed: () => setState(() => _count++),
|
||||
child: Text('$_count'),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,855 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.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/publish/model/publication.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.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/studyset/widget/deleted_studyset_card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_card.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_file_picker.dart';
|
||||
import '../../../support/fake_publish_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/memory_published_store.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
||||
void main() {
|
||||
late FakeStudysetGateway gateway;
|
||||
late FakeCardGateway cardGateway;
|
||||
late FakeFilePicker files;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeStudysetGateway();
|
||||
cardGateway = FakeCardGateway();
|
||||
files = FakeFilePicker();
|
||||
});
|
||||
|
||||
/// How many syncs ran.
|
||||
var syncs = 0;
|
||||
|
||||
/// Shows the library in a window [width] wide; to a person with an
|
||||
/// identity ([identified]), whose studysets [publishing] publishes and
|
||||
/// [publishedStore] remembers as published.
|
||||
Future<void> showLibrary(
|
||||
WidgetTester tester, {
|
||||
double width = 1200,
|
||||
bool identified = false,
|
||||
FakePublishGateway? publishing,
|
||||
MemoryPublishedStore? publishedStore,
|
||||
}) async {
|
||||
tester.view.physicalSize = Size(width, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
syncs = 0;
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
authGateway: identified
|
||||
? FakeAuthGateway(
|
||||
status: const SignedIn(
|
||||
authUserId: 'github-person',
|
||||
isIdentified: true,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
studysetGateway: gateway,
|
||||
cardGateway: cardGateway,
|
||||
files: files,
|
||||
publishGateway: publishing,
|
||||
publishedStore: publishedStore,
|
||||
syncOnce: ({required onMerge}) async => syncs++,
|
||||
),
|
||||
location: RoutePaths.library,
|
||||
);
|
||||
}
|
||||
|
||||
/// A studyset on this device before the app starts; a copy of the one
|
||||
/// published at [publishedAt].
|
||||
void stored(
|
||||
String id,
|
||||
String title, {
|
||||
String? secondaryTitle,
|
||||
DateTime? archivedAt,
|
||||
DateTime? starredAt,
|
||||
String? publishedAt,
|
||||
}) => gateway.receiveRemoteChange(
|
||||
id,
|
||||
encodeStudysetPayload(
|
||||
StudysetContent(
|
||||
title: title,
|
||||
description: RichContent.empty(),
|
||||
secondaryTitle: secondaryTitle,
|
||||
published: publishedAt == null
|
||||
? null
|
||||
: const PublishedRef(publication: 'p', item: 'i', version: 1),
|
||||
publishedAt: publishedAt,
|
||||
),
|
||||
),
|
||||
archivedAt: archivedAt,
|
||||
starredAt: starredAt,
|
||||
);
|
||||
|
||||
/// [finder] within the card of the studyset titled [title].
|
||||
Finder onCard(String title, Finder finder) => find.descendant(
|
||||
of: find.widgetWithText(StudysetCard, title),
|
||||
matching: finder,
|
||||
);
|
||||
|
||||
/// Chooses [option] in the options of the studyset titled [title].
|
||||
Future<void> choose(WidgetTester tester, String title, String option) async {
|
||||
await tester.tap(onCard(title, find.byTooltip('Options')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(option));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets("until this device's first sync went through, an empty "
|
||||
'library says it is on its way; then that there are no studysets '
|
||||
'yet', (tester) async {
|
||||
tester.view.physicalSize = const Size(1200, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final first = Completer<void>();
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
studysetGateway: gateway,
|
||||
syncedBefore: false,
|
||||
syncOnce: ({required onMerge}) => first.future,
|
||||
),
|
||||
);
|
||||
|
||||
// Its spinner turns without end: frames, not settling.
|
||||
await tester.tap(tab('Library'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(find.textContaining('Your library is on its way from'), findsOne);
|
||||
expect(find.text('No studysets yet'), findsNothing);
|
||||
|
||||
first.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('on its way'), findsNothing);
|
||||
expect(find.text('No studysets yet'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('says when there are no studysets yet', (tester) async {
|
||||
await showLibrary(tester);
|
||||
|
||||
expect(find.text('No studysets yet'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('says so when the studysets cannot be read, and reads them '
|
||||
'again on Retry', (tester) async {
|
||||
gateway.failReads = Exception('Disk unavailable');
|
||||
stored('japanese', 'Japanese');
|
||||
|
||||
await showLibrary(tester);
|
||||
|
||||
expect(find.text("Couldn't read your studysets."), findsOneWidget);
|
||||
gateway.failReads = null;
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Retry'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Japanese'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Refresh syncs, from the menu and by pulling the list '
|
||||
'down', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await showLibrary(tester);
|
||||
final before = syncs;
|
||||
|
||||
await tester.tap(find.byTooltip('Library options'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Refresh'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(syncs, before + 1);
|
||||
|
||||
// Below the cards: a card itself can be dragged elsewhere.
|
||||
await tester.flingFrom(const Offset(600, 1200), const Offset(0, 400), 1000);
|
||||
await tester.pumpAndSettle();
|
||||
expect(syncs, before + 2);
|
||||
});
|
||||
|
||||
testWidgets('an empty library is pulled down to sync too', (tester) async {
|
||||
await showLibrary(tester);
|
||||
expect(find.text('No studysets yet'), findsOneWidget);
|
||||
final before = syncs;
|
||||
|
||||
await tester.flingFrom(const Offset(600, 600), const Offset(0, 400), 1000);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(syncs, before + 1);
|
||||
});
|
||||
|
||||
testWidgets('creates a studyset with a secondary title and a colour', (
|
||||
tester,
|
||||
) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
await showLibrary(tester);
|
||||
|
||||
await tester.tap(find.text('Create studyset'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SheetScaffold), findsOneWidget);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Title'),
|
||||
'Vocabulary',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Secondary title'),
|
||||
'japanese',
|
||||
);
|
||||
await tester.tap(find.bySemanticsLabel('Color: Cobalt'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.bySemanticsLabel('Fern'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Create'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('JAPANESE'), findsOneWidget);
|
||||
expect(find.text('Vocabulary'), findsOneWidget);
|
||||
expect(
|
||||
decodeStudysetPayload(gateway.writes.single.$2),
|
||||
StudysetContent(
|
||||
title: 'Vocabulary',
|
||||
description: RichContent.empty(),
|
||||
secondaryTitle: 'JAPANESE',
|
||||
colorName: 'fern',
|
||||
),
|
||||
);
|
||||
semantics.dispose();
|
||||
});
|
||||
|
||||
testWidgets('a new studyset is cobalt unless another colour is chosen', (
|
||||
tester,
|
||||
) async {
|
||||
await showLibrary(tester);
|
||||
|
||||
await tester.tap(find.text('Create studyset'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Kanji');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Create'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
decodeStudysetPayload(gateway.writes.single.$2).colorName,
|
||||
'cobalt',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('creating waits for a title', (tester) async {
|
||||
await showLibrary(tester);
|
||||
await tester.tap(find.text('Create studyset'));
|
||||
await tester.pumpAndSettle();
|
||||
FilledButton create() => tester.widget<FilledButton>(
|
||||
find.ancestor(
|
||||
of: find.text('Create'),
|
||||
matching: find.byType(FilledButton),
|
||||
),
|
||||
);
|
||||
expect(create().onPressed, isNull);
|
||||
// The title is required: said below it, its outline stronger, while
|
||||
// it is empty.
|
||||
expect(find.text('Required'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Title'), ' ');
|
||||
await tester.pump();
|
||||
expect(create().onPressed, isNull);
|
||||
expect(find.text('Required'), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Kanji');
|
||||
await tester.pump();
|
||||
expect(create().onPressed, isNotNull);
|
||||
expect(find.text('Required'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('closing the sheet with changes asks first; staying keeps '
|
||||
'them, discarding closes it', (tester) async {
|
||||
await showLibrary(tester);
|
||||
await tester.tap(find.text('Create studyset'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Kanji');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Discard changes?'), findsOneWidget);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SheetScaffold), findsOneWidget);
|
||||
expect(find.text('Kanji'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Discard'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(SheetScaffold), findsNothing);
|
||||
expect(gateway.writes, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets("the bottom sheet doesn't swipe away, which would lose "
|
||||
'changes without asking; a tap beside it asks', (tester) async {
|
||||
await showLibrary(tester, width: 400);
|
||||
await tester.tap(find.byTooltip('Create studyset'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Title'), 'Kanji');
|
||||
await tester.pump();
|
||||
|
||||
await tester.fling(
|
||||
find.text('Create studyset'),
|
||||
const Offset(0, 600),
|
||||
2000,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(SheetScaffold), findsOneWidget);
|
||||
expect(find.text('Kanji'), findsOneWidget);
|
||||
|
||||
await tester.tapAt(const Offset(200, 20));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Discard changes?'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('closing an untouched sheet asks nothing', (tester) async {
|
||||
await showLibrary(tester);
|
||||
await tester.tap(find.text('Create studyset'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Discard changes?'), findsNothing);
|
||||
expect(find.byType(SheetScaffold), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('edits a studyset from its options', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await showLibrary(tester);
|
||||
|
||||
await choose(tester, 'Japanese', 'Edit');
|
||||
expect(find.text('Edit studyset'), findsOneWidget);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Japanese'),
|
||||
'日本語',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('日本語'), findsOneWidget);
|
||||
expect(find.text('Japanese'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('lists starred studysets first', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
stored('german', 'German', starredAt: DateTime.utc(2026, 10));
|
||||
|
||||
await showLibrary(tester);
|
||||
|
||||
expect(
|
||||
tester.getTopLeft(find.text('German')).dx,
|
||||
lessThan(tester.getTopLeft(find.text('Japanese')).dx),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('archiving tucks a studyset into the closed archive; Undo '
|
||||
'brings it back', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
stored('german', 'German');
|
||||
await showLibrary(tester);
|
||||
|
||||
await choose(tester, 'Japanese', 'Archive');
|
||||
expect(find.text('Studyset archived'), findsOneWidget);
|
||||
expect(find.text('Japanese'), findsNothing);
|
||||
expect(find.text('Archived'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Undo'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Japanese'), findsOneWidget);
|
||||
expect(find.text('Archived'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the archive opens to show what rests there, which can be '
|
||||
'unarchived', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
stored('kanji', 'Kanji', archivedAt: DateTime.utc(2026, 10));
|
||||
await showLibrary(tester);
|
||||
expect(find.text('Kanji'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Archived'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Kanji'), findsOneWidget);
|
||||
await choose(tester, 'Kanji', 'Unarchive');
|
||||
|
||||
expect(find.text('Studyset unarchived'), findsOneWidget);
|
||||
expect(find.text('Archived'), findsNothing);
|
||||
expect(find.text('Kanji'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('searches titles and secondary titles, the archive too', (
|
||||
tester,
|
||||
) async {
|
||||
stored('vocabulary', 'Vocabulary', secondaryTitle: 'Japanese');
|
||||
stored('grammar', 'Grammar');
|
||||
stored(
|
||||
'kanji',
|
||||
'Kanji',
|
||||
secondaryTitle: 'Japanese',
|
||||
archivedAt: DateTime.utc(2026, 10),
|
||||
);
|
||||
await showLibrary(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Search'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField), 'japan');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Vocabulary'), findsOneWidget);
|
||||
expect(find.text('Kanji'), findsOneWidget);
|
||||
expect(find.text('Grammar'), findsNothing);
|
||||
|
||||
await tester.tap(find.byTooltip('Close search'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Grammar'), findsOneWidget);
|
||||
expect(find.text('Kanji'), findsNothing, reason: 'the archive is closed');
|
||||
});
|
||||
|
||||
testWidgets('says when no studyset matches the search', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await showLibrary(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Search'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField), 'swahili');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('No studysets match your search'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("the options are every studyset's, as its screen has them, "
|
||||
'in this order', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await showLibrary(tester);
|
||||
|
||||
await tester.tap(onCard('Japanese', find.byTooltip('Options')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tops = [
|
||||
for (final label in [
|
||||
'Edit',
|
||||
'Move to…',
|
||||
'Study options',
|
||||
'Import content',
|
||||
'Export content',
|
||||
'Reset progress',
|
||||
'Archive',
|
||||
'Delete',
|
||||
'More info',
|
||||
])
|
||||
tester.getTopLeft(find.text(label)).dy,
|
||||
];
|
||||
expect(tops, [...tops]..sort());
|
||||
});
|
||||
|
||||
testWidgets('moves a studyset into another from its options', (
|
||||
tester,
|
||||
) async {
|
||||
stored('japanese', 'Japanese');
|
||||
stored('languages', 'Languages');
|
||||
await showLibrary(tester);
|
||||
|
||||
await choose(tester, 'Japanese', 'Move to…');
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(SheetScaffold),
|
||||
matching: find.text('Languages'),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Move'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(gateway.moves.single.$1, 'japanese');
|
||||
expect(gateway.moves.single.$2, 'languages');
|
||||
});
|
||||
|
||||
testWidgets('Import content opens the studyset, asking there what to '
|
||||
'import: from Anki, say', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await showLibrary(tester);
|
||||
|
||||
await choose(tester, 'Japanese', 'Import content');
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.descendant(of: find.byType(AppBar), matching: find.text('Japanese')),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.text('Anki deck'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(files.asked, [
|
||||
['apkg', 'colpkg'],
|
||||
]);
|
||||
});
|
||||
|
||||
group('study options', () {
|
||||
/// The study options the studyset [id] was last written with.
|
||||
StudysetStudyOptions? written(String id) => decodeStudysetPayload(
|
||||
gateway.writes.lastWhere((write) => write.$1 == id).$2,
|
||||
).studyOptions;
|
||||
|
||||
testWidgets('start as the defaults, and are kept with the studyset once '
|
||||
'saved', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await showLibrary(tester);
|
||||
|
||||
await choose(tester, 'Japanese', 'Study options');
|
||||
expect(
|
||||
find.text('20 / day', findRichText: true),
|
||||
findsOneWidget,
|
||||
reason: 'new cards a day',
|
||||
);
|
||||
expect(find.text('Unlimited'), findsOneWidget, reason: 'reviews a day');
|
||||
expect(find.text('Reset to defaults'), findsNothing);
|
||||
|
||||
await tester.tap(find.byTooltip('Decrease').first);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Limit reviews per day'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byTooltip('Increase').last);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Mixed'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
written('japanese'),
|
||||
const StudysetStudyOptions(
|
||||
newPerDay: 15,
|
||||
reviewsPerDay: 225,
|
||||
order: NewCardOrder.mixed,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a limit steps to the next multiple of its step, takes any '
|
||||
'number typed, and is switched off for none, coming back as it '
|
||||
'was', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await showLibrary(tester);
|
||||
await choose(tester, 'Japanese', 'Study options');
|
||||
Finder newCards() => find.text('20 / day', findRichText: true);
|
||||
expect(newCards(), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Type a number').first);
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(TextField), '12');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
expect(find.text('12 / day', findRichText: true), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Increase').first);
|
||||
await tester.pump();
|
||||
expect(find.text('15 / day', findRichText: true), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Limit new cards per day'));
|
||||
await tester.pump();
|
||||
expect(find.text('15 / day', findRichText: true), findsNothing);
|
||||
expect(find.text('Unlimited'), findsNWidgets(2));
|
||||
await tester.tap(find.text('Limit new cards per day'));
|
||||
await tester.pump();
|
||||
expect(find.text('15 / day', findRichText: true), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Limit new cards per day'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(written('japanese')?.newPerDay, StudysetStudyOptions.unlimited);
|
||||
});
|
||||
|
||||
testWidgets('saved as they came, the defaults stay the defaults', (
|
||||
tester,
|
||||
) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await showLibrary(tester);
|
||||
|
||||
await choose(tester, 'Japanese', 'Study options');
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(gateway.writes, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('go back to the defaults with Reset', (tester) async {
|
||||
gateway.receiveRemoteChange(
|
||||
'japanese',
|
||||
encodeStudysetPayload(
|
||||
StudysetContent.titled(
|
||||
'Japanese',
|
||||
).withStudyOptions(const StudysetStudyOptions(newPerDay: 5)),
|
||||
),
|
||||
);
|
||||
await showLibrary(tester);
|
||||
|
||||
await choose(tester, 'Japanese', 'Study options');
|
||||
expect(find.text('5 / day', findRichText: true), findsOneWidget);
|
||||
await tester.tap(find.text('Reset to defaults'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(gateway.writes, isNotEmpty);
|
||||
expect(written('japanese'), isNull);
|
||||
});
|
||||
|
||||
testWidgets('stay when the studyset is edited', (tester) async {
|
||||
gateway.receiveRemoteChange(
|
||||
'japanese',
|
||||
encodeStudysetPayload(
|
||||
StudysetContent.titled(
|
||||
'Japanese',
|
||||
).withStudyOptions(const StudysetStudyOptions(newPerDay: 5)),
|
||||
),
|
||||
);
|
||||
await showLibrary(tester);
|
||||
|
||||
await choose(tester, 'Japanese', 'Edit');
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextField, 'Japanese'),
|
||||
'Nihongo',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(written('japanese'), const StudysetStudyOptions(newPerDay: 5));
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('Delete stands out in red among the options', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await showLibrary(tester);
|
||||
|
||||
await tester.tap(onCard('Japanese', find.byTooltip('Options')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final error = Theme.of(tester.element(find.text('Delete')))
|
||||
.colorScheme
|
||||
.error;
|
||||
expect(tester.widget<Text>(find.text('Delete')).style?.color, error);
|
||||
expect(tester.widget<Text>(find.text('Edit')).style?.color, isNot(error));
|
||||
});
|
||||
|
||||
Finder onDeletedCard(String title, Finder finder) => find.descendant(
|
||||
of: find.widgetWithText(DeletedStudysetCard, title),
|
||||
matching: finder,
|
||||
);
|
||||
|
||||
testWidgets("deleting doesn't ask: the studyset waits in Recently deleted, "
|
||||
'saying for how long; Undo brings it back', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await showLibrary(tester);
|
||||
|
||||
await choose(tester, 'Japanese', 'Delete');
|
||||
expect(find.byType(AlertDialog), findsNothing);
|
||||
expect(find.text('Moved to Recently deleted'), findsOneWidget);
|
||||
expect(find.widgetWithText(StudysetCard, 'Japanese'), findsNothing);
|
||||
expect(find.text('Recently deleted'), findsOneWidget);
|
||||
expect(
|
||||
onDeletedCard('Japanese', find.text('14 days left')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Undo'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(StudysetCard, 'Japanese'), findsOneWidget);
|
||||
expect(find.text('Recently deleted'), findsNothing);
|
||||
expect(gateway.deleted, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('a deleted studyset is restored from Recently deleted, even '
|
||||
'when it was the only one', (tester) async {
|
||||
gateway.receiveRemoteChange(
|
||||
'japanese',
|
||||
encodeStudysetPayload(StudysetContent.titled('Japanese')),
|
||||
deletedAt: clock.now().toUtc().subtract(const Duration(days: 10)),
|
||||
);
|
||||
await showLibrary(tester);
|
||||
expect(find.text('No studysets yet'), findsNothing);
|
||||
expect(
|
||||
onDeletedCard('Japanese', find.text('4 days left')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(onDeletedCard('Japanese', find.text('Restore')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Studyset restored'), findsOneWidget);
|
||||
expect(find.widgetWithText(StudysetCard, 'Japanese'), findsOneWidget);
|
||||
expect(find.text('Recently deleted'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Delete now asks, then deletes the studyset for good with its '
|
||||
'cards', (tester) async {
|
||||
gateway.receiveRemoteChange(
|
||||
'japanese',
|
||||
encodeStudysetPayload(StudysetContent.titled('Japanese')),
|
||||
deletedAt: clock.now().toUtc(),
|
||||
);
|
||||
final cards = CardRepository(cardGateway);
|
||||
addTearDown(cards.dispose);
|
||||
final card = await cards.create(
|
||||
'japanese',
|
||||
CardContent(front: RichContent.empty(), back: RichContent.empty()),
|
||||
);
|
||||
await showLibrary(tester);
|
||||
|
||||
Future<void> deleteNow() async {
|
||||
await tester.tap(onDeletedCard('Japanese', find.byTooltip('Options')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Delete now'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Delete for good?'), findsOneWidget);
|
||||
}
|
||||
|
||||
await deleteNow();
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(gateway.deleted, isEmpty);
|
||||
|
||||
await deleteNow();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete for good'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(gateway.deleted, ['japanese']);
|
||||
expect(cardGateway.deletions, ['blank $card', 'delete $card']);
|
||||
expect(find.text('Recently deleted'), findsNothing);
|
||||
expect(find.text('No studysets yet'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a card invites studying its due cards, and counts anew after '
|
||||
'studying', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
final cards = CardRepository(cardGateway);
|
||||
addTearDown(cards.dispose);
|
||||
await cards.create(
|
||||
'japanese',
|
||||
CardContent(front: RichContent.empty(), back: RichContent.empty()),
|
||||
);
|
||||
await showLibrary(tester);
|
||||
expect(find.text('1 flashcard'), findsOneWidget);
|
||||
expect(onCard('Japanese', find.text('1')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Study'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Show answer'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Perfect'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Done'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byTooltip('Add content'), findsOneWidget);
|
||||
expect(onCard('Japanese', find.text('1')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the create button floats in compact windows and sits in the '
|
||||
'app bar from medium on', (tester) async {
|
||||
await showLibrary(tester, width: 599);
|
||||
expect(find.byType(FloatingActionButton), findsOneWidget);
|
||||
expect(find.text('Create studyset'), findsNothing);
|
||||
|
||||
await showLibrary(tester, width: 600);
|
||||
expect(find.byType(FloatingActionButton), findsNothing);
|
||||
expect(find.text('Create studyset'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('Who made a studyset, and whether it is public', () {
|
||||
Publication publication({bool withdrawn = false}) => Publication(
|
||||
publicId: 'p',
|
||||
slug: 'japanese',
|
||||
location: 'halkyon.eu/@raisondetredev/japanese',
|
||||
withdrawn: withdrawn,
|
||||
);
|
||||
|
||||
testWidgets("the person's own says By you, a copy its author's handle", (
|
||||
tester,
|
||||
) async {
|
||||
stored('japanese', 'Japanese');
|
||||
stored('kanji', 'Kanji', publishedAt: 'halkyon.eu/@alice/kanji');
|
||||
|
||||
await showLibrary(tester);
|
||||
|
||||
expect(onCard('Japanese', find.text('By you')), findsOneWidget);
|
||||
expect(onCard('Kanji', find.text('By @alice')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a lock while private, a globe once out on Explore, as the '
|
||||
'instance says', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
stored('kanji', 'Kanji');
|
||||
stored('kana', 'Kana');
|
||||
final store = MemoryPublishedStore();
|
||||
|
||||
await showLibrary(
|
||||
tester,
|
||||
identified: true,
|
||||
publishing: FakePublishGateway(
|
||||
publications: {
|
||||
'japanese': publication(),
|
||||
'kana': publication(withdrawn: true),
|
||||
},
|
||||
),
|
||||
publishedStore: store,
|
||||
);
|
||||
|
||||
expect(onCard('Japanese', find.byTooltip('Public')), findsOneWidget);
|
||||
expect(onCard('Kanji', find.byTooltip('Private')), findsOneWidget);
|
||||
expect(onCard('Kana', find.byTooltip('Private')), findsOneWidget);
|
||||
// Remembered for when the instance can't be reached.
|
||||
expect(store.kept, {'japanese'});
|
||||
});
|
||||
|
||||
testWidgets('while the instance is out of reach, as the device last '
|
||||
'knew', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
stored('kanji', 'Kanji');
|
||||
|
||||
await showLibrary(
|
||||
tester,
|
||||
identified: true,
|
||||
publishing: FakePublishGateway()..fail = Exception('Offline'),
|
||||
publishedStore: MemoryPublishedStore({'japanese'}),
|
||||
);
|
||||
|
||||
expect(onCard('Japanese', find.byTooltip('Public')), findsOneWidget);
|
||||
expect(onCard('Kanji', find.byTooltip('Private')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the lock opens the Publish sheet', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await showLibrary(tester);
|
||||
|
||||
await tester.tap(onCard('Japanese', find.byTooltip('Private')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Publish'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Sign in to have a public profile and publish.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/l10n/app_languages.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
Future<void> show(WidgetTester tester, {required bool enabled}) =>
|
||||
tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: appLocalizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: StudysetBackButton(
|
||||
ancestors: const [],
|
||||
all: const [],
|
||||
enabled: enabled,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
IconButton button(WidgetTester tester) => tester.widget<IconButton>(
|
||||
find.descendant(
|
||||
of: find.byType(StudysetBackButton),
|
||||
matching: find.byType(IconButton),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('while something runs here, back is greyed and its levels '
|
||||
'stay closed', (tester) async {
|
||||
await show(tester, enabled: false);
|
||||
|
||||
expect(button(tester).onPressed, isNull);
|
||||
await tester.longPress(find.byType(StudysetBackButton));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Library'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('otherwise back works and holding it lists the levels', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester, enabled: true);
|
||||
|
||||
expect(button(tester).onPressed, isNotNull);
|
||||
await tester.longPress(find.byType(StudysetBackButton));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Library'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/l10n/app_languages.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/learn_theme.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_card.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
final vocabulary = Studyset(
|
||||
id: 'vocabulary',
|
||||
content: StudysetContent(
|
||||
title: 'Vocabulary',
|
||||
description: RichContent.empty(),
|
||||
secondaryTitle: 'Japanese',
|
||||
colorName: 'cobalt',
|
||||
),
|
||||
);
|
||||
|
||||
/// 52 cards, a quarter of them mature, none due.
|
||||
const nothingDue = StudyProgress(cards: 52, due: 0, reviewsDue: 0, mature: 13);
|
||||
|
||||
void main() {
|
||||
late List<String> calls;
|
||||
|
||||
setUp(() => calls = []);
|
||||
|
||||
Future<void> show(
|
||||
WidgetTester tester, {
|
||||
Studyset? studyset,
|
||||
StudyProgress? progress = nothingDue,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: learnTheme(Brightness.light),
|
||||
localizationsDelegates: appLocalizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 360,
|
||||
height: 188,
|
||||
child: StudysetCard(
|
||||
studyset: studyset ?? vocabulary,
|
||||
progress: progress,
|
||||
published: false,
|
||||
onShare: () => calls.add('share'),
|
||||
onOpen: () => calls.add('open'),
|
||||
onStudy: () => calls.add('study'),
|
||||
onToggleStar: () => calls.add('star'),
|
||||
onOption: (option) => calls.add(option.name),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Studyset changed({DateTime? archivedAt, DateTime? starredAt}) => Studyset(
|
||||
id: vocabulary.id,
|
||||
content: vocabulary.content,
|
||||
archivedAt: archivedAt,
|
||||
starredAt: starredAt,
|
||||
);
|
||||
|
||||
testWidgets('shows the title with its kicker and what the studyset '
|
||||
'holds', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
expect(find.text('JAPANESE'), findsOneWidget);
|
||||
expect(find.text('Vocabulary'), findsOneWidget);
|
||||
expect(find.text('52 flashcards'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('says when a studyset holds nothing yet', (tester) async {
|
||||
await show(
|
||||
tester,
|
||||
progress: const StudyProgress(cards: 0, due: 0, reviewsDue: 0, mature: 0),
|
||||
);
|
||||
|
||||
expect(find.text('No content yet'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('claims nothing while its progress is being worked out', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester, progress: null);
|
||||
|
||||
expect(find.textContaining('flashcard'), findsNothing);
|
||||
expect(find.text('No content yet'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('invites a review when reviews are due, with how many cards '
|
||||
'are due', (tester) async {
|
||||
await show(
|
||||
tester,
|
||||
progress: const StudyProgress(
|
||||
cards: 52,
|
||||
due: 12,
|
||||
reviewsDue: 3,
|
||||
mature: 13,
|
||||
),
|
||||
);
|
||||
expect(find.text('12'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Review'));
|
||||
|
||||
expect(calls, ['study']);
|
||||
});
|
||||
|
||||
testWidgets('invites studying when only new cards are due', (tester) async {
|
||||
await show(
|
||||
tester,
|
||||
progress: const StudyProgress(
|
||||
cards: 52,
|
||||
due: 20,
|
||||
reviewsDue: 0,
|
||||
mature: 0,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('Study'));
|
||||
|
||||
expect(calls, ['study']);
|
||||
});
|
||||
|
||||
testWidgets('invites adding content when nothing is due, which opens '
|
||||
'it', (tester) async {
|
||||
await show(tester);
|
||||
expect(find.text('0'), findsNothing);
|
||||
|
||||
await tester.tap(find.byTooltip('Add content'));
|
||||
|
||||
expect(calls, ['open']);
|
||||
});
|
||||
|
||||
testWidgets('counts at most 99+ due cards', (tester) async {
|
||||
await show(
|
||||
tester,
|
||||
progress: const StudyProgress(
|
||||
cards: 900,
|
||||
due: 875,
|
||||
reviewsDue: 875,
|
||||
mature: 0,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('99+'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('fills its ring with the share of mature cards', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
final ring = tester.widget<CircularProgressIndicator>(
|
||||
find.byType(CircularProgressIndicator),
|
||||
);
|
||||
expect(ring.value, 0.25);
|
||||
});
|
||||
|
||||
testWidgets('an archived studyset rests: dimmed and paused, inviting '
|
||||
'nothing', (tester) async {
|
||||
await show(
|
||||
tester,
|
||||
studyset: changed(archivedAt: DateTime.utc(2026, 10)),
|
||||
progress: const StudyProgress(
|
||||
cards: 52,
|
||||
due: 12,
|
||||
reviewsDue: 3,
|
||||
mature: 13,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byTooltip('Archived'), findsOneWidget);
|
||||
expect(find.byTooltip('Review'), findsNothing);
|
||||
expect(find.text('12'), findsNothing);
|
||||
expect(
|
||||
tester
|
||||
.widget<Opacity>(
|
||||
find.ancestor(
|
||||
of: find.text('Vocabulary'),
|
||||
matching: find.byType(Opacity),
|
||||
),
|
||||
)
|
||||
.opacity,
|
||||
0.6,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('opens when tapped', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
await tester.tap(find.text('Vocabulary'));
|
||||
|
||||
expect(calls, ['open']);
|
||||
});
|
||||
|
||||
testWidgets('a starred studyset shows its star, which takes it away', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester, studyset: changed(starredAt: DateTime.utc(2026, 10)));
|
||||
|
||||
await tester.tap(find.byTooltip('Unstar'));
|
||||
|
||||
expect(calls, ['star']);
|
||||
});
|
||||
|
||||
testWidgets('shows the star of an unstarred studyset only to a pointer '
|
||||
'hovering over it', (tester) async {
|
||||
await show(tester);
|
||||
expect(find.byTooltip('Star').hitTestable(), findsNothing);
|
||||
|
||||
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
addTearDown(mouse.removePointer);
|
||||
await mouse.addPointer(location: tester.getCenter(find.text('Vocabulary')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byTooltip('Star').hitTestable(), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a long press stars it', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
await tester.longPress(find.text('Vocabulary'));
|
||||
|
||||
expect(calls, ['star']);
|
||||
});
|
||||
|
||||
for (final (option, call) in [
|
||||
('Edit', 'edit'),
|
||||
('Import content', 'importContent'),
|
||||
('Export content', 'exportContent'),
|
||||
('Reset progress', 'reset'),
|
||||
('Archive', 'archive'),
|
||||
('Delete', 'delete'),
|
||||
]) {
|
||||
testWidgets('its options include "$option"', (tester) async {
|
||||
await show(tester);
|
||||
|
||||
await tester.tap(find.byTooltip('Options'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Unarchive'), findsNothing);
|
||||
await tester.tap(find.text(option));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(calls, [call]);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('an archived studyset offers to unarchive it', (tester) async {
|
||||
await show(tester, studyset: changed(archivedAt: DateTime.utc(2026, 10)));
|
||||
|
||||
await tester.tap(find.byTooltip('Options'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Archive'), findsNothing);
|
||||
await tester.tap(find.text('Unarchive'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(calls, ['unarchive']);
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue