feat(discussions): questions on published studysets, answered by learners
A learner asks about a published studyset, a topic in it, or one card, note or step of a release, and others answer, as on Stack Exchange (ADR 0013): questions and answers in the app's editor without images, sounds or videos, comments of up to 600 characters under the question and each answer. There are no votes: the asker accepts an answer, which comes first, then the oldest; the author's posts are marked. The instance reads from git's file list which topics a question's target sits in, so a topic lists what is asked within it and below it. Posts live in two server-only tables, written through endpoints, so the synced tables stay as they are and v0.17.0 keeps syncing. Everyone signed in reads; writing needs an identity and a public profile; the studyset's author hides posts and blocks people with feedback's blocks; a withdrawn studyset takes no new questions; anyone writes at most 30 posts an hour. A post with others' replies under it is emptied when deleted, else deleted; deletePerson deletes what a person wrote the same way, and a published studyset's deletion takes its discussions with it. The migration adds Serverpod's gen_random_uuid_v7(), discussion_post and discussion_seen with their indexes and cascading foreign keys, and changes nothing else. In the app, discussions open from a copy's studyset or topic menu, a studied card's "Ask about this", Explore's summary and the Publish sheet; the Profile tab's inbox lists those the person follows, and its count includes what is new in them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
25237b8011
commit
5efa552489
6 changed files with 28214 additions and 27 deletions
|
|
@ -1,5 +1,6 @@
|
|||
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/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_gateway.dart';
|
||||
|
|
@ -20,6 +21,7 @@ import 'package:material_ui/material_ui.dart';
|
|||
|
||||
import '../support/fake_auth_gateway.dart';
|
||||
import '../support/fake_card_gateway.dart';
|
||||
import '../support/fake_discussion_gateway.dart';
|
||||
import '../support/fake_learning_path_gateway.dart';
|
||||
import '../support/fake_note_gateway.dart';
|
||||
import '../support/fake_settings_gateway.dart';
|
||||
|
|
@ -49,6 +51,7 @@ void main() {
|
|||
late FakeLearningPathGateway pathGateway;
|
||||
late FakeTagGateway tagGateway;
|
||||
late FakeSettingsGateway settingsGateway;
|
||||
late FakeDiscussionGateway discussions;
|
||||
|
||||
setUp(() async {
|
||||
studysets = FakeStudysetGateway()
|
||||
|
|
@ -77,6 +80,18 @@ void main() {
|
|||
pathGateway = FakeLearningPathGateway();
|
||||
tagGateway = FakeTagGateway();
|
||||
settingsGateway = FakeSettingsGateway();
|
||||
final someone = PublicPerson(
|
||||
handle: long('someone', 30).replaceAll(' ', '_'),
|
||||
displayName: long('Someone', TextLimits.name * 3),
|
||||
);
|
||||
discussions = FakeDiscussionGateway(studysetTitle: studysetTitle)
|
||||
..addQuestion(
|
||||
long('Meaning', TextLimits.title * 2),
|
||||
by: someone,
|
||||
answers: [
|
||||
FakeDiscussionGateway().answerBy(long('Answer', 300), by: someone),
|
||||
],
|
||||
);
|
||||
|
||||
final cards = CardRepository(cardGateway);
|
||||
final cardId = await cards.create(
|
||||
|
|
@ -131,6 +146,7 @@ void main() {
|
|||
pathGateway: pathGateway,
|
||||
tagGateway: tagGateway,
|
||||
settingsGateway: settingsGateway,
|
||||
discussionGateway: discussions,
|
||||
),
|
||||
location: location,
|
||||
);
|
||||
|
|
@ -184,6 +200,20 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets("on $window, a long studyset's discussions and a question", (
|
||||
tester,
|
||||
) async {
|
||||
await show(
|
||||
tester,
|
||||
RoutePaths.discussions('pub-1', title: topicTitle),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.textContaining('Meaning'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('on $window, studying a long studyset', (tester) async {
|
||||
await show(tester, RoutePaths.study('japanese'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/discussion/data/discussion_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/copy_updater.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
|
||||
|
|
@ -32,6 +33,7 @@ import 'package:material_ui/material_ui.dart';
|
|||
import '../../support/fake_asset_gateway.dart';
|
||||
import '../../support/fake_asset_server_gateway.dart';
|
||||
import '../../support/fake_card_gateway.dart';
|
||||
import '../../support/fake_discussion_gateway.dart';
|
||||
import '../../support/fake_explore_gateway.dart';
|
||||
import '../../support/fake_feedback_gateways.dart';
|
||||
import '../../support/fake_learning_path_gateway.dart';
|
||||
|
|
@ -102,6 +104,7 @@ PersonDependencies personDependencies({Future<void> Function()? onDispose}) {
|
|||
paths,
|
||||
assets,
|
||||
),
|
||||
discussions: DiscussionRepository(FakeDiscussionGateway()),
|
||||
onDispose: onDispose,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter_test/flutter_test.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/auth/model/auth_status.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_auth_gateway.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));
|
||||
});
|
||||
}
|
||||
305
halkyon_learn_flutter/test/support/fake_discussion_gateway.dart
Normal file
305
halkyon_learn_flutter/test/support/fake_discussion_gateway.dart
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
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/data/discussion_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/discussion/model/discussion.dart';
|
||||
|
||||
/// The instance's discussions in memory, as the person [me] (the
|
||||
/// studyset's author when [author]) sees them; every question on a studyset
|
||||
/// titled [studysetTitle]. Records what was written.
|
||||
class FakeDiscussionGateway({
|
||||
final PublicPerson me = const PublicPerson(handle: 'me'),
|
||||
final bool author = false,
|
||||
final String studysetTitle = 'Japanese',
|
||||
|
||||
/// Why the person may not write; null when they may.
|
||||
var DiscussionRefusal? refusal,
|
||||
}) implements DiscussionGateway {
|
||||
/// Thrown by every call while set: the instance can't be reached.
|
||||
Exception? fail;
|
||||
|
||||
/// The questions, by id, with what is in them.
|
||||
final questions = <String, Discussion>{};
|
||||
|
||||
/// The topics each question is within.
|
||||
final topicsOf = <String, Set<String>>{};
|
||||
|
||||
/// The discussions marked read, in order.
|
||||
final seen = <String>[];
|
||||
|
||||
/// The handles blocked.
|
||||
final blocked = <String>[];
|
||||
|
||||
var _next = 0;
|
||||
|
||||
/// Adds a question by [by] about [target], with [answers]; returns it.
|
||||
Discussion addQuestion(
|
||||
String title, {
|
||||
String publicId = 'pub-1',
|
||||
PublicPerson by = const PublicPerson(handle: 'bob'),
|
||||
DiscussionTarget target = const DiscussionTarget(
|
||||
kind: DiscussionTargetKind.studyset,
|
||||
item: 'root',
|
||||
version: 1,
|
||||
),
|
||||
Set<String> topics = const {'root'},
|
||||
List<DiscussionPost> answers = const [],
|
||||
List<DiscussionPost> comments = const [],
|
||||
String? acceptedAnswerId,
|
||||
String text = 'Why?',
|
||||
}) {
|
||||
final id = 'q${_next++}';
|
||||
final discussion = Discussion(
|
||||
question: DiscussionPost(
|
||||
id: id,
|
||||
kind: DiscussionPostKind.question,
|
||||
createdAt: DateTime.utc(2026, 10, 15, 12),
|
||||
by: by,
|
||||
title: title,
|
||||
content: RichContent.plain(text),
|
||||
mine: by == me,
|
||||
),
|
||||
publicId: publicId,
|
||||
studysetTitle: studysetTitle,
|
||||
location: 'halkyon.eu/@alice/japanese',
|
||||
target: target,
|
||||
answers: answers,
|
||||
comments: comments,
|
||||
acceptedAnswerId: acceptedAnswerId,
|
||||
asker: by == me,
|
||||
author: author,
|
||||
refusal: refusal,
|
||||
);
|
||||
questions[id] = discussion;
|
||||
topicsOf[id] = topics;
|
||||
return discussion;
|
||||
}
|
||||
|
||||
/// An answer by [by].
|
||||
DiscussionPost answerBy(
|
||||
String text, {
|
||||
PublicPerson by = const PublicPerson(handle: 'carol'),
|
||||
String? id,
|
||||
}) => DiscussionPost(
|
||||
id: id ?? 'a${_next++}',
|
||||
kind: DiscussionPostKind.answer,
|
||||
createdAt: DateTime.utc(2026, 10, 15, 13),
|
||||
by: by,
|
||||
content: RichContent.plain(text),
|
||||
mine: by == me,
|
||||
);
|
||||
|
||||
void _failing() {
|
||||
if (fail case final error?) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
void _writing() {
|
||||
_failing();
|
||||
if (refusal case final refusal?) {
|
||||
throw DiscussionRefusedException(refusal);
|
||||
}
|
||||
}
|
||||
|
||||
DiscussionSummary _summary(Discussion discussion) => DiscussionSummary(
|
||||
questionId: discussion.question.id,
|
||||
publicId: discussion.publicId,
|
||||
studysetTitle: discussion.studysetTitle,
|
||||
title: discussion.question.title,
|
||||
asker: discussion.question.by,
|
||||
askerIsAuthor: discussion.question.fromAuthor,
|
||||
target: discussion.target,
|
||||
createdAt: discussion.question.createdAt,
|
||||
activeAt: discussion.question.createdAt,
|
||||
answers: discussion.answers.length,
|
||||
accepted: discussion.acceptedAnswerId != null,
|
||||
mine: discussion.asker,
|
||||
unseen: !seen.contains(discussion.question.id),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<List<DiscussionSummary>> list(
|
||||
String publicId, {
|
||||
String? topic,
|
||||
DiscussionTarget? item,
|
||||
String? query,
|
||||
int page = 0,
|
||||
}) async {
|
||||
_failing();
|
||||
return [
|
||||
for (final MapEntry(key: id, value: discussion) in questions.entries)
|
||||
if (discussion.publicId == publicId &&
|
||||
(topic == null || topicsOf[id]!.contains(topic)) &&
|
||||
(item == null || discussion.target == item) &&
|
||||
(query == null ||
|
||||
discussion.question.title.toLowerCase().contains(
|
||||
query.toLowerCase(),
|
||||
)))
|
||||
_summary(discussion),
|
||||
].reversed.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Discussion> read(String questionId) async {
|
||||
_failing();
|
||||
return questions[questionId] ?? (throw DiscussionMissing());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DiscussionRefusal?> askRefusal(String publicId) async {
|
||||
_failing();
|
||||
return refusal;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> ask(
|
||||
String publicId,
|
||||
DiscussionTarget target,
|
||||
String title,
|
||||
RichContent content,
|
||||
) async {
|
||||
_writing();
|
||||
return addQuestion(
|
||||
title,
|
||||
publicId: publicId,
|
||||
by: me,
|
||||
target: target,
|
||||
topics: {target.item},
|
||||
text: content.plainText,
|
||||
).question.id;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> answer(String questionId, RichContent content) async {
|
||||
_writing();
|
||||
final discussion = questions[questionId] ?? (throw DiscussionMissing());
|
||||
final answer = DiscussionPost(
|
||||
id: 'a${_next++}',
|
||||
kind: DiscussionPostKind.answer,
|
||||
createdAt: DateTime.utc(2026, 10, 15, 14),
|
||||
by: me,
|
||||
fromAuthor: author,
|
||||
content: content,
|
||||
mine: true,
|
||||
);
|
||||
questions[questionId] = _with(
|
||||
discussion,
|
||||
answers: [
|
||||
...discussion.answers,
|
||||
answer,
|
||||
],
|
||||
);
|
||||
return answer.id;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> comment(String onId, String text) async {
|
||||
_writing();
|
||||
final MapEntry(key: id, value: discussion) = questions.entries.firstWhere(
|
||||
(entry) =>
|
||||
entry.key == onId ||
|
||||
entry.value.answers.any((answer) => answer.id == onId),
|
||||
orElse: () => throw DiscussionMissing(),
|
||||
);
|
||||
final comment = DiscussionPost(
|
||||
id: 'c${_next++}',
|
||||
kind: DiscussionPostKind.comment,
|
||||
createdAt: DateTime.utc(2026, 10, 15, 15),
|
||||
parentId: onId,
|
||||
by: me,
|
||||
text: text,
|
||||
mine: true,
|
||||
);
|
||||
questions[id] = _with(
|
||||
discussion,
|
||||
comments: [
|
||||
...discussion.comments,
|
||||
comment,
|
||||
],
|
||||
);
|
||||
return comment.id;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> edit(String postId, RichContent content, {String? title}) async {
|
||||
_writing();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> editComment(String commentId, String text) async {
|
||||
_writing();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String postId) async {
|
||||
_failing();
|
||||
questions.remove(postId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> accept(String questionId, String? answerId) async {
|
||||
_failing();
|
||||
final discussion = questions[questionId] ?? (throw DiscussionMissing());
|
||||
questions[questionId] = _with(
|
||||
discussion,
|
||||
acceptedAnswerId: answerId,
|
||||
unaccept: answerId == null,
|
||||
answers: [
|
||||
...discussion.answers.where((answer) => answer.id == answerId),
|
||||
...discussion.answers.where((answer) => answer.id != answerId),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> hide(String postId, {required bool hidden}) async {
|
||||
_failing();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<DiscussionSummary>> inbox() async {
|
||||
_failing();
|
||||
return [
|
||||
for (final discussion in questions.values)
|
||||
if (discussion.asker || discussion.author) _summary(discussion),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> unseen() async =>
|
||||
(await inbox()).where((summary) => summary.unseen).length;
|
||||
|
||||
@override
|
||||
Future<void> markSeen(String questionId) async {
|
||||
_failing();
|
||||
seen.add(questionId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> block(String handle) async {
|
||||
_failing();
|
||||
blocked.add(handle);
|
||||
}
|
||||
|
||||
Discussion _with(
|
||||
Discussion discussion, {
|
||||
List<DiscussionPost>? answers,
|
||||
List<DiscussionPost>? comments,
|
||||
String? acceptedAnswerId,
|
||||
bool unaccept = false,
|
||||
}) => Discussion(
|
||||
question: discussion.question,
|
||||
publicId: discussion.publicId,
|
||||
studysetTitle: discussion.studysetTitle,
|
||||
location: discussion.location,
|
||||
target: discussion.target,
|
||||
answers: answers ?? discussion.answers,
|
||||
comments: comments ?? discussion.comments,
|
||||
acceptedAnswerId: unaccept
|
||||
? null
|
||||
: acceptedAnswerId ?? discussion.acceptedAnswerId,
|
||||
asker: discussion.asker,
|
||||
author: discussion.author,
|
||||
refusal: discussion.refusal,
|
||||
);
|
||||
}
|
||||
|
|
@ -36,6 +36,8 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart
|
|||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/device_dictionaries.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/discussion/data/discussion_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/discussion/data/discussion_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/copy_updater.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repository.dart';
|
||||
|
|
@ -88,6 +90,7 @@ import 'fake_auth_gateway.dart';
|
|||
import 'fake_card_gateway.dart';
|
||||
import 'fake_device_dictionaries.dart';
|
||||
import 'fake_dictionary_gateway.dart';
|
||||
import 'fake_discussion_gateway.dart';
|
||||
import 'fake_explore_gateway.dart';
|
||||
import 'fake_feedback_gateways.dart';
|
||||
import 'fake_file_picker.dart';
|
||||
|
|
@ -165,6 +168,7 @@ Dependencies testDependencies({
|
|||
ExploreGateway? exploreGateway,
|
||||
FeedbackWriteGateway? feedbackWriteGateway,
|
||||
FeedbackServerGateway? feedbackServerGateway,
|
||||
DiscussionGateway? discussionGateway,
|
||||
SyncOnce? syncOnce,
|
||||
PendingChangesStore? pendingChanges,
|
||||
OpenPerson? openPerson,
|
||||
|
|
@ -340,6 +344,9 @@ Dependencies testDependencies({
|
|||
paths,
|
||||
assets,
|
||||
),
|
||||
discussions: DiscussionRepository(
|
||||
discussionGateway ?? FakeDiscussionGateway(),
|
||||
),
|
||||
onDispose: () async {
|
||||
await deletions.dispose();
|
||||
await noteDeletions.dispose();
|
||||
|
|
|
|||
Loading…
Reference in a new issue