Files
rippr/test/ride_statistics_test.dart
Dylan 4af4e3411a 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>
2026-08-14 20:59:35 -05:00

303 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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