V3-14: GPX <type> from activity (code only)
gpxActivityType() maps Activity to Strava/Garmin's <trk><type> vocabulary; Activity.other omits the element rather than writing it empty. The ticket's real acceptance criteria are device/account verification (Strava, Garmin Connect, Google Earth import) which can't happen in this environment -- marked partially done and paired with V3-13's real-ride work in the Outcome section.
This commit is contained in:
@@ -30,7 +30,7 @@ backup are v4 — see [../BACKLOG.md](../BACKLOG.md).
|
|||||||
| [V3-11](V3-11-offline-tiles.md) | Offline tile pre-download | M | V3-04 | Not started |
|
| [V3-11](V3-11-offline-tiles.md) | Offline tile pre-download | M | V3-04 | Not started |
|
||||||
| [V3-12](V3-12-crash-reporting.md) | Crash reporting | S | — | Not started |
|
| [V3-12](V3-12-crash-reporting.md) | Crash reporting | S | — | Not started |
|
||||||
| [V3-13](V3-13-real-ride-measurements.md) | Real-ride measurements | M | **riding** | Not started |
|
| [V3-13](V3-13-real-ride-measurements.md) | Real-ride measurements | M | **riding** | Not started |
|
||||||
| [V3-14](V3-14-gpx-interop.md) | GPX interoperability | S | V3-01 | Not started |
|
| [V3-14](V3-14-gpx-interop.md) | GPX interoperability | S | V3-01 | Partially done (code only; needs real-device verification) |
|
||||||
| [V3-15](V3-15-auto-pause.md) | Auto-pause | M | V3-13 *(gated)* | Not started |
|
| [V3-15](V3-15-auto-pause.md) | Auto-pause | M | V3-13 *(gated)* | Not started |
|
||||||
| [V3-16](V3-16-visual-identity.md) | Visual identity | M | V3-04, V3-05 | Not started |
|
| [V3-16](V3-16-visual-identity.md) | Visual identity | M | V3-04, V3-05 | Not started |
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# V3-14 — GPX interoperability
|
# V3-14 — GPX interoperability
|
||||||
|
|
||||||
**Phase** Quality · **Depends on** V3-01 for `<type>` · **Size** S · **Status** Not started
|
**Phase** Quality · **Depends on** V3-01 for `<type>` · **Size** S · **Status** Partially done
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Confirm an exported ride actually imports into Strava, Garmin Connect and Google Earth —
|
Confirm an exported ride actually imports into Strava, Garmin Connect and Google Earth —
|
||||||
@@ -49,3 +49,32 @@ Silently breaking the parity harness by changing export output. Update it delibe
|
|||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
GPX import into Rippr. FIT and TCX formats.
|
GPX import into Rippr. FIT and TCX formats.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
The code half is done; the verification half is explicitly not, and can't be from here.
|
||||||
|
|
||||||
|
`gpxActivityType(Activity)` maps the internal enum to GPX `<trk><type>` values chosen to
|
||||||
|
match Strava's and Garmin Connect's published import vocabularies (`motorcycling`,
|
||||||
|
`cycling`, `skateboarding`, `running`, `walking`). `Activity.other` maps to `null`, and
|
||||||
|
`gpx()` omits the element entirely rather than writing `<type/>` — an empty element would
|
||||||
|
claim "this ride has a type, and it's nothing," which isn't the same fact as "no type was
|
||||||
|
recorded." Scooter reuses `motorcycling`: GPX has no dedicated vocabulary entry for it and
|
||||||
|
that is the closer of the two categories a consumer actually offers.
|
||||||
|
|
||||||
|
No existing test needed updating, and there is no byte-identical Kotlin-comparison harness
|
||||||
|
for GPX in this repo to speak of — the parity harness described in the original port plan
|
||||||
|
compares pure-logic modules (geo, telemetry, ride statistics) against fixtures, not a live
|
||||||
|
GPX diff against a Kotlin process. `<type>` is a pure addition; the nine existing GPX tests
|
||||||
|
assert specific element counts and positions that a new sibling element doesn't disturb,
|
||||||
|
confirmed by running them unchanged. Three new tests added: `<type>` present and correct
|
||||||
|
for a mapped activity, absent (not empty) for `other`, and every `Activity` value covered
|
||||||
|
without throwing.
|
||||||
|
|
||||||
|
**Not done, and cannot be done in this environment:** the ticket's actual acceptance
|
||||||
|
criteria are entirely device/account verification — export a real ride and import it into
|
||||||
|
Strava, Garmin Connect, and Google Earth; confirm the activity type lands correctly;
|
||||||
|
document how each consumer treats `<trkseg>` boundaries at a pause. None of that is
|
||||||
|
reachable without real accounts on those services and a phone to generate a real multi-
|
||||||
|
segment ride. This ticket should be reopened for that verification pass once V3-13's
|
||||||
|
real-ride work happens — the two naturally pair, since V3-13 already requires an actual
|
||||||
|
ride to exist. `flutter analyze` clean; full suite green (257 tests, up from 254).
|
||||||
|
|||||||
@@ -21,6 +21,24 @@ String _coord(double value) => value.toStringAsFixed(7);
|
|||||||
|
|
||||||
String _num(double value) => value.toStringAsFixed(1);
|
String _num(double value) => value.toStringAsFixed(1);
|
||||||
|
|
||||||
|
/// GPX has no fixed vocabulary for `<trk><type>` -- it is free text, and every consumer
|
||||||
|
/// reads its own dialect of it. Strava and Garmin Connect both recognise the values used
|
||||||
|
/// here (checked against their published import docs, not just guessed); Google Earth
|
||||||
|
/// ignores the element entirely and only cares about the geometry. See V3-14.
|
||||||
|
///
|
||||||
|
/// [Activity.other] maps to `null`, deliberately absent rather than an empty `<type/>` --
|
||||||
|
/// an empty element is what "unlabelled" already looks like to a consumer, so writing one
|
||||||
|
/// out would claim a fact ("this element exists and is empty") that isn't true.
|
||||||
|
String? gpxActivityType(Activity activity) => switch (activity) {
|
||||||
|
Activity.motorcycle => 'motorcycling',
|
||||||
|
Activity.bicycle => 'cycling',
|
||||||
|
Activity.scooter => 'motorcycling',
|
||||||
|
Activity.skateboard => 'skateboarding',
|
||||||
|
Activity.running => 'running',
|
||||||
|
Activity.walking => 'walking',
|
||||||
|
Activity.other => null,
|
||||||
|
};
|
||||||
|
|
||||||
/// XML-escapes text destined for an attribute or element body.
|
/// XML-escapes text destined for an attribute or element body.
|
||||||
///
|
///
|
||||||
/// A trip named "Sam & Dave's" would otherwise produce malformed XML, and the failure is
|
/// A trip named "Sam & Dave's" would otherwise produce malformed XML, and the failure is
|
||||||
@@ -97,6 +115,8 @@ String gpx(
|
|||||||
b.write(' </metadata>\n');
|
b.write(' </metadata>\n');
|
||||||
b.write(' <trk>\n');
|
b.write(' <trk>\n');
|
||||||
b.write(' <name>${escapeXml(name)}</name>\n');
|
b.write(' <name>${escapeXml(name)}</name>\n');
|
||||||
|
final type = gpxActivityType(trip.activity);
|
||||||
|
if (type != null) b.write(' <type>$type</type>\n');
|
||||||
|
|
||||||
for (final segmentId in ordered) {
|
for (final segmentId in ordered) {
|
||||||
b.write(' <trkseg>\n');
|
b.write(' <trkseg>\n');
|
||||||
|
|||||||
@@ -141,6 +141,33 @@ void main() {
|
|||||||
expect(doc.findAllElements('trkpt').length, 1,
|
expect(doc.findAllElements('trkpt').length, 1,
|
||||||
reason: 'no point may be silently dropped');
|
reason: 'no point may be silently dropped');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('<type> reflects the trip activity (V3-14)', () {
|
||||||
|
final r = ride(segmentCount: 1, perSegment: 1);
|
||||||
|
final bike = trip.copyWith(activity: Activity.bicycle);
|
||||||
|
|
||||||
|
final doc = XmlDocument.parse(gpx(bike, r.segments, r.points, 'Ride'));
|
||||||
|
|
||||||
|
expect(doc.findAllElements('type').single.innerText, 'cycling');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('<type> is absent, not empty, for Activity.other (V3-14)', () {
|
||||||
|
final r = ride(segmentCount: 1, perSegment: 1);
|
||||||
|
final other = trip.copyWith(activity: Activity.other);
|
||||||
|
|
||||||
|
final doc = XmlDocument.parse(gpx(other, r.segments, r.points, 'Ride'));
|
||||||
|
|
||||||
|
expect(doc.findAllElements('type'), isEmpty,
|
||||||
|
reason: 'an empty <type/> would falsely claim the element exists');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gpxActivityType covers every activity without throwing', () {
|
||||||
|
for (final activity in Activity.values) {
|
||||||
|
// Must not throw; only Activity.other is allowed to map to null.
|
||||||
|
final type = gpxActivityType(activity);
|
||||||
|
expect(type == null, activity == Activity.other);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('geojson', () {
|
group('geojson', () {
|
||||||
|
|||||||
Reference in New Issue
Block a user