use super::*;
use oxigeo_core::vector::{Feature, FeatureCollection, FeatureId};
fn ls(coords: &[(f64, f64)]) -> LineString {
LineString {
coords: coords
.iter()
.map(|(x, y)| Coordinate::new_2d(*x, *y))
.collect(),
}
}
fn poly_raw(exterior_coords: &[(f64, f64)]) -> Polygon {
Polygon {
exterior: ls(exterior_coords),
interiors: Vec::new(),
}
}
fn ccw_square(x0: f64, y0: f64, x1: f64, y1: f64) -> Polygon {
poly_raw(&[(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)])
}
fn fc_with_polygon(poly: Polygon) -> FeatureCollection {
FeatureCollection::new(vec![Feature::new(Geometry::Polygon(poly))])
}
fn fc_with_linestring(ls_geom: LineString) -> FeatureCollection {
FeatureCollection::new(vec![Feature::new(Geometry::LineString(ls_geom))])
}
#[test]
fn test_topology_checker_creation() {
let checker = TopologyChecker::new();
assert!(checker.config.check_self_intersections);
}
#[test]
fn test_invalid_coordinate_detection() {
let checker = TopologyChecker::new();
let coord = Coordinate::new_2d(f64::NAN, 0.0);
let errors = checker.validate_point(&coord, &None);
assert!(errors.is_ok());
let errors = errors.ok().unwrap_or_default();
assert!(!errors.is_empty());
assert_eq!(errors[0].error_type, TopologyErrorType::InvalidCoordinate);
}
#[test]
fn test_linestring_validation() {
let checker = TopologyChecker::new();
let linestring = LineString {
coords: vec![Coordinate::new_2d(0.0, 0.0), Coordinate::new_2d(1.0, 1.0)],
};
let errors = checker.validate_linestring(&linestring, &None);
assert!(errors.is_ok());
}
#[test]
fn test_coords_equal() {
let checker = TopologyChecker::new();
let c1 = Coordinate::new_2d(0.0, 0.0);
let c2 = Coordinate::new_2d(0.0, 0.0);
let c3 = Coordinate::new_2d(1.0, 1.0);
assert!(checker.coords_equal(&c1, &c2));
assert!(!checker.coords_equal(&c1, &c3));
}
#[test]
fn test_self_intersect_simple_x() {
let ls_geom = ls(&[(0.0, 0.0), (2.0, 2.0), (0.0, 2.0), (2.0, 0.0)]);
let result = has_self_intersection(&ls_geom);
assert!(
result.is_some(),
"Expected self-intersection to be detected"
);
let pairs = result.unwrap_or_default();
assert!(
pairs.contains(&(0, 2)),
"Expected pair (0, 2) in crossings, got: {:?}",
pairs
);
}
#[test]
fn test_self_intersect_no_intersection() {
let pts: Vec<(f64, f64)> = (0..10).map(|i| (i as f64, 0.0)).collect();
let ls_geom = ls(&pts);
let result = has_self_intersection(&ls_geom);
assert!(result.is_none(), "Straight line must not self-intersect");
}
#[test]
fn test_self_intersect_endpoint_shared_only() {
let ls_geom = ls(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)]);
let result = has_self_intersection(&ls_geom);
assert!(result.is_none(), "L-bend must not be flagged");
}
#[test]
fn test_self_intersect_collinear_overlap() {
let ls_geom = ls(&[(0.0, 0.0), (2.0, 0.0), (1.0, 0.0), (3.0, 0.0)]);
let result = has_self_intersection(&ls_geom);
assert!(result.is_some(), "Collinear overlap should be detected");
}
#[test]
fn test_check_topology_rules_polygon_orientation_violation() {
let cw_poly = poly_raw(&[(0.0, 0.0), (0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0)]);
let options = TopologyOptions::default();
let violations = check_topology_rules(&fc_with_polygon(cw_poly), &options);
let has_orient = violations.iter().any(|v| {
matches!(
v,
TopologyViolation::RingOrientation {
ring_index: 0,
expected_ccw: true,
..
}
)
});
assert!(
has_orient,
"Expected RingOrientation violation, got: {:?}",
violations
);
}
#[test]
fn test_check_topology_rules_unclosed_ring() {
let unclosed = poly_raw(&[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]);
let options = TopologyOptions::default();
let violations = check_topology_rules(&fc_with_polygon(unclosed), &options);
let has_unclosed = violations
.iter()
.any(|v| matches!(v, TopologyViolation::UnclosedRing { ring_index: 0, .. }));
assert!(
has_unclosed,
"Expected UnclosedRing violation, got: {:?}",
violations
);
}
#[test]
fn test_check_topology_rules_polygon_self_intersect_ring() {
let bowtie = poly_raw(&[(0.0, 0.0), (2.0, 2.0), (0.0, 2.0), (2.0, 0.0), (0.0, 0.0)]);
let options = TopologyOptions::default();
let violations = check_topology_rules(&fc_with_polygon(bowtie), &options);
let has_self_intersect = violations
.iter()
.any(|v| matches!(v, TopologyViolation::SelfIntersection { .. }));
assert!(
has_self_intersect,
"Expected SelfIntersection on bowtie ring, got: {:?}",
violations
);
}
#[test]
fn test_check_topology_rules_overlap_detection() {
let poly_a = ccw_square(0.0, 0.0, 2.0, 2.0);
let poly_b = ccw_square(1.0, 1.0, 3.0, 3.0);
let fc = FeatureCollection::new(vec![
Feature::with_id(FeatureId::Integer(1), Geometry::Polygon(poly_a)),
Feature::with_id(FeatureId::Integer(2), Geometry::Polygon(poly_b)),
]);
let options = TopologyOptions::default();
let violations = check_topology_rules(&fc, &options);
let overlap = violations
.iter()
.find(|v| matches!(v, TopologyViolation::Overlap { .. }));
assert!(
overlap.is_some(),
"Expected Overlap violation, got: {:?}",
violations
);
if let Some(TopologyViolation::Overlap { area, .. }) = overlap {
assert!(*area > 0.0, "Expected positive overlap area, got {}", area);
}
}
#[test]
fn test_check_topology_rules_clean_data_returns_empty() {
let clean = ccw_square(0.0, 0.0, 2.0, 2.0);
let options = TopologyOptions::default();
let violations = check_topology_rules(&fc_with_polygon(clean), &options);
assert!(
violations.is_empty(),
"Expected no violations for clean polygon, got: {:?}",
violations
);
}
#[test]
fn test_check_topology_rules_gap_detection_optional() {
let poly_a = ccw_square(0.0, 0.0, 1.0, 1.0);
let poly_b = ccw_square(1.5, 0.0, 2.5, 1.0); let fc = FeatureCollection::new(vec![
Feature::new(Geometry::Polygon(poly_a)),
Feature::new(Geometry::Polygon(poly_b)),
]);
let options_off = TopologyOptions::default();
let violations_off = check_topology_rules(&fc, &options_off);
let has_gap_off = violations_off
.iter()
.any(|v| matches!(v, TopologyViolation::Gap { .. }));
assert!(
!has_gap_off,
"Should not detect gaps when detect_gaps=false"
);
let options_on = TopologyOptions {
detect_gaps: true,
..TopologyOptions::default()
};
let violations_on = check_topology_rules(&fc, &options_on);
let _ = violations_on;
}
#[test]
fn test_check_topology_rules_1000_polygons_perf_smoke() {
let mut features = Vec::with_capacity(1000);
for row in 0..25 {
for col in 0..40 {
let x0 = col as f64 * 2.0;
let y0 = row as f64 * 2.0;
let poly = ccw_square(x0, y0, x0 + 1.0, y0 + 1.0);
features.push(Feature::new(Geometry::Polygon(poly)));
}
}
let fc = FeatureCollection::new(features);
let options = TopologyOptions::default();
let violations = check_topology_rules(&fc, &options);
let overlap_count = violations
.iter()
.filter(|v| matches!(v, TopologyViolation::Overlap { .. }))
.count();
assert!(
overlap_count == 0,
"Non-overlapping grid should produce 0 Overlap violations, got {}",
overlap_count
);
assert!(
violations.len() < 10,
"Expected < 10 violations for clean grid, got {}",
violations.len()
);
}
#[test]
fn test_check_topology_rules_linestring_self_intersect() {
let ls_geom = ls(&[(0.0, 0.0), (2.0, 2.0), (0.0, 2.0), (2.0, 0.0)]);
let fc = fc_with_linestring(ls_geom);
let options = TopologyOptions::default();
let violations = check_topology_rules(&fc, &options);
let has_si = violations
.iter()
.any(|v| matches!(v, TopologyViolation::SelfIntersection { .. }));
assert!(
has_si,
"Expected SelfIntersection for X linestring, got: {:?}",
violations
);
}
#[test]
fn test_calculate_area_subtracts_holes() {
let checker = TopologyChecker::new();
let exterior = ls(&[
(0.0, 0.0),
(10.0, 0.0),
(10.0, 10.0),
(0.0, 10.0),
(0.0, 0.0),
]);
let hole = ls(&[(1.0, 1.0), (9.0, 1.0), (9.0, 9.0), (1.0, 9.0), (1.0, 1.0)]);
let polygon = Polygon {
exterior,
interiors: vec![hole],
};
let area = checker.calculate_area(&polygon);
assert!(
(area - 36.0).abs() < 1e-9,
"expected net area 36.0, got {area}"
);
}
#[test]
fn test_calculate_area_no_holes_unchanged() {
let checker = TopologyChecker::new();
let exterior = ls(&[
(0.0, 0.0),
(10.0, 0.0),
(10.0, 10.0),
(0.0, 10.0),
(0.0, 0.0),
]);
let polygon = Polygon {
exterior,
interiors: vec![],
};
let area = checker.calculate_area(&polygon);
assert!(
(area - 100.0).abs() < 1e-9,
"expected area 100.0, got {area}"
);
}
#[test]
fn test_check_sliver_detects_thin_annulus() {
let checker = TopologyChecker::new();
let exterior = ls(&[
(0.0, 0.0),
(10.0, 0.0),
(10.0, 10.0),
(0.0, 10.0),
(0.0, 0.0),
]);
let hole = ls(&[
(0.02, 0.02),
(9.98, 0.02),
(9.98, 9.98),
(0.02, 9.98),
(0.02, 0.02),
]);
let polygon = Polygon {
exterior,
interiors: vec![hole],
};
let sliver = checker
.check_sliver(&polygon, &None)
.expect("check_sliver should succeed")
.expect("thin annulus should be flagged as a sliver");
assert!(
sliver.area < 1.0,
"expected net sliver area < 1.0, got {}",
sliver.area
);
}
#[test]
fn test_check_sliver_ignores_solid_square() {
let checker = TopologyChecker::new();
let exterior = ls(&[
(0.0, 0.0),
(10.0, 0.0),
(10.0, 10.0),
(0.0, 10.0),
(0.0, 0.0),
]);
let polygon = Polygon {
exterior,
interiors: vec![],
};
let sliver = checker
.check_sliver(&polygon, &None)
.expect("check_sliver should succeed");
assert!(sliver.is_none(), "solid square should not be a sliver");
}
#[test]
fn test_detect_line_crossings_finds_real_crossing() {
let line_a = ls(&[(0.0, 0.0), (2.0, 2.0)]);
let line_b = ls(&[(0.0, 2.0), (2.0, 0.0)]);
let fc = FeatureCollection::new(vec![
Feature::with_id(FeatureId::Integer(1), Geometry::LineString(line_a)),
Feature::with_id(FeatureId::Integer(2), Geometry::LineString(line_b)),
]);
let options = TopologyOptions::default();
let violations = check_topology_rules(&fc, &options);
assert!(
violations
.iter()
.any(|v| matches!(v, TopologyViolation::Crossing { .. })),
"two genuinely crossing linestrings must produce a Crossing violation, got: {violations:?}"
);
let rule_violations: Vec<RuleViolation> = violations
.into_iter()
.map(topology_violation_to_rule_violation)
.collect();
assert!(
rule_violations
.iter()
.any(|rv| rv.rule == TopologyRule::MustNotCross),
"Crossing violations must map to TopologyRule::MustNotCross"
);
}
#[test]
fn test_detect_line_crossings_ignores_parallel_lines() {
let line_a = ls(&[(0.0, 0.0), (2.0, 0.0)]);
let line_b = ls(&[(0.0, 1.0), (2.0, 1.0)]);
let fc = FeatureCollection::new(vec![
Feature::new(Geometry::LineString(line_a)),
Feature::new(Geometry::LineString(line_b)),
]);
let options = TopologyOptions::default();
let violations = check_topology_rules(&fc, &options);
assert!(
!violations
.iter()
.any(|v| matches!(v, TopologyViolation::Crossing { .. })),
"parallel non-touching lines must not be flagged as crossing"
);
}
#[test]
fn test_detect_line_crossings_opt_out() {
let line_a = ls(&[(0.0, 0.0), (2.0, 2.0)]);
let line_b = ls(&[(0.0, 2.0), (2.0, 0.0)]);
let fc = FeatureCollection::new(vec![
Feature::new(Geometry::LineString(line_a)),
Feature::new(Geometry::LineString(line_b)),
]);
let options = TopologyOptions {
detect_crossings: false,
..TopologyOptions::default()
};
let violations = check_topology_rules(&fc, &options);
assert!(
!violations
.iter()
.any(|v| matches!(v, TopologyViolation::Crossing { .. })),
"detect_crossings=false must suppress crossing detection"
);
}
#[test]
fn test_topology_checker_respects_disabled_overlap_rule() {
let poly_a = ccw_square(0.0, 0.0, 2.0, 2.0);
let poly_b = ccw_square(1.0, 1.0, 3.0, 3.0);
let fc = FeatureCollection::new(vec![
Feature::with_id(FeatureId::Integer(1), Geometry::Polygon(poly_a)),
Feature::with_id(FeatureId::Integer(2), Geometry::Polygon(poly_b)),
]);
let config = TopologyConfig {
topology_rules: vec![TopologyRule::MustNotHaveGaps],
..TopologyConfig::default()
};
let checker = TopologyChecker::with_config(config);
let result = checker.validate(&fc).expect("validate should succeed");
assert!(
!result
.rule_violations
.iter()
.any(|rv| rv.rule == TopologyRule::MustNotOverlap),
"disabling MustNotOverlap must actually suppress overlap violations, not silently \
report them anyway"
);
}
#[test]
fn test_topology_checker_rejects_unsupported_rule() {
for unsupported in [
TopologyRule::MustBeCoveredBy,
TopologyRule::BoundaryMustBeCoveredBy,
TopologyRule::MustBeInside,
TopologyRule::PointsMustBeCoveredByLine,
] {
let config = TopologyConfig {
topology_rules: vec![unsupported],
..TopologyConfig::default()
};
let checker = TopologyChecker::with_config(config);
let clean = ccw_square(0.0, 0.0, 2.0, 2.0);
let result = checker.validate(&fc_with_polygon(clean));
assert!(
result.is_err(),
"{unsupported:?} is not enforced by this engine and must be rejected instead \
of silently validating as if it were checked"
);
}
}
#[test]
fn test_topology_checker_accepts_all_supported_rules() {
let config = TopologyConfig {
topology_rules: vec![
TopologyRule::MustNotOverlap,
TopologyRule::MustNotHaveGaps,
TopologyRule::MustNotCross,
TopologyRule::MustNotSelfOverlap,
],
..TopologyConfig::default()
};
let checker = TopologyChecker::with_config(config);
let clean = ccw_square(0.0, 0.0, 2.0, 2.0);
let result = checker.validate(&fc_with_polygon(clean));
assert!(result.is_ok(), "supported rules must not be rejected");
}