perfect-postcode/server-rs/src/tests.rs
2026-02-02 20:10:32 +00:00

251 lines
8.3 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_f32, 50.5, 51.0];
let lon = vec![0.0_f32, 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_f32, 50.5, 51.0];
let lon = vec![0.0_f32, 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_f32, 50.5, 51.0];
let lon = vec![0.0_f32, 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_f32, 50.5, 51.0];
let lon = vec![0.0_f32, 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_f32];
let lon = vec![0.0_f32];
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_f32, 50.5, 51.0];
let lon = vec![0.0_f32, 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_f32, 51.0, 52.0];
let lon = vec![0.0_f32, 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![f32::NAN];
let enum_features: Vec<EnumFeatureData> = vec![];
let enum_data: Vec<u8> = 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_data, 0);
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()],
}];
let feature_names: Vec<String> = vec![];
// Row-major enum data: 1 row, 1 enum, value=0 (index into "A")
let enum_data: Vec<u8> = vec![0];
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_data, 1);
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()],
}];
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()
);
}
}