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,306 @@
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/profile/public_person.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/bloc/discussion_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/bloc/discussion_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/data/discussion_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/discussion/model/discussion.dart';
import '../../support/fake_discussion_gateway.dart';
const card = DiscussionTarget(
kind: DiscussionTargetKind.card,
item: 'card-1',
version: 2,
);
/// Rich text holding an image of the person's own files.
RichContent withImage() => RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'image',
'data': {'url': 'asset:1234'},
},
],
},
});
Matcher refused(DiscussionRefusal reason) => throwsA(
isA<DiscussionRefusedException>().having(
(error) => error.reason,
'reason',
reason,
),
);
void main() {
group('DiscussionRepository', () {
test('refuses blank text, a blank title or comment, and images or '
'sounds, before asking the instance', () async {
final gateway = FakeDiscussionGateway();
final repository = DiscussionRepository(gateway);
addTearDown(repository.dispose);
await expectLater(
repository.ask('pub-1', card, 'Why?', RichContent.empty()),
refused(DiscussionRefusal.empty),
);
await expectLater(
repository.ask('pub-1', card, ' ', RichContent.plain('Why?')),
refused(DiscussionRefusal.empty),
);
await expectLater(
repository.ask('pub-1', card, 'Why?', withImage()),
refused(DiscussionRefusal.withFiles),
);
await expectLater(
repository.answer('q0', withImage()),
refused(DiscussionRefusal.withFiles),
);
await expectLater(
repository.comment('q0', ' '),
refused(DiscussionRefusal.empty),
);
expect(gateway.questions, isEmpty);
});
test('says when the person wrote something, so lists read anew', () async {
final gateway = FakeDiscussionGateway();
final repository = DiscussionRepository(gateway);
addTearDown(repository.dispose);
var changes = 0;
repository.changes.listen((_) => changes++);
final id = await repository.ask(
'pub-1',
card,
' Meaning of は? ',
RichContent.plain('Why は?'),
);
await repository.comment(id, 'Bump.');
await pumpEventQueue();
expect(changes, 2);
expect(gateway.questions[id]!.question.title, 'Meaning of は?');
});
});
group('DiscussionListBloc', () {
test('reads the questions of a topic, says whether the person may ask, '
'and finds questions by their titles', () async {
final gateway = FakeDiscussionGateway()
..addQuestion('Meaning of は', topics: {'root', 'grammar'})
..addQuestion('Reading of 生', topics: {'root', 'kanji'});
final repository = DiscussionRepository(gateway);
final bloc = DiscussionListBloc(
repository,
publicId: 'pub-1',
topic: 'grammar',
)..add(const DiscussionListEventLoaded());
addTearDown(() async {
await bloc.close();
await repository.dispose();
});
await expectLater(
bloc.stream,
emitsThrough(
isA<DiscussionListState>().having(
(state) => state.status,
'status',
DiscussionListStatus.ready,
),
),
);
expect(
[for (final summary in bloc.state.questions) summary.title],
[
'Meaning of は',
],
);
expect(bloc.state.askRefusal, isNull);
bloc.add(const DiscussionListEventQueryChanged('生'));
await expectLater(
bloc.stream,
emitsThrough(
isA<DiscussionListState>().having(
(state) => state.questions,
'questions',
isEmpty,
),
),
);
});
test('asks a question and says which, or why it was refused', () async {
final gateway = FakeDiscussionGateway();
final repository = DiscussionRepository(gateway);
final bloc = DiscussionListBloc(repository, publicId: 'pub-1');
addTearDown(() async {
await bloc.close();
await repository.dispose();
});
bloc.add(
DiscussionListEventAsked(card, 'Why?', RichContent.plain('Why は?')),
);
await expectLater(
bloc.stream,
emitsThrough(
isA<DiscussionListState>().having(
(state) => state.asked,
'asked',
isNotNull,
),
),
);
expect(gateway.questions[bloc.state.asked]!.target, card);
gateway.refusal = DiscussionRefusal.noProfile;
bloc.add(
DiscussionListEventAsked(card, 'Again?', RichContent.plain('Hm.')),
);
await expectLater(
bloc.stream,
emitsThrough(
isA<DiscussionListState>().having(
(state) => state.refusal,
'refusal',
DiscussionRefusal.noProfile,
),
),
);
});
test('without a studyset, reads the inbox', () async {
final gateway = FakeDiscussionGateway(author: true)
..addQuestion('Meaning of は');
final repository = DiscussionRepository(gateway);
final bloc = DiscussionListBloc(repository)
..add(const DiscussionListEventLoaded());
addTearDown(() async {
await bloc.close();
await repository.dispose();
});
await expectLater(
bloc.stream,
emitsThrough(
isA<DiscussionListState>().having(
(state) => state.questions.length,
'questions',
1,
),
),
);
expect(bloc.state.questions.single.unseen, isTrue);
});
});
group('DiscussionBloc', () {
DiscussionBloc blocOf(FakeDiscussionGateway gateway, String questionId) {
final repository = DiscussionRepository(gateway);
final bloc = DiscussionBloc(repository, questionId: questionId);
addTearDown(() async {
await bloc.close();
await repository.dispose();
});
return bloc;
}
test('reads the discussion and marks it read', () async {
final gateway = FakeDiscussionGateway();
final question = gateway.addQuestion('Meaning of は').question.id;
final bloc = blocOf(gateway, question)
..add(const DiscussionEventLoaded());
await expectLater(
bloc.stream,
emitsThrough(
isA<DiscussionState>().having(
(state) => state.status,
'status',
DiscussionStatus.ready,
),
),
);
await pumpEventQueue();
expect(bloc.state.discussion!.question.title, 'Meaning of は');
expect(gateway.seen, [question]);
});
test('answers and accepts, reading anew each time; a refusal says '
'why', () async {
const bob = PublicPerson(handle: 'bob');
final gateway = FakeDiscussionGateway(me: bob);
// Bob asks: the fake's asker by default.
final question = gateway.addQuestion('Meaning of は').question.id;
final bloc = blocOf(gateway, question)
..add(const DiscussionEventLoaded())
..add(DiscussionEventAnswered(RichContent.plain('Topic marker.')));
await expectLater(
bloc.stream,
emitsThrough(
isA<DiscussionState>().having(
(state) => state.outcome,
'outcome',
DiscussionOutcome.answered,
),
),
);
final answer = bloc.state.discussion!.answers.single;
expect(answer.mine, isTrue);
bloc.add(DiscussionEventAccepted(answer.id));
await expectLater(
bloc.stream,
emitsThrough(
isA<DiscussionState>().having(
(state) => state.discussion?.acceptedAnswerId,
'accepted',
answer.id,
),
),
);
gateway.refusal = DiscussionRefusal.tooMany;
bloc.add(const DiscussionEventCommented('q0', 'More!'));
await expectLater(
bloc.stream,
emitsThrough(
isA<DiscussionState>()
.having(
(state) => state.outcome,
'outcome',
DiscussionOutcome.refused,
)
.having(
(state) => state.refusal,
'refusal',
DiscussionRefusal.tooMany,
),
),
);
});
test('a discussion gone or not the person’s to see is missing', () async {
final bloc = blocOf(FakeDiscussionGateway(), 'nothing')
..add(const DiscussionEventLoaded());
await expectLater(
bloc.stream,
emitsThrough(
isA<DiscussionState>().having(
(state) => state.status,
'status',
DiscussionStatus.missing,
),
),
);
});
});
}

