Files
samplez/rippr-flutter-src/test/ride_accumulator_test.dart
Dylan 0bc42b2e5a Add the Rippr Flutter port: source, history bundle, and installable APK
The port runs on Android and iOS and is feature-complete; the native Android app
is superseded but kept, since it is still the only version that has recorded real
rides.

rippr-flutter-1.0-debug.apk is package com.rippr.port, deliberately different
from the native com.rippr so both install side by side. Recording the same ride
on both at once is the strongest available check that the port is faithful.

Added INSTALL.md covering both platforms. Android is a one-line adb install; iOS
has no APK equivalent and must be built and signed through Xcode with a free
Apple ID, which gives a 7-day profile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 22:37:45 -05:00

205 lines
6.3 KiB
Dart

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