fix(explore): copies take the author's later changes to exam plans

Taking a newer release kept every exam plan of a copy as it was. Copying
names a plan's topics by the copy's own studysets, so the copy's plans
never equalled the release it last took, which names the author's, and
were kept as the learner's change. Releases also hold their keys sorted
at every level while the app writes its own order, so the comparison of
a field failed on the order alone: a plan with topics and tags, and the
description of every copy, whose rich text nests maps, never took a
change either.

Now the plans of both releases name the copy's studysets before they
are compared (topics new in the release are named once their studysets
are made, as before), and fields compare whatever the order of their
keys. A plan the learner changed stays theirs. The updater's tests now
build releases with sorted keys, as the server writes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-14 17:51:12 +02:00
commit 57240f01f3
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -41,14 +41,27 @@ Map<String, Object?> json(ByteData payload) =>
CardContent card(String front, String back) =>
CardContent(front: RichContent.plain(front), back: RichContent.plain(back));
/// [json] with the keys of every map in it sorted, as the server writes a
/// release's files.
Map<String, Object?> sortedKeys(Map<String, Object?> json) => {
for (final key in json.keys.toList()..sort()) key: _sorted(json[key]),
};
Object? _sorted(Object? json) => switch (json) {
final Map<String, Object?> map => sortedKeys(map),
final List<Object?> list => [for (final value in list) _sorted(value)],
_ => json,
};
final book = Uint8List.fromList(utf8.encode('%PDF-1.7 a book'));
final bookHash = sha256.convert(book).toString();
const bookAsset = '0192aaaa-0000-7000-8000-000000000002';
/// Release [version] of a Japanese studyset, described as [description],
/// planning for [examPlans], with [cards] (by id: front, back and tags) in
/// it, a Kanji studyset in it holding [kanjiCards], and a PDF titled
/// [bookTitle].
/// it, a Kanji studyset in it holding [kanjiCards], a Grammar studyset
/// [withGrammar], and a PDF titled [bookTitle]; its files' keys sorted, as
/// the server writes them.
PublishedRelease japanese(
int version, {
String description = 'From N5 on.',
@ -56,6 +69,7 @@ PublishedRelease japanese(
Map<String, (String, String, List<String>)> cards = const {},
Map<String, (String, String, List<String>)> kanjiCards = const {},
bool withKanji = true,
bool withGrammar = false,
String bookTitle = 'A book',
}) {
Map<String, Object?> cardFile(
@ -75,58 +89,70 @@ PublishedRelease japanese(
),
),
};
final items = <String, Map<String, Object?>>{
'studyset.json': {
'id': 'japanese',
'payload': json(
encodeStudysetPayload(
StudysetContent(
title: 'Japanese',
description: RichContent.plain(description),
license: 'CC-BY-SA-4.0',
examPlans: examPlans,
),
),
),
},
for (final (i, MapEntry(key: id, value: card)) in cards.entries.indexed)
'cards/$id.json': cardFile(id, card, i),
if (withKanji) ...{
'kanji/studyset.json': {
'id': 'kanji',
'position': 'a1',
'payload': json(
encodeStudysetPayload(StudysetContent.titled('Kanji')),
),
},
for (final (i, MapEntry(key: id, value: card))
in kanjiCards.entries.indexed)
'kanji/cards/$id.json': cardFile(id, card, i),
},
if (withGrammar)
'grammar/studyset.json': {
'id': 'grammar',
'position': 'a2',
'payload': json(
encodeStudysetPayload(StudysetContent.titled('Grammar')),
),
},
'pdfs/book.json': {
'id': 'book',
'position': 'a0',
'payload': json(
encodePdfSourcePayload(
PdfSourceContent(
title: bookTitle,
fileName: 'book.pdf',
assetId: bookAsset,
),
),
),
},
'files.json': {
bookAsset: {
'sha256': bookHash,
'mimeType': 'application/pdf',
'size': book.length,
'payload': {'schemaVersion': 1, 'fileName': 'book.pdf'},
},
},
};
return PublishedRelease(
publicId: 'publication-1',
version: version,
items: {
'studyset.json': {
'id': 'japanese',
'payload': json(
encodeStudysetPayload(
StudysetContent(
title: 'Japanese',
description: RichContent.plain(description),
license: 'CC-BY-SA-4.0',
examPlans: examPlans,
),
),
),
},
for (final (i, MapEntry(key: id, value: card)) in cards.entries.indexed)
'cards/$id.json': cardFile(id, card, i),
if (withKanji) ...{
'kanji/studyset.json': {
'id': 'kanji',
'position': 'a1',
'payload': json(
encodeStudysetPayload(StudysetContent.titled('Kanji')),
),
},
for (final (i, MapEntry(key: id, value: card))
in kanjiCards.entries.indexed)
'kanji/cards/$id.json': cardFile(id, card, i),
},
'pdfs/book.json': {
'id': 'book',
'position': 'a0',
'payload': json(
encodePdfSourcePayload(
PdfSourceContent(
title: bookTitle,
fileName: 'book.pdf',
assetId: bookAsset,
),
),
),
},
'files.json': {
bookAsset: {
'sha256': bookHash,
'mimeType': 'application/pdf',
'size': book.length,
'payload': {'schemaVersion': 1, 'fileName': 'book.pdf'},
},
},
for (final MapEntry(key: path, value: item) in items.entries)
path: sortedKeys(item),
},
);
}
@ -358,6 +384,93 @@ void main() {
expect(now.content.exam, exam);
});
test("the author's later changes to an exam plan come in, a topic new in "
"the release named by the copy's studyset", () async {
final v1 = japanese(
1,
examPlans: const [
ExamPlan(
id: 'kanji-test',
name: 'Kanji test',
topics: ['kanji'],
tags: ['JLPT-N5'],
),
],
);
final root = await copyOf(v1);
await updater.take(
root,
v1,
japanese(
2,
withGrammar: true,
examPlans: const [
ExamPlan(
id: 'kanji-test',
name: 'Kanji and grammar test',
topics: ['kanji', 'grammar'],
tags: ['JLPT-N5'],
),
],
),
fileOf: fileOf,
);
final all = await studysets.all();
String titled(String title) =>
all.singleWhere((s) => s.content.title == title).id;
expect(all.singleWhere((s) => s.id == root).content.examPlans, [
ExamPlan(
id: 'kanji-test',
name: 'Kanji and grammar test',
topics: [titled('Kanji'), titled('Grammar')],
tags: const ['JLPT-N5'],
),
]);
});
test("the learner's own change to an exam plan stays theirs", () async {
const plan = ExamPlan(
id: 'kanji-test',
name: 'Kanji test',
topics: ['kanji'],
tags: ['JLPT-N5'],
);
final v1 = japanese(1, examPlans: const [plan]);
final root = await copyOf(v1);
final copy = (await studysets.all()).singleWhere((s) => s.id == root);
final mine = ExamPlan(
id: 'kanji-test',
name: 'My kanji test',
topics: copy.content.examPlans.single.topics,
tags: const ['JLPT-N5'],
);
await studysets.updateContent(root, copy.content.withExamPlans([mine]));
await updater.take(
root,
v1,
japanese(
2,
description: 'From N5 to N3.',
examPlans: const [
ExamPlan(
id: 'kanji-test',
name: 'Kanji exam',
topics: ['kanji'],
tags: ['JLPT-N5'],
),
],
),
fileOf: fileOf,
);
final now = (await studysets.all()).singleWhere((s) => s.id == root);
expect(now.content.examPlans, [mine]);
expect(now.content.description.plainText, 'From N5 to N3.');
});
test("a studyset's or PDF's fields the learner changed stay theirs; the "
"copy says it holds the newer release; files it has aren't fetched "
'again', () async {