250 lines
8.2 KiB
Rust
250 lines
8.2 KiB
Rust
#[cfg(test)]
|
|
mod grid_index_tests {
|
|
use crate::grid_index::GridIndex;
|
|
|
|
#[test]
|
|
fn query_bounds_fully_below_grid_returns_empty() {
|
|
let lat = vec![50.0, 50.5, 51.0];
|
|
let lon = vec![0.0, 0.5, 1.0];
|
|
let grid = GridIndex::build(&lat, &lon, 0.01);
|
|
|
|
let results = grid.query(10.0, -10.0, 20.0, -5.0);
|
|
assert!(
|
|
results.is_empty(),
|
|
"Should return empty for bounds fully below grid"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn query_bounds_fully_above_grid_returns_empty() {
|
|
let lat = vec![50.0, 50.5, 51.0];
|
|
let lon = vec![0.0, 0.5, 1.0];
|
|
let grid = GridIndex::build(&lat, &lon, 0.01);
|
|
|
|
let results = grid.query(80.0, 50.0, 90.0, 60.0);
|
|
assert!(
|
|
results.is_empty(),
|
|
"Should return empty for bounds fully above grid"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn query_inverted_bounds_returns_empty() {
|
|
let lat = vec![50.0, 50.5, 51.0];
|
|
let lon = vec![0.0, 0.5, 1.0];
|
|
let grid = GridIndex::build(&lat, &lon, 0.01);
|
|
|
|
// south > north
|
|
let results = grid.query(52.0, 0.0, 49.0, 1.0);
|
|
assert!(
|
|
results.is_empty(),
|
|
"Should return empty for inverted bounds"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn for_each_bounds_fully_outside_yields_nothing() {
|
|
let lat = vec![50.0, 50.5, 51.0];
|
|
let lon = vec![0.0, 0.5, 1.0];
|
|
let grid = GridIndex::build(&lat, &lon, 0.01);
|
|
|
|
let mut count = 0;
|
|
grid.for_each_in_bounds(10.0, -10.0, 20.0, -5.0, |_| count += 1);
|
|
assert_eq!(
|
|
count, 0,
|
|
"for_each should yield nothing for out-of-bounds query"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn query_with_large_cells_outside_returns_empty() {
|
|
// Previously, out-of-bounds queries with large cell sizes would
|
|
// scan cell (0,0) which could contain data. Now returns empty.
|
|
let lat = vec![50.0];
|
|
let lon = vec![0.0];
|
|
let grid = GridIndex::build(&lat, &lon, 1.0);
|
|
|
|
let results = grid.query(0.0, -50.0, 10.0, -40.0);
|
|
assert!(
|
|
results.is_empty(),
|
|
"Should return empty even with large cell size"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn query_within_bounds_returns_correct_results() {
|
|
let lat = vec![50.0, 50.5, 51.0];
|
|
let lon = vec![0.0, 0.5, 1.0];
|
|
let grid = GridIndex::build(&lat, &lon, 0.01);
|
|
|
|
let results = grid.query(49.9, -0.1, 51.1, 1.1);
|
|
assert_eq!(results.len(), 3, "Should return all 3 points within bounds");
|
|
}
|
|
|
|
#[test]
|
|
fn query_partial_bounds_returns_subset() {
|
|
let lat = vec![50.0, 51.0, 52.0];
|
|
let lon = vec![0.0, 0.0, 0.0];
|
|
let grid = GridIndex::build(&lat, &lon, 0.01);
|
|
|
|
let results = grid.query(49.9, -0.1, 50.1, 0.1);
|
|
assert_eq!(results.len(), 1, "Should return only the point at lat=50");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod filter_tests {
|
|
use crate::data::EnumFeatureData;
|
|
use crate::filter::{parse_filters, row_passes_filters};
|
|
|
|
#[test]
|
|
fn nan_rows_fail_numeric_filter_even_with_infinite_range() {
|
|
let feature_names = vec!["price".to_string()];
|
|
let feature_data = vec![f64::NAN];
|
|
let enum_features: Vec<EnumFeatureData> = vec![];
|
|
|
|
let (numeric, enums) =
|
|
parse_filters(Some("price:-inf:inf"), &feature_names, &enum_features);
|
|
assert_eq!(numeric.len(), 1, "Should parse -inf:inf as valid filter");
|
|
|
|
let passes = row_passes_filters(0, &numeric, &enums, &feature_data, 1, &enum_features);
|
|
assert!(!passes, "NaN should fail filter even with infinite range");
|
|
}
|
|
|
|
#[test]
|
|
fn empty_enum_filter_value_rejects_all() {
|
|
let enum_features = vec![EnumFeatureData {
|
|
name: "rating".to_string(),
|
|
values: vec!["A".to_string(), "B".to_string()],
|
|
data: vec![0],
|
|
}];
|
|
let feature_names: Vec<String> = vec![];
|
|
|
|
let (numeric, enums) = parse_filters(Some("rating:"), &feature_names, &enum_features);
|
|
assert_eq!(enums.len(), 1);
|
|
assert!(enums[0].allowed.is_empty());
|
|
|
|
let passes = row_passes_filters(0, &numeric, &enums, &[], 0, &enum_features);
|
|
assert!(!passes, "Empty allowed set should reject all rows");
|
|
}
|
|
|
|
#[test]
|
|
fn enum_filter_with_nonexistent_values_produces_empty_allowed() {
|
|
let enum_features = vec![EnumFeatureData {
|
|
name: "rating".to_string(),
|
|
values: vec!["A".to_string(), "B".to_string()],
|
|
data: vec![0],
|
|
}];
|
|
let feature_names: Vec<String> = vec![];
|
|
|
|
let (_, enums) = parse_filters(Some("rating:X|Y|Z"), &feature_names, &enum_features);
|
|
assert_eq!(enums.len(), 1);
|
|
assert!(enums[0].allowed.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_numeric_min_is_silently_skipped() {
|
|
let feature_names = vec!["price".to_string()];
|
|
let enum_features: Vec<EnumFeatureData> = vec![];
|
|
|
|
let (numeric, enums) = parse_filters(
|
|
Some("price:not_a_number:200"),
|
|
&feature_names,
|
|
&enum_features,
|
|
);
|
|
assert_eq!(numeric.len(), 0);
|
|
assert_eq!(enums.len(), 0);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod json_tests {
|
|
#[test]
|
|
fn json_escaped_postcode_with_quotes_is_valid() {
|
|
use crate::routes::hexagons::write_json_escaped;
|
|
|
|
let mut buf = String::new();
|
|
buf.push_str("{\"postcode\":\"");
|
|
write_json_escaped(&mut buf, "SW1A \"test");
|
|
buf.push_str("\"}");
|
|
|
|
let result: Result<serde_json::Value, _> = serde_json::from_str(&buf);
|
|
assert!(
|
|
result.is_ok(),
|
|
"Escaped quote should produce valid JSON: {}",
|
|
buf
|
|
);
|
|
assert_eq!(result.unwrap()["postcode"].as_str().unwrap(), "SW1A \"test");
|
|
}
|
|
|
|
#[test]
|
|
fn json_escaped_postcode_with_backslash_is_valid() {
|
|
use crate::routes::hexagons::write_json_escaped;
|
|
|
|
let mut buf = String::new();
|
|
buf.push_str("{\"postcode\":\"");
|
|
write_json_escaped(&mut buf, "SW1A\\2AA");
|
|
buf.push_str("\"}");
|
|
|
|
let result: Result<serde_json::Value, _> = serde_json::from_str(&buf);
|
|
assert!(
|
|
result.is_ok(),
|
|
"Escaped backslash should produce valid JSON: {}",
|
|
buf
|
|
);
|
|
assert_eq!(result.unwrap()["postcode"].as_str().unwrap(), "SW1A\\2AA");
|
|
}
|
|
|
|
#[test]
|
|
fn nan_is_not_valid_json() {
|
|
use std::fmt::Write;
|
|
// Verify that raw NaN in write! is still invalid JSON (documenting the risk
|
|
// that the is_finite() guard in write_hexagons_json protects against).
|
|
let mut buf = String::new();
|
|
write!(buf, "{{\"min_price\":{}}}", f64::NAN).unwrap();
|
|
|
|
let result: Result<serde_json::Value, _> = serde_json::from_str(&buf);
|
|
assert!(result.is_err(), "Raw NaN should produce invalid JSON");
|
|
}
|
|
|
|
#[test]
|
|
fn infinity_is_not_valid_json() {
|
|
use std::fmt::Write;
|
|
let mut buf = String::new();
|
|
write!(buf, "{{\"min_price\":{}}}", f64::INFINITY).unwrap();
|
|
|
|
let result: Result<serde_json::Value, _> = serde_json::from_str(&buf);
|
|
assert!(result.is_err(), "Raw Infinity should produce invalid JSON");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod enum_encoding_tests {
|
|
#[test]
|
|
fn u8_cast_wraps_around_beyond_255() {
|
|
// Documents the underlying u8 wrapping behavior that the truncation
|
|
// guard in property.rs now prevents.
|
|
let num_values = 300usize;
|
|
let indices: Vec<u8> = (0..num_values).map(|index| index as u8).collect();
|
|
|
|
assert_eq!(indices[0], indices[256], "u8 wraps: 0 == 256");
|
|
assert_eq!(indices[1], indices[257], "u8 wraps: 1 == 257");
|
|
|
|
use std::collections::HashMap;
|
|
let values: Vec<String> = (0..num_values).map(|i| format!("val_{}", i)).collect();
|
|
let value_to_idx: HashMap<&str, u8> = values
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, value)| (value.as_str(), index as u8))
|
|
.collect();
|
|
|
|
let unique_indices: std::collections::HashSet<u8> =
|
|
value_to_idx.values().cloned().collect();
|
|
assert!(
|
|
unique_indices.len() < num_values,
|
|
"Without the truncation guard, {} values produce only {} unique u8 indices",
|
|
num_values,
|
|
unique_indices.len()
|
|
);
|
|
}
|
|
}
|