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>
307 lines
10 KiB
Dart
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);
|
|
});
|
|
}
|