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,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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue