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,217 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/profile/public_person.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.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_asset_gateway.dart';
import '../../support/fake_asset_server_gateway.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_feedback_gateways.dart';
import '../../support/fake_learning_path_gateway.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/memory_asset_file_cache.dart';
const origin = PublishedRef(
publication: 'publication-1',
item: 'card-of-alice',
version: 3,
);
final image = Uint8List.fromList(utf8.encode('an image'));
final imageHash = sha256.convert(image).toString();
void main() {
late FakeFeedbackWriteGateway written;
late FakeFeedbackServerGateway server;
late CardRepository cards;
late NoteRepository notes;
late AssetRepository assets;
late FeedbackRepository feedback;
setUp(() {
written = FakeFeedbackWriteGateway();
server = FakeFeedbackServerGateway(files: {imageHash: image});
cards = CardRepository(FakeCardGateway());
notes = NoteRepository(FakeNoteGateway());
assets = AssetRepository(
FakeAssetGateway(),
FakeAssetServerGateway(),
MemoryAssetFileCache(),
);
feedback = FeedbackRepository(
written,
server,
cards,
notes,
LearningPathRepository(FakeLearningPathGateway()),
assets,
);
});
test('a report names what it is about, as of which release', () async {
await feedback.report(
origin,
kind: FeedbackTargetKind.card,
category: ReportCategory.typo,
body: ' 猫 is misspelt. ',
);
final thread = written.threads.single;
expect(thread.publicationId, 'publication-1');
expect(thread.kind, FeedbackKind.report);
expect(thread.json['target'], {
'kind': 'card',
'item': 'card-of-alice',
'version': 3,
});
expect(thread.json['category'], 'typo');
expect(thread.json['body'], '猫 is misspelt.');
});
test('a suggestion proposes the card as the learner has it, without what '
'is theirs alone, and describes the files it names', () async {
final asset = await assets.add(
image,
fileName: 'cat.png',
mimeType: 'image/png',
);
await feedback.suggestCard(
CardContent(
front: RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'image',
'data': {'url': 'asset:$asset'},
},
],
},
}),
back: RichContent.plain('a cat'),
published: origin,
report: CardReport(
category: ReportCategory.typo,
at: DateTime.utc(2026, 10, 9),
),
),
body: 'A picture helps.',
);
final thread = written.threads.single;
expect(thread.kind, FeedbackKind.suggestion);
final proposed = thread.json['proposed']! as Map<String, Object?>;
expect(proposed.containsKey('published'), isFalse);
expect(proposed.containsKey('report'), isFalse);
expect(thread.json['files'], [
{
'assetId': asset,
'sha256': imageHash,
'mimeType': 'image/png',
'fileName': 'cat.png',
},
]);
});
test("an accepted suggestion becomes the author's own card, its files "
'theirs, and is recorded so', () async {
final id = await cards.create(
'japanese',
CardContent(
front: RichContent.plain('猫'),
back: RichContent.plain('cat'),
report: CardReport(
category: ReportCategory.other,
note: 'Mine.',
at: DateTime.utc(2026, 10, 9),
),
),
);
const learnersAsset = '0192aaaa-0000-7000-8000-00000000abcd';
await feedback.accept(
FeedbackThread(
threadId: 'thread-1',
publicId: 'publication-1',
studysetTitle: 'Japanese',
location: 'halkyon.eu/@alice/japanese',
kind: FeedbackKind.suggestion,
writer: const PublicPerson(handle: 'bob'),
target: FeedbackTarget(
kind: FeedbackTargetKind.card,
item: id,
version: 3,
),
createdAt: DateTime.utc(2026, 10, 9),
proposed: {
'schemaVersion': 9,
'type': 'frontBack',
'front': RichContent.plain('asset:$learnersAsset').toJson(),
'back': RichContent.plain('a cat').toJson(),
},
files: [
FeedbackFile(
assetId: learnersAsset,
sha256: imageHash,
mimeType: 'image/png',
),
],
),
);
final card = (await cards.byId(id))!;
expect(card.content.back.plainText, 'a cat');
final front = card.content.front.plainText;
expect(front, isNot(contains(learnersAsset)));
expect(await assets.idOf(imageHash), front.substring('asset:'.length));
expect(card.content.report?.note, 'Mine.');
expect(written.decisions.single, (
threadId: 'thread-1',
decision: FeedbackDecision.accepted,
));
});
test('a suggestion on something gone is not taken, and nothing is '
'recorded', () async {
await expectLater(
feedback.accept(
FeedbackThread(
threadId: 'thread-1',
publicId: 'publication-1',
studysetTitle: 'Japanese',
location: 'halkyon.eu/@alice/japanese',
kind: FeedbackKind.suggestion,
writer: const PublicPerson(handle: 'bob'),
target: const FeedbackTarget(
kind: FeedbackTargetKind.note,
item: 'gone',
version: 3,
),
createdAt: DateTime.utc(2026, 10, 9),
proposed: {
'schemaVersion': 3,
'title': 'Particles',
'body': RichContent.plain('は').toJson(),
},
),
),
throwsA(isA<SuggestionNotTaken>()),
);
expect(written.decisions, isEmpty);
expect(await notes.notesOf('japanese'), isEmpty);
expect(NoteContent.empty().published, isNull);
});
}

