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

View file

@ -0,0 +1,86 @@
// Files synced over the network, against a separately started server (ADR
// 0001, rule 9; ADR 0004, rule 5): the asset row syncs like all content, the
// file goes through the server's storage. Every device is its own client,
// local database and file cache with the app's real gateways and repository
// on top, and syncs and uploads only when a test says so.
//
// Needs `serverpod start` running; tool/check.sh skips these tests. Run them in
// this package with `flutter test -t requires-server`. Each run signs in one
// new anonymous person (at most 30 per IP and hour). That nobody else gets a
// person's file is the server's tests' (asset_test.dart).
@Tags(['requires-server'])
library;
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart';
import 'support/sync_devices.dart';
void main() {
late AuthSuccess alice;
late Directory folder;
final devices = <SyncDevice>[];
setUpAll(() async {
alice = await signInAnonymously();
});
setUp(() async {
folder = await Directory.systemTemp.createTemp('asset_sync_');
});
tearDown(() async {
for (final device in devices) {
await device.close();
}
devices.clear();
await folder.delete(recursive: true);
});
/// A device of [person]'s.
Future<SyncDevice> device(AuthSuccess person, String name) async {
final device = await SyncDevice.open(
person,
File('${folder.path}/$name.sqlite'),
space: person.authUserId,
);
devices.add(device);
return device;
}
/// An image of its own for each test, so the server never has it already.
Uint8List newImage() => Uint8List.fromList(
List.generate(4096, (_) => Random.secure().nextInt(256)),
);
test('a file added on one device is shown on another once synced and '
'uploaded', () async {
final phone = await device(alice, 'phone');
final laptop = await device(alice, 'laptop');
final image = newImage();
final id = await phone.assets.add(
image,
fileName: 'cat.png',
mimeType: 'image/png',
);
await phone.sync();
await laptop.sync();
expect(
await laptop.assets.fileOf(id),
isNull,
reason: 'the row is there, the file not yet',
);
await phone.assets.uploadPending();
final file = await laptop.assets.fileOf(id);
expect(file?.bytes, image);
expect(file?.fileName, 'cat.png');
expect(file?.mimeType, 'image/png');
});
}

View file

@ -0,0 +1,273 @@
// Cards synced over the network, against a separately started server (ADR
// 0001, rule 9), their revisions merged per field (ADR 0003, amended).
//
// Needs `serverpod start` running; tool/check.sh skips these tests. Run them in
// this package with `flutter test -t requires-server`. Each run signs in one
// new anonymous person (at most 30 per IP and hour).
@Tags(['requires-server'])
library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart';
import 'support/sync_devices.dart';
RichContent text(String text) => RichContent({
'document': {'type': 'page', 'note': text},
});
CardContent card(String front, String back) =>
CardContent(front: text(front), back: text(back));
void main() {
late AuthSuccess person;
late Directory folder;
final devices = <SyncDevice>[];
setUpAll(() async {
person = await signInAnonymously();
});
setUp(() async {
folder = await Directory.systemTemp.createTemp('card_sync_');
});
tearDown(() async {
for (final device in devices) {
await device.close();
}
devices.clear();
await folder.delete(recursive: true);
});
Future<SyncDevice> device(String name) async {
final device = await SyncDevice.open(
person,
File('${folder.path}/$name.sqlite'),
space: person.authUserId,
);
devices.add(device);
return device;
}
/// A studyset with one card, made on [phone] and synced to [laptop];
/// returns their ids.
Future<(String, String)> shared(SyncDevice phone, SyncDevice laptop) async {
final studyset = await phone.studysets.create(
StudysetContent.titled('Japanese'),
);
final created = await phone.cards.create(studyset, card('猫', 'cat'));
await phone.sync();
await laptop.sync();
return (studyset, created);
}
/// Syncs both devices so each has the other's changes.
Future<void> syncBoth(SyncDevice phone, SyncDevice laptop) async {
await phone.sync();
await laptop.sync();
await phone.sync();
}
test('edits of different sides on two devices both survive', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final (studyset, id) = await shared(phone, laptop);
await phone.cards.save(
(await phone.card(studyset, id))!,
card('ねこ', 'cat'),
);
await laptop.cards.save(
(await laptop.card(studyset, id))!,
card('猫', 'a cat'),
);
await syncBoth(phone, laptop);
for (final device in [phone, laptop]) {
final merged = (await device.card(studyset, id))!;
expect(merged.content, card('ねこ', 'a cat'));
expect(merged.hasConflict, isFalse);
}
});
test('the same side changed differently is a conflict on both devices; '
'resolving it on one resolves it everywhere', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final (studyset, id) = await shared(phone, laptop);
await phone.cards.save(
(await phone.card(studyset, id))!,
card('猫', 'a cat'),
);
await laptop.cards.save(
(await laptop.card(studyset, id))!,
card('猫', 'the cat'),
);
await syncBoth(phone, laptop);
for (final device in [phone, laptop]) {
expect((await device.card(studyset, id))!.conflicts.keys, [
CardField.back,
]);
}
await phone.cards.save(
(await phone.card(studyset, id))!,
card('猫', 'a cat'),
);
await syncBoth(phone, laptop);
final resolved = (await laptop.card(studyset, id))!;
expect(resolved.hasConflict, isFalse);
expect(resolved.content.back, text('a cat'));
});
test('ratings on two devices both count, and both devices compute the '
'same schedule', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final (_, id) = await shared(phone, laptop);
final morning = DateTime.utc(2026, 10, 5, 8);
await phone.reviews.record(
cardId: id,
rating: Rating.good,
reviewedAt: morning,
duration: const Duration(seconds: 3),
);
await laptop.reviews.record(
cardId: id,
rating: Rating.again,
reviewedAt: morning.add(const Duration(hours: 1)),
duration: const Duration(seconds: 9),
);
await syncBoth(phone, laptop);
final onPhone = await phone.reviews.reviewsOf({id});
final onLaptop = await laptop.reviews.reviewsOf({id});
expect(onPhone, hasLength(2));
expect(scheduleOf(id, onPhone), scheduleOf(id, onLaptop));
});
test('a card deleted on one device takes the ratings another made of it '
'meanwhile with it, on both', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final (studyset, id) = await shared(phone, laptop);
await phone.cards.delete((await phone.card(studyset, id))!);
await laptop.reviews.record(
cardId: id,
rating: Rating.good,
reviewedAt: DateTime.utc(2026, 10, 5, 8),
duration: const Duration(seconds: 3),
);
await syncBoth(phone, laptop);
for (final device in [phone, laptop]) {
expect(await device.card(studyset, id), isNull);
expect(await device.reviews.reviewsOf({id}), isEmpty);
}
});
test("a word's reading changed on one device and its meanings on another "
'both survive', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final studyset = await phone.studysets.create(
StudysetContent.titled('Japanese'),
);
final word = CardContent(
type: CardType.vocabulary,
front: text('食べる'),
back: RichContent.empty(),
reading: 'たべる',
meanings: const ['to eat'],
);
final id = await phone.cards.create(studyset, word);
await phone.sync();
await laptop.sync();
await phone.cards.save(
(await phone.card(studyset, id))!,
word.copyWith(reading: 'タベル'),
);
await laptop.cards.save(
(await laptop.card(studyset, id))!,
word.copyWith(meanings: ['to eat', 'to live on']),
);
await syncBoth(phone, laptop);
for (final device in [phone, laptop]) {
final merged = (await device.card(studyset, id))!;
expect(merged.hasConflict, isFalse);
expect(
merged.content,
word.copyWith(reading: 'タベル', meanings: ['to eat', 'to live on']),
);
}
});
test('a review back to front syncs with its mode and moves only the '
'schedule back to front; a fresh start syncs too', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final (_, id) = await shared(phone, laptop);
final morning = DateTime.utc(2026, 10, 5, 8);
await phone.reviews.record(
cardId: id,
rating: Rating.good,
reviewedAt: morning,
duration: const Duration(seconds: 3),
mode: StudyMode.reverse,
);
await syncBoth(phone, laptop);
final forward = await laptop.reviews.schedulesOf([id]);
final backward = await laptop.reviews.schedulesOf([
id,
], mode: StudyMode.reverse);
expect(forward[id]!.isNew, isTrue);
expect(backward[id]!.reviewCount, 1);
await laptop.reviews.reset([id], morning.add(const Duration(hours: 1)));
await syncBoth(phone, laptop);
expect(
(await phone.reviews.schedulesOf([
id,
], mode: StudyMode.reverse))[id]!.isNew,
isTrue,
);
});
test("a deleted card's revisions are blank on the server", () async {
final phone = await device('phone');
final laptop = await device('laptop');
final (studyset, id) = await shared(phone, laptop);
await phone.cards.delete((await phone.card(studyset, id))!);
await phone.sync();
await laptop.sync();
expect(await laptop.card(studyset, id), isNull);
// The engine keeps deleted rows until the space is deleted, so their
// content has to be gone (ADR 0002, rule 2).
// SELECT "payload" FROM "card_revision" WHERE "cardId" = $1
final rows = await queryServer(
'SELECT "payload" FROM "card_revision" WHERE "cardId" = @id:uuid',
{'id': id},
);
expect(rows, isNotEmpty);
expect([for (final row in rows) row.single], everyElement(isEmpty));
});
}

