feat(train): a workout keeps its plan's steps as laps, shown on its page (ADR 0030, D43)
As a recording with a plan is saved, each step run becomes a lap in the workout's sealed payload: the step, its round, when it started and ended, its moving time and distance, with the highest heart rate the zones ran with. The page lists the steps with their pace, speed or split, stroke rate, watts and heart rate, marks whether each held its target, and the charts shade the work steps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
d9f98449a8
commit
e1304975d6
2 changed files with 840 additions and 38 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
|
||||
|
|
@ -311,4 +312,89 @@ void main() {
|
|||
expect(analysis.watts.last.value, 200);
|
||||
});
|
||||
});
|
||||
|
||||
group('a plan (D43)', () {
|
||||
// 3 m/s (5:33 a km) for 600 s, the heart at 150, then 170 from 300 s.
|
||||
final fixes = [for (var s = 0; s <= 600; s += 10) north(s, s * 3)];
|
||||
final rates = [
|
||||
for (var s = 0; s <= 600; s += 10)
|
||||
HeartRate(time: at(s), bpm: s < 300 ? 150 : 170),
|
||||
];
|
||||
Recording intervals(List<PlanStep> steps, {int? maxHeartRate}) => Recording(
|
||||
activity: Activity.running,
|
||||
startedAt: start,
|
||||
fixes: fixes,
|
||||
heartRates: rates,
|
||||
plan: PlanContent(
|
||||
name: 'Two',
|
||||
blocks: [PlanBlock(steps: steps)],
|
||||
),
|
||||
planMaxHeartRate: maxHeartRate,
|
||||
planSteps: [
|
||||
StepMark(time: at(0), index: 0),
|
||||
StepMark(time: at(300), index: 1),
|
||||
],
|
||||
endedAt: at(600),
|
||||
);
|
||||
|
||||
test('each step has its own heart rate, and says whether it held its '
|
||||
'target', () {
|
||||
final analysis = analysed(
|
||||
intervals([
|
||||
const PlanStep(
|
||||
kind: StepKind.recovery,
|
||||
end: EndAfterTime(Duration(minutes: 5)),
|
||||
target: Target(TargetKind.pace, 320, 340),
|
||||
),
|
||||
const PlanStep(
|
||||
kind: StepKind.work,
|
||||
end: EndAfterTime(Duration(minutes: 5)),
|
||||
target: Target(TargetKind.pace, 270, 300),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(analysis.laps.map((l) => l.averageBpm), [150, 170]);
|
||||
expect(analysis.laps.map((l) => l.held), [true, false]);
|
||||
expect(analysis.laps.first.lap.metres, 900);
|
||||
});
|
||||
|
||||
test(
|
||||
'a zone is held on the heart rates it spans for the highest rate it '
|
||||
'ran with; without that rate, or without a target, it says nothing',
|
||||
() {
|
||||
final zones = [
|
||||
PlanStep(
|
||||
kind: StepKind.work,
|
||||
end: const EndAfterTime(Duration(minutes: 5)),
|
||||
target: Target.zone(3),
|
||||
),
|
||||
const PlanStep(
|
||||
kind: StepKind.work,
|
||||
end: EndAfterTime(Duration(minutes: 5)),
|
||||
),
|
||||
];
|
||||
|
||||
// Zone 3 of 200 bpm: 140 to 160.
|
||||
expect(
|
||||
analysed(intervals(zones, maxHeartRate: 200)).laps.map((l) => l.held),
|
||||
[true, null],
|
||||
);
|
||||
expect(analysed(intervals(zones)).laps.first.held, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('its work steps are where the charts shade, over the distance', () {
|
||||
final analysis = analysed(
|
||||
intervals(const [
|
||||
PlanStep(kind: StepKind.warmUp, end: EndWithLap()),
|
||||
PlanStep(kind: StepKind.work, end: EndWithLap()),
|
||||
]),
|
||||
);
|
||||
|
||||
final band = analysis.workBands.single;
|
||||
expect(band.from, closeTo(900, 1));
|
||||
expect(band.to, closeTo(1800, 1));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_payload.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
|
|
@ -84,4 +85,86 @@ void main() {
|
|||
expect(decodeWorkout(encodeWorkout(imported)), imported);
|
||||
expect(imported.withText(name: 'Intervals').origin, imported.origin);
|
||||
});
|
||||
|
||||
test('a workout run with a plan keeps its name, its highest heart rate and '
|
||||
'its steps as run (D43)', () {
|
||||
final intervals = WorkoutContent(
|
||||
activity: Activity.running,
|
||||
startedAt: run.startedAt,
|
||||
endedAt: run.endedAt,
|
||||
plan: WorkoutPlan(
|
||||
name: '4×4',
|
||||
maxHeartRate: 190,
|
||||
laps: [
|
||||
WorkoutLap(
|
||||
step: PlanStep(
|
||||
kind: StepKind.work,
|
||||
end: const EndAfterTime(Duration(minutes: 4)),
|
||||
target: Target.zone(4),
|
||||
),
|
||||
rounds: 4,
|
||||
startedAt: run.startedAt,
|
||||
endedAt: run.startedAt.add(const Duration(minutes: 4, seconds: 9)),
|
||||
movingTime: const Duration(minutes: 4),
|
||||
metres: 1012,
|
||||
),
|
||||
WorkoutLap(
|
||||
step: const PlanStep(kind: StepKind.recovery, end: EndWithLap()),
|
||||
rounds: 4,
|
||||
startedAt: run.startedAt.add(
|
||||
const Duration(minutes: 4, seconds: 9),
|
||||
),
|
||||
endedAt: run.endedAt,
|
||||
movingTime: const Duration(minutes: 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(decodeWorkout(encodeWorkout(intervals)), intervals);
|
||||
expect(intervals.withText(name: 'Hard').plan, intervals.plan);
|
||||
});
|
||||
|
||||
test("a lap it can't read is left out, the rest kept", () {
|
||||
final json = jsonDecode(
|
||||
utf8.decode(
|
||||
Uint8List.sublistView(
|
||||
encodeWorkout(
|
||||
WorkoutContent(
|
||||
activity: Activity.running,
|
||||
startedAt: run.startedAt,
|
||||
endedAt: run.endedAt,
|
||||
plan: WorkoutPlan(
|
||||
name: 'Laps',
|
||||
laps: [
|
||||
WorkoutLap(
|
||||
step: const PlanStep(
|
||||
kind: StepKind.work,
|
||||
end: EndWithLap(),
|
||||
),
|
||||
startedAt: run.startedAt,
|
||||
endedAt: run.endedAt,
|
||||
movingTime: const Duration(minutes: 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
) as Map<String, Object?>;
|
||||
final plan = json['plan']! as Map<String, Object?>;
|
||||
plan['laps'] = [
|
||||
{
|
||||
'step': {'kind': 'sprint'},
|
||||
},
|
||||
...plan['laps']! as List<Object?>,
|
||||
];
|
||||
|
||||
final read = decodeWorkout(
|
||||
ByteData.sublistView(utf8.encode(jsonEncode(json))),
|
||||
)!;
|
||||
|
||||
expect(read.plan?.laps, hasLength(1));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue