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:
parent
11abd9afce
commit
649499be23
2 changed files with 0 additions and 0 deletions
|
|
@ -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)));
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue