Add unit tests

This commit is contained in:
Andras Schmelczer 2026-02-05 21:19:01 +00:00
parent 55598aaaa0
commit 5fe192d25a
9 changed files with 512 additions and 1037 deletions

65
server-rs/Cargo.lock generated
View file

@ -322,9 +322,9 @@ checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]] [[package]]
name = "bytemuck" name = "bytemuck"
version = "1.24.0" version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
dependencies = [ dependencies = [
"bytemuck_derive", "bytemuck_derive",
] ]
@ -348,9 +348,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.0" version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
dependencies = [ dependencies = [
"serde", "serde",
] ]
@ -411,9 +411,9 @@ dependencies = [
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.56" version = "4.5.57"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e" checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@ -421,9 +421,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.5.56" version = "4.5.57"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0" checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@ -749,9 +749,9 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.1.8" version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [ dependencies = [
"crc32fast", "crc32fast",
"miniz_oxide", "miniz_oxide",
@ -1161,14 +1161,13 @@ dependencies = [
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.19" version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
@ -2600,9 +2599,9 @@ dependencies = [
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.12.2" version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
@ -2612,9 +2611,9 @@ dependencies = [
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.13" version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
@ -2623,9 +2622,9 @@ dependencies = [
[[package]] [[package]]
name = "regex-syntax" name = "regex-syntax"
version = "0.8.8" version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
[[package]] [[package]]
name = "reqwest" name = "reqwest"
@ -2946,9 +2945,9 @@ checksum = "c1e9a774a6c28142ac54bb25d25562e6bcf957493a184f15ad4eebccb23e410a"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.11" version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]] [[package]]
name = "slotmap" name = "slotmap"
@ -3130,9 +3129,9 @@ dependencies = [
[[package]] [[package]]
name = "system-configuration" name = "system-configuration"
version = "0.6.1" version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"core-foundation 0.9.4", "core-foundation 0.9.4",
@ -3649,9 +3648,9 @@ dependencies = [
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.5" version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
@ -4009,18 +4008,18 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.37" version = "0.8.38"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac" checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.37" version = "0.8.38"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0" checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -4089,15 +4088,15 @@ dependencies = [
[[package]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.5.5" version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.17" version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
[[package]] [[package]]
name = "zstd" name = "zstd"

View file

@ -4,4 +4,4 @@ mod property;
pub use poi::{POICategoryGroup, POIData}; pub use poi::{POICategoryGroup, POIData};
pub use postcodes::PostcodeData; pub use postcodes::PostcodeData;
pub use property::{compute_feature_stats, precompute_h3, Histogram, PropertyData}; pub use property::{precompute_h3, Histogram, PropertyData};

View file

@ -5,8 +5,6 @@ mod metrics;
mod og_middleware; mod og_middleware;
pub mod parsing; pub mod parsing;
mod routes; mod routes;
#[cfg(test)]
mod semantic_tests;
mod state; mod state;
pub mod utils; pub mod utils;

View file

@ -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)] #[cfg(test)]
@ -76,8 +92,14 @@ mod tests {
#[test] #[test]
fn parse_bounds_valid() { 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!(
assert_eq!(parse_bounds("-51.5, -0.1, 51.6, 0.2").unwrap(), (-51.5, -0.1, 51.6, 0.2)); 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] #[test]
@ -88,6 +110,14 @@ mod tests {
assert!(parse_bounds("").is_err()); 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] #[test]
fn h3_cell_bounds_applies_buffer() { fn h3_cell_bounds_applies_buffer() {
let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap(); let cell = h3o::CellIndex::from_str("8928308280fffff").unwrap();
@ -102,39 +132,143 @@ mod tests {
#[test] #[test]
fn h3_cell_bounds_returns_degrees_not_radians() { 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 cell = h3o::CellIndex::from_str("8928308280fffff").unwrap();
let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.0); let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.0);
// If h3o returned radians, values would be < π ≈ 3.14 assert!(
// Latitude ~37.77° proves we're getting degrees, not radians min_lat > 30.0 && min_lat < 45.0,
assert!(min_lat > 30.0 && min_lat < 45.0, "min_lat {} should be ~37° (degrees)", min_lat); "min_lat {} should be ~37° (degrees)",
assert!(max_lat > 30.0 && max_lat < 45.0, "max_lat {} should be ~37° (degrees)", max_lat); min_lat
);
// Longitude ~-122° also proves degrees (radians would be < π) assert!(
assert!(min_lon < -100.0, "min_lon {} should be ~-122° (degrees)", min_lon); max_lat > 30.0 && max_lat < 45.0,
assert!(max_lon < -100.0, "max_lon {} should be ~-122° (degrees)", max_lon); "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] #[test]
fn bounds_intersect_overlapping() { 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)); 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)); 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)); 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)); assert!(bounds_intersect(0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 2.0, 1.0));
} }
#[test] #[test]
fn bounds_intersect_non_overlapping() { 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)); 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)); 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)); 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());
}
} }

View file

@ -116,9 +116,39 @@ mod tests {
map 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] #[test]
fn parse_filters_numeric() { 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.len(), 1);
assert_eq!(numeric[0].feat_idx, 0); assert_eq!(numeric[0].feat_idx, 0);
assert_eq!(numeric[0].min, 100.0); assert_eq!(numeric[0].min, 100.0);
@ -128,11 +158,11 @@ mod tests {
#[test] #[test]
fn parse_filters_enum() { 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!(numeric.is_empty());
assert_eq!(enums.len(), 1); assert_eq!(enums.len(), 1);
assert_eq!(enums[0].feat_idx, 2); 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(&(0.0_f32).to_bits())); // A = index 0
assert!(enums[0].allowed.contains(&(2.0_f32).to_bits())); // C = index 2 assert!(enums[0].allowed.contains(&(2.0_f32).to_bits())); // C = index 2
assert_eq!(enums[0].allowed.len(), 2); assert_eq!(enums[0].allowed.len(), 2);
@ -152,7 +182,11 @@ mod tests {
#[test] #[test]
fn row_passes_numeric_filter() { 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]; let data = vec![15.0, 5.0, f32::NAN];
assert!(row_passes_filters(0, &filters, &[], &data, 1)); assert!(row_passes_filters(0, &filters, &[], &data, 1));
@ -162,8 +196,11 @@ mod tests {
#[test] #[test]
fn row_passes_enum_filter() { fn row_passes_enum_filter() {
let filters = vec![ParsedEnumFilter { feat_idx: 0, allowed: vec![0.0, 2.0] }]; let allowed: FxHashSet<u32> = [0.0_f32, 2.0].iter().map(|v| v.to_bits()).collect();
// Row 0: value 0.0 (allowed), Row 1: value 1.0 (not allowed), Row 2: value 2.0 (allowed), Row 3: NaN (fails) let filters = vec![ParsedEnumFilter {
feat_idx: 0,
allowed,
}];
let data = vec![0.0, 1.0, 2.0, f32::NAN]; let data = vec![0.0, 1.0, 2.0, f32::NAN];
assert!(row_passes_filters(0, &[], &filters, &data, 1)); 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(2, &[], &filters, &data, 1));
assert!(!row_passes_filters(3, &[], &filters, &data, 1)); // NaN fails 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));
}
} }

View file

@ -282,7 +282,9 @@ pub async fn get_hexagons(
let t_total = t0.elapsed(); let t_total = t0.elapsed();
info!( info!(
resolution, 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 = num_filters,
filters_raw = filters_str.as_deref().unwrap_or("-"), filters_raw = filters_str.as_deref().unwrap_or("-"),
agg_ms = format_args!("{:.1}", t_agg.as_secs_f64() * 1000.0), agg_ms = format_args!("{:.1}", t_agg.as_secs_f64() * 1000.0),

View file

@ -173,6 +173,8 @@ pub async fn get_postcodes(
// Build response, filtering postcodes to only those whose polygon intersects query bounds // Build response, filtering postcodes to only those whose polygon intersects query bounds
let mut features = Vec::with_capacity(postcode_aggs.len()); 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 { for (pc_idx, aggregation) in postcode_aggs {
if aggregation.count == 0 { 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) { if !bounds_intersect(pc_south, pc_west, pc_north, pc_east, south, west, north, east) {
filtered_out += 1;
continue; continue;
} }
@ -235,7 +238,10 @@ pub async fn get_postcodes(
let t_total = t0.elapsed(); let t_total = t0.elapsed();
info!( 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 = num_filters,
filters_raw = filters_str.as_deref().unwrap_or("-"), filters_raw = filters_str.as_deref().unwrap_or("-"),
total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0), total_ms = format_args!("{:.1}", t_total.as_secs_f64() * 1000.0),

View file

@ -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);
}
}
}

View file

@ -222,4 +222,108 @@ mod tests {
let grid = GridIndex::build(&[], &[], 0.1); let grid = GridIndex::build(&[], &[], 0.1);
assert!(grid.query(-90.0, -180.0, 90.0, 180.0).is_empty()); 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);
}
} }