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