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
18 changed files with 388 additions and 66 deletions
|
|
@ -34,7 +34,7 @@ Only when the server cannot be started at all, fall back to the CLI in the serve
|
||||||
|
|
||||||
Tests need no Docker. `config/test.yaml` sets `database.dataPath`, so Serverpod starts and manages the test database (an embedded PostgreSQL) itself. Just run `dart test` in the server package.
|
Tests need no Docker. `config/test.yaml` sets `database.dataPath`, so Serverpod starts and manages the test database (an embedded PostgreSQL) itself. Just run `dart test` in the server package.
|
||||||
|
|
||||||
Tooling traps: rules of `leancode_lint`'s analyzer plugin are ignored with its prefix (`// ignore: leancode_lint/missing_equatable_props`); `dart fix --apply` moves `flutter_driver` into the app's `dependencies` (as `any`) on every run, so restore it as a dev dependency (`sdk: flutter`) afterwards; never `dart format` generated code, only the hand-written paths, as `tool/check.sh` does (the server's `test/integration/test_tools/` is generated too, so format test files one by one, never the whole `test/` folder); and never rebase or check out other commits while `serverpod start` runs: its watcher regenerates code for each intermediate state, leaves stale generated files behind and hot-reloads them. Stop it first, or work in a separate worktree.
|
Tooling traps: rules of `leancode_lint`'s analyzer plugin are ignored with its prefix (`// ignore: leancode_lint/missing_equatable_props`); `dart fix --apply` moves `flutter_driver` into the app's `dependencies` (as `any`) on every run, so restore it as a dev dependency (`sdk: flutter`) afterwards; never `dart format` generated code, only the hand-written paths, as `tool/check.sh` does (the server's `test/integration/test_tools/` is generated too, so format test files one by one, never the whole `test/` folder); a UUID table's `id` column takes only a set typed `Set<UuidValue>` in `inSet` and `notInSet` (`_uuids` in `sync_tag_gateway.dart`): it is nullable as declared, so a set literal in its place becomes a set of nullable ids, which Serverpod (v4.0.4) refuses at run time with a type error the fake gateways never meet (found 2026-10-17: taking a tag off anything had failed on devices since v0.1.0); and never rebase or check out other commits while `serverpod start` runs: its watcher regenerates code for each intermediate state, leaves stale generated files behind and hot-reloads them. Stop it first, or work in a separate worktree.
|
||||||
|
|
||||||
After a migration that adds or changes synced tables, restart `serverpod start` once it has applied it. The sync engine (checked with `serverpod_offline_sync` v0.0.8) reconciles its registry of synced tables when a server first uses the database, and if that changed the registry, every later session of that server rebuilds the projections of every space, one transaction each, until the server stops ([serverpod_offline_sync#150](https://github.com/marcelomendoncasoares/serverpod_offline_sync/issues/150), open). With the many people the tests leave in the development database, syncs then take seconds and the sync tests time out. The restarted server finds the registry as it left it. Deploys are affected too (found 2026-10-13: after v0.13.0's deploy every request took a minute or two and syncs failed, as the new server, not the maintenance run, reconciled the registry), so `deploy/redeploy.sh` starts the server once more after a deploy that brought a migration. Drop this note, and that restart, once #150 is fixed.
|
After a migration that adds or changes synced tables, restart `serverpod start` once it has applied it. The sync engine (checked with `serverpod_offline_sync` v0.0.8) reconciles its registry of synced tables when a server first uses the database, and if that changed the registry, every later session of that server rebuilds the projections of every space, one transaction each, until the server stops ([serverpod_offline_sync#150](https://github.com/marcelomendoncasoares/serverpod_offline_sync/issues/150), open). With the many people the tests leave in the development database, syncs then take seconds and the sync tests time out. The restarted server finds the registry as it left it. Deploys are affected too (found 2026-10-13: after v0.13.0's deploy every request took a minute or two and syncs failed, as the new server, not the maintenance run, reconciled the registry), so `deploy/redeploy.sh` starts the server once more after a deploy that brought a migration. Drop this note, and that restart, once #150 is fixed.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,16 +88,9 @@ class CardBrowserBloc(
|
||||||
for (final id in state.selected) id: event.tagIds,
|
for (final id in state.selected) id: event.tagIds,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
on<CardBrowserEventTagsRemoved>((event, emit) async {
|
on<CardBrowserEventTagsRemoved>(
|
||||||
for (final each in state.cards) {
|
(event, emit) => _tags.removeTags(state.selected, event.tagIds),
|
||||||
if (state.selected.contains(each.card.id)) {
|
);
|
||||||
final ids = {for (final tag in each.tags) tag.id};
|
|
||||||
if (ids.intersection(event.tagIds).isNotEmpty) {
|
|
||||||
await _tags.setTagsOf(each.card.id, ids.difference(event.tagIds));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
on<CardBrowserEventProgressReset>(
|
on<CardBrowserEventProgressReset>(
|
||||||
(event, emit) => _reviews.reset(state.selected, clock.now()),
|
(event, emit) => _reviews.reset(state.selected, clock.now()),
|
||||||
);
|
);
|
||||||
|
|
@ -110,7 +103,13 @@ class CardBrowserBloc(
|
||||||
|
|
||||||
/// What a search looks through in each card, kept while the card and
|
/// What a search looks through in each card, kept while the card and
|
||||||
/// its tags are the same: thousands of cards, a search at every letter.
|
/// its tags are the same: thousands of cards, a search at every letter.
|
||||||
final _searchTexts = <String, ({Card card, List<Tag> tags, String text})>{};
|
/// A card's content is the same while its heads are (ADR 0003), though a
|
||||||
|
/// card read again is another object.
|
||||||
|
final _searchTexts =
|
||||||
|
<String, ({Set<String> heads, List<Tag> tags, String text})>{};
|
||||||
|
|
||||||
|
/// The key A–Z orders each card by, kept while the card is the same.
|
||||||
|
final _alphabeticKeys = <String, ({Set<String> heads, String key})>{};
|
||||||
|
|
||||||
/// Whether the schedules are being worked out, and whether to again.
|
/// Whether the schedules are being worked out, and whether to again.
|
||||||
var _working = false;
|
var _working = false;
|
||||||
|
|
@ -258,6 +257,7 @@ class CardBrowserBloc(
|
||||||
/// [state] with the cards its filter picks, in its order.
|
/// [state] with the cards its filter picks, in its order.
|
||||||
CardBrowserState _shown(CardBrowserState state) {
|
CardBrowserState _shown(CardBrowserState state) {
|
||||||
final filter = state.filter;
|
final filter = state.filter;
|
||||||
|
final matches = queryMatcher(filter.query);
|
||||||
final now = clock.now();
|
final now = clock.now();
|
||||||
final startOfToday = startOfLocalDay(now);
|
final startOfToday = startOfLocalDay(now);
|
||||||
final picked = [
|
final picked = [
|
||||||
|
|
@ -275,23 +275,58 @@ class CardBrowserBloc(
|
||||||
startOfToday: startOfToday,
|
startOfToday: startOfToday,
|
||||||
),
|
),
|
||||||
)) &&
|
)) &&
|
||||||
matchesQuery(_searchTextOf(each), filter.query))
|
(matches == null || matches(_searchTextOf(each))))
|
||||||
each,
|
each,
|
||||||
];
|
];
|
||||||
return state.copyWith(shown: sortedCards(picked, state.sort));
|
return state.copyWith(
|
||||||
|
shown: sortedCards(
|
||||||
|
picked,
|
||||||
|
state.sort,
|
||||||
|
alphabeticKey: _alphabeticKeyOf,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _searchTextOf(BrowsedCard each) {
|
String _searchTextOf(BrowsedCard each) {
|
||||||
final known = _searchTexts[each.card.id];
|
final known = _searchTexts[each.card.id];
|
||||||
if (known != null &&
|
if (known != null &&
|
||||||
identical(known.card, each.card) &&
|
_sameHeads(known.heads, each.card.heads) &&
|
||||||
identical(known.tags, each.tags)) {
|
_sameTags(known.tags, each.tags)) {
|
||||||
return known.text;
|
return known.text;
|
||||||
}
|
}
|
||||||
final text = searchTextOf(each.card, each.tags);
|
final text = searchTextOf(each.card, each.tags);
|
||||||
_searchTexts[each.card.id] = (card: each.card, tags: each.tags, text: text);
|
_searchTexts[each.card.id] = (
|
||||||
|
heads: each.card.heads,
|
||||||
|
tags: each.tags,
|
||||||
|
text: text,
|
||||||
|
);
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _alphabeticKeyOf(Card card) {
|
||||||
|
final known = _alphabeticKeys[card.id];
|
||||||
|
if (known != null && _sameHeads(known.heads, card.heads)) {
|
||||||
|
return known.key;
|
||||||
|
}
|
||||||
|
final key = alphabeticKeyOf(card);
|
||||||
|
_alphabeticKeys[card.id] = (heads: card.heads, key: key);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _sameHeads(Set<String> a, Set<String> b) =>
|
||||||
|
a.length == b.length && a.containsAll(b);
|
||||||
|
|
||||||
|
static bool _sameTags(List<Tag> a, List<Tag> b) {
|
||||||
|
if (a.length != b.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (var i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] != b[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CardBrowserFilter _withQuery(CardBrowserFilter filter, String query) => (
|
CardBrowserFilter _withQuery(CardBrowserFilter filter, String query) => (
|
||||||
|
|
|
||||||
|
|
@ -196,18 +196,26 @@ final _bare = {
|
||||||
for (final char in accented.split('')) char: bare,
|
for (final char in accented.split('')) char: bare,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Whether a card whose [searchText] is [searchTextOf] holds [query]: as
|
/// What tells the cards holding [query] by their search text
|
||||||
/// typed (German's umlauts written out or not, see [searchableText]), or
|
/// ([searchTextOf]): as typed (German's umlauts written out or not, see
|
||||||
/// romaji typed for its kana (`taberu` finds たべる).
|
/// [searchableText]), or romaji typed for its kana (`taberu` finds たべる);
|
||||||
bool matchesQuery(String searchText, String query) {
|
/// null for a blank query, which every card matches, so no search text is
|
||||||
|
/// needed. The query is folded once, not once a card.
|
||||||
|
bool Function(String searchText)? queryMatcher(String query) {
|
||||||
final typed = searchFolded(query.trim());
|
final typed = searchFolded(query.trim());
|
||||||
if (typed.isEmpty) {
|
if (typed.isEmpty) {
|
||||||
return true;
|
return null;
|
||||||
}
|
}
|
||||||
return searchText.contains(typed) ||
|
final kana = romajiToHiragana(typed, finished: true);
|
||||||
searchText.contains(romajiToHiragana(typed, finished: true));
|
return (searchText) =>
|
||||||
|
searchText.contains(typed) || searchText.contains(kana);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a card whose [searchText] is [searchTextOf] holds [query], as
|
||||||
|
/// [queryMatcher] tells it.
|
||||||
|
bool matchesQuery(String searchText, String query) =>
|
||||||
|
queryMatcher(query)?.call(searchText) ?? true;
|
||||||
|
|
||||||
/// Whether [card] is in [state] at [now], the local day having begun at
|
/// Whether [card] is in [state] at [now], the local day having begun at
|
||||||
/// [startOfToday].
|
/// [startOfToday].
|
||||||
bool isInState(
|
bool isInState(
|
||||||
|
|
@ -230,16 +238,28 @@ bool isInState(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [cards], given in the studysets' own order, as [sort] orders them.
|
/// The key A–Z orders [card] by: its reading where it has one, else its
|
||||||
List<BrowsedCard> sortedCards(List<BrowsedCard> cards, CardBrowserSort sort) {
|
/// front, folded as [searchFolded] folds it.
|
||||||
String alphabetic(BrowsedCard each) {
|
String alphabeticKeyOf(Card card) {
|
||||||
final content = each.card.content;
|
final content = card.content;
|
||||||
return searchFolded(
|
return searchFolded(
|
||||||
content.reading.trim().isNotEmpty
|
content.reading.trim().isNotEmpty
|
||||||
? content.reading
|
? content.reading
|
||||||
: content.front.plainText.trim(),
|
: content.front.plainText.trim(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [cards], given in the studysets' own order, as [sort] orders them: A–Z
|
||||||
|
/// by [alphabeticKey], worked out once a card rather than for each
|
||||||
|
/// comparison (thousands of cards took hundreds of thousands).
|
||||||
|
List<BrowsedCard> sortedCards(
|
||||||
|
List<BrowsedCard> cards,
|
||||||
|
CardBrowserSort sort, {
|
||||||
|
String Function(Card card) alphabeticKey = alphabeticKeyOf,
|
||||||
|
}) {
|
||||||
|
Map<String, String> alphabetic() => {
|
||||||
|
for (final each in cards) each.card.id: alphabeticKey(each.card),
|
||||||
|
};
|
||||||
|
|
||||||
return switch (sort) {
|
return switch (sort) {
|
||||||
CardBrowserSort.manual => cards,
|
CardBrowserSort.manual => cards,
|
||||||
|
|
@ -250,13 +270,15 @@ List<BrowsedCard> sortedCards(List<BrowsedCard> cards, CardBrowserSort sort) {
|
||||||
CardBrowserSort.oldest => [
|
CardBrowserSort.oldest => [
|
||||||
...cards,
|
...cards,
|
||||||
]..sort((a, b) => a.card.id.compareTo(b.card.id)),
|
]..sort((a, b) => a.card.id.compareTo(b.card.id)),
|
||||||
CardBrowserSort.alphabetical =>
|
CardBrowserSort.alphabetical => switch (alphabetic()) {
|
||||||
[...cards]..sort(
|
final keys =>
|
||||||
(a, b) => switch (alphabetic(a).compareTo(alphabetic(b))) {
|
[...cards]..sort(
|
||||||
0 => a.card.id.compareTo(b.card.id),
|
(a, b) => switch (keys[a.card.id]!.compareTo(keys[b.card.id]!)) {
|
||||||
final order => order,
|
0 => a.card.id.compareTo(b.card.id),
|
||||||
},
|
final order => order,
|
||||||
),
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
CardBrowserSort.due =>
|
CardBrowserSort.due =>
|
||||||
[...cards]..sort(
|
[...cards]..sort(
|
||||||
(a, b) => switch ((a.schedule?.due, b.schedule?.due)) {
|
(a, b) => switch ((a.schedule?.due, b.schedule?.due)) {
|
||||||
|
|
|
||||||
|
|
@ -297,17 +297,21 @@ class ReleaseCopier(
|
||||||
await _tags.setTagsOfPath(pathId, await tagsOf(path.tags));
|
await _tags.setTagsOfPath(pathId, await tagsOf(path.tags));
|
||||||
}
|
}
|
||||||
step();
|
step();
|
||||||
for (final stepItem in _ordered(named.steps[path.id])) {
|
final steps = _ordered(named.steps[path.id]);
|
||||||
ids[stepItem.id] = (await _paths.createStep(
|
final stepIds = await _paths.createSteps(pathId, [
|
||||||
pathId,
|
for (final stepItem in steps)
|
||||||
stepItem.kind!,
|
(
|
||||||
origin == null
|
kind: stepItem.kind!,
|
||||||
? stepItem.content
|
content: origin == null
|
||||||
: stepItem.content.withField(
|
? stepItem.content
|
||||||
StepField.published,
|
: stepItem.content.withField(
|
||||||
origin(stepItem.id),
|
StepField.published,
|
||||||
),
|
origin(stepItem.id),
|
||||||
)).stepId;
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
for (final (i, stepItem) in steps.indexed) {
|
||||||
|
ids[stepItem.id] = stepIds[i];
|
||||||
step();
|
step();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,8 +83,8 @@ class PathListBloc(
|
||||||
final imported = await importer.store(
|
final imported = await importer.store(
|
||||||
_studysetId,
|
_studysetId,
|
||||||
path,
|
path,
|
||||||
onStored: () {
|
onStored: (parts) {
|
||||||
stored++;
|
stored += parts;
|
||||||
report();
|
report();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ typedef PathRow = ({
|
||||||
/// A step's row as the device's database stores it.
|
/// A step's row as the device's database stores it.
|
||||||
typedef StepRow = ({String id, String pathId, String position, String kind});
|
typedef StepRow = ({String id, String pathId, String position, String kind});
|
||||||
|
|
||||||
|
/// A new step's row, with its first revision's payload.
|
||||||
|
typedef NewStepRow = ({String position, String kind, ByteData payload});
|
||||||
|
|
||||||
/// A step revision's row as the device's database stores it.
|
/// A step revision's row as the device's database stores it.
|
||||||
typedef StepRevisionRow = ({
|
typedef StepRevisionRow = ({
|
||||||
String id,
|
String id,
|
||||||
|
|
@ -56,6 +59,10 @@ abstract interface class LearningPathGateway() {
|
||||||
required ByteData payload,
|
required ByteData payload,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Stores new steps of [pathId] with their first revisions, at once;
|
||||||
|
/// returns their ids in the same order.
|
||||||
|
Future<List<String>> insertSteps(String pathId, List<NewStepRow> steps);
|
||||||
|
|
||||||
/// Appends a revision to the step [stepId] that replaces [parents];
|
/// Appends a revision to the step [stepId] that replaces [parents];
|
||||||
/// returns its id.
|
/// returns its id.
|
||||||
Future<String> insertRevision({
|
Future<String> insertRevision({
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,36 @@ class LearningPathRepository(final LearningPathGateway _gateway) {
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates steps of the kinds and with the contents [steps] at the end of
|
||||||
|
/// [pathId], in order, at once, as one change (an import's or a copy's,
|
||||||
|
/// often dozens; one change each had everything following the paths read
|
||||||
|
/// again after every step); returns their ids in the same order.
|
||||||
|
Future<List<String>> createSteps(
|
||||||
|
String pathId,
|
||||||
|
List<({StepKind kind, StepContent content})> steps,
|
||||||
|
) async {
|
||||||
|
if (steps.isEmpty) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
final positions = FractionalIndexing.generateNKeysBetween(
|
||||||
|
lastPosition([
|
||||||
|
for (final row in await _gateway.steps({pathId})) row.position,
|
||||||
|
]),
|
||||||
|
null,
|
||||||
|
steps.length,
|
||||||
|
);
|
||||||
|
final ids = await _gateway.insertSteps(pathId, [
|
||||||
|
for (final (i, (:kind, :content)) in steps.indexed)
|
||||||
|
(
|
||||||
|
position: positions[i],
|
||||||
|
kind: kind.value,
|
||||||
|
payload: encodeStepPayload(content.normalized),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
_changes.add(null);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
/// Stores [content] as the next revision of the step [stepId], replacing
|
/// Stores [content] as the next revision of the step [stepId], replacing
|
||||||
/// [basedOn]: the revisions the edit started from (all current heads to
|
/// [basedOn]: the revisions the edit started from (all current heads to
|
||||||
/// settle a conflict), so another device's edit merges with it (ADR 0003).
|
/// settle a conflict), so another device's edit merges with it (ADR 0003).
|
||||||
|
|
|
||||||
|
|
@ -119,18 +119,17 @@ class PathImporter(final LearningPathRepository _paths) {
|
||||||
store(studysetId, parsePathImport(source));
|
store(studysetId, parsePathImport(source));
|
||||||
|
|
||||||
/// Stores [parsed], a path read already, at the end of [studysetId], with
|
/// Stores [parsed], a path read already, at the end of [studysetId], with
|
||||||
/// its steps; [onStored] after the path and after each step.
|
/// its steps; [onStored] with how many of its parts were stored, after
|
||||||
|
/// the path and after its steps, which are stored together.
|
||||||
Future<ImportedPath> store(
|
Future<ImportedPath> store(
|
||||||
String studysetId,
|
String studysetId,
|
||||||
ParsedPath parsed, {
|
ParsedPath parsed, {
|
||||||
void Function()? onStored,
|
void Function(int parts)? onStored,
|
||||||
}) async {
|
}) async {
|
||||||
final pathId = await _paths.createPath(studysetId, parsed.content);
|
final pathId = await _paths.createPath(studysetId, parsed.content);
|
||||||
onStored?.call();
|
onStored?.call(1);
|
||||||
for (final (:kind, :content) in parsed.steps) {
|
await _paths.createSteps(pathId, parsed.steps);
|
||||||
await _paths.createStep(pathId, kind, content);
|
onStored?.call(parsed.steps.length);
|
||||||
onStored?.call();
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
pathId: pathId,
|
pathId: pathId,
|
||||||
steps: parsed.steps.length,
|
steps: parsed.steps.length,
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,38 @@ class SyncLearningPathGateway(
|
||||||
return (stepId: step.id!.uuid, revisionId: revisionId);
|
return (stepId: step.id!.uuid, revisionId: revisionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<String>> insertSteps(
|
||||||
|
String pathId,
|
||||||
|
List<NewStepRow> steps,
|
||||||
|
) async {
|
||||||
|
if (steps.isEmpty) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
final rows = await protocol.LearningPathStep.db.insert(_db, [
|
||||||
|
for (final step in steps)
|
||||||
|
protocol.LearningPathStep(
|
||||||
|
pathId: _uuid(pathId),
|
||||||
|
position: step.position,
|
||||||
|
kind: step.kind,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
// The batch's positions are distinct, so they tell the stored rows apart
|
||||||
|
// whatever order they come back in.
|
||||||
|
final idOf = {for (final row in rows) row.position: row.id!};
|
||||||
|
await protocol.LearningPathStepRevision.db.insert(_db, [
|
||||||
|
for (final step in steps)
|
||||||
|
protocol.LearningPathStepRevision(
|
||||||
|
stepId: idOf[step.position]!,
|
||||||
|
parents: const [],
|
||||||
|
authorId: _uuid(_authorId),
|
||||||
|
payload: step.payload,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
_sync.requestSync();
|
||||||
|
return [for (final step in steps) idOf[step.position]!.uuid];
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String> insertRevision({
|
Future<String> insertRevision({
|
||||||
required String stepId,
|
required String stepId,
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ class SyncTagGateway(
|
||||||
}
|
}
|
||||||
await protocol.CardTag.db.deleteWhere(
|
await protocol.CardTag.db.deleteWhere(
|
||||||
_db,
|
_db,
|
||||||
where: (t) => t.id.inSet({for (final id in ids) _uuid(id)}),
|
where: (t) => t.id.inSet(_uuids(ids)),
|
||||||
);
|
);
|
||||||
_sync.requestSync();
|
_sync.requestSync();
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +148,7 @@ class SyncTagGateway(
|
||||||
}
|
}
|
||||||
await protocol.NoteTag.db.deleteWhere(
|
await protocol.NoteTag.db.deleteWhere(
|
||||||
_db,
|
_db,
|
||||||
where: (t) => t.id.inSet({for (final id in ids) _uuid(id)}),
|
where: (t) => t.id.inSet(_uuids(ids)),
|
||||||
);
|
);
|
||||||
_sync.requestSync();
|
_sync.requestSync();
|
||||||
}
|
}
|
||||||
|
|
@ -178,7 +178,7 @@ class SyncTagGateway(
|
||||||
}
|
}
|
||||||
await protocol.LearningPathTag.db.deleteWhere(
|
await protocol.LearningPathTag.db.deleteWhere(
|
||||||
_db,
|
_db,
|
||||||
where: (t) => t.id.inSet({for (final id in ids) _uuid(id)}),
|
where: (t) => t.id.inSet(_uuids(ids)),
|
||||||
);
|
);
|
||||||
_sync.requestSync();
|
_sync.requestSync();
|
||||||
}
|
}
|
||||||
|
|
@ -188,4 +188,12 @@ class SyncTagGateway(
|
||||||
|
|
||||||
/// The id [id] as the database stores it.
|
/// The id [id] as the database stores it.
|
||||||
protocol.UuidValue _uuid(String id) => protocol.UuidValue.withValidation(id);
|
protocol.UuidValue _uuid(String id) => protocol.UuidValue.withValidation(id);
|
||||||
|
|
||||||
|
/// [ids] as a set the `id` column's `inSet` takes. Typed explicitly: the
|
||||||
|
/// column is nullable as declared, so a literal in its place would be a
|
||||||
|
/// set of nullable ids, which Serverpod (v4.0.4) refuses at run time
|
||||||
|
/// with a type error.
|
||||||
|
Set<protocol.UuidValue> _uuids(Set<String> ids) => {
|
||||||
|
for (final id in ids) _uuid(id),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,20 @@ class TagRepository(
|
||||||
await _refresh();
|
await _refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Takes the tags [tagIds] from the cards [cardIds] at once, as one
|
||||||
|
/// change: a browser's hundreds of cards, where taking them from each
|
||||||
|
/// card on its own read every card's tags three times a card.
|
||||||
|
Future<void> removeTags(Set<String> cardIds, Set<String> tagIds) async {
|
||||||
|
await _serially(() async {
|
||||||
|
await _gateway.deleteCardTags({
|
||||||
|
for (final row in await _gateway.cardTags())
|
||||||
|
if (cardIds.contains(row.cardId) && tagIds.contains(row.tagId))
|
||||||
|
row.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await _refresh();
|
||||||
|
}
|
||||||
|
|
||||||
/// Deletes the tag [id] from every card, note and path and then itself;
|
/// Deletes the tag [id] from every card, note and path and then itself;
|
||||||
/// returns what
|
/// returns what
|
||||||
/// [restore] needs to undo that. Its name is blanked first: the sync
|
/// [restore] needs to undo that. Its name is blanked first: the sync
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
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_browser.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('a search finds words of any language as typed on any keyboard: '
|
test('a search finds words of any language as typed on any keyboard: '
|
||||||
|
|
@ -25,6 +28,52 @@ void main() {
|
||||||
expect(finds('god', 'goed'), isFalse);
|
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é '
|
test('A–Z compares letters as their bare ones: Ärger among the a, xué '
|
||||||
'beside xue', () {
|
'beside xue', () {
|
||||||
expect(searchFolded('Ärger'), 'arger');
|
expect(searchFolded('Ärger'), 'arger');
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,8 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('says how far an import has come: the path being stored, which of '
|
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);
|
await until((s) => s.loaded);
|
||||||
final progress = <PathImportProgress?>[];
|
final progress = <PathImportProgress?>[];
|
||||||
final listening = bloc.stream.listen(
|
final listening = bloc.stream.listen(
|
||||||
|
|
@ -205,7 +206,7 @@ void main() {
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
{for (final p in reported) p.done},
|
{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.importProgress, isNull);
|
||||||
expect(bloc.state.importing, isFalse);
|
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 '
|
test('an edit saved on what it started from merges with another '
|
||||||
"device's", () async {
|
"device's", () async {
|
||||||
final created = await paths.createStep(
|
final created = await paths.createStep(
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,27 @@ void main() {
|
||||||
expect(tags.cardsWith(verb), isEmpty);
|
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 {
|
test('renames and recolours a tag', () async {
|
||||||
final id = await repository.create('verb');
|
final id = await repository.create('verb');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,20 @@ class FakeLearningPathGateway() implements LearningPathGateway {
|
||||||
return (stepId: id, revisionId: revisionId);
|
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
|
@override
|
||||||
Future<String> insertRevision({
|
Future<String> insertRevision({
|
||||||
required String stepId,
|
required String stepId,
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,31 @@ void main() {
|
||||||
expect((await laptop.tags.read()).pathsWith(lesson), {path});
|
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 "
|
test("a step's title changed on one device and its exercise on another "
|
||||||
'both survive', () async {
|
'both survive', () async {
|
||||||
final phone = await device('phone');
|
final phone = await device('phone');
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,33 @@ void main() {
|
||||||
expect((await laptop.tags.read()).cardsWith(animal), isEmpty);
|
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 '
|
test('settings made on two devices offline end up as one row holding '
|
||||||
'both', () async {
|
'both', () async {
|
||||||
final phone = await device('phone');
|
final phone = await device('phone');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue