perf: a path's steps, untagging and the card browser in bulk; fix untagging
A path's steps are now made in one batch, as one change (LearningPathRepository.createSteps), for the path import and the release copier: each step's change had everything following the paths read again. On a real library, 60 steps took 3 s, now 0.4 s. An import's progress moves per path with its steps. The card browser takes tags off the chosen cards at once (TagRepository.removeTags) instead of reading every card's tags three times a card. It keeps each card's search text and A-Z key while the card's heads and tags are the same, builds no search text without a search, and works out each A-Z key once instead of at every comparison (8,067 cards: 250,870 keys and 426 ms, now 8,067 and 94 ms). Fix: SyncTagGateway's deletes passed a set literal to the id column's inSet, which Serverpod refuses at run time, as the column is nullable as declared. Taking a tag off a card, note or path has failed on devices since v0.1.0; the fakes never met it. The sync test now takes tags off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
bbc357f3ef
commit
ed84b47192
7 changed files with 388 additions and 66 deletions
|
|
@ -1,5 +1,8 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_browser.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
|
||||
void main() {
|
||||
test('a search finds words of any language as typed on any keyboard: '
|
||||
|
|
@ -25,6 +28,52 @@ void main() {
|
|||
expect(finds('god', 'goed'), isFalse);
|
||||
});
|
||||
|
||||
test('a blank search needs no search text: every card matches', () {
|
||||
expect(queryMatcher(' '), isNull);
|
||||
expect(queryMatcher('neko')?.call('neko\nねこ'), isTrue);
|
||||
});
|
||||
|
||||
test("A–Z works each card's key out once, not at every comparison", () {
|
||||
BrowsedCard browsed(String front) => (
|
||||
card: Card(
|
||||
id: 'card-$front',
|
||||
position: 'a0',
|
||||
content: CardContent(
|
||||
front: RichContent.plain(front),
|
||||
back: RichContent.empty(),
|
||||
),
|
||||
heads: const {},
|
||||
),
|
||||
studysetId: 'japanese',
|
||||
tags: const [],
|
||||
schedule: null,
|
||||
);
|
||||
var worked = 0;
|
||||
|
||||
final sorted = sortedCards(
|
||||
[
|
||||
for (final front in ['ね', 'a', 'Ärger', 'c', 'b']) browsed(front),
|
||||
],
|
||||
CardBrowserSort.alphabetical,
|
||||
alphabeticKey: (card) {
|
||||
worked++;
|
||||
return alphabeticKeyOf(card);
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
[for (final each in sorted) each.card.content.front.plainText],
|
||||
[
|
||||
'a',
|
||||
'Ärger',
|
||||
'b',
|
||||
'c',
|
||||
'ね',
|
||||
],
|
||||
);
|
||||
expect(worked, 5);
|
||||
});
|
||||
|
||||
test('A–Z compares letters as their bare ones: Ärger among the a, xué '
|
||||
'beside xue', () {
|
||||
expect(searchFolded('Ärger'), 'arger');
|
||||
|
|
|
|||
|
|
@ -176,7 +176,8 @@ void main() {
|
|||
});
|
||||
|
||||
test('says how far an import has come: the path being stored, which of '
|
||||
'how many, and the share stored, path and steps alike', () async {
|
||||
'how many, and the share stored, path and steps alike, the steps of '
|
||||
'a path stored together', () async {
|
||||
await until((s) => s.loaded);
|
||||
final progress = <PathImportProgress?>[];
|
||||
final listening = bloc.stream.listen(
|
||||
|
|
@ -205,7 +206,7 @@ void main() {
|
|||
);
|
||||
expect(
|
||||
{for (final p in reported) p.done},
|
||||
{0.0, 1 / 6, 2 / 6, 3 / 6, 4 / 6, 5 / 6, 1.0},
|
||||
{0.0, 1 / 6, 3 / 6, 4 / 6, 1.0},
|
||||
);
|
||||
expect(bloc.state.importProgress, isNull);
|
||||
expect(bloc.state.importing, isFalse);
|
||||
|
|
|
|||
|
|
@ -153,6 +153,40 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('steps made together go after the others in their order, as one '
|
||||
'change', () async {
|
||||
await paths.createStep(
|
||||
path,
|
||||
StepKind.info,
|
||||
titled(StepKind.info, 'First'),
|
||||
);
|
||||
var changes = 0;
|
||||
final counting = paths.changes.listen((_) => changes++);
|
||||
addTearDown(counting.cancel);
|
||||
|
||||
final ids = await paths.createSteps(path, [
|
||||
(kind: StepKind.info, content: titled(StepKind.info, 'Read')),
|
||||
(
|
||||
kind: StepKind.singleChoice,
|
||||
content: titled(StepKind.singleChoice, 'Choose'),
|
||||
),
|
||||
]);
|
||||
await pumpEventQueue();
|
||||
|
||||
final steps = await paths.stepsOf(path);
|
||||
expect(
|
||||
[for (final s in steps) s.content.title],
|
||||
[
|
||||
'First',
|
||||
'Read',
|
||||
'Choose',
|
||||
],
|
||||
);
|
||||
expect([for (final s in steps.skip(1)) s.id], ids);
|
||||
expect(steps.last.kind, StepKind.singleChoice);
|
||||
expect(changes, 1);
|
||||
});
|
||||
|
||||
test('an edit saved on what it started from merges with another '
|
||||
"device's", () async {
|
||||
final created = await paths.createStep(
|
||||
|
|
|
|||
|
|
@ -262,6 +262,27 @@ void main() {
|
|||
expect(tags.cardsWith(verb), isEmpty);
|
||||
});
|
||||
|
||||
test('takes tags from many cards at once, leaving their other tags and '
|
||||
"the cards not named, reading the cards' tags once", () async {
|
||||
final verb = await repository.create('verb');
|
||||
final common = await repository.create('common');
|
||||
await repository.addTags({
|
||||
for (final card in ['card-1', 'card-2', 'card-3']) card: {verb, common},
|
||||
});
|
||||
final following = repository.watch().listen((_) {});
|
||||
addTearDown(following.cancel);
|
||||
await pumpEventQueue();
|
||||
final reads = gateway.cardTagReads;
|
||||
|
||||
await repository.removeTags({'card-1', 'card-2'}, {verb});
|
||||
|
||||
// Once to find what to take, once to hand everyone following the tags.
|
||||
expect(gateway.cardTagReads - reads, 2);
|
||||
final tags = await repository.read();
|
||||
expect(tags.cardsWith(verb), {'card-3'});
|
||||
expect(tags.cardsWith(common), {'card-1', 'card-2', 'card-3'});
|
||||
});
|
||||
|
||||
test('renames and recolours a tag', () async {
|
||||
final id = await repository.create('verb');
|
||||
|
||||
|
|
|
|||
|
|
@ -133,6 +133,20 @@ class FakeLearningPathGateway() implements LearningPathGateway {
|
|||
return (stepId: id, revisionId: revisionId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> insertSteps(
|
||||
String pathId,
|
||||
List<NewStepRow> steps,
|
||||
) async => [
|
||||
for (final step in steps)
|
||||
(await insertStep(
|
||||
pathId: pathId,
|
||||
position: step.position,
|
||||
kind: step.kind,
|
||||
payload: step.payload,
|
||||
)).stepId,
|
||||
];
|
||||
|
||||
@override
|
||||
Future<String> insertRevision({
|
||||
required String stepId,
|
||||
|
|
|
|||
|
|
@ -122,6 +122,31 @@ void main() {
|
|||
expect((await laptop.tags.read()).pathsWith(lesson), {path});
|
||||
});
|
||||
|
||||
test('steps made together, as an import makes them, reach the other '
|
||||
'device in their order', () async {
|
||||
final phone = await device('phone');
|
||||
final laptop = await device('laptop');
|
||||
final studyset = await phone.studysets.create(
|
||||
StudysetContent.titled('Japanese'),
|
||||
);
|
||||
final path = await phone.paths.createPath(
|
||||
studyset,
|
||||
LearningPathContent.titled('Particles'),
|
||||
);
|
||||
final ids = await phone.paths.createSteps(path, [
|
||||
(kind: StepKind.info, content: step(StepKind.info, 'は marks the topic')),
|
||||
(
|
||||
kind: StepKind.singleChoice,
|
||||
content: step(StepKind.singleChoice, 'Which marks the topic?', choice),
|
||||
),
|
||||
]);
|
||||
await syncBoth(phone, laptop);
|
||||
|
||||
final steps = await laptop.paths.stepsOf(path);
|
||||
expect([for (final s in steps) s.id], ids);
|
||||
expect(steps.last.content.exercise, choice);
|
||||
});
|
||||
|
||||
test("a step's title changed on one device and its exercise on another "
|
||||
'both survive', () async {
|
||||
final phone = await device('phone');
|
||||
|
|
|
|||
|
|
@ -117,6 +117,33 @@ void main() {
|
|||
expect((await laptop.tags.read()).cardsWith(animal), isEmpty);
|
||||
});
|
||||
|
||||
test('a tag taken off cards, one at a time and many at once, leaves the '
|
||||
'other devices too', () async {
|
||||
final phone = await device('phone');
|
||||
final laptop = await device('laptop');
|
||||
final studyset = await phone.studysets.create(
|
||||
StudysetContent.titled('Japanese'),
|
||||
);
|
||||
final neko = await phone.cards.create(studyset, empty);
|
||||
final inu = await phone.cards.create(studyset, empty);
|
||||
final tori = await phone.cards.create(studyset, empty);
|
||||
// A name of its own: the person's tags from the tests before sync too.
|
||||
final pet = await phone.tags.create('pet');
|
||||
await phone.tags.addTags({
|
||||
for (final card in [neko, inu, tori]) card: {pet},
|
||||
});
|
||||
await phone.sync();
|
||||
await laptop.sync();
|
||||
expect((await laptop.tags.read()).cardsWith(pet), {neko, inu, tori});
|
||||
|
||||
await phone.tags.setTagsOf(neko, {});
|
||||
await phone.tags.removeTags({inu, tori}, {pet});
|
||||
await phone.sync();
|
||||
await laptop.sync();
|
||||
|
||||
expect((await laptop.tags.read()).cardsWith(pet), isEmpty);
|
||||
});
|
||||
|
||||
test('settings made on two devices offline end up as one row holding '
|
||||
'both', () async {
|
||||
final phone = await device('phone');
|
||||
|
|
|
|||
Loading…
Reference in a new issue