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
2 changed files with 0 additions and 0 deletions
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue