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
9 changed files with 0 additions and 0 deletions
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
273
apps/learn/halkyon_learn_flutter/test/sync/card_sync_test.dart
Normal file
273
apps/learn/halkyon_learn_flutter/test/sync/card_sync_test.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
186
apps/learn/halkyon_learn_flutter/test/sync/note_sync_test.dart
Normal file
186
apps/learn/halkyon_learn_flutter/test/sync/note_sync_test.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
201
apps/learn/halkyon_learn_flutter/test/sync/tag_sync_test.dart
Normal file
201
apps/learn/halkyon_learn_flutter/test/sync/tag_sync_test.dart
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue