feat(train): a workout out as GPX, TCX or FIT (ADR 0030, D49)
11b: Export on a workout's page hands it to the share sheet as GPX (with a track only; heart rate and cadence in Garmin's TrackPointExtension), TCX (laps, heart rate, cadence, watts, a row's distance) or FIT (file ID, records, timer events at the pauses, laps with their intensities, session, activity), all from the same points: the precise fixes while moving, else the rower's frames, else the heart rates; a plan's steps are the laps. The FIT writer is our own (protocol 1.0, CRC-16/ARC), tested by a reader written from the specification. Not tried yet with Strava, intervals.icu or Garmin Connect. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
db7e0b7bcd
commit
128ba1f6c8
1 changed files with 1386 additions and 15 deletions
|
|
@ -0,0 +1,294 @@
|
|||
import 'dart:convert';
|
||||
|
||||
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/fit_encoder.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_export.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
import '../../support/fit_reader.dart';
|
||||
|
||||
/// A workout out as GPX, TCX and FIT (ADR 0030, D49).
|
||||
void main() {
|
||||
final start = DateTime.utc(2026, 10, 29, 7, 30);
|
||||
DateTime at(num seconds) =>
|
||||
start.add(Duration(milliseconds: (seconds * 1000).round()));
|
||||
|
||||
/// A fix [metres] north of where the equator crosses Greenwich.
|
||||
Fix north(int seconds, num metres, {int accuracyCm = 500}) => Fix(
|
||||
time: at(seconds),
|
||||
latE7: (metres * 89.932).round(),
|
||||
lonE7: 0,
|
||||
accuracyCm: accuracyCm,
|
||||
altitudeCm: 10000 + seconds * 10,
|
||||
);
|
||||
|
||||
/// A run of two minutes at 3 m/s, paused from 60 s to 80 s, an imprecise
|
||||
/// fix at 25 s, a heart rate each second and a step each 0.4 s.
|
||||
final run = Recording(
|
||||
activity: Activity.running,
|
||||
startedAt: start,
|
||||
fixes: [
|
||||
for (var s = 0; s <= 120; s += 10)
|
||||
if (s != 70) north(s, s * 3),
|
||||
north(25, 500, accuracyCm: 4000),
|
||||
north(70, 210),
|
||||
]..sort((a, b) => a.time.compareTo(b.time)),
|
||||
pauses: [
|
||||
PauseMark(time: at(60)),
|
||||
PauseMark(time: at(80), paused: false),
|
||||
],
|
||||
heartRates: [
|
||||
for (var s = 0; s <= 120; s++) HeartRate(time: at(s), bpm: 140 + s ~/ 10),
|
||||
],
|
||||
steps: [for (var i = 0; i < 300; i++) at(i * 0.4)],
|
||||
endedAt: at(120),
|
||||
);
|
||||
final (content, series) = recordedWorkout(run);
|
||||
final track = exportTrackOf(content, series);
|
||||
|
||||
test("FIT's CRC is CRC-16/ARC", () {
|
||||
expect(fitCrc('123456789'.codeUnits), 0xBB3D);
|
||||
});
|
||||
|
||||
test('the points are the precise fixes while moving, with the distance, '
|
||||
'the heart rate and the strides', () {
|
||||
expect(track.located, isTrue);
|
||||
expect(
|
||||
[
|
||||
for (final point in track.points)
|
||||
point.time.difference(start).inSeconds,
|
||||
],
|
||||
[0, 10, 20, 30, 40, 50, 80, 90, 100, 110, 120],
|
||||
);
|
||||
final first = track.points.first;
|
||||
expect(first.metres, 0);
|
||||
expect(first.altitude, 100);
|
||||
expect(first.bpm, 140);
|
||||
// 25 steps in 10 s: 150 a minute, 75 strides.
|
||||
expect(first.cadence, 75);
|
||||
expect(track.points[1].metres, closeTo(30, 0.5));
|
||||
// The fix at 60 s came as the pause did.
|
||||
final afterPause = track.points[6];
|
||||
expect(afterPause.afterPause, isTrue);
|
||||
// The distance doesn't count the pause's way.
|
||||
expect(afterPause.metres, closeTo(track.points[5].metres!, 0.01));
|
||||
expect(track.points.where((point) => point.afterPause), hasLength(1));
|
||||
expect(track.laps.single.metres, content.distanceMetres);
|
||||
expect(track.laps.single.movingTime, const Duration(seconds: 100));
|
||||
});
|
||||
|
||||
test('GPX: a segment each piece, the heart rate and cadence in '
|
||||
"Garmin's extension", () {
|
||||
final gpx = XmlDocument.parse(
|
||||
gpxOfWorkout(content, track, name: 'Morning run'),
|
||||
);
|
||||
final root = gpx.rootElement;
|
||||
expect(root.getAttribute('version'), '1.1');
|
||||
final segments = root.findAllElements('trkseg').toList();
|
||||
expect(segments, hasLength(2));
|
||||
expect(segments.first.findElements('trkpt'), hasLength(6));
|
||||
final point = segments.first.findElements('trkpt').first;
|
||||
expect(point.getAttribute('lat'), '0.0000000');
|
||||
expect(point.getElement('time')!.innerText, '2026-10-29T07:30:00Z');
|
||||
expect(point.getElement('ele')!.innerText, '100.0');
|
||||
expect(point.findAllElements('gpxtpx:hr').single.innerText, '140');
|
||||
expect(point.findAllElements('gpxtpx:cad').single.innerText, '75');
|
||||
expect(root.findAllElements('type').single.innerText, 'running');
|
||||
});
|
||||
|
||||
test("TCX: the sport, a lap, each trackpoint's elements in the schema's "
|
||||
"order, a run's cadence as RunCadence", () {
|
||||
final tcx = XmlDocument.parse(tcxOf(content, track));
|
||||
final activity = tcx.findAllElements('Activity').single;
|
||||
expect(activity.getAttribute('Sport'), 'Running');
|
||||
final lap = activity.findElements('Lap').single;
|
||||
expect(lap.getAttribute('StartTime'), '2026-10-29T07:30:00Z');
|
||||
expect(
|
||||
[for (final element in lap.childElements) element.localName],
|
||||
[
|
||||
'TotalTimeSeconds',
|
||||
'DistanceMeters',
|
||||
'Calories',
|
||||
'AverageHeartRateBpm',
|
||||
'MaximumHeartRateBpm',
|
||||
'Intensity',
|
||||
'TriggerMethod',
|
||||
'Track',
|
||||
],
|
||||
);
|
||||
expect(lap.getElement('TotalTimeSeconds')!.innerText, '100.0');
|
||||
expect(lap.getElement('MaximumHeartRateBpm')!.innerText.trim(), '152');
|
||||
final points = lap.findAllElements('Trackpoint').toList();
|
||||
expect(points, hasLength(11));
|
||||
expect(
|
||||
[for (final element in points.first.childElements) element.localName],
|
||||
[
|
||||
'Time',
|
||||
'Position',
|
||||
'AltitudeMeters',
|
||||
'DistanceMeters',
|
||||
'HeartRateBpm',
|
||||
'Extensions',
|
||||
],
|
||||
);
|
||||
expect(points.first.findAllElements('RunCadence').single.innerText, '75');
|
||||
expect(points.first.findAllElements('Cadence'), isEmpty);
|
||||
});
|
||||
|
||||
test('FIT: the file ID, a record each point, the timer events, the lap, '
|
||||
'the session and the activity, as its specification reads', () {
|
||||
final messages = readFit(fitOf(content, track));
|
||||
Iterable<FitMessage> all(int global) =>
|
||||
messages.where((message) => message.global == global);
|
||||
final fileId = all(0).single.fields;
|
||||
expect(fileId[0], 4);
|
||||
expect(fileId[4], fitTime(start));
|
||||
final records = all(20).toList();
|
||||
expect(records, hasLength(11));
|
||||
expect(records.first.fields[253], fitTime(start));
|
||||
expect(records.first.fields[3], 140);
|
||||
expect(records.first.fields[4], 75);
|
||||
// 100 m altitude: (100 + 500) × 5.
|
||||
expect(records.first.fields[2], 3000);
|
||||
// 90 m north, in 1e-7 degrees as kept, in semicircles.
|
||||
expect(
|
||||
records[3].fields[0],
|
||||
((90 * 89.932).round() / 1e7 * 2147483648 / 180).round(),
|
||||
);
|
||||
expect(records[1].fields[5], closeTo(3000, 50));
|
||||
expect(records.first.fields.containsKey(1), isTrue);
|
||||
expect(
|
||||
[for (final event in all(21)) event.fields[1]],
|
||||
[0, 4, 0, 4],
|
||||
);
|
||||
final lap = all(19).single.fields;
|
||||
expect(lap[2], fitTime(start));
|
||||
expect(lap[8], 100000);
|
||||
expect(lap[7], 120000);
|
||||
expect(lap[24], 7);
|
||||
final session = all(18).single.fields;
|
||||
expect(session[5], 1);
|
||||
expect(session[8], 100000);
|
||||
expect(session[9], content.distanceMetres! * 100);
|
||||
expect(session[17], 152);
|
||||
expect(session[26], 1);
|
||||
final activity = all(34).single.fields;
|
||||
expect(activity[1], 1);
|
||||
expect(activity[3], 26);
|
||||
});
|
||||
|
||||
test("a plan's steps are the laps, with their intensities", () {
|
||||
final planned = WorkoutContent(
|
||||
activity: Activity.running,
|
||||
startedAt: start,
|
||||
endedAt: at(120),
|
||||
movingTime: const Duration(seconds: 100),
|
||||
distanceMetres: 300,
|
||||
plan: WorkoutPlan(
|
||||
name: 'Short',
|
||||
laps: [
|
||||
WorkoutLap(
|
||||
step: const PlanStep(
|
||||
kind: StepKind.work,
|
||||
end: EndAfterTime(Duration(minutes: 1)),
|
||||
),
|
||||
startedAt: start,
|
||||
endedAt: at(60),
|
||||
movingTime: const Duration(seconds: 60),
|
||||
metres: 180,
|
||||
),
|
||||
WorkoutLap(
|
||||
step: const PlanStep(kind: StepKind.recovery, end: EndWithLap()),
|
||||
startedAt: at(60),
|
||||
endedAt: at(120),
|
||||
movingTime: const Duration(seconds: 40),
|
||||
metres: 120,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final laps = exportTrackOf(planned, series).laps;
|
||||
expect(
|
||||
[for (final lap in laps) lap.kind],
|
||||
[
|
||||
StepKind.work,
|
||||
StepKind.recovery,
|
||||
],
|
||||
);
|
||||
final tcx = XmlDocument.parse(
|
||||
tcxOf(planned, exportTrackOf(planned, series)),
|
||||
);
|
||||
final tcxLaps = tcx.findAllElements('Lap').toList();
|
||||
expect(
|
||||
[for (final lap in tcxLaps) lap.getElement('Intensity')!.innerText],
|
||||
[
|
||||
'Active',
|
||||
'Resting',
|
||||
],
|
||||
);
|
||||
// The point at 60 s starts the second lap.
|
||||
expect(tcxLaps.first.findAllElements('Trackpoint'), hasLength(6));
|
||||
final fitLaps = [
|
||||
for (final message in readFit(
|
||||
fitOf(planned, exportTrackOf(planned, series)),
|
||||
))
|
||||
if (message.global == 19) message.fields,
|
||||
];
|
||||
expect([for (final lap in fitLaps) lap[23]], [0, 1]);
|
||||
expect([for (final lap in fitLaps) lap[24]], [0, 7]);
|
||||
});
|
||||
|
||||
test("a row goes out as its rower's frames, never as GPX", () {
|
||||
final row = Recording(
|
||||
activity: Activity.indoorRowing,
|
||||
startedAt: start,
|
||||
rowing: [
|
||||
for (var s = 0; s <= 60; s += 2)
|
||||
RowingSample(
|
||||
time: at(s),
|
||||
strokeRate: 24,
|
||||
strokes: s ~/ 2,
|
||||
metres: s * 4,
|
||||
watts: 180,
|
||||
),
|
||||
],
|
||||
endedAt: at(60),
|
||||
);
|
||||
final (rowed, rowSeries) = recordedWorkout(row);
|
||||
final rowTrack = exportTrackOf(rowed, rowSeries);
|
||||
expect(rowTrack.located, isFalse);
|
||||
expect(ExportFormat.gpx.takes(rowTrack), isFalse);
|
||||
expect(ExportFormat.fit.takes(rowTrack), isTrue);
|
||||
expect(rowTrack.points.last.metres, 240);
|
||||
expect(rowTrack.points.last.cadence, 24);
|
||||
expect(rowTrack.points.last.watts, 180);
|
||||
final tcx = XmlDocument.parse(tcxOf(rowed, rowTrack));
|
||||
expect(
|
||||
tcx.findAllElements('Activity').single.getAttribute('Sport'),
|
||||
'Other',
|
||||
);
|
||||
expect(tcx.findAllElements('Cadence').first.innerText, '24');
|
||||
expect(tcx.findAllElements('Watts').first.innerText, '180');
|
||||
final session = [
|
||||
for (final message in readFit(fitOf(rowed, rowTrack)))
|
||||
if (message.global == 18) message.fields,
|
||||
].single;
|
||||
expect(session[5], 15);
|
||||
expect(session[6], 14);
|
||||
});
|
||||
|
||||
test('the file is named by when the workout started, in UTC', () {
|
||||
expect(
|
||||
exportNameOf(content, ExportFormat.fit),
|
||||
'halkyon-train-20261029-0730.fit',
|
||||
);
|
||||
expect(
|
||||
utf8.decode(exportOf(ExportFormat.gpx, content, track, name: 'Run')),
|
||||
contains('<name>Run</name>'),
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue