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
6 changed files with 2949 additions and 427 deletions
|
|
@ -66,6 +66,7 @@ void main() {
|
|||
NoteRepository(FakeNoteGateway()),
|
||||
LearningPathRepository(FakeLearningPathGateway()),
|
||||
assets,
|
||||
FakeFeedbackEnvelopes(),
|
||||
),
|
||||
)..add(const FeedbackListEventLoaded());
|
||||
addTearDown(bloc.close);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,22 +2,35 @@ import 'dart:async';
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_envelopes.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
|
||||
|
||||
/// A [FeedbackWriteGateway] in memory: what was written, in order.
|
||||
class FakeFeedbackWriteGateway() implements FeedbackWriteGateway {
|
||||
/// The threads written: their published studyset, kind and payload JSON.
|
||||
/// The threads written: their published studyset, kind and payload JSON
|
||||
/// (opened, where [FakeFeedbackEnvelopes] sealed it), and within a shared
|
||||
/// space, the space, its row id and its key.
|
||||
final threads =
|
||||
<
|
||||
({String publicationId, FeedbackKind kind, Map<String, Object?> json})
|
||||
({
|
||||
String publicationId,
|
||||
FeedbackKind kind,
|
||||
Map<String, Object?> json,
|
||||
String? space,
|
||||
String? id,
|
||||
String? keyId,
|
||||
})
|
||||
>[];
|
||||
|
||||
/// The replies written, by thread.
|
||||
final messages = <({String threadId, Map<String, Object?> json})>[];
|
||||
/// The replies written, by thread, with their key.
|
||||
final messages =
|
||||
<({String threadId, Map<String, Object?> json, String? keyId})>[];
|
||||
|
||||
/// The decisions written, by thread.
|
||||
final decisions = <({String threadId, FeedbackDecision decision})>[];
|
||||
/// The decisions written, by thread, and the shared space they went in.
|
||||
final decisions =
|
||||
<({String threadId, FeedbackDecision decision, String? space})>[];
|
||||
|
||||
/// When set, writing a decision fails with it.
|
||||
Exception? failDecisions;
|
||||
|
|
@ -30,29 +43,43 @@ class FakeFeedbackWriteGateway() implements FeedbackWriteGateway {
|
|||
required String publicationId,
|
||||
required FeedbackKind kind,
|
||||
required ByteData payload,
|
||||
String? space,
|
||||
String? id,
|
||||
String? keyId,
|
||||
ByteData? nonce,
|
||||
}) async {
|
||||
threads.add((
|
||||
publicationId: publicationId,
|
||||
kind: kind,
|
||||
json: _json(payload),
|
||||
space: space,
|
||||
id: id,
|
||||
keyId: keyId,
|
||||
));
|
||||
return 'thread-${threads.length}';
|
||||
return id ?? 'thread-${threads.length}';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insertMessage(String threadId, ByteData payload) async =>
|
||||
messages.add((threadId: threadId, json: _json(payload)));
|
||||
Future<void> insertMessage(
|
||||
String threadId,
|
||||
ByteData payload, {
|
||||
String? id,
|
||||
String? keyId,
|
||||
ByteData? nonce,
|
||||
}) async =>
|
||||
messages.add((threadId: threadId, json: _json(payload), keyId: keyId));
|
||||
|
||||
@override
|
||||
Future<void> insertDecision(
|
||||
String threadId,
|
||||
FeedbackDecision decision,
|
||||
) async {
|
||||
FeedbackDecision decision, {
|
||||
String? space,
|
||||
}) async {
|
||||
await holdDecisions?.future;
|
||||
if (failDecisions case final error?) {
|
||||
throw error;
|
||||
}
|
||||
decisions.add((threadId: threadId, decision: decision));
|
||||
decisions.add((threadId: threadId, decision: decision, space: space));
|
||||
}
|
||||
|
||||
static Map<String, Object?> _json(ByteData payload) =>
|
||||
|
|
@ -96,6 +123,15 @@ class FakeFeedbackServerGateway({
|
|||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FeedbackThread>> threadsInSpace(String space) async {
|
||||
await _maybeFail();
|
||||
return [
|
||||
for (final thread in _threads)
|
||||
if (thread.space == space) thread,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FeedbackThread>> inbox() async {
|
||||
await _maybeFail();
|
||||
|
|
@ -131,3 +167,61 @@ class FakeFeedbackServerGateway({
|
|||
return files[sha256]!;
|
||||
}
|
||||
}
|
||||
|
||||
/// [FeedbackEnvelopes] in memory: the studysets [spaces] holds are shared
|
||||
/// in those spaces, those [keys] holds private under those keys; sealing
|
||||
/// marks a payload with its key and leaves it readable, so tests read what
|
||||
/// was written, and opening refuses a key not in [keys]'s values or a row
|
||||
/// sealed for another.
|
||||
class FakeFeedbackEnvelopes({
|
||||
final Map<String, String> spaces = const {},
|
||||
final Map<String, String> keys = const {},
|
||||
final Map<String, String> titles = const {},
|
||||
}) implements FeedbackEnvelopes {
|
||||
var _ids = 0;
|
||||
|
||||
@override
|
||||
Future<String?> spaceOf(String studysetId) async => spaces[studysetId];
|
||||
|
||||
@override
|
||||
Future<String?> keyToWriteIn(String studysetId) async => keys[studysetId];
|
||||
|
||||
@override
|
||||
String newId() => 'row-${++_ids}';
|
||||
|
||||
@override
|
||||
Envelope seal(
|
||||
ByteData plain, {
|
||||
required String? keyId,
|
||||
required String table,
|
||||
required String id,
|
||||
required String bound,
|
||||
}) => (
|
||||
keyId: keyId,
|
||||
nonce: keyId == null
|
||||
? null
|
||||
: ByteData.sublistView(utf8.encode('$table|$id|$bound')),
|
||||
payload: plain,
|
||||
);
|
||||
|
||||
@override
|
||||
ByteData? open(
|
||||
Envelope envelope, {
|
||||
required String table,
|
||||
required String id,
|
||||
required String bound,
|
||||
}) {
|
||||
if (envelope.keyId case final keyId?) {
|
||||
final nonce = envelope.nonce;
|
||||
if (!keys.containsValue(keyId) ||
|
||||
nonce == null ||
|
||||
utf8.decode(Uint8List.sublistView(nonce)) != '$table|$id|$bound') {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return envelope.payload;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> titleOf(String studysetId) async => titles[studysetId] ?? '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repositor
|
|||
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/export/data/export_archive.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/export/data/studyset_markdown.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_envelopes.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/feedback/data/feedback_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/geo_map_repository.dart';
|
||||
|
|
@ -162,6 +163,7 @@ Dependencies testDependencies({
|
|||
ExploreGateway? exploreGateway,
|
||||
FeedbackWriteGateway? feedbackWriteGateway,
|
||||
FeedbackServerGateway? feedbackServerGateway,
|
||||
FeedbackEnvelopes? feedbackEnvelopes,
|
||||
DiscussionGateway? discussionGateway,
|
||||
SharedSpaceGateway? sharedSpaces,
|
||||
SharedKeys? sharedKeys,
|
||||
|
|
@ -414,6 +416,7 @@ Dependencies testDependencies({
|
|||
notes,
|
||||
paths,
|
||||
assets,
|
||||
feedbackEnvelopes ?? FakeFeedbackEnvelopes(),
|
||||
),
|
||||
discussions: DiscussionRepository(
|
||||
discussionGateway ?? FakeDiscussionGateway(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue