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

View file

@ -0,0 +1,314 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/save_status.dart';
import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_editor_bloc.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_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import '../../../support/fake_note_gateway.dart';
import '../../../support/fake_tag_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 notes;
late TagRepository tags;
NoteEditorBloc? bloc;
setUp(() {
gateway = FakeNoteGateway();
notes = NoteRepository(gateway);
tags = TagRepository(FakeTagGateway(), noteChanges: notes.changes);
});
tearDown(() async {
await bloc?.close();
bloc = null;
await tags.dispose();
await notes.dispose();
});
/// Opens the editor on the note [noteId], or on a new note; it saves
/// after [idle] without typing.
NoteEditorBloc open({
String? noteId,
Duration idle = const Duration(milliseconds: 20),
}) => bloc = NoteEditorBloc(
notes,
tags,
'japanese',
noteId: noteId,
idleSave: idle,
)..add(const NoteEditorEventStarted());
/// The next state (or the current one) [test] holds for.
Future<NoteEditorState> until(bool Function(NoteEditorState) test) {
if (test(bloc!.state)) {
return Future.value(bloc!.state);
}
return bloc!.stream.firstWhere(test);
}
Future<String> stored(NoteContent content) async =>
(await notes.create('japanese', content)).noteId;
test('opens a stored note with its content and tags', () async {
final id = await stored(note('Particles', 'は'));
final grammar = await tags.create('grammar');
await tags.setTagsOfNote(id, {grammar});
open(noteId: id);
final state = await until(
(s) => s.loaded && s.savedTagIds.isNotEmpty,
);
expect(state.content, note('Particles', 'は'));
expect(state.tagIds, {grammar});
expect(state.status, EditorSaveStatus.clean);
});
group('A new note', () {
test('is created by its first save, after a pause in typing', () async {
open();
await until((s) => s.loaded);
bloc!.add(const NoteEditorEventTitleChanged('Particles'));
final saved = await until((s) => s.status == EditorSaveStatus.saved);
expect(saved.noteId, isNotNull);
expect(
(await notes.notesOf('japanese')).single.content.title,
'Particles',
);
});
test('left blank is never created', () async {
open();
await until((s) => s.loaded);
bloc!
..add(const NoteEditorEventTitleChanged(' '))
..add(const NoteEditorEventSaveRequested());
await Future<void>.delayed(const Duration(milliseconds: 50));
await bloc!.close();
expect(await notes.notesOf('japanese'), isEmpty);
});
});
test('saves after a pause in typing, each save on the one before', () async {
final id = await stored(note('Particles', 'は'));
open(noteId: id);
await until((s) => s.loaded);
bloc!.add(NoteEditorEventBodyChanged(text('は and')));
await until((s) => s.status == EditorSaveStatus.saved);
bloc!.add(NoteEditorEventBodyChanged(text('は and が')));
await until(
(s) =>
s.status == EditorSaveStatus.saved && s.saved.body == text('は and が'),
);
final written = gateway.written;
expect(written, hasLength(3));
expect(written[1].parents, {written[0].id});
expect(written[2].parents, {written[1].id});
final current = (await notes.notesOf('japanese')).single;
expect(current.heads, hasLength(1));
expect(current.content.body, text('は and が'));
});
test('saves at once when asked, as when the app is hidden', () async {
final id = await stored(note('Particles', 'は'));
open(noteId: id, idle: const Duration(hours: 1));
await until((s) => s.loaded);
bloc!
..add(const NoteEditorEventTitleChanged('助詞'))
..add(const NoteEditorEventSaveRequested());
await until((s) => s.status == EditorSaveStatus.saved);
expect((await notes.notesOf('japanese')).single.content.title, '助詞');
});
test(
'closing saves what is unsaved, without waiting for the pause',
() async {
final id = await stored(note('Particles', 'は'));
open(noteId: id, idle: const Duration(hours: 1));
await until((s) => s.loaded);
bloc!.add(const NoteEditorEventTitleChanged('助詞'));
await until((s) => s.status == EditorSaveStatus.editing);
await bloc!.close();
expect((await notes.notesOf('japanese')).single.content.title, '助詞');
},
);
test("keeps a title's trailing space while typing, though stored "
'trimmed', () async {
final id = await stored(note('Particles', 'は'));
open(noteId: id);
await until((s) => s.loaded);
bloc!.add(const NoteEditorEventTitleChanged('Particles and '));
final saved = await until((s) => s.status == EditorSaveStatus.saved);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(bloc!.state.content.title, 'Particles and ');
expect(bloc!.state.revision, saved.revision);
expect(
(await notes.notesOf('japanese')).single.content.title,
'Particles and',
);
});
test('a change back to what is stored needs no save', () async {
final id = await stored(note('Particles', 'は'));
open(noteId: id);
await until((s) => s.loaded);
bloc!.add(const NoteEditorEventTitleChanged('Particles '));
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(bloc!.state.status, EditorSaveStatus.clean);
expect(gateway.written, hasLength(1));
});
group('Another device', () {
test("takes over a note with nothing unsaved, as the editor's new "
'content', () async {
final created = await notes.create('japanese', note('Particles', 'は'));
open(noteId: created.noteId);
final opened = await until((s) => s.loaded);
gateway.receiveRemoteRevision(
id: 'nrev-9000',
noteId: created.noteId,
parents: {created.revisionId},
payload: encodeNotePayload(note('Particles', 'は and が')),
);
final changed = await until((s) => s.content.body == text('は and が'));
expect(changed.revision, greaterThan(opened.revision));
expect(changed.status, EditorSaveStatus.clean);
});
test("leaves the person's unsaved text alone, and the save merges "
'with it', () async {
final created = await notes.create('japanese', note('Particles', 'は'));
open(noteId: created.noteId, idle: const Duration(hours: 1));
await until((s) => s.loaded);
bloc!.add(const NoteEditorEventTitleChanged('助詞'));
await until((s) => s.status == EditorSaveStatus.editing);
gateway.receiveRemoteRevision(
id: 'nrev-9000',
noteId: created.noteId,
parents: {created.revisionId},
payload: encodeNotePayload(note('Particles', 'は and が')),
);
await until((s) => s.saved.body == text('は and が'));
expect(bloc!.state.content, note('助詞', 'は'));
bloc!.add(const NoteEditorEventSaveRequested());
await until((s) => s.content == note('助詞', 'は and が'));
final merged = (await notes.notesOf('japanese')).single;
expect(merged.content, note('助詞', 'は and が'));
expect(merged.hasConflict, isFalse);
});
test('a body both changed is a conflict: nothing saves until a version '
'is kept, which settles it', () async {
final created = await notes.create('japanese', note('Particles', 'は'));
open(noteId: created.noteId, idle: const Duration(hours: 1));
await until((s) => s.loaded);
bloc!.add(NoteEditorEventBodyChanged(text('は: topic')));
await until((s) => s.status == EditorSaveStatus.editing);
gateway.receiveRemoteRevision(
id: 'nrev-9000',
noteId: created.noteId,
parents: {created.revisionId},
payload: encodeNotePayload(note('Particles', 'が: subject')),
);
await until((s) => s.saved.body == text('が: subject'));
bloc!.add(const NoteEditorEventSaveRequested());
final conflicted = await until((s) => s.unresolved.isNotEmpty);
expect(conflicted.conflicts[NoteField.body], [
text('は: topic'),
text('が: subject'),
]);
final writes = gateway.written.length;
bloc!
..add(NoteEditorEventBodyChanged(text('typed meanwhile')))
..add(const NoteEditorEventSaveRequested());
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(gateway.written, hasLength(writes));
bloc!.add(NoteEditorEventVersionChosen(NoteField.body, text('は: topic')));
final settled = await until(
(s) => s.conflicts.isEmpty && s.status == EditorSaveStatus.saved,
);
expect(settled.content.body, text('は: topic'));
expect(settled.revision, greaterThan(conflicted.revision));
final stored = (await notes.notesOf('japanese')).single;
expect(stored.hasConflict, isFalse);
expect(stored.content.body, text('は: topic'));
});
test(
'a note deleted elsewhere is gone, and nothing saves into it',
() async {
final id = await stored(note('Particles', 'は'));
open(noteId: id);
await until((s) => s.loaded);
gateway.receiveRemoteDeletion(id);
await until((s) => s.gone);
final writes = gateway.written.length;
bloc!.add(const NoteEditorEventTitleChanged('助詞'));
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(gateway.written, hasLength(writes));
},
);
});
test('tags chosen for a stored note are stored at once', () async {
final id = await stored(note('Particles', 'は'));
final grammar = await tags.create('grammar');
open(noteId: id);
await until((s) => s.loaded);
bloc!.add(NoteEditorEventTagsChanged({grammar}));
await until((s) => s.savedTagIds.contains(grammar));
expect((await tags.read()).notesWith(grammar), {id});
});
test('a failed save says so, and the next pause tries again', () async {
final id = await stored(note('Particles', 'は'));
open(noteId: id);
await until((s) => s.loaded);
gateway.failWrites = Exception('disk');
bloc!.add(const NoteEditorEventTitleChanged('助詞'));
await until((s) => s.status == EditorSaveStatus.failed);
gateway.failWrites = null;
bloc!.add(const NoteEditorEventTitleChanged('助詞!'));
await until((s) => s.status == EditorSaveStatus.saved);
expect((await notes.notesOf('japanese')).single.content.title, '助詞!');
});
}

