Files
samplez/rippr-flutter-src/test/geo_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
7.2 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 '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));
});
});
}