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
2 changed files with 207 additions and 51 deletions

View file

@ -183,6 +183,13 @@ class CopyUpdater(
} }
// Studysets: the top one always, to say which release it holds now. // Studysets: the top one always, to say which release it holds now.
// Their exam plans' topics are read as the copy names them, so plans
// the learner left as they came compare equal; topics new in the
// release are named once their studysets are made, below.
final copied = {
for (final MapEntry(key: item, value: studyset) in copy.studysets.entries)
item: studyset.id,
};
for (final entry in diff.changed.where(_is(ReleaseKind.studyset))) { for (final entry in diff.changed.where(_is(ReleaseKind.studyset))) {
final studyset = copy.studysets[entry.id]!; final studyset = copy.studysets[entry.id]!;
await _studysets.updateContent( await _studysets.updateContent(
@ -191,8 +198,8 @@ class CopyUpdater(
decodeStudysetPayload( decodeStudysetPayload(
_bytes( _bytes(
_threeWay( _threeWay(
owned(diff.base[entry.id]!.payload), _withTopicsOf(owned(diff.base[entry.id]!.payload), copied),
owned(entry.payload), _withTopicsOf(owned(entry.payload), copied),
_json(encodeStudysetPayload(studyset.content)), _json(encodeStudysetPayload(studyset.content)),
), ),
), ),
@ -570,7 +577,8 @@ class CopyUpdater(
/// What the learner's [mine] becomes with the release's [newer] taken /// What the learner's [mine] becomes with the release's [newer] taken
/// from [base], each field (a key of the payload) on its own: one the /// from [base], each field (a key of the payload) on its own: one the
/// learner left as [base] had it takes [newer]'s, one they changed stays /// learner left as [base] had it takes [newer]'s, one they changed stays
/// theirs. The schema stays the app's. /// theirs. The schema stays the app's. Fields compare whatever the order
/// of their keys: a release keeps them sorted, the app writes its own.
static Map<String, Object?> _threeWay( static Map<String, Object?> _threeWay(
Map<String, Object?> base, Map<String, Object?> base,
Map<String, Object?> newer, Map<String, Object?> newer,
@ -579,12 +587,47 @@ class CopyUpdater(
for (final key in {...base.keys, ...newer.keys, ...mine.keys}) for (final key in {...base.keys, ...newer.keys, ...mine.keys})
if (key == 'schemaVersion') if (key == 'schemaVersion')
key: mine[key] key: mine[key]
else if (jsonEncode(mine[key]) == jsonEncode(base[key])) ...{ else if (jsonEncode(_sorted(mine[key])) ==
jsonEncode(_sorted(base[key]))) ...{
if (newer.containsKey(key)) key: newer[key], if (newer.containsKey(key)) key: newer[key],
} else if (mine.containsKey(key)) } else if (mine.containsKey(key))
key: mine[key], key: mine[key],
}; };
/// [json] with the keys of every map in it sorted.
static Object? _sorted(Object? json) => switch (json) {
final Map<String, Object?> map => {
for (final key in map.keys.toList()..sort()) key: _sorted(map[key]),
},
final List<Object?> list => [for (final value in list) _sorted(value)],
_ => json,
};
/// A studyset's [payload] with the topics of its exam plans named by the
/// studysets [copied] gives for them; topics it doesn't know stay.
static Map<String, Object?> _withTopicsOf(
Map<String, Object?> payload,
Map<String, String> copied,
) => switch (payload['examPlans']) {
final List<Object?> plans => {
...payload,
'examPlans': [
for (final plan in plans)
if (plan case <String, Object?>{'topics': final List<Object?> topics})
{
...plan,
'topics': [
for (final topic in topics)
if (topic is String) copied[topic] ?? topic else topic,
],
}
else
plan,
],
},
_ => payload,
};
static Map<String, Object?> _json(ByteData payload) => static Map<String, Object?> _json(ByteData payload) =>
jsonDecode(utf8.decode(Uint8List.sublistView(payload))) jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
as Map<String, Object?>; as Map<String, Object?>;

View file

@ -41,14 +41,27 @@ Map<String, Object?> json(ByteData payload) =>
CardContent card(String front, String back) => CardContent card(String front, String back) =>
CardContent(front: RichContent.plain(front), back: RichContent.plain(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 book = Uint8List.fromList(utf8.encode('%PDF-1.7 a book'));
final bookHash = sha256.convert(book).toString(); final bookHash = sha256.convert(book).toString();
const bookAsset = '0192aaaa-0000-7000-8000-000000000002'; const bookAsset = '0192aaaa-0000-7000-8000-000000000002';
/// Release [version] of a Japanese studyset, described as [description], /// Release [version] of a Japanese studyset, described as [description],
/// planning for [examPlans], with [cards] (by id: front, back and tags) in /// planning for [examPlans], with [cards] (by id: front, back and tags) in
/// it, a Kanji studyset in it holding [kanjiCards], and a PDF titled /// it, a Kanji studyset in it holding [kanjiCards], a Grammar studyset
/// [bookTitle]. /// [withGrammar], and a PDF titled [bookTitle]; its files' keys sorted, as
/// the server writes them.
PublishedRelease japanese( PublishedRelease japanese(
int version, { int version, {
String description = 'From N5 on.', 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>)> cards = const {},
Map<String, (String, String, List<String>)> kanjiCards = const {}, Map<String, (String, String, List<String>)> kanjiCards = const {},
bool withKanji = true, bool withKanji = true,
bool withGrammar = false,
String bookTitle = 'A book', String bookTitle = 'A book',
}) { }) {
Map<String, Object?> cardFile( 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( return PublishedRelease(
publicId: 'publication-1', publicId: 'publication-1',
version: version, version: version,
items: { items: {
'studyset.json': { for (final MapEntry(key: path, value: item) in items.entries)
'id': 'japanese', path: sortedKeys(item),
'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'},
},
},
}, },
); );
} }
@ -358,6 +384,93 @@ void main() {
expect(now.content.exam, exam); 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 " 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 " "copy says it holds the newer release; files it has aren't fetched "
'again', () async { 'again', () async {