perf(studyset): read exam plans only after changes that matter, once they settle
Every studyset screen holds an exam cubit, those stacked beneath the one in view and beneath a study session too, and each read the plans again on every change of cards, reviews, paths, their progress, PDFs or tags: with every rating, each of them decoded every card of its studyset, some 8,000 in a language's, and its progress with an exam chosen too. Now the plans are read again only when studysets, cards or tags change, and the progress alone on reviews, paths, their progress and PDFs, only while an exam is chosen. Reading waits until changes have settled for 2 s (cancelled when the cubit closes), and a burst still reads once. Studysets and tags handed out again unchanged are no change, and a change of the studyset itself (the choice, its author's plans) shows at once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
fe80c565d1
commit
87b200e3eb
2 changed files with 271 additions and 8 deletions
|
|
@ -0,0 +1,193 @@
|
|||
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_gateway.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/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/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 [FakeCardGateway] counting how often it reads a studyset's cards:
|
||||
/// reading the plans reads each studied studyset's once, and so does
|
||||
/// reading the progress with the exam chosen.
|
||||
class _CountingCardGateway() extends FakeCardGateway {
|
||||
int reads = 0;
|
||||
|
||||
@override
|
||||
Future<List<CardRow>> cardsOf(String studysetId) {
|
||||
reads++;
|
||||
return super.cardsOf(studysetId);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
final today = DateTime(2026, 10, 14, 9);
|
||||
final exam = ExamChoice(plan: 'auto:jlpt:1', date: DateTime(2026, 12, 6));
|
||||
late _CountingCardGateway cardGateway;
|
||||
late StudysetRepository studysets;
|
||||
late CardRepository cards;
|
||||
late TagRepository tags;
|
||||
late ReviewRepository reviews;
|
||||
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}) {
|
||||
cardGateway = _CountingCardGateway();
|
||||
studysets = StudysetRepository(FakeStudysetGateway());
|
||||
cards = CardRepository(cardGateway);
|
||||
final paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
tags = TagRepository(
|
||||
FakeTagGateway(),
|
||||
cardChanges: cards.changes,
|
||||
pathChanges: paths.changes,
|
||||
);
|
||||
reviews = ReviewRepository(FakeReviewGateway());
|
||||
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: PathProgressRepository(FakePathAnswerGateway()),
|
||||
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 cards 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 = cardGateway.reads;
|
||||
|
||||
review(async, neko);
|
||||
async.elapse(ExamCubit.settle * 2);
|
||||
|
||||
expect(cardGateway.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 = cardGateway.reads;
|
||||
|
||||
review(async, neko);
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
review(async, inu);
|
||||
async.elapse(const Duration(seconds: 1));
|
||||
expect(cardGateway.reads, reads, reason: 'still settling');
|
||||
async.elapse(ExamCubit.settle);
|
||||
|
||||
// One studyset's cards read once: the progress, not the plans.
|
||||
expect(cardGateway.reads, reads + 1);
|
||||
expect(cubit.state.progress?.newCards, 0);
|
||||
|
||||
review(async, neko);
|
||||
unawaited(cubit.close());
|
||||
async.elapse(ExamCubit.settle * 2);
|
||||
expect(cardGateway.reads, reads + 1);
|
||||
}, 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/exam_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
|
||||
|
|
@ -2034,6 +2035,8 @@ void main() {
|
|||
final n5 = await person.tags.create('JLPT-N5');
|
||||
await person.tags.setTagsOf(neko, {n5});
|
||||
await person.tags.setTagsOf(inu, {n5});
|
||||
// The plans are read again once the changes have settled.
|
||||
await tester.pump(ExamCubit.settle);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await openPart(tester, 'Study plan');
|
||||
|
|
|
|||
Loading…
Reference in a new issue