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:
parent
7aee78bd87
commit
82c777b1a5
1 changed files with 2949 additions and 427 deletions
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue