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 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 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 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).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); }); }