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

@@ -157,3 +157,123 @@ Two lessons, both echoing v2's harness troubles:
- **A killed background job still reports exit code 0.** Both jobs "completed - **A killed background job still reports exit code 0.** Both jobs "completed
successfully" *because they were killed*. Never read a success code from a process you successfully" *because they were killed*. Never read a success code from a process you
terminated — re-run it cleanly. terminated — re-run it cleanly.
---
## T03 — Telemetry, formatting, ephemeral state · **complete**
`telemetry.dart` (msToKmh, sanitizeSpeedKmh, isUsableFix, formatDuration, encodeBatch),
`ui/format.dart`, `telemetry/live_telemetry.dart`. **8 tests.**
Also added `domain/models.dart` — `Trip`, `Segment`, `TrackPoint`, `TripState`,
`RideStats` as plain Dart with **no persistence dependency**. Drift will map *to* these
in T08 rather than the domain depending on the database. This is the Dart equivalent of
the discipline that made the Kotlin logic testable on the JVM.
**Kotlin `Float` becomes Dart `double`.** Dart has no float32. Widening is the right
call — a shim would be friction for sub-millimetre precision on GPS-derived values — but
it means speed-derived values cannot be compared bit-for-bit. See T07 for the measured
consequence.
**`Format` builds its `DateFormat` per call**, unlike the Kotlin original which captured
`Locale.getDefault()` once at class-init. Doing the same in Dart would freeze the format
for the process lifetime and ignore a locale change.
---
## T04 — Ride statistics · **complete**
`stats/ride_statistics.dart` including `ElevationAccumulator`. **21 tests.**
Ported structurally faithfully — moving average, reversal hysteresis,
`gainIncludingPending()`, and the `finish()` reconciliation against `lastRaw`. Nothing
was "improved" during translation, per the plan.
### The failure that proved the port correct
The ported elevation test failed: **50.86 m against Kotlin's 35 m bound.** That looks
exactly like a porting bug in the hardest code in the project.
It was not. The two languages' `Random(42)` are different streams. Rather than tune the
bound blind — which `docs/v3/BACKLOG.md` explicitly warns against — the question was
settled by building the T07 harness early and driving **both implementations from one
shared LCG**:
```
noisy_gain = 38.959594555022136 ← Kotlin
noisy_gain = 38.959594555022136 ← Dart
```
Bit-identical. The port is exact.
**This also found something about the native app.** On the shared fixture the algorithm
yields ~39 m, which would **fail Kotlin's own 35 m bound**. The native test passes on
seed luck, not on a property of the algorithm. A sweep of 25 Dart seeds spanned
24.7–46.7 m (median 36). The Dart test now uses the shared LCG, asserts bit-equality
with Kotlin, and sets its bound from measured behaviour with headroom.
> Worth carrying back to the native repo if it is ever revived: that guard is weaker
> than it looks.
---
## T05 — Live accumulator · **complete**
`recording/ride_accumulator.dart`. **12 tests, green on the first run.**
The cross-batch anchor is intact — `distance across many small batches matches one big
batch` is the guard, and its absence under-reports distance by a few percent invisibly.
---
## T06 — Export writers · **complete**
`export/ride_export.dart`. **15 tests, green on the first run.** Parsed with a real XML
parser and `jsonDecode`, not substring matching, exactly as the Kotlin suite did.
---
## T07 — Cross-language parity harness · **complete**
`tool/parity/` — `run.sh`, `main.kt` (Kotlin oracle), `probe.dart`. Run it with
`JAVA_HOME` set to a 17–21 JDK; needs `kotlinc` (`brew install kotlin`, ~95 MB).
It copies `Geo.kt`, `RideStatistics.kt` and `RideExport.kt` **verbatim** from the native
repo and compiles them against minimal stand-ins for the Room-annotated holders and one
`Telemetry` constant. The files under test are never reimplemented.
### Result
Every key byte-identical across 20 fixtures, including:
| | |
|---|---|
| `noisy_gain` | `38.959594555022136` — the elevation accumulator, to the last digit |
| `simplify_count` | 218 of 21,600 points, identical Douglas–Peucker decisions |
| `gpx_len` / `gpx_fnv` | GPX output byte-identical, including the escaped hostile name |
| `geojson_len` / `geojson_fnv` | GeoJSON byte-identical |
**The single accepted difference** is `run_avg_speed`: `40.030228` (Kotlin `Float`) vs
`40.03022888407912` (Dart `double`) — precisely the divergence predicted in T03. The
harness prints this explanation on failure so a future reader is not left guessing.
### Two traps hit while building it
**`String.hashCode` is not comparable across languages.** The first version compared
Java's and Dart's hashes of the GPX output — which would have "failed" forever for no
reason. Replaced with an FNV-1a implemented identically in both.
**A missing jar silently passed as success.** When `RideExport.kt` was not yet copied,
compilation failed, `java -jar` errored, and the pipeline continued. `run.sh` now checks
for the jar and exits non-zero. This is the same class of bug as the v2 sweep that
returned four identical results because a compile error hid behind `/dev/null` — the
comment in `run.sh` says so explicitly.
---
## Phase 1 complete
**79 tests passing, `flutter analyze` clean.** ~890 lines of Kotlin logic ported, with
its ~965 lines of tests, and proven equivalent rather than assumed equivalent.
Next: **T08 (Drift schema)**, the first task that touches persistence.

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`). /// Dart's `Map` is insertion-ordered, matching Kotlin's `groupBy` (a `LinkedHashMap`).
/// Order matters: the segment iteration order determines nothing statistically, but /// Order matters: the segment iteration order determines nothing statistically, but
/// keeping it identical makes the two implementations diffable. /// keeping it identical makes the two implementations diffable.
LinkedHashMap<int, List<TrackPoint>> _groupBySegment(List<TrackPoint> points) { Map<int, List<TrackPoint>> _groupBySegment(List<TrackPoint> points) {
final grouped = LinkedHashMap<int, List<TrackPoint>>(); // 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) { for (final p in points) {
(grouped[p.segmentId] ??= <TrackPoint>[]).add(p); (grouped[p.segmentId] ??= <TrackPoint>[]).add(p);
} }

View File

@@ -1213,7 +1213,7 @@ packages:
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml: xml:
dependency: transitive dependency: "direct dev"
description: description:
name: xml name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"

View File

@@ -65,6 +65,7 @@ dev_dependencies:
drift_dev: ^2.34.5 drift_dev: ^2.34.5
build_runner: ^2.16.0 build_runner: ^2.16.0
mocktail: ^1.0.5 mocktail: ^1.0.5
xml: ^6.6.1
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec

View File

@@ -0,0 +1,204 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/recording/ride_accumulator.dart';
import 'package:rippr/src/stats/ride_statistics.dart';
/// Ported from `com.rippr.AccumulatorTest`.
///
/// As in `ride_statistics_test.dart`, the one RNG-driven case uses the shared LCG rather
/// than either language's `Random`, so the fixture is identical across implementations.
class _Lcg {
_Lcg(this._s);
int _s;
double nextDouble() {
_s = _s * 6364136223846793005 + 1442695040888963407;
final bits = (_s >>> 11) & ((1 << 53) - 1);
return bits / (1 << 53);
}
}
void main() {
var nextId = 1;
TrackPoint point({
int segmentId = 1,
required int ts,
double lat = 51.0,
double speed = 40.0,
double alt = 1000.0,
}) =>
TrackPoint(
id: nextId++,
tripId: 1,
segmentId: segmentId,
timestamp: ts,
latitude: lat,
longitude: -114.0,
speedKmh: speed,
altitudeM: alt,
);
List<TrackPoint> run(int n,
{int segmentId = 1, int startTs = 0, double startLat = 51.0}) =>
List.generate(
n,
(i) => point(
segmentId: segmentId,
ts: startTs + i * 1000,
lat: startLat + i * 0.0001),
);
setUp(() => nextId = 1);
group('the cross-batch anchor — the subtle bug this class exists to avoid', () {
test('distance across many small batches matches one big batch', () {
final points = run(100);
final batched = Accumulator();
for (var i = 0; i < points.length; i += 7) {
batched.fold(points.sublist(i, (i + 7).clamp(0, points.length)));
}
final single = Accumulator()..fold(points);
expect(batched.distanceM, closeTo(single.distanceM, 1e-6),
reason:
'losing the anchor between flushes silently under-reports distance');
});
test('accumulated distance agrees with the batch statistics', () {
final points = run(100);
final acc = Accumulator();
for (var i = 0; i < points.length; i += 25) {
acc.fold(points.sublist(i, (i + 25).clamp(0, points.length)));
}
final summary = computeSummary(points);
expect(acc.distanceM, closeTo(summary.distanceM, 0.5));
expect(acc.movingMillis, summary.movingMillis);
expect(acc.maxSpeedKmh, closeTo(summary.maxSpeedKmh, 1e-6));
expect(acc.pointCount, summary.pointCount);
});
});
group('segment boundaries', () {
test('distance does not span a segment boundary', () {
// Second segment starts a full degree of latitude away — a trailered gap.
final acc = Accumulator();
acc.fold(run(5, segmentId: 1, startLat: 51.0));
acc.fold(run(5, segmentId: 2, startTs: 600000, startLat: 52.0));
expect(acc.distanceM, lessThan(200.0),
reason: 'the ~111 km gap leaked into distance: ${acc.distanceM} m');
});
test('onSegmentChanged clears the anchor even within one batch stream', () {
final acc = Accumulator();
acc.fold(run(5, segmentId: 1, startLat: 51.0));
acc.onSegmentChanged();
acc.fold(run(5, segmentId: 1, startTs: 600000, startLat: 52.0));
expect(acc.distanceM, lessThan(200.0),
reason: 'anchor was not cleared: ${acc.distanceM} m');
});
});
group('moving time', () {
test('moving time ignores samples below the speed noise floor', () {
final acc = Accumulator();
acc.fold(List.generate(30, (i) => point(ts: i * 1000, speed: 40.0)));
acc.fold(
List.generate(30, (i) => point(ts: (30 + i) * 1000, speed: 0.0)));
expect(acc.movingMillis, 29000);
});
test('a long gap between fixes does not inject phantom moving time', () {
final acc = Accumulator();
acc.fold([
point(ts: 0, speed: 90.0),
point(ts: 120000, lat: 51.001, speed: 90.0),
]);
expect(acc.movingMillis, 0);
});
});
group('elevation', () {
test('elevation gain is visible before the climb ends', () {
// A steady ascent that never turns back down. Reading the committed total alone
// would report zero, so the live figure has to include the pending run.
final acc = Accumulator();
acc.fold(List.generate(101, (i) => point(ts: i * 1000, alt: 1000.0 + i)));
expect(acc.elevationGain(), closeTo(100.0, 6.0));
});
test('a parked bike does not accumulate phantom climbing', () {
final rng = _Lcg(7);
final acc = Accumulator();
acc.fold(List.generate(
600,
(i) => point(
ts: i * 1000,
speed: 0.0,
alt: 1000.0 + (rng.nextDouble() * 16.0 - 8.0)),
));
expect(acc.elevationGain(), lessThan(60.0),
reason: 'phantom climbing: ${acc.elevationGain()} m');
});
});
group('restore', () {
test('restore continues from persisted totals rather than starting over', () {
final acc = Accumulator();
acc.restore(
distanceM: 5000.0,
movingMillis: 300000,
maxSpeedKmh: 95.0,
elevationGainM: 120.0,
pointCount: 600,
lastPoint: null,
);
acc.fold(run(11));
expect(acc.distanceM, greaterThan(5000.0), reason: 'distance went backwards');
expect(acc.distanceM, closeTo(5111.2, 5.0));
expect(acc.pointCount, 611);
expect(acc.maxSpeedKmh, closeTo(95.0, 1e-6));
expect(acc.elevationGain(), greaterThanOrEqualTo(120.0),
reason: 'persisted elevation was lost');
});
test('restore keeps a higher persisted max speed', () {
final acc = Accumulator();
acc.restore(
distanceM: 0.0,
movingMillis: 0,
maxSpeedKmh: 150.0,
elevationGainM: 0.0,
pointCount: 0,
lastPoint: null,
);
acc.fold(run(5));
expect(acc.maxSpeedKmh, closeTo(150.0, 1e-6));
});
});
test('reset clears everything', () {
final acc = Accumulator();
acc.fold(run(20));
acc.reset();
expect(acc.distanceM, closeTo(0.0, 1e-9));
expect(acc.movingMillis, 0);
expect(acc.maxSpeedKmh, closeTo(0.0, 1e-9));
expect(acc.pointCount, 0);
expect(acc.elevationGain(), closeTo(0.0, 1e-9));
});
test('folding an empty batch is a no-op', () {
final acc = Accumulator();
acc.fold(const []);
expect(acc.pointCount, 0);
expect(acc.distanceM, closeTo(0.0, 1e-9));
});
}

207
test/ride_export_test.dart Normal file
View File

@@ -0,0 +1,207 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/export/ride_export.dart';
import 'package:xml/xml.dart';
/// Ported from `com.rippr.export.RideExportTest`.
///
/// Like the Kotlin original, this parses the output with a **real** parser rather than
/// matching substrings. Structural validity is the whole point: a malformed export fails
/// invisibly until some other tool rejects the file.
void main() {
var nextId = 1;
const trip = Trip(
id: 1,
startedAt: 1700000000000,
endedAt: 1700000600000,
state: TripState.completed,
distanceM: 12345.6,
maxSpeedKmh: 118.4,
);
TrackPoint point(int segmentId, int i) => TrackPoint(
id: nextId++,
tripId: 1,
segmentId: segmentId,
timestamp: 1700000000000 + i * 1000,
latitude: 51.0447 + i * 0.0001,
longitude: -114.0719,
speedKmh: 72.0,
altitudeM: 1045.0,
);
({List<Segment> segments, List<TrackPoint> points, int total}) ride(
{required int segmentCount, required int perSegment}) {
final segments = List.generate(
segmentCount,
(i) => Segment(id: i + 1, tripId: 1, startedAt: 0, endedAt: 1),
);
final points = <TrackPoint>[
for (final s in segments)
for (var i = 0; i < perSegment; i++) point(s.id, i),
];
return (
segments: segments,
points: points,
total: segmentCount * perSegment
);
}
setUp(() => nextId = 1);
group('gpx', () {
test('gpx is well-formed and carries the expected structure', () {
final r = ride(segmentCount: 2, perSegment: 5);
final doc =
XmlDocument.parse(gpx(trip, r.segments, r.points, 'Morning loop'));
expect(doc.rootElement.name.local, 'gpx');
expect(doc.rootElement.getAttribute('version'), '1.1');
expect(doc.findAllElements('trk').length, 1);
});
test('one trkseg per segment, so pauses survive the round trip', () {
final r = ride(segmentCount: 3, perSegment: 4);
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
expect(doc.findAllElements('trkseg').length, 3);
});
test('every raw point is exported with no decimation', () {
final r = ride(segmentCount: 2, perSegment: 250);
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
expect(doc.findAllElements('trkpt').length, r.total,
reason: 'decimation must never reach an export');
});
test('timestamps are ISO 8601 in UTC', () {
final r = ride(segmentCount: 1, perSegment: 1);
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
final time = doc.findAllElements('time').elementAt(1).innerText;
expect(time, '2023-11-14T22:13:20Z');
expect(time.endsWith('Z'), isTrue, reason: 'must be UTC with a Z suffix');
});
test('coordinates and elevation land on the right attributes', () {
final r = ride(segmentCount: 1, perSegment: 1);
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
final trkpt = doc.findAllElements('trkpt').first;
expect(trkpt.getAttribute('lat'), '51.0447000');
expect(trkpt.getAttribute('lon'), '-114.0719000');
expect(doc.findAllElements('ele').first.innerText, '1045.0');
});
test('speed is exported in metres per second', () {
final r = ride(segmentCount: 1, perSegment: 1);
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
// 72 km/h is exactly 20 m/s.
expect(doc.findAllElements('speed').first.innerText, '20.0');
});
test('a hostile trip name still produces valid xml', () {
final r = ride(segmentCount: 1, perSegment: 2);
const nasty = 'Sam & Dave\'s <ride> "fast" ';
// Would throw if the name were interpolated unescaped.
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, nasty));
expect(doc.findAllElements('name').first.innerText, nasty);
});
test('escaping covers every xml metacharacter', () {
expect(escapeXml('&<>"\''), '&amp;&lt;&gt;&quot;&apos;');
});
test('an empty ride still produces a valid document', () {
final doc = XmlDocument.parse(gpx(trip, const [], const [], 'Empty'));
expect(doc.rootElement.name.local, 'gpx');
expect(doc.findAllElements('trkpt').length, 0);
expect(doc.findAllElements('trkseg').length, 0);
});
test('points whose segment is missing are still exported', () {
final orphan = [point(99, 0)];
final doc = XmlDocument.parse(gpx(trip, const [], orphan, 'Ride'));
expect(doc.findAllElements('trkpt').length, 1,
reason: 'no point may be silently dropped');
});
});
group('geojson', () {
test('geojson coordinates are longitude first', () {
final r = ride(segmentCount: 1, perSegment: 1);
final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride'))
as Map<String, dynamic>;
final coords = ((((json['features'] as List).first
as Map<String, dynamic>)['geometry']
as Map<String, dynamic>)['coordinates'] as List).first as List;
expect(coords[0] as double, closeTo(-114.0719, 1e-6),
reason: 'longitude must come first');
expect(coords[1] as double, closeTo(51.0447, 1e-6));
expect(coords[2] as double, closeTo(1045.0, 1e-6));
});
test('geojson has one LineString feature per segment', () {
final r = ride(segmentCount: 3, perSegment: 4);
final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride'))
as Map<String, dynamic>;
final features = json['features'] as List;
expect(features.length, 3);
expect(
((features.first as Map<String, dynamic>)['geometry']
as Map<String, dynamic>)['type'],
'LineString');
});
test('geojson exports every raw point', () {
final r = ride(segmentCount: 2, perSegment: 120);
final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride'))
as Map<String, dynamic>;
var exported = 0;
for (final f in json['features'] as List) {
exported += (((f as Map<String, dynamic>)['geometry']
as Map<String, dynamic>)['coordinates'] as List).length;
}
expect(exported, r.total);
});
test('a hostile name is escaped in json too', () {
final r = ride(segmentCount: 1, perSegment: 1);
final json = jsonDecode(
geoJson(trip, r.segments, r.points, r'He said "go" \ fast'))
as Map<String, dynamic>;
expect((json['properties'] as Map<String, dynamic>)['name'],
r'He said "go" \ fast');
});
test('geojson of an empty ride parses', () {
final json = jsonDecode(geoJson(trip, const [], const [], 'Empty'))
as Map<String, dynamic>;
expect((json['features'] as List).length, 0);
});
});
}

View File

@@ -3,6 +3,9 @@ import com.rippr.data.Segment
import com.rippr.geo.Geo import com.rippr.geo.Geo
import com.rippr.geo.LatLon import com.rippr.geo.LatLon
import com.rippr.stats.RideStatistics import com.rippr.stats.RideStatistics
import com.rippr.data.Trip
import com.rippr.data.TripState
import com.rippr.export.RideExport
// A deterministic LCG implemented identically in Kotlin and Dart, so both languages // A deterministic LCG implemented identically in Kotlin and Dart, so both languages
// see the SAME noise sequence. Neither language's built-in Random can do this. // see the SAME noise sequence. Neither language's built-in Random can do this.
@@ -14,6 +17,14 @@ class Lcg(private var s: Long) {
} }
} }
// FNV-1a 32-bit. String.hashCode() differs between Java and Dart, so it cannot be used
// to compare text across languages -- this can.
fun fnv1a(s: String): Long {
var h = 2166136261L
for (ch in s) { h = h xor ch.code.toLong(); h = (h * 16777619L) and 0xFFFFFFFFL }
return h
}
fun p(seg: Long, ts: Long, lat: Double, lon: Double, sp: Float, alt: Double, id: Long) = fun p(seg: Long, ts: Long, lat: Double, lon: Double, sp: Float, alt: Double, id: Long) =
TrackPoint(id = id, tripId = 1, segmentId = seg, timestamp = ts, TrackPoint(id = id, tripId = 1, segmentId = seg, timestamp = ts,
latitude = lat, longitude = lon, speedKmh = sp, altitudeM = alt) latitude = lat, longitude = lon, speedKmh = sp, altitudeM = alt)
@@ -55,5 +66,19 @@ fun main() {
val hist = RideStatistics.speedHistogram(run, 10) val hist = RideStatistics.speedHistogram(run, 10)
out("hist_buckets", hist.size) out("hist_buckets", hist.size)
// --- export byte output -------------------------------------------------
val etrip = Trip(id = 1, startedAt = 1_700_000_000_000L, endedAt = 1_700_000_600_000L,
state = TripState.COMPLETED, distanceM = 12_345.6, maxSpeedKmh = 118.4f)
val esegs = listOf(Segment(id = 1, tripId = 1, startedAt = 0, endedAt = 1),
Segment(id = 2, tripId = 1, startedAt = 2, endedAt = 3))
val epts = esegs.flatMap { sg -> (0 until 5).map {
p(sg.id, 1_700_000_000_000L + it * 1000L, 51.0447 + it * 0.0001, -114.0719, 72f, 1045.0, it.toLong() + 1) } }
val g = RideExport.gpx(etrip, esegs, epts, "Sam & Dave's <ride>")
val j = RideExport.geoJson(etrip, esegs, epts, "Sam & Dave's <ride>")
out("gpx_len", g.length)
out("gpx_fnv", fnv1a(g))
out("geojson_len", j.length)
out("geojson_fnv", fnv1a(j))
out("hist_first_millis", hist.first().millis) out("hist_first_millis", hist.first().millis)
} }

View File

@@ -1,8 +1,10 @@
// ignore_for_file: avoid_print -- this is a command-line harness; stdout is the output.
// Dart side of the cross-language parity harness (T07). // Dart side of the cross-language parity harness (T07).
// Must stay fixture-for-fixture identical to tool/parity/main.kt. // Must stay fixture-for-fixture identical to tool/parity/main.kt.
import 'dart:math' as math; import 'dart:math' as math;
import 'package:rippr/src/domain/models.dart'; import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/export/ride_export.dart';
import 'package:rippr/src/geo/geo.dart'; import 'package:rippr/src/geo/geo.dart';
import 'package:rippr/src/stats/ride_statistics.dart'; import 'package:rippr/src/stats/ride_statistics.dart';
@@ -18,6 +20,17 @@ class Lcg {
} }
} }
/// FNV-1a 32-bit. Dart's String.hashCode differs from Java's, so it cannot be used to
/// compare text across languages -- this can.
int fnv1a(String s) {
var h = 2166136261;
for (final c in s.codeUnits) {
h ^= c;
h = (h * 16777619) & 0xFFFFFFFF;
}
return h;
}
TrackPoint p(int seg, int ts, double lat, double lon, double sp, double alt, int id) => TrackPoint p(int seg, int ts, double lat, double lon, double sp, double alt, int id) =>
TrackPoint(id: id, tripId: 1, segmentId: seg, timestamp: ts, TrackPoint(id: id, tripId: 1, segmentId: seg, timestamp: ts,
latitude: lat, longitude: lon, speedKmh: sp, altitudeM: alt); latitude: lat, longitude: lon, speedKmh: sp, altitudeM: alt);
@@ -61,4 +74,29 @@ void main() {
final hist = speedHistogram(run, bucketKmh: 10); final hist = speedHistogram(run, bucketKmh: 10);
out('hist_buckets', hist.length); out('hist_buckets', hist.length);
out('hist_first_millis', hist.first.millis); out('hist_first_millis', hist.first.millis);
// --- export byte output -------------------------------------------------
const etrip = Trip(
id: 1,
startedAt: 1700000000000,
endedAt: 1700000600000,
state: TripState.completed,
distanceM: 12345.6,
maxSpeedKmh: 118.4);
const esegs = [
Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1),
Segment(id: 2, tripId: 1, startedAt: 2, endedAt: 3),
];
final epts = <TrackPoint>[
for (final sg in esegs)
for (var i = 0; i < 5; i++)
p(sg.id, 1700000000000 + i * 1000, 51.0447 + i * 0.0001, -114.0719, 72.0,
1045.0, i + 1),
];
final g = gpx(etrip, esegs, epts, "Sam & Dave's <ride>");
final j = geoJson(etrip, esegs, epts, "Sam & Dave's <ride>");
out('gpx_len', g.length);
out('gpx_fnv', fnv1a(g));
out('geojson_len', j.length);
out('geojson_fnv', fnv1a(j));
} }

View File

@@ -27,11 +27,20 @@ fi
# so the algorithm files compile without androidx or org.json on the classpath. # so the algorithm files compile without androidx or org.json on the classpath.
cp "$NATIVE/app/src/main/java/com/rippr/geo/Geo.kt" "$WORK/" cp "$NATIVE/app/src/main/java/com/rippr/geo/Geo.kt" "$WORK/"
cp "$NATIVE/app/src/main/java/com/rippr/stats/RideStatistics.kt" "$WORK/" cp "$NATIVE/app/src/main/java/com/rippr/stats/RideStatistics.kt" "$WORK/"
cp "$NATIVE/app/src/main/java/com/rippr/export/RideExport.kt" "$WORK/"
cp "$HERE/stubs.kt" "$HERE/telemetry_stub.kt" "$HERE/main.kt" "$WORK/" cp "$HERE/stubs.kt" "$HERE/telemetry_stub.kt" "$HERE/main.kt" "$WORK/"
echo "== compiling Kotlin oracle ==" echo "== compiling Kotlin oracle =="
( cd "$WORK" && kotlinc Geo.kt RideStatistics.kt stubs.kt telemetry_stub.kt main.kt \ ( cd "$WORK" && kotlinc Geo.kt RideStatistics.kt RideExport.kt stubs.kt \
-include-runtime -d parity.jar 2>&1 | grep -v '^warning:' || true ) telemetry_stub.kt main.kt -include-runtime -d parity.jar 2>&1 \
| grep -v '^warning:' || true )
# A missing jar means compilation failed. Never let that pass as a green run -- a v2
# sweep hid exactly this behind a redirect and reported four identical results.
if [ ! -f "$WORK/parity.jar" ]; then
echo "Kotlin oracle failed to compile (see errors above)." >&2
exit 1
fi
JAVA_BIN="${JAVA_HOME:+$JAVA_HOME/bin/}java" JAVA_BIN="${JAVA_HOME:+$JAVA_HOME/bin/}java"
"$JAVA_BIN" -jar "$WORK/parity.jar" | grep '=' | sort > "$WORK/kotlin.txt" "$JAVA_BIN" -jar "$WORK/parity.jar" | grep '=' | sort > "$WORK/kotlin.txt"

View File

@@ -8,3 +8,12 @@ data class TrackPoint(
data class Segment( data class Segment(
val id: Long = 0, val tripId: Long, val startedAt: Long, val endedAt: Long? = null, val id: Long = 0, val tripId: Long, val startedAt: Long, val endedAt: Long? = null,
) { val isOpen: Boolean get() = endedAt == null } ) { val isOpen: Boolean get() = endedAt == null }
enum class TripState { RECORDING, PAUSED, COMPLETED }
data class Trip(
val id: Long = 0, val startedAt: Long, val endedAt: Long? = null,
val name: String? = null, val state: TripState = TripState.RECORDING,
val distanceM: Double = 0.0, val movingMillis: Long = 0,
val maxSpeedKmh: Float = 0f, val elevationGainM: Double = 0.0,
val pointCount: Int = 0,
)