View file

@ -0,0 +1,82 @@
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/bloc/note_list_bloc.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/fake_note_gateway.dart';
void main() {
late NoteRepository notes;
late NoteDeletions deletions;
late NoteListBloc bloc;
setUp(() {
notes = NoteRepository(FakeNoteGateway());
deletions = NoteDeletions(notes, undoWindow: const Duration(hours: 1));
bloc = NoteListBloc(notes, deletions, 'japanese')
..add(const NoteListEventStarted());
});
tearDown(() async {
await bloc.close();
await deletions.dispose();
await notes.dispose();
});
Future<String> note(String title) async => (await notes.create(
'japanese',
NoteContent(title: title, body: RichContent.empty()),
)).noteId;
Future<List<String>> titles() async => [
for (final note in (await bloc.stream.firstWhere(
(s) => s.loaded && !s.failed,
)).notes)
note.content.title,
];
test('lists the notes in order, and leaves out one whose deletion waits '
'until undone', () async {
await note('Particles');
final verbs = await note('Verbs');
await bloc.stream.firstWhere((s) => s.notes.length == 2);
deletions.delete(verbs);
final waiting = await bloc.stream.firstWhere((s) => s.notes.length == 1);
expect(waiting.notes.single.content.title, 'Particles');
deletions.undo(verbs);
final back = await bloc.stream.firstWhere((s) => s.notes.length == 2);
expect(
[for (final n in back.notes) n.content.title],
[
'Particles',
'Verbs',
],
);
});
test('moves a note', () async {
await note('Particles');
final verbs = await note('Verbs');
await bloc.stream.firstWhere((s) => s.notes.length == 2);
bloc.add(NoteListEventMoveRequested(verbs, 0));
final moved = await bloc.stream.firstWhere(
(s) => s.notes.first.id == verbs,
);
expect(
[for (final n in moved.notes) n.content.title],
[
'Verbs',
'Particles',
],
);
});
test('says when there are none', () async {
expect(await titles(), isEmpty);
});
}

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});
});
}

View file

@ -0,0 +1,80 @@
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/note/model/note_merge.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_revision.dart';
/// Rich content holding just [text], enough to tell versions apart.
RichContent text(String text) => RichContent({
'document': {'type': 'page', 'note': text},
});
NoteContent note(String title, String body) =>
NoteContent(title: title, body: text(body));
/// A revision with the id [id] (ids sort by creation, like UUID v7).
NoteRevision revision(
String id,
NoteContent content, {
Set<String> parents = const {},
}) => NoteRevision(id: id, parents: parents, content: content);
void main() {
test('the newest revision of a chain is the current one', () {
final merged = mergeNoteRevisions([
revision('r1', note('Particles', 'は marks the topic')),
revision('r2', note('Particles', 'は and が'), parents: {'r1'}),
])!;
expect(merged.content, note('Particles', 'は and が'));
expect(merged.heads, {'r2'});
expect(merged.conflicts, isEmpty);
});
test('a title changed on one device and the body on another both '
'survive', () {
final merged = mergeNoteRevisions([
revision('r1', note('Particles', 'は marks the topic')),
revision('r2', note('は and が', 'は marks the topic'), parents: {'r1'}),
revision('r3', note('Particles', 'は and が'), parents: {'r1'}),
])!;
expect(merged.content, note('は and が', 'は and が'));
expect(merged.conflicts, isEmpty);
});
test('a body changed differently on two devices is a conflict with both '
'versions, the newest showing', () {
final merged = mergeNoteRevisions([
revision('r1', note('Particles', 'は marks the topic')),
revision('r2', note('Particles', 'は: topic'), parents: {'r1'}),
revision('r3', note('Particles', 'が: subject'), parents: {'r1'}),
])!;
expect(merged.conflicts, {
NoteField.body: [text('は: topic'), text('が: subject')],
});
expect(merged.content.body, text('が: subject'));
expect(merged.heads, {'r2', 'r3'});
});
test('without revisions there is no note', () {
expect(mergeNoteRevisions(const []), isNull);
});
group('NoteContent', () {
test('is blank with neither title nor text', () {
expect(NoteContent.empty().isBlank, isTrue);
expect(note(' ', '').isBlank, isTrue);
expect(note('Particles', '').isBlank, isFalse);
expect(
NoteContent(body: RichContent.plain('は')).isBlank,
isFalse,
);
});
test('is stored with its title trimmed', () {
expect(note(' Particles ', 'は').normalized, note('Particles', 'は'));
});
});
}

View file

@ -0,0 +1,27 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations_en.dart';
import 'package:halkyon_learn_flutter/src/feature/note/widget/edited_ago.dart';
import 'package:intl/date_symbol_data_local.dart';
void main() {
final l10n = AppLocalizationsEn();
final now = DateTime(2026, 10, 2, 15);
String ago(DateTime editedAt) => editedAgo(l10n, editedAt, now: now);
// The app's localizations do this as they load.
setUpAll(() => initializeDateFormatting('en'));
test('says how long ago, by the clock within a day', () {
expect(ago(now.subtract(const Duration(seconds: 30))), 'just now');
expect(ago(now.subtract(const Duration(minutes: 1))), '1 minute ago');
expect(ago(now.subtract(const Duration(minutes: 59))), '59 minutes ago');
expect(ago(now.subtract(const Duration(hours: 23))), '23 hours ago');
});
test('by the calendar after that, and the date from a week on', () {
expect(ago(DateTime(2026, 10, 1, 9)), 'yesterday');
expect(ago(DateTime(2026, 9, 28, 23)), '4 days ago');
expect(ago(DateTime(2026, 9, 20, 12)), 'Sep 20, 2026');
});
}

View file

@ -0,0 +1,251 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.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_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
import '../../../support/fake_note_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
/// Rich content with one paragraph of [text].
RichContent paragraph(String text) => RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': text},
],
},
},
],
},
});
void main() {
late FakeStudysetGateway studysets;
late FakeNoteGateway noteGateway;
setUp(() {
studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
);
noteGateway = FakeNoteGateway();
});
/// Shows the app at [location], wide enough for the desktop layout.
Future<void> showApp(WidgetTester tester, String location) async {
tester.view.physicalSize = const Size(1200, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(studysetGateway: studysets, noteGateway: noteGateway),
location: location,
);
// Flushes the editor's and views' timers.
await tester.pump(const Duration(seconds: 1));
}
Future<void> openNotesTab(WidgetTester tester) async {
await tester.tap(find.text('Notes'));
await tester.pumpAndSettle();
}
/// A note stored on this device before the app starts; returns its id and
/// first revision.
Future<({String noteId, String revisionId})> stored(
String title,
String body,
) {
final notes = NoteRepository(noteGateway);
addTearDown(notes.dispose);
return notes.create(
'japanese',
NoteContent(title: title, body: paragraph(body)),
);
}
testWidgets('the Notes tab lists the notes, an untitled one so, with when '
'each was edited', (tester) async {
await stored('Particles', 'は marks the topic');
await stored('', 'A thought');
await showApp(tester, RoutePaths.studyset('japanese'));
await openNotesTab(tester);
expect(find.text('Particles'), findsOneWidget);
expect(find.text('Untitled note'), findsOneWidget);
expect(find.text('Edited just now'), findsNWidgets(2));
});
testWidgets('without notes, the tab says so', (tester) async {
await showApp(tester, RoutePaths.studyset('japanese'));
await openNotesTab(tester);
expect(find.text('No notes yet'), findsOneWidget);
});
testWidgets('Create a note opens a new note; leaving saves it, and it '
'shows in the tab', (tester) async {
await showApp(tester, RoutePaths.studyset('japanese'));
await tester.tap(find.text('Add material'));
await tester.pumpAndSettle();
await tester.tap(find.text('Create a note'));
// A menu item acts in the frame after its menu closes.
await tester.pump();
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.byType(RichContentEditor), findsOneWidget);
await tester.enterText(find.byType(TextField), 'Particles');
await tester.pump();
await tester.pageBack();
await tester.pumpAndSettle();
await openNotesTab(tester);
expect(find.text('Particles'), findsOneWidget);
expect(noteGateway.written, hasLength(1));
});
testWidgets('a note is one card below a bare app bar: its title, its tags '
'behind Add tags, a line, then its text', (tester) async {
final created = await stored('Particles', 'は: topic');
await showApp(tester, RoutePaths.note('japanese', created.noteId));
await tester.pumpAndSettle();
final card = find.byType(SectionCard);
expect(card, findsOneWidget);
for (final part in [
find.text('Particles'),
find.widgetWithText(ActionChip, 'Add tags'),
find.byType(Divider),
find.byType(RichContentEditor),
]) {
expect(find.descendant(of: card, matching: part), findsOneWidget);
}
final title = tester.getRect(find.text('Particles'));
final tags = tester.getRect(find.text('Add tags'));
final line = tester.getRect(find.byType(Divider));
final text = tester.getRect(find.byType(RichContentEditor));
expect(title.top, lessThan(tags.top));
expect(tags.bottom, lessThanOrEqualTo(line.top));
expect(line.bottom, lessThanOrEqualTo(text.top));
expect(
find.descendant(of: find.byType(AppBar), matching: find.byType(Text)),
findsNothing,
);
});
testWidgets("a note's More info shows its ids, to copy", (
tester,
) async {
final created = await stored('Particles', 'は');
await showApp(tester, RoutePaths.studyset('japanese'));
await openNotesTab(tester);
await tester.tap(find.byTooltip('Note options'));
await tester.pumpAndSettle();
await tester.tap(find.text('More info'));
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('Item ID'), findsOneWidget);
expect(find.text(created.noteId), findsOneWidget);
expect(find.text('japanese'), findsOneWidget);
});
testWidgets('deleting asks, takes the note from the list, and Undo brings '
'it back', (tester) async {
await stored('Particles', 'は');
await showApp(tester, RoutePaths.studyset('japanese'));
await openNotesTab(tester);
await tester.tap(find.byTooltip('Note options'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(find.text('Delete note?'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
expect(find.text('Note deleted'), findsOneWidget);
expect(find.text('Particles'), findsNothing);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle();
expect(find.text('Particles'), findsOneWidget);
expect(noteGateway.deletions, isEmpty);
});
testWidgets('a note changed differently on two devices shows both '
'versions; keeping one saves it and writing goes on', (tester) async {
final created = await stored('Particles', 'は marks the topic');
final notes = NoteRepository(noteGateway);
addTearDown(notes.dispose);
await notes.saveEdit(
created.noteId,
NoteContent(title: 'Particles', body: paragraph('は: topic')),
basedOn: {created.revisionId},
);
noteGateway.receiveRemoteRevision(
id: 'nrev-9000',
noteId: created.noteId,
parents: {created.revisionId},
payload: encodeNotePayload(
NoteContent(title: 'Particles', body: paragraph('が: subject')),
),
);
await showApp(tester, RoutePaths.note('japanese', created.noteId));
expect(
find.textContaining('changed differently on another device'),
findsOneWidget,
);
expect(find.text('Keep this'), findsNWidgets(2));
await tester.tap(find.text('Keep this').first);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.text('Keep this'), findsNothing);
expect(find.text('Saved'), findsOneWidget);
expect(
(await notes.notesOf('japanese')).single.content.body,
paragraph('は: topic'),
);
});
testWidgets('a note deleted on another device says it no longer exists', (
tester,
) async {
final created = await stored('Particles', 'は');
await showApp(tester, RoutePaths.note('japanese', created.noteId));
expect(find.text('Particles'), findsOneWidget);
noteGateway.receiveRemoteDeletion(created.noteId);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.textContaining('no longer exists'), findsOneWidget);
});
}