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

View file

@ -0,0 +1,580 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/asset/asset_endpoint.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_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:test/test.dart';
import '../support/sync_database.dart';
import '../test_tools/serverpod_test_tools.dart';
void main() {
withServerpod(
'Explore',
(sessionBuilder, endpoints) {
late Session session;
late UuidValue alice;
late UuidValue bob;
/// The people a test made, whose spaces, profiles and publications it
/// deletes again.
final people = <UuidValue>{};
TestSessionBuilder signedIn(UuidValue person) => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
person.uuid,
{identified},
),
);
Future<void> profile(UuidValue person, String handle, String name) =>
endpoints.profile.save(signedIn(person), handle, name, '', []);
setUp(() async {
Serverpod.instance.initializeOfflineSync(syncTables: syncTables);
session = sessionBuilder.build();
repositoriesFolder = await Directory.systemTemp.createTemp('repos-');
alice = const Uuid().v7obj();
bob = const Uuid().v7obj();
people.addAll([alice, bob]);
await profile(alice, 'alice', 'Alice Liddell');
await profile(bob, 'bob', 'Robert');
});
tearDown(() async {
await PublishedStudyset.db.deleteWhere(
session,
where: (t) => t.authUserId.inSet(people),
);
await PublicProfile.db.deleteWhere(
session,
where: (t) => t.authUserId.inSet(people),
);
for (final person in people) {
await session.db.unsafeExecute(
'DELETE FROM "serverpod_cloud_storage" WHERE "path" LIKE @prefix',
parameters: QueryParameters.named({
'prefix': '${assetPath(person, '')}%',
}),
);
await session.db.unsafeExecute(
'DELETE FROM "offline_sync_spaces" WHERE "uuidSpaceId" = @space',
parameters: QueryParameters.named({'space': person.uuid}),
);
}
people.clear();
await repositoriesFolder.delete(recursive: true);
repositoriesFolder = null;
});
/// Someone signed in without an identity.
final anonymous = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
const Uuid().v7(),
{},
),
);
Future<List<ExploreEntry>> list([ExploreQuery? query]) =>
endpoints.explore.list(anonymous, query ?? ExploreQuery());
List<String> titles(List<ExploreEntry> entries) => [
for (final entry in entries) entry.title,
];
/// A studyset of [person] titled [title] in [frontLanguage] under
/// CC BY 4.0, holding a card showing an image, and a learning path
/// when [withPath]; returns its id.
Future<UuidValue> studyset(
UuidValue person,
String title, {
String? frontLanguage,
bool withPath = false,
}) async {
final image = utf8.encode('the image of $title');
final hash = sha256.convert(image).toString();
final id = await asPerson(session, person, (transaction) async {
final studyset = (await Studyset.db.insertRow(
session,
Studyset(
payload: _json({
'schemaVersion': 4,
'title': title,
'secondaryTitle': 'for beginners',
'description': {'document': 'about $title'},
'license': 'CC-BY-4.0',
'licenseHolder': 'Alice',
'licenseYear': 2026,
'frontLanguage': ?frontLanguage,
}),
),
transaction: transaction,
)).id!;
final asset = (await Asset.db.insertRow(
session,
Asset(
sha256: hash,
mimeType: 'image/png',
byteSize: image.length,
payload: _json({'schemaVersion': 1, 'fileName': 'a.png'}),
),
transaction: transaction,
)).id!;
final card = (await Card.db.insertRow(
session,
Card(studysetId: studyset, position: 'a0'),
transaction: transaction,
)).id!;
await CardRevision.db.insertRow(
session,
CardRevision(
cardId: card,
parents: [],
authorId: person,
payload: _json({
'schemaVersion': 8,
'type': 'vocabulary',
'front': {'image': 'asset:${asset.uuid}'},
'back': title,
}),
),
transaction: transaction,
);
if (withPath) {
await LearningPath.db.insertRow(
session,
LearningPath(
studysetId: studyset,
position: 'a0',
payload: _json({'schemaVersion': 1, 'title': 'A path'}),
),
transaction: transaction,
);
}
return studyset;
});
await session.storage.storeFile(
storageId: assetStorage,
path: assetPath(person, hash),
byteData: ByteData.sublistView(Uint8List.fromList(image)),
);
return id;
}
Future<Publication> publish(
UuidValue person,
UuidValue studyset,
String slug, {
String notes = '',
}) => endpoints.publish.publish(signedIn(person), studyset, slug, notes);
/// Gives [studyset] of [person] another card, so its next release has
/// something new.
Future<void> addCard(UuidValue person, UuidValue studyset) =>
asPerson(session, person, (transaction) async {
final card = (await Card.db.insertRow(
session,
Card(studysetId: studyset, position: 'a1'),
transaction: transaction,
)).id!;
await CardRevision.db.insertRow(
session,
CardRevision(
cardId: card,
parents: [],
authorId: person,
payload: _json({'schemaVersion': 8, 'type': 'vocabulary'}),
),
transaction: transaction,
);
});
test('lists published studysets, the newest release first, with '
'their author, where they are and what they hold', () async {
final japanese = await studyset(alice, 'Japanese');
await publish(alice, japanese, 'japanese');
await publish(bob, await studyset(bob, 'Latin'), 'latin');
expect(titles(await list()), ['Latin', 'Japanese']);
await addCard(alice, japanese);
await publish(alice, japanese, 'japanese', notes: 'One more card.');
final entries = await list();
expect(titles(entries), ['Japanese', 'Latin']);
final entry = entries.first;
expect(entry.version, 2);
expect(entry.notes, 'One more card.');
expect(entry.location, endsWith('/@alice/japanese'));
expect(entry.handle, 'alice');
expect(entry.displayName, 'Alice Liddell');
expect(entry.secondaryTitle, 'for beginners');
expect(jsonDecode(entry.description!), {'document': 'about Japanese'});
expect(entry.license, 'CC-BY-4.0');
expect((entry.licenseHolder, entry.licenseYear), ('Alice', 2026));
expect((entry.studysetCount, entry.cardCount), (1, 2));
});
test('finds by title or author, whatever their case; what is typed is '
'only itself', () async {
await publish(alice, await studyset(alice, 'Japanese'), 'japanese');
await publish(bob, await studyset(bob, 'Latin 100%'), 'latin');
Future<List<String>> search(String text) async =>
titles(await list(ExploreQuery(text: text)));
expect(await search('japan'), ['Japanese']);
expect(await search('ROBERT'), ['Latin 100%']);
expect(await search('alice'), ['Japanese']);
expect(await search('100%'), ['Latin 100%']);
expect(await search('_'), isEmpty);
});
test('a language takes its regional ones; kinds of content must be '
'there', () async {
await publish(
alice,
await studyset(alice, 'Japanese', frontLanguage: 'ja-JP'),
'japanese',
);
await publish(
bob,
await studyset(bob, 'Latin', frontLanguage: 'la', withPath: true),
'latin',
);
expect(
titles(await list(ExploreQuery(frontLanguage: 'ja'))),
['Japanese'],
);
expect(
titles(await list(ExploreQuery(frontLanguage: 'JA-jp'))),
['Japanese'],
);
expect(await list(ExploreQuery(frontLanguage: 'j')), isEmpty);
expect(await list(ExploreQuery(frontLanguage: "ja' OR 1=1")), isEmpty);
expect(titles(await list(ExploreQuery(withPaths: true))), ['Latin']);
expect(await list(ExploreQuery(offset: 2)), isEmpty);
});
test('gives out the latest release: its JSON files, and each file its '
'content names', () async {
final japanese = await studyset(alice, 'Japanese');
final published = await publish(alice, japanese, 'japanese');
final release = await endpoints.explore.release(
anonymous,
published.publicId,
);
expect(release.version, 1);
final paths = {for (final item in release.items) item.path};
expect(paths, contains('studyset.json'));
expect(paths, contains('files.json'));
expect(
paths.where((path) => path.startsWith('cards/')),
hasLength(1),
);
final root = release.items.firstWhere(
(item) => item.path == 'studyset.json',
);
expect(
(jsonDecode(root.json) as Map)['payload'],
containsPair('title', 'Japanese'),
);
final hash = release.files.single;
expect(
utf8.decode(
Uint8List.sublistView(
await endpoints.explore.file(
anonymous,
published.publicId,
1,
hash,
),
),
),
'the image of Japanese',
);
for (final unknown in ['0' * 64, '../studyset.json', 'files.json']) {
await expectLater(
endpoints.explore.file(anonymous, published.publicId, 1, unknown),
throwsA(isA<NotFoundException>()),
reason: unknown,
);
}
});
test('gives out an older release, to take a newer one from', () async {
final japanese = await studyset(alice, 'Japanese');
await publish(alice, japanese, 'japanese');
await addCard(alice, japanese);
final published = await publish(alice, japanese, 'japanese');
Future<int> cardsIn(int? version) async =>
(await endpoints.explore.release(
anonymous,
published.publicId,
version: version,
)).items.where((item) => item.path.startsWith('cards/')).length;
expect(await cardsIn(1), 1);
expect(await cardsIn(2), 2);
expect(await cardsIn(null), 2);
for (final version in [0, 3]) {
await expectLater(
endpoints.explore.release(
anonymous,
published.publicId,
version: version,
),
throwsA(isA<NotFoundException>()),
reason: '$version',
);
}
});
test('gives out a release in parts: its JSON files in the order of '
'their paths from where asked, how many it holds in all, the '
'files their content names, and the version they are of', () async {
final japanese = await studyset(alice, 'Japanese');
await publish(alice, japanese, 'japanese');
await addCard(alice, japanese);
final published = await publish(alice, japanese, 'japanese');
final whole = await endpoints.explore.release(
anonymous,
published.publicId,
);
final parts = <ReleasePart>[];
var offset = 0;
do {
parts.add(
await endpoints.explore.releasePart(
anonymous,
published.publicId,
offset: offset,
count: 2,
),
);
offset += parts.last.items.length;
} while (offset < parts.last.total);
expect(parts.length, greaterThan(1));
expect(parts.every((part) => part.items.length <= 2), isTrue);
expect(
[
for (final part in parts)
for (final item in part.items) (item.path, item.json),
],
[for (final item in whole.items) (item.path, item.json)],
);
expect({for (final part in parts) part.total}, {whole.items.length});
expect({for (final part in parts) part.version}, {2});
expect(
{for (final part in parts) part.files.join()},
{
whole.files.join(),
},
);
final older = await endpoints.explore.releasePart(
anonymous,
published.publicId,
version: 1,
offset: 0,
count: 500,
);
expect(older.version, 1);
expect(older.total, whole.items.length - 1);
expect(older.items, hasLength(older.total));
expect(
(await endpoints.explore.releasePart(
anonymous,
published.publicId,
offset: whole.items.length,
count: 500,
)).items,
isEmpty,
);
});
test('gives out no part of a release that does not exist, nor one '
'from before the first file or of no files', () async {
final published = await publish(
alice,
await studyset(alice, 'Japanese'),
'japanese',
);
for (final (version, offset, count) in [
(0, 0, 2),
(2, 0, 2),
(null, -1, 2),
(null, 0, 0),
]) {
await expectLater(
endpoints.explore.releasePart(
anonymous,
published.publicId,
version: version,
offset: offset,
count: count,
),
throwsA(isA<NotFoundException>()),
reason: '$version, $offset, $count',
);
}
});
test('tells its author which of their studysets each of theirs was '
'published from, and nobody else', () async {
final japanese = await studyset(alice, 'Japanese');
final published = await publish(alice, japanese, 'japanese');
UuidValue? yoursIn(List<ExploreEntry> entries) => entries
.firstWhere((entry) => entry.publicId == published.publicId)
.yourStudysetId;
expect(
yoursIn(
await endpoints.explore.list(signedIn(alice), ExploreQuery()),
),
japanese,
);
expect(
yoursIn(
await endpoints.explore.entries(signedIn(alice), [
published.publicId,
]),
),
japanese,
);
expect(
yoursIn(await endpoints.explore.list(signedIn(bob), ExploreQuery())),
isNull,
);
expect(yoursIn(await list()), isNull);
expect(
yoursIn(
await endpoints.explore.entries(signedIn(bob), [
published.publicId,
]),
),
isNull,
);
});
test('says which of the studysets asked for are out, and their '
'latest version', () async {
final published = await publish(
alice,
await studyset(alice, 'Japanese'),
'japanese',
);
final entries = await endpoints.explore.entries(anonymous, [
published.publicId,
const Uuid().v4obj(),
]);
expect([for (final entry in entries) entry.version], [1]);
});
test('a withdrawn studyset is neither listed nor given out, until '
'published again', () async {
final japanese = await studyset(alice, 'Japanese');
final published = await publish(alice, japanese, 'japanese');
final withdrawn = await endpoints.publish.withdraw(
signedIn(alice),
japanese,
);
expect(withdrawn.withdrawn, isTrue);
expect(await list(), isEmpty);
expect(
await endpoints.explore.entries(anonymous, [published.publicId]),
isEmpty,
);
await expectLater(
endpoints.explore.release(anonymous, published.publicId),
throwsA(isA<NotFoundException>()),
);
await expectLater(
endpoints.explore.releasePart(
anonymous,
published.publicId,
offset: 0,
count: 500,
),
throwsA(isA<NotFoundException>()),
);
// Nothing changed, yet publishing brings it back as it was.
final again = await publish(alice, japanese, 'japanese');
expect(again.withdrawn, isFalse);
expect(again.releases.single.version, 1);
expect(titles(await list()), ['Japanese']);
});
test('only its author withdraws a studyset', () async {
final japanese = await studyset(alice, 'Japanese');
await publish(alice, japanese, 'japanese');
await expectLater(
endpoints.publish.withdraw(signedIn(bob), japanese),
throwsA(isA<NotFoundException>()),
);
await expectLater(
endpoints.publish.withdraw(anonymous, japanese),
throwsA(isA<ServerpodInsufficientAccessException>()),
);
expect(titles(await list()), ['Japanese']);
});
test('nobody signed in sees Explore', () async {
final published = await publish(
alice,
await studyset(alice, 'Japanese'),
'japanese',
);
final nobody = sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
final unauthenticated = throwsA(
isA<ServerpodUnauthenticatedException>(),
);
await expectLater(
endpoints.explore.list(nobody, ExploreQuery()),
unauthenticated,
);
await expectLater(
endpoints.explore.release(nobody, published.publicId),
unauthenticated,
);
await expectLater(
endpoints.explore.releasePart(
nobody,
published.publicId,
offset: 0,
count: 500,
),
unauthenticated,
);
await expectLater(
endpoints.explore.entries(nobody, [published.publicId]),
unauthenticated,
);
});
},
databaseInterceptor: syncDatabaseInterceptor,
rollbackDatabase: RollbackDatabase.disabled,
);
}
/// [json] as a synced payload.
ByteData _json(Map<String, Object?> json) =>
ByteData.sublistView(utf8.encode(jsonEncode(json)));

View file

@ -0,0 +1,182 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/explore/learner_count.dart';
import 'package:halkyon_learn_server/src/generated/protocol.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';
void main() {
test('learners are shown rounded down, never fewer than 5', () {
expect(
[
for (final n in [
0,
4,
5,
9,
10,
19,
20,
49,
50,
99,
100,
250,
999,
1000,
])
learnersShown(n),
],
[null, null, 5, 5, 10, 10, 20, 20, 50, 50, 100, 200, 500, 1000],
);
});
withServerpod(
'Counting learners',
(sessionBuilder, endpoints) {
late Session session;
final now = DateTime.utc(2026, 10, 9, 12);
final people = <UuidValue>[];
late PublishedStudyset japanese;
setUp(() async {
Serverpod.instance.initializeOfflineSync(syncTables: syncTables);
useLearnBase(sessionBuilder);
session = sessionBuilder.build();
japanese = await PublishedStudyset.db.insertRow(
session,
PublishedStudyset(
publicId: const Uuid().v4obj(),
studysetId: const Uuid().v7obj(),
authUserId: const Uuid().v7obj(),
slug: 'japanese',
createdAt: now,
),
);
});
tearDown(() async {
for (final person in people) {
await deletePerson(session, person);
}
people.clear();
await PublishedStudyset.db.deleteRow(session, japanese);
});
ByteData json(Map<String, Object?> json) =>
ByteData.sublistView(utf8.encode(jsonEncode(json)));
/// A person (with an identity unless [anonymous]), counted if
/// [counted], who studied a copy of Japanese on [daysAgo] (a review
/// in [mode] each), and since put it in Recently deleted if
/// [deleted].
Future<void> learner({
bool counted = true,
bool anonymous = false,
List<int> daysAgo = const [1, 2, 3],
String mode = 'review',
bool deleted = false,
}) async {
final person = (await AuthServices.instance.authUsers.create(
session,
scopes: anonymous ? const {} : {identified},
)).id;
people.add(person);
await asPerson(session, person, (transaction) async {
await PersonSettings.db.insertRow(
session,
PersonSettings(
payload: json({
'schemaVersion': 8,
'studyTags': <String, Object?>{},
'countLearning': ?(counted ? true : null),
}),
),
transaction: transaction,
);
final studyset = (await Studyset.db.insertRow(
session,
Studyset(
deletedAt: deleted ? now : null,
payload: json({
'schemaVersion': 5,
'title': 'Japanese',
'published': {
'publication': japanese.publicId.uuid,
'item': 'root',
'version': 1,
},
}),
),
transaction: transaction,
)).id!;
final card = (await Card.db.insertRow(
session,
Card(studysetId: studyset, position: 'a0'),
transaction: transaction,
)).id!;
for (final days in daysAgo) {
await ReviewLog.db.insertRow(
session,
ReviewLog(
cardId: card,
rating: 3,
reviewedAt: now.subtract(Duration(days: days)),
durationMs: 1000,
mode: mode,
),
transaction: transaction,
);
}
});
}
Future<int> counted() async {
await countLearnersOf(session, now: now);
return (await PublishedStudyset.db.findById(
session,
japanese.id!,
))!.learners;
}
test('counts the people with an identity who chose to be, studying a '
'copy on 3 days of the last 30', () async {
await learner();
await learner(daysAgo: [1, 1, 1, 2, 29]);
expect(await counted(), 2);
});
test('leaves out who chose not to be counted, anonymous people, too '
'little or too long ago, and a copy deleted', () async {
await learner(counted: false);
await learner(anonymous: true);
await learner(daysAgo: [1, 2]);
await learner(daysAgo: [31, 32, 40]);
await learner(mode: 'reset');
await learner(deleted: true);
expect(await counted(), 0);
});
test('a count goes down when learning stops', () async {
await learner();
expect(await counted(), 1);
await deletePerson(session, people.removeLast());
expect(await counted(), 0);
});
},
databaseInterceptor: syncDatabaseInterceptor,
rollbackDatabase: RollbackDatabase.disabled,
);
}