T00 — toolchain green on both platforms. flutter doctor reports no issues. Pinned Flutter to Temurin 21 (AGP rejects the default JDK 25, same constraint as the native build) and installed Android cmdline-tools with an explicit --sdk_root, the trap already documented in the native DEVELOPMENT.md. No caches were deleted: the 16 GiB disk reading that drove the cleanup plan re-measured at 36 GiB before anything was removed. T01 — flutter create for android+ios. applicationId is com.rippr.port, not com.rippr, so the native app stays installable alongside it during the port; T27 switches it at cutover. T02 — geo/geo.dart ported from com.rippr.geo.Geo with all 23 tests, tolerances and comments carried over unchanged. Kotlin's object namespace became top-level functions; LatLon stays our own type so the pure layer never depends on flutter_map. 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));
|
||
});
|
||
});
|
||
}
|