Complete Phase 1: accumulator, exports, and full parity coverage

T05 — ride_accumulator.dart with the cross-batch anchor intact (12 tests).
T06 — ride_export.dart, GPX 1.1 and GeoJSON (15 tests), parsed with real
parsers rather than substring matching.
T07 — parity harness extended to cover export byte output.

GPX and GeoJSON are byte-identical across Kotlin and Dart: same length, same
FNV hash, including the escaped hostile name and all %.7f/%.1f formatting.

Two harness bugs found and fixed while building it. String.hashCode is not
comparable across Java and Dart, so text comparison used FNV-1a instead. And a
missing jar let a failed Kotlin compile pass as a green run -- run.sh now checks
for the artifact and exits non-zero, the same failure mode as the v2 sweep that
hid a compile error behind /dev/null.

Phase 1 done: 79 tests passing, analyze clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 01:08:30 -05:00
parent 4af4e3411a
commit 398157b1f5
12 changed files with 918 additions and 6 deletions

View File

@@ -0,0 +1,183 @@
/// Serialises a ride to formats other tools understand.
///
/// Ported from `com.rippr.export.RideExport`. Pure string generation with no platform
/// dependency, so it is fully unit-testable.
///
/// **Never decimate here.** Simplification exists only in the map render path; an export
/// must carry every raw point. The tests assert exact counts to catch any leak.
library;
import '../domain/models.dart';
/// ISO 8601 in UTC. Fix timestamps are already UTC epoch millis, so no zone maths.
String _time(int epochMillis) {
final d = DateTime.fromMillisecondsSinceEpoch(epochMillis, isUtc: true);
String two(int v) => v.toString().padLeft(2, '0');
return '${d.year.toString().padLeft(4, '0')}-${two(d.month)}-${two(d.day)}'
'T${two(d.hour)}:${two(d.minute)}:${two(d.second)}Z';
}
String _coord(double value) => value.toStringAsFixed(7);
String _num(double value) => value.toStringAsFixed(1);
/// 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
/// invisible until an import rejects the file.
String escapeXml(String text) {
final b = StringBuffer();
for (final c in text.split('')) {
switch (c) {
case '&':
b.write('&amp;');
case '<':
b.write('&lt;');
case '>':
b.write('&gt;');
case '"':
b.write('&quot;');
case "'":
b.write('&apos;');
default:
b.write(c);
}
}
return b.toString();
}
/// Segment order first, then any points whose segment is missing, so nothing is dropped.
List<int> _orderedSegmentIds(
List<Segment> segments, Map<int, List<TrackPoint>> bySegment) {
final ids = <int>[
for (final s in segments)
if (bySegment.containsKey(s.id)) s.id,
];
for (final id in bySegment.keys) {
if (!segments.any((s) => s.id == id)) ids.add(id);
}
return ids;
}
Map<int, List<TrackPoint>> _groupBySegment(List<TrackPoint> points) {
final grouped = <int, List<TrackPoint>>{};
for (final p in points) {
(grouped[p.segmentId] ??= <TrackPoint>[]).add(p);
}
return grouped;
}
/// GPX 1.1.
///
/// One `<trkseg>` per [Segment] — this is exactly how GPX represents a recording gap, so
/// pauses survive the round-trip into Strava, Garmin, or Google Earth instead of becoming
/// a straight line across town.
///
/// Speed goes in `<extensions>`; it is not part of core GPX 1.1, and consumers that do
/// not understand the extension ignore it, which is the correct degradation.
String gpx(
Trip trip,
List<Segment> segments,
List<TrackPoint> points,
String name,
) {
final bySegment = _groupBySegment(points);
final ordered = _orderedSegmentIds(segments, bySegment);
final b = StringBuffer();
b.write('<?xml version="1.0" encoding="UTF-8"?>\n');
b.write(
'<gpx version="1.1" creator="Rippr" xmlns="http://www.topografix.com/GPX/1/1">\n');
b.write(' <metadata>\n');
b.write(' <name>${escapeXml(name)}</name>\n');
b.write(' <time>${_time(trip.startedAt)}</time>\n');
b.write(' </metadata>\n');
b.write(' <trk>\n');
b.write(' <name>${escapeXml(name)}</name>\n');
for (final segmentId in ordered) {
b.write(' <trkseg>\n');
for (final p in bySegment[segmentId]!) {
b.write(
' <trkpt lat="${_coord(p.latitude)}" lon="${_coord(p.longitude)}">\n');
b.write(' <ele>${_num(p.altitudeM)}</ele>\n');
b.write(' <time>${_time(p.timestamp)}</time>\n');
b.write(' <extensions>\n');
// GPX speed is metres per second.
b.write(' <speed>${_num(p.speedKmh / 3.6)}</speed>\n');
b.write(' </extensions>\n');
b.write(' </trkpt>\n');
}
b.write(' </trkseg>\n');
}
b.write(' </trk>\n');
b.write('</gpx>\n');
return b.toString();
}
/// GeoJSON `FeatureCollection`, one `LineString` per segment.
///
/// Coordinates are `[lon, lat, ele]` — **longitude first**, the opposite order to GPX and
/// the most common mistake in GeoJSON output.
String geoJson(
Trip trip,
List<Segment> segments,
List<TrackPoint> points,
String name,
) {
final bySegment = _groupBySegment(points);
final ordered = _orderedSegmentIds(segments, bySegment);
final features = ordered.map((segmentId) {
final coords = bySegment[segmentId]!
.map((p) =>
'[${_coord(p.longitude)}, ${_coord(p.latitude)}, ${_num(p.altitudeM)}]')
.join(', ');
return ''' {
"type": "Feature",
"properties": { "segment_id": $segmentId },
"geometry": { "type": "LineString", "coordinates": [$coords] }
}''';
}).join(',\n');
return '''{
"type": "FeatureCollection",
"properties": {
"name": ${_jsonString(name)},
"started_at": ${trip.startedAt},
"distance_m": ${_num(trip.distanceM)},
"max_speed_kmh": ${_num(trip.maxSpeedKmh)}
},
"features": [
$features
]
}
''';
}
String _jsonString(String text) {
final b = StringBuffer('"');
for (final c in text.split('')) {
switch (c) {
case '"':
b.write('\\"');
case r'\':
b.write(r'\\');
case '\n':
b.write(r'\n');
case '\r':
b.write(r'\r');
case '\t':
b.write(r'\t');
default:
if (c.codeUnitAt(0) < 0x20) {
b.write('\\u${c.codeUnitAt(0).toRadixString(16).padLeft(4, '0')}');
} else {
b.write(c);
}
}
}
b.write('"');
return b.toString();
}