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
4 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue