210 lines
8.4 KiB
C++
210 lines
8.4 KiB
C++
#include <catch2/catch_test_macros.hpp>
|
||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||
#include "geo/geo.h"
|
||
#include <cmath>
|
||
|
||
using namespace geo;
|
||
using Catch::Matchers::WithinAbs;
|
||
using Catch::Matchers::WithinRel;
|
||
|
||
// ── Distance ────────────────────────────────────────────────────────────────
|
||
|
||
TEST_CASE("Haversine distance: known reference values", "[geo][distance]") {
|
||
// London to Paris: ~343 km
|
||
Coord london{-0.1278, 51.5074};
|
||
Coord paris{2.3522, 48.8566};
|
||
double d = distance_km(london, paris);
|
||
REQUIRE_THAT(d, WithinRel(343.5, 0.02)); // 2% tolerance
|
||
|
||
// Same point should be zero
|
||
REQUIRE_THAT(distance_km(london, london), WithinAbs(0.0, 1e-10));
|
||
|
||
// Equatorial points 1 degree apart: ~111.32 km
|
||
Coord eq0{0, 0};
|
||
Coord eq1{1, 0};
|
||
REQUIRE_THAT(distance_km(eq0, eq1), WithinRel(111.32, 0.01));
|
||
}
|
||
|
||
TEST_CASE("Haversine distance: antipodal points", "[geo][distance]") {
|
||
// North pole to south pole: ~20015 km (half circumference)
|
||
Coord north{0, 90};
|
||
Coord south{0, -90};
|
||
double d = distance_km(north, south);
|
||
REQUIRE_THAT(d, WithinRel(20015.0, 0.01));
|
||
}
|
||
|
||
// ── BBox ────────────────────────────────────────────────────────────────────
|
||
|
||
TEST_CASE("BBox of a simple triangle", "[geo][bbox]") {
|
||
std::vector<Coord> triangle = {{0, 0}, {10, 5}, {5, 10}};
|
||
BBox b = bbox(triangle);
|
||
REQUIRE(b.minLon == 0.0);
|
||
REQUIRE(b.minLat == 0.0);
|
||
REQUIRE(b.maxLon == 10.0);
|
||
REQUIRE(b.maxLat == 10.0);
|
||
}
|
||
|
||
TEST_CASE("BBox center", "[geo][bbox]") {
|
||
BBox b{-10, -20, 10, 20};
|
||
Coord c = b.center();
|
||
REQUIRE(c.lon == 0.0);
|
||
REQUIRE(c.lat == 0.0);
|
||
}
|
||
|
||
TEST_CASE("BBox union", "[geo][bbox]") {
|
||
std::vector<BBox> boxes = {{0, 0, 5, 5}, {3, 3, 10, 10}};
|
||
BBox u = bbox_union(boxes);
|
||
REQUIRE(u.minLon == 0.0);
|
||
REQUIRE(u.minLat == 0.0);
|
||
REQUIRE(u.maxLon == 10.0);
|
||
REQUIRE(u.maxLat == 10.0);
|
||
}
|
||
|
||
TEST_CASE("BBox of empty ring returns zeros", "[geo][bbox]") {
|
||
std::vector<Coord> empty;
|
||
BBox b = bbox(empty);
|
||
REQUIRE(b.minLon == 0.0);
|
||
REQUIRE(b.maxLon == 0.0);
|
||
}
|
||
|
||
// ── Centroid ────────────────────────────────────────────────────────────────
|
||
|
||
TEST_CASE("Centroid of a square", "[geo][centroid]") {
|
||
std::vector<Coord> square = {{0, 0}, {10, 0}, {10, 10}, {0, 10}, {0, 0}};
|
||
Coord c = centroid(square);
|
||
REQUIRE_THAT(c.lon, WithinAbs(5.0, 1e-10));
|
||
REQUIRE_THAT(c.lat, WithinAbs(5.0, 1e-10));
|
||
}
|
||
|
||
TEST_CASE("Centroid handles closed ring (duplicate first/last)", "[geo][centroid]") {
|
||
// Closed triangle — first and last point are the same
|
||
std::vector<Coord> closed = {{0, 0}, {6, 0}, {3, 6}, {0, 0}};
|
||
Coord c = centroid(closed);
|
||
// Average of 3 unique points: (0+6+3)/3 = 3, (0+0+6)/3 = 2
|
||
REQUIRE_THAT(c.lon, WithinAbs(3.0, 1e-10));
|
||
REQUIRE_THAT(c.lat, WithinAbs(2.0, 1e-10));
|
||
}
|
||
|
||
// ── Area ────────────────────────────────────────────────────────────────────
|
||
|
||
TEST_CASE("Area of an equatorial 1x1 degree square", "[geo][area]") {
|
||
// ~111.32 km × ~110.57 km ≈ ~12,308 km²
|
||
std::vector<Coord> sq = {{0, 0}, {1, 0}, {1, 1}, {0, 1}, {0, 0}};
|
||
double a = area_sq_km(sq);
|
||
REQUIRE_THAT(a, WithinRel(12308.0, 0.05)); // 5% tolerance
|
||
}
|
||
|
||
TEST_CASE("Area of a zero-size polygon is zero", "[geo][area]") {
|
||
std::vector<Coord> pt = {{5, 5}};
|
||
REQUIRE(area_sq_km(pt) == 0.0);
|
||
}
|
||
|
||
// ── Point-in-polygon ────────────────────────────────────────────────────────
|
||
|
||
TEST_CASE("PIP: point inside a square", "[geo][pip]") {
|
||
std::vector<Coord> sq = {{0, 0}, {10, 0}, {10, 10}, {0, 10}, {0, 0}};
|
||
REQUIRE(point_in_polygon({5, 5}, sq) == true);
|
||
REQUIRE(point_in_polygon({1, 1}, sq) == true);
|
||
}
|
||
|
||
TEST_CASE("PIP: point outside a square", "[geo][pip]") {
|
||
std::vector<Coord> sq = {{0, 0}, {10, 0}, {10, 10}, {0, 10}, {0, 0}};
|
||
REQUIRE(point_in_polygon({-1, 5}, sq) == false);
|
||
REQUIRE(point_in_polygon({15, 5}, sq) == false);
|
||
}
|
||
|
||
TEST_CASE("PIP: point on edge is indeterminate but consistent", "[geo][pip]") {
|
||
std::vector<Coord> sq = {{0, 0}, {10, 0}, {10, 10}, {0, 10}, {0, 0}};
|
||
// Edge behavior is implementation-defined but should not crash
|
||
(void)point_in_polygon({0, 5}, sq);
|
||
(void)point_in_polygon({5, 0}, sq);
|
||
}
|
||
|
||
// ── Bearing ─────────────────────────────────────────────────────────────────
|
||
|
||
TEST_CASE("Bearing: due north", "[geo][bearing]") {
|
||
Coord a{0, 0};
|
||
Coord b{0, 10};
|
||
REQUIRE_THAT(bearing_deg(a, b), WithinAbs(0.0, 0.1));
|
||
}
|
||
|
||
TEST_CASE("Bearing: due east", "[geo][bearing]") {
|
||
Coord a{0, 0};
|
||
Coord b{10, 0};
|
||
REQUIRE_THAT(bearing_deg(a, b), WithinAbs(90.0, 0.5));
|
||
}
|
||
|
||
// ── Destination ─────────────────────────────────────────────────────────────
|
||
|
||
TEST_CASE("Destination: 100km north from equator", "[geo][destination]") {
|
||
Coord start{0, 0};
|
||
Coord dest = destination(start, 0.0, 100.0); // due north
|
||
REQUIRE_THAT(dest.lat, WithinRel(0.899, 0.02)); // ~0.9 degrees
|
||
REQUIRE_THAT(dest.lon, WithinAbs(0.0, 0.01));
|
||
}
|
||
|
||
TEST_CASE("Destination roundtrip: go 100km then measure distance", "[geo][destination]") {
|
||
Coord start{2.3522, 48.8566}; // Paris
|
||
Coord dest = destination(start, 45.0, 100.0); // 100km northeast
|
||
double d = distance_km(start, dest);
|
||
REQUIRE_THAT(d, WithinRel(100.0, 0.01)); // should be ~100km back
|
||
}
|
||
|
||
// ── Square grid ─────────────────────────────────────────────────────────────
|
||
|
||
TEST_CASE("Square grid: generates cells within bbox", "[geo][grid]") {
|
||
BBox extent{0, 0, 1, 1}; // ~111km x ~110km
|
||
auto cells = square_grid(extent, 50.0); // 50km cells → ~4 cells
|
||
REQUIRE(cells.size() >= 4);
|
||
for (const auto& c : cells) {
|
||
REQUIRE(c.lon >= extent.minLon);
|
||
REQUIRE(c.lon <= extent.maxLon);
|
||
REQUIRE(c.lat >= extent.minLat);
|
||
REQUIRE(c.lat <= extent.maxLat);
|
||
}
|
||
}
|
||
|
||
TEST_CASE("Square grid: zero cell size returns empty", "[geo][grid]") {
|
||
BBox extent{0, 0, 10, 10};
|
||
auto cells = square_grid(extent, 0.0);
|
||
REQUIRE(cells.empty());
|
||
}
|
||
|
||
// ── Hex grid ────────────────────────────────────────────────────────────────
|
||
|
||
TEST_CASE("Hex grid: generates cells within bbox", "[geo][grid]") {
|
||
BBox extent{0, 0, 1, 1};
|
||
auto cells = hex_grid(extent, 50.0);
|
||
REQUIRE(cells.size() >= 4);
|
||
for (const auto& c : cells) {
|
||
REQUIRE(c.lon >= extent.minLon);
|
||
REQUIRE(c.lon <= extent.maxLon);
|
||
REQUIRE(c.lat >= extent.minLat);
|
||
REQUIRE(c.lat <= extent.maxLat);
|
||
}
|
||
}
|
||
|
||
TEST_CASE("Hex grid: has offset rows", "[geo][grid]") {
|
||
BBox extent{0, 0, 2, 2}; // large enough for multiple rows
|
||
auto cells = hex_grid(extent, 30.0);
|
||
// Find first and second row Y values
|
||
if (cells.size() >= 3) {
|
||
// Just verify we got some cells (hex pattern is complex to validate)
|
||
REQUIRE(cells.size() > 2);
|
||
}
|
||
}
|
||
|
||
// ── Viewport estimation ─────────────────────────────────────────────────────
|
||
|
||
TEST_CASE("Viewport estimation at equator zoom 14", "[geo][viewport]") {
|
||
double sq = estimate_viewport_sq_km(0.0, 14);
|
||
// At zoom 14, equator: ~9.55 m/px → ~9.78 * 7.33 ≈ 71.7 km²
|
||
REQUIRE_THAT(sq, WithinRel(71.7, 0.15)); // 15% tolerance
|
||
}
|
||
|
||
TEST_CASE("Viewport estimation: higher zoom = smaller area", "[geo][viewport]") {
|
||
double z14 = estimate_viewport_sq_km(40.0, 14);
|
||
double z16 = estimate_viewport_sq_km(40.0, 16);
|
||
REQUIRE(z16 < z14);
|
||
}
|