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
3 changed files with 2949 additions and 427 deletions

View file

@ -66,6 +66,7 @@ void main() {
NoteRepository(FakeNoteGateway()),
LearningPathRepository(FakeLearningPathGateway()),
assets,
FakeFeedbackEnvelopes(),
),
)..add(const FeedbackListEventLoaded());
addTearDown(bloc.close);

View file

@ -59,6 +59,7 @@ void main() {
notes,
LearningPathRepository(FakeLearningPathGateway()),
assets,
FakeFeedbackEnvelopes(),
);
});
@ -150,6 +151,7 @@ void main() {
notes,
LearningPathRepository(FakeLearningPathGateway()),
sealedAssets,
FakeFeedbackEnvelopes(),
);
final mine = await sealedAssets.add(
image,
@ -253,6 +255,7 @@ void main() {
expect(written.decisions.single, (
threadId: 'thread-1',
decision: FeedbackDecision.accepted,
space: null,
));
});

View file

@ -0,0 +1,326 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart'
show FakeFileServerGateway, MemoryFileCache;
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/profile/public_person.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/feedback/data/feedback_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import '../../support/fake_asset_gateway.dart';
import '../../support/fake_asset_sealing.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_feedback_gateways.dart';
import '../../support/fake_learning_path_gateway.dart';
import '../../support/fake_note_gateway.dart';
/// Suggestions and reports within a shared space (Halkyon's ADR 0044, D44,
/// D46 to D48): written in the person's own space, sealed for the space
/// with its studyset's key; decided on in the space; taken in as changes
/// and new cards and notes, naming only the studyset's own files.
void main() {
late FakeFeedbackWriteGateway written;
late FakeFeedbackServerGateway server;
late CardRepository cards;
late NoteRepository notes;
late AssetRepository assets;
late FeedbackRepository feedback;
/// "japanese" is shared in space-1, private under its key k-space;
/// "readable" in space-2, readable; "mine" in no shared space.
FeedbackRepository repositoryOn(AssetRepository assets) => FeedbackRepository(
written,
server,
cards,
notes,
LearningPathRepository(FakeLearningPathGateway()),
assets,
FakeFeedbackEnvelopes(
spaces: {'japanese': 'space-1', 'readable': 'space-2'},
keys: {'japanese': 'k-space'},
),
);
setUp(() {
written = FakeFeedbackWriteGateway();
server = FakeFeedbackServerGateway();
cards = CardRepository(FakeCardGateway());
notes = NoteRepository(FakeNoteGateway());
assets = AssetRepository(
FakeAssetGateway(),
FakeFileServerGateway(),
MemoryFileCache(),
FakeAssetSealing(),
);
feedback = repositoryOn(assets);
});
/// A suggestion within space-1 on "japanese", as the instance gathers it.
FeedbackThread inSpace({
required FeedbackTarget target,
Map<String, Object?>? proposed,
List<FeedbackFile> files = const [],
}) => FeedbackThread(
threadId: 'thread-1',
publicId: 'space-1',
studysetTitle: 'Japanese',
location: '',
kind: FeedbackKind.suggestion,
writer: const PublicPerson(handle: 'carol'),
target: target,
createdAt: DateTime.utc(2026, 11, 11),
proposed: proposed,
files: files,
space: 'space-1',
studysetId: 'japanese',
);
test("a Viewer's change to a card goes in their own space, for the "
"studyset's space, sealed with its key and naming the card", () async {
await feedback.suggestCardInSpace(
CardContent(
front: RichContent.plain('猫'),
back: RichContent.plain('cat'),
report: CardReport(
category: ReportCategory.typo,
at: DateTime.utc(2026, 11, 11),
),
),
studysetId: 'japanese',
cardId: 'card-1',
body: ' Another reading. ',
);
final thread = written.threads.single;
expect(thread.space, 'space-1');
expect(thread.publicationId, 'space-1');
expect(thread.kind, FeedbackKind.suggestion);
expect(thread.keyId, 'k-space');
expect(thread.id, isNotNull);
expect(thread.json['studyset'], 'japanese');
expect(thread.json['target'], {
'kind': 'card',
'item': 'card-1',
'version': 0,
});
expect(thread.json['body'], 'Another reading.');
final proposed = thread.json['proposed']! as Map<String, Object?>;
expect(proposed.containsKey('report'), isFalse);
});
test('a new card or note is proposed for the studyset it goes into; in a '
'readable studyset, readable', () async {
await feedback.suggestCardInSpace(
CardContent(
front: RichContent.plain('犬'),
back: RichContent.plain('dog'),
),
studysetId: 'readable',
body: '',
);
await feedback.suggestNoteInSpace(
NoteContent(title: 'Particles', body: RichContent.plain('は')),
studysetId: 'readable',
body: '',
);
expect(
[for (final t in written.threads) t.json['target']],
[
{'kind': 'newCard', 'item': 'readable', 'version': 0},
{'kind': 'newNote', 'item': 'readable', 'version': 0},
],
);
expect([for (final t in written.threads) t.keyId], [null, null]);
expect([for (final t in written.threads) t.space], ['space-2', 'space-2']);
});
test('a report within a space asks for something to go, to its '
'deciders', () async {
await feedback.reportInSpace(
studysetId: 'japanese',
kind: FeedbackTargetKind.card,
item: 'card-1',
category: ReportCategory.other,
body: 'A duplicate: delete it?',
);
final thread = written.threads.single;
expect(thread.kind, FeedbackKind.report);
expect(thread.space, 'space-1');
expect(thread.json['category'], 'other');
});
test('nothing is suggested within a studyset in no shared space', () async {
await expectLater(
feedback.suggestCardInSpace(
CardContent(
front: RichContent.plain('猫'),
back: RichContent.plain('cat'),
),
studysetId: 'mine',
body: '',
),
throwsArgumentError,
);
expect(written.threads, isEmpty);
expect(await feedback.threadsInSpaceOf('mine'), isEmpty);
});
test('a reply within a space is sealed as its thread; a decision goes in '
'the space', () async {
final thread = inSpace(
target: const FeedbackTarget(
kind: FeedbackTargetKind.card,
item: 'card-1',
version: 0,
),
);
await feedback.reply(thread, 'Which reading?');
await feedback.decide(thread, FeedbackDecision.declined);
expect(written.messages.single.keyId, 'k-space');
expect(written.messages.single.json['body'], 'Which reading?');
expect(written.decisions.single, (
threadId: 'thread-1',
decision: FeedbackDecision.declined,
space: 'space-1',
));
});
test('accepted, a change becomes a revision of the shared card, and a '
'new card and note go into their studyset', () async {
final id = await cards.create(
'japanese',
CardContent(front: RichContent.plain('猫'), back: RichContent.plain('?')),
);
await feedback.accept(
inSpace(
target: FeedbackTarget(
kind: FeedbackTargetKind.card,
item: id,
version: 0,
),
proposed: {
'schemaVersion': 9,
'type': 'frontBack',
'front': RichContent.plain('猫').toJson(),
'back': RichContent.plain('cat').toJson(),
},
),
);
await feedback.accept(
inSpace(
target: const FeedbackTarget(
kind: FeedbackTargetKind.newCard,
item: 'japanese',
version: 0,
),
proposed: {
'schemaVersion': 9,
'type': 'frontBack',
'front': RichContent.plain('犬').toJson(),
'back': RichContent.plain('dog').toJson(),
},
),
);
await feedback.accept(
inSpace(
target: const FeedbackTarget(
kind: FeedbackTargetKind.newNote,
item: 'japanese',
version: 0,
),
proposed: {
'schemaVersion': 3,
'title': 'Particles',
'body': RichContent.plain('は').toJson(),
},
),
);
expect((await cards.byId(id))!.content.back.plainText, 'cat');
final added = (await cards.cardsIn(['japanese'])).where(
(card) => card.id != id,
);
expect(added.single.content.front.plainText, '犬');
expect(
(await cards.originsOf({added.single.id}))[added.single.id],
suggestedCardOrigin,
);
expect((await notes.notesOf('japanese')).single.content.title, 'Particles');
expect(
[for (final d in written.decisions) (d.decision, d.space)],
[
(FeedbackDecision.accepted, 'space-1'),
(FeedbackDecision.accepted, 'space-1'),
(FeedbackDecision.accepted, 'space-1'),
],
);
});
test("a suggestion naming a file that isn't the studyset's, or bringing "
'files of its own, is not taken', () async {
final sealed = AssetRepository(
FakeAssetGateway(),
FakeFileServerGateway(),
MemoryFileCache(),
FakeAssetSealing(keys: {'japanese': 'k-space', 'other': 'k-other'}),
);
feedback = repositoryOn(sealed);
final elsewhere = await sealed.add(
Uint8List.fromList([1, 2, 3]),
fileName: 'secret.png',
mimeType: 'image/png',
studysetId: 'other',
);
const target = FeedbackTarget(
kind: FeedbackTargetKind.newCard,
item: 'japanese',
version: 0,
);
final naming = {
'schemaVersion': 9,
'type': 'frontBack',
'front': RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'image',
'data': {'url': 'asset:$elsewhere'},
},
],
},
}).toJson(),
'back': RichContent.plain('dog').toJson(),
};
for (final thread in [
inSpace(target: target, proposed: naming),
inSpace(
target: target,
proposed: {...naming, 'front': RichContent.plain('犬').toJson()},
files: const [
FeedbackFile(assetId: 'a', sha256: 'b', mimeType: 'image/png'),
],
),
]) {
await expectLater(
feedback.accept(thread),
throwsA(isA<SuggestionNotTaken>()),
);
}
expect(await cards.cardsIn(['japanese']), isEmpty);
expect(written.decisions, isEmpty);
});
}