perf(learn): flashcards moved in the card browser go at once, in one write

Moving the chosen cards to another studyset wrote them one at a time,
each looking up its privacy keys and writing its row, while the
browser still showed them. CardBrowserBloc now shows the move at once
(gone from here, or after the cards of the studyset they go into where
the browser shows it, _withMoves) and puts them back where the move
fails, counting the failure for the screen's snack bar
(moveFailures, cardBrowserMoveFailed).

CardRepository.moveTo hands the gateway every card's new position at
once (CardGateway.updateStudysets): SyncCardGateway reads them in one
query, checks every card's key before changing any (one private card
among them refuses the whole move, as before for each), rekeys those
that need it, and updates every row in one write and one sync request.
updateStudyset delegates to it.

Whats-New: Flashcards moved to another studyset in the card browser move at once.
Whats-New-De: Im Karteikarten-Browser in ein anderes Lernset verschobene Karteikarten sind sofort dort.
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:19:20 +01:00
commit d6b2bec113
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 481 additions and 194 deletions

View file

@ -373,6 +373,70 @@ void main() {
expect(await sealed(open), isTrue);
});
test('cards moved together are all checked before any moves: one private '
'card among them keeps every one where it was; readable ones moved '
'into a private studyset together are all sealed', () async {
final laptop = await device(account: alice);
final private = await laptop.studysets.insert(
studysetPayload('Diary'),
DateTime.utc(2026, 10, 22),
);
final readable = await laptop.studysets.insert(
studysetPayload('Kitchen German'),
DateTime.utc(2026, 10, 22),
);
await laptop.studysets.setPrivate(readable, private: false);
final elsewhere = await laptop.studysets.insert(
studysetPayload('Cooking'),
DateTime.utc(2026, 10, 22),
);
await laptop.studysets.setPrivate(elsewhere, private: false);
final secret = await laptop.cards.insertCard(
studysetId: private,
position: 'a0',
payload: cardPayload('secret'),
);
final opens = [
for (final front in ['open 1', 'open 2'])
await laptop.cards.insertCard(
studysetId: elsewhere,
position: 'a0',
payload: cardPayload(front),
),
];
Future<String> studysetOf(String card) async =>
(await protocol.Card.db.findById(
db,
protocol.UuidValue.withValidation(card),
))!.studysetId.uuid;
await expectLater(
laptop.cards.updateStudysets(readable, {
opens[0]: 'a1',
secret: 'a2',
opens[1]: 'a3',
}),
throwsA(isA<PrivacyRefused>()),
);
expect(
[for (final card in opens) await studysetOf(card)],
[
elsewhere,
elsewhere,
],
);
expect(await studysetOf(secret), private);
await laptop.cards.updateStudysets(private, {
opens[0]: 'a1',
opens[1]: 'a2',
});
for (final card in opens) {
expect(await studysetOf(card), private);
expect(await sealed(card), isTrue);
}
});
test('a private topic moved into a readable studyset stays private, with a '
'key of its own', () async {
final laptop = await device(account: alice);

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_browser_bloc.dart';
@ -30,9 +32,11 @@ void main() {
late ReviewRepository reviews;
late CardDeletions deletions;
late CardBrowserBloc bloc;
late FakeCardGateway cardGateway;
setUp(() {
cards = CardRepository(FakeCardGateway());
cardGateway = FakeCardGateway();
cards = CardRepository(cardGateway);
tags = TagRepository(FakeTagGateway(), cardChanges: cards.changes);
reviews = ReviewRepository(FakeReviewGateway());
deletions = CardDeletions(cards);
@ -159,12 +163,84 @@ void main() {
bloc.add(const CardBrowserEventMoved('kana'));
final moved = await until((s) => s.cards.length == 1);
expect(moved.selected, isEmpty);
// Shown moved at once; written a moment after.
await pumpEventQueue();
expect(
[for (final each in await cards.cardsOf('kana')) each.id],
[ids[0], ids[1], ids[2]],
);
});
group('cards moved', () {
late List<String> ids;
setUp(() async {
ids = [
for (final front in ['a', 'b', 'c'])
await cards.create('japanese', card(front, 'x')),
];
});
void moveFirstTwo(String into) => bloc
..add(const CardBrowserEventSelectingToggled())
..add(CardBrowserEventCardSelected(ids[0]))
..add(CardBrowserEventCardSelected(ids[1]))
..add(CardBrowserEventMoved(into));
test('elsewhere leave at once, before they are written, and are '
'written together', () async {
start();
await until((s) => s.loaded && s.cards.length == 3);
final hold = cardGateway.holdMoves = Completer<void>();
moveFirstTwo('kana');
final moving = await until((s) => s.cards.length == 1);
expect(fronts(moving), ['c']);
expect(cardGateway.moves, 0, reason: 'not written yet');
hold.complete();
await pumpEventQueue();
expect(cardGateway.moves, 1);
expect(
[for (final each in await cards.cardsOf('kana')) each.id],
[
ids[0],
ids[1],
],
);
expect(fronts(bloc.state), ['c']);
});
test('into a studyset the browser shows go there at once', () async {
start(['japanese', 'kana']);
await until((s) => s.loaded && s.cards.length == 3);
cardGateway.holdMoves = Completer<void>();
moveFirstTwo('kana');
final moving = await until(
(s) => s.cards.where((each) => each.studysetId == 'kana').length == 2,
);
expect(fronts(moving), ['c', 'a', 'b']);
});
test('that fail to move are where they were again, and the failure '
'counted for the screen to say', () async {
start();
await until((s) => s.loaded && s.cards.length == 3);
final hold = cardGateway.holdMoves = Completer<void>();
cardGateway.failMoves = Exception('disk full');
moveFirstTwo('kana');
await until((s) => s.cards.length == 1);
hold.complete();
await until((s) => s.moveFailures == 1);
await until((s) => s.cards.length == 3);
expect(fronts(bloc.state), ['a', 'b', 'c']);
expect(await cards.cardsOf('kana'), isEmpty);
});
});
test('a studyset and those inside it are browsed together, in tree '
'order', () async {
await cards.create('japanese', card('top', 'x'));

View file

@ -135,12 +135,38 @@ class FakeCardGateway() implements CardGateway {
return revision.id;
}
/// When set, moving cards fails with it, none moved.
Exception? failMoves;
/// While set, moving cards waits for it to complete.
Completer<void>? holdMoves;
/// How many times cards were moved, however many at once.
int moves = 0;
@override
Future<void> updateStudysets(
String studysetId,
Map<String, String> positions,
) async {
await holdMoves?.future;
if (failMoves case final error?) {
throw error;
}
moves++;
for (final MapEntry(key: id, value: position) in positions.entries) {
_place(id, studysetId, position);
}
}
@override
Future<void> updateStudyset(
String cardId,
String studysetId,
String position,
) async {
) async => _place(cardId, studysetId, position);
void _place(String cardId, String studysetId, String position) {
if (_cards[cardId] case final card?) {
_cards[cardId] = (
studysetId: studysetId,