Add unit tests
This commit is contained in:
parent
55598aaaa0
commit
5fe192d25a
9 changed files with 512 additions and 1037 deletions
|
|
@ -4,4 +4,4 @@ mod property;
|
|||
|
||||
pub use poi::{POICategoryGroup, POIData};
|
||||
pub use postcodes::PostcodeData;
|
||||
pub use property::{compute_feature_stats, precompute_h3, Histogram, PropertyData};
|
||||
pub use property::{precompute_h3, Histogram, PropertyData};
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ mod metrics;
|
|||
mod og_middleware;
|
||||
pub mod parsing;
|
||||
mod routes;
|
||||
#[cfg(test)]
|
||||
mod semantic_tests;
|
||||
mod state;
|
||||
pub mod utils;
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,23 @@ pub fn parse_bounds(bounds_str: &str) -> Result<(f64, f64, f64, f64), (StatusCod
|
|||
));
|
||||
}
|
||||
|
||||
Ok((parts[0], parts[1], parts[2], parts[3]))
|
||||
let (south, west, north, east) = (parts[0], parts[1], parts[2], parts[3]);
|
||||
|
||||
// Validate that bounds are not inverted
|
||||
if south > north {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Invalid bounds: south ({}) must be <= north ({})", south, north),
|
||||
));
|
||||
}
|
||||
if west > east {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Invalid bounds: west ({}) must be <= east ({})", west, east),
|
||||
));
|
||||
}
|
||||
|
||||
Ok((south, west, north, east))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -76,8 +92,14 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn parse_bounds_valid() {
|
||||
assert_eq!(parse_bounds("1.0,2.0,3.0,4.0").unwrap(), (1.0, 2.0, 3.0, 4.0));
|
||||
assert_eq!(parse_bounds("-51.5, -0.1, 51.6, 0.2").unwrap(), (-51.5, -0.1, 51.6, 0.2));
|
||||
assert_eq!(
|
||||
parse_bounds("1.0,2.0,3.0,4.0").unwrap(),
|
||||
(1.0, 2.0, 3.0, 4.0)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_bounds("-51.5, -0.1, 51.6, 0.2").unwrap(),
|
||||
(-51.5, -0.1, 51.6, 0.2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -88,6 +110,14 @@ mod tests {
|
|||
assert!(parse_bounds("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bounds_inverted_rejected() {
|
||||
// south > north is rejected
|
||||
assert!(parse_bounds("52.0,-0.5,51.0,0.5").is_err());
|
||||
// west > east is rejected
|
||||
assert!(parse_bounds("51.0,0.5,52.0,-0.5").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h3_cell_bounds_applies_buffer() {
|
||||
let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap();
|
||||
|
|
@ -102,39 +132,143 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn h3_cell_bounds_returns_degrees_not_radians() {
|
||||
// Cell "8928308280fffff" is in San Francisco area (~37.77°N, ~-122.4°W)
|
||||
let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap();
|
||||
let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.0);
|
||||
|
||||
// If h3o returned radians, values would be < π ≈ 3.14
|
||||
// Latitude ~37.77° proves we're getting degrees, not radians
|
||||
assert!(min_lat > 30.0 && min_lat < 45.0, "min_lat {} should be ~37° (degrees)", min_lat);
|
||||
assert!(max_lat > 30.0 && max_lat < 45.0, "max_lat {} should be ~37° (degrees)", max_lat);
|
||||
|
||||
// Longitude ~-122° also proves degrees (radians would be < π)
|
||||
assert!(min_lon < -100.0, "min_lon {} should be ~-122° (degrees)", min_lon);
|
||||
assert!(max_lon < -100.0, "max_lon {} should be ~-122° (degrees)", max_lon);
|
||||
assert!(
|
||||
min_lat > 30.0 && min_lat < 45.0,
|
||||
"min_lat {} should be ~37° (degrees)",
|
||||
min_lat
|
||||
);
|
||||
assert!(
|
||||
max_lat > 30.0 && max_lat < 45.0,
|
||||
"max_lat {} should be ~37° (degrees)",
|
||||
max_lat
|
||||
);
|
||||
assert!(
|
||||
min_lon < -100.0,
|
||||
"min_lon {} should be ~-122° (degrees)",
|
||||
min_lon
|
||||
);
|
||||
assert!(
|
||||
max_lon < -100.0,
|
||||
"max_lon {} should be ~-122° (degrees)",
|
||||
max_lon
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_intersect_overlapping() {
|
||||
// Two overlapping boxes
|
||||
assert!(bounds_intersect(0.0, 0.0, 2.0, 2.0, 1.0, 1.0, 3.0, 3.0));
|
||||
// Box B is inside box A
|
||||
assert!(bounds_intersect(0.0, 0.0, 10.0, 10.0, 2.0, 2.0, 5.0, 5.0));
|
||||
// Box A is inside box B
|
||||
assert!(bounds_intersect(2.0, 2.0, 5.0, 5.0, 0.0, 0.0, 10.0, 10.0));
|
||||
// Touching at edge
|
||||
assert!(bounds_intersect(0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 2.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_intersect_non_overlapping() {
|
||||
// Box B is to the right of box A
|
||||
assert!(!bounds_intersect(0.0, 0.0, 1.0, 1.0, 0.0, 2.0, 1.0, 3.0));
|
||||
// Box B is above box A
|
||||
assert!(!bounds_intersect(0.0, 0.0, 1.0, 1.0, 2.0, 0.0, 3.0, 1.0));
|
||||
// Completely separate
|
||||
assert!(!bounds_intersect(0.0, 0.0, 1.0, 1.0, 5.0, 5.0, 6.0, 6.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bounds_with_spaces() {
|
||||
let (south, west, _north, _east) = parse_bounds("51.0, -0.5, 52.0, 0.5").unwrap();
|
||||
assert_eq!(south, 51.0);
|
||||
assert_eq!(west, -0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bounds_negative_values() {
|
||||
let (south, _west, north, _east) = parse_bounds("-51.5,-0.5,-50.0,0.5").unwrap();
|
||||
assert_eq!(south, -51.5);
|
||||
assert_eq!(north, -50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touching_at_corner_intersects() {
|
||||
assert!(bounds_intersect(
|
||||
0.0, 0.0, 1.0, 1.0, // Box A
|
||||
1.0, 1.0, 2.0, 2.0 // Box B touches at (1,1)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touching_at_edge_intersects() {
|
||||
assert!(bounds_intersect(
|
||||
0.0, 0.0, 1.0, 1.0, // Box A
|
||||
1.0, 0.0, 2.0, 1.0 // Box B touches along right edge
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disjoint_diagonally_no_intersect() {
|
||||
assert!(!bounds_intersect(
|
||||
0.0, 0.0, 1.0, 1.0, // Box A
|
||||
2.0, 2.0, 3.0, 3.0 // Box B diagonally away
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_coordinates_intersect() {
|
||||
assert!(bounds_intersect(
|
||||
-2.0, -2.0, -1.0, -1.0, // Box A (negative coords)
|
||||
-1.5, -1.5, -0.5, -0.5 // Box B overlaps
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h3_cell_bounds_zero_buffer() {
|
||||
let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap();
|
||||
let (south, west, north, east) = h3_cell_bounds(cell, 0.0);
|
||||
|
||||
assert!(south < north, "south {} should be < north {}", south, north);
|
||||
assert!(west < east, "west {} should be < east {}", west, east);
|
||||
assert!(south > 30.0 && south < 45.0);
|
||||
assert!(west < -100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h3_cell_bounds_different_resolutions() {
|
||||
let cell_high = h3o::CellIndex::from_str("8928308280fffff").unwrap();
|
||||
let res5 = h3o::Resolution::try_from(5).unwrap();
|
||||
let cell_low = cell_high.parent(res5).unwrap();
|
||||
|
||||
let (s_low, w_low, n_low, e_low) = h3_cell_bounds(cell_low, 0.0);
|
||||
let (s_high, w_high, n_high, e_high) = h3_cell_bounds(cell_high, 0.0);
|
||||
|
||||
let area_low = (n_low - s_low) * (e_low - w_low);
|
||||
let area_high = (n_high - s_high) * (e_high - w_high);
|
||||
assert!(area_low > area_high, "Lower res should have larger area");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_cell_at_lower_resolution() {
|
||||
let child = h3o::CellIndex::from_str("8928308280fffff").unwrap();
|
||||
|
||||
let parent_res = h3o::Resolution::try_from(7).unwrap();
|
||||
let parent = child.parent(parent_res).unwrap();
|
||||
|
||||
assert_eq!(parent.resolution(), parent_res);
|
||||
assert!(parent.children(child.resolution()).any(|c| c == child));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_resolution_returns_self() {
|
||||
let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap();
|
||||
let res = cell.resolution();
|
||||
|
||||
let parent = cell.parent(res);
|
||||
assert_eq!(parent, Some(cell));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_resolution_parent_fails() {
|
||||
let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap();
|
||||
|
||||
let higher_res = h3o::Resolution::try_from(10).unwrap();
|
||||
let parent = cell.parent(higher_res);
|
||||
assert!(parent.is_none());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,9 +116,39 @@ mod tests {
|
|||
map
|
||||
}
|
||||
|
||||
fn extended_feature_map() -> FxHashMap<String, usize> {
|
||||
[
|
||||
("Price".into(), 0),
|
||||
("Area".into(), 1),
|
||||
("Rating".into(), 2),
|
||||
("Type".into(), 3),
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extended_enum_values() -> FxHashMap<usize, Vec<String>> {
|
||||
let mut map = FxHashMap::default();
|
||||
map.insert(
|
||||
3,
|
||||
vec![
|
||||
"Detached".into(),
|
||||
"Semi".into(),
|
||||
"Terraced".into(),
|
||||
"Flat".into(),
|
||||
],
|
||||
);
|
||||
map
|
||||
}
|
||||
|
||||
fn allowed_set(values: &[f32]) -> FxHashSet<u32> {
|
||||
values.iter().map(|v| v.to_bits()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_filters_numeric() {
|
||||
let (numeric, enums) = parse_filters(Some("price:100:500"), &feature_name_to_index(), &enum_values());
|
||||
let (numeric, enums) =
|
||||
parse_filters(Some("price:100:500"), &feature_name_to_index(), &enum_values());
|
||||
assert_eq!(numeric.len(), 1);
|
||||
assert_eq!(numeric[0].feat_idx, 0);
|
||||
assert_eq!(numeric[0].min, 100.0);
|
||||
|
|
@ -128,11 +158,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn parse_filters_enum() {
|
||||
let (numeric, enums) = parse_filters(Some("rating:A|C"), &feature_name_to_index(), &enum_values());
|
||||
let (numeric, enums) =
|
||||
parse_filters(Some("rating:A|C"), &feature_name_to_index(), &enum_values());
|
||||
assert!(numeric.is_empty());
|
||||
assert_eq!(enums.len(), 1);
|
||||
assert_eq!(enums[0].feat_idx, 2);
|
||||
// Allowed values are stored as f32 bits
|
||||
assert!(enums[0].allowed.contains(&(0.0_f32).to_bits())); // A = index 0
|
||||
assert!(enums[0].allowed.contains(&(2.0_f32).to_bits())); // C = index 2
|
||||
assert_eq!(enums[0].allowed.len(), 2);
|
||||
|
|
@ -152,7 +182,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn row_passes_numeric_filter() {
|
||||
let filters = vec![ParsedFilter { feat_idx: 0, min: 10.0, max: 20.0 }];
|
||||
let filters = vec![ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min: 10.0,
|
||||
max: 20.0,
|
||||
}];
|
||||
let data = vec![15.0, 5.0, f32::NAN];
|
||||
|
||||
assert!(row_passes_filters(0, &filters, &[], &data, 1));
|
||||
|
|
@ -162,8 +196,11 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn row_passes_enum_filter() {
|
||||
let filters = vec![ParsedEnumFilter { feat_idx: 0, allowed: vec![0.0, 2.0] }];
|
||||
// Row 0: value 0.0 (allowed), Row 1: value 1.0 (not allowed), Row 2: value 2.0 (allowed), Row 3: NaN (fails)
|
||||
let allowed: FxHashSet<u32> = [0.0_f32, 2.0].iter().map(|v| v.to_bits()).collect();
|
||||
let filters = vec![ParsedEnumFilter {
|
||||
feat_idx: 0,
|
||||
allowed,
|
||||
}];
|
||||
let data = vec![0.0, 1.0, 2.0, f32::NAN];
|
||||
|
||||
assert!(row_passes_filters(0, &[], &filters, &data, 1));
|
||||
|
|
@ -171,4 +208,173 @@ mod tests {
|
|||
assert!(row_passes_filters(2, &[], &filters, &data, 1));
|
||||
assert!(!row_passes_filters(3, &[], &filters, &data, 1)); // NaN fails
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multiple_numeric_filters() {
|
||||
let (numeric, _enums) = parse_filters(
|
||||
Some("Price:100000:500000,Area:50:200"),
|
||||
&extended_feature_map(),
|
||||
&extended_enum_values(),
|
||||
);
|
||||
|
||||
assert_eq!(numeric.len(), 2);
|
||||
assert_eq!(numeric[0].feat_idx, 0);
|
||||
assert_eq!(numeric[1].feat_idx, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mixed_filters() {
|
||||
let (numeric, enums) = parse_filters(
|
||||
Some("Price:100000:500000,Type:Semi|Terraced"),
|
||||
&extended_feature_map(),
|
||||
&extended_enum_values(),
|
||||
);
|
||||
|
||||
assert_eq!(numeric.len(), 1);
|
||||
assert_eq!(enums.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_numeric_format_ignored() {
|
||||
let (numeric, enums) = parse_filters(
|
||||
Some("Price:not_a_number:500000"),
|
||||
&extended_feature_map(),
|
||||
&extended_enum_values(),
|
||||
);
|
||||
|
||||
assert!(numeric.is_empty());
|
||||
assert!(enums.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_enum_with_unknown_value() {
|
||||
let (_numeric, enums) = parse_filters(
|
||||
Some("Type:Detached|Unknown|Flat"),
|
||||
&extended_feature_map(),
|
||||
&extended_enum_values(),
|
||||
);
|
||||
|
||||
assert_eq!(enums.len(), 1);
|
||||
assert!(enums[0].allowed.contains(&(0.0_f32).to_bits())); // Detached
|
||||
assert!(enums[0].allowed.contains(&(3.0_f32).to_bits())); // Flat
|
||||
assert_eq!(enums[0].allowed.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_filter_with_whitespace() {
|
||||
let (numeric, enums) = parse_filters(
|
||||
Some("Price : 100000 : 500000 , Type : Detached | Flat"),
|
||||
&extended_feature_map(),
|
||||
&extended_enum_values(),
|
||||
);
|
||||
|
||||
assert_eq!(numeric.len(), 1);
|
||||
assert_eq!(enums.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_passes_no_filters() {
|
||||
let feature_data = vec![100.0_f32, 50.0];
|
||||
assert!(row_passes_filters(0, &[], &[], &feature_data, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_passes_numeric_filter_at_boundary() {
|
||||
let filters = vec![ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min: 100.0,
|
||||
max: 200.0,
|
||||
}];
|
||||
|
||||
assert!(row_passes_filters(0, &filters, &[], &[100.0], 1));
|
||||
assert!(row_passes_filters(0, &filters, &[], &[200.0], 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_fails_empty_enum_filter() {
|
||||
let feature_data = vec![1.0_f32];
|
||||
let enum_filters = vec![ParsedEnumFilter {
|
||||
feat_idx: 0,
|
||||
allowed: FxHashSet::default(),
|
||||
}];
|
||||
assert!(!row_passes_filters(0, &[], &enum_filters, &feature_data, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_filters_all_must_pass() {
|
||||
let feature_data = vec![150.0_f32, 1.0];
|
||||
|
||||
let numeric_filters = vec![ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min: 100.0,
|
||||
max: 200.0,
|
||||
}];
|
||||
let enum_filters = vec![ParsedEnumFilter {
|
||||
feat_idx: 1,
|
||||
allowed: allowed_set(&[1.0, 2.0]),
|
||||
}];
|
||||
|
||||
assert!(row_passes_filters(
|
||||
0,
|
||||
&numeric_filters,
|
||||
&enum_filters,
|
||||
&feature_data,
|
||||
2
|
||||
));
|
||||
|
||||
let enum_filters_fail = vec![ParsedEnumFilter {
|
||||
feat_idx: 1,
|
||||
allowed: allowed_set(&[0.0, 2.0]),
|
||||
}];
|
||||
assert!(!row_passes_filters(
|
||||
0,
|
||||
&numeric_filters,
|
||||
&enum_filters_fail,
|
||||
&feature_data,
|
||||
2
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_major_layout_correct_indexing() {
|
||||
let feature_data = vec![
|
||||
100.0_f32, 0.0, // Row 0
|
||||
200.0, 1.0, // Row 1
|
||||
300.0, 2.0, // Row 2
|
||||
];
|
||||
let num_features = 2;
|
||||
|
||||
let filters = vec![ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min: 150.0,
|
||||
max: 250.0,
|
||||
}];
|
||||
|
||||
assert!(!row_passes_filters(0, &filters, &[], &feature_data, num_features));
|
||||
assert!(row_passes_filters(1, &filters, &[], &feature_data, num_features));
|
||||
assert!(!row_passes_filters(2, &filters, &[], &feature_data, num_features));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_at_float_precision_boundary() {
|
||||
let value = 100.0_f32;
|
||||
let filter = ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min: 100.0 - f32::EPSILON,
|
||||
max: 100.0 + f32::EPSILON,
|
||||
};
|
||||
|
||||
assert!(row_passes_filters(0, &[filter], &[], &[value], 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_filter_with_fractional_index() {
|
||||
let feature_data = vec![1.5_f32]; // Not exactly 1.0 or 2.0
|
||||
let enum_filters = vec![ParsedEnumFilter {
|
||||
feat_idx: 0,
|
||||
allowed: allowed_set(&[1.0, 2.0]),
|
||||
}];
|
||||
|
||||
assert!(!row_passes_filters(0, &[], &enum_filters, &feature_data, 1));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -282,7 +282,9 @@ pub async fn get_hexagons(
|
|||
let t_total = t0.elapsed();
|
||||
info!(
|
||||
resolution,
|
||||
cells = groups.len(),
|
||||
cells_before_filter = groups.len(),
|
||||
cells_after_filter = features.len(),
|
||||
bounds = format_args!("{:.4},{:.4},{:.4},{:.4}", south, west, north, east),
|
||||
filters = num_filters,
|
||||
filters_raw = filters_str.as_deref().unwrap_or("-"),
|
||||
agg_ms = format_args!("{:.1}", t_agg.as_secs_f64() * 1000.0),
|
||||
|
|
|
|||
|
|
@ -173,6 +173,8 @@ pub async fn get_postcodes(
|
|||
|
||||
// Build response, filtering postcodes to only those whose polygon intersects query bounds
|
||||
let mut features = Vec::with_capacity(postcode_aggs.len());
|
||||
let postcodes_before_filter = postcode_aggs.len();
|
||||
let mut filtered_out = 0usize;
|
||||
|
||||
for (pc_idx, aggregation) in postcode_aggs {
|
||||
if aggregation.count == 0 {
|
||||
|
|
@ -193,6 +195,7 @@ pub async fn get_postcodes(
|
|||
}
|
||||
|
||||
if !bounds_intersect(pc_south, pc_west, pc_north, pc_east, south, west, north, east) {
|
||||
filtered_out += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -235,7 +238,10 @@ pub async fn get_postcodes(
|
|||
|
||||
let t_total = t0.elapsed();
|
||||
info!(
|
||||
postcodes = features.len(),
|
||||
postcodes_before_filter,
|
||||
postcodes_after_filter = features.len(),
|
||||
filtered_out,
|
||||
bounds = format_args!("{:.6},{:.6},{:.6},{:.6}", south, west, north, east),
|
||||
filters = num_filters,
|
||||
filters_raw = filters_str.as_deref().unwrap_or("-"),
|
||||
total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0),
|
||||
|
|
|
|||
|
|
@ -1,974 +0,0 @@
|
|||
//! Comprehensive semantic tests for the server.
|
||||
//!
|
||||
//! These tests validate the correctness of data processing, aggregation logic,
|
||||
//! spatial queries, and filter semantics without requiring real data files.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
use crate::data::{compute_feature_stats, Histogram};
|
||||
use crate::features::Bounds;
|
||||
use crate::parsing::{
|
||||
bounds_intersect, h3_cell_bounds, parse_bounds, parse_filters, row_passes_filters,
|
||||
ParsedEnumFilter, ParsedFilter,
|
||||
};
|
||||
use crate::utils::GridIndex;
|
||||
|
||||
// =========================================================================
|
||||
// GridIndex Tests
|
||||
// =========================================================================
|
||||
|
||||
mod grid_index {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_grid_returns_empty() {
|
||||
let grid = GridIndex::build(&[], &[], 0.01);
|
||||
assert!(grid.query(-90.0, -180.0, 90.0, 180.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_point_inside_query() {
|
||||
let lat = vec![51.5_f32];
|
||||
let lon = vec![-0.1_f32];
|
||||
let grid = GridIndex::build(&lat, &lon, 0.01);
|
||||
|
||||
// Query that contains the point
|
||||
let result = grid.query(51.4, -0.2, 51.6, 0.0);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_point_outside_query() {
|
||||
let lat = vec![51.5_f32];
|
||||
let lon = vec![-0.1_f32];
|
||||
let grid = GridIndex::build(&lat, &lon, 0.01);
|
||||
|
||||
// Query that doesn't contain the point
|
||||
let result = grid.query(52.0, 0.0, 53.0, 1.0);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_points_partial_query() {
|
||||
let lat = vec![51.5_f32, 51.6, 51.7, 52.0];
|
||||
let lon = vec![-0.1_f32, -0.1, -0.1, -0.1];
|
||||
let grid = GridIndex::build(&lat, &lon, 0.01);
|
||||
|
||||
// Query that contains only points 0, 1, 2
|
||||
let result = grid.query(51.4, -0.2, 51.8, 0.0);
|
||||
assert_eq!(result.len(), 3);
|
||||
assert!(result.contains(&0));
|
||||
assert!(result.contains(&1));
|
||||
assert!(result.contains(&2));
|
||||
assert!(!result.contains(&3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_at_grid_boundary() {
|
||||
// Points at exactly cell boundaries
|
||||
let lat = vec![51.0_f32, 51.01, 51.02];
|
||||
let lon = vec![0.0_f32, 0.01, 0.02];
|
||||
let grid = GridIndex::build(&lat, &lon, 0.01);
|
||||
|
||||
// Query just past the first cell
|
||||
let result = grid.query(50.99, -0.01, 51.005, 0.005);
|
||||
assert!(result.contains(&0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_each_matches_query() {
|
||||
let lat = vec![51.5_f32, 51.6, 51.7];
|
||||
let lon = vec![-0.1_f32, -0.2, -0.3];
|
||||
let grid = GridIndex::build(&lat, &lon, 0.01);
|
||||
|
||||
let query_result = grid.query(51.4, -0.25, 51.65, 0.0);
|
||||
|
||||
let mut foreach_result = Vec::new();
|
||||
grid.for_each_in_bounds(51.4, -0.25, 51.65, 0.0, |idx| {
|
||||
foreach_result.push(idx);
|
||||
});
|
||||
|
||||
// Both methods should return the same indices
|
||||
assert_eq!(query_result.len(), foreach_result.len());
|
||||
for idx in &query_result {
|
||||
assert!(foreach_result.contains(idx));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_coordinates() {
|
||||
let lat = vec![-33.9_f32, -33.8, -33.7];
|
||||
let lon = vec![151.2_f32, 151.3, 151.4];
|
||||
let grid = GridIndex::build(&lat, &lon, 0.01);
|
||||
|
||||
// Query: south=-34.0, north=-33.65
|
||||
// -33.9 is in range (between -34 and -33.65), lon 151.2 in range (151.1 to 151.5) ✓
|
||||
// -33.8 is in range, lon 151.3 in range ✓
|
||||
// -33.7 is in range, lon 151.4 in range ✓
|
||||
let result = grid.query(-34.0, 151.1, -33.65, 151.5);
|
||||
assert_eq!(result.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_bounds_completely_outside_grid() {
|
||||
let lat = vec![51.5_f32];
|
||||
let lon = vec![-0.1_f32];
|
||||
let grid = GridIndex::build(&lat, &lon, 0.01);
|
||||
|
||||
// Query in a completely different area
|
||||
let result = grid.query(0.0, 100.0, 10.0, 110.0);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn very_small_cell_size() {
|
||||
let lat = vec![51.5_f32, 51.5001, 51.5002];
|
||||
let lon = vec![-0.1_f32, -0.1001, -0.1002];
|
||||
let grid = GridIndex::build(&lat, &lon, 0.0001);
|
||||
|
||||
let result = grid.query(51.4999, -0.1003, 51.5003, -0.0999);
|
||||
assert_eq!(result.len(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Filter Parsing Tests
|
||||
// =========================================================================
|
||||
|
||||
mod filter_parsing {
|
||||
use super::*;
|
||||
|
||||
fn make_feature_name_to_index() -> FxHashMap<String, usize> {
|
||||
[
|
||||
("Price".into(), 0),
|
||||
("Area".into(), 1),
|
||||
("Rating".into(), 2),
|
||||
("Type".into(), 3),
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn make_enum_values() -> FxHashMap<usize, Vec<String>> {
|
||||
let mut map = FxHashMap::default();
|
||||
// Feature index 3 (Type) is an enum
|
||||
map.insert(3, vec!["Detached".into(), "Semi".into(), "Terraced".into(), "Flat".into()]);
|
||||
map
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_single_numeric_filter() {
|
||||
let (numeric, enums) = parse_filters(
|
||||
Some("Price:100000:500000"),
|
||||
&make_feature_name_to_index(),
|
||||
&make_enum_values(),
|
||||
);
|
||||
|
||||
assert_eq!(numeric.len(), 1);
|
||||
assert!(enums.is_empty());
|
||||
assert_eq!(numeric[0].feat_idx, 0);
|
||||
assert_eq!(numeric[0].min, 100000.0);
|
||||
assert_eq!(numeric[0].max, 500000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_multiple_numeric_filters() {
|
||||
let (numeric, _enums) = parse_filters(
|
||||
Some("Price:100000:500000,Area:50:200"),
|
||||
&make_feature_name_to_index(),
|
||||
&make_enum_values(),
|
||||
);
|
||||
|
||||
assert_eq!(numeric.len(), 2);
|
||||
assert_eq!(numeric[0].feat_idx, 0);
|
||||
assert_eq!(numeric[1].feat_idx, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_single_enum_filter() {
|
||||
let (numeric, enums) = parse_filters(
|
||||
Some("Type:Detached|Flat"),
|
||||
&make_feature_name_to_index(),
|
||||
&make_enum_values(),
|
||||
);
|
||||
|
||||
assert!(numeric.is_empty());
|
||||
assert_eq!(enums.len(), 1);
|
||||
assert_eq!(enums[0].feat_idx, 3);
|
||||
assert_eq!(enums[0].allowed, vec![0.0, 3.0]); // Detached=0, Flat=3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mixed_filters() {
|
||||
let (numeric, enums) = parse_filters(
|
||||
Some("Price:100000:500000,Type:Semi|Terraced"),
|
||||
&make_feature_name_to_index(),
|
||||
&make_enum_values(),
|
||||
);
|
||||
|
||||
assert_eq!(numeric.len(), 1);
|
||||
assert_eq!(enums.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unknown_feature_ignored() {
|
||||
let (numeric, enums) = parse_filters(
|
||||
Some("Unknown:100:200"),
|
||||
&make_feature_name_to_index(),
|
||||
&make_enum_values(),
|
||||
);
|
||||
|
||||
assert!(numeric.is_empty());
|
||||
assert!(enums.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_numeric_format_ignored() {
|
||||
let (numeric, enums) = parse_filters(
|
||||
Some("Price:not_a_number:500000"),
|
||||
&make_feature_name_to_index(),
|
||||
&make_enum_values(),
|
||||
);
|
||||
|
||||
assert!(numeric.is_empty());
|
||||
assert!(enums.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_enum_with_unknown_value() {
|
||||
let (_numeric, enums) = parse_filters(
|
||||
Some("Type:Detached|Unknown|Flat"),
|
||||
&make_feature_name_to_index(),
|
||||
&make_enum_values(),
|
||||
);
|
||||
|
||||
assert_eq!(enums.len(), 1);
|
||||
// Unknown is filtered out, only Detached and Flat remain
|
||||
assert_eq!(enums[0].allowed, vec![0.0, 3.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_filter_string() {
|
||||
let (numeric, enums) = parse_filters(
|
||||
Some(""),
|
||||
&make_feature_name_to_index(),
|
||||
&make_enum_values(),
|
||||
);
|
||||
|
||||
assert!(numeric.is_empty());
|
||||
assert!(enums.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_none_filter() {
|
||||
let (numeric, enums) = parse_filters(
|
||||
None,
|
||||
&make_feature_name_to_index(),
|
||||
&make_enum_values(),
|
||||
);
|
||||
|
||||
assert!(numeric.is_empty());
|
||||
assert!(enums.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_filter_with_whitespace() {
|
||||
let (numeric, enums) = parse_filters(
|
||||
Some("Price : 100000 : 500000 , Type : Detached | Flat"),
|
||||
&make_feature_name_to_index(),
|
||||
&make_enum_values(),
|
||||
);
|
||||
|
||||
assert_eq!(numeric.len(), 1);
|
||||
assert_eq!(enums.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Filter Application Tests
|
||||
// =========================================================================
|
||||
|
||||
mod filter_application {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn row_passes_no_filters() {
|
||||
let feature_data = vec![100.0_f32, 50.0];
|
||||
assert!(row_passes_filters(0, &[], &[], &feature_data, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_passes_numeric_filter_in_range() {
|
||||
let feature_data = vec![150.0_f32];
|
||||
let filters = vec![ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min: 100.0,
|
||||
max: 200.0,
|
||||
}];
|
||||
assert!(row_passes_filters(0, &filters, &[], &feature_data, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_fails_numeric_filter_below_min() {
|
||||
let feature_data = vec![50.0_f32];
|
||||
let filters = vec![ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min: 100.0,
|
||||
max: 200.0,
|
||||
}];
|
||||
assert!(!row_passes_filters(0, &filters, &[], &feature_data, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_fails_numeric_filter_above_max() {
|
||||
let feature_data = vec![250.0_f32];
|
||||
let filters = vec![ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min: 100.0,
|
||||
max: 200.0,
|
||||
}];
|
||||
assert!(!row_passes_filters(0, &filters, &[], &feature_data, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_passes_numeric_filter_at_boundary() {
|
||||
let filters = vec![ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min: 100.0,
|
||||
max: 200.0,
|
||||
}];
|
||||
|
||||
// At min boundary
|
||||
assert!(row_passes_filters(0, &filters, &[], &[100.0], 1));
|
||||
// At max boundary
|
||||
assert!(row_passes_filters(0, &filters, &[], &[200.0], 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_fails_numeric_filter_with_nan() {
|
||||
let feature_data = vec![f32::NAN];
|
||||
let filters = vec![ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min: 100.0,
|
||||
max: 200.0,
|
||||
}];
|
||||
assert!(!row_passes_filters(0, &filters, &[], &feature_data, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_passes_enum_filter_allowed_value() {
|
||||
let feature_data = vec![1.0_f32]; // Index 1
|
||||
let enum_filters = vec![ParsedEnumFilter {
|
||||
feat_idx: 0,
|
||||
allowed: vec![0.0, 1.0, 2.0],
|
||||
}];
|
||||
assert!(row_passes_filters(0, &[], &enum_filters, &feature_data, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_fails_enum_filter_disallowed_value() {
|
||||
let feature_data = vec![3.0_f32]; // Index 3 not in allowed
|
||||
let enum_filters = vec![ParsedEnumFilter {
|
||||
feat_idx: 0,
|
||||
allowed: vec![0.0, 1.0, 2.0],
|
||||
}];
|
||||
assert!(!row_passes_filters(0, &[], &enum_filters, &feature_data, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_fails_enum_filter_with_nan() {
|
||||
let feature_data = vec![f32::NAN];
|
||||
let enum_filters = vec![ParsedEnumFilter {
|
||||
feat_idx: 0,
|
||||
allowed: vec![0.0, 1.0, 2.0],
|
||||
}];
|
||||
assert!(!row_passes_filters(0, &[], &enum_filters, &feature_data, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_fails_empty_enum_filter() {
|
||||
let feature_data = vec![1.0_f32];
|
||||
let enum_filters = vec![ParsedEnumFilter {
|
||||
feat_idx: 0,
|
||||
allowed: vec![], // Empty allowed list
|
||||
}];
|
||||
// Empty allowed means nothing passes
|
||||
assert!(!row_passes_filters(0, &[], &enum_filters, &feature_data, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_filters_all_must_pass() {
|
||||
// Row with two features: price=150, type=1
|
||||
let feature_data = vec![150.0_f32, 1.0];
|
||||
|
||||
let numeric_filters = vec![ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min: 100.0,
|
||||
max: 200.0,
|
||||
}];
|
||||
let enum_filters = vec![ParsedEnumFilter {
|
||||
feat_idx: 1,
|
||||
allowed: vec![1.0, 2.0],
|
||||
}];
|
||||
|
||||
assert!(row_passes_filters(0, &numeric_filters, &enum_filters, &feature_data, 2));
|
||||
|
||||
// Change enum filter to not include 1.0
|
||||
let enum_filters_fail = vec![ParsedEnumFilter {
|
||||
feat_idx: 1,
|
||||
allowed: vec![0.0, 2.0],
|
||||
}];
|
||||
assert!(!row_passes_filters(0, &numeric_filters, &enum_filters_fail, &feature_data, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_major_layout_correct_indexing() {
|
||||
// 3 rows, 2 features each
|
||||
// Row 0: [100, 0], Row 1: [200, 1], Row 2: [300, 2]
|
||||
let feature_data = vec![
|
||||
100.0_f32, 0.0, // Row 0
|
||||
200.0, 1.0, // Row 1
|
||||
300.0, 2.0, // Row 2
|
||||
];
|
||||
let num_features = 2;
|
||||
|
||||
let filters = vec![ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min: 150.0,
|
||||
max: 250.0,
|
||||
}];
|
||||
|
||||
assert!(!row_passes_filters(0, &filters, &[], &feature_data, num_features)); // 100 not in range
|
||||
assert!(row_passes_filters(1, &filters, &[], &feature_data, num_features)); // 200 in range
|
||||
assert!(!row_passes_filters(2, &filters, &[], &feature_data, num_features)); // 300 not in range
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Bounds Parsing Tests
|
||||
// =========================================================================
|
||||
|
||||
mod bounds_parsing {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_valid_bounds() {
|
||||
let (south, west, north, east) = parse_bounds("51.0,-0.5,52.0,0.5").unwrap();
|
||||
assert_eq!(south, 51.0);
|
||||
assert_eq!(west, -0.5);
|
||||
assert_eq!(north, 52.0);
|
||||
assert_eq!(east, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bounds_with_spaces() {
|
||||
let (south, west, _north, _east) = parse_bounds("51.0, -0.5, 52.0, 0.5").unwrap();
|
||||
assert_eq!(south, 51.0);
|
||||
assert_eq!(west, -0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bounds_negative_values() {
|
||||
let (south, _west, north, _east) = parse_bounds("-51.5,-0.5,-50.0,0.5").unwrap();
|
||||
assert_eq!(south, -51.5);
|
||||
assert_eq!(north, -50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bounds_invalid_too_few_parts() {
|
||||
assert!(parse_bounds("51.0,-0.5,52.0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bounds_invalid_too_many_parts() {
|
||||
assert!(parse_bounds("51.0,-0.5,52.0,0.5,1.0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bounds_invalid_non_numeric() {
|
||||
assert!(parse_bounds("51.0,abc,52.0,0.5").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bounds_empty_string() {
|
||||
assert!(parse_bounds("").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Bounds Intersection Tests
|
||||
// =========================================================================
|
||||
|
||||
mod bounds_intersection {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn overlapping_boxes_intersect() {
|
||||
assert!(bounds_intersect(
|
||||
0.0, 0.0, 2.0, 2.0, // Box A
|
||||
1.0, 1.0, 3.0, 3.0 // Box B overlaps
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_box_inside_other_intersects() {
|
||||
assert!(bounds_intersect(
|
||||
0.0, 0.0, 10.0, 10.0, // Box A (large)
|
||||
2.0, 2.0, 5.0, 5.0 // Box B (inside A)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touching_at_corner_intersects() {
|
||||
assert!(bounds_intersect(
|
||||
0.0, 0.0, 1.0, 1.0, // Box A
|
||||
1.0, 1.0, 2.0, 2.0 // Box B touches at (1,1)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touching_at_edge_intersects() {
|
||||
assert!(bounds_intersect(
|
||||
0.0, 0.0, 1.0, 1.0, // Box A
|
||||
1.0, 0.0, 2.0, 1.0 // Box B touches along right edge
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disjoint_horizontally_no_intersect() {
|
||||
assert!(!bounds_intersect(
|
||||
0.0, 0.0, 1.0, 1.0, // Box A
|
||||
0.0, 2.0, 1.0, 3.0 // Box B to the right
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disjoint_vertically_no_intersect() {
|
||||
assert!(!bounds_intersect(
|
||||
0.0, 0.0, 1.0, 1.0, // Box A
|
||||
2.0, 0.0, 3.0, 1.0 // Box B above
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disjoint_diagonally_no_intersect() {
|
||||
assert!(!bounds_intersect(
|
||||
0.0, 0.0, 1.0, 1.0, // Box A
|
||||
2.0, 2.0, 3.0, 3.0 // Box B diagonally away
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_coordinates_intersect() {
|
||||
assert!(bounds_intersect(
|
||||
-2.0, -2.0, -1.0, -1.0, // Box A (negative coords)
|
||||
-1.5, -1.5, -0.5, -0.5 // Box B overlaps
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// H3 Cell Bounds Tests
|
||||
// =========================================================================
|
||||
|
||||
mod h3_bounds {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn h3_cell_bounds_zero_buffer() {
|
||||
let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap();
|
||||
let (south, west, north, east) = h3_cell_bounds(cell, 0.0);
|
||||
|
||||
// San Francisco area, should be roughly 37.77°N, -122.4°W
|
||||
assert!(south < north, "south {} should be < north {}", south, north);
|
||||
assert!(west < east, "west {} should be < east {}", west, east);
|
||||
assert!(south > 30.0 && south < 45.0);
|
||||
assert!(west < -100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h3_cell_bounds_with_buffer() {
|
||||
let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap();
|
||||
let (s0, w0, n0, e0) = h3_cell_bounds(cell, 0.0);
|
||||
let (s1, w1, n1, e1) = h3_cell_bounds(cell, 0.1);
|
||||
|
||||
// With buffer, bounds should be larger
|
||||
assert!(s1 < s0, "south with buffer should be smaller");
|
||||
assert!(w1 < w0, "west with buffer should be smaller");
|
||||
assert!(n1 > n0, "north with buffer should be larger");
|
||||
assert!(e1 > e0, "east with buffer should be larger");
|
||||
|
||||
// Buffer should be exactly 0.1 degrees
|
||||
assert!((s0 - s1 - 0.1).abs() < 1e-10);
|
||||
assert!((w0 - w1 - 0.1).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h3_cell_bounds_different_resolutions() {
|
||||
// Resolution 9 cell
|
||||
let cell_high = h3o::CellIndex::from_str("8928308280fffff").unwrap();
|
||||
// Get its resolution 5 parent
|
||||
let res5 = h3o::Resolution::try_from(5).unwrap();
|
||||
let cell_low = cell_high.parent(res5).unwrap();
|
||||
|
||||
let (s_low, w_low, n_low, e_low) = h3_cell_bounds(cell_low, 0.0);
|
||||
let (s_high, w_high, n_high, e_high) = h3_cell_bounds(cell_high, 0.0);
|
||||
|
||||
// Lower resolution cell should have larger bounds
|
||||
let area_low = (n_low - s_low) * (e_low - w_low);
|
||||
let area_high = (n_high - s_high) * (e_high - w_high);
|
||||
assert!(area_low > area_high, "Lower res should have larger area");
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Histogram Computation Tests
|
||||
// =========================================================================
|
||||
|
||||
mod histogram {
|
||||
use super::*;
|
||||
|
||||
fn make_fixed_bounds(min: f32, max: f32) -> Bounds {
|
||||
Bounds::Fixed { min, max }
|
||||
}
|
||||
|
||||
fn make_percentile_bounds(low: f64, high: f64) -> Bounds {
|
||||
Bounds::Percentile { low, high }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_empty_data() {
|
||||
let data: Vec<f32> = vec![];
|
||||
let bounds = make_fixed_bounds(0.0, 100.0);
|
||||
let stats = compute_feature_stats(&data, &bounds);
|
||||
|
||||
assert_eq!(stats.slider_min, 0.0);
|
||||
assert_eq!(stats.slider_max, 100.0);
|
||||
assert_eq!(stats.histogram.counts.iter().sum::<u64>(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_single_value() {
|
||||
let data = vec![50.0_f32];
|
||||
let bounds = make_fixed_bounds(0.0, 100.0);
|
||||
let stats = compute_feature_stats(&data, &bounds);
|
||||
|
||||
assert_eq!(stats.histogram.min, 50.0);
|
||||
assert_eq!(stats.histogram.max, 50.0);
|
||||
assert_eq!(stats.histogram.counts.iter().sum::<u64>(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_uniform_distribution() {
|
||||
// 100 values from 0 to 99
|
||||
let data: Vec<f32> = (0..100).map(|i| i as f32).collect();
|
||||
let bounds = make_fixed_bounds(0.0, 100.0);
|
||||
let stats = compute_feature_stats(&data, &bounds);
|
||||
|
||||
assert_eq!(stats.histogram.min, 0.0);
|
||||
assert_eq!(stats.histogram.max, 99.0);
|
||||
assert_eq!(stats.histogram.counts.iter().sum::<u64>(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_with_nan_values() {
|
||||
let data = vec![10.0_f32, f32::NAN, 20.0, f32::NAN, 30.0];
|
||||
let bounds = make_fixed_bounds(0.0, 100.0);
|
||||
let stats = compute_feature_stats(&data, &bounds);
|
||||
|
||||
// Only 3 non-NaN values
|
||||
assert_eq!(stats.histogram.counts.iter().sum::<u64>(), 3);
|
||||
assert_eq!(stats.histogram.min, 10.0);
|
||||
assert_eq!(stats.histogram.max, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_all_nan() {
|
||||
let data = vec![f32::NAN, f32::NAN, f32::NAN];
|
||||
let bounds = make_fixed_bounds(0.0, 100.0);
|
||||
let stats = compute_feature_stats(&data, &bounds);
|
||||
|
||||
assert_eq!(stats.histogram.counts.iter().sum::<u64>(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_all_same_value() {
|
||||
let data = vec![42.0_f32; 1000];
|
||||
let bounds = make_fixed_bounds(0.0, 100.0);
|
||||
let stats = compute_feature_stats(&data, &bounds);
|
||||
|
||||
assert_eq!(stats.histogram.min, 42.0);
|
||||
assert_eq!(stats.histogram.max, 42.0);
|
||||
assert_eq!(stats.histogram.p1, 42.0);
|
||||
assert_eq!(stats.histogram.p99, 42.0);
|
||||
assert_eq!(stats.histogram.counts.iter().sum::<u64>(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_percentile_bounds() {
|
||||
// Data with outliers: 1 very low, 1 very high, 98 in middle
|
||||
let mut data: Vec<f32> = vec![0.0]; // Low outlier
|
||||
data.extend((1..99).map(|i| 50.0 + i as f32 * 0.01)); // Main data around 50
|
||||
data.push(1000.0); // High outlier
|
||||
|
||||
let bounds = make_percentile_bounds(2.0, 98.0);
|
||||
let stats = compute_feature_stats(&data, &bounds);
|
||||
|
||||
// Slider should exclude outliers
|
||||
assert!(stats.slider_min > 0.0);
|
||||
assert!(stats.slider_max < 1000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_bin_for_value() {
|
||||
let hist = Histogram {
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
p1: 10.0,
|
||||
p99: 90.0,
|
||||
counts: vec![0; 10], // 10 bins
|
||||
};
|
||||
|
||||
// Low outlier bin (bin 0)
|
||||
assert_eq!(hist.bin_for_value(5.0), 0);
|
||||
|
||||
// High outlier bin (bin 9)
|
||||
assert_eq!(hist.bin_for_value(95.0), 9);
|
||||
|
||||
// Middle bins (bins 1-8)
|
||||
let mid_value = 50.0;
|
||||
let bin = hist.bin_for_value(mid_value);
|
||||
assert!(bin >= 1 && bin <= 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_middle_bin_width() {
|
||||
let hist = Histogram {
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
p1: 10.0,
|
||||
p99: 90.0,
|
||||
counts: vec![0; 10], // 10 bins
|
||||
};
|
||||
|
||||
// Middle bins span p1 to p99 (80 units) across 8 bins (10 - 2 outlier bins)
|
||||
let expected_width = (90.0 - 10.0) / 8.0;
|
||||
assert!((hist.middle_bin_width() - expected_width).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_cardinality_caps_bins() {
|
||||
// Only 3 unique values - should cap bins at 3
|
||||
let data = vec![1.0_f32, 1.0, 2.0, 2.0, 3.0, 3.0];
|
||||
let bounds = make_fixed_bounds(0.0, 100.0);
|
||||
let stats = compute_feature_stats(&data, &bounds);
|
||||
|
||||
// Bins should be capped at cardinality (3)
|
||||
assert_eq!(stats.histogram.counts.len(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Aggregation Semantics Tests
|
||||
// =========================================================================
|
||||
|
||||
mod aggregation {
|
||||
/// Test that min/max aggregation correctly handles NaN values
|
||||
#[test]
|
||||
fn min_max_skips_nan() {
|
||||
let values = vec![10.0_f32, f32::NAN, 20.0, f32::NAN, 5.0];
|
||||
|
||||
let mut min = f32::INFINITY;
|
||||
let mut max = f32::NEG_INFINITY;
|
||||
for &v in &values {
|
||||
if v.is_finite() {
|
||||
if v < min {
|
||||
min = v;
|
||||
}
|
||||
if v > max {
|
||||
max = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(min, 5.0);
|
||||
assert_eq!(max, 20.0);
|
||||
}
|
||||
|
||||
/// Test that counting only counts non-NaN values
|
||||
#[test]
|
||||
fn count_skips_nan() {
|
||||
let values = vec![1.0_f32, f32::NAN, 2.0, f32::NAN, 3.0];
|
||||
let count = values.iter().filter(|v| v.is_finite()).count();
|
||||
assert_eq!(count, 3);
|
||||
}
|
||||
|
||||
/// Test enum value counting with indices
|
||||
#[test]
|
||||
fn enum_value_counting() {
|
||||
// Enum values: 0.0=Detached, 1.0=Semi, 2.0=Terraced, 3.0=Flat
|
||||
let values = vec![0.0_f32, 1.0, 1.0, 2.0, f32::NAN, 3.0, 1.0];
|
||||
let enum_count = 4;
|
||||
|
||||
let mut counts = vec![0u64; enum_count];
|
||||
for &v in &values {
|
||||
if v.is_finite() {
|
||||
let idx = v as usize;
|
||||
if idx < enum_count {
|
||||
counts[idx] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(counts[0], 1); // Detached
|
||||
assert_eq!(counts[1], 3); // Semi
|
||||
assert_eq!(counts[2], 1); // Terraced
|
||||
assert_eq!(counts[3], 1); // Flat
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// H3 Resolution Tests
|
||||
// =========================================================================
|
||||
|
||||
mod h3_resolution {
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn parent_cell_at_lower_resolution() {
|
||||
// Resolution 9 cell
|
||||
let child = h3o::CellIndex::from_str("8928308280fffff").unwrap();
|
||||
|
||||
// Get parent at resolution 7
|
||||
let parent_res = h3o::Resolution::try_from(7).unwrap();
|
||||
let parent = child.parent(parent_res).unwrap();
|
||||
|
||||
assert_eq!(parent.resolution(), parent_res);
|
||||
|
||||
// Child should be contained in parent
|
||||
assert!(parent.children(child.resolution()).any(|c| c == child));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_resolution_returns_self() {
|
||||
let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap();
|
||||
let res = cell.resolution();
|
||||
|
||||
// Getting parent at same resolution should return the cell itself
|
||||
let parent = cell.parent(res);
|
||||
assert_eq!(parent, Some(cell));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_resolution_parent_fails() {
|
||||
// Resolution 9 cell
|
||||
let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap();
|
||||
|
||||
// Try to get "parent" at higher resolution (impossible)
|
||||
let higher_res = h3o::Resolution::try_from(10).unwrap();
|
||||
let parent = cell.parent(higher_res);
|
||||
assert!(parent.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Edge Cases and Error Handling
|
||||
// =========================================================================
|
||||
|
||||
mod edge_cases {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn very_large_coordinates() {
|
||||
let lat = vec![89.9_f32, -89.9];
|
||||
let lon = vec![179.9_f32, -179.9];
|
||||
let grid = GridIndex::build(&lat, &lon, 0.01);
|
||||
|
||||
let result = grid.query(-90.0, -180.0, 90.0, 180.0);
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_at_float_precision_boundary() {
|
||||
let value = 100.0_f32;
|
||||
let filter = ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min: 100.0 - f32::EPSILON,
|
||||
max: 100.0 + f32::EPSILON,
|
||||
};
|
||||
|
||||
assert!(row_passes_filters(0, &[filter], &[], &[value], 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_filter_with_fractional_index() {
|
||||
// What happens if the stored value isn't exactly an integer?
|
||||
let feature_data = vec![1.5_f32]; // Not exactly 1.0 or 2.0
|
||||
let enum_filters = vec![ParsedEnumFilter {
|
||||
feat_idx: 0,
|
||||
allowed: vec![1.0, 2.0],
|
||||
}];
|
||||
|
||||
// 1.5 is not in the allowed list [1.0, 2.0]
|
||||
assert!(!row_passes_filters(0, &[], &enum_filters, &feature_data, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[test]
|
||||
fn bounds_with_inverted_min_max() {
|
||||
// What if south > north? (Invalid input)
|
||||
// The parse_bounds function doesn't validate this
|
||||
let (south, _west, north, _east) = parse_bounds("52.0,-0.5,51.0,0.5").unwrap();
|
||||
assert_eq!(south, 52.0);
|
||||
assert_eq!(north, 51.0);
|
||||
// south > north is allowed by parsing but logically invalid
|
||||
|
||||
// GridIndex should handle this gracefully
|
||||
let lat = vec![51.5_f32];
|
||||
let lon = vec![-0.1_f32];
|
||||
let grid = GridIndex::build(&lat, &lon, 0.01);
|
||||
|
||||
// Query with inverted bounds returns empty (row_min > row_max is rejected)
|
||||
let result = grid.query(52.0, -0.5, 51.0, 0.5);
|
||||
assert!(result.is_empty(), "Inverted bounds should return empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infinity_values_in_data() {
|
||||
// NOTE: The current implementation uses !is_nan() not is_finite()
|
||||
// So INFINITY values ARE included in min/max calculations.
|
||||
// This documents current behavior - consider if this should be fixed.
|
||||
let data = vec![f32::INFINITY, f32::NEG_INFINITY, 50.0];
|
||||
let bounds = Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
};
|
||||
let stats = compute_feature_stats(&data, &bounds);
|
||||
|
||||
// Current behavior: infinity is included (uses !is_nan())
|
||||
assert_eq!(stats.histogram.min, f32::NEG_INFINITY);
|
||||
assert_eq!(stats.histogram.max, f32::INFINITY);
|
||||
// All 3 values are counted (none are NaN)
|
||||
assert_eq!(stats.histogram.counts.iter().sum::<u64>(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_finite_values() {
|
||||
// Test that normal finite values work correctly
|
||||
let data = vec![10.0_f32, 20.0, 30.0];
|
||||
let bounds = Bounds::Fixed {
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
};
|
||||
let stats = compute_feature_stats(&data, &bounds);
|
||||
|
||||
assert_eq!(stats.histogram.min, 10.0);
|
||||
assert_eq!(stats.histogram.max, 30.0);
|
||||
assert_eq!(stats.histogram.counts.iter().sum::<u64>(), 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -222,4 +222,108 @@ mod tests {
|
|||
let grid = GridIndex::build(&[], &[], 0.1);
|
||||
assert!(grid.query(-90.0, -180.0, 90.0, 180.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_point_inside_query() {
|
||||
let lat = vec![51.5_f32];
|
||||
let lon = vec![-0.1_f32];
|
||||
let grid = GridIndex::build(&lat, &lon, 0.01);
|
||||
|
||||
let result = grid.query(51.4, -0.2, 51.6, 0.0);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_point_outside_query() {
|
||||
let lat = vec![51.5_f32];
|
||||
let lon = vec![-0.1_f32];
|
||||
let grid = GridIndex::build(&lat, &lon, 0.01);
|
||||
|
||||
let result = grid.query(52.0, 0.0, 53.0, 1.0);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_points_partial_query() {
|
||||
let lat = vec![51.5_f32, 51.6, 51.7, 52.0];
|
||||
let lon = vec![-0.1_f32, -0.1, -0.1, -0.1];
|
||||
let grid = GridIndex::build(&lat, &lon, 0.01);
|
||||
|
||||
let result = grid.query(51.4, -0.2, 51.8, 0.0);
|
||||
assert_eq!(result.len(), 3);
|
||||
assert!(result.contains(&0));
|
||||
assert!(result.contains(&1));
|
||||
assert!(result.contains(&2));
|
||||
assert!(!result.contains(&3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_at_grid_boundary() {
|
||||
let lat = vec![51.0_f32, 51.01, 51.02];
|
||||
let lon = vec![0.0_f32, 0.01, 0.02];
|
||||
let grid = GridIndex::build(&lat, &lon, 0.01);
|
||||
|
||||
let result = grid.query(50.99, -0.01, 51.005, 0.005);
|
||||
assert!(result.contains(&0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_each_matches_query() {
|
||||
let lat = vec![51.5_f32, 51.6, 51.7];
|
||||
let lon = vec![-0.1_f32, -0.2, -0.3];
|
||||
let grid = GridIndex::build(&lat, &lon, 0.01);
|
||||
|
||||
let query_result = grid.query(51.4, -0.25, 51.65, 0.0);
|
||||
|
||||
let mut foreach_result = Vec::new();
|
||||
grid.for_each_in_bounds(51.4, -0.25, 51.65, 0.0, |idx| {
|
||||
foreach_result.push(idx);
|
||||
});
|
||||
|
||||
assert_eq!(query_result.len(), foreach_result.len());
|
||||
for idx in &query_result {
|
||||
assert!(foreach_result.contains(idx));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_coordinates() {
|
||||
let lat = vec![-33.9_f32, -33.8, -33.7];
|
||||
let lon = vec![151.2_f32, 151.3, 151.4];
|
||||
let grid = GridIndex::build(&lat, &lon, 0.01);
|
||||
|
||||
let result = grid.query(-34.0, 151.1, -33.65, 151.5);
|
||||
assert_eq!(result.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_bounds_completely_outside_grid() {
|
||||
let lat = vec![51.5_f32];
|
||||
let lon = vec![-0.1_f32];
|
||||
let grid = GridIndex::build(&lat, &lon, 0.01);
|
||||
|
||||
let result = grid.query(0.0, 100.0, 10.0, 110.0);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn very_small_cell_size() {
|
||||
let lat = vec![51.5_f32, 51.5001, 51.5002];
|
||||
let lon = vec![-0.1_f32, -0.1001, -0.1002];
|
||||
let grid = GridIndex::build(&lat, &lon, 0.0001);
|
||||
|
||||
let result = grid.query(51.4999, -0.1003, 51.5003, -0.0999);
|
||||
assert_eq!(result.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn very_large_coordinates() {
|
||||
let lat = vec![89.9_f32, -89.9];
|
||||
let lon = vec![179.9_f32, -179.9];
|
||||
let grid = GridIndex::build(&lat, &lon, 0.01);
|
||||
|
||||
let result = grid.query(-90.0, -180.0, 90.0, 180.0);
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue