feat(train): a recording runs an interval plan, with cues and Lap (ADR 0030, D42)

The plan chosen on the Record sheet goes to the recording service spelled
out: each step's end, its target (zones as the heart rates they span for
the profile's highest rate) and its spoken line in the app's language.
PlanRunner.kt moves through the steps on the moving time and distance,
counts a timed step's last seconds down and cues off target from a 30 s
window; PlanCues.kt gives a tone, a vibration and the voice, ducking
music. The file's plan and step lines bring it back, and the recording
screen shows the step, what is left of it, its target, the next step and
Lap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-28 22:59:48 +01:00
commit d9f98449a8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,131 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations_de.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations_en.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/plan/widget/plan_speech.dart';
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
/// What the recorder is handed to run a plan (ADR 0030, D42): the run
/// spelled out, zones as heart rates, and what is said.
void main() {
final en = AppLocalizationsEn();
const plan = PlanContent(
name: '4×4',
blocks: [
PlanBlock(
steps: [
PlanStep(
kind: StepKind.warmUp,
end: EndAfterTime(Duration(minutes: 10)),
),
],
),
PlanBlock(
repeat: 2,
steps: [
PlanStep(
kind: StepKind.work,
end: EndAfterTime(Duration(minutes: 4)),
target: Target(TargetKind.heartZone, 4, 4),
),
PlanStep(
kind: StepKind.recovery,
end: EndAfterDistance(400),
target: Target(TargetKind.pace, 330, 360),
),
],
),
PlanBlock(
steps: [PlanStep(kind: StepKind.coolDown, end: EndWithLap())],
),
],
offTargetCues: false,
);
test('every round of every step goes, with its end, its target and what '
'is said; zones as the heart rates they span', () {
final json = recorderPlanOf(en, 'en-GB', plan, maxHeartRate: 190);
expect(json['offTargetCues'], isFalse);
expect(json['locale'], 'en-GB');
expect(json['steps'], [
{'seconds': 600, 'say': 'Warm-up, 10 minutes'},
{
'seconds': 240,
'target': {'kind': 'heartRate', 'low': 152, 'high': 171},
'say': 'Work, 1 of 2, 4 minutes, zone 4',
},
{
'metres': 400,
'target': {'kind': 'pace', 'low': 330.0, 'high': 360.0},
'say': 'Recovery, 1 of 2, 400 metres, 5:30 to 6:00 a kilometre',
},
{
'seconds': 240,
'target': {'kind': 'heartRate', 'low': 152, 'high': 171},
'say': 'Work, 2 of 2, 4 minutes, zone 4',
},
{
'metres': 400,
'target': {'kind': 'pace', 'low': 330.0, 'high': 360.0},
'say': 'Recovery, 2 of 2, 400 metres, 5:30 to 6:00 a kilometre',
},
{'say': 'Cool-down, until you tap Lap'},
]);
expect(json['say'], {
'harder': 'Push a little',
'easier': 'Ease off a little',
'done': 'Plan done',
});
});
test('the plan itself goes too, as its payload, for the recording file', () {
final json = recorderPlanOf(en, 'en-GB', plan, maxHeartRate: 190);
expect(json['plan'], containsPair('name', '4×4'));
expect(json['plan'], containsPair('schemaVersion', 1));
});
test("it is said in the app's language", () {
const step = PlanStep(
kind: StepKind.work,
end: EndAfterTime(Duration(seconds: 90)),
target: Target(TargetKind.strokeRate, 24, 26),
);
expect(
spokenStep(AppLocalizationsDe(), 'de', (step: step, round: 3, rounds: 8)),
'Belastung, 3 von 8, 1 Minute 30 Sekunden, '
'24 bis 26 Ruderschläge pro Minute',
);
expect(
spokenStep(en, 'en', (
step: const PlanStep(
kind: StepKind.work,
end: EndAfterDistance(1500),
),
round: 1,
rounds: 1,
)),
'Work, 1.5 kilometres',
);
});
test("zones are worked out from the profile's highest heart rate, else "
'from the age, else from a guess', () {
final today = DateTime.utc(2026, 10, 28);
expect(
planMaxHeartRate(const ProfileContent(maxHeartRate: 185), today),
185,
);
expect(
planMaxHeartRate(
ProfileContent(birthDate: DateTime.utc(1996, 3, 2)),
today,
),
190,
);
expect(planMaxHeartRate(null, today), 180);
});
}