feat(train): interval plans, synced and sealed, with an editor and templates (ADR 0030, D42)
Train's server gains the synced `plan` table, a sealed payload alone; the migration `plans` only adds it, its space index and the foreign key to its sync space, and a person's deletion takes it with their space. The app keeps plans as `plan` rows sealed for their row: a name, what they are for, whether a step off target is told, and blocks of steps repeated, each ending after a time, a distance or a lap tap and aiming at a pace, speed, heart rate or zone, or the rower's split, watts or stroke rate. Home's Plans tab lists them, a new one starts from nothing or from a template (4×4 minutes, Tabata, 10×400 m), the editor changes steps on a sheet, and a deletion asks first and can be undone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
fa7680821d
commit
f406ddcc59
4 changed files with 23608 additions and 10 deletions
|
|
@ -0,0 +1,92 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/bloc/plan_editor_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/stored_plan.dart';
|
||||
|
||||
import '../../support/memory_content_key_gateway.dart';
|
||||
import '../../support/memory_plan_gateway.dart';
|
||||
|
||||
/// A plan being made or changed (ADR 0030, D42).
|
||||
void main() {
|
||||
late TrainKeys keys;
|
||||
late PlanRepository plans;
|
||||
|
||||
PlanRepository repository(UnlockedAccountKeys? account) {
|
||||
keys = TrainKeys(
|
||||
MemoryContentKeyGateway(),
|
||||
FakeContentCipher(),
|
||||
() async => account,
|
||||
);
|
||||
return plans = PlanRepository(MemoryPlanGateway(), keys);
|
||||
}
|
||||
|
||||
tearDown(() async {
|
||||
await plans.dispose();
|
||||
keys.dispose();
|
||||
});
|
||||
|
||||
test('steps and repeats are added, changed, moved and removed', () {
|
||||
final cubit =
|
||||
PlanEditorCubit(
|
||||
repository(FakeAccountCrypto().keysNumbered(1)),
|
||||
plan: const PlanContent(name: 'x', blocks: []),
|
||||
)
|
||||
..addStep()
|
||||
..addRepeat()
|
||||
..setRepeat(1, 6)
|
||||
..addStep(block: 1)
|
||||
..setStep(
|
||||
1,
|
||||
2,
|
||||
const PlanStep(kind: StepKind.recovery, end: EndWithLap()),
|
||||
)
|
||||
..moveBlock(1, up: true)
|
||||
..removeStep(0, 0);
|
||||
|
||||
final plan = cubit.state.plan;
|
||||
expect([for (final block in plan.blocks) block.repeat], [6, 1]);
|
||||
expect(plan.blocks.first.steps.map((s) => s.end), [
|
||||
const EndAfterTime(Duration(minutes: 1)),
|
||||
const EndWithLap(),
|
||||
]);
|
||||
expect(plan.run, hasLength(13));
|
||||
|
||||
cubit
|
||||
..setRepeat(0, 0)
|
||||
..removeStep(1, 0);
|
||||
expect(cubit.state.plan.blocks.single.repeat, 1);
|
||||
});
|
||||
|
||||
test('a plan saves with a name and a step, then edits itself', () async {
|
||||
final cubit = PlanEditorCubit(
|
||||
repository(FakeAccountCrypto().keysNumbered(1)),
|
||||
plan: const PlanContent(name: ' ', blocks: []),
|
||||
)..addStep();
|
||||
expect(cubit.state.canSave, isFalse);
|
||||
expect(await cubit.save(), isFalse);
|
||||
|
||||
cubit.rename(' Fartlek ');
|
||||
expect(await cubit.save(), isTrue);
|
||||
final id = cubit.state.id!;
|
||||
cubit.rename('Fartlek, long');
|
||||
expect(await cubit.save(), isTrue);
|
||||
|
||||
final [StoredPlan(id: kept, :content)] = await plans.all();
|
||||
expect(kept, id);
|
||||
expect(content?.name, 'Fartlek, long');
|
||||
});
|
||||
|
||||
test('without the keys, nothing is saved', () async {
|
||||
final cubit = PlanEditorCubit(
|
||||
repository(null),
|
||||
plan: const PlanContent(name: 'x', blocks: []),
|
||||
)..addStep();
|
||||
|
||||
expect(await cubit.save(), isFalse);
|
||||
expect(await plans.all(), isEmpty);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/stored_plan.dart';
|
||||
|
||||
import '../../support/memory_content_key_gateway.dart';
|
||||
import '../../support/memory_plan_gateway.dart';
|
||||
|
||||
/// The person's interval plans, sealed on the way to the instance and opened
|
||||
/// on the way back (ADR 0030, D7, D42), with libsodium.
|
||||
void main() {
|
||||
final crypto = SodiumAccountCrypto();
|
||||
late UnlockedAccountKeys alice;
|
||||
late UnlockedAccountKeys bob;
|
||||
late MemoryContentKeyGateway keyRows;
|
||||
late MemoryPlanGateway rows;
|
||||
final opened = <TrainKeys>[];
|
||||
|
||||
const tabata = PlanContent(
|
||||
name: 'Tabata',
|
||||
blocks: [
|
||||
PlanBlock(
|
||||
repeat: 8,
|
||||
steps: [
|
||||
PlanStep(
|
||||
kind: StepKind.work,
|
||||
end: EndAfterTime(Duration(seconds: 20)),
|
||||
),
|
||||
PlanStep(
|
||||
kind: StepKind.recovery,
|
||||
end: EndAfterTime(Duration(seconds: 10)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Future<PlanRepository> device(UnlockedAccountKeys? account) async {
|
||||
final keys = TrainKeys(keyRows, SodiumContentCipher(), () async => account);
|
||||
opened.add(keys);
|
||||
await keys.refresh();
|
||||
return PlanRepository(rows, keys);
|
||||
}
|
||||
|
||||
setUpAll(() async {
|
||||
alice = (await crypto.newAccount('alice password')).keys;
|
||||
bob = (await crypto.newAccount('bob password')).keys;
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
keyRows = MemoryContentKeyGateway();
|
||||
rows = MemoryPlanGateway();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
for (final keys in opened) {
|
||||
keys.dispose();
|
||||
}
|
||||
opened.clear();
|
||||
});
|
||||
|
||||
test('a plan made on one device reads on another of the account, '
|
||||
'edited and deleted there', () async {
|
||||
final laptop = await device(alice);
|
||||
final id = await laptop.save(tabata);
|
||||
final phone = await device(alice);
|
||||
expect(await phone.all(), [StoredPlan(id, tabata)]);
|
||||
|
||||
const renamed = PlanContent(name: 'Tabata, short', blocks: []);
|
||||
await phone.update(id, renamed);
|
||||
expect(await laptop.all(), [StoredPlan(id, renamed)]);
|
||||
|
||||
await laptop.delete(id);
|
||||
expect(await phone.all(), isEmpty);
|
||||
});
|
||||
|
||||
test('nothing the instance gets is readable: no name, no JSON', () async {
|
||||
await (await device(alice)).save(tabata);
|
||||
|
||||
final sealed = rows.rows.values.single;
|
||||
expect(sealed.keyId, isNotNull);
|
||||
expect(
|
||||
utf8.decode(Uint8List.sublistView(sealed.payload), allowMalformed: true),
|
||||
isNot(contains('Tabata')),
|
||||
);
|
||||
});
|
||||
|
||||
test("another person's plan, a readable one, or one moved to another row "
|
||||
'shows locked, never as theirs', () async {
|
||||
final id = await (await device(alice)).save(tabata);
|
||||
final sealed = rows.rows[id]!;
|
||||
rows.rows
|
||||
..['moved'] = sealed
|
||||
..['readable'] = (
|
||||
keyId: null,
|
||||
nonce: null,
|
||||
payload: ByteData.sublistView(
|
||||
Uint8List.fromList(utf8.encode('{"schemaVersion":1}')),
|
||||
),
|
||||
);
|
||||
|
||||
final others = await device(bob);
|
||||
expect(
|
||||
(await others.all()).where((plan) => plan.content != null),
|
||||
isEmpty,
|
||||
);
|
||||
final mine = await device(alice);
|
||||
expect(
|
||||
{for (final plan in await mine.all()) plan.id: plan.content},
|
||||
{id: tabata, 'moved': null, 'readable': null},
|
||||
);
|
||||
});
|
||||
|
||||
test('a device without the keys reads nothing and saves nothing', () async {
|
||||
await (await device(alice)).save(tabata);
|
||||
final stranger = await device(null);
|
||||
|
||||
expect((await stranger.all()).single.content, isNull);
|
||||
await expectLater(stranger.save(tabata), throwsA(isA<KeysMissing>()));
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_payload.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
/// Interval plans (ADR 0030, D42): steps in repeat blocks, each ending after
|
||||
/// a time, a distance or a lap tap, and aiming at a target.
|
||||
void main() {
|
||||
final fourByFour = PlanContent(
|
||||
name: '4×4',
|
||||
activity: Activity.running,
|
||||
blocks: [
|
||||
const PlanBlock(
|
||||
steps: [
|
||||
PlanStep(
|
||||
kind: StepKind.warmUp,
|
||||
end: EndAfterTime(Duration(minutes: 10)),
|
||||
),
|
||||
],
|
||||
),
|
||||
PlanBlock(
|
||||
repeat: 4,
|
||||
steps: [
|
||||
PlanStep(
|
||||
kind: StepKind.work,
|
||||
end: const EndAfterTime(Duration(minutes: 4)),
|
||||
target: Target.zone(4),
|
||||
),
|
||||
const PlanStep(
|
||||
kind: StepKind.recovery,
|
||||
end: EndAfterDistance(400),
|
||||
target: Target(TargetKind.pace, 330, 390),
|
||||
),
|
||||
],
|
||||
),
|
||||
const PlanBlock(
|
||||
steps: [PlanStep(kind: StepKind.coolDown, end: EndWithLap())],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
ByteData bytes(Object json) =>
|
||||
ByteData.sublistView(Uint8List.fromList(utf8.encode(jsonEncode(json))));
|
||||
|
||||
test("a plan's rounds are spelled out in the order they run", () {
|
||||
final run = fourByFour.run;
|
||||
|
||||
expect(run, hasLength(10));
|
||||
expect(run.first.step.kind, StepKind.warmUp);
|
||||
expect(
|
||||
[for (final s in run.skip(1).take(8)) s.round],
|
||||
[
|
||||
1, 1, 2, 2, 3, 3, 4, 4, //
|
||||
],
|
||||
);
|
||||
expect(run.last.step.end, const EndWithLap());
|
||||
expect(fourByFour.timed, const Duration(minutes: 26));
|
||||
expect(fourByFour.metres, 1600);
|
||||
});
|
||||
|
||||
test('the zones are 50–60 % up to 90–100 % of the highest rate', () {
|
||||
expect(heartZone(1, 190), (low: 95, high: 114));
|
||||
expect(heartZone(4, 190), (low: 152, high: 171));
|
||||
expect(heartZone(5, 190), (low: 171, high: 190));
|
||||
});
|
||||
|
||||
test('a plan sealed reads back as it was', () {
|
||||
expect(decodePlan(encodePlan(fourByFour)), fourByFour);
|
||||
});
|
||||
|
||||
test('a plan of a newer schema, or none, is not read', () {
|
||||
expect(
|
||||
decodePlan(
|
||||
bytes({'schemaVersion': 2, 'name': 'x', 'blocks': <Object>[]}),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
expect(decodePlan(bytes({'name': 'x'})), isNull);
|
||||
});
|
||||
|
||||
test('a step it cannot read is left out, a target it does not know is '
|
||||
'none', () {
|
||||
final plan = decodePlan(
|
||||
bytes({
|
||||
'schemaVersion': 1,
|
||||
'name': 'odd',
|
||||
'blocks': [
|
||||
{
|
||||
'repeat': 2,
|
||||
'steps': [
|
||||
{
|
||||
'kind': 'work',
|
||||
'end': {'by': 'time', 'seconds': 30},
|
||||
'target': {'kind': 'lactate', 'low': 1, 'high': 2},
|
||||
},
|
||||
{
|
||||
'kind': 'sprint',
|
||||
'end': {'by': 'lap'},
|
||||
},
|
||||
{
|
||||
'kind': 'recovery',
|
||||
'end': {'by': 'time', 'seconds': 0},
|
||||
},
|
||||
],
|
||||
},
|
||||
{'repeat': 0, 'steps': <Object>[]},
|
||||
],
|
||||
}),
|
||||
)!;
|
||||
|
||||
expect(plan.blocks, [
|
||||
const PlanBlock(
|
||||
repeat: 2,
|
||||
steps: [
|
||||
PlanStep(
|
||||
kind: StepKind.work,
|
||||
end: EndAfterTime(Duration(seconds: 30)),
|
||||
),
|
||||
],
|
||||
),
|
||||
]);
|
||||
expect(plan.offTargetCues, isTrue);
|
||||
expect(plan.activity, isNull);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/sync_content_key_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/data/sync_plan_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/stored_plan.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
// The plans on the device's own database, as the sync engine keeps them,
|
||||
// with libsodium as its build hook compiles it.
|
||||
|
||||
final _person = protocol.UuidValue.withValidation(
|
||||
'01a16000-0000-7000-8000-000000000001',
|
||||
);
|
||||
|
||||
void main() {
|
||||
final crypto = SodiumAccountCrypto();
|
||||
late UnlockedAccountKeys alice;
|
||||
late Directory folder;
|
||||
late OfflineSyncDatabaseSession db;
|
||||
late SyncService sync;
|
||||
late TrainKeys keys;
|
||||
late PlanRepository plans;
|
||||
|
||||
const tenBy400 = PlanContent(
|
||||
name: '10×400 m',
|
||||
blocks: [
|
||||
PlanBlock(
|
||||
repeat: 10,
|
||||
steps: [
|
||||
PlanStep(kind: StepKind.work, end: EndAfterDistance(400)),
|
||||
PlanStep(
|
||||
kind: StepKind.recovery,
|
||||
end: EndAfterTime(Duration(seconds: 90)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
setUpAll(() async {
|
||||
alice = (await crypto.newAccount('alice password')).keys;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
folder = await Directory.systemTemp.createTemp('plans');
|
||||
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),
|
||||
);
|
||||
keys = TrainKeys(
|
||||
SyncContentKeyGateway(db, sync),
|
||||
SodiumContentCipher(),
|
||||
() async => alice,
|
||||
);
|
||||
await keys.refresh();
|
||||
plans = PlanRepository(SyncPlanGateway(db, sync), keys);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await plans.dispose();
|
||||
keys.dispose();
|
||||
await sync.dispose();
|
||||
await db.close();
|
||||
await folder.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('a plan is kept, edited and read back opened', () async {
|
||||
final id = await plans.save(tenBy400);
|
||||
const renamed = PlanContent(name: '5×400 m', blocks: []);
|
||||
|
||||
await plans.update(id, renamed);
|
||||
|
||||
expect(await plans.all(), [StoredPlan(id, renamed)]);
|
||||
});
|
||||
|
||||
test('deleting a plan blanks its payload first', () async {
|
||||
final id = await plans.save(tenBy400);
|
||||
final kept = await plans.save(tenBy400);
|
||||
|
||||
await plans.delete(id);
|
||||
|
||||
expect(await plans.all(), [StoredPlan(kept, tenBy400)]);
|
||||
// The sync engine keeps the deleted row as its tombstone: blank.
|
||||
final rows = await db.db.unsafeQuery(
|
||||
'SELECT length("payload"), "keyId", "nonce" FROM "plan"',
|
||||
);
|
||||
final blank = [
|
||||
for (final row in rows)
|
||||
if (row[0] == 0) row,
|
||||
];
|
||||
expect(rows.length - blank.length, 1);
|
||||
expect(blank, hasLength(1));
|
||||
expect(blank.single.skip(1), [null, null]);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue