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>
205 lines
7.2 KiB
Dart
205 lines
7.2 KiB
Dart
import 'dart:math' as math;
|
||
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:rippr/src/geo/geo.dart';
|
||
|
||
/// Ported from `com.rippr.geo.GeoTest` (native Android v2.0.1).
|
||
///
|
||
/// Every case and every tolerance is carried over unchanged. Where the Kotlin
|
||
/// suite encoded a hard-won correction — notably the Calgary–Edmonton
|
||
/// great-circle figure, which was verified by hand after the *test* turned out to
|
||
/// be wrong rather than the code — that correction travels with it.
|
||
void main() {
|
||
const calgary = LatLon(51.0447, -114.0719);
|
||
const edmonton = LatLon(53.5461, -113.4938);
|
||
|
||
List<LatLon> line(int n) =>
|
||
List.generate(n, (i) => LatLon(51.0 + i * 0.001, -114.0));
|
||
|
||
group('haversine', () {
|
||
test('matches a known long-distance reference', () {
|
||
// Calgary to Edmonton, great-circle. Cross-checked by hand: 2.5014° of
|
||
// latitude is ~278.1 km, and 0.5781° of longitude at ~52.3° is ~39.3 km,
|
||
// giving sqrt(278.1² + 39.3²) ≈ 280.9 km. (Road distance is ~300 km — not
|
||
// the same thing.)
|
||
final d = haversineBetween(calgary, edmonton);
|
||
expect(d, closeTo(280900.0, 280900.0 * 0.005));
|
||
});
|
||
|
||
test('one degree of latitude is about 111 km anywhere', () {
|
||
final atEquator = haversineMeters(0.0, 0.0, 1.0, 0.0);
|
||
final atLatitude60 = haversineMeters(60.0, 0.0, 61.0, 0.0);
|
||
expect(atEquator, closeTo(111195.0, 200.0));
|
||
expect(atLatitude60, closeTo(atEquator, 200.0),
|
||
reason: 'latitude spacing does not vary with longitude');
|
||
});
|
||
|
||
test('a degree of longitude shrinks with latitude', () {
|
||
final atEquator = haversineMeters(0.0, 0.0, 0.0, 1.0);
|
||
final atLatitude60 = haversineMeters(60.0, 0.0, 60.0, 1.0);
|
||
// cos(60°) = 0.5, so it should be about half.
|
||
expect(atLatitude60, closeTo(atEquator / 2, 500.0));
|
||
});
|
||
|
||
test('identical points are zero distance', () {
|
||
expect(haversineBetween(calgary, calgary), closeTo(0.0, 1e-9));
|
||
});
|
||
|
||
test('short hops between consecutive fixes stay accurate', () {
|
||
// ~11 m north, the scale of a 2 Hz fix at road speed.
|
||
final d = haversineMeters(51.0447, -114.0719, 51.04480, -114.0719);
|
||
expect(d, closeTo(11.1, 0.5));
|
||
});
|
||
|
||
test('distance is symmetric', () {
|
||
expect(
|
||
haversineBetween(calgary, edmonton),
|
||
closeTo(haversineBetween(edmonton, calgary), 1e-6),
|
||
);
|
||
});
|
||
|
||
test('handles an antimeridian crossing without exploding', () {
|
||
// 0.02° apart in longitude, straddling +/-180.
|
||
final d = haversineMeters(0.0, 179.99, 0.0, -179.99);
|
||
expect(d, lessThan(3000), reason: 'expected a short hop, got $d m');
|
||
});
|
||
});
|
||
|
||
group('douglas-peucker', () {
|
||
test('a straight line collapses to its endpoints', () {
|
||
final simplified = simplify(line(50), 5.0);
|
||
expect(simplified.length, 2);
|
||
expect(simplified.first, line(50).first);
|
||
expect(simplified.last, line(50).last);
|
||
});
|
||
|
||
test('endpoints always survive', () {
|
||
final zigzag = List.generate(
|
||
30,
|
||
(i) => LatLon(51.0 + i * 0.001, -114.0 + (i % 2 == 0 ? 0.0 : 0.002)),
|
||
);
|
||
final simplified = simplify(zigzag, 5.0);
|
||
expect(simplified.first, zigzag.first);
|
||
expect(simplified.last, zigzag.last);
|
||
});
|
||
|
||
test('deviation above epsilon is preserved', () {
|
||
// Middle point sits ~110 m off the line between its neighbours.
|
||
const points = [
|
||
LatLon(51.000, -114.0),
|
||
LatLon(51.001, -113.999),
|
||
LatLon(51.002, -114.0),
|
||
];
|
||
expect(simplify(points, 5.0).length, 3);
|
||
});
|
||
|
||
test('deviation below epsilon is dropped', () {
|
||
const points = [
|
||
LatLon(51.000, -114.0),
|
||
LatLon(51.001, -114.00001), // under a metre off the line
|
||
LatLon(51.002, -114.0),
|
||
];
|
||
expect(simplify(points, 5.0).length, 2);
|
||
});
|
||
|
||
test('epsilon of zero returns the input untouched', () {
|
||
final input = line(20);
|
||
expect(simplify(input, 0.0), input);
|
||
});
|
||
|
||
test('inputs of two or fewer are returned as-is', () {
|
||
expect(simplify(const <LatLon>[], 5.0).length, 0);
|
||
expect(simplify(line(1), 5.0).length, 1);
|
||
expect(simplify(line(2), 5.0).length, 2);
|
||
});
|
||
|
||
test('handles a full ride without stack overflow and stays fast', () {
|
||
// Three hours at 2 Hz, the real scale this has to survive.
|
||
final ride = List.generate(
|
||
21600,
|
||
(i) => LatLon(51.0 + i * 0.00001, -114.0 + math.sin(i / 100.0) * 0.001),
|
||
);
|
||
final started = DateTime.now();
|
||
final simplified = simplify(ride, 5.0);
|
||
final elapsedMs = DateTime.now().difference(started).inMilliseconds;
|
||
|
||
expect(simplified.length, lessThan(ride.length),
|
||
reason: 'simplification should reduce the point count');
|
||
expect(simplified.first, ride.first);
|
||
expect(simplified.last, ride.last);
|
||
expect(elapsedMs, lessThan(1000),
|
||
reason: 'took ${elapsedMs}ms, expected well under a second');
|
||
});
|
||
});
|
||
|
||
group('perpendicular distance', () {
|
||
test('a point on the segment has zero perpendicular distance', () {
|
||
final d = perpendicularDistanceMeters(
|
||
const LatLon(51.001, -114.0),
|
||
const LatLon(51.000, -114.0),
|
||
const LatLon(51.002, -114.0),
|
||
);
|
||
expect(d.abs(), lessThan(0.01), reason: 'expected ~0, got $d');
|
||
});
|
||
|
||
test('a point beyond the end measures to the endpoint, not the infinite line',
|
||
() {
|
||
// Directly past the segment's end along the same bearing.
|
||
final d = perpendicularDistanceMeters(
|
||
const LatLon(51.003, -114.0),
|
||
const LatLon(51.000, -114.0),
|
||
const LatLon(51.002, -114.0),
|
||
);
|
||
expect(d, closeTo(111.0, 5.0),
|
||
reason: 'should be the ~111 m to the endpoint');
|
||
});
|
||
|
||
test('a degenerate segment falls back to point distance', () {
|
||
const a = LatLon(51.0, -114.0);
|
||
final d = perpendicularDistanceMeters(const LatLon(51.001, -114.0), a, a);
|
||
expect(d, closeTo(111.0, 5.0));
|
||
});
|
||
});
|
||
|
||
group('bounds', () {
|
||
test('bounds is null for an empty path', () {
|
||
expect(bounds(const <LatLon>[]), isNull);
|
||
});
|
||
|
||
test('bounds spans mixed-sign coordinates', () {
|
||
final b = bounds(const [
|
||
LatLon(-10.0, -20.0),
|
||
LatLon(30.0, 40.0),
|
||
LatLon(5.0, 0.0),
|
||
])!;
|
||
expect(b.minLat, closeTo(-10.0, 1e-9));
|
||
expect(b.maxLat, closeTo(30.0, 1e-9));
|
||
expect(b.minLon, closeTo(-20.0, 1e-9));
|
||
expect(b.maxLon, closeTo(40.0, 1e-9));
|
||
expect(b.centerLat, closeTo(10.0, 1e-9));
|
||
});
|
||
|
||
test('a stationary ride reports degenerate bounds', () {
|
||
final b = bounds(List.filled(10, calgary))!;
|
||
expect(b.isDegenerate, isTrue,
|
||
reason: 'map auto-fit must special-case this');
|
||
});
|
||
|
||
test('a real path is not degenerate', () {
|
||
expect(bounds(line(10))!.isDegenerate, isFalse);
|
||
});
|
||
});
|
||
|
||
group('path length', () {
|
||
test('path length sums consecutive hops', () {
|
||
final points = line(11); // ten hops of 0.001 degrees latitude
|
||
expect(pathLengthMeters(points), closeTo(10 * 111.19, 20.0));
|
||
});
|
||
|
||
test('path length of fewer than two points is zero', () {
|
||
expect(pathLengthMeters(const <LatLon>[]), closeTo(0.0, 1e-9));
|
||
expect(pathLengthMeters(const [calgary]), closeTo(0.0, 1e-9));
|
||
});
|
||
});
|
||
}
|