halkyon/halkyon_learn_flutter/test/support/fake_discussion_gateway.dart

305 lines
8.2 KiB
Dart
Raw Normal View History

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>
2026-10-15 23:52:40 +02:00
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,
);
}