feat(todo): the person's tasks, sections and labels, sealed and synced (ADR 0033, D6)
A table's rows are sealed for their table and row by one SealedRows, which the repositories of projects, sections, tasks and labels share, each table with its own sync gateway, a deletion blanking its rows first in one transaction. A task's payload holds its title, description, project, section, parent, place, date (a day, an optional wall-clock time and duration), deadline, priority, labels and when it was done; deleting a task deletes its sub-tasks, and a project or a section goes with its tasks. Rows that don't open show locked; a device without the account's keys writes nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
128bcb127b
commit
bd3710ae58
6 changed files with 1557 additions and 224 deletions
|
|
@ -4,12 +4,15 @@ import 'package:halkyon_app/testing.dart';
|
|||
import 'package:halkyon_todo_flutter/src/app/todo_app.dart';
|
||||
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../support/memory_content_key_gateway.dart';
|
||||
import '../support/memory_project_gateway.dart';
|
||||
import '../support/memory_sealed_row_gateway.dart';
|
||||
|
||||
void main() {
|
||||
final builtIn = Instance.parse('todo.example.org')!;
|
||||
|
|
@ -17,8 +20,34 @@ void main() {
|
|||
late FakeAuthGateway gateway;
|
||||
late List<SignedIn> opened;
|
||||
late MemorySignInAddresses addresses;
|
||||
late TodoKeys keys;
|
||||
late SyncService sync;
|
||||
late MemorySealedRowGateway projectRows;
|
||||
late MemorySealedRowGateway sectionRows;
|
||||
late MemorySealedRowGateway taskRows;
|
||||
late MemorySealedRowGateway labelRows;
|
||||
|
||||
tearDown(() async {
|
||||
await sync.dispose();
|
||||
keys.dispose();
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
// The account's keys on this device, sealing with halkyon_app's fake
|
||||
// cipher; each table's rows in memory.
|
||||
keys = TodoKeys(
|
||||
MemoryContentKeyGateway(),
|
||||
FakeContentCipher(),
|
||||
() async => FakeAccountCrypto().keysNumbered(1),
|
||||
);
|
||||
sync = SyncService(
|
||||
({required onMerge}) async {},
|
||||
debounce: const Duration(days: 1),
|
||||
);
|
||||
projectRows = MemorySealedRowGateway();
|
||||
sectionRows = MemorySealedRowGateway();
|
||||
taskRows = MemorySealedRowGateway();
|
||||
labelRows = MemorySealedRowGateway();
|
||||
store = MemoryInstanceStore();
|
||||
gateway = FakeAuthGateway();
|
||||
addresses = MemorySignInAddresses();
|
||||
|
|
@ -91,25 +120,13 @@ void main() {
|
|||
'https://code.halkyon.app/project-halkyon/halkyon',
|
||||
openPerson: (person) async {
|
||||
opened.add(person);
|
||||
final keys = TodoKeys(
|
||||
MemoryContentKeyGateway(),
|
||||
SodiumContentCipher(),
|
||||
() async => null,
|
||||
);
|
||||
final sync = SyncService(
|
||||
({required onMerge}) async {},
|
||||
debounce: const Duration(days: 1),
|
||||
);
|
||||
final projects = ProjectRepository(MemoryProjectGateway(), keys);
|
||||
return TodoPerson(
|
||||
person,
|
||||
sync: sync,
|
||||
projects: projects,
|
||||
onDispose: () async {
|
||||
await projects.dispose();
|
||||
await sync.dispose();
|
||||
keys.dispose();
|
||||
},
|
||||
projects: ProjectRepository(projectRows, keys),
|
||||
sections: SectionRepository(sectionRows, keys),
|
||||
tasks: TaskRepository(taskRows, keys),
|
||||
labels: LabelRepository(labelRows, keys),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.da
|
|||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
||||
import '../../support/memory_content_key_gateway.dart';
|
||||
import '../../support/memory_project_gateway.dart';
|
||||
import '../../support/memory_sealed_row_gateway.dart';
|
||||
|
||||
/// The person's projects, sealed on the way to the instance and opened on
|
||||
/// the way back (ADR 0033, D5, D6), with libsodium.
|
||||
|
|
@ -19,7 +19,7 @@ void main() {
|
|||
late UnlockedAccountKeys alice;
|
||||
late UnlockedAccountKeys bob;
|
||||
late MemoryContentKeyGateway keyRows;
|
||||
late MemoryProjectGateway rows;
|
||||
late MemorySealedRowGateway rows;
|
||||
final opened = <TodoKeys>[];
|
||||
|
||||
const home = ProjectContent(
|
||||
|
|
@ -42,7 +42,7 @@ void main() {
|
|||
|
||||
setUp(() {
|
||||
keyRows = MemoryContentKeyGateway();
|
||||
rows = MemoryProjectGateway();
|
||||
rows = MemorySealedRowGateway();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
|
|
@ -61,7 +61,7 @@ void main() {
|
|||
|
||||
final laptop = await device(alice);
|
||||
expect(await laptop.ensureInbox(), id);
|
||||
expect(rows.rows.keys.where((key) => key == id), hasLength(1));
|
||||
expect(rows.byId.keys.where((key) => key == id), hasLength(1));
|
||||
final all = await laptop.all();
|
||||
expect(all.first, StoredProject(id, ProjectContent.inbox, inbox: true));
|
||||
expect(all.where((project) => project.inbox), hasLength(1));
|
||||
|
|
@ -78,7 +78,7 @@ void main() {
|
|||
]);
|
||||
|
||||
expect(ids.toSet(), hasLength(1));
|
||||
expect(rows.rows, hasLength(1));
|
||||
expect(rows.byId, hasLength(1));
|
||||
});
|
||||
|
||||
test("names nothing of the person's that the instance could read: its "
|
||||
|
|
@ -94,7 +94,7 @@ void main() {
|
|||
final id = await phone.ensureInbox();
|
||||
|
||||
await expectLater(phone.delete(id), throwsA(isA<InboxKept>()));
|
||||
expect(rows.rows.keys, contains(id));
|
||||
expect(rows.byId.keys, contains(id));
|
||||
});
|
||||
|
||||
test('is only the row under its ID: a row claiming to be it elsewhere is '
|
||||
|
|
@ -142,7 +142,7 @@ void main() {
|
|||
'JSON', () async {
|
||||
await (await device(alice)).save(home);
|
||||
|
||||
final sealed = rows.rows.values.single;
|
||||
final sealed = rows.byId.values.single;
|
||||
expect(sealed.keyId, isNotNull);
|
||||
final text = utf8.decode(
|
||||
Uint8List.sublistView(sealed.payload),
|
||||
|
|
@ -156,8 +156,8 @@ void main() {
|
|||
test("another person's project, a readable one, or one moved to another "
|
||||
'row shows locked, never as theirs', () async {
|
||||
final id = await (await device(alice)).save(home);
|
||||
final sealed = rows.rows[id]!;
|
||||
rows.rows
|
||||
final sealed = rows.byId[id]!;
|
||||
rows.byId
|
||||
..['moved'] = sealed
|
||||
..['readable'] = (
|
||||
keyId: null,
|
||||
|
|
@ -184,7 +184,7 @@ void main() {
|
|||
final phone = await device(alice);
|
||||
final other = await phone.save(home);
|
||||
final inbox = phone.inboxId;
|
||||
rows.rows[inbox] = rows.rows[other]!;
|
||||
rows.byId[inbox] = rows.byId[other]!;
|
||||
|
||||
final all = await phone.all();
|
||||
expect(all.firstWhere((project) => project.inbox).content, isNull);
|
||||
|
|
@ -198,7 +198,7 @@ void main() {
|
|||
expect((await stranger.all()).single.content, isNull);
|
||||
await expectLater(stranger.save(home), throwsA(isA<KeysMissing>()));
|
||||
await expectLater(stranger.ensureInbox(), throwsA(isA<KeysMissing>()));
|
||||
expect(rows.rows, hasLength(1));
|
||||
expect(rows.byId, hasLength(1));
|
||||
});
|
||||
|
||||
test('a change here or from another device is told', () async {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/label/data/label_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/data/section_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/data/task_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
||||
/// Tasks', sections' and labels' payloads (ADR 0033, D6): UTF-8 JSON with
|
||||
/// their schema's version, read back as written, and never read half.
|
||||
void main() {
|
||||
ByteData json(Object value) =>
|
||||
ByteData.sublistView(Uint8List.fromList(utf8.encode(jsonEncode(value))));
|
||||
|
||||
Map<String, Object?> written(ByteData payload) =>
|
||||
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
|
||||
as Map<String, Object?>;
|
||||
|
||||
final full = TaskContent(
|
||||
title: 'Pilates',
|
||||
projectId: 'p',
|
||||
description: 'Bring the mat',
|
||||
sectionId: 's',
|
||||
parentId: 't',
|
||||
position: 3,
|
||||
date: const TaskDate(
|
||||
Day(2026, 11, 2),
|
||||
time: 6 * 60 + 30,
|
||||
duration: Duration(minutes: 30),
|
||||
),
|
||||
deadline: const Day(2026, 11, 8),
|
||||
priority: Priority.p2,
|
||||
labelIds: const ['l1', 'l2'],
|
||||
completedAt: DateTime(2026, 11, 2, 7),
|
||||
);
|
||||
|
||||
group('a task', () {
|
||||
test('reads back as it was written, in full or barely', () {
|
||||
expect(decodeTask(encodeTask(full)), full);
|
||||
const bare = TaskContent(title: 'Milk', projectId: 'p');
|
||||
expect(decodeTask(encodeTask(bare)), bare);
|
||||
});
|
||||
|
||||
test('is written as days, minutes and numbers, and leaves out what it '
|
||||
"hasn't", () {
|
||||
expect(written(encodeTask(full)), {
|
||||
'schemaVersion': 1,
|
||||
'title': 'Pilates',
|
||||
'projectId': 'p',
|
||||
'description': 'Bring the mat',
|
||||
'sectionId': 's',
|
||||
'parentId': 't',
|
||||
'position': 3.0,
|
||||
'date': {'day': '2026-11-02', 'time': 390, 'minutes': 30},
|
||||
'deadline': '2026-11-08',
|
||||
'priority': 2,
|
||||
'labelIds': ['l1', 'l2'],
|
||||
'completedAt': DateTime(2026, 11, 2, 7).toUtc().toIso8601String(),
|
||||
});
|
||||
expect(
|
||||
written(encodeTask(const TaskContent(title: 'Milk', projectId: 'p'))),
|
||||
{
|
||||
'schemaVersion': 1,
|
||||
'title': 'Milk',
|
||||
'projectId': 'p',
|
||||
'position': 0.0,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('of a newer schema, without a title or project, with a day that '
|
||||
"isn't one or a time past midnight, isn't read at all", () {
|
||||
final base = {'schemaVersion': 1, 'title': 'A', 'projectId': 'p'};
|
||||
expect(decodeTask(json(base)), isNotNull);
|
||||
for (final broken in [
|
||||
{...base, 'schemaVersion': 2},
|
||||
{'schemaVersion': 1, 'projectId': 'p'},
|
||||
{'schemaVersion': 1, 'title': 'A'},
|
||||
{
|
||||
...base,
|
||||
'date': {'day': '2026-02-31'},
|
||||
},
|
||||
{
|
||||
...base,
|
||||
'date': {'day': '2026-11-02', 'time': 1440},
|
||||
},
|
||||
{...base, 'deadline': 'soon'},
|
||||
{...base, 'date': 'today'},
|
||||
]) {
|
||||
expect(decodeTask(json(broken)), isNull, reason: '$broken');
|
||||
}
|
||||
expect(decodeTask(ByteData(0)), isNull);
|
||||
});
|
||||
|
||||
test('a priority it doesn’t know is none', () {
|
||||
expect(
|
||||
decodeTask(
|
||||
json({
|
||||
'schemaVersion': 1,
|
||||
'title': 'A',
|
||||
'projectId': 'p',
|
||||
'priority': 9,
|
||||
}),
|
||||
)?.priority,
|
||||
Priority.none,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('a day', () {
|
||||
test('reads only as YYYY-MM-DD, and only a day there is', () {
|
||||
expect(Day.tryParse('2028-02-29'), const Day(2028, 2, 29));
|
||||
expect(Day.tryParse('2026-02-29'), isNull);
|
||||
expect(Day.tryParse('2026-1-2'), isNull);
|
||||
expect(const Day(2026, 1, 2).toString(), '2026-01-02');
|
||||
});
|
||||
|
||||
test('counts and orders across months and a change of clocks', () {
|
||||
const first = Day(2026, 10, 24);
|
||||
expect(first.plus(8), const Day(2026, 11, 1));
|
||||
expect(first.daysUntil(const Day(2026, 11, 1)), 8);
|
||||
expect(first < const Day(2026, 11, 1), isTrue);
|
||||
expect(const Day(2026, 11, 1) > first, isTrue);
|
||||
expect(first.compareTo(first), 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('a section and a label read back as they were written', () {
|
||||
const section = SectionContent(
|
||||
name: 'Everyday life',
|
||||
projectId: 'p',
|
||||
description: 'About everyday life',
|
||||
position: 2,
|
||||
);
|
||||
expect(decodeSection(encodeSection(section)), section);
|
||||
const label = LabelContent(
|
||||
name: 'errand',
|
||||
color: PaletteColor.carrot,
|
||||
position: 1,
|
||||
);
|
||||
expect(decodeLabel(encodeLabel(label)), label);
|
||||
expect(decodeSection(json({'schemaVersion': 1, 'name': 'A'})), isNull);
|
||||
expect(decodeLabel(json({'schemaVersion': 2, 'name': 'A'})), isNull);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/data/section_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
|
||||
import '../../support/memory_content_key_gateway.dart';
|
||||
import '../../support/memory_sealed_row_gateway.dart';
|
||||
|
||||
/// The person's tasks, sections and labels, sealed on the way to the
|
||||
/// instance and opened on the way back (ADR 0033, D5, D6), with libsodium.
|
||||
void main() {
|
||||
final crypto = SodiumAccountCrypto();
|
||||
late UnlockedAccountKeys alice;
|
||||
late UnlockedAccountKeys bob;
|
||||
late MemoryContentKeyGateway keyRows;
|
||||
late MemorySealedRowGateway rows;
|
||||
final opened = <TodoKeys>[];
|
||||
|
||||
const milk = TaskContent(
|
||||
title: 'Buy milk',
|
||||
projectId: 'inbox',
|
||||
date: TaskDate(Day(2026, 11, 2), time: 9 * 60),
|
||||
priority: Priority.p1,
|
||||
);
|
||||
|
||||
Future<TodoKeys> keysOf(UnlockedAccountKeys? account) async {
|
||||
final keys = TodoKeys(keyRows, SodiumContentCipher(), () async => account);
|
||||
opened.add(keys);
|
||||
await keys.refresh();
|
||||
return keys;
|
||||
}
|
||||
|
||||
Future<TaskRepository> device(UnlockedAccountKeys? account) async =>
|
||||
TaskRepository(rows, await keysOf(account));
|
||||
|
||||
setUpAll(() async {
|
||||
alice = (await crypto.newAccount('alice password')).keys;
|
||||
bob = (await crypto.newAccount('bob password')).keys;
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
keyRows = MemoryContentKeyGateway();
|
||||
rows = MemorySealedRowGateway();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
for (final keys in opened) {
|
||||
keys.dispose();
|
||||
}
|
||||
opened.clear();
|
||||
});
|
||||
|
||||
test('a task made on one device reads on another of the account, edited, '
|
||||
'done and deleted there', () async {
|
||||
final phone = await device(alice);
|
||||
final id = await phone.add(milk);
|
||||
final laptop = await device(alice);
|
||||
expect(await laptop.all(), [StoredTask(id, milk)]);
|
||||
|
||||
final at = DateTime(2026, 11, 2, 9, 30);
|
||||
final done = (await laptop.all()).single;
|
||||
await withClock(Clock.fixed(at), () => laptop.complete(done));
|
||||
expect(
|
||||
(await phone.all()).single.content,
|
||||
milk.copyWith(completedAt: () => at),
|
||||
);
|
||||
await phone.complete((await phone.all()).single, done: false);
|
||||
expect((await laptop.all()).single.content?.completed, isFalse);
|
||||
|
||||
await laptop.delete(id);
|
||||
expect(await phone.all(), isEmpty);
|
||||
});
|
||||
|
||||
test(
|
||||
'deleting a task deletes its sub-tasks and theirs, and no other',
|
||||
() async {
|
||||
final phone = await device(alice);
|
||||
final parent = await phone.add(milk);
|
||||
final child = await phone.add(
|
||||
const TaskContent(title: 'Oat', projectId: 'inbox').copyWith(
|
||||
parentId: () => parent,
|
||||
),
|
||||
);
|
||||
await phone.add(
|
||||
const TaskContent(title: 'Barista', projectId: 'inbox').copyWith(
|
||||
parentId: () => child,
|
||||
),
|
||||
);
|
||||
final other = await phone.add(milk);
|
||||
|
||||
await phone.delete(parent);
|
||||
|
||||
expect([for (final task in await phone.all()) task.id], [other]);
|
||||
},
|
||||
);
|
||||
|
||||
test("deleting a project's or a section's tasks deletes only theirs, "
|
||||
'with their sub-tasks', () async {
|
||||
final phone = await device(alice);
|
||||
final inSection = await phone.add(
|
||||
const TaskContent(title: 'A', projectId: 'home', sectionId: 's'),
|
||||
);
|
||||
await phone.add(
|
||||
TaskContent(title: 'A1', projectId: 'home', parentId: inSection),
|
||||
);
|
||||
final aboveSections = await phone.add(
|
||||
const TaskContent(title: 'B', projectId: 'home'),
|
||||
);
|
||||
final elsewhere = await phone.add(milk);
|
||||
|
||||
await phone.deleteIn('home', sectionId: 's');
|
||||
expect(
|
||||
{for (final task in await phone.all()) task.id},
|
||||
{
|
||||
aboveSections,
|
||||
elsewhere,
|
||||
},
|
||||
);
|
||||
|
||||
await phone.deleteIn('home');
|
||||
expect([for (final task in await phone.all()) task.id], [elsewhere]);
|
||||
});
|
||||
|
||||
test('nothing the instance gets is readable: no title, no project, no '
|
||||
'date, not whether it is done', () async {
|
||||
final phone = await device(alice);
|
||||
final id = await phone.add(milk);
|
||||
await phone.complete(StoredTask(id, milk));
|
||||
|
||||
final sealed = rows.byId.values.single;
|
||||
expect(sealed.keyId, isNotNull);
|
||||
final text = utf8.decode(
|
||||
Uint8List.sublistView(sealed.payload),
|
||||
allowMalformed: true,
|
||||
);
|
||||
for (final word in ['milk', 'inbox', '2026', 'completedAt', 'title']) {
|
||||
expect(text, isNot(contains(word)), reason: word);
|
||||
}
|
||||
});
|
||||
|
||||
test("another person's task, a readable one, or one moved to another row "
|
||||
'shows locked, never as theirs', () async {
|
||||
final id = await (await device(alice)).add(milk);
|
||||
final sealed = rows.byId[id]!;
|
||||
rows.byId
|
||||
..['moved'] = sealed
|
||||
..['readable'] = (
|
||||
keyId: null,
|
||||
nonce: null,
|
||||
payload: ByteData.sublistView(
|
||||
Uint8List.fromList(
|
||||
utf8.encode('{"schemaVersion":1,"title":"X","projectId":"p"}'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
(await (await device(bob)).all()).where((task) => task.content != null),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
{
|
||||
for (final task in await (await device(alice)).all())
|
||||
task.id: task.content,
|
||||
},
|
||||
{id: milk, 'moved': null, 'readable': null},
|
||||
);
|
||||
});
|
||||
|
||||
test('a device without the keys reads nothing and writes nothing', () async {
|
||||
await (await device(alice)).add(milk);
|
||||
final stranger = await device(null);
|
||||
|
||||
expect((await stranger.all()).single.content, isNull);
|
||||
await expectLater(stranger.add(milk), throwsA(isA<KeysMissing>()));
|
||||
await expectLater(
|
||||
stranger.update(rows.byId.keys.single, milk),
|
||||
throwsA(isA<KeysMissing>()),
|
||||
);
|
||||
expect(rows.byId, hasLength(1));
|
||||
});
|
||||
|
||||
test('sections and labels are sealed and opened alike, and locked for '
|
||||
'anyone else', () async {
|
||||
final sections = SectionRepository(rows, await keysOf(alice));
|
||||
const section = SectionContent(name: 'Everyday life', projectId: 'home');
|
||||
final id = await sections.add(section);
|
||||
expect((await sections.all()).single.content, section);
|
||||
expect(
|
||||
(await SectionRepository(rows, await keysOf(bob)).all()).single.content,
|
||||
isNull,
|
||||
);
|
||||
await sections.delete([id]);
|
||||
expect(await sections.all(), isEmpty);
|
||||
|
||||
final labels = LabelRepository(rows, await keysOf(alice));
|
||||
const label = LabelContent(name: 'errand');
|
||||
await labels.add(label);
|
||||
expect((await labels.all()).single.content, label);
|
||||
expect(
|
||||
(await LabelRepository(rows, await keysOf(bob)).all()).single.content,
|
||||
isNull,
|
||||
);
|
||||
await expectLater(
|
||||
LabelRepository(rows, await keysOf(null)).add(label),
|
||||
throwsA(isA<KeysMissing>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_gateway.dart';
|
||||
|
||||
/// [ProjectGateway] in memory, whose rows a test changes as an instance could.
|
||||
class MemoryProjectGateway() implements ProjectGateway {
|
||||
final rows = <String, Envelope>{};
|
||||
final _remote = StreamController<void>.broadcast();
|
||||
|
||||
/// As a sync bringing others' changes.
|
||||
void bringRemoteChanges() => _remote.add(null);
|
||||
|
||||
@override
|
||||
Future<List<ProjectRow>> projects() async => [
|
||||
for (final MapEntry(:key, :value) in rows.entries)
|
||||
(id: key, envelope: value),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> insert(ProjectRow project) async =>
|
||||
rows[project.id] = project.envelope;
|
||||
|
||||
@override
|
||||
Future<void> update(String id, Envelope envelope) async {
|
||||
if (rows.containsKey(id)) {
|
||||
rows[id] = envelope;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id) async => rows.remove(id);
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _remote.stream;
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
|
||||
|
||||
/// [SealedRowGateway] in memory, whose rows a test changes as an instance
|
||||
/// could, and shares between "devices" as the sync engine would.
|
||||
class MemorySealedRowGateway() implements SealedRowGateway {
|
||||
/// The rows, as the instance holds them.
|
||||
final byId = <String, Envelope>{};
|
||||
final _remote = StreamController<void>.broadcast();
|
||||
|
||||
/// As a sync bringing others' changes.
|
||||
void bringRemoteChanges() => _remote.add(null);
|
||||
|
||||
@override
|
||||
Future<List<SealedRow>> rows() async => [
|
||||
for (final MapEntry(:key, :value) in byId.entries)
|
||||
(id: key, envelope: value),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> insert(SealedRow row) async => byId[row.id] = row.envelope;
|
||||
|
||||
@override
|
||||
Future<void> update(String id, Envelope envelope) async {
|
||||
if (byId.containsKey(id)) {
|
||||
byId[id] = envelope;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(Iterable<String> ids) async => ids.forEach(byId.remove);
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _remote.stream;
|
||||
}
|
||||
Loading…
Reference in a new issue