perf(learn): discussions and feedback show what the person does at once
In a discussion, accepting an answer (or none), hiding or showing a post and deleting one waited for the instance with every action disabled (busy). DiscussionBloc now shows the discussion as the action leaves it at once (Discussion.withAccepted, withPost, DiscussionPost.copyWith), the actions staying available, and puts it back as it was where the instance refuses (the refusal's snack bar as before) or can't be reached, then reads it anew. Answering, commenting and editing still wait, as what the instance makes of them is theirs to say. In the feedback inbox, a thread opened shows read at once, a failure to record it said to nobody (the next read shows the truth), and a decision (closing, reopening, hiding, showing, declining) shows at once and is put back with the failure's snack bar where it isn't written (FeedbackThread.seen, decided). Accepting a suggestion still waits, as it changes the author's content and can fail with SuggestionNotTaken. Whats-New: Accepting, hiding and deleting in discussions, and closing or declining feedback, show at once. Whats-New-De: Annehmen, Ausblenden und Löschen in Diskussionen sowie Schließen oder Ablehnen von Rückmeldungen zeigen sich sofort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
891e9dfd75
commit
107e6f9913
4 changed files with 416 additions and 11 deletions
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_editor/halkyon_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/profile/public_person.dart';
|
||||
|
|
@ -287,6 +289,86 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
group('accepting, hiding and deleting show at once', () {
|
||||
const bob = PublicPerson(handle: 'bob');
|
||||
late FakeDiscussionGateway gateway;
|
||||
late DiscussionBloc bloc;
|
||||
final answer = DiscussionPost(
|
||||
id: 'a1',
|
||||
kind: DiscussionPostKind.answer,
|
||||
createdAt: DateTime.utc(2026, 10, 16),
|
||||
by: const PublicPerson(handle: 'ann'),
|
||||
content: RichContent.plain('Topic marker.'),
|
||||
);
|
||||
|
||||
Future<void> outcome(DiscussionOutcome outcome) => expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(
|
||||
isA<DiscussionState>().having(
|
||||
(state) => state.outcome,
|
||||
'outcome',
|
||||
outcome,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
setUp(() async {
|
||||
gateway = FakeDiscussionGateway(me: bob, author: true);
|
||||
final question = gateway
|
||||
.addQuestion('Meaning of は', answers: [answer])
|
||||
.question
|
||||
.id;
|
||||
bloc = blocOf(gateway, question)..add(const DiscussionEventLoaded());
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(
|
||||
isA<DiscussionState>().having(
|
||||
(state) => state.status,
|
||||
'status',
|
||||
DiscussionStatus.ready,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'accepted before the instance answers, nothing waiting on it',
|
||||
() async {
|
||||
final hold = gateway.hold = Completer<void>();
|
||||
|
||||
bloc.add(const DiscussionEventAccepted('a1'));
|
||||
await pumpEventQueue();
|
||||
expect(bloc.state.discussion!.acceptedAnswerId, 'a1');
|
||||
expect(bloc.state.busy, isFalse);
|
||||
|
||||
hold.complete();
|
||||
await outcome(DiscussionOutcome.accepted);
|
||||
expect(bloc.state.discussion!.acceptedAnswerId, 'a1');
|
||||
},
|
||||
);
|
||||
|
||||
test('the instance out of reach, an answer accepted, a post hidden or '
|
||||
'deleted is as it was again, and the person told', () async {
|
||||
final hold = gateway.hold = Completer<void>();
|
||||
gateway.fail = Exception('offline');
|
||||
|
||||
bloc.add(const DiscussionEventAccepted('a1'));
|
||||
await pumpEventQueue();
|
||||
expect(bloc.state.discussion!.acceptedAnswerId, 'a1');
|
||||
hold.complete();
|
||||
await outcome(DiscussionOutcome.failed);
|
||||
expect(bloc.state.discussion!.acceptedAnswerId, isNull);
|
||||
|
||||
bloc.add(const DiscussionEventHidden('a1', hidden: true));
|
||||
await outcome(DiscussionOutcome.failed);
|
||||
expect(bloc.state.discussion!.answers.single.hidden, isFalse);
|
||||
|
||||
bloc.add(const DiscussionEventDeleted('a1'));
|
||||
await outcome(DiscussionOutcome.failed);
|
||||
expect(bloc.state.discussion!.answers.single.deleted, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
test('a discussion gone or not the person’s to see is missing', () async {
|
||||
final bloc = blocOf(FakeDiscussionGateway(), 'nothing')
|
||||
..add(const DiscussionEventLoaded());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
import 'dart:async';
|
||||
|
||||
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/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/bloc/feedback_list_bloc.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 '../../support/fake_asset_gateway.dart';
|
||||
import '../../support/fake_asset_sealing.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';
|
||||
|
||||
/// The author's inbox shows what they do at once: a thread read, a
|
||||
/// decision taken; accepting a suggestion waits, as it changes content.
|
||||
void main() {
|
||||
late FakeFeedbackWriteGateway written;
|
||||
late FakeFeedbackServerGateway server;
|
||||
late FeedbackListBloc bloc;
|
||||
|
||||
final report = FeedbackThread(
|
||||
threadId: 'thread-1',
|
||||
publicId: 'pub-1',
|
||||
studysetTitle: 'Japanese',
|
||||
location: 'halkyon.eu/@alice/japanese',
|
||||
kind: FeedbackKind.report,
|
||||
writer: const PublicPerson(handle: 'bob'),
|
||||
target: const FeedbackTarget(
|
||||
kind: FeedbackTargetKind.card,
|
||||
item: 'card-1',
|
||||
version: 2,
|
||||
),
|
||||
createdAt: DateTime.utc(2026, 10, 9),
|
||||
category: ReportCategory.typo,
|
||||
body: '猫 is misspelt.',
|
||||
forMe: true,
|
||||
unseen: true,
|
||||
);
|
||||
|
||||
FeedbackThread shown() => bloc.state.byId('thread-1')!;
|
||||
|
||||
setUp(() async {
|
||||
written = FakeFeedbackWriteGateway();
|
||||
server = FakeFeedbackServerGateway(threads: [report]);
|
||||
final assets = AssetRepository(
|
||||
FakeAssetGateway(),
|
||||
FakeAssetServerGateway(),
|
||||
MemoryAssetFileCache(),
|
||||
FakeAssetSealing(),
|
||||
);
|
||||
addTearDown(assets.dispose);
|
||||
bloc = FeedbackListBloc(
|
||||
FeedbackRepository(
|
||||
written,
|
||||
server,
|
||||
CardRepository(FakeCardGateway()),
|
||||
NoteRepository(FakeNoteGateway()),
|
||||
LearningPathRepository(FakeLearningPathGateway()),
|
||||
assets,
|
||||
),
|
||||
)..add(const FeedbackListEventLoaded());
|
||||
addTearDown(bloc.close);
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(
|
||||
isA<FeedbackListState>().having(
|
||||
(state) => state.status,
|
||||
'status',
|
||||
FeedbackListStatus.ready,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('a thread opened is read at once; where the instance fails to '
|
||||
'record it, nobody is told', () async {
|
||||
final hold = server.holdSeen = Completer<void>();
|
||||
server.fail = Exception('offline');
|
||||
|
||||
bloc.add(FeedbackListEventOpened(report));
|
||||
await pumpEventQueue();
|
||||
expect(shown().unseen, isFalse);
|
||||
|
||||
hold.complete();
|
||||
await pumpEventQueue();
|
||||
expect(bloc.state.outcome, isNull);
|
||||
});
|
||||
|
||||
test('a thread closed shows closed before the decision is written; one '
|
||||
"that isn't written is open again, and the person told", () async {
|
||||
final hold = written.holdDecisions = Completer<void>();
|
||||
|
||||
bloc.add(FeedbackListEventDecided(report, FeedbackDecision.closed));
|
||||
await pumpEventQueue();
|
||||
expect(shown().status, FeedbackStatus.closed);
|
||||
expect(written.decisions, isEmpty);
|
||||
hold.complete();
|
||||
await pumpEventQueue();
|
||||
expect(written.decisions.single.decision, FeedbackDecision.closed);
|
||||
|
||||
written
|
||||
..holdDecisions = null
|
||||
..failDecisions = Exception('disk full');
|
||||
bloc.add(FeedbackListEventDecided(report, FeedbackDecision.hidden));
|
||||
await expectLater(
|
||||
bloc.stream,
|
||||
emitsThrough(
|
||||
isA<FeedbackListState>().having(
|
||||
(state) => state.outcome,
|
||||
'outcome',
|
||||
FeedbackOutcome.failed,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(shown().hidden, isFalse);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:halkyon_editor/halkyon_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/profile/public_person.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/discussion/data/discussion_gateway.dart';
|
||||
|
|
@ -17,6 +19,10 @@ class FakeDiscussionGateway({
|
|||
/// Thrown by every call while set: the instance can't be reached.
|
||||
Exception? fail;
|
||||
|
||||
/// While set, deleting, accepting and hiding wait for it to complete, so
|
||||
/// that a test sees what shows before the instance answers.
|
||||
Completer<void>? hold;
|
||||
|
||||
/// The questions, by id, with what is in them.
|
||||
final questions = <String, Discussion>{};
|
||||
|
||||
|
|
@ -232,12 +238,14 @@ class FakeDiscussionGateway({
|
|||
|
||||
@override
|
||||
Future<void> delete(String postId) async {
|
||||
await hold?.future;
|
||||
_failing();
|
||||
questions.remove(postId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> accept(String questionId, String? answerId) async {
|
||||
await hold?.future;
|
||||
_failing();
|
||||
final discussion = questions[questionId] ?? (throw DiscussionMissing());
|
||||
questions[questionId] = _with(
|
||||
|
|
@ -253,6 +261,7 @@ class FakeDiscussionGateway({
|
|||
|
||||
@override
|
||||
Future<void> hide(String postId, {required bool hidden}) async {
|
||||
await hold?.future;
|
||||
_failing();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
|
|
@ -18,6 +19,12 @@ class FakeFeedbackWriteGateway() implements FeedbackWriteGateway {
|
|||
/// The decisions written, by thread.
|
||||
final decisions = <({String threadId, FeedbackDecision decision})>[];
|
||||
|
||||
/// When set, writing a decision fails with it.
|
||||
Exception? failDecisions;
|
||||
|
||||
/// While set, writing a decision waits for it to complete.
|
||||
Completer<void>? holdDecisions;
|
||||
|
||||
@override
|
||||
Future<String> insertThread({
|
||||
required String publicationId,
|
||||
|
|
@ -40,7 +47,13 @@ class FakeFeedbackWriteGateway() implements FeedbackWriteGateway {
|
|||
Future<void> insertDecision(
|
||||
String threadId,
|
||||
FeedbackDecision decision,
|
||||
) async => decisions.add((threadId: threadId, decision: decision));
|
||||
) async {
|
||||
await holdDecisions?.future;
|
||||
if (failDecisions case final error?) {
|
||||
throw error;
|
||||
}
|
||||
decisions.add((threadId: threadId, decision: decision));
|
||||
}
|
||||
|
||||
static Map<String, Object?> _json(ByteData payload) =>
|
||||
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
|
||||
|
|
@ -62,6 +75,9 @@ class FakeFeedbackServerGateway({
|
|||
/// When set, every request fails with it.
|
||||
Exception? fail;
|
||||
|
||||
/// While set, marking a thread read waits for it to complete.
|
||||
Completer<void>? holdSeen;
|
||||
|
||||
/// Replaces what the instance gathers.
|
||||
set gathered(List<FeedbackThread> threads) => _threads = [...threads];
|
||||
|
||||
|
|
@ -95,6 +111,7 @@ class FakeFeedbackServerGateway({
|
|||
|
||||
@override
|
||||
Future<void> markSeen(String threadId) async {
|
||||
await holdSeen?.future;
|
||||
await _maybeFail();
|
||||
seen.add(threadId);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue