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

View file

@ -0,0 +1,69 @@
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/data/note_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import '../../../support/eventually.dart';
import '../../../support/fake_note_gateway.dart';
void main() {
late FakeNoteGateway gateway;
late NoteRepository notes;
late NoteDeletions deletions;
setUp(() {
gateway = FakeNoteGateway();
notes = NoteRepository(gateway);
deletions = NoteDeletions(
notes,
undoWindow: const Duration(milliseconds: 50),
);
});
tearDown(() async {
await deletions.dispose();
await notes.dispose();
});
Future<String> note() async => (await notes.create(
'japanese',
NoteContent(title: 'Particles', body: RichContent.plain('は')),
)).noteId;
test('a deletion waits for the time to undo, then blanks the note and '
'deletes it', () async {
final id = await note();
deletions.delete(id);
expect(deletions.pending, {id});
expect(gateway.deletions, isEmpty);
await eventually(() => deletions.pending.isEmpty);
expect(gateway.deletions, ['blank $id', 'delete $id']);
expect(await notes.notesOf('japanese'), isEmpty);
});
test('undoing in time keeps the note', () async {
final id = await note();
deletions
..delete(id)
..undo(id);
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(deletions.pending, isEmpty);
expect(gateway.deletions, isEmpty);
expect(await notes.notesOf('japanese'), hasLength(1));
});
test('closing drops what still waits', () async {
final id = await note();
deletions.delete(id);
await deletions.dispose();
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(gateway.deletions, isEmpty);
deletions = NoteDeletions(notes);
});
}

View file

@ -0,0 +1,71 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
void main() {
ByteData bytes(Object json) =>
ByteData.sublistView(utf8.encode(jsonEncode(json)));
test('a note payload survives a round trip, Japanese included', () {
final content = NoteContent(
title: '助詞',
body: RichContent.plain('は marks the topic'),
);
expect(decodeNotePayload(encodeNotePayload(content)), content);
});
test('an untitled note stays untitled', () {
final content = NoteContent(body: RichContent.plain('は'));
expect(decodeNotePayload(encodeNotePayload(content)).title, '');
});
test('a note tied to a page of a PDF keeps it; one of the first schema is '
'tied to none', () {
final content = NoteContent(
body: RichContent.plain('は'),
source: (pdfId: 'grammar-pdf', page: 12),
);
expect(decodeNotePayload(encodeNotePayload(content)), content);
expect(
decodeNotePayload(
bytes({
'schemaVersion': 1,
'title': '',
'body': RichContent.empty().toJson(),
}),
).source,
isNull,
);
});
test('refuses a schema newer than this app knows', () {
expect(
() => decodeNotePayload(
bytes({
'schemaVersion': noteSchemaVersion + 1,
'title': '',
'body': RichContent.empty().toJson(),
}),
),
throwsFormatException,
);
});
test('refuses what is not a note payload, a blanked one included', () {
expect(() => decodeNotePayload(redactedPayload), throwsFormatException);
expect(
() => decodeNotePayload(
bytes({'schemaVersion': noteSchemaVersion, 'title': 'Particles'}),
),
throwsFormatException,
);
});
}

View file

@ -0,0 +1,235 @@
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import '../../../support/fake_note_gateway.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 FakeNoteGateway gateway;
late NoteRepository repository;
setUp(() {
gateway = FakeNoteGateway();
repository = NoteRepository(gateway);
});
tearDown(() => repository.dispose());
Iterable<String> titles(List<Note> notes) => [
for (final note in notes) note.content.title,
];
test('a new note goes after the others, stored with its title '
'trimmed', () async {
await repository.create('japanese', note('Particles', 'は'));
await repository.create('japanese', note(' Verbs ', '食べる'));
await repository.create('german', note('Cases', 'der'));
final notes = await repository.notesOf('japanese');
expect(titles(notes), ['Particles', 'Verbs']);
expect(notes.last.content, note('Verbs', '食べる'));
});
test(
'moves a note between two others, to the start and to the end',
() async {
final ids = [
for (final title in ['a', 'b', 'c'])
(await repository.create('japanese', note(title, ''))).noteId,
];
Future<Iterable<String>> order() async =>
titles(await repository.notesOf('japanese'));
await repository.move('japanese', ids[2], toIndex: 1);
expect(await order(), ['a', 'c', 'b']);
await repository.move('japanese', ids[1], toIndex: 0);
expect(await order(), ['b', 'a', 'c']);
await repository.move('japanese', ids[1], toIndex: 2);
expect(await order(), ['a', 'c', 'b']);
},
);
test('says when a note was last edited, here or elsewhere', () async {
final created = await withClock(
Clock.fixed(DateTime.utc(2026, 10, 2, 9)),
() => repository.create('japanese', note('Particles', 'は')),
);
expect(
(await repository.notesOf('japanese')).single.editedAt,
DateTime.utc(2026, 10, 2, 9),
);
await withClock(
Clock.fixed(DateTime.utc(2026, 10, 2, 10)),
() => repository.saveEdit(
created.noteId,
note('Particles', 'は and が'),
basedOn: {created.revisionId},
),
);
final edited = (await repository.notesOf('japanese')).single;
expect(edited.content, note('Particles', 'は and が'));
expect(edited.editedAt, DateTime.utc(2026, 10, 2, 10));
expect(edited.heads, hasLength(1));
});
group('Edits on two devices', () {
late ({String noteId, String revisionId}) created;
setUp(() async {
created = await repository.create(
'japanese',
note('Particles', 'は marks the topic'),
);
});
void remoteEdit(NoteContent content) => gateway.receiveRemoteRevision(
id: 'nrev-9000',
noteId: created.noteId,
parents: {created.revisionId},
payload: encodeNotePayload(content),
);
test('an edit saved on what it started from merges with what came '
'meanwhile', () async {
remoteEdit(note('は and が', 'は marks the topic'));
final id = await repository.saveEdit(
created.noteId,
note('Particles', 'は and が'),
basedOn: {created.revisionId},
);
expect(gateway.written.last.parents, {created.revisionId});
final merged = (await repository.notesOf('japanese')).single;
expect(merged.content, note('は and が', 'は and が'));
expect(merged.heads, {id, 'nrev-9000'});
expect(merged.hasConflict, isFalse);
});
test('a body both changed is a conflict; saving on every head settles '
'it', () async {
remoteEdit(note('Particles', 'が marks the subject'));
await repository.saveEdit(
created.noteId,
note('Particles', 'は: topic'),
basedOn: {created.revisionId},
);
final conflicted = (await repository.notesOf('japanese')).single;
expect(conflicted.conflicts.keys, [NoteField.body]);
await repository.saveEdit(
created.noteId,
note('Particles', 'は: topic, が: subject'),
basedOn: conflicted.heads,
);
final settled = (await repository.notesOf('japanese')).single;
expect(settled.hasConflict, isFalse);
expect(settled.content.body, text('は: topic, が: subject'));
});
});
test('tells of every change, made here or synced from elsewhere', () async {
var changes = 0;
final subscription = repository.changes.listen((_) => changes++);
addTearDown(subscription.cancel);
final created = await repository.create('japanese', note('a', 'b'));
gateway.receiveRemoteRevision(
id: 'nrev-9000',
noteId: created.noteId,
parents: {created.revisionId},
payload: encodeNotePayload(note('a', 'c')),
);
await Future<void>.delayed(Duration.zero);
expect(changes, 2);
});
test("watches a studyset's notes through every change", () async {
final seen = <List<String>>[];
final subscription = repository
.watch('japanese')
.listen((notes) => seen.add([...titles(notes)]));
addTearDown(subscription.cancel);
await Future<void>.delayed(Duration.zero);
await repository.create('japanese', note('Particles', 'は'));
await Future<void>.delayed(Duration.zero);
expect(seen, [
<String>[],
['Particles'],
]);
});
test("skips a revision this app can't read", () async {
final created = await repository.create('japanese', note('a', 'b'));
gateway.receiveRemoteRevision(
id: 'nrev-9000',
noteId: created.noteId,
parents: {created.revisionId},
payload: encodePayload(noteSchemaVersion + 1, {'title': 'new'}),
);
expect(
(await repository.notesOf('japanese')).single.content,
note('a', 'b'),
);
});
test('deleting blanks every revision before the note goes', () async {
final created = await repository.create('japanese', note('秘密', 'secret'));
await repository.delete(created.noteId);
expect(gateway.deletions, [
'blank ${created.noteId}',
'delete ${created.noteId}',
]);
expect(gateway.payloads.values, everyElement(redactedPayload));
expect(await repository.notesOf('japanese'), isEmpty);
});
test("deletes a studyset's notes, each blanked first, and no other "
"studyset's", () async {
final first = await repository.create('japanese', note('a', 'b'));
final second = await repository.create('japanese', note('c', 'd'));
await repository.create('german', note('e', 'f'));
await repository.deleteAllOf('japanese');
expect(gateway.deletions, [
'blank ${first.noteId}',
'delete ${first.noteId}',
'blank ${second.noteId}',
'delete ${second.noteId}',
]);
expect(await repository.notesOf('japanese'), isEmpty);
expect(await repository.notesOf('german'), hasLength(1));
});
test('counts the notes of every studyset', () async {
await repository.create('japanese', note('a', 'b'));
await repository.create('japanese', note('c', 'd'));
await repository.create('german', note('e', 'f'));
expect(await repository.countsByStudyset(), {'japanese': 2, 'german': 1});
});
}