View file

@ -0,0 +1,236 @@
// Learning paths synced over the network, against a separately started server
// (ADR 0001, rule 9): a path's own content in its row, its steps' as revisions
// merged per field (ADR 0003), the answers as a log.
//
// Needs `serverpod start` running; tool/check.sh skips these tests. Run them in
// this package with `flutter test -t requires-server`. Each run signs in one
// new anonymous person (at most 30 per IP and hour).
@Tags(['requires-server'])
library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/answer.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart';
import 'support/sync_devices.dart';
void main() {
late AuthSuccess person;
late Directory folder;
final devices = <SyncDevice>[];
setUpAll(() async {
person = await signInAnonymously();
});
setUp(() async {
folder = await Directory.systemTemp.createTemp('path_sync_');
});
tearDown(() async {
for (final device in devices) {
await device.close();
}
devices.clear();
await folder.delete(recursive: true);
});
Future<SyncDevice> device(String name) async {
final device = await SyncDevice.open(
person,
File('${folder.path}/$name.sqlite'),
space: person.authUserId,
);
devices.add(device);
return device;
}
Future<void> syncBoth(SyncDevice phone, SyncDevice laptop) async {
await phone.sync();
await laptop.sync();
await phone.sync();
}
const choice = SingleChoice(options: ['は', 'が'], correct: 0);
StepContent step(StepKind kind, String title, [Exercise? exercise]) {
final content = StepContent.empty(kind).withField(StepField.title, title);
return exercise == null
? content
: content.withField(StepField.exercise, exercise);
}
/// A studyset with a path of an info step and a choice, made on [phone]
/// and synced to [laptop]; returns the path and the steps with their
/// first revisions.
Future<
(
String,
({String stepId, String revisionId}),
({String stepId, String revisionId}),
)
>
shared(SyncDevice phone, SyncDevice laptop) async {
final studyset = await phone.studysets.create(
StudysetContent.titled('Japanese'),
);
final path = await phone.paths.createPath(
studyset,
LearningPathContent.titled('Particles'),
);
final info = await phone.paths.createStep(
path,
StepKind.info,
step(StepKind.info, 'は marks the topic'),
);
final question = await phone.paths.createStep(
path,
StepKind.singleChoice,
step(StepKind.singleChoice, 'Which marks the topic?', choice),
);
await phone.sync();
await laptop.sync();
return (path, info, question);
}
test('a path written on one device reaches the other, with its steps and '
'tags', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final (path, _, _) = await shared(phone, laptop);
// A name of its own: the person's tags from the tests before sync too.
final lesson = await phone.tags.create('lesson-one');
await phone.tags.setTagsOfPath(path, {lesson});
await syncBoth(phone, laptop);
final steps = await laptop.paths.stepsOf(path);
expect(
[for (final s in steps) s.kind],
[
StepKind.info,
StepKind.singleChoice,
],
);
expect(steps.last.content.exercise, choice);
expect((await laptop.tags.read()).pathsWith(lesson), {path});
});
test('steps made together, as an import makes them, reach the other '
'device in their order', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final studyset = await phone.studysets.create(
StudysetContent.titled('Japanese'),
);
final path = await phone.paths.createPath(
studyset,
LearningPathContent.titled('Particles'),
);
final ids = await phone.paths.createSteps(path, [
(kind: StepKind.info, content: step(StepKind.info, 'は marks the topic')),
(
kind: StepKind.singleChoice,
content: step(StepKind.singleChoice, 'Which marks the topic?', choice),
),
]);
await syncBoth(phone, laptop);
final steps = await laptop.paths.stepsOf(path);
expect([for (final s in steps) s.id], ids);
expect(steps.last.content.exercise, choice);
});
test("a step's title changed on one device and its exercise on another "
'both survive', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final (path, _, question) = await shared(phone, laptop);
const fixed = SingleChoice(options: ['は', 'が', 'を'], correct: 0);
await phone.paths.saveStep(
question.stepId,
step(StepKind.singleChoice, 'は or が?', choice),
basedOn: {question.revisionId},
);
await laptop.paths.saveStep(
question.stepId,
step(StepKind.singleChoice, 'Which marks the topic?', fixed),
basedOn: {question.revisionId},
);
await syncBoth(phone, laptop);
for (final device in [phone, laptop]) {
final merged = (await device.paths.stepsOf(path)).last;
expect(merged.content.title, 'は or が?');
expect(merged.content.exercise, fixed);
expect(merged.hasConflict, isFalse);
}
});
test('answers on two devices both count, and a fresh start on one '
'reaches the other', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final (_, info, question) = await shared(phone, laptop);
final kinds = {
info.stepId: StepKind.info,
question.stepId: StepKind.singleChoice,
};
await phone.pathProgress.record(info.stepId, AnswerOutcome.continued);
await laptop.pathProgress.record(
question.stepId,
AnswerOutcome.right,
const SingleChoiceAnswer(0),
);
await syncBoth(phone, laptop);
for (final device in [phone, laptop]) {
final progress = await device.pathProgress.of(kinds);
expect([for (final p in progress.values) p.done], [true, true]);
}
await phone.pathProgress.reset(kinds.keys.toSet());
await syncBoth(phone, laptop);
final afresh = await laptop.pathProgress.of(kinds);
expect(afresh.values.any((p) => p.done), isFalse);
});
test("a deleted path's content is blank on the server, and its answers "
'go', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final (path, info, _) = await shared(phone, laptop);
await phone.pathProgress.record(info.stepId, AnswerOutcome.continued);
await syncBoth(phone, laptop);
await phone.paths.deletePath(path);
await phone.sync();
await laptop.sync();
expect(await laptop.paths.stepsOf(path), isEmpty);
// The engine keeps deleted rows until the space is deleted, so their
// content has to be gone (ADR 0002, rule 2).
// SELECT "payload" FROM "learning_path_step_revision" WHERE "stepId" = $1
final revisions = await queryServer(
'SELECT "payload" FROM "learning_path_step_revision" '
'WHERE "stepId" = @id:uuid',
{'id': info.stepId},
);
expect(revisions, isNotEmpty);
expect([for (final row in revisions) row.single], everyElement(isEmpty));
// SELECT "payload" FROM "learning_path" WHERE "id" = $1
final paths = await queryServer(
'SELECT "payload" FROM "learning_path" WHERE "id" = @id:uuid',
{'id': path},
);
expect([for (final row in paths) row.single], everyElement(isEmpty));
});
}

View file

@ -0,0 +1,186 @@
// Notes synced over the network, against a separately started server (ADR
// 0001, rule 9), their revisions merged title and body apart (ADR 0003).
//
// Needs `serverpod start` running; tool/check.sh skips these tests. Run them in
// this package with `flutter test -t requires-server`. Each run signs in one
// new anonymous person (at most 30 per IP and hour).
@Tags(['requires-server'])
library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart';
import 'support/sync_devices.dart';
RichContent text(String text) => RichContent({
'document': {'type': 'page', 'note': text},
});
NoteContent note(String title, String body) =>
NoteContent(title: title, body: text(body));
void main() {
late AuthSuccess person;
late Directory folder;
final devices = <SyncDevice>[];
setUpAll(() async {
person = await signInAnonymously();
});
setUp(() async {
folder = await Directory.systemTemp.createTemp('note_sync_');
});
tearDown(() async {
for (final device in devices) {
await device.close();
}
devices.clear();
await folder.delete(recursive: true);
});
Future<SyncDevice> device(String name) async {
final device = await SyncDevice.open(
person,
File('${folder.path}/$name.sqlite'),
space: person.authUserId,
);
devices.add(device);
return device;
}
/// A studyset with one note, made on [phone] and synced to [laptop];
/// returns their ids and the note's first revision.
Future<(String, String, String)> shared(
SyncDevice phone,
SyncDevice laptop,
) async {
final studyset = await phone.studysets.create(
StudysetContent.titled('Japanese'),
);
final created = await phone.notes.create(
studyset,
note('Particles', 'は marks the topic'),
);
await phone.sync();
await laptop.sync();
return (studyset, created.noteId, created.revisionId);
}
/// Syncs both devices so each has the other's changes.
Future<void> syncBoth(SyncDevice phone, SyncDevice laptop) async {
await phone.sync();
await laptop.sync();
await phone.sync();
}
test('a note written on one device reaches the other, with its '
'tags', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final (studyset, id, _) = await shared(phone, laptop);
final grammar = await phone.tags.create('grammar');
await phone.tags.setTagsOfNote(id, {grammar});
await syncBoth(phone, laptop);
final arrived = (await laptop.note(studyset, id))!;
expect(arrived.content, note('Particles', 'は marks the topic'));
expect(arrived.editedAt, isNotNull);
expect((await laptop.tags.read()).notesWith(grammar), {id});
});
test('a title changed on one device and the body on another both '
'survive', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final (studyset, id, first) = await shared(phone, laptop);
await phone.notes.saveEdit(
id,
note('は and が', 'は marks the topic'),
basedOn: {first},
);
await laptop.notes.saveEdit(
id,
note('Particles', 'は: topic, が: subject'),
basedOn: {first},
);
await syncBoth(phone, laptop);
for (final device in [phone, laptop]) {
final merged = (await device.note(studyset, id))!;
expect(merged.content, note('は and が', 'は: topic, が: subject'));
expect(merged.hasConflict, isFalse);
}
});
test('a body changed differently is a conflict on both devices; settling '
'it on one settles it everywhere', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final (studyset, id, first) = await shared(phone, laptop);
await phone.notes.saveEdit(
id,
note('Particles', 'は: topic'),
basedOn: {first},
);
await laptop.notes.saveEdit(
id,
note('Particles', 'が: subject'),
basedOn: {first},
);
await syncBoth(phone, laptop);
for (final device in [phone, laptop]) {
expect((await device.note(studyset, id))!.conflicts.keys, [
NoteField.body,
]);
}
final conflicted = (await phone.note(studyset, id))!;
await phone.notes.saveEdit(
id,
note('Particles', 'は: topic'),
basedOn: conflicted.heads,
);
await syncBoth(phone, laptop);
final settled = (await laptop.note(studyset, id))!;
expect(settled.hasConflict, isFalse);
expect(settled.content.body, text('は: topic'));
});
test("a deleted note's revisions are blank on the server, and its tags "
'go', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final (studyset, id, _) = await shared(phone, laptop);
// A name of its own: the person's tags from the tests before sync too.
final topic = await phone.tags.create('topic');
await phone.tags.setTagsOfNote(id, {topic});
await syncBoth(phone, laptop);
expect((await laptop.tags.read()).notesWith(topic), {id});
await phone.notes.delete(id);
await phone.sync();
await laptop.sync();
expect(await laptop.note(studyset, id), isNull);
expect((await laptop.tags.read()).notesWith(topic), isEmpty);
// The engine keeps deleted rows until the space is deleted, so their
// content has to be gone (ADR 0002, rule 2).
// SELECT "payload" FROM "note_revision" WHERE "noteId" = $1
final rows = await queryServer(
'SELECT "payload" FROM "note_revision" WHERE "noteId" = @id:uuid',
{'id': id},
);
expect(rows, isNotEmpty);
expect([for (final row in rows) row.single], everyElement(isEmpty));
});
}