View file

@ -0,0 +1,223 @@
import 'package:appflowy_editor/appflowy_editor.dart';
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/discussion/model/discussion.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';
import '../../support/fake_discussion_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/pump_app.dart';
import '../../support/test_dependencies.dart';
void main() {
late FakeStudysetGateway studysets;
late FakeDiscussionGateway discussions;
setUp(() {
studysets = FakeStudysetGateway();
discussions = FakeDiscussionGateway();
});
Future<void> show(WidgetTester tester, String location) async {
tester.view.physicalSize = const Size(1200, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
authGateway: FakeAuthGateway(
status: const SignedIn(
authUserId: 'github-person',
isIdentified: true,
),
),
studysetGateway: studysets,
discussionGateway: discussions,
),
location: location,
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
/// Types [text] into the editor on screen.
Future<void> typeInEditor(WidgetTester tester, String text) async {
await tester.tap(find.byType(AppFlowyEditor));
await tester.pump(const Duration(seconds: 1));
final editorState =
tester.widget<AppFlowyEditor>(find.byType(AppFlowyEditor)).editorState
..selection = Selection.collapsed(Position(path: [0]));
await tester.pump(const Duration(seconds: 1));
await editorState.insertTextAtCurrentSelection(text);
await tester.pump(const Duration(seconds: 1));
}
testWidgets("a copy's menu opens its discussions; a question opens with its "
'answers, and is read', (tester) async {
studysets.receiveRemoteChange(
'japanese',
encodeStudysetPayload(
StudysetContent(
title: 'Japanese',
description: RichContent.empty(),
license: 'CC-BY-SA-4.0',
published: const PublishedRef(
publication: 'pub-1',
item: 'root',
version: 2,
),
publishedAt: 'halkyon.eu/@alice/japanese',
),
),
);
final question = discussions.addQuestion(
'Meaning of は here?',
answers: [discussions.answerBy('The topic marker.')],
);
discussions.addQuestion('Elsewhere', publicId: 'pub-2');
await show(tester, RoutePaths.studyset('japanese'));
await tester.tap(find.byTooltip('Options').first);
await tester.pumpAndSettle();
await tester.tap(find.text('Discussions'));
await tester.pumpAndSettle();
expect(find.text('Meaning of は here?'), findsOneWidget);
expect(find.text('Elsewhere'), findsNothing);
expect(find.textContaining('1 answer'), findsOneWidget);
await tester.tap(find.text('Meaning of は here?'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('The topic marker.', findRichText: true), findsOneWidget);
expect(find.textContaining('carol'), findsOneWidget);
expect(discussions.seen, [question.question.id]);
});
testWidgets('asking about a topic posts the question and opens it', (
tester,
) async {
await show(
tester,
RoutePaths.discussions(
'pub-1',
topic: 'grammar',
version: 2,
title: 'Grammar',
),
);
expect(find.text('No questions yet'), findsOneWidget);
await tester.tap(find.text('Ask a question'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextField, 'Title'),
'は or が?',
);
await typeInEditor(tester, 'Which goes after 私?');
await tester.tap(find.text('Post'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
final asked = discussions.questions.values.single;
expect(asked.question.title, 'は or が?');
expect(
asked.target,
const DiscussionTarget(
kind: DiscussionTargetKind.studyset,
item: 'grammar',
version: 2,
),
);
expect(find.text('Question'), findsOneWidget);
expect(find.text('は or が?'), findsOneWidget);
});
testWidgets('the asker accepts an answer, answers and comments', (
tester,
) async {
const bob = PublicPerson(handle: 'bob');
discussions = FakeDiscussionGateway(me: bob);
final answer = discussions.answerBy('The topic marker.');
final question = discussions.addQuestion(
'Meaning of は here?',
answers: [answer],
);
await show(
tester,
RoutePaths.discussion('pub-1', question.question.id),
);
await tester.tap(find.text('Accept'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Answer accepted.'), findsOneWidget);
expect(find.text('Accepted'), findsOneWidget);
await tester.tap(find.text('Write an answer'));
await tester.pumpAndSettle();
await typeInEditor(tester, 'Thanks, I see.');
await tester.tap(find.text('Post'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Answer posted.'), findsOneWidget);
expect(find.text('Thanks, I see.', findRichText: true), findsOneWidget);
await tester.tap(find.text('Add a comment').first);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, 'Good to know.');
// Post enables a frame after the text.
await tester.pump();
await tester.tap(find.text('Post'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Comment posted.'), findsOneWidget);
expect(find.text('Good to know.'), findsOneWidget);
});
testWidgets('without a public profile, the person reads and is told how to '
'take part', (tester) async {
discussions = FakeDiscussionGateway(
refusal: DiscussionRefusal.noProfile,
);
final question = discussions.addQuestion('Meaning of は here?');
await show(tester, RoutePaths.discussions('pub-1'));
expect(find.text('Meaning of は here?'), findsOneWidget);
expect(find.text('Ask a question'), findsNothing);
expect(find.textContaining('make a public profile'), findsOneWidget);
await tester.tap(find.text('Meaning of は here?'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Write an answer'), findsNothing);
expect(find.text('Add a comment'), findsNothing);
expect(find.textContaining('make a public profile'), findsOneWidget);
expect(discussions.seen, [question.question.id]);
});
testWidgets('the inbox lists the discussions the person follows, new ones '
'counted on the Profile tab', (tester) async {
discussions = FakeDiscussionGateway(author: true)
..addQuestion('Meaning of は here?');
await show(tester, RoutePaths.profile);
expect(find.text('Discussions'), findsOneWidget);
expect(find.text('Meaning of は here?'), findsOneWidget);
expect(find.text('1'), findsWidgets);
await tester.tap(find.text('Meaning of は here?'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text('Question'), findsOneWidget);
expect(discussions.seen, hasLength(1));
});
}