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