View file

@ -0,0 +1,150 @@
// PDFs synced over the network, against a separately started server (ADR
// 0001, rule 9): a PDF's content in its row, its file a file of the person's,
// its reading a log.
//
// Needs `serverpod start` running; tool/check.sh skips these tests. Run them in
// this package with `flutter test -t requires-server`. Each run signs in one
// new anonymous person (at most 30 per IP and hour).
@Tags(['requires-server'])
library;
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart';
import 'support/sync_devices.dart';
void main() {
late AuthSuccess person;
late Directory folder;
final devices = <SyncDevice>[];
setUpAll(() async {
person = await signInAnonymously();
});
setUp(() async {
folder = await Directory.systemTemp.createTemp('pdf_sync_');
});
tearDown(() async {
for (final device in devices) {
await device.close();
}
devices.clear();
await folder.delete(recursive: true);
});
Future<SyncDevice> device(String name) async {
final device = await SyncDevice.open(
person,
File('${folder.path}/$name.sqlite'),
space: person.authUserId,
);
devices.add(device);
return device;
}
Future<void> syncBoth(SyncDevice phone, SyncDevice laptop) async {
await phone.sync();
await laptop.sync();
await phone.sync();
}
/// A PDF no run has stored before, so its file is uploaded anew.
Uint8List newPdf() => Uint8List.fromList(
utf8.encode('%PDF-1.7 ${Random().nextInt(1 << 32)} ${DateTime.now()}'),
);
/// A studyset with a PDF, added on [phone] and synced to [laptop]; returns
/// the PDF's id and bytes.
Future<(String, Uint8List)> shared(
SyncDevice phone,
SyncDevice laptop,
) async {
final studyset = await phone.studysets.create(
StudysetContent.titled('Japanese'),
);
final bytes = newPdf();
final id = (await phone.pdfs.import(
studyset,
bytes: bytes,
fileName: 'Grammar.pdf',
) as PdfImported).id;
await phone.sync();
await laptop.sync();
return (id, bytes);
}
test('a PDF added on one device reaches the other, its file once '
'uploaded', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final (id, bytes) = await shared(phone, laptop);
final there = await laptop.pdfs.byId(id);
expect(there?.content.title, 'Grammar');
await phone.assets.uploadPending();
final file = await laptop.assets.fileOf(there!.content.assetId);
expect(file?.bytes, bytes);
expect(file?.mimeType, 'application/pdf');
});
test('reading on two devices counts the furthest page of both, and a '
'fresh start on one reaches the other', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final (id, _) = await shared(phone, laptop);
await phone.pdfs.reached(id, 5);
await laptop.pdfs.reached(id, 9);
await syncBoth(phone, laptop);
for (final device in [phone, laptop]) {
expect(await device.pdfs.furthestPages(), {id: 9});
}
await phone.pdfs.resetReading(id);
await syncBoth(phone, laptop);
expect(await laptop.pdfs.furthestPages(), isEmpty);
});
test("a deleted PDF's content is blank on the server, and its reading "
'and its file go', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final (id, _) = await shared(phone, laptop);
final assetId = (await phone.pdfs.byId(id))!.content.assetId;
await phone.pdfs.reached(id, 3);
await syncBoth(phone, laptop);
await phone.pdfs.delete(id);
await phone.sync();
await laptop.sync();
expect(await laptop.pdfs.byId(id), isNull);
expect(await laptop.pdfs.furthestPages(), isEmpty);
// The engine keeps deleted rows until the space is deleted, so their
// content has to be gone (ADR 0002, rule 2).
// SELECT "payload" FROM "pdf_source" WHERE "id" = $1
final sources = await queryServer(
'SELECT "payload" FROM "pdf_source" WHERE "id" = @id:uuid',
{'id': id},
);
expect(sources, isNotEmpty);
expect([for (final row in sources) row.single], everyElement(isEmpty));
// SELECT "payload" FROM "asset" WHERE "id" = $1
final assets = await queryServer(
'SELECT "payload" FROM "asset" WHERE "id" = @id:uuid',
{'id': assetId},
);
expect([for (final row in assets) row.single], everyElement(isEmpty));
});
}

View file

