Port Telemetry, Format, RideStatistics; add cross-language parity harness

T03 — telemetry.dart, format.dart, live_telemetry.dart, plus pure domain models
(Trip/Segment/TrackPoint/RideStats) with no persistence dependency, so Drift can
map to them in T08 rather than the domain depending on the database.

T04 — ride_statistics.dart including ElevationAccumulator, ported structurally
faithfully: moving average, reversal hysteresis, gainIncludingPending, and the
finish() reconciliation against lastRaw.

T07 (early, because T04 forced it) — tool/parity/ drives identical fixtures
through the real Kotlin files and the Dart port, then diffs. Result: every value
byte-identical, including noisy_gain=38.959594555022136 to the last digit. The
sole difference is run_avg_speed, where Kotlin's 32-bit Float widens to double
with artefacts Dart's binary64 does not reproduce. Documented, not papered over.

That harness settled a real question. The ported elevation test failed at 50.9m
against Kotlin's 35m bound, which looked like a porting bug. It was not: Kotlin's
and Dart's Random(42) are different streams. On a shared LCG fixture both produce
39.0m -- which would also fail Kotlin's own bound. The native guard passes on seed
luck rather than on a property of the algorithm. The Dart test now uses the shared
LCG, asserts bit-equality with Kotlin, and sets its bound from measured behaviour
(25 seeds spanned 24.7-46.7m).

52 tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 20:59:35 -05:00
parent f0f9ed8c34
commit 4af4e3411a
14 changed files with 1368 additions and 1 deletions

View File

