import 'package:halkyon_content/rich_text.dart'; import 'package:halkyon_content/testing.dart'; import 'package:halkyon_learn_agent/src/agent_library.dart'; import 'package:halkyon_learn_core/core/asset/data/asset_repository.dart'; import 'package:halkyon_learn_core/core/profile/public_person.dart'; import 'package:halkyon_learn_core/feature/card/data/card_repository.dart'; import 'package:halkyon_learn_core/feature/card/model/card_content.dart'; import 'package:halkyon_learn_core/feature/feedback/data/feedback_repository.dart'; import 'package:halkyon_learn_core/feature/feedback/model/feedback.dart'; import 'package:halkyon_learn_core/feature/learning_path/data/learning_path_repository.dart'; import 'package:halkyon_learn_core/feature/note/data/note_repository.dart'; import 'package:halkyon_learn_core/feature/note/model/note_content.dart'; import 'package:halkyon_learn_core/feature/studyset/data/studyset_payload.dart'; import 'package:halkyon_learn_core/feature/studyset/data/studyset_repository.dart'; import 'package:halkyon_learn_core/feature/studyset/model/studyset_content.dart'; import 'package:halkyon_learn_core/testing.dart'; import 'package:test/test.dart'; /// What the agent reads and suggests (Halkyon's ADR 0044, D7, D10, D51): /// the studysets shared with it alone, their cards and notes as text, and /// every change as a suggestion in the studyset's space, never a write. void main() { late FakeStudysetGateway studysetGateway; late FakeCardGateway cardGateway; late FakeNoteGateway noteGateway; late FakeFeedbackWriteGateway written; late FakeFeedbackServerGateway server; late CardRepository cards; late NoteRepository notes; late AgentLibrary library; late String cardId; late String noteId; setUp(() async { studysetGateway = FakeStudysetGateway() ..receiveRemoteChange( 'japanese', encodeStudysetPayload(StudysetContent.titled('Japanese')), ) ..receiveRemoteChange( 'kanji', encodeStudysetPayload(StudysetContent.titled('Kanji')), parentId: 'japanese', ) ..receiveRemoteChange( 'diary', encodeStudysetPayload(StudysetContent.titled('Diary')), ) ..shared['japanese'] = (space: 'space-1', viewOnly: true) ..shared['kanji'] = (space: 'space-1', viewOnly: true); cardGateway = FakeCardGateway(); noteGateway = FakeNoteGateway(); written = FakeFeedbackWriteGateway(); server = FakeFeedbackServerGateway(); final studysets = StudysetRepository(studysetGateway); cards = CardRepository(cardGateway); notes = NoteRepository(noteGateway); final assets = AssetRepository( FakeAssetGateway(), FakeFileServerGateway(), MemoryFileCache(), FakeAssetSealing(), ); addTearDown(() async { await studysets.dispose(); await cards.dispose(); await notes.dispose(); await assets.dispose(); }); cardId = await cards.create( 'japanese', CardContent( front: RichContent.plain('猫'), back: RichContent.plain('dog'), ), ); await cards.create( 'diary', CardContent( front: RichContent.plain('Secret'), back: RichContent.plain('Mine'), ), ); noteId = (await notes.create( 'kanji', NoteContent(title: 'Radicals', body: RichContent.plain('氵 is water.')), )).noteId; library = AgentLibrary( studysets: studysets, cards: cards, notes: notes, feedback: FeedbackRepository( written, server, cards, notes, LearningPathRepository(FakeLearningPathGateway()), assets, FakeFeedbackEnvelopes( spaces: {'japanese': 'space-1', 'kanji': 'space-1'}, ), ), ); }); test('lists the studysets shared with it, never one of its own ' 'space', () async { final listed = await library.sharedStudysets(); expect([for (final s in listed) s['id']], ['japanese', 'kanji']); expect(listed.first, { 'id': 'japanese', 'title': 'Japanese', 'parent': null, 'role': 'viewer', 'cards': 1, 'notes': 0, }); expect(listed.last['parent'], 'japanese'); }); test("reads a studyset's cards and notes as text; one not shared with it " "isn't there", () async { final japanese = await library.studyset('japanese'); expect(japanese['cards'], [ {'id': cardId, 'type': 'frontBack', 'front': '猫', 'back': 'dog'}, ]); expect(japanese['topics'], ['kanji']); final kanji = await library.studyset('kanji'); expect(kanji['notes'], [ {'id': noteId, 'title': 'Radicals', 'body': '氵 is water.'}, ]); await expectLater( library.studyset('diary'), throwsA(isA()), ); await expectLater( library.studyset('nothing'), throwsA(isA()), ); }); test('searches the shared cards alone', () async { expect( [for (final card in await library.search('猫')) card['id']], [cardId], ); expect(await library.search('secret'), isEmpty); }); test("a change to a card is suggested in its studyset's space, the card " 'unchanged', () async { await library.suggestCardChange( cardId, back: 'cat', message: '猫 is a cat.', ); final thread = written.threads.single; expect(thread.space, 'space-1'); expect(thread.json['target'], { 'kind': 'card', 'item': cardId, 'version': 0, }); expect(thread.json['body'], '猫 is a cat.'); final proposed = thread.json['proposed']! as Map; expect( RichContent.fromJson(proposed['back']! as Map).plainText, 'cat', ); expect((await cards.byId(cardId))!.content.back.plainText, 'dog'); }); test('new cards and notes are suggested for a shared studyset, nothing ' 'added', () async { await library.suggestNewCard( 'kanji', front: '水', back: 'water', message: '', ); await library.suggestNewNote( 'japanese', title: 'Particles', body: 'は marks the topic.\nが the subject.', message: 'A start.', ); await library.suggestNoteChange( noteId, title: 'Radicals (部首)', message: '', ); expect( [for (final t in written.threads) t.json['target']], [ {'kind': 'newCard', 'item': 'kanji', 'version': 0}, {'kind': 'newNote', 'item': 'japanese', 'version': 0}, {'kind': 'note', 'item': noteId, 'version': 0}, ], ); expect(await cards.cardsOf('kanji'), isEmpty); expect(await notes.notesOf('japanese'), isEmpty); }); test( 'a vocabulary card is suggested with its reading and meanings', () async { await library.suggestNewCard( 'japanese', type: 'vocabulary', front: '犬', reading: 'いぬ', meanings: ['dog'], message: '', ); final proposed = written.threads.single.json['proposed']! as Map; expect(proposed['type'], 'vocabulary'); expect(proposed['reading'], 'いぬ'); expect(proposed['meanings'], ['dog']); }, ); test('nothing is suggested in a studyset not shared with it, on an item ' "it can't find, or of a kind it can't write", () async { await expectLater( library.suggestNewCard('diary', front: 'x', back: 'y', message: ''), throwsA(isA()), ); await expectLater( library.suggestCardChange('nothing', back: 'y', message: ''), throwsA(isA()), ); final mine = (await cards.cardsOf('diary')).single.id; await expectLater( library.suggestCardChange(mine, back: 'y', message: ''), throwsA(isA()), ); await expectLater( library.suggestNewCard( 'japanese', type: 'matching', front: 'x', message: '', ), throwsA(isA()), ); await expectLater( library.report('card', mine, message: 'Delete it.'), throwsA(isA()), ); expect(written.threads, isEmpty); }); test('a report asks for something shared to go, saying why', () async { await library.report('note', noteId, message: 'A duplicate.'); await expectLater( library.report('card', cardId, message: ' '), throwsA(isA()), ); final thread = written.threads.single; expect(thread.kind, FeedbackKind.report); expect(thread.json['target'], { 'kind': 'note', 'item': noteId, 'version': 0, }); expect(thread.json['body'], 'A duplicate.'); }); test("a studyset's suggestions are listed with their replies, and " 'replied to', () async { server.gathered = [ FeedbackThread( threadId: 'thread-1', publicId: 'space-1', studysetTitle: '', location: '', kind: FeedbackKind.suggestion, writer: const PublicPerson(handle: 'claude'), target: FeedbackTarget( kind: FeedbackTargetKind.card, item: cardId, version: 0, ), createdAt: DateTime.utc(2026, 11, 11), body: '猫 is a cat.', status: FeedbackStatus.declined, messages: [ FeedbackMessage( id: 'm-1', by: const PublicPerson(handle: 'alice'), fromAuthor: true, body: 'It is a pun.', createdAt: DateTime.utc(2026, 11, 11, 1), ), ], space: 'space-1', studysetId: 'japanese', ), ]; final listed = await library.suggestions('japanese'); expect(listed.single, { 'id': 'thread-1', 'kind': 'suggestion', 'target': {'kind': 'card', 'item': cardId}, 'by': '@claude', 'status': 'declined', 'message': '猫 is a cat.', 'replies': [ {'by': '@alice', 'message': 'It is a pun.'}, ], }); await library.reply('japanese', 'thread-1', message: 'Thanks!'); expect(written.messages.single.json['body'], 'Thanks!'); await expectLater( library.reply('japanese', 'thread-9', message: 'Hm?'), throwsA(isA()), ); }); }