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