feat(learn): Learn's agent, an MCP server suggesting in shared studysets
Some checks failed
CI / check (push) Failing after 1m56s
Some checks failed
CI / check (push) Failing after 1m56s
Halkyon's ADR 0044, step 6c (D7, D49 to D52). apps/learn/halkyon_learn_agent is one executable, `halkyon-agent --instance URL sign-in | sync | mcp`: - sign-in keeps the token read from stdin, signs in with it, makes the agent's key pair on its first sign-in, keeps it before setting the public halves on the register (agents.setKeys), and syncs; - it opens its data as the app's _openPersonData does, on halkyon_learn_core and halkyon_content: its own SQLite database, the sync's space router, its keys and those of the spaces shared with it, and the repositories of studysets, cards, notes, paths, files and feedback; - mcp serves ten tools over stdio (dart_mcp): list, read and search the studysets shared with it, suggest a change or a new card or note, report, list a studyset's suggestions and reply. Each syncs first and a suggestion again at once; a refusal is the tool's error; - its files live in $XDG_CONFIG_HOME/halkyon-agent/<host>, 0700 and 0600, refused when others may read them (D50); - tool/install.sh builds it with `dart build cli` (libsodium and SQLite bundled) and copies it to its user on the host, the build before kept (D52). The synced gateways of learning paths and files, and the fakes of the core's gateways, move into halkyon_learn_core (its testing.dart), which the app's tests and the agent's share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
72cac92365
commit
54e212a51c
10 changed files with 2925 additions and 314 deletions
|
|
@ -1,82 +0,0 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_core/core/asset/data/asset_gateway.dart';
|
||||
|
||||
/// An asset row as tests write one: readable, unless
|
||||
/// `FakeAssetGateway.receiveRemote` is told its key.
|
||||
typedef FakeAssetRow = ({
|
||||
String id,
|
||||
String sha256,
|
||||
String mimeType,
|
||||
int byteSize,
|
||||
ByteData payload,
|
||||
});
|
||||
|
||||
/// An [AssetGateway] in memory: ids are `asset-001`, `asset-002`, … in the
|
||||
/// order of creation. Payloads are kept as given: sealing them is the
|
||||
/// envelope's, which its own tests cover.
|
||||
class FakeAssetGateway() implements AssetGateway {
|
||||
final _rows = <String, AssetRow>{};
|
||||
var _next = 1;
|
||||
|
||||
/// The rows, in the order they were stored.
|
||||
List<AssetRow> get rows => [..._rows.values];
|
||||
|
||||
/// The rows deleted, in order.
|
||||
final deleted = <String>[];
|
||||
|
||||
/// Stores [row] as if another device had written it and a sync had
|
||||
/// brought it here, sealed with [keyId] if given, [locked] where that key
|
||||
/// doesn't open here.
|
||||
void receiveRemote(FakeAssetRow row, {String? keyId, bool locked = false}) =>
|
||||
_rows[row.id] = (
|
||||
id: row.id,
|
||||
sha256: row.sha256,
|
||||
mimeType: row.mimeType,
|
||||
byteSize: row.byteSize,
|
||||
payload: row.payload,
|
||||
keyId: keyId,
|
||||
locked: locked,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<AssetRow?> byId(String id) async => _rows[id];
|
||||
|
||||
@override
|
||||
Future<AssetRow?> bySha256(String sha256) async =>
|
||||
_rows.values.where((row) => row.sha256 == sha256).firstOrNull;
|
||||
|
||||
@override
|
||||
Future<List<AssetRow>> all() async => [..._rows.values];
|
||||
|
||||
@override
|
||||
String newId() => 'asset-${'${_next++}'.padLeft(3, '0')}';
|
||||
|
||||
@override
|
||||
Future<String> insert({
|
||||
required String id,
|
||||
required String sha256,
|
||||
required String mimeType,
|
||||
required int byteSize,
|
||||
required ByteData payload,
|
||||
String? keyId,
|
||||
}) async {
|
||||
_rows[id] = (
|
||||
id: id,
|
||||
sha256: sha256,
|
||||
mimeType: mimeType,
|
||||
byteSize: byteSize,
|
||||
payload: payload,
|
||||
keyId: keyId,
|
||||
locked: false,
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id) async {
|
||||
if (_rows.remove(id) != null) {
|
||||
deleted.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_learn_core/core/asset/data/asset_sealing.dart';
|
||||
|
||||
/// [AssetSealing] for tests: the studysets in [keys] are private, sealed
|
||||
/// with the key named; the others readable. Files are sealed with
|
||||
/// [FakeContentCipher], bound to their row as the real ones are, behind a
|
||||
/// header naming their key.
|
||||
class FakeAssetSealing({
|
||||
/// Each private studyset's key, by its id.
|
||||
final Map<String, String> keys = const {},
|
||||
|
||||
/// Each private item's key, by its id.
|
||||
final Map<String, String> itemKeys = const {},
|
||||
}) implements AssetSealing {
|
||||
/// The cipher the keys are open in; [keys]' keys are opened on first use.
|
||||
final cipher = FakeContentCipher();
|
||||
|
||||
var _unlocked = false;
|
||||
|
||||
void _unlock() {
|
||||
if (_unlocked) {
|
||||
return;
|
||||
}
|
||||
_unlocked = true;
|
||||
unawaited(cipher.unlock(FakeAccountCrypto().keysNumbered(1)));
|
||||
<String>{...keys.values, ...itemKeys.values}.forEach(cipher.newKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> keyOfStudyset(String studysetId) async => keys[studysetId];
|
||||
|
||||
@override
|
||||
Future<String?> keyToWriteIn(String studysetId) async => keys[studysetId];
|
||||
|
||||
@override
|
||||
Future<String?> keyOfItem(ItemKind kind, String id) async => itemKeys[id];
|
||||
|
||||
@override
|
||||
Uint8List sealFile(
|
||||
Uint8List plain, {
|
||||
required String keyId,
|
||||
required String assetId,
|
||||
}) {
|
||||
_unlock();
|
||||
final sealed = cipher.seal(
|
||||
plain,
|
||||
keyId: keyId,
|
||||
context: utf8.encode('asset|$assetId|file'),
|
||||
);
|
||||
final id = utf8.encode(keyId);
|
||||
return Uint8List.fromList([id.length, ...id, ...sealed.payload]);
|
||||
}
|
||||
|
||||
@override
|
||||
Uint8List? openFile(Uint8List sealed, {required String assetId}) {
|
||||
_unlock();
|
||||
if (sealed.isEmpty || sealed.length < 1 + sealed[0]) {
|
||||
return null;
|
||||
}
|
||||
final keyId = utf8.decode(
|
||||
Uint8List.sublistView(sealed, 1, 1 + sealed[0]),
|
||||
allowMalformed: true,
|
||||
);
|
||||
return cipher.unseal(
|
||||
SealedPayload(
|
||||
keyId: keyId,
|
||||
nonce: Uint8List(24),
|
||||
payload: Uint8List.sublistView(sealed, 1 + sealed[0]),
|
||||
),
|
||||
context: utf8.encode('asset|$assetId|file'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_learn_core/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_core/feature/card/data/card_gateway.dart';
|
||||
|
||||
/// A [CardGateway] in memory. Ids sort by creation, as UUID v7 ids do: cards
|
||||
/// are `card-0001`, `card-0002`, …, revisions `rev-0001`, …;
|
||||
/// [receiveRemoteRevision] plays a sync that brought another device's edit.
|
||||
class FakeCardGateway() implements CardGateway {
|
||||
final _cards =
|
||||
<
|
||||
String,
|
||||
({
|
||||
String studysetId,
|
||||
String position,
|
||||
DateTime? buriedAt,
|
||||
DateTime createdAt,
|
||||
})
|
||||
>{};
|
||||
final _revisions = <String, CardRevisionRow>{};
|
||||
final _remoteChanges = StreamController<void>.broadcast();
|
||||
var _nextId = 1;
|
||||
|
||||
/// Every revision written on this device, in order.
|
||||
final written = <CardRevisionRow>[];
|
||||
|
||||
/// The deletion steps, in order: `blank <card id>`, `delete <card id>`.
|
||||
final deletions = <String>[];
|
||||
|
||||
/// When set, storing a card or a revision fails with it.
|
||||
Exception? failWrites;
|
||||
|
||||
/// When set, deleting a card fails with it, before anything is blanked.
|
||||
Exception? failDeletes;
|
||||
|
||||
/// When set, deleting a card waits until it completes.
|
||||
Completer<void>? holdDeletes;
|
||||
|
||||
/// When set, storing cards in a batch waits until it completes.
|
||||
Completer<void>? holdWrites;
|
||||
|
||||
/// The payload each revision holds now, by revision id.
|
||||
Map<String, ByteData> get payloads => {
|
||||
for (final revision in _revisions.values) revision.id: revision.payload,
|
||||
};
|
||||
|
||||
/// Stores a revision as if another device had written it and a sync had
|
||||
/// brought it here.
|
||||
void receiveRemoteRevision({
|
||||
required String id,
|
||||
required String cardId,
|
||||
required Set<String> parents,
|
||||
required ByteData payload,
|
||||
}) {
|
||||
_revisions[id] = (
|
||||
id: id,
|
||||
cardId: cardId,
|
||||
parents: parents,
|
||||
payload: payload,
|
||||
);
|
||||
_remoteChanges.add(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CardRow>> cardsOf(String studysetId) async => [
|
||||
for (final MapEntry(key: id, value: card) in _cards.entries)
|
||||
if (card.studysetId == studysetId)
|
||||
(id: id, position: card.position, buriedAt: card.buriedAt),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<CardStampRow>> allCards() async => [
|
||||
for (final MapEntry(key: id, value: card) in _cards.entries)
|
||||
(id: id, studysetId: card.studysetId, createdAt: card.createdAt),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<CardRevisionRow>> revisionsOf(Set<String> cardIds) async => [
|
||||
for (final revision in _revisions.values)
|
||||
if (cardIds.contains(revision.cardId)) revision,
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<CardRevisionRow>> allRevisions() async => [..._revisions.values];
|
||||
|
||||
@override
|
||||
Future<List<String>> insertCards(
|
||||
String studysetId,
|
||||
List<NewCardRow> cards,
|
||||
) async {
|
||||
if (holdWrites case final hold?) {
|
||||
await hold.future;
|
||||
}
|
||||
return [
|
||||
for (final card in cards)
|
||||
await insertCard(
|
||||
studysetId: studysetId,
|
||||
position: card.position,
|
||||
payload: card.payload,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> insertCard({
|
||||
required String studysetId,
|
||||
required String position,
|
||||
required ByteData payload,
|
||||
}) async {
|
||||
if (failWrites case final error?) {
|
||||
throw error;
|
||||
}
|
||||
final id = _newId('card');
|
||||
_cards[id] = (
|
||||
studysetId: studysetId,
|
||||
position: position,
|
||||
buriedAt: null,
|
||||
createdAt: clock.now().toUtc(),
|
||||
);
|
||||
await insertRevision(cardId: id, parents: const {}, payload: payload);
|
||||
return id;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> insertRevision({
|
||||
required String cardId,
|
||||
required Set<String> parents,
|
||||
required ByteData payload,
|
||||
}) async {
|
||||
if (failWrites case final error?) {
|
||||
throw error;
|
||||
}
|
||||
final revision = (
|
||||
id: _newId('rev'),
|
||||
cardId: cardId,
|
||||
parents: parents,
|
||||
payload: payload,
|
||||
);
|
||||
_revisions[revision.id] = revision;
|
||||
written.add(revision);
|
||||
return revision.id;
|
||||
}
|
||||
|
||||
/// When set, moving cards fails with it, none moved.
|
||||
Exception? failMoves;
|
||||
|
||||
/// While set, moving cards waits for it to complete.
|
||||
Completer<void>? holdMoves;
|
||||
|
||||
/// How many times cards were moved, however many at once.
|
||||
int moves = 0;
|
||||
|
||||
@override
|
||||
Future<void> updateStudysets(
|
||||
String studysetId,
|
||||
Map<String, String> positions,
|
||||
) async {
|
||||
await holdMoves?.future;
|
||||
if (failMoves case final error?) {
|
||||
throw error;
|
||||
}
|
||||
moves++;
|
||||
for (final MapEntry(key: id, value: position) in positions.entries) {
|
||||
_place(id, studysetId, position);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateStudyset(
|
||||
String cardId,
|
||||
String studysetId,
|
||||
String position,
|
||||
) async => _place(cardId, studysetId, position);
|
||||
|
||||
void _place(String cardId, String studysetId, String position) {
|
||||
if (_cards[cardId] case final card?) {
|
||||
_cards[cardId] = (
|
||||
studysetId: studysetId,
|
||||
position: position,
|
||||
buriedAt: card.buriedAt,
|
||||
createdAt: card.createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateBuriedAt(String cardId, DateTime? buriedAt) async {
|
||||
if (_cards[cardId] case final card?) {
|
||||
_cards[cardId] = (
|
||||
studysetId: card.studysetId,
|
||||
position: card.position,
|
||||
buriedAt: buriedAt?.toUtc(),
|
||||
createdAt: card.createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> blankRevisions(String cardId) async {
|
||||
await holdDeletes?.future;
|
||||
if (failDeletes case final error?) {
|
||||
throw error;
|
||||
}
|
||||
deletions.add('blank $cardId');
|
||||
for (final revision in [..._revisions.values]) {
|
||||
if (revision.cardId == cardId) {
|
||||
_revisions[revision.id] = (
|
||||
id: revision.id,
|
||||
cardId: cardId,
|
||||
parents: revision.parents,
|
||||
payload: redactedPayload,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteCard(String cardId) async {
|
||||
deletions.add('delete $cardId');
|
||||
_cards.remove(cardId);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _remoteChanges.stream;
|
||||
|
||||
String _newId(String kind) =>
|
||||
'$kind-${(_nextId++).toString().padLeft(4, '0')}';
|
||||
}
|
||||
|
|
@ -1,227 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_core/feature/feedback/data/feedback_envelopes.dart';
|
||||
import 'package:halkyon_learn_core/feature/feedback/data/feedback_gateway.dart';
|
||||
import 'package:halkyon_learn_core/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
|
||||
/// (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? space,
|
||||
String? id,
|
||||
String? keyId,
|
||||
})
|
||||
>[];
|
||||
|
||||
/// The replies written, by thread, with their key.
|
||||
final messages =
|
||||
<({String threadId, Map<String, Object?> json, String? keyId})>[];
|
||||
|
||||
/// 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;
|
||||
|
||||
/// While set, writing a decision waits for it to complete.
|
||||
Completer<void>? holdDecisions;
|
||||
|
||||
@override
|
||||
Future<String> insertThread({
|
||||
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 id ?? 'thread-${threads.length}';
|
||||
}
|
||||
|
||||
@override
|
||||
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, {
|
||||
String? space,
|
||||
}) async {
|
||||
await holdDecisions?.future;
|
||||
if (failDecisions case final error?) {
|
||||
throw error;
|
||||
}
|
||||
decisions.add((threadId: threadId, decision: decision, space: space));
|
||||
}
|
||||
|
||||
static Map<String, Object?> _json(ByteData payload) =>
|
||||
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
|
||||
as Map<String, Object?>;
|
||||
}
|
||||
|
||||
/// A [FeedbackServerGateway] in memory: [threads] as the instance gathers
|
||||
/// them (all public, for the inbox too), [files] by SHA-256.
|
||||
class FakeFeedbackServerGateway({
|
||||
final List<FeedbackThread> threads = const [],
|
||||
final Map<String, Uint8List> files = const {},
|
||||
}) implements FeedbackServerGateway {
|
||||
late var _threads = [...threads];
|
||||
|
||||
/// The threads read, and the people blocked.
|
||||
final seen = <String>[];
|
||||
final blocked = <String>[];
|
||||
|
||||
/// When set, every request fails with it.
|
||||
Exception? fail;
|
||||
|
||||
/// While set, marking a thread read waits for it to complete.
|
||||
Completer<void>? holdSeen;
|
||||
|
||||
/// Replaces what the instance gathers.
|
||||
set gathered(List<FeedbackThread> threads) => _threads = [...threads];
|
||||
|
||||
Future<void> _maybeFail() async {
|
||||
if (fail case final error?) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FeedbackThread>> threadsOf(String publicId) async {
|
||||
await _maybeFail();
|
||||
return [
|
||||
for (final thread in _threads)
|
||||
if (thread.publicId == publicId) thread,
|
||||
];
|
||||
}
|
||||
|
||||
@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();
|
||||
return [
|
||||
for (final thread in _threads)
|
||||
if (thread.mine || thread.forMe) thread,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> unseen() async =>
|
||||
(await inbox()).where((thread) => thread.unseen).length;
|
||||
|
||||
@override
|
||||
Future<void> markSeen(String threadId) async {
|
||||
await holdSeen?.future;
|
||||
await _maybeFail();
|
||||
seen.add(threadId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> block(String handle) async => blocked.add(handle);
|
||||
|
||||
@override
|
||||
Future<void> unblock(String handle) async => blocked.remove(handle);
|
||||
|
||||
@override
|
||||
Future<List<String>> blockedPeople() async => [...blocked];
|
||||
|
||||
@override
|
||||
Future<Uint8List> file(String threadId, String sha256) async {
|
||||
await _maybeFail();
|
||||
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] ?? '';
|
||||
}
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_core/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_core/feature/learning_path/data/learning_path_gateway.dart';
|
||||
|
||||
/// A [LearningPathGateway] in memory. Ids sort by creation, as UUID v7 ids
|
||||
/// do: paths are `path-0001`, …, steps `step-0002`, …, revisions
|
||||
/// `srev-0003`, …; [receiveRemoteRevision] plays a sync that brought another
|
||||
/// device's edit.
|
||||
class FakeLearningPathGateway() implements LearningPathGateway {
|
||||
final _paths = <String, PathRow>{};
|
||||
final _steps = <String, StepRow>{};
|
||||
final _revisions = <String, StepRevisionRow>{};
|
||||
final _remoteChanges = StreamController<void>.broadcast();
|
||||
var _nextId = 1;
|
||||
|
||||
/// Every step revision written on this device, in order.
|
||||
final written = <StepRevisionRow>[];
|
||||
|
||||
/// While set, a new step waits for it to complete before it is stored, as
|
||||
/// on a slow device: what shows while steps come in can be seen.
|
||||
Completer<void>? holdSteps;
|
||||
|
||||
/// The deletion steps, in order: `blank step <id>`, `blank path <id>`,
|
||||
/// `delete step <id>`, `delete path <id>`.
|
||||
final deletions = <String>[];
|
||||
|
||||
/// The payload each step revision holds now, by revision id.
|
||||
Map<String, ByteData> get revisionPayloads => {
|
||||
for (final revision in _revisions.values) revision.id: revision.payload,
|
||||
};
|
||||
|
||||
/// The payload the path [id] holds now; null once deleted.
|
||||
ByteData? pathPayload(String id) => _paths[id]?.payload;
|
||||
|
||||
/// Stores a step revision as if another device had written it and a sync
|
||||
/// had brought it here.
|
||||
void receiveRemoteRevision({
|
||||
required String id,
|
||||
required String stepId,
|
||||
required Set<String> parents,
|
||||
required ByteData payload,
|
||||
}) {
|
||||
_revisions[id] = (
|
||||
id: id,
|
||||
stepId: stepId,
|
||||
parents: parents,
|
||||
payload: payload,
|
||||
);
|
||||
_remoteChanges.add(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PathRow>> paths({String? studysetId}) async => [
|
||||
for (final path in _paths.values)
|
||||
if (studysetId == null || path.studysetId == studysetId) path,
|
||||
];
|
||||
|
||||
@override
|
||||
Future<String> insertPath({
|
||||
required String studysetId,
|
||||
required String position,
|
||||
required ByteData payload,
|
||||
}) async {
|
||||
final id = _newId('path');
|
||||
_paths[id] = (
|
||||
id: id,
|
||||
studysetId: studysetId,
|
||||
position: position,
|
||||
payload: payload,
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updatePathPayload(String id, ByteData payload) async {
|
||||
if (_paths[id] case final path?) {
|
||||
_paths[id] = (
|
||||
id: id,
|
||||
studysetId: path.studysetId,
|
||||
position: path.position,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updatePathPosition(String id, String position) async {
|
||||
if (_paths[id] case final path?) {
|
||||
_paths[id] = (
|
||||
id: id,
|
||||
studysetId: path.studysetId,
|
||||
position: position,
|
||||
payload: path.payload,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deletePath(String id) async {
|
||||
deletions.add('delete path $id');
|
||||
_paths.remove(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<StepRow>> steps(Set<String> pathIds) async => [
|
||||
for (final step in _steps.values)
|
||||
if (pathIds.contains(step.pathId)) step,
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<StepRevisionRow>> revisions(Set<String> stepIds) async => [
|
||||
for (final revision in _revisions.values)
|
||||
if (stepIds.contains(revision.stepId)) revision,
|
||||
];
|
||||
|
||||
@override
|
||||
Future<({String stepId, String revisionId})> insertStep({
|
||||
required String pathId,
|
||||
required String position,
|
||||
required String kind,
|
||||
required ByteData payload,
|
||||
}) async {
|
||||
await holdSteps?.future;
|
||||
final id = _newId('step');
|
||||
_steps[id] = (id: id, pathId: pathId, position: position, kind: kind);
|
||||
final revisionId = await insertRevision(
|
||||
stepId: id,
|
||||
parents: const {},
|
||||
payload: payload,
|
||||
);
|
||||
return (stepId: id, revisionId: revisionId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> insertSteps(
|
||||
String pathId,
|
||||
List<NewStepRow> steps,
|
||||
) async => [
|
||||
for (final step in steps)
|
||||
(await insertStep(
|
||||
pathId: pathId,
|
||||
position: step.position,
|
||||
kind: step.kind,
|
||||
payload: step.payload,
|
||||
)).stepId,
|
||||
];
|
||||
|
||||
@override
|
||||
Future<String> insertRevision({
|
||||
required String stepId,
|
||||
required Set<String> parents,
|
||||
required ByteData payload,
|
||||
}) async {
|
||||
final revision = (
|
||||
id: _newId('srev'),
|
||||
stepId: stepId,
|
||||
parents: parents,
|
||||
payload: payload,
|
||||
);
|
||||
_revisions[revision.id] = revision;
|
||||
written.add(revision);
|
||||
return revision.id;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateStepPosition(String id, String position) async {
|
||||
if (_steps[id] case final step?) {
|
||||
_steps[id] = (
|
||||
id: id,
|
||||
pathId: step.pathId,
|
||||
position: position,
|
||||
kind: step.kind,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> blank({String? stepId, String? pathId}) async {
|
||||
if (stepId != null) {
|
||||
deletions.add('blank step $stepId');
|
||||
for (final revision in [..._revisions.values]) {
|
||||
if (revision.stepId == stepId) {
|
||||
_revisions[revision.id] = (
|
||||
id: revision.id,
|
||||
stepId: stepId,
|
||||
parents: revision.parents,
|
||||
payload: redactedPayload,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pathId != null) {
|
||||
deletions.add('blank path $pathId');
|
||||
await updatePathPayload(pathId, redactedPayload);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteStep(String id) async {
|
||||
deletions.add('delete step $id');
|
||||
_steps.remove(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _remoteChanges.stream;
|
||||
|
||||
String _newId(String kind) =>
|
||||
'$kind-${(_nextId++).toString().padLeft(4, '0')}';
|
||||
}
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_learn_core/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_core/feature/note/data/note_gateway.dart';
|
||||
|
||||
/// A [NoteGateway] in memory. Ids sort by creation, as UUID v7 ids do: notes
|
||||
/// are `note-0001`, `note-0002`, …, revisions `nrev-0001`, …, each written
|
||||
/// at the clock's time; [receiveRemoteRevision] plays a sync that brought
|
||||
/// another device's edit.
|
||||
class FakeNoteGateway() implements NoteGateway {
|
||||
final _notes = <String, ({String studysetId, String position})>{};
|
||||
final _revisions = <String, NoteRevisionRow>{};
|
||||
final _remoteChanges = StreamController<void>.broadcast();
|
||||
var _nextId = 1;
|
||||
|
||||
/// Every revision written on this device, in order.
|
||||
final written = <NoteRevisionRow>[];
|
||||
|
||||
/// The deletion steps, in order: `blank <note id>`, `delete <note id>`.
|
||||
final deletions = <String>[];
|
||||
|
||||
/// When set, storing a note or a revision fails with it.
|
||||
Exception? failWrites;
|
||||
|
||||
/// The payload each revision holds now, by revision id.
|
||||
Map<String, ByteData> get payloads => {
|
||||
for (final revision in _revisions.values) revision.id: revision.payload,
|
||||
};
|
||||
|
||||
/// Stores a revision as if another device had written it and a sync had
|
||||
/// brought it here.
|
||||
void receiveRemoteRevision({
|
||||
required String id,
|
||||
required String noteId,
|
||||
required Set<String> parents,
|
||||
required ByteData payload,
|
||||
}) {
|
||||
_revisions[id] = (
|
||||
id: id,
|
||||
noteId: noteId,
|
||||
parents: parents,
|
||||
payload: payload,
|
||||
writtenAt: clock.now().toUtc(),
|
||||
);
|
||||
_remoteChanges.add(null);
|
||||
}
|
||||
|
||||
/// Deletes the note [noteId] as if another device had done it and a sync
|
||||
/// had brought it here.
|
||||
void receiveRemoteDeletion(String noteId) {
|
||||
_notes.remove(noteId);
|
||||
_remoteChanges.add(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<NoteRow>> notesOf(String studysetId) async => [
|
||||
for (final MapEntry(key: id, value: note) in _notes.entries)
|
||||
if (note.studysetId == studysetId) (id: id, position: note.position),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<NoteStampRow>> allNotes() async => [
|
||||
for (final MapEntry(key: id, value: note) in _notes.entries)
|
||||
(id: id, studysetId: note.studysetId),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<NoteRevisionRow>> revisionsOf(Set<String> noteIds) async => [
|
||||
for (final revision in _revisions.values)
|
||||
if (noteIds.contains(revision.noteId)) revision,
|
||||
];
|
||||
|
||||
@override
|
||||
Future<({String noteId, String revisionId})> insertNote({
|
||||
required String studysetId,
|
||||
required String position,
|
||||
required ByteData payload,
|
||||
}) async {
|
||||
if (failWrites case final error?) {
|
||||
throw error;
|
||||
}
|
||||
final id = _newId('note');
|
||||
_notes[id] = (studysetId: studysetId, position: position);
|
||||
final revisionId = await insertRevision(
|
||||
noteId: id,
|
||||
parents: const {},
|
||||
payload: payload,
|
||||
);
|
||||
return (noteId: id, revisionId: revisionId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> insertRevision({
|
||||
required String noteId,
|
||||
required Set<String> parents,
|
||||
required ByteData payload,
|
||||
}) async {
|
||||
if (failWrites case final error?) {
|
||||
throw error;
|
||||
}
|
||||
final revision = (
|
||||
id: _newId('nrev'),
|
||||
noteId: noteId,
|
||||
parents: parents,
|
||||
payload: payload,
|
||||
writtenAt: clock.now().toUtc(),
|
||||
);
|
||||
_revisions[revision.id] = revision;
|
||||
written.add(revision);
|
||||
return revision.id;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updatePosition(String noteId, String position) async {
|
||||
if (_notes[noteId] case final note?) {
|
||||
_notes[noteId] = (studysetId: note.studysetId, position: position);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> blankRevisions(String noteId) async {
|
||||
deletions.add('blank $noteId');
|
||||
for (final revision in [..._revisions.values]) {
|
||||
if (revision.noteId == noteId) {
|
||||
_revisions[revision.id] = (
|
||||
id: revision.id,
|
||||
noteId: noteId,
|
||||
parents: revision.parents,
|
||||
payload: redactedPayload,
|
||||
writtenAt: revision.writtenAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteNote(String noteId) async {
|
||||
deletions.add('delete $noteId');
|
||||
_notes.remove(noteId);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _remoteChanges.stream;
|
||||
|
||||
String _newId(String kind) =>
|
||||
'$kind-${(_nextId++).toString().padLeft(4, '0')}';
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import 'package:halkyon_learn_core/testing.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/sharing/data/studyset_sharing.dart';
|
||||
|
||||
import 'fake_studyset_gateway.dart';
|
||||
|
||||
/// [SpaceSetup] for tests: every space reaches the device at once, unless
|
||||
/// [reaches] says otherwise; a placed studyset is marked shared in [rows],
|
||||
/// as the router places it on a device.
|
||||
|
|
|
|||
|
|
@ -1,347 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_core/core/privacy/model/unsigned_edit.dart';
|
||||
import 'package:halkyon_learn_core/feature/studyset/data/studyset_gateway.dart';
|
||||
|
||||
/// A [StudysetGateway] in memory: ids are `studyset-1`, `studyset-2`, …, and
|
||||
/// [receiveRemoteChange] and [receiveRemoteDeletion] play a sync that brought
|
||||
/// changes.
|
||||
class FakeStudysetGateway() implements StudysetGateway {
|
||||
final _rows = <String, StudysetRow>{};
|
||||
final _remoteChanges = StreamController<void>.broadcast();
|
||||
var _nextId = 1;
|
||||
|
||||
/// Every payload written, in order, as (id, payload).
|
||||
final writes = <(String, ByteData)>[];
|
||||
|
||||
/// The ids of the deleted rows, in order.
|
||||
final deleted = <String>[];
|
||||
|
||||
/// Every move, in order, as (id, parent, position).
|
||||
final moves = <(String, String?, String)>[];
|
||||
|
||||
/// When set, reading the studysets fails with it.
|
||||
Exception? failReads;
|
||||
|
||||
/// When set, reading the studysets waits until it completes.
|
||||
Completer<void>? holdReads;
|
||||
|
||||
/// How often the studysets were read.
|
||||
int reads = 0;
|
||||
|
||||
/// Whether a new top-level studyset is private, as on a device holding
|
||||
/// the account's keys (ADR 0019).
|
||||
bool privateByDefault = false;
|
||||
|
||||
/// Stores [payload] as if another device had written it and a sync had
|
||||
/// brought it here, with the other columns given.
|
||||
void receiveRemoteChange(
|
||||
String id,
|
||||
ByteData payload, {
|
||||
String? parentId,
|
||||
String position = 'a0',
|
||||
DateTime? archivedAt,
|
||||
DateTime? deletedAt,
|
||||
DateTime? starredAt,
|
||||
DateTime? updatedAt,
|
||||
bool isPrivate = false,
|
||||
bool locked = false,
|
||||
}) {
|
||||
_rows[id] = (
|
||||
id: id,
|
||||
payload: payload,
|
||||
parentId: parentId,
|
||||
position: position,
|
||||
archivedAt: archivedAt,
|
||||
deletedAt: deletedAt,
|
||||
starredAt: starredAt,
|
||||
updatedAt: updatedAt,
|
||||
isPrivate: isPrivate,
|
||||
locked: locked,
|
||||
fullyPrivate: true,
|
||||
keysHere: true,
|
||||
);
|
||||
_remoteChanges.add(null);
|
||||
}
|
||||
|
||||
/// Removes the studyset [id] as if another device had deleted it and a
|
||||
/// sync had brought that here.
|
||||
void receiveRemoteDeletion(String id) {
|
||||
_rows.remove(id);
|
||||
_remoteChanges.add(null);
|
||||
}
|
||||
|
||||
/// Every change of privacy, in order, as (id, private).
|
||||
final privacyChanges = <(String, bool)>[];
|
||||
|
||||
/// While set, making a tree private or readable waits for it, as a
|
||||
/// rewrite of thousands of rows does.
|
||||
Completer<void>? holdPrivacy;
|
||||
|
||||
@override
|
||||
bool canMakePrivate = false;
|
||||
|
||||
@override
|
||||
bool keysMissing = false;
|
||||
|
||||
/// The unsigned edits each studyset holds.
|
||||
final unsigned = <String, List<UnsignedEdit>>{};
|
||||
|
||||
/// The studysets whose unsigned edits were taken in.
|
||||
final adopted = <String>[];
|
||||
|
||||
@override
|
||||
Future<List<UnsignedEdit>> unsignedEditsOf(String id) async =>
|
||||
unsigned[id] ?? const [];
|
||||
|
||||
@override
|
||||
Future<int> adoptUnsignedEdits(String id, List<UnsignedEdit> listed) async {
|
||||
adopted.add(id);
|
||||
unsigned.remove(id);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> setPrivate(String id, {required bool private}) async {
|
||||
if (holdPrivacy case final hold?) {
|
||||
await hold.future;
|
||||
}
|
||||
final tree = {id};
|
||||
var grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (final row in _rows.values) {
|
||||
if (tree.contains(row.parentId) && tree.add(row.id)) {
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (final member in tree) {
|
||||
final row = _rows[member]!;
|
||||
_rows[member] = (
|
||||
id: row.id,
|
||||
payload: row.payload,
|
||||
parentId: row.parentId,
|
||||
position: row.position,
|
||||
archivedAt: row.archivedAt,
|
||||
deletedAt: row.deletedAt,
|
||||
starredAt: row.starredAt,
|
||||
updatedAt: row.updatedAt,
|
||||
isPrivate: private,
|
||||
locked: row.locked,
|
||||
fullyPrivate: true,
|
||||
keysHere: true,
|
||||
);
|
||||
}
|
||||
privacyChanges.add((id, private));
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// The studysets in shared spaces, as [sharing] gives them.
|
||||
final shared = <String, StudysetSharing>{};
|
||||
|
||||
@override
|
||||
Future<Map<String, StudysetSharing>> sharing() async => {...shared};
|
||||
|
||||
@override
|
||||
Future<List<StudysetRow>> all() async {
|
||||
reads++;
|
||||
if (holdReads case final hold?) {
|
||||
await hold.future;
|
||||
}
|
||||
if (failReads case final error?) {
|
||||
throw error;
|
||||
}
|
||||
return [..._rows.values];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> insert(
|
||||
ByteData payload,
|
||||
DateTime updatedAt, {
|
||||
String? parentId,
|
||||
String position = 'a0',
|
||||
String? id,
|
||||
}) async {
|
||||
id ??= 'studyset-${_nextId++}';
|
||||
_rows[id] = (
|
||||
id: id,
|
||||
payload: payload,
|
||||
parentId: parentId,
|
||||
position: position,
|
||||
archivedAt: null,
|
||||
deletedAt: null,
|
||||
starredAt: null,
|
||||
updatedAt: updatedAt,
|
||||
// New studysets are private where the device can encrypt (ADR 0019):
|
||||
// the parent's tree's privacy, or [privateByDefault] at the top.
|
||||
isPrivate: switch (parentId) {
|
||||
final parent? => _rows[parent]?.isPrivate ?? false,
|
||||
null => privateByDefault,
|
||||
},
|
||||
locked: false,
|
||||
fullyPrivate: true,
|
||||
keysHere: true,
|
||||
);
|
||||
writes.add((id, payload));
|
||||
return id;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updatePayload(
|
||||
String id,
|
||||
ByteData payload,
|
||||
DateTime updatedAt,
|
||||
) async {
|
||||
if (_rows[id] case final row?) {
|
||||
_rows[id] = (
|
||||
id: id,
|
||||
payload: payload,
|
||||
parentId: row.parentId,
|
||||
position: row.position,
|
||||
archivedAt: row.archivedAt,
|
||||
deletedAt: row.deletedAt,
|
||||
starredAt: row.starredAt,
|
||||
updatedAt: updatedAt,
|
||||
isPrivate: row.isPrivate,
|
||||
locked: row.locked,
|
||||
fullyPrivate: true,
|
||||
keysHere: true,
|
||||
);
|
||||
writes.add((id, payload));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateStarredAt(
|
||||
String id,
|
||||
DateTime? starredAt,
|
||||
DateTime updatedAt,
|
||||
) async {
|
||||
if (_rows[id] case final row?) {
|
||||
_rows[id] = (
|
||||
id: id,
|
||||
payload: row.payload,
|
||||
parentId: row.parentId,
|
||||
position: row.position,
|
||||
archivedAt: row.archivedAt,
|
||||
deletedAt: row.deletedAt,
|
||||
starredAt: starredAt,
|
||||
updatedAt: updatedAt,
|
||||
isPrivate: row.isPrivate,
|
||||
locked: row.locked,
|
||||
fullyPrivate: true,
|
||||
keysHere: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateParent(
|
||||
String id,
|
||||
String? parentId,
|
||||
String position,
|
||||
DateTime updatedAt,
|
||||
) async {
|
||||
if (_rows[id] case final row?) {
|
||||
_rows[id] = (
|
||||
id: id,
|
||||
payload: row.payload,
|
||||
parentId: parentId,
|
||||
position: position,
|
||||
archivedAt: row.archivedAt,
|
||||
deletedAt: row.deletedAt,
|
||||
starredAt: row.starredAt,
|
||||
updatedAt: updatedAt,
|
||||
isPrivate: row.isPrivate,
|
||||
locked: row.locked,
|
||||
fullyPrivate: true,
|
||||
keysHere: true,
|
||||
);
|
||||
moves.add((id, parentId, position));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updatePosition(
|
||||
String id,
|
||||
String position,
|
||||
DateTime updatedAt,
|
||||
) async {
|
||||
if (_rows[id] case final row?) {
|
||||
_rows[id] = (
|
||||
id: id,
|
||||
payload: row.payload,
|
||||
parentId: row.parentId,
|
||||
position: position,
|
||||
archivedAt: row.archivedAt,
|
||||
deletedAt: row.deletedAt,
|
||||
starredAt: row.starredAt,
|
||||
updatedAt: updatedAt,
|
||||
isPrivate: row.isPrivate,
|
||||
locked: row.locked,
|
||||
fullyPrivate: true,
|
||||
keysHere: true,
|
||||
);
|
||||
moves.add((id, row.parentId, position));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateArchivedAt(
|
||||
String id,
|
||||
DateTime? archivedAt,
|
||||
DateTime updatedAt,
|
||||
) async {
|
||||
if (_rows[id] case final row?) {
|
||||
_rows[id] = (
|
||||
id: id,
|
||||
payload: row.payload,
|
||||
parentId: row.parentId,
|
||||
position: row.position,
|
||||
archivedAt: archivedAt,
|
||||
deletedAt: row.deletedAt,
|
||||
starredAt: row.starredAt,
|
||||
updatedAt: updatedAt,
|
||||
isPrivate: row.isPrivate,
|
||||
locked: row.locked,
|
||||
fullyPrivate: true,
|
||||
keysHere: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateDeletedAt(
|
||||
String id,
|
||||
DateTime? deletedAt,
|
||||
DateTime updatedAt,
|
||||
) async {
|
||||
if (_rows[id] case final row?) {
|
||||
_rows[id] = (
|
||||
id: id,
|
||||
payload: row.payload,
|
||||
parentId: row.parentId,
|
||||
position: row.position,
|
||||
archivedAt: row.archivedAt,
|
||||
deletedAt: deletedAt,
|
||||
starredAt: row.starredAt,
|
||||
updatedAt: updatedAt,
|
||||
isPrivate: row.isPrivate,
|
||||
locked: row.locked,
|
||||
fullyPrivate: true,
|
||||
keysHere: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id) async {
|
||||
_rows.remove(id);
|
||||
deleted.add(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _remoteChanges.stream;
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_core/feature/tag/data/tag_gateway.dart';
|
||||
|
||||
/// A [TagGateway] in memory: ids are `tag-001`, `tag-002`, … and
|
||||
/// `card-tag-001`, … and `note-tag-001`, …, in the order of creation as UUID
|
||||
/// v7s are, and the
|
||||
/// `receiveRemote…` methods play a sync that brought changes.
|
||||
class FakeTagGateway() implements TagGateway {
|
||||
final _tags = <String, ByteData>{};
|
||||
final _cardTags = <String, CardTagRow>{};
|
||||
final _noteTags = <String, NoteTagRow>{};
|
||||
final _pathTags = <String, PathTagRow>{};
|
||||
final _remoteChanges = StreamController<void>.broadcast();
|
||||
var _nextTag = 1;
|
||||
var _nextCardTag = 1;
|
||||
var _nextNoteTag = 1;
|
||||
var _nextPathTag = 1;
|
||||
|
||||
/// Every tag payload written, in order, as (id, payload).
|
||||
final writes = <(String, ByteData)>[];
|
||||
|
||||
/// The ids of the deleted tags, in order.
|
||||
final deleted = <String>[];
|
||||
|
||||
/// When set, reading the tags fails with it.
|
||||
Exception? failReads;
|
||||
|
||||
/// How often the cards' tags were read: every card's, so reading them is
|
||||
/// what costs.
|
||||
int cardTagReads = 0;
|
||||
|
||||
/// How many tags cards have, in all.
|
||||
int get cardTagCount => _cardTags.length;
|
||||
|
||||
/// Stores the tag [id] as if another device had written it and a sync had
|
||||
/// brought it here.
|
||||
void receiveRemoteTag(String id, ByteData payload) {
|
||||
_tags[id] = payload;
|
||||
_remoteChanges.add(null);
|
||||
}
|
||||
|
||||
/// Gives the card [cardId] the tag [tagId] as if another device had done
|
||||
/// it and a sync had brought it here.
|
||||
void receiveRemoteCardTag(String id, String cardId, String tagId) {
|
||||
_cardTags[id] = (id: id, cardId: cardId, tagId: tagId);
|
||||
_remoteChanges.add(null);
|
||||
}
|
||||
|
||||
/// Gives the note [noteId] the tag [tagId] as if another device had done
|
||||
/// it and a sync had brought it here.
|
||||
void receiveRemoteNoteTag(String id, String noteId, String tagId) {
|
||||
_noteTags[id] = (id: id, noteId: noteId, tagId: tagId);
|
||||
_remoteChanges.add(null);
|
||||
}
|
||||
|
||||
/// Gives the learning path [pathId] the tag [tagId] as if another device
|
||||
/// had done it and a sync had brought it here.
|
||||
void receiveRemotePathTag(String id, String pathId, String tagId) {
|
||||
_pathTags[id] = (id: id, pathId: pathId, tagId: tagId);
|
||||
_remoteChanges.add(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TagRow>> tags() async {
|
||||
if (failReads case final error?) {
|
||||
throw error;
|
||||
}
|
||||
return [
|
||||
for (final MapEntry(key: id, value: payload) in _tags.entries)
|
||||
(id: id, payload: payload),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CardTagRow>> cardTags() async {
|
||||
cardTagReads++;
|
||||
return [..._cardTags.values];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<NoteTagRow>> noteTags() async => [..._noteTags.values];
|
||||
|
||||
@override
|
||||
Future<List<PathTagRow>> pathTags() async => [..._pathTags.values];
|
||||
|
||||
@override
|
||||
Future<String> insertTag(ByteData payload) async {
|
||||
final id = 'tag-${_padded(_nextTag++)}';
|
||||
_tags[id] = payload;
|
||||
writes.add((id, payload));
|
||||
return id;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateTagPayload(String id, ByteData payload) async {
|
||||
if (_tags.containsKey(id)) {
|
||||
_tags[id] = payload;
|
||||
writes.add((id, payload));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteTag(String id) async {
|
||||
_cardTags.removeWhere((_, row) => row.tagId == id);
|
||||
_noteTags.removeWhere((_, row) => row.tagId == id);
|
||||
_pathTags.removeWhere((_, row) => row.tagId == id);
|
||||
_tags.remove(id);
|
||||
deleted.add(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insertCardTags(
|
||||
Iterable<({String cardId, String tagId})> assignments,
|
||||
) async {
|
||||
for (final (:cardId, :tagId) in assignments) {
|
||||
final id = 'card-tag-${_padded(_nextCardTag++)}';
|
||||
_cardTags[id] = (id: id, cardId: cardId, tagId: tagId);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteCardTags(Set<String> ids) async {
|
||||
_cardTags.removeWhere((id, _) => ids.contains(id));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insertNoteTags(
|
||||
Iterable<({String noteId, String tagId})> assignments,
|
||||
) async {
|
||||
for (final (:noteId, :tagId) in assignments) {
|
||||
final id = 'note-tag-${_padded(_nextNoteTag++)}';
|
||||
_noteTags[id] = (id: id, noteId: noteId, tagId: tagId);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteNoteTags(Set<String> ids) async {
|
||||
_noteTags.removeWhere((id, _) => ids.contains(id));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insertPathTags(
|
||||
Iterable<({String pathId, String tagId})> assignments,
|
||||
) async {
|
||||
for (final (:pathId, :tagId) in assignments) {
|
||||
final id = 'path-tag-${_padded(_nextPathTag++)}';
|
||||
_pathTags[id] = (id: id, pathId: pathId, tagId: tagId);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deletePathTags(Set<String> ids) async {
|
||||
_pathTags.removeWhere((id, _) => ids.contains(id));
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _remoteChanges.stream;
|
||||
|
||||
String _padded(int number) => '$number'.padLeft(3, '0');
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import 'package:halkyon_learn_core/feature/studyset/data/studyset_repository.dar
|
|||
import 'package:halkyon_learn_core/feature/studyset/data/studyset_tabs.dart';
|
||||
import 'package:halkyon_learn_core/feature/tag/data/tag_gateway.dart';
|
||||
import 'package:halkyon_learn_core/feature/tag/data/tag_repository.dart';
|
||||
import 'package:halkyon_learn_core/testing.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/sound_player.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
|
||||
|
|
@ -78,20 +79,14 @@ import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.d
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_progress_repository.dart';
|
||||
|
||||
import 'fake_asset_gateway.dart';
|
||||
import 'fake_asset_sealing.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';
|
||||
import 'fake_explore_gateway.dart';
|
||||
import 'fake_feedback_gateways.dart';
|
||||
import 'fake_file_saver.dart';
|
||||
import 'fake_grammar_gateway.dart';
|
||||
import 'fake_jellyfin.dart';
|
||||
import 'fake_learning_path_gateway.dart';
|
||||
import 'fake_note_gateway.dart';
|
||||
import 'fake_path_answer_gateway.dart';
|
||||
import 'fake_pdf_engine.dart';
|
||||
import 'fake_pdf_source_gateway.dart';
|
||||
|
|
@ -102,8 +97,6 @@ import 'fake_review_gateway.dart';
|
|||
import 'fake_settings_gateway.dart';
|
||||
import 'fake_sound_player.dart';
|
||||
import 'fake_space_setup.dart';
|
||||
import 'fake_studyset_gateway.dart';
|
||||
import 'fake_tag_gateway.dart';
|
||||
import 'fake_youtube_videos.dart';
|
||||
import 'memory_device_settings_store.dart';
|
||||
import 'memory_pomodoro_run_store.dart';
|
||||
|
|
|
|||
Loading…
Reference in a new issue