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:
Tizian 「ティツィアーン」 2026-10-29 14:37:48 +01:00
commit 128ba1f6c8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -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>'),
);
});
}