feat(base,learn): suggest changes within a shared studyset

ADR 0044's step 6b (D44, D46 to D48): a Viewer, and an agent, suggest
changes within a shared studyset, which its Owner and Editors accept or
decline.

A suggestion or report is written as Explore's are, in its writer's own
synced space, with kinds of its own (space-suggestion, space-report)
and the space's id in feedback_thread's publicationId, so no synced
table changes and older apps, skipping kinds they don't know, keep
syncing. Where the studyset is private it is sealed with its key, bound
to the space; a reply to it is sealed for its thread.

Learn's server gathers a space's threads for its members alone
(feedback.threadsInSpace, NotFound for anyone else): only threads and
replies by members now, decisions only from rows in the space itself,
where the engine lets its Owner and Editors alone write; sealed
payloads pass in base64 with their key and nonce. The inbox adds the
threads its caller decides on or wrote. The module tells a host a
person's spaces and each space's members (sharedSpacesOf,
membersOfSpaces). No migration: no table changes.

In the app, a Viewer's card and note offer Suggest a change: the fields
write into the suggestion alone, sent with a message. A shared
studyset's menu lists its Suggestions, where its Owner and Editors
accept a change as a revision, or a new card or note into the
studyset, and decide in the space. A suggestion naming a file that
isn't the studyset's, or bringing files of its own, isn't taken.

Whats-New: In a studyset shared with you as a viewer, suggest a change to a card or note; its owner and editors find your suggestions under Suggestions in its menu and accept or decline them.
Whats-New-De: In einem Lernset, das du nur ansehen darfst, schlägst du Änderungen an Karteikarten und Notizen vor; Besitzer:in und Bearbeiter:innen finden sie im Menü unter „Vorschläge“ und nehmen sie an oder lehnen sie ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 20:34:06 +01:00
commit 82c777b1a5
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,6 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/studyset_access.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';
@ -11,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_conten
import 'package:material_ui/material_ui.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_feedback_gateways.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/pump_app.dart';
@ -26,6 +28,7 @@ void main() {
late FakeNoteGateway noteGateway;
late String cardId;
late String noteId;
late FakeFeedbackWriteGateway written;
setUp(() async {
studysets = FakeStudysetGateway()
@ -33,6 +36,7 @@ void main() {
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
);
written = FakeFeedbackWriteGateway();
cardGateway = FakeCardGateway();
noteGateway = FakeNoteGateway();
final cards = CardRepository(cardGateway);
@ -62,6 +66,10 @@ void main() {
studysetGateway: studysets,
cardGateway: cardGateway,
noteGateway: noteGateway,
feedbackWriteGateway: written,
feedbackEnvelopes: FakeFeedbackEnvelopes(
spaces: {'japanese': 'space-1'},
),
),
location: location,
);
@ -147,4 +155,108 @@ void main() {
isTrue,
);
});
/// Sends the suggestion begun, saying [why].
Future<void> send(WidgetTester tester, String why) async {
await tester.tap(find.text('Send suggestion'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).last, why);
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, 'Send'));
await tester.pumpAndSettle();
}
testWidgets('a Viewer suggests a change to a card, which changes nothing '
'of it, to the owner and editors', (tester) async {
shared(viewOnly: true);
await showApp(tester, RoutePaths.card('japanese', cardId));
await tester.tap(find.text('Suggest a change'));
await tester.pumpAndSettle();
// Its fields write, into the suggestion alone.
expect(
tester.widget<StudysetAccess>(find.byType(StudysetAccess).last).viewOnly,
isFalse,
);
expect(
find.textContaining('nothing changes until', findRichText: true),
findsOneWidget,
);
await send(tester, 'Spelled with kanji?');
final thread = written.threads.single;
expect(thread.space, 'space-1');
expect(thread.json['target'], {
'kind': 'card',
'item': cardId,
'version': 0,
});
expect(thread.json['body'], 'Spelled with kanji?');
expect(find.text('Suggestion sent.'), findsOneWidget);
// Back to reading it, unchanged.
expect(
tester.widget<StudysetAccess>(find.byType(StudysetAccess).last).viewOnly,
isTrue,
);
expect(find.text('Suggest a change'), findsOneWidget);
});
testWidgets("a Viewer's changed note title goes as a suggestion; "
'cancelled, nothing goes', (tester) async {
shared(viewOnly: true);
await showApp(tester, RoutePaths.note('japanese', noteId));
await tester.tap(find.text('Suggest a change'));
await tester.pumpAndSettle();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(
tester.widget<TextField>(find.byType(TextField).first).readOnly,
isTrue,
);
await tester.tap(find.text('Suggest a change'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).first, 'Particles は and が');
await tester.pump();
await send(tester, '');
final thread = written.threads.single;
expect(thread.json['target'], {
'kind': 'note',
'item': noteId,
'version': 0,
});
expect(
(thread.json['proposed']! as Map<String, Object?>)['title'],
'Particles は and が',
);
expect(
tester.widget<TextField>(find.byType(TextField).first).controller!.text,
'Particles',
);
});
testWidgets("a shared studyset's menu offers its suggestions", (
tester,
) async {
shared(viewOnly: false);
await showApp(tester, RoutePaths.studyset('japanese'));
await tester.tap(
find.descendant(
of: find.byType(AppBar),
matching: find.byTooltip('Options'),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Suggestions'));
await tester.pumpAndSettle();
expect(
find.text('Nobody has reported or suggested anything yet.'),
findsOneWidget,
);
});
}