halkyon/halkyon_learn_flutter/test/feature/feedback/feedback_widgets_test.dart
Tizian 「ティツィアーン」 1c1a1aada8
feat(feedback): report, suggest, and an inbox in the Profile tab
The app's side of stage 4 (ADR 0005, rule 11). The card's Report sheet
offers "Send to the author too" on a card from a release; a copy's menu
reports the studyset as a whole and lists what is reported and
suggested on it, as Explore's summary does; the editors of cards, notes
and steps suggest them to the author as the learner has them, never
under "no derivatives". The Profile tab holds the inbox: the threads on
the person's published studysets and those they wrote, the newest
activity first, with a count on the tab. A thread shows a suggestion
beside the author's own item and its replies; its writer and the author
reply, and the author accepts (the content becomes their own edit, its
files fetched from the writer's), declines, closes, hides or blocks.
Writing needs an identity; reading what is public doesn't.

ADR 0005 gets an addendum for stage 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 09:46:43 +02:00

307 lines
10 KiB
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/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.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_auth_gateway.dart';
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 FeedbackPerson(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);
});
}