fix(studyset): an exam's pace keeps its rate, and plans hold what they show
The pace kept a week for reviews only while more than two weeks were left: followed from further off, it halved its rate at two weeks and brought new cards until the exam. It now keeps a week, or half the days left when the exam is closer, and the sheet says so. An exam whose plan is gone said "Today", as the days left came from the pace, which needs the plan; they are now counted from the date alone. Once the exam's day was over, the study plan still asked for everything new at once and Study for the exam queued every new card; now neither shows, and a session for it studies as usual. Cards tagged HSK 3 alone offered HSK 1 and 2, plans covering nothing; a level offers a plan only when it covers some tag present. The plan editor kept topics deleted or moved out since and tags spelled in another case, chosen but not shown, and ticking such a tag added it twice; once it has read what a plan may cover, only that stays chosen, each tag as the flashcards spell it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
57240f01f3
commit
9c39155b94
12 changed files with 264 additions and 78 deletions
|
|
@ -145,7 +145,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
|
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
|
||||||
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Since blanking can't be undone, an Undo comes before it: a moment's for cards, notes, paths and PDFs (`DelayedDeletions`), 14 days for a studyset (ADR 0007, decided 2026-10-12, as Vaia keeps them): `StudysetDeletions`, opened with the person's data, marks it deleted (`studyset.deletedAt`), and it waits in the library's Recently deleted with everything inside it, restorable, while every other list, count and session reads without it (`StudysetRepository.all` and `watchAll` leave out what is deleted and inside it, `notDeleted`; `withDeleted` reads everything); once the 14 days are over, whichever of the person's devices looks next (as their data opens, after every sync) deletes the whole subtree for good, what is inside first, each studyset's cards blanked before it, and Delete now does at once after asking. Deleting doesn't ask, offering Undo. Widgets delete, undo and delete for good through it directly (`deleteStudysetWithUndo`, `deleteStudysetForGood`), not through a bloc: deleting a studyset from its own screen closes that screen and its bloc, and the deletion and its Undo must outlive them. The server never deletes them (it can't blank a payload; ADR 0007), and its connector, publishing and learner count leave them out too (`notDeleted` in `lib/src/studyset/`). Tags differ, being values (a tag is its name whatever its case, which it keeps; ADR 0004 rule 2): one is blanked and deleted at once, and its Undo makes a tag of that name and colour anew, on the same cards. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
|
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Since blanking can't be undone, an Undo comes before it: a moment's for cards, notes, paths and PDFs (`DelayedDeletions`), 14 days for a studyset (ADR 0007, decided 2026-10-12, as Vaia keeps them): `StudysetDeletions`, opened with the person's data, marks it deleted (`studyset.deletedAt`), and it waits in the library's Recently deleted with everything inside it, restorable, while every other list, count and session reads without it (`StudysetRepository.all` and `watchAll` leave out what is deleted and inside it, `notDeleted`; `withDeleted` reads everything); once the 14 days are over, whichever of the person's devices looks next (as their data opens, after every sync) deletes the whole subtree for good, what is inside first, each studyset's cards blanked before it, and Delete now does at once after asking. Deleting doesn't ask, offering Undo. Widgets delete, undo and delete for good through it directly (`deleteStudysetWithUndo`, `deleteStudysetForGood`), not through a bloc: deleting a studyset from its own screen closes that screen and its bloc, and the deletion and its Undo must outlive them. The server never deletes them (it can't blank a payload; ADR 0007), and its connector, publishing and learner count leave them out too (`notDeleted` in `lib/src/studyset/`). Tags differ, being values (a tag is its name whatever its case, which it keeps; ADR 0004 rule 2): one is blanked and deleted at once, and its Undo makes a tag of that name and colour anew, on the same cards. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
|
||||||
- **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes.
|
- **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes.
|
||||||
- **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`); taking back a rating deletes its row. Besides ratings it holds a fresh start (`reset`), which never counts as studying; older app versions skip a mode they don't know. A studyset's Reset progress asks what of it starts afresh, naming only what has progress (decided 2026-10-12): its flashcards (a fresh start each), reading its PDFs (a `reset` in their reading log) and its learning paths (a `reset` answer per step), one Undo taking all of it back. A burial isn't in the log but on the card: `card.buriedAt` sets it aside until the next local day begins on the device looking, out of every queue and count (Unbury clears it), read with the cards every list reads anyway. A card's schedule is never stored: `scheduleOf` (`feature/study/model/card_schedule.dart`) replays its reviews in time order with FSRS-6 (`fsrs`, pinned exactly), and only the final due date is fuzzed, from the card's id and review count, so every device computes the same. Studying a studyset covers everything inside it but archived studysets (`studiedIn`), and the study queue (`study_queue.dart`) takes each studyset's cards as a group: due cards of all of them earliest first, then each studyset's new cards in tree order, within each studyset's daily limits of new cards and reviews (its study options, `StudysetStudyOptions` in its payload, schema 3; without, the person's new cards a day and no limit on reviews; cards in their learning steps come anyway), in the order the studied studyset's options ask for (reviews first, mixed, new first). **Exam plans** (`feature/studyset/model/exam_plan.dart`, decided 2026-10-14): a studyset plans for exams (`examPlans` in its payload, schema 7: each a name, the topics it takes whole by their ids, the tags whose cards it takes by their names), its author's published with it (the copier and the updater point the topics at the copy's studysets), and plans are offered from level tags too (`automaticExamPlans`: JLPT, HSK, KRDict's levels, each taking the levels below it, ids `auto:…`). The person's choice (`exam`: the plan's id and the exam's day, `YYYY-MM-DD`) is theirs alone, never published, like a PDF's deadline. `ExamCoverage` reads what a plan covers (its topics' studysets with their paths and PDFs, its tags' cards anywhere in the studyset); the Study plan shows how far the person has come with it and the pace (`examPace`: what is new spread so that it is learnt a week before the exam when more than two weeks are left, by the exam otherwise; new cards counted as the day began), and Study for the exam (`StudyFocus.exam`) studies what it covers alone, as one group whose new cards a day are that pace, reviews unlimited. The library's and the topics' counts and badges add up the same way, each card on the schedule a session studies it on (`card_study_mode.dart`: written by hand while Write by hand is on, back first while Back first is; `ReviewRepository.schedulesAsStudied`); Home's due forecast still counts front to back, as it reads cards without their content. Deleting a card deletes its reviews.
|
- **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`); taking back a rating deletes its row. Besides ratings it holds a fresh start (`reset`), which never counts as studying; older app versions skip a mode they don't know. A studyset's Reset progress asks what of it starts afresh, naming only what has progress (decided 2026-10-12): its flashcards (a fresh start each), reading its PDFs (a `reset` in their reading log) and its learning paths (a `reset` answer per step), one Undo taking all of it back. A burial isn't in the log but on the card: `card.buriedAt` sets it aside until the next local day begins on the device looking, out of every queue and count (Unbury clears it), read with the cards every list reads anyway. A card's schedule is never stored: `scheduleOf` (`feature/study/model/card_schedule.dart`) replays its reviews in time order with FSRS-6 (`fsrs`, pinned exactly), and only the final due date is fuzzed, from the card's id and review count, so every device computes the same. Studying a studyset covers everything inside it but archived studysets (`studiedIn`), and the study queue (`study_queue.dart`) takes each studyset's cards as a group: due cards of all of them earliest first, then each studyset's new cards in tree order, within each studyset's daily limits of new cards and reviews (its study options, `StudysetStudyOptions` in its payload, schema 3; without, the person's new cards a day and no limit on reviews; cards in their learning steps come anyway), in the order the studied studyset's options ask for (reviews first, mixed, new first). **Exam plans** (`feature/studyset/model/exam_plan.dart`, decided 2026-10-14): a studyset plans for exams (`examPlans` in its payload, schema 7: each a name, the topics it takes whole by their ids, the tags whose cards it takes by their names), its author's published with it (the copier and the updater point the topics at the copy's studysets), and plans are offered from level tags too (`automaticExamPlans`: JLPT, HSK, KRDict's levels, each taking the levels below it, ids `auto:…`). The person's choice (`exam`: the plan's id and the exam's day, `YYYY-MM-DD`) is theirs alone, never published, like a PDF's deadline. `ExamCoverage` reads what a plan covers (its topics' studysets with their paths and PDFs, its tags' cards anywhere in the studyset); the Study plan shows how far the person has come with it and the pace (`examPace`: what is new spread so that it is learnt a week before the exam, or halfway there when it is closer; new cards counted as the day began), and Study for the exam (`StudyFocus.exam`) studies what it covers alone, as one group whose new cards a day are that pace, reviews unlimited; once the exam's day is over, neither shows, and a session for it studies as usual. The library's and the topics' counts and badges add up the same way, each card on the schedule a session studies it on (`card_study_mode.dart`: written by hand while Write by hand is on, back first while Back first is; `ReviewRepository.schedulesAsStudied`); Home's due forecast still counts front to back, as it reads cards without their content. Deleting a card deletes its reviews.
|
||||||
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
|
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
|
||||||
- **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `deletedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule), `write` (by hand, its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync.
|
- **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `deletedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule), `write` (by hand, its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync.
|
||||||
- **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces/<space>/<sha256>`, at most 20 MB each, and within limits (`file_limits.dart`, from the environment: an anonymous person 100 MB, an identified one 500 MB or what a `file_allowance` row grants them, the instance 15 GB), checked when an upload starts (unfinished uploads count, or they could fill the disk unnoticed) and again when it finishes (only what is kept counts), refused with `FileLimitExceededException`; `deletePerson` deletes the person's files and allowance, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:<asset row id>` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people.
|
- **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces/<space>/<sha256>`, at most 20 MB each, and within limits (`file_limits.dart`, from the environment: an anonymous person 100 MB, an identified one 500 MB or what a `file_allowance` row grants them, the instance 15 GB), checked when an upload starts (unfinished uploads count, or they could fill the disk unnoticed) and again when it finishes (only what is kept counts), refused with `FileLimitExceededException`; `deletePerson` deletes the person's files and allowance, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:<asset row id>` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people.
|
||||||
|
|
|
||||||
|
|
@ -1806,7 +1806,7 @@
|
||||||
"examPacePages": "{count, plural, =1{1 Seite am Tag} other{{count} Seiten am Tag}}",
|
"examPacePages": "{count, plural, =1{1 Seite am Tag} other{{count} Seiten am Tag}}",
|
||||||
"examPaceDone": "Alles Neue ist gelernt: bis zur Prüfung wiederholen.",
|
"examPaceDone": "Alles Neue ist gelernt: bis zur Prüfung wiederholen.",
|
||||||
"examStudy": "Für die Prüfung lernen",
|
"examStudy": "Für die Prüfung lernen",
|
||||||
"examSheetHelp": "Wähle, was die Prüfung abdeckt und wann sie ist. Alles Neue wird dann so verteilt, dass es eine Woche vorher gelernt ist und die letzte Woche zum Wiederholen bleibt. Das Datum siehst nur du.",
|
"examSheetHelp": "Wähle, was die Prüfung abdeckt und wann sie ist. Alles Neue wird dann so verteilt, dass es eine Woche vor der Prüfung gelernt ist, oder auf halbem Weg dorthin, wenn sie näher ist, und der Rest zum Wiederholen bleibt. Das Datum siehst nur du.",
|
||||||
"examDate": "Prüfungsdatum",
|
"examDate": "Prüfungsdatum",
|
||||||
"examSheetPlans": "Prüfungspläne dieses Lernsets",
|
"examSheetPlans": "Prüfungspläne dieses Lernsets",
|
||||||
"examSheetFromTags": "Aus den Stufen-Tags",
|
"examSheetFromTags": "Aus den Stufen-Tags",
|
||||||
|
|
|
||||||
|
|
@ -2137,7 +2137,7 @@
|
||||||
"@examPacePages": {"placeholders": {"count": {"type": "int"}}},
|
"@examPacePages": {"placeholders": {"count": {"type": "int"}}},
|
||||||
"examPaceDone": "Everything new is learnt: review until the exam.",
|
"examPaceDone": "Everything new is learnt: review until the exam.",
|
||||||
"examStudy": "Study for the exam",
|
"examStudy": "Study for the exam",
|
||||||
"examSheetHelp": "Choose what the exam covers and when it is. Everything new is then spread so that it is learnt a week before, leaving the last week for reviews. Only you see the date.",
|
"examSheetHelp": "Choose what the exam covers and when it is. Everything new is then spread so that it is learnt a week before the exam, or halfway there when it is closer, leaving the rest for reviews. Only you see the date.",
|
||||||
"examDate": "Exam date",
|
"examDate": "Exam date",
|
||||||
"examSheetPlans": "This studyset’s exam plans",
|
"examSheetPlans": "This studyset’s exam plans",
|
||||||
"examSheetFromTags": "From the level tags",
|
"examSheetFromTags": "From the level tags",
|
||||||
|
|
|
||||||
|
|
@ -10680,7 +10680,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @examSheetHelp.
|
/// No description provided for @examSheetHelp.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Choose what the exam covers and when it is. Everything new is then spread so that it is learnt a week before, leaving the last week for reviews. Only you see the date.'**
|
/// **'Choose what the exam covers and when it is. Everything new is then spread so that it is learnt a week before the exam, or halfway there when it is closer, leaving the rest for reviews. Only you see the date.'**
|
||||||
String get examSheetHelp;
|
String get examSheetHelp;
|
||||||
|
|
||||||
/// No description provided for @examDate.
|
/// No description provided for @examDate.
|
||||||
|
|
|
||||||
|
|
@ -6790,7 +6790,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get examSheetHelp =>
|
String get examSheetHelp =>
|
||||||
'Wähle, was die Prüfung abdeckt und wann sie ist. Alles Neue wird dann so verteilt, dass es eine Woche vorher gelernt ist und die letzte Woche zum Wiederholen bleibt. Das Datum siehst nur du.';
|
'Wähle, was die Prüfung abdeckt und wann sie ist. Alles Neue wird dann so verteilt, dass es eine Woche vor der Prüfung gelernt ist, oder auf halbem Weg dorthin, wenn sie näher ist, und der Rest zum Wiederholen bleibt. Das Datum siehst nur du.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get examDate => 'Prüfungsdatum';
|
String get examDate => 'Prüfungsdatum';
|
||||||
|
|
|
||||||
|
|
@ -6729,7 +6729,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get examSheetHelp =>
|
String get examSheetHelp =>
|
||||||
'Choose what the exam covers and when it is. Everything new is then spread so that it is learnt a week before, leaving the last week for reviews. Only you see the date.';
|
'Choose what the exam covers and when it is. Everything new is then spread so that it is learnt a week before the exam, or halfway there when it is closer, leaving the rest for reviews. Only you see the date.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get examDate => 'Exam date';
|
String get examDate => 'Exam date';
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,8 @@ class StudySessionBloc(
|
||||||
/// For a session studying for the exam the person prepares for in the
|
/// For a session studying for the exam the person prepares for in the
|
||||||
/// studyset: what the exam's plan covers, as one group whose new cards
|
/// studyset: what the exam's plan covers, as one group whose new cards
|
||||||
/// a day are the pace that gets them through by the exam (`examPace`);
|
/// a day are the pace that gets them through by the exam (`examPace`);
|
||||||
/// null without an exam, or with one its studyset no longer plans for.
|
/// null without an exam, with one whose day is over, or with one its
|
||||||
|
/// studyset no longer plans for: the session then studies as usual.
|
||||||
Future<List<List<Card>>?> _examGroups() async {
|
Future<List<List<Card>>?> _examGroups() async {
|
||||||
final all = await _studysets.all();
|
final all = await _studysets.all();
|
||||||
final choice = all
|
final choice = all
|
||||||
|
|
@ -426,7 +427,7 @@ class StudySessionBloc(
|
||||||
.firstOrNull
|
.firstOrNull
|
||||||
?.content
|
?.content
|
||||||
.exam;
|
.exam;
|
||||||
if (choice == null) {
|
if (choice == null || choice.isPastAt(clock.now())) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final coverage = ExamCoverage(
|
final coverage = ExamCoverage(
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,15 @@ final class const ExamChoice({
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// The days from [now]'s day to the exam's, as the calendar counts them;
|
||||||
|
/// none on the day and after it.
|
||||||
|
int daysLeftAt(DateTime now) => _daysBetween(now, date);
|
||||||
|
|
||||||
|
/// Whether the exam's day is over at [now]: from the day after it on, a
|
||||||
|
/// pace to it or studying for it means nothing any more.
|
||||||
|
bool isPastAt(DateTime now) =>
|
||||||
|
date.isBefore(DateTime(now.year, now.month, now.day));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [plan, date];
|
List<Object?> get props => [plan, date];
|
||||||
}
|
}
|
||||||
|
|
@ -118,21 +127,22 @@ const _examLevels = [
|
||||||
/// The plans offered from the level tags a studyset's cards carry
|
/// The plans offered from the level tags a studyset's cards carry
|
||||||
/// ([tagNames], whatever their case): one for each level an exam tests,
|
/// ([tagNames], whatever their case): one for each level an exam tests,
|
||||||
/// taking the levels below it too, as an exam does (JLPT N4 takes N5's
|
/// taking the levels below it too, as an exam does (JLPT N4 takes N5's
|
||||||
/// words), up to the highest level present.
|
/// words), up to the highest level present. A level with none of its tags
|
||||||
|
/// or those below present is left out, as it would cover nothing: cards
|
||||||
|
/// tagged HSK 3 alone offer no HSK 1 or 2.
|
||||||
List<ExamPlan> automaticExamPlans(Iterable<String> tagNames) {
|
List<ExamPlan> automaticExamPlans(Iterable<String> tagNames) {
|
||||||
final present = {for (final name in tagNames) name.toLowerCase()};
|
final present = {for (final name in tagNames) name.toLowerCase()};
|
||||||
|
bool has((String, String) level) => present.contains(level.$2.toLowerCase());
|
||||||
return [
|
return [
|
||||||
for (final (:prefix, :levels, :exam) in _examLevels)
|
for (final (:prefix, :levels, :exam) in _examLevels)
|
||||||
for (final (index, (level, _)) in levels.indexed)
|
for (final (index, (level, _)) in levels.indexed)
|
||||||
if (levels
|
if (levels.skip(index).any(has) && levels.take(index + 1).any(has))
|
||||||
.skip(index)
|
|
||||||
.any((each) => present.contains(each.$2.toLowerCase())))
|
|
||||||
ExamPlan(
|
ExamPlan(
|
||||||
id: '$_automatic$prefix:${index + 1}',
|
id: '$_automatic$prefix:${index + 1}',
|
||||||
name: '$exam $level',
|
name: '$exam $level',
|
||||||
tags: [
|
tags: [
|
||||||
for (final (_, tag) in levels.take(index + 1))
|
for (final each in levels.take(index + 1))
|
||||||
if (present.contains(tag.toLowerCase())) tag,
|
if (has(each)) each.$2,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
@ -141,8 +151,8 @@ List<ExamPlan> automaticExamPlans(Iterable<String> tagNames) {
|
||||||
/// How far a person has come with what an exam covers, and the pace that
|
/// How far a person has come with what an exam covers, and the pace that
|
||||||
/// gets them through it by the exam: the days left, the new cards to learn
|
/// gets them through it by the exam: the days left, the new cards to learn
|
||||||
/// a day, the steps of learning paths to work through a week and the pages
|
/// a day, the steps of learning paths to work through a week and the pages
|
||||||
/// to read a day, so that what is new is learnt a week before the exam (or
|
/// to read a day, so that what is new is learnt a week before the exam, or
|
||||||
/// by it, closer than that), leaving the last week for reviews.
|
/// halfway there when it is closer, leaving the time after for reviews.
|
||||||
typedef ExamPace = ({
|
typedef ExamPace = ({
|
||||||
int daysLeft,
|
int daysLeft,
|
||||||
int newPerDay,
|
int newPerDay,
|
||||||
|
|
@ -152,7 +162,10 @@ typedef ExamPace = ({
|
||||||
|
|
||||||
/// The pace from [today] to the exam on [examDate] for [newCards] cards
|
/// The pace from [today] to the exam on [examDate] for [newCards] cards
|
||||||
/// never studied, [stepsLeft] steps not done and [pagesLeft] pages not
|
/// never studied, [stepsLeft] steps not done and [pagesLeft] pages not
|
||||||
/// read (see [ExamPace]).
|
/// read (see [ExamPace]). The days kept for reviews are a week, or half of
|
||||||
|
/// those left when fewer than two weeks are: a pace followed from further
|
||||||
|
/// off keeps its rate as two weeks are left, rather than halving it and
|
||||||
|
/// bringing new cards up to the exam.
|
||||||
ExamPace examPace({
|
ExamPace examPace({
|
||||||
required DateTime today,
|
required DateTime today,
|
||||||
required DateTime examDate,
|
required DateTime examDate,
|
||||||
|
|
@ -160,14 +173,8 @@ ExamPace examPace({
|
||||||
required int stepsLeft,
|
required int stepsLeft,
|
||||||
required int pagesLeft,
|
required int pagesLeft,
|
||||||
}) {
|
}) {
|
||||||
final start = DateTime(today.year, today.month, today.day);
|
final daysLeft = _daysBetween(today, examDate);
|
||||||
final end = DateTime(examDate.year, examDate.month, examDate.day);
|
final learning = math.max(1, daysLeft - math.min(7, daysLeft ~/ 2));
|
||||||
// Whole days between, as the calendar counts them, summer time or not.
|
|
||||||
final daysLeft = math.max(
|
|
||||||
0,
|
|
||||||
(end.difference(start).inHours / 24).round(),
|
|
||||||
);
|
|
||||||
final learning = math.max(1, daysLeft > 14 ? daysLeft - 7 : daysLeft);
|
|
||||||
int perDay(int left) => left <= 0 ? 0 : (left / learning).ceil();
|
int perDay(int left) => left <= 0 ? 0 : (left / learning).ceil();
|
||||||
return (
|
return (
|
||||||
daysLeft: daysLeft,
|
daysLeft: daysLeft,
|
||||||
|
|
@ -179,6 +186,14 @@ ExamPace examPace({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The whole days from [from]'s day to [to]'s, as the calendar counts them,
|
||||||
|
/// summer time or not; none when [to] is that day or before it.
|
||||||
|
int _daysBetween(DateTime from, DateTime to) {
|
||||||
|
final start = DateTime(from.year, from.month, from.day);
|
||||||
|
final end = DateTime(to.year, to.month, to.day);
|
||||||
|
return math.max(0, (end.difference(start).inHours / 24).round());
|
||||||
|
}
|
||||||
|
|
||||||
List<String> _strings(Object? json) => [
|
List<String> _strings(Object? json) => [
|
||||||
if (json is List)
|
if (json is List)
|
||||||
for (final item in json)
|
for (final item in json)
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@ import 'package:material_ui/material_ui.dart';
|
||||||
/// top of its study plan (decided 2026-10-14). Without one, a row to
|
/// top of its study plan (decided 2026-10-14). Without one, a row to
|
||||||
/// prepare for one; with one, its name and how many days are left, how far
|
/// prepare for one; with one, its name and how many days are left, how far
|
||||||
/// the person has come with what it covers (flashcards started, steps
|
/// the person has come with what it covers (flashcards started, steps
|
||||||
/// done, pages read), the pace that gets them through by the exam, and a
|
/// done, pages read), and until the exam's day is over the pace that gets
|
||||||
/// button to study for it: what it covers alone, its new cards at that
|
/// them through by the exam and a button to study for it: what it covers
|
||||||
/// pace.
|
/// alone, its new cards at that pace.
|
||||||
class const ExamSection({required final String studysetId, super.key})
|
class const ExamSection({required final String studysetId, super.key})
|
||||||
extends StatelessWidget {
|
extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -56,7 +56,8 @@ class const ExamSection({required final String studysetId, super.key})
|
||||||
final date = MaterialLocalizations.of(context).formatMediumDate(
|
final date = MaterialLocalizations.of(context).formatMediumDate(
|
||||||
choice.date,
|
choice.date,
|
||||||
);
|
);
|
||||||
final pace = progress == null
|
final past = choice.isPastAt(today);
|
||||||
|
final pace = progress == null || past
|
||||||
? null
|
? null
|
||||||
: examPace(
|
: examPace(
|
||||||
today: today,
|
today: today,
|
||||||
|
|
@ -65,9 +66,6 @@ class const ExamSection({required final String studysetId, super.key})
|
||||||
stepsLeft: progress.steps - progress.stepsDone,
|
stepsLeft: progress.steps - progress.stepsDone,
|
||||||
pagesLeft: progress.pages - progress.pagesRead,
|
pagesLeft: progress.pages - progress.pagesRead,
|
||||||
);
|
);
|
||||||
final past = choice.date.isBefore(
|
|
||||||
DateTime(today.year, today.month, today.day),
|
|
||||||
);
|
|
||||||
return DecoratedBox(
|
return DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surfaceContainer,
|
color: colors.surfaceContainer,
|
||||||
|
|
@ -94,7 +92,7 @@ class const ExamSection({required final String studysetId, super.key})
|
||||||
Text(
|
Text(
|
||||||
past
|
past
|
||||||
? l10n.examPast(date)
|
? l10n.examPast(date)
|
||||||
: l10n.examDaysLeft(pace?.daysLeft ?? 0, date),
|
: l10n.examDaysLeft(choice.daysLeftAt(today), date),
|
||||||
style: muted,
|
style: muted,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -107,7 +105,7 @@ class const ExamSection({required final String studysetId, super.key})
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (plan != null && progress != null && pace != null)
|
if (plan != null && progress != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 12),
|
padding: const EdgeInsets.only(right: 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -140,8 +138,12 @@ class const ExamSection({required final String studysetId, super.key})
|
||||||
progress.pages,
|
progress.pages,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (pace != null) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(l10n.examPaceTitle, style: theme.textTheme.labelLarge),
|
Text(
|
||||||
|
l10n.examPaceTitle,
|
||||||
|
style: theme.textTheme.labelLarge,
|
||||||
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
switch ([
|
switch ([
|
||||||
|
|
@ -171,6 +173,7 @@ class const ExamSection({required final String studysetId, super.key})
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -405,10 +408,28 @@ class _PlanEditorState() extends State<_PlanEditor> {
|
||||||
late final _name = TextEditingController(text: widget.plan?.name);
|
late final _name = TextEditingController(text: widget.plan?.name);
|
||||||
late final _topics = {...?widget.plan?.topics};
|
late final _topics = {...?widget.plan?.topics};
|
||||||
late final _tags = {...?widget.plan?.tags};
|
late final _tags = {...?widget.plan?.tags};
|
||||||
|
|
||||||
|
/// What the plan may cover. Once it is read, only what shows stays
|
||||||
|
/// chosen: no topic deleted or moved out since, and each tag as the cards
|
||||||
|
/// spell it, so that its chip shows it chosen and ticking it never adds
|
||||||
|
/// it twice.
|
||||||
late final Future<
|
late final Future<
|
||||||
({List<({Studyset studyset, int depth})> topics, List<String> tags})
|
({List<({Studyset studyset, int depth})> topics, List<String> tags})
|
||||||
>
|
>
|
||||||
_coverable = context.read<ExamCubit>().coverable();
|
_coverable = context.read<ExamCubit>().coverable().then((coverable) {
|
||||||
|
final topics = {for (final topic in coverable.topics) topic.studyset.id};
|
||||||
|
final spelled = {for (final tag in coverable.tags) tag.toLowerCase(): tag};
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_topics.retainAll(topics);
|
||||||
|
final tags = {for (final tag in _tags) ?spelled[tag.toLowerCase()]};
|
||||||
|
_tags
|
||||||
|
..clear()
|
||||||
|
..addAll(tags);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return coverable;
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
|
|
||||||
|
|
@ -1193,7 +1193,7 @@ void main() {
|
||||||
.withExamPlans(const [
|
.withExamPlans(const [
|
||||||
ExamPlan(id: 'verbs', name: 'Verb test', tags: ['verb']),
|
ExamPlan(id: 'verbs', name: 'Verb test', tags: ['verb']),
|
||||||
])
|
])
|
||||||
.withExam(ExamChoice(plan: 'verbs', date: DateTime(2026, 10, 7))),
|
.withExam(ExamChoice(plan: 'verbs', date: DateTime(2026, 10, 9))),
|
||||||
);
|
);
|
||||||
|
|
||||||
await withClock(Clock(() => now), () async {
|
await withClock(Clock(() => now), () async {
|
||||||
|
|
@ -1201,7 +1201,8 @@ void main() {
|
||||||
addTearDown(bloc.close);
|
addTearDown(bloc.close);
|
||||||
final first = await showing(bloc, '食べる');
|
final first = await showing(bloc, '食べる');
|
||||||
|
|
||||||
// Two new verbs over the two days left: one a day.
|
// Two new verbs over the first two of the four days left, the
|
||||||
|
// last two kept for reviews: one a day.
|
||||||
expect(first.pills, hasLength(1));
|
expect(first.pills, hasLength(1));
|
||||||
});
|
});
|
||||||
await studysets.updateContent(
|
await studysets.updateContent(
|
||||||
|
|
@ -1216,6 +1217,25 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('for an exam whose day is over, studies as usual rather than all '
|
||||||
|
'its new cards at once', () async {
|
||||||
|
await studysets.updateContent(
|
||||||
|
'japanese',
|
||||||
|
StudysetContent.titled('Japanese')
|
||||||
|
.withExamPlans(const [
|
||||||
|
ExamPlan(id: 'verbs', name: 'Verb test', tags: ['verb']),
|
||||||
|
])
|
||||||
|
.withExam(ExamChoice(plan: 'verbs', date: DateTime(2026, 9, 30))),
|
||||||
|
);
|
||||||
|
|
||||||
|
await withClock(Clock(() => now), () async {
|
||||||
|
final bloc = focused(const StudyFocus(exam: true));
|
||||||
|
addTearDown(bloc.close);
|
||||||
|
|
||||||
|
expect((await showing(bloc, '食べる')).pills, hasLength(3));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('goes to the route and back as its query', () {
|
test('goes to the route and back as its query', () {
|
||||||
const focus = StudyFocus(
|
const focus = StudyFocus(
|
||||||
tag: (id: 'verb-id', name: 'verb'),
|
tag: (id: 'verb-id', name: 'verb'),
|
||||||
|
|
|
||||||
|
|
@ -25,20 +25,19 @@ void main() {
|
||||||
[
|
[
|
||||||
for (final plan in automaticExamPlans(['HSK-7-9', '중급'])) plan.name,
|
for (final plan in automaticExamPlans(['HSK-7-9', '중급'])) plan.name,
|
||||||
],
|
],
|
||||||
[
|
['HSK 7–9', 'Korean 중급'],
|
||||||
'HSK 1',
|
|
||||||
'HSK 2',
|
|
||||||
'HSK 3',
|
|
||||||
'HSK 4',
|
|
||||||
'HSK 5',
|
|
||||||
'HSK 6',
|
|
||||||
'HSK 7–9',
|
|
||||||
'Korean 초급',
|
|
||||||
'Korean 중급',
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
expect(automaticExamPlans(['noun', 'verb']), isEmpty);
|
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', () {
|
group('An exam plan', () {
|
||||||
|
|
@ -76,6 +75,17 @@ void main() {
|
||||||
expect(ExamChoice.fromJson({'plan': 'final', 'date': 'soon'}), isNull);
|
expect(ExamChoice.fromJson({'plan': 'final', 'date': 'soon'}), isNull);
|
||||||
expect(ExamChoice.fromJson({'plan': 'final'}), 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', () {
|
group('The pace to an exam', () {
|
||||||
|
|
@ -98,7 +108,8 @@ void main() {
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('uses every day left when two weeks or fewer are', () {
|
test('leaves half the days left for reviews when the exam is closer '
|
||||||
|
'than two weeks', () {
|
||||||
final pace = examPace(
|
final pace = examPace(
|
||||||
today: DateTime(2026, 10, 14),
|
today: DateTime(2026, 10, 14),
|
||||||
examDate: DateTime(2026, 10, 24),
|
examDate: DateTime(2026, 10, 24),
|
||||||
|
|
@ -107,14 +118,30 @@ void main() {
|
||||||
pagesLeft: 101,
|
pagesLeft: 101,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 10 days left, 5 of them to learn in.
|
||||||
expect(pace, (
|
expect(pace, (
|
||||||
daysLeft: 10,
|
daysLeft: 10,
|
||||||
newPerDay: 3,
|
newPerDay: 5,
|
||||||
stepsPerWeek: 3,
|
stepsPerWeek: 3,
|
||||||
pagesPerDay: 11,
|
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', () {
|
test('counts calendar days across a change of summer time', () {
|
||||||
expect(
|
expect(
|
||||||
examPace(
|
examPace(
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_opt
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.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/bloc/exam_cubit.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.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';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_breadcrumb.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_breadcrumb.dart';
|
||||||
|
|
@ -119,6 +120,8 @@ void main() {
|
||||||
String? frontLanguage,
|
String? frontLanguage,
|
||||||
String? backLanguage,
|
String? backLanguage,
|
||||||
String? colorName,
|
String? colorName,
|
||||||
|
List<ExamPlan> examPlans = const [],
|
||||||
|
ExamChoice? exam,
|
||||||
}) => gateway.receiveRemoteChange(
|
}) => gateway.receiveRemoteChange(
|
||||||
id,
|
id,
|
||||||
encodeStudysetPayload(
|
encodeStudysetPayload(
|
||||||
|
|
@ -132,6 +135,8 @@ void main() {
|
||||||
bibliography: bibliography,
|
bibliography: bibliography,
|
||||||
frontLanguage: frontLanguage,
|
frontLanguage: frontLanguage,
|
||||||
backLanguage: backLanguage,
|
backLanguage: backLanguage,
|
||||||
|
examPlans: examPlans,
|
||||||
|
exam: exam,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
parentId: parent,
|
parentId: parent,
|
||||||
|
|
@ -2105,6 +2110,103 @@ void main() {
|
||||||
|
|
||||||
expect(find.text('Prepare for an exam'), findsOneWidget);
|
expect(find.text('Prepare for an exam'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('an exam whose plan is gone still says how far off it '
|
||||||
|
'is', (tester) async {
|
||||||
|
final today = clock.now();
|
||||||
|
stored(
|
||||||
|
'japanese',
|
||||||
|
'Japanese',
|
||||||
|
exam: ExamChoice(
|
||||||
|
plan: 'gone',
|
||||||
|
date: DateTime(today.year, today.month, today.day + 60),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||||
|
|
||||||
|
await openPart(tester, 'Study plan');
|
||||||
|
|
||||||
|
expect(find.text('This exam plan is gone'), findsOneWidget);
|
||||||
|
expect(find.textContaining('In 60 days'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets("once the exam's day is over, it shows how far the person "
|
||||||
|
'came, without a pace or studying for it', (tester) async {
|
||||||
|
final today = clock.now();
|
||||||
|
stored(
|
||||||
|
'japanese',
|
||||||
|
'Japanese',
|
||||||
|
examPlans: const [
|
||||||
|
ExamPlan(id: 'kana-quiz', name: 'Kana quiz', topics: ['kana']),
|
||||||
|
],
|
||||||
|
exam: ExamChoice(
|
||||||
|
plan: 'kana-quiz',
|
||||||
|
date: DateTime(today.year, today.month, today.day - 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
stored('kana', 'Kana', parent: 'japanese');
|
||||||
|
await card('kana', 'あ');
|
||||||
|
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||||
|
|
||||||
|
await openPart(tester, 'Study plan');
|
||||||
|
|
||||||
|
expect(find.textContaining('It was on'), findsOneWidget);
|
||||||
|
expect(find.text('0 of 1 flashcard started'), findsOneWidget);
|
||||||
|
expect(find.text('Your pace'), findsNothing);
|
||||||
|
expect(find.text('Study for the exam'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('editing a plan keeps only the topics and tags it shows, '
|
||||||
|
'each tag as the flashcards spell it', (tester) async {
|
||||||
|
stored(
|
||||||
|
'japanese',
|
||||||
|
'Japanese',
|
||||||
|
examPlans: const [
|
||||||
|
ExamPlan(
|
||||||
|
id: 'final',
|
||||||
|
name: 'Final',
|
||||||
|
topics: ['kana', 'gone'],
|
||||||
|
tags: ['VERB'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
stored('kana', 'Kana', parent: 'japanese');
|
||||||
|
final taberu = await card('kana', '食べる');
|
||||||
|
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||||
|
final person = PersonScope.of(
|
||||||
|
tester.element(find.byType(StudysetScreen)),
|
||||||
|
);
|
||||||
|
await person.tags.setTagsOf(taberu, {await person.tags.create('Verb')});
|
||||||
|
|
||||||
|
await openPart(tester, 'Study plan');
|
||||||
|
await tester.tap(find.text('Prepare for an exam'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byTooltip('Edit exam plan'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<CheckboxListTile>(
|
||||||
|
find.widgetWithText(CheckboxListTile, 'Kana'),
|
||||||
|
)
|
||||||
|
.value,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester
|
||||||
|
.widget<FilterChip>(find.widgetWithText(FilterChip, 'Verb'))
|
||||||
|
.selected,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Save').last);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final japanese = (await person.studysets.all()).singleWhere(
|
||||||
|
(studyset) => studyset.id == 'japanese',
|
||||||
|
);
|
||||||
|
expect(japanese.content.examPlans, const [
|
||||||
|
ExamPlan(id: 'final', name: 'Final', topics: ['kana'], tags: ['Verb']),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('choosing another part ticks, as the navigation bar does; '
|
testWidgets('choosing another part ticks, as the navigation bar does; '
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue