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();
}

View File

@@ -0,0 +1,114 @@
/// Running totals for the ride in progress.
///
/// Ported from `com.rippr.Accumulator`.
///
/// Distance and elevation both need consecutive-row differences, so they are folded in
/// here as points are written and persisted onto the `Trip` row once per flush. That
/// lets the recording screen show live numbers without rescanning the point table.
///
/// The values this produces are an *estimate*. They are overwritten by
/// [computeSummary] when the trip completes, which is also why this reuses
/// [ElevationAccumulator] rather than reimplementing hysteresis: a naive version
/// measured 1498 m of phantom climbing on a parked bike.
///
/// Not thread-safe — the writer loop owns it, under the recorder's write lock.
library;
import 'dart:math' as math;
import '../domain/models.dart';
import '../geo/geo.dart';
import '../stats/ride_statistics.dart';
import '../telemetry/telemetry.dart';
class Accumulator {
/// The last point of the *previous* batch.
///
/// Without this, distance restarts at every flush boundary and silently under-reports
/// by roughly one inter-point hop per batch — a few percent, which nobody notices
/// until they compare against an odometer.
TrackPoint? _lastPoint;
ElevationAccumulator _elevation = ElevationAccumulator();
double _restoredElevationM = 0.0;
double _distanceM = 0.0;
int _movingMillis = 0;
double _maxSpeedKmh = 0.0;
int _pointCount = 0;
double get distanceM => _distanceM;
int get movingMillis => _movingMillis;
double get maxSpeedKmh => _maxSpeedKmh;
int get pointCount => _pointCount;
void fold(List<TrackPoint> points) {
for (final point in points) {
_pointCount++;
_maxSpeedKmh = math.max(_maxSpeedKmh, point.speedKmh);
_elevation.add(point.altitudeM);
final prev = _lastPoint;
// Distance and moving time only accrue *within* a segment. Points either side of
// a pause can be kilometres apart and that gap was never ridden.
if (prev != null && prev.segmentId == point.segmentId) {
_distanceM += haversineMeters(
prev.latitude,
prev.longitude,
point.latitude,
point.longitude,
);
final dt = point.timestamp - prev.timestamp;
if (dt >= 1 &&
dt <= maxSampleGapMillis &&
point.speedKmh >= speedNoiseFloorKmh) {
_movingMillis += dt;
}
}
_lastPoint = point;
}
}
/// Elevation gain including the run still in progress.
///
/// Reads a snapshot rather than mutating, so it is safe to call on every flush while
/// recording continues.
double elevationGain() =>
_restoredElevationM + _elevation.gainIncludingPending();
/// Seeds from a trip already in progress after the process is restarted.
void restore({
required double distanceM,
required int movingMillis,
required double maxSpeedKmh,
required double elevationGainM,
required int pointCount,
TrackPoint? lastPoint,
}) {
_distanceM = distanceM;
_movingMillis = movingMillis;
_maxSpeedKmh = maxSpeedKmh;
_pointCount = pointCount;
_lastPoint = lastPoint;
// Elevation cannot be resumed mid-run from a scalar total, so the accumulator
// restarts and only gain from here on is added to the persisted figure. The
// authoritative recomputation at trip completion corrects any drift.
_elevation = ElevationAccumulator();
_restoredElevationM = elevationGainM;
}
void reset() {
_lastPoint = null;
_elevation = ElevationAccumulator();
_distanceM = 0.0;
_movingMillis = 0;
_maxSpeedKmh = 0.0;
_pointCount = 0;
_restoredElevationM = 0.0;
}
/// Clears the cross-segment anchor so a resumed ride does not span the pause.
void onSegmentChanged() {
_lastPoint = null;
}
}

View File

@@ -106,8 +106,10 @@ const int _smoothingWindow = 15;
/// Dart's `Map` is insertion-ordered, matching Kotlin's `groupBy` (a `LinkedHashMap`).
/// Order matters: the segment iteration order determines nothing statistically, but
/// keeping it identical makes the two implementations diffable.
LinkedHashMap<int, List<TrackPoint>> _groupBySegment(List<TrackPoint> points) {
final grouped = LinkedHashMap<int, List<TrackPoint>>();
Map<int, List<TrackPoint>> _groupBySegment(List<TrackPoint> points) {
// A Dart map literal is already a LinkedHashMap, so insertion order is preserved --
// matching Kotlin's groupBy.
final grouped = <int, List<TrackPoint>>{};
for (final p in points) {
(grouped[p.segmentId] ??= <TrackPoint>[]).add(p);
}