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:
204
test/ride_accumulator_test.dart
Normal file
204
test/ride_accumulator_test.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
207
test/ride_export_test.dart
Normal file
207
test/ride_export_test.dart
Normal file
@@ -0,0 +1,207 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/export/ride_export.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
/// Ported from `com.rippr.export.RideExportTest`.
|
||||
///
|
||||
/// Like the Kotlin original, this parses the output with a **real** parser rather than
|
||||
/// matching substrings. Structural validity is the whole point: a malformed export fails
|
||||
/// invisibly until some other tool rejects the file.
|
||||
void main() {
|
||||
var nextId = 1;
|
||||
|
||||
const trip = Trip(
|
||||
id: 1,
|
||||
startedAt: 1700000000000,
|
||||
endedAt: 1700000600000,
|
||||
state: TripState.completed,
|
||||
distanceM: 12345.6,
|
||||
maxSpeedKmh: 118.4,
|
||||
);
|
||||
|
||||
TrackPoint point(int segmentId, int i) => TrackPoint(
|
||||
id: nextId++,
|
||||
tripId: 1,
|
||||
segmentId: segmentId,
|
||||
timestamp: 1700000000000 + i * 1000,
|
||||
latitude: 51.0447 + i * 0.0001,
|
||||
longitude: -114.0719,
|
||||
speedKmh: 72.0,
|
||||
altitudeM: 1045.0,
|
||||
);
|
||||
|
||||
({List<Segment> segments, List<TrackPoint> points, int total}) ride(
|
||||
{required int segmentCount, required int perSegment}) {
|
||||
final segments = List.generate(
|
||||
segmentCount,
|
||||
(i) => Segment(id: i + 1, tripId: 1, startedAt: 0, endedAt: 1),
|
||||
);
|
||||
final points = <TrackPoint>[
|
||||
for (final s in segments)
|
||||
for (var i = 0; i < perSegment; i++) point(s.id, i),
|
||||
];
|
||||
return (
|
||||
segments: segments,
|
||||
points: points,
|
||||
total: segmentCount * perSegment
|
||||
);
|
||||
}
|
||||
|
||||
setUp(() => nextId = 1);
|
||||
|
||||
group('gpx', () {
|
||||
test('gpx is well-formed and carries the expected structure', () {
|
||||
final r = ride(segmentCount: 2, perSegment: 5);
|
||||
|
||||
final doc =
|
||||
XmlDocument.parse(gpx(trip, r.segments, r.points, 'Morning loop'));
|
||||
|
||||
expect(doc.rootElement.name.local, 'gpx');
|
||||
expect(doc.rootElement.getAttribute('version'), '1.1');
|
||||
expect(doc.findAllElements('trk').length, 1);
|
||||
});
|
||||
|
||||
test('one trkseg per segment, so pauses survive the round trip', () {
|
||||
final r = ride(segmentCount: 3, perSegment: 4);
|
||||
|
||||
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
||||
|
||||
expect(doc.findAllElements('trkseg').length, 3);
|
||||
});
|
||||
|
||||
test('every raw point is exported with no decimation', () {
|
||||
final r = ride(segmentCount: 2, perSegment: 250);
|
||||
|
||||
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
||||
|
||||
expect(doc.findAllElements('trkpt').length, r.total,
|
||||
reason: 'decimation must never reach an export');
|
||||
});
|
||||
|
||||
test('timestamps are ISO 8601 in UTC', () {
|
||||
final r = ride(segmentCount: 1, perSegment: 1);
|
||||
|
||||
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
||||
final time = doc.findAllElements('time').elementAt(1).innerText;
|
||||
|
||||
expect(time, '2023-11-14T22:13:20Z');
|
||||
expect(time.endsWith('Z'), isTrue, reason: 'must be UTC with a Z suffix');
|
||||
});
|
||||
|
||||
test('coordinates and elevation land on the right attributes', () {
|
||||
final r = ride(segmentCount: 1, perSegment: 1);
|
||||
|
||||
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
||||
final trkpt = doc.findAllElements('trkpt').first;
|
||||
|
||||
expect(trkpt.getAttribute('lat'), '51.0447000');
|
||||
expect(trkpt.getAttribute('lon'), '-114.0719000');
|
||||
expect(doc.findAllElements('ele').first.innerText, '1045.0');
|
||||
});
|
||||
|
||||
test('speed is exported in metres per second', () {
|
||||
final r = ride(segmentCount: 1, perSegment: 1);
|
||||
|
||||
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
||||
|
||||
// 72 km/h is exactly 20 m/s.
|
||||
expect(doc.findAllElements('speed').first.innerText, '20.0');
|
||||
});
|
||||
|
||||
test('a hostile trip name still produces valid xml', () {
|
||||
final r = ride(segmentCount: 1, perSegment: 2);
|
||||
const nasty = 'Sam & Dave\'s <ride> "fast" ';
|
||||
|
||||
// Would throw if the name were interpolated unescaped.
|
||||
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, nasty));
|
||||
|
||||
expect(doc.findAllElements('name').first.innerText, nasty);
|
||||
});
|
||||
|
||||
test('escaping covers every xml metacharacter', () {
|
||||
expect(escapeXml('&<>"\''), '&<>"'');
|
||||
});
|
||||
|
||||
test('an empty ride still produces a valid document', () {
|
||||
final doc = XmlDocument.parse(gpx(trip, const [], const [], 'Empty'));
|
||||
|
||||
expect(doc.rootElement.name.local, 'gpx');
|
||||
expect(doc.findAllElements('trkpt').length, 0);
|
||||
expect(doc.findAllElements('trkseg').length, 0);
|
||||
});
|
||||
|
||||
test('points whose segment is missing are still exported', () {
|
||||
final orphan = [point(99, 0)];
|
||||
|
||||
final doc = XmlDocument.parse(gpx(trip, const [], orphan, 'Ride'));
|
||||
|
||||
expect(doc.findAllElements('trkpt').length, 1,
|
||||
reason: 'no point may be silently dropped');
|
||||
});
|
||||
});
|
||||
|
||||
group('geojson', () {
|
||||
test('geojson coordinates are longitude first', () {
|
||||
final r = ride(segmentCount: 1, perSegment: 1);
|
||||
|
||||
final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride'))
|
||||
as Map<String, dynamic>;
|
||||
final coords = ((((json['features'] as List).first
|
||||
as Map<String, dynamic>)['geometry']
|
||||
as Map<String, dynamic>)['coordinates'] as List).first as List;
|
||||
|
||||
expect(coords[0] as double, closeTo(-114.0719, 1e-6),
|
||||
reason: 'longitude must come first');
|
||||
expect(coords[1] as double, closeTo(51.0447, 1e-6));
|
||||
expect(coords[2] as double, closeTo(1045.0, 1e-6));
|
||||
});
|
||||
|
||||
test('geojson has one LineString feature per segment', () {
|
||||
final r = ride(segmentCount: 3, perSegment: 4);
|
||||
|
||||
final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride'))
|
||||
as Map<String, dynamic>;
|
||||
final features = json['features'] as List;
|
||||
|
||||
expect(features.length, 3);
|
||||
expect(
|
||||
((features.first as Map<String, dynamic>)['geometry']
|
||||
as Map<String, dynamic>)['type'],
|
||||
'LineString');
|
||||
});
|
||||
|
||||
test('geojson exports every raw point', () {
|
||||
final r = ride(segmentCount: 2, perSegment: 120);
|
||||
|
||||
final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride'))
|
||||
as Map<String, dynamic>;
|
||||
var exported = 0;
|
||||
for (final f in json['features'] as List) {
|
||||
exported += (((f as Map<String, dynamic>)['geometry']
|
||||
as Map<String, dynamic>)['coordinates'] as List).length;
|
||||
}
|
||||
|
||||
expect(exported, r.total);
|
||||
});
|
||||
|
||||
test('a hostile name is escaped in json too', () {
|
||||
final r = ride(segmentCount: 1, perSegment: 1);
|
||||
|
||||
final json = jsonDecode(
|
||||
geoJson(trip, r.segments, r.points, r'He said "go" \ fast'))
|
||||
as Map<String, dynamic>;
|
||||
|
||||
expect((json['properties'] as Map<String, dynamic>)['name'],
|
||||
r'He said "go" \ fast');
|
||||
});
|
||||
|
||||
test('geojson of an empty ride parses', () {
|
||||
final json = jsonDecode(geoJson(trip, const [], const [], 'Empty'))
|
||||
as Map<String, dynamic>;
|
||||
expect((json['features'] as List).length, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user