#[cfg(test)]
mod tests {
use crate::geometry::PropertyValue;
use crate::layer::Layer;
use crate::models::AltitudeMode;
use crate::picking::{HitCategory, PickOptions, PickQuery};
use crate::visualization::{
ColorRamp, ColorStop, ColumnInstance, ColumnInstanceSet, ExtrusionParams, GeoGrid,
GridExtrusionLayer, GridScalarLayer, InstancedColumnLayer, PointCloudLayer, PointInstance,
PointInstanceSet, ScalarField2D, VisualizationOverlay,
};
use crate::MapState;
use rustial_math::GeoCoord;
fn test_ramp() -> ColorRamp {
ColorRamp::new(vec![
ColorStop {
value: 0.0,
color: [0.0, 0.0, 1.0, 1.0],
},
ColorStop {
value: 1.0,
color: [1.0, 0.0, 0.0, 1.0],
},
])
}
#[test]
fn geo_grid_cell_center_round_trips_through_cell_at_geo() {
let grid = GeoGrid::new(GeoCoord::from_lat_lon(51.1, 17.0), 8, 6, 100.0, 80.0);
for row in 0..grid.rows {
for col in 0..grid.cols {
let center = grid.cell_center(row, col).expect("cell center");
let hit = grid.cell_at_geo(¢er);
assert_eq!(
hit,
Some((row, col)),
"round-trip failed for ({row}, {col})"
);
}
}
}
#[test]
fn map_state_set_grid_scalar_replaces_existing_named_layer() {
let mut state = MapState::new();
let target = GeoCoord::from_lat_lon(10.0, 20.0);
state.set_grid_scalar(
"density",
GeoGrid::new(target, 1, 1, 100.0, 100.0),
ScalarField2D::from_data(1, 1, vec![1.0]),
test_ramp(),
);
state.set_grid_scalar(
"density",
GeoGrid::new(target, 2, 2, 50.0, 50.0),
ScalarField2D::from_data(2, 2, vec![2.0, 3.0, 4.0, 5.0]),
test_ramp(),
);
assert_eq!(state.layers().len(), 1);
let layer = state
.layers()
.get(0)
.expect("layer")
.as_any()
.downcast_ref::<GridScalarLayer>()
.expect("grid scalar layer");
assert_eq!(layer.grid.rows, 2);
assert_eq!(layer.grid.cols, 2);
assert_eq!(layer.field.sample(1, 1), Some(5.0));
}
#[test]
fn map_state_set_grid_extrusion_replaces_existing_named_layer() {
let mut state = MapState::new();
let target = GeoCoord::from_lat_lon(10.0, 20.0);
state.set_grid_extrusion(
"surface",
GeoGrid::new(target, 1, 1, 100.0, 100.0),
ScalarField2D::from_data(1, 1, vec![2.0]),
test_ramp(),
ExtrusionParams {
height_scale: 2.0,
base_meters: 1.0,
},
);
let first_id = state.layers().get(0).expect("layer").id();
let mut field = ScalarField2D::from_data(1, 1, vec![2.0]);
field.update_values(vec![3.0]);
state.set_grid_extrusion(
"surface",
GeoGrid::new(target, 1, 1, 100.0, 100.0),
field,
test_ramp(),
ExtrusionParams {
height_scale: 4.0,
base_meters: 6.0,
},
);
assert_eq!(state.layers().len(), 1);
let layer = state
.layers()
.get(0)
.expect("layer")
.as_any()
.downcast_ref::<GridExtrusionLayer>()
.expect("grid extrusion layer");
assert_eq!(layer.id(), first_id);
assert_eq!(layer.field.sample(0, 0), Some(3.0));
assert_eq!(layer.field.value_generation, 1);
assert!((layer.params.height_scale - 4.0).abs() < 1e-9);
assert!((layer.params.base_meters - 6.0).abs() < 1e-9);
}
#[test]
fn map_state_set_instanced_columns_replaces_existing_named_layer() {
let mut state = MapState::new();
let target = GeoCoord::from_lat_lon(10.0, 20.0);
state.set_instanced_columns(
"bars",
ColumnInstanceSet::new(vec![ColumnInstance::new(target, 10.0, 5.0).with_pick_id(1)]),
test_ramp(),
);
state.set_instanced_columns(
"bars",
ColumnInstanceSet::new(vec![ColumnInstance::new(target, 20.0, 6.0).with_pick_id(2)]),
test_ramp(),
);
assert_eq!(state.layers().len(), 1);
let layer = state
.layers()
.get(0)
.expect("layer")
.as_any()
.downcast_ref::<InstancedColumnLayer>()
.expect("instanced column layer");
assert_eq!(layer.columns.columns.len(), 1);
assert_eq!(layer.columns.columns[0].pick_id, 2);
assert!((layer.columns.columns[0].height - 20.0).abs() < 1e-9);
}
#[test]
fn map_state_set_point_cloud_replaces_existing_named_layer() {
let mut state = MapState::new();
let target = GeoCoord::from_lat_lon(10.0, 20.0);
state.set_point_cloud(
"events",
PointInstanceSet::new(vec![PointInstance::new(target, 5.0).with_pick_id(1)]),
test_ramp(),
);
state.set_point_cloud(
"events",
PointInstanceSet::new(vec![PointInstance::new(target, 8.0)
.with_pick_id(2)
.with_intensity(0.25)]),
test_ramp(),
);
assert_eq!(state.layers().len(), 1);
let layer = state
.layers()
.get(0)
.expect("layer")
.as_any()
.downcast_ref::<PointCloudLayer>()
.expect("point cloud layer");
assert_eq!(layer.points.points.len(), 1);
assert_eq!(layer.points.points[0].pick_id, 2);
assert!((layer.points.points[0].radius - 8.0).abs() < 1e-9);
}
#[test]
fn geo_grid_bounds_are_ordered_at_equator_and_high_latitude() {
let equator = GeoGrid::new(GeoCoord::from_lat_lon(0.5, 10.0), 4, 4, 250.0, 250.0);
let (eq_nw, eq_se) = equator.geo_bounds();
assert!(eq_nw.lat > eq_se.lat);
assert!(eq_nw.lon < eq_se.lon);
let high_lat = GeoGrid::new(GeoCoord::from_lat_lon(70.0, 25.0), 4, 4, 250.0, 250.0);
let (hi_nw, hi_se) = high_lat.geo_bounds();
assert!(hi_nw.lat > hi_se.lat);
assert!(hi_nw.lon < hi_se.lon);
}
#[test]
fn scalar_field_normalization_ignores_nan_samples() {
let field = ScalarField2D::from_data(1, 3, vec![0.0, f32::NAN, 100.0]);
assert_eq!(field.normalized(0, 0), Some(0.0));
assert_eq!(field.normalized(0, 1), None);
assert_eq!(field.normalized(0, 2), Some(1.0));
}
#[test]
fn color_ramp_evaluate_interpolates_and_clamps() {
let ramp = test_ramp();
assert_eq!(ramp.evaluate(0.0), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(ramp.evaluate(1.0), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(ramp.evaluate(-10.0), [0.0, 0.0, 1.0, 1.0]);
assert_eq!(ramp.evaluate(10.0), [1.0, 0.0, 0.0, 1.0]);
let mid = ramp.evaluate(0.5);
assert!((mid[0] - 0.5).abs() < 1e-6);
assert!((mid[1] - 0.0).abs() < 1e-6);
assert!((mid[2] - 0.5).abs() < 1e-6);
assert!((mid[3] - 1.0).abs() < 1e-6);
}
#[test]
fn color_ramp_texture_data_produces_expected_rgba_bytes() {
let ramp = test_ramp();
let tex = ramp.as_texture_data(2);
assert_eq!(tex, vec![0, 0, 255, 255, 255, 0, 0, 255]);
}
#[test]
fn grid_scalar_layer_participates_in_map_state_pick() {
let mut state = MapState::new();
state.set_viewport(800, 600);
let target = GeoCoord::from_lat_lon(51.5, -0.12);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
let grid = GeoGrid::new(target, 2, 2, 100.0, 100.0);
let cell_center = grid.cell_center(0, 0).expect("cell center");
let field = ScalarField2D::from_data(2, 2, vec![10.0, 20.0, 30.0, 40.0]);
let layer = GridScalarLayer::new("density", grid, field, test_ramp());
state.push_layer(Box::new(layer));
state.update();
let result = state.pick(PickQuery::geo(cell_center), PickOptions::new());
let hit = result.first().expect("grid hit");
assert_eq!(hit.category, HitCategory::Feature);
assert_eq!(hit.layer_id.as_deref(), Some("density"));
assert_eq!(hit.feature_index, Some(0));
assert_eq!(hit.feature_id.as_deref(), Some("0:0"));
assert_eq!(hit.properties.get("row"), Some(&PropertyValue::Number(0.0)));
assert_eq!(hit.properties.get("col"), Some(&PropertyValue::Number(0.0)));
assert_eq!(
hit.properties.get("value"),
Some(&PropertyValue::Number(10.0))
);
}
#[test]
fn grid_extrusion_layer_pick_reports_height() {
let mut state = MapState::new();
state.set_viewport(800, 600);
let target = GeoCoord::from_lat_lon(40.0, -74.0);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
let grid = GeoGrid::new(target, 1, 1, 100.0, 100.0);
let cell_center = grid.cell_center(0, 0).expect("cell center");
let field = ScalarField2D::from_data(1, 1, vec![12.0]);
let layer = GridExtrusionLayer::new("extrusion", grid, field, test_ramp()).with_params(
ExtrusionParams {
height_scale: 5.0,
base_meters: 2.0,
},
);
state.push_layer(Box::new(layer));
state.update();
let result = state.pick(PickQuery::geo(cell_center), PickOptions::new());
let hit = result.first().expect("extrusion hit");
assert_eq!(hit.feature_index, Some(0));
assert_eq!(
hit.properties.get("value"),
Some(&PropertyValue::Number(12.0))
);
assert_eq!(
hit.properties.get("height"),
Some(&PropertyValue::Number(60.0))
);
}
#[test]
fn instanced_column_layer_pick_returns_stable_pick_id() {
let mut state = MapState::new();
state.set_viewport(800, 600);
let target = GeoCoord::from_lat_lon(48.8566, 2.3522);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
let columns = ColumnInstanceSet::new(vec![ColumnInstance::new(target, 50.0, 20.0)
.with_pick_id(42)
.with_color([1.0, 0.0, 0.0, 1.0])
.with_base(5.0)]);
let layer = InstancedColumnLayer::new("columns", columns, test_ramp());
state.push_layer(Box::new(layer));
state.update();
let result = state.pick(PickQuery::geo(target), PickOptions::new());
let hit = result.first().expect("column hit");
assert_eq!(hit.category, HitCategory::Feature);
assert_eq!(hit.feature_index, Some(0));
assert_eq!(hit.feature_id.as_deref(), Some("col:42"));
assert_eq!(
hit.properties.get("pick_id"),
Some(&PropertyValue::Number(42.0))
);
assert_eq!(
hit.properties.get("height"),
Some(&PropertyValue::Number(50.0))
);
assert_eq!(
hit.properties.get("width"),
Some(&PropertyValue::Number(20.0))
);
}
#[test]
fn point_cloud_layer_pick_returns_stable_pick_id() {
let mut state = MapState::new();
state.set_viewport(800, 600);
let target = GeoCoord::from_lat_lon(48.8566, 2.3522);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
let points = PointInstanceSet::new(vec![PointInstance::new(target, 12.0)
.with_pick_id(7)
.with_intensity(0.3)
.with_color([0.2, 0.8, 0.3, 0.9])]);
let layer = PointCloudLayer::new("points", points, test_ramp());
state.push_layer(Box::new(layer));
state.update();
let result = state.pick(PickQuery::geo(target), PickOptions::new());
let hit = result.first().expect("point hit");
assert_eq!(hit.category, HitCategory::Feature);
assert_eq!(hit.feature_index, Some(0));
assert_eq!(hit.feature_id.as_deref(), Some("pt:7"));
assert_eq!(
hit.properties.get("pick_id"),
Some(&PropertyValue::Number(7.0))
);
assert_eq!(
hit.properties.get("radius"),
Some(&PropertyValue::Number(12.0))
);
assert_eq!(
hit.properties.get("intensity"),
Some(&PropertyValue::Number(0.30000001192092896))
);
}
#[test]
fn frame_output_contains_visualization_overlays() {
let mut state = MapState::new();
state.set_viewport(800, 600);
let target = GeoCoord::from_lat_lon(51.1, 17.0);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
let grid = GeoGrid::new(target, 1, 1, 100.0, 100.0);
let field = ScalarField2D::from_data(1, 1, vec![7.0]);
state.push_layer(Box::new(GridScalarLayer::new(
"density",
grid.clone(),
field.clone(),
test_ramp(),
)));
let columns = ColumnInstanceSet::new(vec![ColumnInstance {
position: target,
height: 10.0,
base: 0.0,
width: 5.0,
color: None,
pick_id: 99,
altitude_mode: AltitudeMode::ClampToGround,
}]);
state.push_layer(Box::new(InstancedColumnLayer::new(
"bars",
columns.clone(),
test_ramp(),
)));
state.update();
let frame = state.frame_output();
assert_eq!(frame.visualization.len(), 2);
match &frame.visualization[0] {
VisualizationOverlay::GridScalar {
grid: g, field: f, ..
} => {
assert_eq!(g.rows, grid.rows);
assert_eq!(g.cols, grid.cols);
assert_eq!(f.sample(0, 0), field.sample(0, 0));
}
other => panic!("expected GridScalar overlay, got {other:?}"),
}
match &frame.visualization[1] {
VisualizationOverlay::Columns { columns: c, .. } => {
assert_eq!(c.columns.len(), columns.columns.len());
assert_eq!(c.columns[0].pick_id, 99);
}
other => panic!("expected Columns overlay, got {other:?}"),
}
}
#[test]
fn frame_output_contains_point_cloud_overlay() {
let mut state = MapState::new();
let target = GeoCoord::from_lat_lon(51.1, 17.0);
state.set_point_cloud(
"points",
PointInstanceSet::new(vec![PointInstance::new(target, 4.0)
.with_pick_id(11)
.with_intensity(0.6)]),
test_ramp(),
);
state.update();
let frame = state.frame_output();
match &frame.visualization[0] {
VisualizationOverlay::Points { points, .. } => {
assert_eq!(points.points.len(), 1);
assert_eq!(points.points[0].pick_id, 11);
}
other => panic!("expected Points overlay, got {other:?}"),
}
}
#[test]
fn scalar_field_update_values_bumps_value_generation_only() {
let mut field = ScalarField2D::from_data(2, 2, vec![1.0, 2.0, 3.0, 4.0]);
assert_eq!(field.generation, 0);
assert_eq!(field.value_generation, 0);
field.update_values(vec![5.0, 6.0, 7.0, 8.0]);
assert_eq!(field.generation, 0);
assert_eq!(field.value_generation, 1);
}
#[test]
fn column_instance_set_exposes_generation_contract() {
let set = ColumnInstanceSet::new(vec![ColumnInstance::new(
GeoCoord::from_lat_lon(0.0, 0.0),
10.0,
5.0,
)]);
assert_eq!(set.generation, 0);
assert_eq!(set.len(), 1);
}
#[test]
fn scalar_field_value_update_preserves_structural_generation() {
let mut field = ScalarField2D::from_data(4, 4, vec![0.0; 16]);
let gen_before = field.generation;
let val_gen_before = field.value_generation;
field.update_values(vec![1.0; 16]);
assert_eq!(
field.generation, gen_before,
"structural generation should not change on value-only update"
);
assert!(
field.value_generation > val_gen_before,
"value_generation should bump on update"
);
}
#[test]
fn column_instance_set_replacement_bumps_generation() {
let set1 = ColumnInstanceSet::new(vec![ColumnInstance::new(
GeoCoord::from_lat_lon(0.0, 0.0),
10.0,
5.0,
)]);
let gen1 = set1.generation;
let set2 = ColumnInstanceSet::new(vec![
ColumnInstance::new(GeoCoord::from_lat_lon(0.0, 0.0), 20.0, 5.0),
ColumnInstance::new(GeoCoord::from_lat_lon(1.0, 1.0), 30.0, 8.0),
]);
assert_ne!(set1.len(), set2.len());
assert_eq!(gen1, 0);
}
#[test]
fn grid_scalar_retained_update_through_frame_output() {
let mut state = MapState::new();
state.set_viewport(128, 128);
let grid = GeoGrid::new(GeoCoord::from_lat_lon(0.0, 0.0), 4, 4, 100.0, 100.0);
let field = ScalarField2D::from_data(4, 4, vec![0.0; 16]);
let ramp = ColorRamp::new(vec![
ColorStop {
value: 0.0,
color: [0.0, 0.0, 1.0, 1.0],
},
ColorStop {
value: 1.0,
color: [1.0, 0.0, 0.0, 1.0],
},
]);
state.set_grid_scalar("test-grid", grid.clone(), field, ramp.clone());
state.update();
let frame1 = state.frame_output();
let viz1 = &frame1.visualization;
assert_eq!(viz1.len(), 1, "should have one viz overlay");
let field2 = ScalarField2D::from_data(4, 4, vec![1.0; 16]);
state.set_grid_scalar("test-grid", grid, field2, ramp);
state.update();
let frame2 = state.frame_output();
let viz2 = &frame2.visualization;
assert_eq!(viz2.len(), 1, "should still have one viz overlay");
match (&viz1[0], &viz2[0]) {
(
VisualizationOverlay::GridScalar { field: f1, .. },
VisualizationOverlay::GridScalar { field: f2, .. },
) => {
assert_ne!(
f1.data, f2.data,
"scalar field values should differ after re-set"
);
}
_ => panic!("expected GridScalar overlays"),
}
}
#[test]
fn point_cloud_retained_update_through_frame_output() {
let mut state = MapState::new();
state.set_viewport(128, 128);
let ramp = ColorRamp::new(vec![
ColorStop {
value: 0.0,
color: [0.0, 1.0, 0.0, 1.0],
},
ColorStop {
value: 1.0,
color: [1.0, 0.0, 0.0, 1.0],
},
]);
let points1 = PointInstanceSet::new(vec![PointInstance::new(
GeoCoord::from_lat_lon(0.0, 0.0),
5.0,
)]);
state.set_point_cloud("test-pc", points1, ramp.clone());
state.update();
let frame1 = state.frame_output();
let overlay_count_1 = frame1
.visualization
.iter()
.filter(|o| matches!(o, VisualizationOverlay::Points { .. }))
.count();
assert_eq!(overlay_count_1, 1);
let points2 = PointInstanceSet::new(vec![
PointInstance::new(GeoCoord::from_lat_lon(0.0, 0.0), 5.0),
PointInstance::new(GeoCoord::from_lat_lon(1.0, 1.0), 8.0),
]);
state.set_point_cloud("test-pc", points2, ramp);
state.update();
let frame2 = state.frame_output();
let overlay = frame2
.visualization
.iter()
.find(|o| matches!(o, VisualizationOverlay::Points { .. }));
match overlay {
Some(VisualizationOverlay::Points { points, .. }) => {
assert_eq!(points.points.len(), 2, "should have 2 points after update");
}
_ => panic!("expected Points overlay"),
}
}
}