halkyon/halkyon_learn_flutter/test/feature/feedback/feedback_widgets_test.dart
Tizian 「ティツィアーン」 25237b8011
refactor(app): FeedbackPerson moves into core as PublicPerson
A second feature needs it: discussions name their writers as reports and
suggestions do, by what their public profile shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-17 19:46:31 +02:00

308 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/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/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 PublicPerson(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);
});
}