feat(studyset): prepare for an exam, with plans, a date and the pace to it
A studyset plans for exams: each plan names what an exam covers, the topics it takes whole (with their flashcards, learning paths and PDFs) and the tags whose flashcards it takes from anywhere in the studyset. The author's plans are published with the studyset (payload schema 7, `examPlans`); copying a release and taking a newer one point their topics at the copy's own studysets. Plans are also offered from level tags: JLPT N5 to N1, HSK 1 to 7-9 and KRDict's levels, each taking the levels below it, as the exams do. The person chooses a plan and the exam's day (`exam`), which stays theirs: the server leaves it out of a release, as a PDF's deadline. The study plan then shows the days left, how many of the plan's flashcards were started, its paths' steps done and its PDFs' pages read, and the pace that gets everything new learnt a week before the exam (by the exam when two weeks or fewer are left): new flashcards a day, steps a week, pages a day. Study for the exam studies only what the plan covers, its new cards at that pace (counted as the day began) and every review due. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
31e4d7d64b
commit
dd6c567ad0
3 changed files with 2351 additions and 20 deletions
|
|
@ -7,6 +7,7 @@ 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() {
|
||||
|
|
@ -58,7 +59,7 @@ void main() {
|
|||
expect(json(payload).keys, {'schemaVersion', 'title', 'description'});
|
||||
});
|
||||
|
||||
test('names its schema version: the fifth, which a bibliography needs', () {
|
||||
test('names its schema version: the seventh, which exam plans need', () {
|
||||
final payload = encodeStudysetPayload(
|
||||
StudysetContent(
|
||||
title: 'Japanese',
|
||||
|
|
@ -67,19 +68,61 @@ void main() {
|
|||
),
|
||||
);
|
||||
|
||||
expect(studysetSchemaVersion, 6);
|
||||
expect(studysetSchemaVersion, 7);
|
||||
expect(json(payload)['schemaVersion'], studysetSchemaVersion);
|
||||
});
|
||||
|
||||
test('without study options or a bibliography writes the sixth schema '
|
||||
'all the same', () {
|
||||
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'], 6);
|
||||
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', () {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
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 1',
|
||||
'HSK 2',
|
||||
'HSK 3',
|
||||
'HSK 4',
|
||||
'HSK 5',
|
||||
'HSK 6',
|
||||
'HSK 7–9',
|
||||
'Korean 초급',
|
||||
'Korean 중급',
|
||||
],
|
||||
);
|
||||
expect(automaticExamPlans(['noun', 'verb']), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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('uses every day left when two weeks or fewer are', () {
|
||||
final pace = examPace(
|
||||
today: DateTime(2026, 10, 14),
|
||||
examDate: DateTime(2026, 10, 24),
|
||||
newCards: 25,
|
||||
stepsLeft: 3,
|
||||
pagesLeft: 101,
|
||||
);
|
||||
|
||||
expect(pace, (
|
||||
daysLeft: 10,
|
||||
newPerDay: 3,
|
||||
stepsPerWeek: 3,
|
||||
pagesPerDay: 11,
|
||||
));
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -2019,6 +2019,91 @@ void main() {
|
|||
expect(find.text('1 min'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('Preparing for an exam', () {
|
||||
testWidgets('a plan offered from level tags is chosen with a date, and '
|
||||
'the study plan shows how far the exam is, what it covers and the '
|
||||
'pace to it', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
final neko = await card('japanese', '猫');
|
||||
final inu = await card('japanese', '犬');
|
||||
await card('japanese', '本');
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
final person = PersonScope.of(
|
||||
tester.element(find.byType(StudysetScreen)),
|
||||
);
|
||||
final n5 = await person.tags.create('JLPT-N5');
|
||||
await person.tags.setTagsOf(neko, {n5});
|
||||
await person.tags.setTagsOf(inu, {n5});
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await openPart(tester, 'Study plan');
|
||||
await tester.tap(find.text('Prepare for an exam'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('From the level tags'), findsOneWidget);
|
||||
await tester.tap(find.text('JLPT N5'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Twelve weeks off by default: two cards over eleven weeks of
|
||||
// learning.
|
||||
expect(find.text('JLPT N5'), findsOneWidget);
|
||||
expect(find.textContaining('In 84 days'), findsOneWidget);
|
||||
expect(find.text('0 of 2 flashcards started'), findsOneWidget);
|
||||
expect(find.text('1 new flashcard a day'), findsOneWidget);
|
||||
expect(find.text('Study for the exam'), findsOneWidget);
|
||||
final japanese = (await person.studysets.all()).singleWhere(
|
||||
(studyset) => studyset.id == 'japanese',
|
||||
);
|
||||
expect(japanese.content.exam?.plan, 'auto:jlpt:1');
|
||||
});
|
||||
|
||||
testWidgets("the studyset's own plan takes whole topics, and can be "
|
||||
'deleted again', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
stored('kana', 'Kana', parent: 'japanese');
|
||||
stored('grammar', 'Grammar', parent: 'japanese', position: 'a1');
|
||||
await card('kana', 'あ');
|
||||
await card('grammar', 'は');
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await openPart(tester, 'Study plan');
|
||||
await tester.tap(find.text('Prepare for an exam'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text(
|
||||
'No exam plans yet. Make one from this studyset’s topics and tags.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.text('New exam plan'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField), 'Kana quiz');
|
||||
await tester.tap(find.widgetWithText(CheckboxListTile, 'Kana'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Save').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Kana quiz'), findsOneWidget);
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Kana quiz'), findsOneWidget);
|
||||
expect(find.text('0 of 1 flashcard started'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Change the exam'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('Edit exam plan'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Delete plan'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Prepare for an exam'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('choosing another part ticks, as the navigation bar does; '
|
||||
'the one shown again does not', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
|
|
|
|||
Loading…
Reference in a new issue