Match RunnerUp TCX: Calories, walk as Other

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
This commit is contained in:
Krzysztof Rudnicki 2026-11-10 15:41:55 +01:00
parent c2544c4a9c
commit 63caf9dee9
6 changed files with 44 additions and 17 deletions

View File

@ -1,11 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2 http://www.garmin.com/xmlschemas/TrainingCenterDatabasev2.xsd">
<Activities>
<Activity Sport="Walking">
<Activity Sport="Other">
<Id>2026-11-09T08:15:00Z</Id>
<Lap StartTime="2026-11-09T08:15:00Z">
<TotalTimeSeconds>1665</TotalTimeSeconds>
<DistanceMeters>2331.004</DistanceMeters>
<Calories>0</Calories>
<Intensity>Active</Intensity>
<TriggerMethod>Manual</TriggerMethod>
<Track>

View File

@ -6,6 +6,7 @@
<Lap StartTime="2026-11-09T16:30:00Z">
<TotalTimeSeconds>1560</TotalTimeSeconds>
<DistanceMeters>4212.014</DistanceMeters>
<Calories>0</Calories>
<Intensity>Active</Intensity>
<TriggerMethod>Manual</TriggerMethod>
<Track>

View File

@ -135,20 +135,18 @@ class TestGoldenWalk:
)
assert data["activity_id"] == str(spec["started_at"])
def test_walk_is_flagged_by_name_even_if_sport_is_not_walking(
self, inbox: Path
) -> None:
"""The app also writes ``Sport="Walking"``; either signal alone suffices."""
def test_walk_is_marked_by_file_name_as_with_runnerup(self, inbox: Path) -> None:
"""Like RunnerUp, the app writes ``Sport="Other"``; the name says walk."""
spec = _golden()["walking"]
name = str(spec["file"])
body = (FIXTURES / name).read_text(encoding="utf-8")
assert 'Sport="Walking"' in body
assert 'Sport="Other"' in body
renamed = inbox / "activity.tcx"
renamed.write_text(body.replace('Sport="Walking"', 'Sport="Other"'))
renamed.write_text(body)
data = Verifier()._pull_and_parse_tcx(str(renamed))
assert data is not None
assert data["walking"] is False
(inbox / name).write_text(body.replace('Sport="Walking"', 'Sport="Other"'))
(inbox / name).write_text(body)
by_name = Verifier()._pull_and_parse_tcx(str(inbox / name))
assert by_name is not None
assert by_name["walking"] is True

View File

@ -4,21 +4,29 @@ library;
import 'package:flutter/foundation.dart';
/// The sports the app records. The name doubles as the TCX `Sport` attribute
/// and the file-name suffix the PC keys on (`..._Walking.tcx`).
/// The sports the app records.
enum RunSport {
/// A run; credited on distance OR duration by the PC.
running('Running'),
running('Running', 'Running'),
/// A walk; the PC sums a day's walks and credits their moving time.
walking('Walking');
///
/// Written as `Sport="Other"` exactly like RunnerUp does (the TCX schema
/// only knows Running, Biking and Other); the `_Walking.tcx` file-name
/// suffix is what marks it a walk.
walking('Walking', 'Other');
const RunSport(this.tcxName);
const RunSport(this.tcxName, this.tcxSport);
/// `Sport="..."` on the TCX `Activity` and `_<name>.tcx` in the file name.
/// The sport's name: the `_<name>.tcx` file-name suffix the PC keys on, and
/// the `runs.sport` column.
final String tcxName;
/// The sport named [tcxName], or null for anything the app does not record.
/// The schema-valid `Sport="..."` attribute the encoder writes.
final String tcxSport;
/// The sport named [tcxName] (also accepts the Strava-style
/// `Sport="Walking"`), or null for anything the app does not record.
static RunSport? fromTcxName(String? tcxName) {
for (final sport in values) {
if (sport.tcxName == tcxName) return sport;

View File

@ -5,7 +5,8 @@
///
/// * File name `RunnerUp_YYYY-MM-DD-HH-MM-SS_<Sport>.tcx`, stamp in LOCAL
/// time (the PC globs today's local date in the name), suffix `Running` or
/// `Walking` (a walk is recognised by that suffix or `Sport="Walking"`).
/// `Walking` (a walk is recognised by that suffix or `Sport="Walking"`; the
/// encoder writes `Sport="Other"` for it, as RunnerUp does).
/// * `<Id>` and every `<Time>` are UTC with a `Z`; one Lap whose
/// `TotalTimeSeconds` is moving time and `DistanceMeters` the credited
/// distance; every Trackpoint carries `Time` and a cumulative
@ -62,7 +63,7 @@ String encodeTcx(RunTrack track) {
void _activity(XmlBuilder b, RunTrack track) => b.element(
'Activity',
attributes: {'Sport': track.sport.tcxName},
attributes: {'Sport': track.sport.tcxSport},
nest: () {
b.element('Id', nest: track.id);
_lap(b, track);
@ -96,6 +97,8 @@ void _lap(XmlBuilder b, RunTrack track) => b.element(
b
..element('TotalTimeSeconds', nest: '${track.totalSeconds}')
..element('DistanceMeters', nest: _num(track.distanceM, 3))
// Required by the TCX schema, as in RunnerUp's own files.
..element('Calories', nest: '0')
..element('Intensity', nest: 'Active')
..element('TriggerMethod', nest: 'Manual')
..element('Track', nest: () => track.points.forEach(_point(b)));

View File

@ -39,6 +39,7 @@ void main() {
expect(xml, contains('<Lap StartTime="2026-11-09T16:30:00Z">'));
expect(xml, contains('<TotalTimeSeconds>20</TotalTimeSeconds>'));
expect(xml, contains('<DistanceMeters>27.500</DistanceMeters>'));
expect(xml, contains('<Calories>0</Calories>'));
expect(xml, contains('<Time>2026-11-09T16:30:10Z</Time>'));
expect(xml, contains('<Name>$kRunSourceName</Name>'));
expect(
@ -49,6 +50,21 @@ void main() {
);
});
test('a walk is Sport="Other" like RunnerUp writes it, named by file', () {
final walk = _track(sport: RunSport.walking);
final xml = encodeTcx(walk);
expect(xml, contains('<Activity Sport="Other">'));
expect(
() => decodeTcx(xml),
throwsA(isA<FormatException>()),
reason: 'only the _Walking suffix says it is a walk',
);
expect(
decodeTcx(xml, fileName: tcxFilename(walk)).sport,
RunSport.walking,
);
});
test('omits AltitudeMeters when the point has none', () {
expect(encodeTcx(_track(alt: null)), isNot(contains('AltitudeMeters')));
expect(encodeTcx(_track()), contains('<AltitudeMeters>31.5<'));