View file

@ -0,0 +1,308 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/profile/public_person.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_tile.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.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:material_ui/material_ui.dart' hide Card;
import '../../support/fake_card_gateway.dart';
import '../../support/fake_feedback_gateways.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/pump_app.dart';
import '../../support/test_dependencies.dart';
const origin = PublishedRef(publication: 'pub-1', item: 'cat', version: 2);
CardContent card(String front, String back, {PublishedRef? published}) =>
CardContent(
front: RichContent.plain(front),
back: RichContent.plain(back),
published: published,
);
/// A suggestion of Bob's on Alice's card [item], proposing its back be
/// [back].
FeedbackThread suggestion(String item, String back, {bool unseen = false}) =>
FeedbackThread(
threadId: 'thread-1',
publicId: 'pub-1',
studysetTitle: 'Japanese',
location: 'halkyon.eu/@alice/japanese',
kind: FeedbackKind.suggestion,
writer: const PublicPerson(handle: 'bob', displayName: 'Bob'),
target: FeedbackTarget(
kind: FeedbackTargetKind.card,
item: item,
version: 2,
),
createdAt: DateTime.utc(2026, 10, 9),
body: 'Kinder.',
proposed: {
'schemaVersion': 9,
'type': 'frontBack',
'front': RichContent.plain('猫').toJson(),
'back': RichContent.plain(back).toJson(),
},
forMe: true,
unseen: unseen,
);
void main() {
late FakeStudysetGateway studysets;
late FakeCardGateway cardGateway;
late CardRepository cards;
late FakeFeedbackWriteGateway written;
late FakeFeedbackServerGateway server;
setUp(() {
studysets = FakeStudysetGateway();
cardGateway = FakeCardGateway();
cards = CardRepository(cardGateway);
written = FakeFeedbackWriteGateway();
server = FakeFeedbackServerGateway();
});
tearDown(() => cards.dispose());
/// A copy of Alice's Japanese, under [license], on this device.
void copy({String license = 'CC-BY-SA-4.0'}) => studysets.receiveRemoteChange(
'japanese',
encodeStudysetPayload(
StudysetContent(
title: 'Japanese',
description: RichContent.empty(),
license: license,
published: const PublishedRef(
publication: 'pub-1',
item: 'root',
version: 2,
),
publishedAt: 'halkyon.eu/@alice/japanese',
),
),
);
Future<void> show(
WidgetTester tester,
String location, {
bool identified = true,
}) async {
tester.view.physicalSize = const Size(1200, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
authGateway: FakeAuthGateway(
status: SignedIn(
authUserId: 'github-person',
isIdentified: identified,
),
),
studysetGateway: studysets,
cardGateway: cardGateway,
feedbackWriteGateway: written,
feedbackServerGateway: server,
),
location: location,
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
Future<void> chooseCardOption(WidgetTester tester, String item) async {
await tester.tap(
find.descendant(
of: find.byType(CardTile),
matching: find.byTooltip('Options'),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text(item));
// A menu item acts once the menu has closed, a frame later.
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
}
testWidgets("a copy's card is reported to its author too, when asked", (
tester,
) async {
copy();
await cards.create('japanese', card('猫', 'cat', published: origin));
await show(tester, RoutePaths.studyset('japanese'));
await chooseCardOption(tester, 'Report an error');
await tester.tap(find.text('Send to the author too'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Report'));
await tester.pumpAndSettle();
expect(find.text('Sent to the author.'), findsOneWidget);
final thread = written.threads.single;
expect(thread.kind, FeedbackKind.report);
expect(thread.json['target'], {
'kind': 'card',
'item': 'cat',
'version': 2,
});
});
testWidgets("a card of the person's own is reported to them alone", (
tester,
) async {
copy();
await cards.create('japanese', card('猫', 'cat'));
await show(tester, RoutePaths.studyset('japanese'));
await chooseCardOption(tester, 'Report an error');
expect(find.text('Send to the author too'), findsNothing);
});
testWidgets("a copy's menu reports the studyset to its author and shows "
'what is reported and suggested on it', (tester) async {
copy();
server.gathered = [suggestion('cat', 'a cat')];
await show(tester, RoutePaths.studyset('japanese'));
Future<void> choose(String option) async {
// The studyset's options, in the app bar: the card list has none.
await tester.tap(find.byTooltip('Options').first);
await tester.pumpAndSettle();
await tester.tap(find.text(option));
await tester.pumpAndSettle();
}
await choose('Report to the author');
await tester.tap(find.text('Content'));
await tester.tap(find.widgetWithText(FilledButton, 'Report'));
await tester.pumpAndSettle();
expect(written.threads.single.json['target'], {
'kind': 'studyset',
'item': 'root',
'version': 2,
});
await choose('Reports and suggestions');
expect(find.textContaining('Kinder.'), findsOneWidget);
});
testWidgets("a copy's card is suggested to its author from its editor", (
tester,
) async {
copy();
final id = await cards.create(
'japanese',
card('猫', 'a cat', published: origin),
);
await show(tester, RoutePaths.card('japanese', id));
await tester.tap(find.byTooltip('More actions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Suggest to the author'));
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, 'Kinder.');
await tester.tap(find.text('Send'));
await tester.pumpAndSettle();
expect(find.text('Suggestion sent.'), findsOneWidget);
final thread = written.threads.single;
expect(thread.kind, FeedbackKind.suggestion);
expect(thread.json['body'], 'Kinder.');
expect(
(thread.json['proposed']! as Map<String, Object?>).containsKey(
'published',
),
isFalse,
);
});
testWidgets('no suggestion under "no derivatives"', (tester) async {
copy(license: 'CC-BY-ND-4.0');
final id = await cards.create(
'japanese',
card('猫', 'a cat', published: origin),
);
await show(tester, RoutePaths.card('japanese', id));
await tester.tap(find.byTooltip('More actions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Suggest to the author'));
await tester.pump();
await tester.pump();
await tester.pumpAndSettle();
expect(
find.text('Its license forbids adaptations: no suggestions.'),
findsOneWidget,
);
expect(written.threads, isEmpty);
});
testWidgets("the author's inbox shows a suggestion beside their card, and "
'accepting it changes the card', (tester) async {
final id = await cards.create('japanese', card('猫', 'cat'));
server.gathered = [suggestion(id, 'a kind cat', unseen: true)];
await show(tester, RoutePaths.profile);
// The Profile tab says something is new.
expect(find.text('1'), findsWidgets);
expect(find.text('Inbox'), findsOneWidget);
await tester.tap(find.textContaining('Kinder.'));
await tester.pumpAndSettle();
expect(server.seen, ['thread-1']);
expect(find.text('Proposed'), findsOneWidget);
expect(find.text('Yours now'), findsOneWidget);
await tester.tap(find.text('Accept'));
await tester.pumpAndSettle();
expect(
find.text(
'Taken into your studyset. The next release credits its writer.',
),
findsOneWidget,
);
expect((await cards.byId(id))!.content.back.plainText, 'a kind cat');
expect(written.decisions.single.decision, FeedbackDecision.accepted);
});
testWidgets('the author replies, closes, and blocks after asking', (
tester,
) async {
server.gathered = [suggestion('gone', 'a cat')];
await show(tester, RoutePaths.profile);
await tester.tap(find.textContaining('Kinder.'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, 'Thanks!');
await tester.tap(find.byTooltip('Send'));
await tester.pumpAndSettle();
expect(written.messages.single.json['body'], 'Thanks!');
await tester.tap(find.text('Close'));
await tester.pumpAndSettle();
expect(written.decisions.single.decision, FeedbackDecision.closed);
await tester.tap(find.text('Block Bob'));
await tester.pumpAndSettle();
expect(find.text('Block Bob?'), findsOneWidget);
await tester.tap(find.text('Block Bob').last);
await tester.pumpAndSettle();
expect(server.blocked, ['bob']);
});
testWidgets('a person without an identity has no inbox', (tester) async {
await show(tester, RoutePaths.profile, identified: false);
expect(find.text('Inbox'), findsNothing);
});
}