perf(learn): a rated card gives way to the next one at once

Rating a card while studying waited for its review to be written, then
for the card's schedule to be replayed, before the next card showed.
StudySessionBloc now starts keeping the review and shows what comes
next at once: the card's new place in the round is worked out from its
reviews with the new one counted as if kept already
(ReviewRepository.schedulesAsStudied's pending, each review counted
once should it be kept by then).

Should keeping it fail, the step leaves the session's Undo, the rating
leaves its accuracy, and the card comes back where it stood, saying so
(withRatingFailed), as a failed rating did before. An Undo of a rating
still being kept waits for it and takes it back once kept; one that
couldn't be kept has nothing to take back, and the Undo has brought
the card back already.

Whats-New: While studying, the next flashcard shows the moment you rate one.
Whats-New-De: Beim Lernen kommt die nächste Karteikarte, sobald du eine bewertet hast.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-07 23:26:25 +01:00
commit 4940230ef1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:clock/clock.dart';
@ -528,6 +529,91 @@ void main() {
expect((await showing(bloc, '犬')).ratingFailed, isFalse);
});
});
test('a rating shows the next card at once, before it is kept', () async {
await cards.create('japanese', card('猫'));
await cards.create('japanese', card('犬'));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
await showing(bloc, '猫');
final hold = reviewGateway.holdWrites = Completer<void>();
bloc.add(const StudySessionEventRated(Rating.easy));
final dog = await showing(bloc, '犬');
expect(reviewGateway.rows, isEmpty, reason: 'not kept yet');
expect(dog.canUndo, isTrue);
hold.complete();
await eventually(() => reviewGateway.rows.length == 1);
expect(reviewGateway.rows.single.rating, Rating.easy.value);
});
});
test("a rating that can't be kept once the next card shows brings "
'the card back, saying so, with nothing to undo', () async {
await cards.create('japanese', card('猫'));
await cards.create('japanese', card('犬'));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
await showing(bloc, '猫');
final hold = reviewGateway.holdWrites = Completer<void>();
reviewGateway.failWrites = Exception('disk');
bloc.add(const StudySessionEventRated(Rating.easy));
await showing(bloc, '犬');
hold.complete();
final failed = await until(
bloc,
(s) => s is StudySessionStateShowing && s.ratingFailed,
) as StudySessionStateShowing;
expect(failed.card.content.front, text('猫'));
expect(failed.canUndo, isFalse);
expect(failed.pills.map((pill) => pill.state), [
PillState.active,
PillState.pending,
]);
reviewGateway
..holdWrites = null
..failWrites = null;
bloc.add(const StudySessionEventRated(Rating.easy));
expect((await showing(bloc, '犬')).ratingFailed, isFalse);
await eventually(() => reviewGateway.rows.length == 1);
});
});
test('Undo while a rating is still being kept takes it back once it '
'is', () async {
await cards.create('japanese', card('猫'));
await cards.create('japanese', card('犬'));
await withClock(Clock(() => now), () async {
final bloc = session();
addTearDown(bloc.close);
await showing(bloc, '猫');
final hold = reviewGateway.holdWrites = Completer<void>();
bloc.add(const StudySessionEventRated(Rating.easy));
await showing(bloc, '犬');
bloc.add(const StudySessionEventUndone());
hold.complete();
final neko = await showing(bloc, '猫');
expect(neko.ratingFailed, isFalse);
await pumpEventQueue();
expect(reviewGateway.rows, isEmpty);
expect(bloc.state, isA<StudySessionStateShowing>());
expect(
(bloc.state as StudySessionStateShowing).card.content.front,
text('猫'),
);
});
});
});
test('a round holds at most the cards per round', () async {