@ -0,0 +1,284 @@
// Publishing end to end, against a separately started server (ADR 0005,
// stages 1 to 3): the author's device syncs a studyset, the instance builds
// a release from what it holds and keeps it in git, another person finds it
// in Explore and adds it to their library, a later release comes into their
// copy as an update while their own edit stays, and once the author takes it
// off Explore, nobody finds it and the copy is offered nothing. Every device
// is its own client and local database with the app's real gateways and
// repositories on top, and syncs only when a test says so.
//
// Needs `serverpod start` running; tool/check.sh skips these tests. Run them in
// this package with `flutter test -t requires-server`. Each run signs in two
// new anonymous people (at most 30 per IP and hour) and gives the author the
// `identified` scope by SQL on the development database, on the person and
// their sessions as linking GitHub does (`grantIdentified`), since a test
// can't sign in to GitHub. Their studyset stays in the development
// server's git repositories, withdrawn after its third release. Who may
// publish what, and what a release leaves out, are the server's tests'
// (publish_test.dart).
@Tags(['requires-server'])
library;
import 'dart:io';
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/copy_updater.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/serverpod_explore_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/data/serverpod_profile_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/data/serverpod_publish_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/exam_plan.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart';
import '../support/memory_published_store.dart';
import 'support/sync_devices.dart';
void main() {
late AuthSuccess author;
late AuthSuccess learner;
late Directory folder;
final devices = <SyncDevice>[];
setUpAll(() async {
author = await signInAnonymously();
learner = await signInAnonymously();
// As `grantIdentified` does once a GitHub account is linked: the scope
// on the person, and on each of their sessions, which keep a copy.
for (final table in [
'serverpod_auth_core_user',
'serverpod_auth_core_session',
]) {
await queryServer(
'UPDATE "$table" '
'SET "scopeNames" = ("scopeNames"::jsonb || \'["identified"]\')::json '
'WHERE "${table == 'serverpod_auth_core_user' ? 'id' : 'authUserId'}" '
'= @id:uuid',
{'id': author.authUserId.uuid},
);
}
});
setUp(() async {
folder = await Directory.systemTemp.createTemp('publish_sync_');
});
tearDown(() async {
for (final device in devices) {
await device.close();
}
devices.clear();
await folder.delete(recursive: true);
});
/// A device of [person]'s.
Future<SyncDevice> device(AuthSuccess person, String name) async {
final device = await SyncDevice.open(
person,
File('${folder.path}/$name.sqlite'),
space: person.authUserId,
);
devices.add(device);
return device;
}
/// Explore on [device], copying into its person's library.
ExploreRepository exploreOn(SyncDevice device) => ExploreRepository(
ServerpodExploreGateway(device.client),
ReleaseCopier(
device.studysets,
device.cards,
device.notes,
device.paths,
device.pdfs,
device.tags,
device.assets,
),
CopyUpdater(
device.studysets,
device.cards,
device.notes,
device.paths,
device.pdfs,
device.tags,
device.assets,
),
device.studysets,
);
CardContent card(String front, String back) => CardContent(
front: RichContent.plain(front),
back: RichContent.plain(back),
);
/// The card of [studysetId] on [device] whose front is [front].
Future<Card> cardOf(
SyncDevice device,
String studysetId,
String front,
) async => (await device.cards.cardsOf(
studysetId,
)).singleWhere((card) => card.content.front.plainText == front);
test('a studyset published from one device is found, copied and updated on '
"another person's, their own edit kept, until it is withdrawn", () async {
final laptop = await device(author, 'author-laptop');
final phone = await device(learner, 'learner-phone');
final run = Random.secure().nextInt(1 << 32).toRadixString(36);
final title = 'Japanese e2e $run';
// The author: a public profile, and a studyset under a licence that
// allows passing it on, with a topic, tagged cards, a note and an exam
// plan, and an exam of their own to prepare for.
await ProfileRepository(ServerpodProfileGateway(laptop.client)).save(
handle: 'e2e_$run',
displayName: 'End to end',
bio: '',
links: const [],
);
final root = await laptop.studysets.create(
StudysetContent(
title: title,
description: RichContent.plain('Published by the sync tests.'),
license: 'CC-BY-SA-4.0',
licenseHolder: 'End to end',
frontLanguage: 'ja',
backLanguage: 'en',
),
);
final kanji = await laptop.studysets.create(
StudysetContent.titled('Kanji'),
parentId: root,
);
final neko = await laptop.cards.create(root, card('猫', 'cat'));
final inu = await laptop.cards.create(root, card('犬', 'dog'));
await laptop.cards.create(kanji, card('食', 'eat'));
final n5 = await laptop.tags.create('JLPT-N5');
await laptop.tags.setTagsOf(neko, {n5});
await laptop.tags.setTagsOf(inu, {n5});
await laptop.notes.create(
root,
NoteContent(title: 'Particles', body: RichContent.plain('は and が')),
);
final rootContent = (await laptop.studyset(root))!.content;
await laptop.studysets.updateContent(
root,
rootContent
.withExamPlans([
ExamPlan(id: 'kanji-quiz', name: 'Kanji quiz', topics: [kanji]),
])
.withExam(
ExamChoice(plan: 'kanji-quiz', date: DateTime(2026, 12, 6)),
),
);
await laptop.sync();
final publishing = PublishRepository(
ServerpodPublishGateway(laptop.client),
MemoryPublishedStore(),
);
addTearDown(publishing.dispose);
final first = await publishing.publish(
root,
slug: 'japanese',
notes: 'The first release.',
);
expect(first.latest?.version, 1);
expect(first.latest?.cards, 3);
expect(first.location, endsWith('/@e2e_$run/japanese'));
// The learner finds it in Explore and adds it to their library.
final explore = exploreOn(phone);
final found = (await explore.list(
ExploreFilter(text: title),
)).page.singleWhere((studyset) => studyset.publicId == first.publicId);
expect(found.version, 1);
expect(found.handle, 'e2e_$run');
expect(found.cards, 3);
final copy = await explore.addToLibrary(found);
await phone.sync();
final copied = (await phone.studyset(copy))!;
expect(copied.content.title, title);
expect(copied.content.license, 'CC-BY-SA-4.0');
expect(copied.content.published?.version, 1);
expect(copied.content.publishedAt, first.location);
expect(copied.content.exam, isNull, reason: "the author's exam is theirs");
final copiedKanji = (await phone.studysets.all()).singleWhere(
(studyset) => studyset.parentId == copy,
);
expect(copiedKanji.content.title, 'Kanji');
expect(copied.content.examPlans.single.topics, [copiedKanji.id]);
final copiedNeko = await cardOf(phone, copy, '猫');
expect(
[for (final tag in (await phone.tags.read()).of(copiedNeko.id)) tag.name],
['JLPT-N5'],
);
expect(
(await cardOf(phone, copiedKanji.id, '食')).content.back.plainText,
'eat',
);
expect((await phone.notes.notesOf(copy)).single.content.title, 'Particles');
expect(await explore.updateOf(copy), isNull, reason: 'it holds the latest');
// The learner changes a card of theirs; the author another, and adds
// one, and publishes again.
await phone.cards.save(copiedNeko, card('猫', 'cat, a neko'));
await laptop.cards.save(
await cardOf(laptop, root, '犬'),
card('犬', 'dog, an inu'),
);
await laptop.cards.create(root, card('鳥', 'bird'));
await laptop.sync();
final second = await publishing.publish(
root,
slug: 'japanese',
notes: 'An inu, and a bird.',
);
expect(second.latest?.version, 2);
// The copy is offered the release, and takes it at a tap.
final offer = await explore.updateOf(copy);
expect(offer?.version, 2);
expect(offer?.added, 1);
expect(offer?.changed, greaterThanOrEqualTo(1));
expect(await explore.takeUpdate(copy, 2), 0, reason: 'nothing to settle');
await phone.sync();
expect(
(await cardOf(phone, copy, '犬')).content.back.plainText,
'dog, an inu',
);
expect(
(await cardOf(phone, copy, '猫')).content.back.plainText,
'cat, a neko',
);
expect((await cardOf(phone, copy, '鳥')).content.back.plainText, 'bird');
expect((await phone.studyset(copy))!.content.published?.version, 2);
// A third release, then withdrawn: nobody finds it, and the copy, which
// holds the second, is offered nothing.
await laptop.cards.create(root, card('魚', 'fish'));
await laptop.sync();
await publishing.publish(root, slug: 'japanese', notes: 'A fish.');
expect((await explore.updateOf(copy))?.version, 3);
final withdrawn = await publishing.withdraw(root);
expect(withdrawn.withdrawn, isTrue);
expect(
(await explore.list(ExploreFilter(text: title))).page.where(
(studyset) => studyset.publicId == first.publicId,
),
isEmpty,
);
expect(await explore.updateOf(copy), isNull);
});
}

