328 lines
10 KiB
Dart
328 lines
10 KiB
Dart
|
|
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<AgentRefused>()),
|
||
|
|
);
|
||
|
|
await expectLater(
|
||
|
|
library.studyset('nothing'),
|
||
|
|
throwsA(isA<AgentRefused>()),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
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<String, Object?>;
|
||
|
|
expect(
|
||
|
|
RichContent.fromJson(proposed['back']! as Map<String, Object?>).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<String, Object?>;
|
||
|
|
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<AgentRefused>()),
|
||
|
|
);
|
||
|
|
await expectLater(
|
||
|
|
library.suggestCardChange('nothing', back: 'y', message: ''),
|
||
|
|
throwsA(isA<AgentRefused>()),
|
||
|
|
);
|
||
|
|
final mine = (await cards.cardsOf('diary')).single.id;
|
||
|
|
await expectLater(
|
||
|
|
library.suggestCardChange(mine, back: 'y', message: ''),
|
||
|
|
throwsA(isA<AgentRefused>()),
|
||
|
|
);
|
||
|
|
await expectLater(
|
||
|
|
library.suggestNewCard(
|
||
|
|
'japanese',
|
||
|
|
type: 'matching',
|
||
|
|
front: 'x',
|
||
|
|
message: '',
|
||
|
|
),
|
||
|
|
throwsA(isA<AgentRefused>()),
|
||
|
|
);
|
||
|
|
await expectLater(
|
||
|
|
library.report('card', mine, message: 'Delete it.'),
|
||
|
|
throwsA(isA<AgentRefused>()),
|
||
|
|
);
|
||
|
|
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<AgentRefused>()),
|
||
|
|
);
|
||
|
|
|
||
|
|
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<AgentRefused>()),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
}
|