feat(learn): conversations with assistants, kept sealed (ADR 0021, step 3)
The first part of step 3, decided as D47 to D52 in ADR 0021: where a conversation shows (a sheet over the screen), where kept ones are found (the field), the scope's chip and that it starts with only what is ticked, how conversations are kept, and what a device that can't seal does. Two synced tables: assistant_conversation (its title, assistant and scope) and assistant_turn (one question and everything up to its answer, never changed once written, ADR 0001, rule 2), so two devices continuing a conversation offline lose nothing. SyncConversationGateway seals every row with the person's key, a turn bound to its conversation, and reads no row that isn't sealed: a device that can't seal keeps nothing (D52, ConversationNotKept). Deleting one blanks it and its turns first (ADR 0002, rule 2). A turn keeps every message a follow-up sends back (tool calls and results, the providers' own blocks), but no image, only that one was sent. The instance's privacy page says conversations are kept, encrypted, and what the instance sees of them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
36899cdc3b
commit
b965e05fdb
5 changed files with 36798 additions and 4 deletions
|
|
@ -0,0 +1,215 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
|
||||
import 'package:halkyon_llm/halkyon_llm.dart';
|
||||
|
||||
// A conversation's and its turns' payloads (ADR 0021, D51): what a follow-up
|
||||
// sends back must come back from them as it was sent.
|
||||
|
||||
final _at = DateTime.utc(2026, 10, 26, 9);
|
||||
|
||||
void main() {
|
||||
group('a conversation', () {
|
||||
test('keeps its title, assistant and scope', () {
|
||||
const scope = AssistantScope(
|
||||
reach: AssistantReach.tree,
|
||||
studysetId: 'studyset-1',
|
||||
includePrivate: true,
|
||||
);
|
||||
final conversation = decodeConversationPayload(
|
||||
'c-1',
|
||||
encodeConversationPayload(
|
||||
title: 'Why て-form here?',
|
||||
assistant: const AssistantRef(id: 'own-1', own: true),
|
||||
scope: scope,
|
||||
),
|
||||
startedAt: _at,
|
||||
);
|
||||
|
||||
expect(
|
||||
conversation,
|
||||
AssistantConversation(
|
||||
id: 'c-1',
|
||||
title: 'Why て-form here?',
|
||||
assistant: const AssistantRef(id: 'own-1', own: true),
|
||||
scope: scope,
|
||||
startedAt: _at,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('starts with only what is ticked, and nothing private (D50, D8)', () {
|
||||
final conversation = decodeConversationPayload(
|
||||
'c-1',
|
||||
encodeConversationPayload(
|
||||
title: 'Hi',
|
||||
assistant: const AssistantRef(id: 'claude'),
|
||||
scope: const AssistantScope(),
|
||||
),
|
||||
startedAt: _at,
|
||||
);
|
||||
|
||||
expect(conversation.scope, const AssistantScope());
|
||||
expect(conversation.assistant.own, isFalse);
|
||||
});
|
||||
|
||||
test("refuses one of a newer app's schema", () {
|
||||
expect(
|
||||
() => decodeConversationPayload(
|
||||
'c-1',
|
||||
encodePayload(conversationSchemaVersion + 1, {}),
|
||||
startedAt: _at,
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses a reach it does not know', () {
|
||||
expect(
|
||||
() => decodeConversationPayload(
|
||||
'c-1',
|
||||
encodePayload(conversationSchemaVersion, {
|
||||
'title': 'Hi',
|
||||
'assistant': {'id': 'claude'},
|
||||
'scope': {'reach': 'everything'},
|
||||
}),
|
||||
startedAt: _at,
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('is titled by its first line, cut short', () {
|
||||
expect(AssistantConversation.titleOf(' Why?\nAnd how?'), 'Why?');
|
||||
final long = AssistantConversation.titleOf('a' * 200);
|
||||
expect(long, hasLength(AssistantConversation.maxTitleLength));
|
||||
expect(long, endsWith('…'));
|
||||
});
|
||||
});
|
||||
|
||||
group('a turn', () {
|
||||
test('keeps every part a follow-up sends back, as it came', () {
|
||||
final messages = [
|
||||
LlmMessage.user([const LlmText('Which verbs are irregular?')]),
|
||||
LlmMessage.assistant([
|
||||
const LlmProviderBlock(
|
||||
protocol: LlmProtocol.anthropic,
|
||||
block: {'type': 'thinking', 'thinking': '…', 'signature': 'sig'},
|
||||
),
|
||||
const LlmText('Let me look.', extra: {'phase': 'commentary'}),
|
||||
const LlmToolCall(
|
||||
id: 'call-1',
|
||||
name: 'find_cards',
|
||||
input: {'query': 'する'},
|
||||
extra: {
|
||||
'extra_content': {'google': 'x'},
|
||||
},
|
||||
),
|
||||
]),
|
||||
LlmMessage.user([
|
||||
const LlmToolResult(callId: 'call-1', content: '[]', isError: true),
|
||||
]),
|
||||
LlmMessage.assistant([const LlmText('する and 来る.')]),
|
||||
];
|
||||
|
||||
final turn = decodeTurnPayload(
|
||||
't-1',
|
||||
encodeTurnPayload(
|
||||
messages,
|
||||
const LlmUsage(inputTokens: 900, outputTokens: 120),
|
||||
),
|
||||
writtenAt: _at,
|
||||
);
|
||||
|
||||
expect(
|
||||
encodeTurnPayload(turn.messages, turn.usage),
|
||||
_same(
|
||||
encodeTurnPayload(
|
||||
messages,
|
||||
const LlmUsage(inputTokens: 900, outputTokens: 120),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(turn.usage, const LlmUsage(inputTokens: 900, outputTokens: 120));
|
||||
expect(turn.writtenAt, _at);
|
||||
final call = turn.messages[1].toolCalls.single;
|
||||
expect(call.input, {'query': 'する'});
|
||||
expect(call.extra, {
|
||||
'extra_content': {'google': 'x'},
|
||||
});
|
||||
expect((turn.messages[2].parts.single as LlmToolResult).isError, isTrue);
|
||||
});
|
||||
|
||||
test('keeps no image, only that one was sent', () {
|
||||
final turn = decodeTurnPayload(
|
||||
't-1',
|
||||
encodeTurnPayload([
|
||||
LlmMessage.user([
|
||||
const LlmText('What is this?'),
|
||||
LlmImage(mediaType: 'image/png', bytes: Uint8List(1024)),
|
||||
]),
|
||||
], LlmUsage.none),
|
||||
writtenAt: _at,
|
||||
);
|
||||
|
||||
expect(turn.messages.single.parts, hasLength(2));
|
||||
expect((turn.messages.single.parts.last as LlmText).text, imageNotKept);
|
||||
});
|
||||
|
||||
test('refuses a part it does not know', () {
|
||||
expect(
|
||||
() => decodeTurnPayload(
|
||||
't-1',
|
||||
encodePayload(turnSchemaVersion, {
|
||||
'messages': [
|
||||
{
|
||||
'role': 'user',
|
||||
'parts': [
|
||||
{'type': 'audio'},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
writtenAt: _at,
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test("refuses a part its role can't hold", () {
|
||||
expect(
|
||||
() => decodeTurnPayload(
|
||||
't-1',
|
||||
encodePayload(turnSchemaVersion, {
|
||||
'messages': [
|
||||
{
|
||||
'role': 'user',
|
||||
'parts': [
|
||||
{
|
||||
'type': 'call',
|
||||
'id': 'c',
|
||||
'name': 'n',
|
||||
'input': <String, Object?>{},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
writtenAt: _at,
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The same bytes as [expected].
|
||||
Matcher _same(ByteData expected) => predicate<ByteData>(
|
||||
(actual) =>
|
||||
Uint8List.sublistView(actual).toString() ==
|
||||
Uint8List.sublistView(expected).toString(),
|
||||
'the same payload',
|
||||
);
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
|
||||
import 'package:halkyon_llm/halkyon_llm.dart';
|
||||
|
||||
import '../../../support/fake_conversation_gateway.dart';
|
||||
|
||||
// The person's conversations with assistants, kept sealed, a turn appended
|
||||
// for each question (ADR 0021, D13, D51), and nothing kept where this
|
||||
// device can't seal (D52).
|
||||
|
||||
const _claude = AssistantRef(id: 'claude');
|
||||
const _scope = AssistantScope(studysetId: 'studyset-1');
|
||||
|
||||
List<LlmMessage> _turn(String question, String answer) => [
|
||||
LlmMessage.user([LlmText(question)]),
|
||||
LlmMessage.assistant([LlmText(answer)]),
|
||||
];
|
||||
|
||||
void main() {
|
||||
late FakeConversationGateway gateway;
|
||||
late ConversationRepository conversations;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeConversationGateway();
|
||||
conversations = ConversationRepository(gateway);
|
||||
});
|
||||
tearDown(() => conversations.dispose());
|
||||
|
||||
test('starts a conversation titled by its first question', () async {
|
||||
final id = await conversations.start(
|
||||
question: 'Why て-form here?\nIn the second card.',
|
||||
assistant: _claude,
|
||||
scope: _scope,
|
||||
);
|
||||
|
||||
final kept = await conversations.all();
|
||||
expect(kept.single.id, id);
|
||||
expect(kept.single.title, 'Why て-form here?');
|
||||
expect(kept.single.assistant, _claude);
|
||||
expect(kept.single.scope, _scope);
|
||||
});
|
||||
|
||||
test('keeps a turn for each question, in order', () async {
|
||||
final id = await conversations.start(
|
||||
question: 'First',
|
||||
assistant: _claude,
|
||||
scope: _scope,
|
||||
);
|
||||
await conversations.keep(
|
||||
id,
|
||||
_turn('First', 'One'),
|
||||
const LlmUsage(inputTokens: 10, outputTokens: 2),
|
||||
);
|
||||
await conversations.keep(id, _turn('Then?', 'Two'), LlmUsage.none);
|
||||
|
||||
final turns = await conversations.turnsOf(id);
|
||||
expect([for (final turn in turns) turn.messages.last.text], ['One', 'Two']);
|
||||
expect(turns.first.usage, const LlmUsage(inputTokens: 10, outputTokens: 2));
|
||||
});
|
||||
|
||||
test('lists the newest first', () async {
|
||||
await conversations.start(
|
||||
question: 'Older',
|
||||
assistant: _claude,
|
||||
scope: _scope,
|
||||
);
|
||||
await conversations.start(
|
||||
question: 'Newer',
|
||||
assistant: _claude,
|
||||
scope: _scope,
|
||||
);
|
||||
|
||||
expect(
|
||||
[for (final each in await conversations.all()) each.title],
|
||||
[
|
||||
'Newer',
|
||||
'Older',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('follows changes made here and synced from elsewhere', () async {
|
||||
final watched = expectLater(
|
||||
conversations.watch().map((all) => [for (final c in all) c.title]),
|
||||
emitsInOrder([
|
||||
isEmpty,
|
||||
['Here'],
|
||||
['There', 'Here'],
|
||||
]),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
await conversations.start(
|
||||
question: 'Here',
|
||||
assistant: _claude,
|
||||
scope: _scope,
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
gateway.receiveConversation(
|
||||
'conversation-900',
|
||||
encodeConversationPayload(
|
||||
title: 'There',
|
||||
assistant: _claude,
|
||||
scope: _scope,
|
||||
),
|
||||
);
|
||||
await watched;
|
||||
});
|
||||
|
||||
test(
|
||||
'deleting one blanks it and its turns first (ADR 0002, rule 2)',
|
||||
() async {
|
||||
final id = await conversations.start(
|
||||
question: 'Gone',
|
||||
assistant: _claude,
|
||||
scope: _scope,
|
||||
);
|
||||
await conversations.keep(id, _turn('Gone', 'Yes'), LlmUsage.none);
|
||||
|
||||
await conversations.delete(id);
|
||||
|
||||
expect(gateway.blanked, [id]);
|
||||
expect(await conversations.all(), isEmpty);
|
||||
expect(await conversations.turnsOf(id), isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('skips what it cannot read, and keeps the rest', () async {
|
||||
gateway.receiveConversation(
|
||||
'conversation-900',
|
||||
encodeTurnPayload(
|
||||
_turn('a', 'b'),
|
||||
LlmUsage.none,
|
||||
),
|
||||
);
|
||||
await conversations.start(
|
||||
question: 'Readable',
|
||||
assistant: _claude,
|
||||
scope: _scope,
|
||||
);
|
||||
|
||||
expect(
|
||||
[for (final each in await conversations.all()) each.title],
|
||||
[
|
||||
'Readable',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
group("where this device can't seal (D52)", () {
|
||||
setUp(() => gateway.sealing = false);
|
||||
|
||||
test('says it keeps nothing', () async {
|
||||
expect(await conversations.keeps(), isFalse);
|
||||
});
|
||||
|
||||
test('refuses to start a conversation, writing nothing', () async {
|
||||
await expectLater(
|
||||
conversations.start(question: 'Hi', assistant: _claude, scope: _scope),
|
||||
throwsA(isA<ConversationNotKept>()),
|
||||
);
|
||||
expect(gateway.written, isEmpty);
|
||||
});
|
||||
|
||||
test('refuses to keep a turn, writing nothing', () async {
|
||||
gateway.sealing = true;
|
||||
final id = await conversations.start(
|
||||
question: 'Hi',
|
||||
assistant: _claude,
|
||||
scope: _scope,
|
||||
);
|
||||
gateway.sealing = false;
|
||||
|
||||
await expectLater(
|
||||
conversations.keep(id, _turn('Hi', 'Hello'), LlmUsage.none),
|
||||
throwsA(isA<ConversationNotKept>()),
|
||||
);
|
||||
expect(gateway.written.keys, [id]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_keys.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy_ledger.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/sync_content_key_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/sync_conversation_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
// The conversations on the device's own database (ADR 0021, D13, D51, D52):
|
||||
// sealed with the person's key, every row; nothing written where the device
|
||||
// can't seal; and what an instance could make up or move left unread.
|
||||
|
||||
final _person = protocol.UuidValue.withValidation(
|
||||
'01a16000-0000-7000-8000-000000000001',
|
||||
);
|
||||
|
||||
ByteData _text(String text) => ByteData.sublistView(utf8.encode(text));
|
||||
|
||||
/// [payload] as text; sealed bytes, which needn't be UTF-8, as far as they
|
||||
/// read.
|
||||
String _read(ByteData payload) =>
|
||||
utf8.decode(Uint8List.sublistView(payload), allowMalformed: true);
|
||||
|
||||
void main() {
|
||||
late Directory folder;
|
||||
late OfflineSyncDatabaseSession db;
|
||||
late SyncService sync;
|
||||
final opened = <({ContentKeys keys, Privacy privacy})>[];
|
||||
|
||||
setUp(() async {
|
||||
folder = await Directory.systemTemp.createTemp('conversations');
|
||||
db = await protocol.Client(
|
||||
'http://127.0.0.1:9/',
|
||||
).createSyncSession('${folder.path}/db.sqlite', persistentUserId: _person);
|
||||
sync = SyncService(
|
||||
({required onMerge}) async {},
|
||||
debounce: const Duration(days: 1),
|
||||
);
|
||||
});
|
||||
tearDown(() async {
|
||||
for (final device in opened) {
|
||||
await device.privacy.dispose();
|
||||
await device.keys.dispose();
|
||||
}
|
||||
opened.clear();
|
||||
await sync.dispose();
|
||||
await db.close();
|
||||
await folder.delete(recursive: true);
|
||||
});
|
||||
|
||||
/// The gateway of a device holding the account's keys, or of one without
|
||||
/// them ([account] null) where the register says the account [hasKeys].
|
||||
Future<SyncConversationGateway> device({
|
||||
UnlockedAccountKeys? account,
|
||||
bool hasKeys = true,
|
||||
}) async {
|
||||
final ledger = PrivacyLedger(MemoryPrivacyLedgerStore());
|
||||
await ledger.open();
|
||||
final keys = ContentKeys(
|
||||
SyncContentKeyGateway(db, sync),
|
||||
FakeContentCipher(),
|
||||
() async => account,
|
||||
seen: ledger,
|
||||
registerHasKeys: () async => hasKeys,
|
||||
);
|
||||
await keys.refresh();
|
||||
final privacy = Privacy(db, keys);
|
||||
opened.add((keys: keys, privacy: privacy));
|
||||
return SyncConversationGateway(db, sync, privacy);
|
||||
}
|
||||
|
||||
final alice = FakeAccountCrypto().keysNumbered(1);
|
||||
|
||||
test('writes every row sealed, and reads it back', () async {
|
||||
final gateway = await device(account: alice);
|
||||
final id = await gateway.insertConversation(_text('Why て-form?'));
|
||||
await gateway.insertTurn(id, _text('Because.'));
|
||||
|
||||
final row = (await protocol.AssistantConversation.db.find(db)).single;
|
||||
expect(row.keyId, isNotNull);
|
||||
expect(_read(row.payload), isNot(contains('て-form')));
|
||||
final turn = (await protocol.AssistantTurn.db.find(db)).single;
|
||||
expect(turn.keyId, isNotNull);
|
||||
|
||||
expect(
|
||||
_read((await gateway.conversations()).single.payload),
|
||||
'Why て-form?',
|
||||
);
|
||||
expect(_read((await gateway.turnsOf(id)).single.payload), 'Because.');
|
||||
expect(await gateway.seals(), isTrue);
|
||||
});
|
||||
|
||||
test("a device without the account's keys neither seals nor reads", () async {
|
||||
final laptop = await device(account: alice);
|
||||
final id = await laptop.insertConversation(_text('Mine'));
|
||||
final keyless = await device();
|
||||
|
||||
expect(await keyless.seals(), isFalse);
|
||||
expect(await keyless.conversations(), isEmpty);
|
||||
await expectLater(
|
||||
keyless.insertConversation(_text('Unsealed')),
|
||||
throwsA(isA<PrivacyRefused>()),
|
||||
);
|
||||
await expectLater(
|
||||
keyless.insertTurn(id, _text('Unsealed')),
|
||||
throwsA(isA<PrivacyRefused>()),
|
||||
);
|
||||
expect(await protocol.AssistantConversation.db.count(db), 1);
|
||||
expect(await protocol.AssistantTurn.db.count(db), 0);
|
||||
});
|
||||
|
||||
test('an account without keys keeps none (D52)', () async {
|
||||
final gateway = await device(hasKeys: false);
|
||||
|
||||
expect(await gateway.seals(), isFalse);
|
||||
await expectLater(
|
||||
gateway.insertConversation(_text('Unsealed')),
|
||||
throwsA(isA<PrivacyRefused>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('a readable row, which only an instance could have made, is never '
|
||||
'read', () async {
|
||||
final gateway = await device(account: alice);
|
||||
final id = await gateway.insertConversation(_text('Mine'));
|
||||
await protocol.AssistantConversation.db.insertRow(
|
||||
db,
|
||||
protocol.AssistantConversation(payload: _text('Made up')),
|
||||
);
|
||||
await protocol.AssistantTurn.db.insertRow(
|
||||
db,
|
||||
protocol.AssistantTurn(
|
||||
conversationId: protocol.UuidValue.withValidation(id),
|
||||
payload: _text('Made up'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
[
|
||||
for (final row in await gateway.conversations()) _read(row.payload),
|
||||
],
|
||||
['Mine'],
|
||||
);
|
||||
expect(await gateway.turnsOf(id), isEmpty);
|
||||
});
|
||||
|
||||
test('a turn moved to another conversation no longer opens', () async {
|
||||
final gateway = await device(account: alice);
|
||||
final first = await gateway.insertConversation(_text('First'));
|
||||
final second = await gateway.insertConversation(_text('Second'));
|
||||
await gateway.insertTurn(first, _text('Of the first'));
|
||||
final turn = (await protocol.AssistantTurn.db.find(db)).single;
|
||||
await protocol.AssistantTurn.db.updateRow(
|
||||
db,
|
||||
turn.copyWith(conversationId: protocol.UuidValue.withValidation(second)),
|
||||
columns: (t) => [t.conversationId],
|
||||
);
|
||||
|
||||
expect(await gateway.turnsOf(second), isEmpty);
|
||||
expect(await gateway.turnsOf(first), isEmpty);
|
||||
});
|
||||
|
||||
test('blanks a conversation and its turns, then deletes them', () async {
|
||||
final gateway = await device(account: alice);
|
||||
final id = await gateway.insertConversation(_text('Gone'));
|
||||
await gateway.insertTurn(id, _text('Turn'));
|
||||
|
||||
await gateway.blankConversation(id);
|
||||
expect(
|
||||
(await protocol.AssistantTurn.db.find(db)).single.payload.lengthInBytes,
|
||||
0,
|
||||
);
|
||||
expect(await gateway.conversations(), isEmpty);
|
||||
|
||||
await gateway.deleteConversation(id);
|
||||
expect(await protocol.AssistantConversation.db.count(db), 0);
|
||||
expect(await protocol.AssistantTurn.db.count(db), 0);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_gateway.dart';
|
||||
|
||||
/// A [ConversationGateway] in memory: conversation ids are
|
||||
/// `conversation-001`, …, turn ids `turn-001`, …, in the order of creation
|
||||
/// as UUID v7s are, each started or written a minute after the one before.
|
||||
class FakeConversationGateway() implements ConversationGateway {
|
||||
final _conversations = <String, ByteData>{};
|
||||
final _turns = <String, ({String conversationId, ByteData payload})>{};
|
||||
final _remoteChanges = StreamController<void>.broadcast();
|
||||
var _next = 1;
|
||||
|
||||
/// Whether what it writes is sealed with the person's key, as on a device
|
||||
/// holding the account's keys.
|
||||
bool sealing = true;
|
||||
|
||||
/// The ids of the conversations blanked, in order.
|
||||
final blanked = <String>[];
|
||||
|
||||
/// Every payload written, by the row's id.
|
||||
final written = <String, ByteData>{};
|
||||
|
||||
static final _start = DateTime.utc(2026, 10, 26, 9);
|
||||
|
||||
DateTime _timeOf(String id) =>
|
||||
_start.add(Duration(minutes: int.parse(id.split('-').last)));
|
||||
|
||||
String _id(String kind) => '$kind-${'${_next++}'.padLeft(3, '0')}';
|
||||
|
||||
/// Stores a conversation as if another device had written it and a sync
|
||||
/// had brought it here.
|
||||
void receiveConversation(String id, ByteData payload) {
|
||||
_conversations[id] = payload;
|
||||
_remoteChanges.add(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ConversationRow>> conversations() async => [
|
||||
for (final MapEntry(key: id, value: payload) in _conversations.entries)
|
||||
if (payload.lengthInBytes > 0)
|
||||
(id: id, payload: payload, startedAt: _timeOf(id)),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<TurnRow>> turnsOf(String conversationId) async => [
|
||||
for (final MapEntry(key: id, value: turn) in _turns.entries)
|
||||
if (turn.conversationId == conversationId &&
|
||||
turn.payload.lengthInBytes > 0)
|
||||
(id: id, payload: turn.payload, writtenAt: _timeOf(id)),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<bool> seals() async => sealing;
|
||||
|
||||
@override
|
||||
Future<String> insertConversation(ByteData payload) async {
|
||||
_refuseUnsealed();
|
||||
final id = _id('conversation');
|
||||
_conversations[id] = payload;
|
||||
written[id] = payload;
|
||||
return id;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> insertTurn(String conversationId, ByteData payload) async {
|
||||
_refuseUnsealed();
|
||||
final id = _id('turn');
|
||||
_turns[id] = (conversationId: conversationId, payload: payload);
|
||||
written[id] = payload;
|
||||
return id;
|
||||
}
|
||||
|
||||
void _refuseUnsealed() {
|
||||
if (!sealing) {
|
||||
throw StateError('Nothing is written unsealed.');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> blankConversation(String conversationId) async {
|
||||
blanked.add(conversationId);
|
||||
if (_conversations.containsKey(conversationId)) {
|
||||
_conversations[conversationId] = ByteData(0);
|
||||
}
|
||||
for (final MapEntry(key: id, value: turn) in _turns.entries.toList()) {
|
||||
if (turn.conversationId == conversationId) {
|
||||
_turns[id] = (conversationId: conversationId, payload: ByteData(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteConversation(String conversationId) async {
|
||||
_conversations.remove(conversationId);
|
||||
_turns.removeWhere((_, turn) => turn.conversationId == conversationId);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _remoteChanges.stream;
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/model/import_archive.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
|
||||
|
|
@ -79,6 +80,7 @@ import 'fake_asset_gateway.dart';
|
|||
import 'fake_asset_sealing.dart';
|
||||
import 'fake_asset_server_gateway.dart';
|
||||
import 'fake_card_gateway.dart';
|
||||
import 'fake_conversation_gateway.dart';
|
||||
import 'fake_device_dictionaries.dart';
|
||||
import 'fake_dictionary_gateway.dart';
|
||||
import 'fake_discussion_gateway.dart';
|
||||
|
|
@ -112,8 +114,8 @@ import 'memory_studyset_tab_store.dart';
|
|||
/// database; each test passes in only the fakes it looks at.
|
||||
///
|
||||
/// Every person gets [studysetGateway], [cardGateway], [noteGateway],
|
||||
/// [pathGateway], [answerGateway], [tagGateway], [settingsGateway] and the
|
||||
/// asset fakes (or fresh fakes of their own),
|
||||
/// [pathGateway], [answerGateway], [tagGateway], [settingsGateway],
|
||||
/// [conversationGateway] and the asset fakes (or fresh fakes of their own),
|
||||
/// unless [openPerson] replaces how a person's dependencies are opened.
|
||||
///
|
||||
/// The device starts with [deviceSettings], kept in memory unless a test
|
||||
|
|
@ -141,6 +143,7 @@ Dependencies testDependencies({
|
|||
YoutubeVideos? videos,
|
||||
PdfEngine? pdfEngine,
|
||||
SettingsGateway? settingsGateway,
|
||||
FakeConversationGateway? conversationGateway,
|
||||
AssetGateway? assetGateway,
|
||||
AssetServerGateway? assetServer,
|
||||
AssetFileCache? assetFiles,
|
||||
|
|
@ -288,6 +291,9 @@ Dependencies testDependencies({
|
|||
final settings = SettingsRepository(
|
||||
settingsGateway ?? FakeSettingsGateway(),
|
||||
);
|
||||
final conversations = ConversationRepository(
|
||||
conversationGateway ?? FakeConversationGateway(),
|
||||
);
|
||||
final reviews = ReviewRepository(
|
||||
reviewGateway ?? FakeReviewGateway(),
|
||||
);
|
||||
|
|
@ -338,6 +344,7 @@ Dependencies testDependencies({
|
|||
studysetProgress: studysetProgress,
|
||||
tags: tags,
|
||||
settings: settings,
|
||||
conversations: conversations,
|
||||
assets: assets,
|
||||
deletions: deletions,
|
||||
noteDeletions: noteDeletions,
|
||||
|
|
@ -402,6 +409,7 @@ Dependencies testDependencies({
|
|||
await studysets.dispose();
|
||||
await tags.dispose();
|
||||
await settings.dispose();
|
||||
await conversations.dispose();
|
||||
await reviews.dispose();
|
||||
await cards.dispose();
|
||||
await notes.dispose();
|
||||
|
|
|
|||
Loading…
Reference in a new issue