View file

@ -0,0 +1,184 @@
// Studysets synced over the network, against a separately started server (ADR 0001,
// rule 9: the client's and the server's sync engines can't share one process).
// Every device is its own client and local database with the app's real gateway
// and repository on top, and syncs only when a test says so.
//
// Needs `serverpod start` running; tool/check.sh skips these tests. Run them in
// this package with `flutter test -t requires-server`. Each run signs in two new
// anonymous people (at most 30 per IP and hour).
@Tags(['requires-server'])
library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart';
import 'support/sync_devices.dart';
void main() {
late AuthSuccess alice;
late AuthSuccess bob;
late Directory folder;
final devices = <SyncDevice>[];
setUpAll(() async {
alice = await signInAnonymously();
bob = await signInAnonymously();
});
setUp(() async {
folder = await Directory.systemTemp.createTemp('studyset_sync_');
});
tearDown(() async {
for (final device in devices) {
await device.close();
}
devices.clear();
await folder.delete(recursive: true);
});
/// A device of [person]. Its local database claims [space] as its personal
/// space: the person's own, unless a test forges another.
Future<SyncDevice> device(
AuthSuccess person,
String name, {
protocol.UuidValue? space,
}) async {
final device = await SyncDevice.open(
person,
File('${folder.path}/$name.sqlite'),
space: space ?? person.authUserId,
);
devices.add(device);
return device;
}
test('two devices of one person end up with the same studysets', () async {
final phone = await device(alice, 'phone');
final laptop = await device(alice, 'laptop');
final id = await phone.studysets.create(StudysetContent.titled('Japanese'));
await phone.sync();
await laptop.sync();
final onLaptop = await laptop.studyset(id);
expect(onLaptop?.content.title, 'Japanese');
await laptop.studysets.updateContent(
id,
onLaptop!.content.copyWith(title: '日本語'),
);
await laptop.sync();
await phone.sync();
expect((await phone.studyset(id))?.content.title, '日本語');
});
test('starring and archiving on one device and renaming on another all '
'survive', () async {
final phone = await device(alice, 'phone');
final laptop = await device(alice, 'laptop');
final id = await phone.studysets.create(StudysetContent.titled('Japanese'));
await phone.sync();
await laptop.sync();
await phone.studysets.star(id);
await phone.studysets.archive(id);
await laptop.studysets.updateContent(
id,
StudysetContent.titled('日本語'),
);
await phone.sync();
await laptop.sync();
await phone.sync();
for (final device in [phone, laptop]) {
final studyset = await device.studyset(id);
expect(studyset?.content.title, '日本語');
expect(studyset?.isStarred, isTrue);
expect(studyset?.isArchived, isTrue);
}
await laptop.studysets.unstar(id);
await laptop.studysets.unarchive(id);
await laptop.sync();
await phone.sync();
final onPhone = await phone.studyset(id);
expect(onPhone?.isStarred, isFalse);
expect(onPhone?.isArchived, isFalse);
});
test('a deleted studyset leaves the other devices and is blank on the '
'server', () async {
final phone = await device(alice, 'phone');
final laptop = await device(alice, 'laptop');
final id = await phone.studysets.create(
StudysetContent.titled('Private notes'),
);
await phone.sync();
await laptop.sync();
// The repository blanks the payload, then deletes; one sync carries both.
await phone.studysets.delete(id);
await phone.sync();
await laptop.sync();
expect(await laptop.studyset(id), isNull);
// The engine keeps the deleted row until its space is deleted, so its
// content has to be gone (ADR 0002, rule 2).
// SELECT "payload" FROM "studyset" WHERE "id" = $1
final rows = await queryServer(
'SELECT "payload" FROM "studyset" WHERE "id" = @id:uuid',
{'id': id},
);
expect(rows.single.single, isEmpty);
});
test("a studyset forged into another person's space never leaves the "
'device', () async {
// Bob has synced before, so his personal space exists on the server.
final bobsPhone = await device(bob, 'bobs-phone');
await bobsPhone.sync();
// Alice's device, signed in as her, but its database claims Bob's space.
final forged = await device(alice, 'forged', space: bob.authUserId);
final intruder = await forged.studysets.create(
StudysetContent.titled('Intruder'),
);
// The server announces which spaces a device syncs (only Alice's), so the
// forged row is never sent. A modified client sending it anyway would
// meet the server's own check (`_assertCanMergeInboundSpace` in the sync
// engine), which this test can't reach without speaking the sync
// protocol by hand.
await forged.sync();
await bobsPhone.sync();
expect(await bobsPhone.studyset(intruder), isNull);
// SELECT 1 FROM "studyset" WHERE "id" = $1
final onServer = await queryServer(
'SELECT 1 FROM "studyset" WHERE "id" = @id:uuid',
{'id': intruder},
);
expect(onServer, isEmpty);
});
test(
"claiming another person's space shows none of their studysets",
() async {
final bobsPhone = await device(bob, 'bobs-phone');
final bobs = await bobsPhone.studysets.create(
StudysetContent.titled("Bob's"),
);
await bobsPhone.sync();
// Alice's device claims Bob's space but writes nothing, so nothing is
// refused: the server sends only the spaces Alice has access to.
final forged = await device(alice, 'forged', space: bob.authUserId);
await forged.sync();
expect(await forged.studyset(bobs), isNull);
},
);
}

View file

@ -0,0 +1,298 @@
import 'dart:io';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_file_cache.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/serverpod_asset_server_gateway.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/sync_asset_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/sync_learning_path_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/sync_path_answer_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/sync_note_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/sync_pdf_source_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/sync_review_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/sync_tag_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:postgres/postgres.dart' as pg;
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
import 'package:yaml/yaml.dart';
// What the sync tests (ADR 0001, rule 9) share: devices of people signed in
// against the running development server, and a look at its database.
const _serverUrl = 'http://localhost:8080/';
/// A new anonymous person, as the app signs one in on first launch.
Future<AuthSuccess> signInAnonymously() async {
final client = protocol.Client(_serverUrl);
try {
return await client.modules.base.anonymousIdp.login();
} finally {
client.close();
}
}
/// Runs [sql] on the running development server's database, as its operator
/// could.
///
/// `serverpod start` runs an embedded PostgreSQL. On Linux and macOS it
/// listens on a Unix socket in the server's `.serverpod/development/run` and
/// trusts local connections; elsewhere it listens on TCP as
/// `config/development.yaml` says, with the password from
/// `config/passwords.yaml`.
Future<pg.Result> queryServer(
String sql,
Map<String, Object?> parameters,
) async {
final connection = await _openServerDatabase();
try {
return await connection.execute(pg.Sql.named(sql), parameters: parameters);
} finally {
await connection.close();
}
}
Future<pg.Connection> _openServerDatabase() async {
// A worktree's tests run against the server `serverpod start` runs, which
// may be another checkout's: SYNC_TEST_SERVER_DIR names its server folder.
final server =
Platform.environment['SYNC_TEST_SERVER_DIR'] ?? '../halkyon_learn_server';
const settings = pg.ConnectionSettings(sslMode: pg.SslMode.disable);
final development = loadYaml(
File('$server/config/development.yaml').readAsStringSync(),
) as YamlMap;
final database = development['database'] as YamlMap;
final name = database['name'] as String;
final user = database['user'] as String;
final socket = File(
'$server/.serverpod/development/run/.s.PGSQL.5432',
).absolute.path;
if (FileSystemEntity.typeSync(socket) ==
FileSystemEntityType.unixDomainSock) {
// A socket's path may be about 100 bytes at most, fewer than the
// project's depth needs; a link in /tmp leads there by a short path, and
// an open connection no longer needs it.
final shortcut = Directory('/tmp').createTempSync('halkyon-pg-');
try {
final link = Link('${shortcut.path}/s')..createSync(socket);
return await pg.Connection.open(
pg.Endpoint(
host: link.path,
isUnixSocket: true,
database: name,
username: user,
),
settings: settings,
);
} finally {
shortcut.deleteSync(recursive: true);
}
}
final passwords = loadYaml(
File('$server/config/passwords.yaml').readAsStringSync(),
) as YamlMap;
return await pg.Connection.open(
pg.Endpoint(
host: database['host'] as String,
port: database['port'] as int,
database: name,
username: user,
password: (passwords['development'] as YamlMap)['database'] as String,
),
settings: settings,
);
}
/// A device: its own client and session, its own local database, and the
/// app's repositories over it. It syncs only when told to.
final class SyncDevice(
final protocol.Client _client,
final OfflineSyncDatabaseSession _db,
final SyncService _syncRequests,
/// The app's studysets on this device's database.
final StudysetRepository studysets,
/// The app's cards on this device's database.
final CardRepository cards,
/// The app's notes on this device's database.
final NoteRepository notes,
/// The app's learning paths on this device's database.
final LearningPathRepository paths,
/// The app's answers to steps of paths on this device's database.
final PathProgressRepository pathProgress,
/// The app's reviews on this device's database.
final ReviewRepository reviews,
/// The app's tags on this device's database.
final TagRepository tags,
/// The app's settings on this device's database.
final SettingsRepository settings,
/// The app's files on this device's database and cache; uploads only when
/// told to.
final AssetRepository assets,
/// The app's PDFs and their reading on this device's database.
final PdfSourceRepository pdfs,
) {
/// Opens a device signed in as [person], whose local database in
/// [database] claims [space] as its personal space.
static Future<SyncDevice> open(
AuthSuccess person,
File database, {
required protocol.UuidValue space,
}) async {
final session = ClientAuthSessionManager(storage: _MemoryAuthStorage());
final client = protocol.Client(_serverUrl)..authSessionManager = session;
await session.updateSignedInUser(person);
final db = await client.createSyncSession(
database.path,
persistentUserId: space,
);
// Syncs as the app's do, so what a sync merges reaches the repositories
// (which keep what they read until then). The gateways ask for a sync
// after every write; these tests sync explicitly instead ([sync]), so
// a request would wait a day.
final syncRequests = SyncService(
({required onMerge}) => client.offlineSync.syncOnce(
db,
onInboundMerge: (space) => onMerge(),
),
debounce: const Duration(days: 1),
);
final cards = CardRepository(
SyncCardGateway(db, syncRequests, person.authUserId.uuid),
);
final notes = NoteRepository(
SyncNoteGateway(db, syncRequests, person.authUserId.uuid),
);
final paths = LearningPathRepository(
SyncLearningPathGateway(db, syncRequests, person.authUserId.uuid),
);
final assets = AssetRepository(
SyncAssetGateway(db, syncRequests),
ServerpodAssetServerGateway(client),
DirectoryAssetFileCache(Directory('${database.path}.files')),
);
return SyncDevice(
client,
db,
syncRequests,
StudysetRepository(SyncStudysetGateway(db, syncRequests)),
cards,
notes,
paths,
PathProgressRepository(SyncPathAnswerGateway(db, syncRequests)),
ReviewRepository(SyncReviewGateway(db, syncRequests)),
TagRepository(
SyncTagGateway(db, syncRequests),
cardChanges: cards.changes,
noteChanges: notes.changes,
pathChanges: paths.changes,
),
SettingsRepository(SyncSettingsGateway(db, syncRequests)),
assets,
PdfSourceRepository(SyncPdfSourceGateway(db, syncRequests), assets),
);
}
/// The device's client, signed in as its person: for what lives on the
/// instance rather than in the synced data (profiles, publishing,
/// Explore).
protocol.Client get client => _client;
/// Sends this device's changes and receives everyone else's, through
/// its sync service, as the app syncs; throws when the sync failed.
Future<void> sync() async {
final done = _syncRequests.statusChanges.firstWhere(
(status) => !status.syncing,
);
_syncRequests.syncNow();
if ((await done).failure != null) {
throw StateError('The sync failed; the log says why.');
}
}
/// The studyset with [id] on this device, or null.
Future<Studyset?> studyset(String id) async {
for (final studyset in await studysets.all()) {
if (studyset.id == id) {
return studyset;
}
}
return null;
}
/// The card with [id] in [studysetId] on this device, or null.
Future<Card?> card(String studysetId, String id) async {
for (final card in await cards.cardsOf(studysetId)) {
if (card.id == id) {
return card;
}
}
return null;
}
/// The note with [id] in [studysetId] on this device, or null.
Future<Note?> note(String studysetId, String id) async {
for (final note in await notes.notesOf(studysetId)) {
if (note.id == id) {
return note;
}
}
return null;
}
/// Closes everything the device opened.
Future<void> close() async {
await studysets.dispose();
await tags.dispose();
await settings.dispose();
await reviews.dispose();
await cards.dispose();
await notes.dispose();
await paths.dispose();
await pathProgress.dispose();
await pdfs.dispose();
await assets.dispose();
await _syncRequests.dispose();
await _db.close();
_client.close();
}
}
/// Keeps a device's session in memory, where the app uses secure storage.
final class _MemoryAuthStorage() implements ClientAuthSuccessStorage {
AuthSuccess? _session;
@override
Future<AuthSuccess?> get() async => _session;
@override
Future<void> set(AuthSuccess? data) async {
_session = data;
}
}