@@ -0,0 +1,302 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/stats/ride_statistics.dart';
/// Ported from `com.rippr.stats.RideStatisticsTest`.
///
/// ## One case cannot be a literal port: the noise fixture
///
/// The Kotlin original seeds `kotlin.random.Random(42)`. Dart's `Random(42)` is a
/// different generator and produces a different sequence, so this suite cannot assert
/// the same *number* — only the same *bound*. That is fine here, because the assertion
/// was always a regression guard rather than an accuracy claim: a naive implementation
/// reported 1498 m over a parked bike, and anything in that neighbourhood must fail.
///
/// T07's cross-language parity harness must therefore drive elevation from a shared,
/// language-independent fixture rather than from either language's RNG.
/// A deterministic linear congruential generator, implemented identically in Kotlin and
/// Dart so both languages can be driven by the *same* noise sequence. Neither language's
/// built-in `Random` can do this — that is the whole reason this exists.
///
/// Dart ints are 64-bit two's complement on the VM and multiplication wraps, matching
/// Kotlin's `Long`. The Kotlin twin lives in `tool/parity/main.kt`.
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 lon = -114.0,
double speed = 50.0,
double alt = 1000.0,
}) =>
TrackPoint(
id: nextId++,
tripId: 1,
segmentId: segmentId,
timestamp: ts,
latitude: lat,
longitude: lon,
speedKmh: speed,
altitudeM: alt,
);
/// A straight northward run: [n] fixes one second apart, 0.0001° (~11 m) each.
List<TrackPoint> straightRun(int n,
{int segmentId = 1, int startTs = 0, double speed = 40.0}) =>
List.generate(
n,
(i) => point(
segmentId: segmentId,
ts: startTs + i * 1000,
lat: 51.0 + i * 0.0001,
speed: speed),
);
setUp(() => nextId = 1);
group('distance', () {
test('distance matches the summed haversine hops', () {
final summary = computeSummary(straightRun(11));
// Ten hops of 0.0001 degrees latitude, ~11.12 m each.
expect(summary.distanceM, closeTo(111.2, 3.0));
});
test('distance never spans a pause', () {
// Two segments 100 km apart — a rider who trailered between them.
final first = straightRun(5, segmentId: 1, startTs: 0);
final second = List.generate(
5,
(i) => point(segmentId: 2, ts: 600000 + i * 1000, lat: 52.0 + i * 0.0001),
);
final summary = computeSummary([...first, ...second]);
// Two runs of ~44.5 m each; the ~111 km gap must not appear.
expect(summary.distanceM, lessThan(200.0),
reason: 'gap leaked into distance: ${summary.distanceM} m');
});
test('single point has zero distance and no NaN', () {
final summary = computeSummary([point(ts: 0)]);
expect(summary.distanceM, closeTo(0.0, 1e-9));
expect(summary.avgMovingSpeedKmh, closeTo(0.0, 1e-9));
expect(summary.pointCount, 1);
});
test('empty input returns the zero summary', () {
expect(computeSummary(const []), RideSummary.empty);
});
});
group('elevation — the regression that matters most', () {
test('stationary noisy altitude yields near-zero elevation gain', () {
// A parked bike for ten minutes with realistic +/-8 m GPS altitude wander.
//
// A naive "sum every delta over the threshold" implementation reported **1498 m**
// of climbing on this shape of input. Smoothing plus reversal hysteresis is what
// brings it down. This bound is a regression guard against that class of failure,
// not an accuracy claim.
//
// ## Why a hand-rolled LCG instead of Random(42)
//
// The Kotlin original seeded `kotlin.random.Random(42)`, which produces a
// different stream from Dart's `Random(42)` — so the two suites could never be
// compared, only vaguely trusted. Driving both from the identical LCG in
// `tool/parity/` proved the port is exact: **38.959594555022136 m in both
// languages, to the last digit.**
//
// That also exposed something about the native app: on a shared fixture this
// algorithm yields ~39 m, which would **fail Kotlin's own 35 m bound**. The
// Kotlin test passes on seed luck, not on a property of the algorithm. A sweep of
// 25 Dart seeds ranged 24.7–46.7 m (median 36). The bound below is therefore set
// from measured behaviour with headroom, rather than inherited from a lucky draw.
final rng = _Lcg(42);
final points = List.generate(
600,
(i) => point(
ts: i * 1000, speed: 0.0, alt: 1000.0 + (rng.nextDouble() * 16.0 - 8.0)),
);
final summary = computeSummary(points);
expect(summary.elevationGainM, closeTo(38.959594555022136, 1e-9),
reason: 'must stay bit-identical to the Kotlin implementation');
expect(summary.elevationGainM, lessThan(60.0),
reason:
'phantom climbing: ${summary.elevationGainM} m over a parked bike');
});
test('a genuine climb is recorded', () {
final points = List.generate(
101,
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, alt: 1000.0 + i),
);
final summary = computeSummary(points);
expect(summary.elevationGainM, closeTo(100.0, 5.0));
expect(summary.elevationLossM, closeTo(0.0, 5.0));
});
test('a descent counts as loss, not gain', () {
final points = List.generate(
101,
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, alt: 1100.0 - i),
);
final summary = computeSummary(points);
expect(summary.elevationLossM, closeTo(100.0, 5.0));
expect(summary.elevationGainM, closeTo(0.0, 5.0));
});
test('an out-and-back records both gain and loss', () {
final up = List.generate(51, (i) => point(ts: i * 1000, alt: 1000.0 + i));
final down =
List.generate(51, (i) => point(ts: 51000 + i * 1000, alt: 1050.0 - i));
final summary = computeSummary([...up, ...down]);
expect(summary.elevationGainM, closeTo(50.0, 5.0));
expect(summary.elevationLossM, closeTo(50.0, 5.0));
});
});
group('moving vs elapsed time', () {
test('moving time excludes time below the speed noise floor', () {
final moving = List.generate(60, (i) => point(ts: i * 1000, speed: 40.0));
final stopped =
List.generate(60, (i) => point(ts: (60 + i) * 1000, speed: 0.0));
final summary = computeSummary([...moving, ...stopped]);
expect(summary.movingMillis.toDouble(), closeTo(59000.0, 2000.0),
reason: '~59 s of movement');
expect(summary.elapsedMillis, 119000);
expect(summary.stoppedMillis, greaterThan(55000));
});
test('a long gap between fixes does not inject phantom moving time', () {
// Two fixes two minutes apart — a tunnel. Without the cap this would count as
// 120 s of movement at the last known speed.
final points = [
point(ts: 0, speed: 90.0),
point(ts: 120000, lat: 51.001, speed: 90.0),
];
expect(computeSummary(points).movingMillis, 0);
});
test('elapsed time prefers closed segment spans when available', () {
final points = straightRun(5, startTs: 1000);
const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 10000)];
expect(computeSummary(points, segments: segments).elapsedMillis, 10000);
});
test('elapsed falls back to point timestamps when a segment is still open', () {
final points = straightRun(5, startTs: 1000); // 1000..5000
const segments = [Segment(id: 1, tripId: 1, startedAt: 0)];
expect(computeSummary(points, segments: segments).elapsedMillis, 4000);
});
});
group('average speed', () {
test('average speed uses distance over moving time, not the sample mean', () {
// 100 fixes 1 s apart, each 0.0001 deg (~11.12 m) => ~1101 m over ~99 s
// => ~40 km/h.
final points = List.generate(
100,
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, speed: 40.0),
);
final summary = computeSummary(points);
expect(summary.avgMovingSpeedKmh, closeTo(40.0, 2.0));
});
test('average speed is zero rather than NaN when nothing moved', () {
final points = List.generate(10, (i) => point(ts: i * 1000, speed: 0.0));
final summary = computeSummary(points);
expect(summary.avgMovingSpeedKmh, closeTo(0.0, 1e-9));
expect(summary.avgMovingSpeedKmh.isNaN, isFalse,
reason: 'NaN would render literally on screen');
});
test('max speed is the highest sample', () {
final points = [
point(ts: 0, speed: 40.0),
point(ts: 1000, speed: 118.4),
point(ts: 2000, speed: 60.0),
];
expect(computeSummary(points).maxSpeedKmh, closeTo(118.4, 0.001));
});
});
group('histogram', () {
test('histogram measures time in band, not sample count', () {
final points = [
point(ts: 0, speed: 5.0),
point(ts: 1000, speed: 15.0), // 1 s in 10-20
point(ts: 2000, speed: 15.0), // 1 s in 10-20
point(ts: 3000, speed: 95.0), // 1 s in 90-100
];
final buckets = speedHistogram(points, bucketKmh: 10);
expect(buckets.firstWhere((b) => b.fromKmh == 10).millis, 2000);
expect(buckets.firstWhere((b) => b.fromKmh == 90).millis, 1000);
});
test('histogram is empty for degenerate input', () {
expect(speedHistogram(const []), isEmpty);
expect(speedHistogram([point(ts: 0)]), isEmpty);
});
});
group('elevation profile', () {
test('profile plots altitude against cumulative distance', () {
final points = List.generate(
5,
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, alt: 1000.0 + i * 10),
);
final profile = elevationProfile(points);
expect(profile.length, 5);
expect(profile.first.distanceM, closeTo(0.0, 1e-9));
expect(profile.last.altitudeM, closeTo(1040.0, 1e-9));
expect(profile.last.distanceM, greaterThan(profile.first.distanceM),
reason: 'distance must increase');
});
test('profile does not accrue distance across a pause', () {
final first = List.generate(
3, (i) => point(segmentId: 1, ts: i * 1000, lat: 51.0 + i * 0.0001));
final second = List.generate(
3,
(i) => point(segmentId: 2, ts: 600000 + i * 1000, lat: 52.0 + i * 0.0001),
);
final profile = elevationProfile([...first, ...second]);
expect(profile.last.distanceM, lessThan(200.0),
reason:
'the 111 km gap leaked into the profile: ${profile.last.distanceM}');
});
test('profile downsamples a long ride', () {
final points = List.generate(
21600,
(i) => point(ts: i * 500, lat: 51.0 + i * 0.00001, alt: 1000.0),
);
final profile = elevationProfile(points, maxSamples: 200);
expect(profile.length, lessThanOrEqualTo(201),
reason: 'expected ~200 samples, got ${profile.length}');
expect(profile.last.altitudeM, closeTo(points.last.altitudeM, 1e-9));
});
test('profile handles empty and single-point input', () {
expect(elevationProfile(const []), isEmpty);
expect(elevationProfile([point(ts: 0)]).length, 1);
});
});
}