chore: Halkyon Learn moves into apps/learn

Its server, client, app and curator, and the tools that write its
assets (maps, Anki fixtures, icons), as they are; paths are fixed in
the commits that follow.

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-19 20:08:06 +02:00
commit 649499be23
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,830 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/discussion/discussion_rules.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:halkyon_learn_server/src/publish/release_repository.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:test/test.dart';
import '../support/auth_fixtures.dart';
import '../support/sync_database.dart';
import '../test_tools/serverpod_test_tools.dart';
/// Rich text as the app's editor writes it, holding [text].
String rich(String text) => jsonEncode({
'formatVersion': 1,
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': text},
],
},
},
],
},
});
void main() {
withServerpod(
'Discussions',
(sessionBuilder, endpoints) {
late Session session;
late UuidValue alice;
late UuidValue bob;
late UuidValue carol;
final people = <UuidValue>[];
/// A person signed in with an identity, or none.
TestSessionBuilder signedIn(UuidValue person, {bool identity = true}) =>
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
person.uuid,
identity ? {identified} : {},
),
);
/// A person, with an identity unless [anonymous], and a public profile
/// under [handle] unless null.
Future<UuidValue> person(String? handle, {bool anonymous = false}) async {
final id = (await AuthServices.instance.authUsers.create(
session,
scopes: anonymous ? const {} : {identified},
)).id;
people.add(id);
if (handle != null) {
await endpoints.profile.save(signedIn(id), handle, '', '', []);
}
return id;
}
setUp(() async {
Serverpod.instance.initializeOfflineSync(syncTables: syncTables);
useLearnBase(sessionBuilder);
session = sessionBuilder.build();
repositoriesFolder = await Directory.systemTemp.createTemp('repos-');
alice = await person('alice');
bob = await person('bob');
carol = await person('carol');
});
tearDown(() async {
for (final person in people) {
await deletePerson(session, person);
}
people.clear();
await repositoriesFolder.delete(recursive: true);
repositoriesFolder = null;
});
ByteData json(Map<String, Object?> json) =>
ByteData.sublistView(utf8.encode(jsonEncode(json)));
late UuidValue root;
late UuidValue grammar;
late UuidValue particles;
late UuidValue kanji;
late UuidValue card;
/// Alice's Japanese, published: Grammar with Particles inside, holding
/// a card, and Kanji beside it. Returns its public id.
Future<UuidValue> japanese() async {
await asPerson(session, alice, (transaction) async {
Future<UuidValue> studyset(String title, {UuidValue? parent}) async =>
(await Studyset.db.insertRow(
session,
Studyset(
parentId: parent,
position: 'a0',
payload: json({
'schemaVersion': 5,
'title': title,
'license': 'CC-BY-SA-4.0',
}),
),
transaction: transaction,
)).id!;
root = await studyset('Japanese');
grammar = await studyset('Grammar', parent: root);
particles = await studyset('Particles', parent: grammar);
kanji = await studyset('Kanji', parent: root);
card = (await Card.db.insertRow(
session,
Card(studysetId: particles, position: 'a0'),
transaction: transaction,
)).id!;
await CardRevision.db.insertRow(
session,
CardRevision(
cardId: card,
parents: [],
authorId: alice,
payload: json({'schemaVersion': 9, 'type': 'frontBack'}),
),
transaction: transaction,
);
});
return (await endpoints.publish.publish(
signedIn(alice),
root,
'japanese',
'',
)).publicId;
}
DiscussionTarget about(String kind, UuidValue item, {int version = 1}) =>
DiscussionTarget(kind: kind, item: item.uuid, version: version);
Future<UuidValue> ask(
UuidValue by,
UuidValue publication, {
DiscussionTarget? target,
String title = 'Meaning of は here?',
String body = 'Why は and not が?',
}) => endpoints.discussion.ask(
signedIn(by),
publication,
target ?? about('card', card),
title,
rich(body),
);
Future<DiscussionView> read(UuidValue by, UuidValue question) =>
endpoints.discussions.read(signedIn(by), question);
Matcher refused(DiscussionRefusal reason) => throwsA(
isA<DiscussionRefusedException>().having(
(error) => error.reason,
'reason',
reason,
),
);
group('asking', () {
test('a person with an identity and a public profile asks about an '
'item of the release; everyone signed in reads it, its writer '
'named', () async {
final publication = await japanese();
final id = await ask(bob, publication);
final anonymous = await person(null, anonymous: true);
final [summary] = await endpoints.discussions.list(
signedIn(anonymous, identity: false),
publication,
);
expect(summary.questionId, id);
expect(summary.title, 'Meaning of は here?');
expect(summary.asker?.handle, 'bob');
expect(summary.askerIsAuthor, isFalse);
expect(summary.target.toJson(), about('card', card).toJson());
expect(summary.studysetTitle, 'Japanese');
expect(summary.answers, 0);
final view = await read(carol, id);
expect(view.question.title, 'Meaning of は here?');
expect(view.question.body, rich('Why は and not が?'));
expect(view.question.mine, isFalse);
expect(view.location, endsWith('/@alice/japanese'));
expect(view.asker, isFalse);
expect(view.author, isFalse);
expect(view.refusal, isNull);
expect((await read(bob, id)).asker, isTrue);
expect((await read(alice, id)).author, isTrue);
});
test('a topic lists what is asked within it and its topics, and '
'nothing else; the published studyset lists everything', () async {
final publication = await japanese();
final onCard = await ask(bob, publication);
final onGrammar = await ask(
bob,
publication,
target: about('studyset', grammar),
);
final onKanji = await ask(
carol,
publication,
target: about('studyset', kanji),
);
final onRoot = await ask(
carol,
publication,
target: about('studyset', root),
);
Future<Set<UuidValue>> within(UuidValue? topic) async => {
for (final summary in await endpoints.discussions.list(
signedIn(carol),
publication,
topic: topic?.uuid,
))
summary.questionId,
};
expect(await within(grammar), {onCard, onGrammar});
expect(await within(particles), {onCard});
expect(await within(kanji), {onKanji});
expect(await within(root), {onCard, onGrammar, onKanji, onRoot});
expect(await within(null), {onCard, onGrammar, onKanji, onRoot});
expect(
[
for (final summary in await endpoints.discussions.list(
signedIn(carol),
publication,
item: about('card', card),
))
summary.questionId,
],
[onCard],
);
});
test(
'a question about the studyset as a whole may name no item',
() async {
final publication = await japanese();
final id = await ask(
bob,
publication,
target: DiscussionTarget(kind: 'studyset', item: '', version: 1),
);
expect((await read(carol, id)).target.item, root.uuid);
},
);
test('finds questions by what their titles say, wildcards as '
'themselves', () async {
final publication = await japanese();
final wa = await ask(bob, publication, title: 'Meaning of は');
await ask(bob, publication, title: 'Reading of 100%');
Future<List<UuidValue>> search(String query) async => [
for (final summary in await endpoints.discussions.list(
signedIn(carol),
publication,
query: query,
))
summary.questionId,
];
expect(await search('meaning'), [wa]);
expect(await search('%'), hasLength(1));
expect(await search('_'), isEmpty);
});
test('needs an identity to write, and signing in to read', () async {
final publication = await japanese();
await expectLater(
endpoints.discussion.ask(
signedIn(bob, identity: false),
publication,
about('card', card),
'Why?',
rich('Why?'),
),
throwsA(isA<ServerpodInsufficientAccessException>()),
);
await expectLater(
endpoints.discussions.list(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
),
publication,
),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
});
test('refuses a person without a public profile, or one the author '
'blocked, and says so to them when they read', () async {
final publication = await japanese();
final dave = await person(null);
final question = await ask(bob, publication);
await expectLater(
ask(dave, publication),
refused(DiscussionRefusal.noProfile),
);
expect(
(await read(dave, question)).refusal,
DiscussionRefusal.noProfile,
);
await endpoints.inbox.block(signedIn(alice), 'carol');
await expectLater(
ask(carol, publication),
refused(DiscussionRefusal.blocked),
);
await expectLater(
endpoints.discussion.answer(signedIn(carol), question, rich('No')),
refused(DiscussionRefusal.blocked),
);
expect(
(await read(carol, question)).refusal,
DiscussionRefusal.blocked,
);
final anonymous = await person(null, anonymous: true);
expect(
(await endpoints.discussions.read(
signedIn(anonymous, identity: false),
question,
)).refusal,
DiscussionRefusal.noIdentity,
);
});
test('refuses a question about what the release lacks, or in a '
'release that does not exist; a studyset never published is '
'missing', () async {
final publication = await japanese();
await expectLater(
ask(bob, publication, target: about('card', grammar)),
refused(DiscussionRefusal.notInRelease),
);
await expectLater(
ask(bob, publication, target: about('card', card, version: 2)),
refused(DiscussionRefusal.notInRelease),
);
await expectLater(
ask(bob, publication, target: about('chapter', card)),
refused(DiscussionRefusal.notInRelease),
);
await expectLater(
ask(bob, const Uuid().v4obj()),
throwsA(isA<NotFoundException>()),
);
await expectLater(
endpoints.discussions.list(signedIn(bob), const Uuid().v4obj()),
throwsA(isA<NotFoundException>()),
);
});
test('a studyset withdrawn keeps its discussions readable, but takes '
'no new questions; answers go on', () async {
final publication = await japanese();
final question = await ask(bob, publication);
await endpoints.publish.withdraw(signedIn(alice), root);
await expectLater(
ask(bob, publication),
refused(DiscussionRefusal.withdrawn),
);
expect(
await endpoints.discussions.list(signedIn(carol), publication),
hasLength(1),
);
await endpoints.discussion.answer(
signedIn(carol),
question,
rich('Topic marker.'),
);
expect((await read(bob, question)).answers, hasLength(1));
});
test('refuses an empty or too long title, text or comment, and more '
'posts in an hour than allowed', () async {
final publication = await japanese();
await expectLater(
ask(bob, publication, title: ' '),
refused(DiscussionRefusal.empty),
);
await expectLater(
ask(bob, publication, title: 'x' * (maxTitleLength + 1)),
refused(DiscussionRefusal.tooLong),
);
await expectLater(
endpoints.discussion.ask(
signedIn(bob),
publication,
about('card', card),
'Why?',
'',
),
refused(DiscussionRefusal.empty),
);
await expectLater(
endpoints.discussion.ask(
signedIn(bob),
publication,
about('card', card),
'Why?',
'not rich text',
),
refused(DiscussionRefusal.empty),
);
await expectLater(
ask(bob, publication, body: 'x' * maxBodyBytes),
refused(DiscussionRefusal.tooLong),
);
final question = await ask(bob, publication);
await expectLater(
endpoints.discussion.comment(
signedIn(carol),
question,
'x' * (maxCommentLength + 1),
),
refused(DiscussionRefusal.tooLong),
);
for (var post = 1; post < maxPostsAnHour; post++) {
await endpoints.discussion.comment(
signedIn(bob),
question,
'$post',
);
}
await expectLater(
endpoints.discussion.comment(signedIn(bob), question, 'more'),
refused(DiscussionRefusal.tooMany),
);
});
});
group('answering', () {
test('anyone with a profile answers and comments; answers come oldest '
'first, the accepted one first, comments beneath what they are '
'on', () async {
final publication = await japanese();
final question = await ask(bob, publication);
final first = await endpoints.discussion.answer(
signedIn(carol),
question,
rich('Topic marker.'),
);
final second = await endpoints.discussion.answer(
signedIn(alice),
question,
rich('は marks the topic; が the subject.'),
);
final onQuestion = await endpoints.discussion.comment(
signedIn(carol),
question,
'Good question.',
);
final onAnswer = await endpoints.discussion.comment(
signedIn(bob),
second,
'Thanks!',
);
var view = await read(carol, question);
expect(
[for (final answer in view.answers) answer.id],
[
first,
second,
],
);
expect(view.answers.last.fromAuthor, isTrue);
expect(view.answers.first.mine, isTrue);
expect(
[
for (final comment in view.comments)
(comment.id, comment.parentId),
],
[(onQuestion, question), (onAnswer, second)],
);
expect(view.comments.first.body, 'Good question.');
await endpoints.discussion.accept(signedIn(bob), question, second);
view = await read(carol, question);
expect(view.acceptedAnswerId, second);
expect(
[for (final answer in view.answers) answer.id],
[
second,
first,
],
);
final [summary] = await endpoints.discussions.list(
signedIn(carol),
publication,
);
expect(summary.answers, 2);
expect(summary.accepted, isTrue);
await endpoints.discussion.accept(signedIn(bob), question, null);
expect((await read(carol, question)).acceptedAnswerId, isNull);
});
test('only the asker accepts, only an answer of the question; '
'anything else is as missing', () async {
final publication = await japanese();
final question = await ask(bob, publication);
final answer = await endpoints.discussion.answer(
signedIn(carol),
question,
rich('Topic marker.'),
);
final other = await ask(carol, publication);
final elsewhere = await endpoints.discussion.answer(
signedIn(bob),
other,
rich('Elsewhere.'),
);
for (final (by, on, accepted) in [
(alice, question, answer),
(carol, question, answer),
(bob, question, elsewhere),
(bob, question, question),
(bob, answer, answer),
]) {
await expectLater(
endpoints.discussion.accept(signedIn(by), on, accepted),
throwsA(isA<NotFoundException>()),
);
}
expect((await read(bob, question)).acceptedAnswerId, isNull);
});
test('a comment goes on a question or an answer, an answer on a '
'question; anything else is as missing', () async {
final publication = await japanese();
final question = await ask(bob, publication);
final comment = await endpoints.discussion.comment(
signedIn(carol),
question,
'Hm.',
);
final answer = await endpoints.discussion.answer(
signedIn(carol),
question,
rich('Topic marker.'),
);
await expectLater(
endpoints.discussion.comment(signedIn(bob), comment, 'Re: hm.'),
throwsA(isA<NotFoundException>()),
);
for (final on in [answer, comment, const Uuid().v7obj()]) {
await expectLater(
endpoints.discussion.answer(signedIn(bob), on, rich('No.')),
throwsA(isA<NotFoundException>()),
);
}
});
});
group('editing and deleting', () {
test('people edit what they wrote, marked edited; what others wrote '
'is as missing', () async {
final publication = await japanese();
final question = await ask(bob, publication);
final comment = await endpoints.discussion.comment(
signedIn(carol),
question,
'Hm.',
);
await endpoints.discussion.edit(
signedIn(bob),
question,
rich('Why は here?'),
title: 'は or が?',
);
await endpoints.discussion.edit(signedIn(carol), comment, 'Hmm.');
final view = await read(alice, question);
expect(view.question.title, 'は or が?');
expect(view.question.body, rich('Why は here?'));
expect(view.question.editedAt, isNotNull);
expect(view.comments.single.body, 'Hmm.');
for (final (by, post) in [(carol, question), (bob, comment)]) {
await expectLater(
endpoints.discussion.edit(signedIn(by), post, rich('Mine now')),
throwsA(isA<NotFoundException>()),
);
}
await expectLater(
endpoints.discussion.delete(signedIn(carol), question),
throwsA(isA<NotFoundException>()),
);
});
test('deleting a post nobody else replied to removes it; with '
"others' replies it stays, its title, text and writer gone, the "
'replies kept', () async {
final publication = await japanese();
final lonely = await ask(bob, publication, title: 'Alone');
final answered = await ask(bob, publication, title: 'Answered');
await endpoints.discussion.comment(signedIn(bob), lonely, 'Bump.');
final answer = await endpoints.discussion.answer(
signedIn(carol),
answered,
rich('Topic marker.'),
);
await endpoints.discussion.delete(signedIn(bob), lonely);
await endpoints.discussion.delete(signedIn(bob), answered);
final [summary] = await endpoints.discussions.list(
signedIn(carol),
publication,
);
expect(summary.questionId, answered);
expect(summary.deleted, isTrue);
expect(summary.title, isEmpty);
expect(summary.asker, isNull);
final view = await read(carol, answered);
expect(view.question.body, isEmpty);
expect(view.question.by, isNull);
expect(view.answers.single.id, answer);
await expectLater(
read(carol, lonely),
throwsA(isA<NotFoundException>()),
);
// A deleted question takes no new answers.
await expectLater(
endpoints.discussion.answer(signedIn(carol), answered, rich('?')),
throwsA(isA<NotFoundException>()),
);
});
test("deleting a person keeps others' answers under their questions, "
'and removes the rest of what they wrote', () async {
final publication = await japanese();
final dave = await person('dave');
final answered = await ask(dave, publication, title: 'Answered');
final lonely = await ask(dave, publication, title: 'Alone');
final answer = await endpoints.discussion.answer(
signedIn(carol),
answered,
rich('Topic marker.'),
);
await endpoints.discussion.answer(
signedIn(dave),
answered,
rich('Thanks, I see.'),
);
await endpoints.discussion.markSeen(signedIn(dave), answered);
await deletePerson(session, dave);
people.remove(dave);
final view = await read(carol, answered);
expect(view.question.deleted, isTrue);
expect(view.question.title, isEmpty);
expect([for (final answer in view.answers) answer.id], [answer]);
await expectLater(
read(carol, lonely),
throwsA(isA<NotFoundException>()),
);
expect(
await DiscussionPost.db.count(
session,
where: (t) => t.authUserId.equals(dave),
),
0,
);
expect(
await DiscussionSeen.db.count(
session,
where: (t) => t.authUserId.equals(dave),
),
0,
);
});
test(
"deleting the author's studyset takes its discussions with it",
() async {
final publication = await japanese();
await ask(bob, publication);
await deletePerson(session, alice);
people.remove(alice);
expect(await DiscussionPost.db.count(session), 0);
},
);
});
group("the author's say", () {
test('the author hides posts on their studysets: then only their '
'writer and the author see them; nobody else hides', () async {
final publication = await japanese();
final question = await ask(bob, publication);
final answer = await endpoints.discussion.answer(
signedIn(carol),
question,
rich('Spam.'),
);
await expectLater(
endpoints.discussion.hide(signedIn(bob), answer, true),
throwsA(isA<NotFoundException>()),
);
await endpoints.discussion.hide(signedIn(alice), answer, true);
expect((await read(bob, question)).answers, isEmpty);
expect((await read(carol, question)).answers.single.hidden, isTrue);
expect((await read(alice, question)).answers.single.hidden, isTrue);
await endpoints.discussion.hide(signedIn(alice), question, true);
await expectLater(
read(carol, question),
throwsA(isA<NotFoundException>()),
);
expect(
await endpoints.discussions.list(signedIn(carol), publication),
isEmpty,
);
expect((await read(bob, question)).question.hidden, isTrue);
await endpoints.discussion.hide(signedIn(alice), question, false);
await endpoints.discussion.hide(signedIn(alice), answer, false);
expect((await read(carol, question)).answers, hasLength(1));
});
test(
'what a person the author blocked wrote shows to them alone',
() async {
final publication = await japanese();
final question = await ask(bob, publication);
await endpoints.discussion.answer(
signedIn(carol),
question,
rich('Spam.'),
);
final theirs = await ask(carol, publication);
await endpoints.inbox.block(signedIn(alice), 'carol');
expect((await read(bob, question)).answers, isEmpty);
expect((await read(carol, question)).answers, hasLength(1));
await expectLater(
read(bob, theirs),
throwsA(isA<NotFoundException>()),
);
expect(
[
for (final summary in await endpoints.discussions.list(
signedIn(bob),
publication,
))
summary.questionId,
],
[question],
);
},
);
});
group('the inbox', () {
test('holds the questions on the person’s studysets and those they '
'wrote in, new until read, again when others write', () async {
final publication = await japanese();
final question = await ask(bob, publication);
var [inbox] = await endpoints.discussion.inbox(signedIn(alice));
expect(inbox.questionId, question);
expect(inbox.unseen, isTrue);
expect(await endpoints.discussion.unseen(signedIn(alice)), 1);
expect(await endpoints.discussion.inbox(signedIn(carol)), isEmpty);
[inbox] = await endpoints.discussion.inbox(signedIn(bob));
expect(inbox.unseen, isFalse, reason: 'Bob wrote all of it');
await endpoints.discussion.markSeen(signedIn(alice), question);
expect(await endpoints.discussion.unseen(signedIn(alice)), 0);
final answer = await endpoints.discussion.answer(
signedIn(carol),
question,
rich('Topic marker.'),
);
expect(await endpoints.discussion.unseen(signedIn(alice)), 1);
expect(await endpoints.discussion.unseen(signedIn(bob)), 1);
expect(await endpoints.discussion.unseen(signedIn(carol)), 0);
await endpoints.discussion.markSeen(signedIn(carol), question);
await endpoints.discussion.accept(signedIn(bob), question, answer);
expect(
await endpoints.discussion.unseen(signedIn(carol)),
1,
reason: 'her answer was accepted',
);
});
test('needs an identity', () async {
await expectLater(
endpoints.discussion.inbox(signedIn(bob, identity: false)),
throwsA(isA<ServerpodInsufficientAccessException>()),
);
});
});
},
databaseInterceptor: syncDatabaseInterceptor,
rollbackDatabase: RollbackDatabase.disabled,
);
}