View file

@ -0,0 +1,201 @@
// Tags and settings synced over the network, against a separately started
// server (ADR 0001, rule 9). Every device is its own client and local database
// with the app's real gateways and repositories on top, and syncs only when a
// test says so.
//
// Needs `serverpod start` running; tool/check.sh skips these tests. Run them in
// this package with `flutter test -t requires-server`. Each run signs in one
// new anonymous person (at most 30 per IP and hour).
@Tags(['requires-server'])
library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart';
import 'support/sync_devices.dart';
void main() {
late AuthSuccess alice;
late Directory folder;
final devices = <SyncDevice>[];
setUpAll(() async {
alice = await signInAnonymously();
});
setUp(() async {
folder = await Directory.systemTemp.createTemp('tag_sync_');
});
tearDown(() async {
for (final device in devices) {
await device.close();
}
devices.clear();
await folder.delete(recursive: true);
});
/// A device of Alice's.
Future<SyncDevice> device(String name) async {
final device = await SyncDevice.open(
alice,
File('${folder.path}/$name.sqlite'),
space: alice.authUserId,
);
devices.add(device);
return device;
}
final empty = CardContent(
front: RichContent.empty(),
back: RichContent.empty(),
);
test('a tag made on two devices offline ends up as one, on the cards of '
'both, and the other is blank on the server', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final studyset = await phone.studysets.create(
StudysetContent.titled('Japanese'),
);
final neko = await phone.cards.create(studyset, empty);
final inu = await phone.cards.create(studyset, empty);
await phone.sync();
await laptop.sync();
// Both offline: each makes the tag and gives it a card.
final onPhone = await phone.tags.create('noun');
await phone.tags.setTagsOf(neko, {onPhone});
final onLaptop = await laptop.tags.create('Noun');
await laptop.tags.setTagsOf(inu, {onLaptop});
await phone.sync();
await laptop.sync();
// Reading folds; the next sync carries that.
await laptop.tags.read();
await laptop.sync();
await phone.sync();
final kept = [onPhone, onLaptop]..sort();
for (final device in [phone, laptop]) {
final tagging = await device.tags.read();
expect(tagging.all.map((tag) => tag.id), [kept.first]);
expect(tagging.cardsWith(kept.first), {neko, inu});
}
// SELECT "payload" FROM "tag" WHERE "id" = $1
final rows = await queryServer(
'SELECT "payload" FROM "tag" WHERE "id" = @id:uuid',
{'id': kept.last},
);
expect(rows.single.single, isEmpty);
});
test("a deleted card's tags leave the other devices too", () async {
final phone = await device('phone');
final laptop = await device('laptop');
final studyset = await phone.studysets.create(
StudysetContent.titled('Japanese'),
);
final neko = await phone.cards.create(studyset, empty);
// A name of its own: the person's tags from the tests before sync too.
final animal = await phone.tags.create('animal');
await phone.tags.setTagsOf(neko, {animal});
await phone.sync();
await laptop.sync();
expect((await laptop.tags.read()).cardsWith(animal), {neko});
await phone.cards.delete((await phone.card(studyset, neko))!);
await phone.sync();
await laptop.sync();
expect((await laptop.tags.read()).cardsWith(animal), isEmpty);
});
test('a tag taken off cards, one at a time and many at once, leaves the '
'other devices too', () async {
final phone = await device('phone');
final laptop = await device('laptop');
final studyset = await phone.studysets.create(
StudysetContent.titled('Japanese'),
);
final neko = await phone.cards.create(studyset, empty);
final inu = await phone.cards.create(studyset, empty);
final tori = await phone.cards.create(studyset, empty);
// A name of its own: the person's tags from the tests before sync too.
final pet = await phone.tags.create('pet');
await phone.tags.addTags({
for (final card in [neko, inu, tori]) card: {pet},
});
await phone.sync();
await laptop.sync();
expect((await laptop.tags.read()).cardsWith(pet), {neko, inu, tori});
await phone.tags.setTagsOf(neko, {});
await phone.tags.removeTags({inu, tori}, {pet});
await phone.sync();
await laptop.sync();
expect((await laptop.tags.read()).cardsWith(pet), isEmpty);
});
test('settings made on two devices offline end up as one row holding '
'both', () async {
final phone = await device('phone');
final laptop = await device('laptop');
const verb = (id: 'tag-verb', name: 'verb');
const noun = (id: 'tag-noun', name: 'noun');
await phone.settings.update((s) => s.withStudyTags('japanese', [verb]));
await laptop.settings.update((s) => s.withStudyTags('german', [noun]));
await phone.sync();
await laptop.sync();
await laptop.settings.read();
await laptop.sync();
await phone.sync();
for (final device in [phone, laptop]) {
expect(
await device.settings.read(),
const PersonSettings(
studyTags: {
'japanese': [verb],
'german': [noun],
},
),
);
}
});
test('rest days chosen on two devices offline both count, and the goal '
'set on one comes to the other', () async {
final phone = await device('phone');
final laptop = await device('laptop');
await phone.settings.update(
(s) => s.copyWith(
restDays: {DateTime(2026, 10, 5)},
goal: const StudyGoal(period: GoalPeriod.day, target: 30),
),
);
await laptop.settings.update(
(s) => s.copyWith(restDays: {DateTime(2026, 10, 6)}),
);
await phone.sync();
await laptop.sync();
await laptop.settings.read();
await laptop.sync();
await phone.sync();
for (final device in [phone, laptop]) {
final settings = await device.settings.read();
expect(settings.restDays, {DateTime(2026, 10, 5), DateTime(2026, 10, 6)});
expect(settings.goal?.target, 30);
}
});
}