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