feat(train): strength plans and sessions (ADR 0035)
Strength plans of exercises from a catalogue or the person's own, in blocks of one exercise or several done in turn (supersets, circuits), each with its sets, reps or seconds, weights and rests; each a sealed `strength_plan` row. Plans are marked with what the place chosen lacks, and a session offers swaps moving the same way that the place allows. A session runs on every device, set by set with the rest counted down, each weight made up from the place's plates, bars and fixed weights with the plate changes between sets, kept in strength_session.json after each change so one cut off is carried on, and saved as a workout of the new activity Strength with its sets, listed on its page; strength training in Health Connect and FIT, no GPX. Migration 20261102113104496-strength-plans is purely additive: it creates the table strength_plan (id uuid v7, spaceId, keyId, the sealed payload and its nonce), its index on spaceId, and a foreign key to offline_sync_spaces with ON DELETE CASCADE, so deleting a person's sync spaces deletes their plans; then the modules' version rows. The gen_random_uuid_v7 function is created or replaced as in every migration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
064b911aa2
commit
508994be3a
9 changed files with 27612 additions and 25 deletions
|
|
@ -48,6 +48,10 @@ import 'package:halkyon_train_flutter/src/feature/route/data/route_files.dart';
|
|||
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/data/routing_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_plan_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_session_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/exercise_catalogue.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/widget/progress_cards.dart';
|
||||
|
|
@ -73,6 +77,7 @@ import '../support/memory_place_gateway.dart';
|
|||
import '../support/memory_plan_gateway.dart';
|
||||
import '../support/memory_profile_gateway.dart';
|
||||
import '../support/memory_route_gateway.dart';
|
||||
import '../support/memory_strength_plan_gateway.dart';
|
||||
import '../support/memory_workout_gateway.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -109,6 +114,8 @@ void main() {
|
|||
PlanRepository? plans,
|
||||
RouteRepository? routes,
|
||||
PlaceRepository? places,
|
||||
StrengthPlanRepository? strengthPlans,
|
||||
StrengthSessionStore? strengthSession,
|
||||
RoutingGateway? routing,
|
||||
PlaceSearchGateway? placeSearch,
|
||||
OfflineMaps? offlineMaps,
|
||||
|
|
@ -239,6 +246,17 @@ void main() {
|
|||
),
|
||||
),
|
||||
chosenPlace: ChosenPlaceStore(),
|
||||
strengthPlans:
|
||||
strengthPlans ??
|
||||
StrengthPlanRepository(
|
||||
MemoryStrengthPlanGateway(),
|
||||
TrainKeys(
|
||||
MemoryContentKeyGateway(),
|
||||
FakeContentCipher(),
|
||||
() async => FakeAccountCrypto().keysNumbered(1),
|
||||
),
|
||||
),
|
||||
strengthSession: strengthSession ?? StrengthSessionStore(),
|
||||
routing: routing ?? FakeRoutingGateway(),
|
||||
placeSearch:
|
||||
placeSearch ?? FakePlaceSearchGateway(isOffered: false),
|
||||
|
|
@ -723,6 +741,157 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('strength plans (ADR 0035)', () {
|
||||
late TrainKeys keys;
|
||||
late StrengthPlanRepository plans;
|
||||
late PlaceRepository places;
|
||||
late WorkoutRepository workouts;
|
||||
|
||||
setUp(() async {
|
||||
keys = TrainKeys(
|
||||
MemoryContentKeyGateway(),
|
||||
FakeContentCipher(),
|
||||
() async => FakeAccountCrypto().keysNumbered(1),
|
||||
);
|
||||
await keys.refresh();
|
||||
plans = StrengthPlanRepository(MemoryStrengthPlanGateway(), keys);
|
||||
places = PlaceRepository(MemoryPlaceGateway(), keys);
|
||||
workouts = WorkoutRepository(MemoryWorkoutGateway(), keys);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await plans.dispose();
|
||||
keys.dispose();
|
||||
});
|
||||
|
||||
Future<void> openPlans(WidgetTester tester) async {
|
||||
await start(
|
||||
tester,
|
||||
usedBefore: true,
|
||||
strengthPlans: plans,
|
||||
places: places,
|
||||
workouts: workouts,
|
||||
);
|
||||
// Without a Record tab, the plans are beside the days in Profile.
|
||||
await goTab(tester, 'Profile');
|
||||
final tab = find.widgetWithText(Tab, 'Plans');
|
||||
await tester.ensureVisible(tab);
|
||||
await tester.tap(tab);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('a plan made of a catalogued exercise is saved with its '
|
||||
'usual sets and listed', (tester) async {
|
||||
await openPlans(tester);
|
||||
await tester.tap(find.text('New strength plan'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final save = find.widgetWithText(TextButton, 'Save');
|
||||
expect(tester.widget<TextButton>(save).onPressed, isNull);
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Day B');
|
||||
await tester.tap(find.text('Add exercise'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField).last, 'push');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Push-up'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<TextButton>(save).onPressed, isNotNull);
|
||||
|
||||
await tester.tap(save);
|
||||
await tester.runAsync(pumpEventQueue);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Day B'), findsOneWidget);
|
||||
expect(find.text('1 exercise · 3 sets'), findsOneWidget);
|
||||
final [kept] = (await tester.runAsync(plans.all))!;
|
||||
expect(kept.content!.exercises.single.id, 'pushUp');
|
||||
});
|
||||
|
||||
testWidgets('a plan says what the place trained at lacks for it', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.runAsync(() async {
|
||||
await places.save(PlaceContent.home('Home'));
|
||||
await plans.save(
|
||||
StrengthPlanContent(
|
||||
name: 'Day A',
|
||||
blocks: [
|
||||
StrengthBlock(
|
||||
exercises: [
|
||||
PlannedExercise(
|
||||
exercise: catalogued('backSquat')!,
|
||||
sets: const [StrengthSet(reps: 5, grams: 60000)],
|
||||
),
|
||||
PlannedExercise(
|
||||
exercise: catalogued('pullUp')!,
|
||||
sets: const [StrengthSet(reps: 8)],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
await openPlans(tester);
|
||||
|
||||
// Home has a pull-up bar, but no rack.
|
||||
expect(find.textContaining('Missing here: a rack'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a session runs set by set with its rest, and is saved as '
|
||||
'a workout listing its sets', (tester) async {
|
||||
await tester.runAsync(
|
||||
() => plans.save(
|
||||
StrengthPlanContent(
|
||||
name: 'Push',
|
||||
blocks: [
|
||||
StrengthBlock(
|
||||
exercises: [
|
||||
PlannedExercise(
|
||||
exercise: catalogued('pushUp')!,
|
||||
sets: const [
|
||||
StrengthSet(reps: 10),
|
||||
StrengthSet(reps: 8),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await openPlans(tester);
|
||||
await tester.tap(find.byTooltip('Start'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Set 1 of 2'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Done'));
|
||||
await tester.runAsync(pumpEventQueue);
|
||||
await tester.pump();
|
||||
expect(find.text('Skip rest'), findsOneWidget);
|
||||
await tester.tap(find.text('Skip rest'));
|
||||
await tester.runAsync(pumpEventQueue);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Set 2 of 2'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Done'));
|
||||
await tester.runAsync(pumpEventQueue);
|
||||
await tester.pump();
|
||||
expect(find.text('Every set is through.'), findsOneWidget);
|
||||
await tester.tap(find.text('Finish and save'));
|
||||
await tester.runAsync(pumpEventQueue);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('2 sets'), findsOneWidget);
|
||||
expect(find.text('Push-up'), findsOneWidget);
|
||||
expect(find.text('10 reps'), findsOneWidget);
|
||||
expect(find.text('8 reps'), findsOneWidget);
|
||||
final [saved] = (await tester.runAsync(workouts.all))!;
|
||||
expect(saved.content!.activity, Activity.strength);
|
||||
expect(saved.content!.strength!.sets, hasLength(2));
|
||||
});
|
||||
});
|
||||
|
||||
group('your body (ADR 0030, D33)', () {
|
||||
late TrainKeys keys;
|
||||
late ProfileRepository profile;
|
||||
|
|
@ -1712,6 +1881,11 @@ void main() {
|
|||
|
||||
// Back on the ride's sheet, its switch as it was left.
|
||||
await closeSheet(tester);
|
||||
await Scrollable.ensureVisible(
|
||||
tester.element(startFor('Cycling')),
|
||||
alignment: 0.5,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(startFor('Cycling'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
import 'package:flutter_test/flutter_test.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/equipment/model/equipment.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/bloc/strength_editor_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/bloc/strength_session_cubit.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_plan_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_session_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/exercise_catalogue.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_session.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
import '../../support/memory_content_key_gateway.dart';
|
||||
import '../../support/memory_strength_plan_gateway.dart';
|
||||
import '../../support/memory_workout_gateway.dart';
|
||||
import 'strength_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
late TrainKeys keys;
|
||||
|
||||
setUp(() async {
|
||||
keys = TrainKeys(
|
||||
MemoryContentKeyGateway(),
|
||||
FakeContentCipher(),
|
||||
() async => FakeAccountCrypto().keysNumbered(1),
|
||||
);
|
||||
await keys.refresh();
|
||||
});
|
||||
|
||||
tearDown(() => keys.dispose());
|
||||
|
||||
group('the editor (ADR 0035, D2)', () {
|
||||
late StrengthPlanRepository plans;
|
||||
|
||||
setUp(
|
||||
() => plans = StrengthPlanRepository(MemoryStrengthPlanGateway(), keys),
|
||||
);
|
||||
|
||||
tearDown(() => plans.dispose());
|
||||
|
||||
test('an exercise is a block of its own, a second added to it makes a '
|
||||
'superset, a third a circuit; sets are added like the last', () {
|
||||
final cubit =
|
||||
StrengthEditorCubit(plans, plan: const StrengthPlanContent(name: ''))
|
||||
..addExercise(squat)
|
||||
..addExercise(pullUp)
|
||||
..addToBlock(1, dip)
|
||||
..setSet(0, 0, 0, const StrengthSet(reps: 5, grams: 60000))
|
||||
..addSet(0, 0)
|
||||
..removeSet(0, 0, 1);
|
||||
final blocks = cubit.state.plan.blocks;
|
||||
expect(blocks.map((block) => block.kind), [
|
||||
BlockKind.single,
|
||||
BlockKind.superset,
|
||||
]);
|
||||
expect(blocks[0].exercises.single.sets, const [
|
||||
StrengthSet(reps: 5, grams: 60000),
|
||||
StrengthSet(reps: 10),
|
||||
StrengthSet(reps: 10),
|
||||
]);
|
||||
cubit.addToBlock(1, pushUp);
|
||||
expect(cubit.state.plan.blocks[1].kind, BlockKind.circuit);
|
||||
cubit
|
||||
..removeExercise(1, 0)
|
||||
..removeExercise(1, 0)
|
||||
..removeExercise(1, 0);
|
||||
expect(cubit.state.plan.blocks, hasLength(1));
|
||||
cubit.moveBlock(0, up: true);
|
||||
expect(cubit.state.changed, isTrue);
|
||||
});
|
||||
|
||||
test('a plan is kept only once named and holding a set; kept again, '
|
||||
'it changes', () async {
|
||||
final cubit = StrengthEditorCubit(
|
||||
plans,
|
||||
plan: const StrengthPlanContent(name: 'Day A'),
|
||||
);
|
||||
expect(cubit.state.savable, isFalse);
|
||||
cubit.addExercise(catalogued('plank')!);
|
||||
expect(
|
||||
cubit.state.plan.blocks.single.exercises.single.sets.first,
|
||||
const StrengthSet(seconds: 30),
|
||||
);
|
||||
await cubit.save();
|
||||
final id = cubit.state.id!;
|
||||
expect(cubit.state.saved, isTrue);
|
||||
|
||||
cubit.rename('Day B');
|
||||
await cubit.save();
|
||||
expect(await plans.all(), [StoredStrengthPlan(id, cubit.state.plan)]);
|
||||
});
|
||||
});
|
||||
|
||||
group('a session (ADR 0035, D3)', () {
|
||||
late WorkoutRepository workouts;
|
||||
late StrengthSessionStore store;
|
||||
final start = DateTime.utc(2026, 11, 2, 18);
|
||||
|
||||
setUp(() {
|
||||
workouts = WorkoutRepository(MemoryWorkoutGateway(), keys);
|
||||
store = StrengthSessionStore();
|
||||
});
|
||||
|
||||
tearDown(() => workouts.dispose());
|
||||
|
||||
test('is kept after every set, rests a second at a time and counts '
|
||||
'the rest out, and is saved as a strength workout', () async {
|
||||
final quick = plan.copyWith(
|
||||
blocks: [plan.blocks.first.copyWith(rest: const Duration(seconds: 1))],
|
||||
);
|
||||
final cubit = StrengthSessionCubit(
|
||||
store,
|
||||
workouts,
|
||||
session: StrengthSession(plan: quick, startedAt: start),
|
||||
place: PlaceContent.home('Home'),
|
||||
);
|
||||
await cubit.open();
|
||||
expect(store.read(), cubit.state.session);
|
||||
|
||||
await cubit.done(reps: 5, grams: 60000);
|
||||
expect(cubit.state.restLeft, isNotNull);
|
||||
expect(store.read()?.done, hasLength(1));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 2100));
|
||||
expect(cubit.state.restLeft, isNull);
|
||||
expect(cubit.state.restsEnded, 1);
|
||||
|
||||
final id = await cubit.finish();
|
||||
expect(cubit.state.phase, SessionPhase.saved);
|
||||
expect(store.read(), isNull);
|
||||
final workout = (await workouts.all()).single;
|
||||
expect(workout.id, id);
|
||||
expect(workout.content?.activity, Activity.strength);
|
||||
expect(workout.content?.strength?.sets.single.grams, 60000);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('one with nothing done is let go rather than saved', () async {
|
||||
final cubit = StrengthSessionCubit(
|
||||
store,
|
||||
workouts,
|
||||
session: StrengthSession(plan: plan, startedAt: start),
|
||||
);
|
||||
await cubit.open();
|
||||
expect(await cubit.finish(), isNull);
|
||||
expect(cubit.state.phase, SessionPhase.abandoned);
|
||||
expect(await workouts.all(), isEmpty);
|
||||
expect(store.read(), isNull);
|
||||
await cubit.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import 'package:halkyon_train_flutter/src/feature/strength/model/exercise_catalogue.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_plan.dart';
|
||||
|
||||
// What the strength tests share.
|
||||
|
||||
final squat = catalogued('backSquat')!;
|
||||
final pullUp = catalogued('pullUp')!;
|
||||
final dip = catalogued('dip')!;
|
||||
final pushUp = catalogued('pushUp')!;
|
||||
|
||||
StrengthSet reps(int reps, [int? grams]) =>
|
||||
StrengthSet(reps: reps, grams: grams);
|
||||
|
||||
/// Squats, three sets, then pull-ups and dips as a superset of two rounds.
|
||||
final plan = StrengthPlanContent(
|
||||
name: 'Day A',
|
||||
blocks: [
|
||||
StrengthBlock(
|
||||
exercises: [
|
||||
PlannedExercise(
|
||||
exercise: squat,
|
||||
sets: [reps(5, 60000), reps(5, 80000), reps(5, 100000)],
|
||||
),
|
||||
],
|
||||
rest: const Duration(minutes: 3),
|
||||
),
|
||||
StrengthBlock(
|
||||
exercises: [
|
||||
PlannedExercise(exercise: pullUp, sets: [reps(8), reps(8), reps(6)]),
|
||||
PlannedExercise(exercise: dip, sets: [reps(10), reps(10)]),
|
||||
],
|
||||
restBetween: const Duration(seconds: 15),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_payload.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_session_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/exercise.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/exercise_catalogue.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_done.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_session.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_payload.dart';
|
||||
|
||||
import 'strength_fixtures.dart';
|
||||
|
||||
ByteData _bytes(Object json) =>
|
||||
ByteData.sublistView(Uint8List.fromList(utf8.encode(jsonEncode(json))));
|
||||
|
||||
const sandbag = Exercise(
|
||||
name: 'Sandbag carry',
|
||||
load: ExerciseLoad.body,
|
||||
pattern: MovementPattern.carry,
|
||||
timed: true,
|
||||
);
|
||||
|
||||
/// Strength plans', workouts' and a session's JSON (ADR 0035, D3, D5, D6).
|
||||
void main() {
|
||||
test("a plan reads back as written, the person's own exercise too", () {
|
||||
final mine = plan.copyWith(
|
||||
blocks: [
|
||||
...plan.blocks,
|
||||
const StrengthBlock(
|
||||
exercises: [
|
||||
PlannedExercise(
|
||||
exercise: sandbag,
|
||||
sets: [StrengthSet(seconds: 60)],
|
||||
),
|
||||
],
|
||||
rest: Duration(minutes: 2),
|
||||
),
|
||||
],
|
||||
);
|
||||
expect(decodeStrengthPlan(encodeStrengthPlan(mine)), mine);
|
||||
});
|
||||
|
||||
test("an exercise this app doesn't know by its id is done as written; "
|
||||
"one it can't read is left out with its sets, and a block left empty "
|
||||
'with it', () {
|
||||
final read = decodeStrengthPlan(
|
||||
_bytes({
|
||||
'schemaVersion': 1,
|
||||
'name': 'New',
|
||||
'blocks': [
|
||||
{
|
||||
'restS': 60,
|
||||
'exercises': [
|
||||
{
|
||||
'exercise': {
|
||||
'id': 'zercherSquat',
|
||||
'needs': ['barbell', 'sled'],
|
||||
'load': 'barbell',
|
||||
'pattern': 'squat',
|
||||
},
|
||||
'sets': [
|
||||
{'reps': 5, 'grams': 60000},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
'restS': 60,
|
||||
'exercises': [
|
||||
{
|
||||
'exercise': {'id': 'x', 'load': 'sled', 'pattern': 'squat'},
|
||||
'sets': [
|
||||
{'reps': 5},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
read,
|
||||
const StrengthPlanContent(
|
||||
name: 'New',
|
||||
blocks: [
|
||||
StrengthBlock(
|
||||
exercises: [
|
||||
PlannedExercise(
|
||||
exercise: Exercise(
|
||||
id: 'zercherSquat',
|
||||
needs: [ExerciseNeed.barbell],
|
||||
load: ExerciseLoad.barbell,
|
||||
pattern: MovementPattern.squat,
|
||||
),
|
||||
sets: [StrengthSet(reps: 5, grams: 60000)],
|
||||
),
|
||||
],
|
||||
rest: Duration(minutes: 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('a newer schema, and what is no plan, read as none', () {
|
||||
expect(
|
||||
decodeStrengthPlan(_bytes({'schemaVersion': 2, 'name': 'x'})),
|
||||
isNull,
|
||||
);
|
||||
expect(decodeStrengthPlan(_bytes([1])), isNull);
|
||||
expect(decodeStrengthPlan(ByteData(0)), isNull);
|
||||
});
|
||||
|
||||
test('a strength workout keeps its sets within workout schema 1', () {
|
||||
final start = DateTime.utc(2026, 11, 2, 18);
|
||||
final workout = StrengthSession(plan: plan, startedAt: start)
|
||||
.withDone(
|
||||
DoneSet(
|
||||
exercise: catalogued('backSquat')!,
|
||||
reps: 5,
|
||||
grams: 60000,
|
||||
doneAt: start.add(const Duration(minutes: 1)),
|
||||
),
|
||||
start,
|
||||
)
|
||||
.toWorkout(start.add(const Duration(minutes: 30)));
|
||||
final payload = encodeWorkout(workout);
|
||||
expect(
|
||||
(jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
|
||||
as Map<String, Object?>)['schemaVersion'],
|
||||
1,
|
||||
);
|
||||
expect(decodeWorkout(payload), workout);
|
||||
});
|
||||
|
||||
test('a session under way reads back whole, and one naming a set its plan '
|
||||
"doesn't have reads as none", () {
|
||||
final start = DateTime.utc(2026, 11, 2, 18);
|
||||
final session =
|
||||
StrengthSession(plan: plan, placeName: 'Home', startedAt: start)
|
||||
.withDone(
|
||||
DoneSet(
|
||||
exercise: catalogued('backSquat')!,
|
||||
reps: 5,
|
||||
grams: 60000,
|
||||
doneAt: start,
|
||||
),
|
||||
start,
|
||||
)
|
||||
.withSkipped()
|
||||
.withSwap(1, 1, catalogued('pikePushUp')!);
|
||||
final json = jsonDecode(jsonEncode(sessionJson(session)));
|
||||
expect(sessionOf(json), session);
|
||||
|
||||
(json as Map<String, Object?>)['skipped'] = [
|
||||
[7, 0, 0],
|
||||
];
|
||||
expect(sessionOf(json), isNull);
|
||||
});
|
||||
|
||||
test('the store keeps the session until it is cleared', () async {
|
||||
final store = StrengthSessionStore();
|
||||
final session = StrengthSession(
|
||||
plan: plan,
|
||||
startedAt: DateTime.utc(2026, 11, 2),
|
||||
);
|
||||
expect(store.read(), isNull);
|
||||
await store.write(session);
|
||||
expect(store.read(), session);
|
||||
await store.clear();
|
||||
expect(store.read(), isNull);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
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/strength/data/strength_plan_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_plan.dart';
|
||||
|
||||
import '../../support/memory_content_key_gateway.dart';
|
||||
import '../../support/memory_strength_plan_gateway.dart';
|
||||
|
||||
/// The person's strength plans, sealed on the way to the instance and opened
|
||||
/// on the way back (ADR 0030, D7; ADR 0035), with libsodium.
|
||||
void main() {
|
||||
final crypto = SodiumAccountCrypto();
|
||||
late UnlockedAccountKeys alice;
|
||||
late UnlockedAccountKeys bob;
|
||||
late MemoryContentKeyGateway keyRows;
|
||||
late MemoryStrengthPlanGateway rows;
|
||||
final opened = <TrainKeys>[];
|
||||
|
||||
const home = StrengthPlanContent(name: 'Day A');
|
||||
|
||||
Future<StrengthPlanRepository> device(UnlockedAccountKeys? account) async {
|
||||
final keys = TrainKeys(keyRows, SodiumContentCipher(), () async => account);
|
||||
opened.add(keys);
|
||||
await keys.refresh();
|
||||
return StrengthPlanRepository(rows, keys);
|
||||
}
|
||||
|
||||
setUpAll(() async {
|
||||
alice = (await crypto.newAccount('alice password')).keys;
|
||||
bob = (await crypto.newAccount('bob password')).keys;
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
keyRows = MemoryContentKeyGateway();
|
||||
rows = MemoryStrengthPlanGateway();
|
||||
});
|
||||
|
||||
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(home);
|
||||
final phone = await device(alice);
|
||||
expect(await phone.all(), [StoredStrengthPlan(id, home)]);
|
||||
|
||||
final renamed = home.copyWith(name: 'Day B');
|
||||
await phone.update(id, renamed);
|
||||
expect(await laptop.all(), [StoredStrengthPlan(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(home);
|
||||
|
||||
final sealed = rows.rows.values.single;
|
||||
expect(sealed.keyId, isNotNull);
|
||||
expect(
|
||||
utf8.decode(Uint8List.sublistView(sealed.payload), allowMalformed: true),
|
||||
isNot(contains('Day A')),
|
||||
);
|
||||
});
|
||||
|
||||
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(home);
|
||||
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: home, 'moved': null, 'readable': null},
|
||||
);
|
||||
});
|
||||
|
||||
test('a device without the keys reads nothing and saves nothing', () async {
|
||||
await (await device(alice)).save(home);
|
||||
final stranger = await device(null);
|
||||
|
||||
expect((await stranger.all()).single.content, isNull);
|
||||
await expectLater(stranger.save(home), throwsA(isA<KeysMissing>()));
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/model/equipment.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/exercise.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/exercise_catalogue.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_done.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_session.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
import 'strength_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
final start = DateTime.utc(2026, 11, 2, 18);
|
||||
|
||||
group('a plan (ADR 0035, D2)', () {
|
||||
test('is done block by block, a superset in rounds, an exercise of '
|
||||
'fewer sets dropping out of the later ones', () {
|
||||
expect(plan.order, [
|
||||
(block: 0, exercise: 0, set: 0),
|
||||
(block: 0, exercise: 0, set: 1),
|
||||
(block: 0, exercise: 0, set: 2),
|
||||
(block: 1, exercise: 0, set: 0),
|
||||
(block: 1, exercise: 1, set: 0),
|
||||
(block: 1, exercise: 0, set: 1),
|
||||
(block: 1, exercise: 1, set: 1),
|
||||
(block: 1, exercise: 0, set: 2),
|
||||
]);
|
||||
expect(plan.blocks.map((block) => block.kind), [
|
||||
BlockKind.single,
|
||||
BlockKind.superset,
|
||||
]);
|
||||
});
|
||||
|
||||
test('names what a place lacks for it, each need once (D4)', () {
|
||||
final home = PlaceContent.home('Home');
|
||||
expect(plan.missingAt(home), [ExerciseNeed.rack, ExerciseNeed.dipBars]);
|
||||
expect(plan.missingAt(PlaceContent.gym('Gym')), isEmpty);
|
||||
expect(
|
||||
plan.missingAt(const PlaceContent(name: 'Park')),
|
||||
[
|
||||
ExerciseNeed.barbell,
|
||||
ExerciseNeed.rack,
|
||||
ExerciseNeed.pullUpBar,
|
||||
ExerciseNeed.dipBars,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('swaps for an exercise move as it does and can be done there', () {
|
||||
final home = PlaceContent.home('Home');
|
||||
final swaps = swapsFor(
|
||||
dip,
|
||||
(exercise) => exercise.missingAt(home).isEmpty,
|
||||
);
|
||||
expect(swaps, contains(catalogued('pikePushUp')));
|
||||
expect(swaps, contains(catalogued('dumbbellShoulderPress')));
|
||||
expect(swaps, isNot(contains(dip)));
|
||||
expect(
|
||||
swaps.every((exercise) => exercise.pattern == dip.pattern),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('a session (ADR 0035, D3)', () {
|
||||
DoneSet did(Exercise exercise, int reps, DateTime at, [int? grams]) =>
|
||||
DoneSet(exercise: exercise, reps: reps, grams: grams, doneAt: at);
|
||||
|
||||
test("rests the block's rest between sets, its rest between within a "
|
||||
'round, and leads into the next block with its rest', () {
|
||||
var session = StrengthSession(plan: plan, startedAt: start);
|
||||
expect(session.next, (block: 0, exercise: 0, set: 0));
|
||||
|
||||
session = session.withDone(did(squat, 5, start, 60000), start);
|
||||
expect(session.restUntil, start.add(const Duration(minutes: 3)));
|
||||
expect(session.next, (block: 0, exercise: 0, set: 1));
|
||||
|
||||
final later = start.add(const Duration(minutes: 4));
|
||||
session = session
|
||||
.withDone(did(squat, 5, later, 80000), later)
|
||||
.withDone(did(squat, 4, later, 100000), later);
|
||||
// The last squat leads into the superset with the squats' rest.
|
||||
expect(session.restUntil, later.add(const Duration(minutes: 3)));
|
||||
|
||||
session = session.withDone(did(pullUp, 8, later), later);
|
||||
expect(session.next, (block: 1, exercise: 1, set: 0));
|
||||
expect(session.restUntil, later.add(const Duration(seconds: 15)));
|
||||
|
||||
session = session.withDone(did(dip, 10, later), later);
|
||||
expect(session.restUntil, later.add(const Duration(seconds: 90)));
|
||||
expect(session.doneBefore((block: 0, exercise: 0, set: 2))?.grams, 80000);
|
||||
});
|
||||
|
||||
test('a set skipped is left out without a rest; the last set leaves no '
|
||||
'rest and nothing next', () {
|
||||
var session = StrengthSession(plan: plan, startedAt: start);
|
||||
for (var i = 0; i < 7; i++) {
|
||||
session = session.withSkipped();
|
||||
}
|
||||
expect(session.restUntil, isNull);
|
||||
expect(session.next, (block: 1, exercise: 0, set: 2));
|
||||
session = session.withDone(did(pullUp, 6, start), start);
|
||||
expect(session.next, isNull);
|
||||
expect(session.restUntil, isNull);
|
||||
});
|
||||
|
||||
test('a swap holds for the rest of the session, and the workout keeps '
|
||||
'what was done', () {
|
||||
final pikePushUp = catalogued('pikePushUp')!;
|
||||
var session = StrengthSession(
|
||||
plan: plan,
|
||||
placeName: 'Home',
|
||||
startedAt: start,
|
||||
).withSwap(1, 1, pikePushUp);
|
||||
expect(session.exerciseAt((block: 1, exercise: 1, set: 1)), pikePushUp);
|
||||
expect(session.exerciseAt((block: 1, exercise: 0, set: 1)), pullUp);
|
||||
|
||||
session = session
|
||||
.withDone(did(squat, 5, start, 60000), start)
|
||||
.withDone(did(squat, 5, start, 80000), start);
|
||||
final end = start.add(const Duration(minutes: 40));
|
||||
final workout = session.toWorkout(end);
|
||||
expect(workout.activity, Activity.strength);
|
||||
expect(workout.name, 'Day A');
|
||||
expect(workout.movingTime, const Duration(minutes: 40));
|
||||
expect(workout.strength?.placeName, 'Home');
|
||||
expect(workout.strength?.sets.map((set) => set.grams), [60000, 80000]);
|
||||
expect(workout.strength?.volumeGrams, 5 * 60000 + 5 * 80000);
|
||||
expect(workout.strength?.byExercise.single.$1, squat);
|
||||
});
|
||||
});
|
||||
|
||||
test('a pushup needs nothing, a dip needs dip bars', () {
|
||||
expect(pushUp.missingAt(const PlaceContent(name: 'Park')), isEmpty);
|
||||
expect(dip.missingAt(const PlaceContent(name: 'Park')), [
|
||||
ExerciseNeed.dipBars,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
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/strength/data/strength_plan_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/data/sync_strength_plan_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_plan.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
// The strength 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 StrengthPlanRepository plans;
|
||||
|
||||
const home = StrengthPlanContent(name: 'Day A');
|
||||
|
||||
setUpAll(() async {
|
||||
alice = (await crypto.newAccount('alice password')).keys;
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
folder = await Directory.systemTemp.createTemp('strength_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 = StrengthPlanRepository(SyncStrengthPlanGateway(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(home);
|
||||
final renamed = home.copyWith(name: 'Day B');
|
||||
|
||||
await plans.update(id, renamed);
|
||||
|
||||
expect(await plans.all(), [StoredStrengthPlan(id, renamed)]);
|
||||
});
|
||||
|
||||
test('deleting a plan blanks its payload first', () async {
|
||||
final id = await plans.save(home);
|
||||
final kept = await plans.save(home);
|
||||
|
||||
await plans.delete(id);
|
||||
|
||||
expect(await plans.all(), [StoredStrengthPlan(kept, home)]);
|
||||
// The sync engine keeps the deleted row as its tombstone: blank.
|
||||
final rows = await db.db.unsafeQuery(
|
||||
'SELECT length("payload"), "keyId", "nonce" FROM "strength_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]);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/model/equipment.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/equipment/model/plate_maths.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/exercise.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/weight_up.dart';
|
||||
|
||||
/// How a set's weight is made up at a place (ADR 0035, D7).
|
||||
void main() {
|
||||
final home = PlaceContent.home('Home');
|
||||
final gym = PlaceContent.gym('Gym');
|
||||
|
||||
test("a bar's weight is the plate maths' on the place's bar", () {
|
||||
expect(
|
||||
weightUp(ExerciseLoad.barbell, 80000, home),
|
||||
const PlatedWeight(
|
||||
bar: 20000,
|
||||
implements: 1,
|
||||
load: PlateLoad(side: [20000, 10000], grams: 80000, short: 0),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('a pair of dumbbells takes a fixed pair where it has one, else two '
|
||||
'handles with plates', () {
|
||||
expect(
|
||||
weightUp(ExerciseLoad.dumbbellPair, 12000, gym),
|
||||
const FixedWeight(
|
||||
kind: EquipmentKind.fixedDumbbells,
|
||||
grams: 12000,
|
||||
short: 0,
|
||||
),
|
||||
);
|
||||
// Home: handles of 2 kg, four of each of 2.5 and 1.25 kg: 2 + 2 × 2.5.
|
||||
expect(
|
||||
weightUp(ExerciseLoad.dumbbellPair, 7000, home),
|
||||
const PlatedWeight(
|
||||
bar: 2000,
|
||||
implements: 2,
|
||||
load: PlateLoad(side: [2500], grams: 7000, short: 0),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('a kettlebell is the nearest at or below; with none, the weight is '
|
||||
'taken as typed, not made up', () {
|
||||
expect(
|
||||
weightUp(ExerciseLoad.kettlebell, 18000, gym),
|
||||
const FixedWeight(
|
||||
kind: EquipmentKind.kettlebells,
|
||||
grams: 16000,
|
||||
short: 2000,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
weightUp(ExerciseLoad.kettlebell, 18000, home),
|
||||
const TypedWeight(18000, madeUp: false),
|
||||
);
|
||||
});
|
||||
|
||||
test("a cable's stack and the body take the weight as typed", () {
|
||||
expect(
|
||||
weightUp(ExerciseLoad.cable, 35000, gym),
|
||||
const TypedWeight(35000),
|
||||
);
|
||||
expect(weightUp(ExerciseLoad.body, 10000, home), const TypedWeight(10000));
|
||||
});
|
||||
|
||||
test("a bar's sets are loaded for the fewest moves over them", () {
|
||||
final series = plateSeries(ExerciseLoad.barbell, [60000, 80000], home)!;
|
||||
expect(series.map((step) => step.load?.side), [
|
||||
[20000],
|
||||
[20000, 10000],
|
||||
]);
|
||||
expect(series.last.change, const PlateChange(off: [], on: [10000]));
|
||||
expect(plateSeries(ExerciseLoad.dumbbellPair, [7000], home), isNull);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_plan_gateway.dart';
|
||||
|
||||
/// [StrengthPlanGateway] in memory, whose rows a test changes as an instance could.
|
||||
class MemoryStrengthPlanGateway() implements StrengthPlanGateway {
|
||||
final rows = <String, Envelope>{};
|
||||
final _remote = StreamController<void>.broadcast();
|
||||
|
||||
/// As a sync bringing others' changes.
|
||||
void bringRemoteChanges() => _remote.add(null);
|
||||
|
||||
@override
|
||||
Future<List<StrengthPlanRow>> plans() async => [
|
||||
for (final MapEntry(:key, :value) in rows.entries)
|
||||
(id: key, envelope: value),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> insert(StrengthPlanRow plan) async =>
|
||||
rows[plan.id] = plan.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;
|
||||
}
|
||||
Loading…
Reference in a new issue