use super::*;
#[cfg(test)]
#[allow(clippy::module_inception)]
mod tests {
use super::*;
use crate::async_data::{
DataTaskPool, DataTaskResultReceiver, MvtDecodeOutput, TerrainTaskOutput,
ThreadDataTaskPool, VectorTaskOutput,
};
use crate::geometry::{Feature, FeatureCollection, Geometry, Point};
use crate::layers::{ModelLayer, TileLayer, VectorLayer, VectorStyle};
use crate::models::{ModelInstance, ModelMesh};
use crate::style::{
CircleStyleLayer, FillStyleLayer, GeoJsonSource, MapStyle, StyleDocument, StyleLayer,
StyleSource, SymbolStyleLayer, VectorTileSource,
};
use crate::tile_manager::TileSelectionConfig;
use crate::tile_source::{TileData, TileError, TileResponse, TileSource, VectorTileData};
use rustial_math::TileId;
use std::collections::HashMap as StdHashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
fn approx_eq(a: f64, b: f64) {
assert!((a - b).abs() <= 1e-6, "expected {a} ~= {b}");
}
struct StaticVectorTileSource {
queued: Mutex<Vec<rustial_math::TileId>>,
response: TileResponse,
}
struct PatternVectorTileSource {
queued: Mutex<Vec<rustial_math::TileId>>,
}
#[derive(Clone, Default)]
struct RecordingTileSource {
requested: Arc<Mutex<Vec<rustial_math::TileId>>>,
}
#[derive(Default)]
struct CountingTaskPool {
vector_spawns: Arc<AtomicUsize>,
}
impl CountingTaskPool {
fn new() -> Self {
Self {
vector_spawns: Arc::new(AtomicUsize::new(0)),
}
}
fn vector_spawn_count(&self) -> usize {
self.vector_spawns.load(Ordering::SeqCst)
}
}
impl RecordingTileSource {
fn requested_ids(&self) -> Vec<rustial_math::TileId> {
self.requested.lock().unwrap().clone()
}
}
impl TileSource for RecordingTileSource {
fn request(&self, id: rustial_math::TileId) {
self.requested.lock().unwrap().push(id);
}
fn request_many(&self, ids: &[rustial_math::TileId]) {
self.requested.lock().unwrap().extend_from_slice(ids);
}
fn poll(&self) -> Vec<(rustial_math::TileId, Result<TileResponse, TileError>)> {
Vec::new()
}
}
fn inset_bounds(bounds: WorldBounds, inset: f64) -> WorldBounds {
WorldBounds::new(
WorldCoord::new(
bounds.min.position.x + inset,
bounds.min.position.y + inset,
0.0,
),
WorldCoord::new(
bounds.max.position.x - inset,
bounds.max.position.y - inset,
0.0,
),
)
}
fn tile_center_geo(tile: TileId) -> GeoCoord {
let bounds = rustial_math::tile_bounds_world(&tile);
let center = WorldCoord::new(
(bounds.min.position.x + bounds.max.position.x) * 0.5,
(bounds.min.position.y + bounds.max.position.y) * 0.5,
0.0,
);
WebMercator::unproject(¢er)
}
fn bounds_contains(bounds: &WorldBounds, world: &WorldCoord) -> bool {
world.position.x >= bounds.min.position.x
&& world.position.x <= bounds.max.position.x
&& world.position.y >= bounds.min.position.y
&& world.position.y <= bounds.max.position.y
}
impl DataTaskPool for CountingTaskPool {
fn spawn_terrain(
&self,
task: Box<dyn FnOnce() -> TerrainTaskOutput + Send + 'static>,
) -> Box<dyn DataTaskResultReceiver<TerrainTaskOutput>> {
ThreadDataTaskPool::new().spawn_terrain(task)
}
fn spawn_vector(
&self,
task: Box<dyn FnOnce() -> VectorTaskOutput + Send + 'static>,
) -> Box<dyn DataTaskResultReceiver<VectorTaskOutput>> {
self.vector_spawns.fetch_add(1, Ordering::SeqCst);
ThreadDataTaskPool::new().spawn_vector(task)
}
fn spawn_decode(
&self,
task: Box<dyn FnOnce() -> MvtDecodeOutput + Send + 'static>,
) -> Box<dyn DataTaskResultReceiver<MvtDecodeOutput>> {
ThreadDataTaskPool::new().spawn_decode(task)
}
}
impl StaticVectorTileSource {
fn new(layers: StdHashMap<String, FeatureCollection>) -> Self {
Self {
queued: Mutex::new(Vec::new()),
response: TileResponse::from_data(TileData::Vector(VectorTileData { layers })),
}
}
}
impl PatternVectorTileSource {
fn new() -> Self {
Self {
queued: Mutex::new(Vec::new()),
}
}
}
#[test]
fn viewport_bounds_cover_dense_hits_for_steep_perspective() {
let mut state = MapState::new();
state.set_viewport(1280, 720);
state.set_camera_target(GeoCoord::from_lat_lon(46.4182, 7.9210));
state.set_camera_distance(19_132.0);
state.set_camera_pitch(68.0_f64.to_radians());
state.set_camera_yaw(0.0_f64.to_radians());
state.update_camera(1.0 / 60.0);
let bounds = *state.viewport_bounds();
let cam = state.camera();
let mut hit_count = 0usize;
for (sx, sy) in viewport_sample_points(1280.0, 720.0) {
if let Some(geo) = cam.screen_to_geo(sx, sy) {
let world = WebMercator::project_clamped(&geo);
assert!(
bounds_contains(&bounds, &world),
"viewport bounds missed sampled hit at ({sx:.1}, {sy:.1})",
);
hit_count += 1;
}
}
assert!(
hit_count > 0,
"expected steep camera to intersect the ground plane"
);
}
impl TileSource for StaticVectorTileSource {
fn request(&self, id: rustial_math::TileId) {
self.queued.lock().expect("queued ids").push(id);
}
fn poll(&self) -> Vec<(rustial_math::TileId, Result<TileResponse, TileError>)> {
let queued = std::mem::take(&mut *self.queued.lock().expect("queued ids"));
queued
.into_iter()
.map(|id| (id, Ok(self.response.clone())))
.collect()
}
}
impl TileSource for PatternVectorTileSource {
fn request(&self, id: rustial_math::TileId) {
self.queued.lock().expect("queued ids").push(id);
}
fn poll(&self) -> Vec<(rustial_math::TileId, Result<TileResponse, TileError>)> {
let queued = std::mem::take(&mut *self.queued.lock().expect("queued ids"));
queued
.into_iter()
.map(|id| {
let label = if id.x % 2 == 0 { "Alpha" } else { "Echo" };
let mut properties = StdHashMap::new();
properties.insert(
"name".to_string(),
crate::geometry::PropertyValue::String(label.to_string()),
);
let feature = Feature {
geometry: Geometry::Point(Point {
coord: GeoCoord::from_lat_lon(51.5, -0.12),
}),
properties,
};
let mut layers = StdHashMap::new();
layers.insert(
"labels".to_string(),
FeatureCollection {
features: vec![feature],
},
);
(
id,
Ok(TileResponse::from_data(TileData::Vector(VectorTileData {
layers,
}))),
)
})
.collect()
}
}
fn dummy_mesh() -> ModelMesh {
ModelMesh {
positions: vec![[0.0, 0.0, 0.0]],
normals: vec![[0.0, 1.0, 0.0]],
uvs: vec![[0.0, 0.0]],
indices: vec![0],
}
}
fn point_feature_collection(coord: GeoCoord) -> FeatureCollection {
FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(Point { coord }),
properties: Default::default(),
}],
}
}
fn placed_symbol_for_tile(
layer_id: &str,
tile: TileId,
feature_id: &str,
text: Option<&str>,
icon: Option<&str>,
) -> PlacedSymbol {
let anchor = GeoCoord::from_lat_lon(0.0, 0.0);
let world = CameraProjection::WebMercator.project(&anchor);
PlacedSymbol {
id: format!("sym-{feature_id}"),
layer_id: Some(layer_id.to_owned()),
source_id: Some("streamed".to_owned()),
source_layer: Some("labels".to_owned()),
source_tile: Some(tile),
feature_id: feature_id.to_owned(),
feature_index: 0,
placement: crate::symbols::SymbolPlacement::Point,
anchor,
world_anchor: [world.position.x, world.position.y, world.position.z],
text: text.map(ToOwned::to_owned),
icon_image: icon.map(ToOwned::to_owned),
font_stack: "Test Sans".to_owned(),
cross_tile_id: feature_id.to_owned(),
rotation_rad: 0.0,
collision_box: crate::symbols::SymbolCollisionBox {
min: [world.position.x - 10.0, world.position.y - 10.0],
max: [world.position.x + 10.0, world.position.y + 10.0],
},
anchor_mode: crate::symbols::SymbolAnchor::Center,
writing_mode: crate::symbols::SymbolWritingMode::Horizontal,
offset_px: [0.0, 0.0],
radial_offset: None,
text_max_width: None,
text_line_height: None,
text_letter_spacing: None,
icon_text_fit: crate::symbols::SymbolIconTextFit::None,
icon_text_fit_padding: [0.0, 0.0, 0.0, 0.0],
size_px: 16.0,
fill_color: [1.0, 1.0, 1.0, 1.0],
halo_color: [0.0, 0.0, 0.0, 1.0],
opacity: 1.0,
visible: true,
glyph_quads: Vec::new(),
}
}
fn labeled_point_feature_collection(coord: GeoCoord, label: &str) -> FeatureCollection {
let mut properties = StdHashMap::new();
properties.insert(
"name".to_string(),
crate::geometry::PropertyValue::String(label.to_string()),
);
FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(Point { coord }),
properties,
}],
}
}
fn point_collection(lat: f64, lon: f64) -> FeatureCollection {
FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(Point {
coord: GeoCoord::from_lat_lon(lat, lon),
}),
properties: Default::default(),
}],
}
}
#[test]
fn streamed_vector_style_source_populates_runtime_vector_layer_from_visible_tiles() {
let target = GeoCoord::from_lat_lon(51.5, -0.12);
let mut source_layers = StdHashMap::new();
source_layers.insert("poi".to_string(), point_feature_collection(target));
let mut circle = CircleStyleLayer::new("poi-circles", "streamed");
circle.source_layer = Some("poi".to_string());
let mut document = StyleDocument::new();
document
.add_source(
"streamed",
StyleSource::VectorTile(
VectorTileSource::streamed(move || {
Box::new(StaticVectorTileSource::new(source_layers.clone()))
})
.with_selection(TileSelectionConfig {
visible_tile_budget: 1,
..Default::default()
}),
),
)
.expect("source added");
document
.add_layer(StyleLayer::Circle(circle))
.expect("layer added");
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
state
.set_style(MapStyle::from_document(document))
.expect("style applied");
state.update();
state.update();
let vector_layer = state
.layers()
.iter()
.find_map(|layer| layer.as_any().downcast_ref::<VectorLayer>())
.expect("vector layer");
assert_eq!(vector_layer.features.len(), 1);
assert!(
!state.vector_meshes().is_empty(),
"streamed vector tiles should feed the vector renderer path"
);
}
#[test]
fn streamed_vector_style_source_respects_source_layer_selection() {
let roads_coord = GeoCoord::from_lat_lon(51.5005, -0.12);
let poi_coord = GeoCoord::from_lat_lon(51.5, -0.12);
let mut source_layers = StdHashMap::new();
source_layers.insert("roads".to_string(), point_feature_collection(roads_coord));
source_layers.insert("poi".to_string(), point_feature_collection(poi_coord));
let mut circle = CircleStyleLayer::new("poi-circles", "streamed");
circle.source_layer = Some("poi".to_string());
let mut document = StyleDocument::new();
document
.add_source(
"streamed",
StyleSource::VectorTile(
VectorTileSource::streamed(move || {
Box::new(StaticVectorTileSource::new(source_layers.clone()))
})
.with_selection(TileSelectionConfig {
visible_tile_budget: 1,
..Default::default()
}),
),
)
.expect("source added");
document
.add_layer(StyleLayer::Circle(circle))
.expect("layer added");
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(poi_coord);
state.set_camera_distance(1_000.0);
state
.set_style(MapStyle::from_document(document))
.expect("style applied");
state.update();
state.update();
let vector_layer = state
.layers()
.iter()
.find_map(|layer| layer.as_any().downcast_ref::<VectorLayer>())
.expect("vector layer");
assert_eq!(vector_layer.features.len(), 1);
match &vector_layer.features.features[0].geometry {
Geometry::Point(point) => {
assert!((point.coord.lat - poi_coord.lat).abs() < 1e-9);
assert!((point.coord.lon - poi_coord.lon).abs() < 1e-9);
}
other => panic!("expected point geometry, got {other:?}"),
}
}
#[test]
fn streamed_vector_pick_reports_tile_owned_provenance() {
let target = GeoCoord::from_lat_lon(51.5, -0.12);
let mut source_layers = StdHashMap::new();
source_layers.insert("poi".to_string(), point_feature_collection(target));
let mut circle = CircleStyleLayer::new("poi-circles", "streamed");
circle.source_layer = Some("poi".to_string());
let mut document = StyleDocument::new();
document
.add_source(
"streamed",
StyleSource::VectorTile(VectorTileSource::streamed(move || {
Box::new(StaticVectorTileSource::new(source_layers.clone()))
})),
)
.expect("source added");
document
.add_layer(StyleLayer::Circle(circle))
.expect("layer added");
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
state
.set_style(MapStyle::from_document(document))
.expect("style applied");
state.update();
state.update();
let result = state.pick(PickQuery::geo(target), PickOptions::new());
let hit = result.first().expect("streamed vector hit");
assert_eq!(hit.source_id.as_deref(), Some("streamed"));
assert_eq!(hit.source_layer.as_deref(), Some("poi"));
assert!(hit.source_tile.is_some());
}
#[test]
fn streamed_vector_pick_uses_tile_owned_query_payload_when_flattened_layer_features_are_cleared(
) {
let target = GeoCoord::from_lat_lon(51.5, -0.12);
let mut source_layers = StdHashMap::new();
source_layers.insert("poi".to_string(), point_feature_collection(target));
let mut circle = CircleStyleLayer::new("poi-circles", "streamed");
circle.source_layer = Some("poi".to_string());
let mut document = StyleDocument::new();
document
.add_source(
"streamed",
StyleSource::VectorTile(VectorTileSource::streamed(move || {
Box::new(StaticVectorTileSource::new(source_layers.clone()))
})),
)
.expect("source added");
document
.add_layer(StyleLayer::Circle(circle))
.expect("layer added");
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
state
.set_style(MapStyle::from_document(document))
.expect("style applied");
state.update();
state.update();
let layer = state
.layers
.iter_mut()
.find_map(|layer| layer.as_any_mut().downcast_mut::<VectorLayer>())
.expect("vector layer");
layer.set_features_with_provenance(FeatureCollection::default(), Vec::new());
let result = state.pick(PickQuery::geo(target), PickOptions::new());
let hit = result
.first()
.expect("streamed vector hit from tile-owned payload");
assert_eq!(hit.source_id.as_deref(), Some("streamed"));
assert_eq!(hit.source_layer.as_deref(), Some("poi"));
assert!(hit.source_tile.is_some());
}
#[test]
fn streamed_vector_rendered_query_uses_tile_owned_payload_when_flattened_features_are_cleared()
{
let target = GeoCoord::from_lat_lon(51.5, -0.12);
let mut source_layers = StdHashMap::new();
source_layers.insert("poi".to_string(), point_feature_collection(target));
let mut circle = CircleStyleLayer::new("poi-circles", "streamed");
circle.source_layer = Some("poi".to_string());
let mut document = StyleDocument::new();
document
.add_source(
"streamed",
StyleSource::VectorTile(VectorTileSource::streamed(move || {
Box::new(StaticVectorTileSource::new(source_layers.clone()))
})),
)
.expect("source added");
document
.add_layer(StyleLayer::Circle(circle))
.expect("layer added");
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
state
.set_style(MapStyle::from_document(document))
.expect("style applied");
state.update();
state.update();
let layer = state
.layers
.iter_mut()
.find_map(|layer| layer.as_any_mut().downcast_mut::<VectorLayer>())
.expect("vector layer");
layer.set_features_with_provenance(FeatureCollection::default(), Vec::new());
let features = state.query_rendered_features(PickQuery::geo(target), QueryOptions::new());
assert_eq!(features.len(), 1);
assert_eq!(features[0].source_id.as_deref(), Some("streamed"));
assert_eq!(features[0].source_layer.as_deref(), Some("poi"));
assert!(features[0].source_tile.is_some());
}
#[test]
fn query_source_features_returns_streamed_tile_features_with_provenance() {
let target = GeoCoord::from_lat_lon(51.5, -0.12);
let mut source_layers = StdHashMap::new();
source_layers.insert("poi".to_string(), point_feature_collection(target));
source_layers.insert(
"roads".to_string(),
point_feature_collection(GeoCoord::from_lat_lon(51.5005, -0.12)),
);
let mut circle = CircleStyleLayer::new("poi-circles", "streamed");
circle.source_layer = Some("poi".to_string());
let mut document = StyleDocument::new();
document
.add_source(
"streamed",
StyleSource::VectorTile(VectorTileSource::streamed(move || {
Box::new(StaticVectorTileSource::new(source_layers.clone()))
})),
)
.expect("source added");
document
.add_layer(StyleLayer::Circle(circle))
.expect("layer added");
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
state
.set_style(MapStyle::from_document(document))
.expect("style applied");
state.update();
state.update();
let features = state.query_source_features("streamed", Some("poi"));
assert_eq!(features.len(), 1);
assert_eq!(features[0].source_id.as_deref(), Some("streamed"));
assert_eq!(features[0].source_layer.as_deref(), Some("poi"));
assert!(features[0].source_tile.is_some());
}
#[test]
fn streamed_symbol_pick_uses_tile_owned_symbol_payload_when_flattened_features_are_cleared() {
let target = GeoCoord::from_lat_lon(51.5, -0.12);
let mut source_layers = StdHashMap::new();
source_layers.insert(
"labels".to_string(),
labeled_point_feature_collection(target, "Hello"),
);
let mut symbol = SymbolStyleLayer::new("poi-labels", "streamed");
symbol.source_layer = Some("labels".to_string());
symbol.text_field = Some("name".to_string().into());
let mut document = StyleDocument::new();
document
.add_source(
"streamed",
StyleSource::VectorTile(VectorTileSource::streamed(move || {
Box::new(StaticVectorTileSource::new(source_layers.clone()))
})),
)
.expect("source added");
document
.add_layer(StyleLayer::Symbol(symbol))
.expect("layer added");
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
state
.set_style(MapStyle::from_document(document))
.expect("style applied");
state.update();
state.update();
let layer = state
.layers
.iter_mut()
.find_map(|layer| layer.as_any_mut().downcast_mut::<VectorLayer>())
.expect("vector layer");
layer.set_features_with_provenance(FeatureCollection::default(), Vec::new());
let result = state.pick(PickQuery::geo(target), PickOptions::new());
let hit = result.first().expect("streamed symbol hit");
assert_eq!(hit.category, HitCategory::Symbol);
assert!(hit.from_symbol);
assert_eq!(hit.source_id.as_deref(), Some("streamed"));
assert_eq!(hit.source_layer.as_deref(), Some("labels"));
assert!(hit.source_tile.is_some());
}
#[test]
fn streamed_symbol_rendered_query_uses_tile_owned_symbol_payload() {
let target = GeoCoord::from_lat_lon(51.5, -0.12);
let mut source_layers = StdHashMap::new();
source_layers.insert(
"labels".to_string(),
labeled_point_feature_collection(target, "Hello"),
);
let mut symbol = SymbolStyleLayer::new("poi-labels", "streamed");
symbol.source_layer = Some("labels".to_string());
symbol.text_field = Some("name".to_string().into());
let mut document = StyleDocument::new();
document
.add_source(
"streamed",
StyleSource::VectorTile(VectorTileSource::streamed(move || {
Box::new(StaticVectorTileSource::new(source_layers.clone()))
})),
)
.expect("source added");
document
.add_layer(StyleLayer::Symbol(symbol))
.expect("layer added");
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
state
.set_style(MapStyle::from_document(document))
.expect("style applied");
state.update();
state.update();
let layer = state
.layers
.iter_mut()
.find_map(|layer| layer.as_any_mut().downcast_mut::<VectorLayer>())
.expect("vector layer");
layer.set_features_with_provenance(FeatureCollection::default(), Vec::new());
let features = state.query_rendered_features(PickQuery::geo(target), QueryOptions::new());
assert_eq!(features.len(), 1);
assert!(features[0].from_symbol);
assert_eq!(features[0].source_id.as_deref(), Some("streamed"));
assert_eq!(features[0].source_layer.as_deref(), Some("labels"));
assert!(features[0].source_tile.is_some());
}
#[test]
fn sync_streamed_symbol_regeneration_uses_tile_owned_payloads_after_flattened_features_are_cleared(
) {
let target = GeoCoord::from_lat_lon(51.5, -0.12);
let mut source_layers = StdHashMap::new();
source_layers.insert(
"labels".to_string(),
labeled_point_feature_collection(target, "Hello"),
);
let mut symbol = SymbolStyleLayer::new("poi-labels", "streamed");
symbol.source_layer = Some("labels".to_string());
symbol.text_field = Some("name".to_string().into());
symbol.font_stack = "Test Sans".to_string().into();
symbol.allow_overlap = true.into();
let mut document = StyleDocument::new();
document
.add_source(
"streamed",
StyleSource::VectorTile(VectorTileSource::streamed(move || {
Box::new(StaticVectorTileSource::new(source_layers.clone()))
})),
)
.expect("source added");
document
.add_layer(StyleLayer::Symbol(symbol))
.expect("layer added");
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
state
.set_style(MapStyle::from_document(document))
.expect("style applied");
state.update();
state.update();
assert!(
!state.placed_symbols().is_empty(),
"initial streamed symbols should be placed"
);
let layer = state
.layers
.iter_mut()
.find_map(|layer| layer.as_any_mut().downcast_mut::<VectorLayer>())
.expect("vector layer");
layer.set_features_with_provenance(FeatureCollection::default(), Vec::new());
let removed = state.invalidate_symbol_glyph_dependency("Test Sans", 'H');
assert_eq!(removed, 1);
state.update();
assert!(
!state.placed_symbols().is_empty(),
"sync streamed symbols should regenerate from tile-owned payloads after flattened features are cleared"
);
}
#[test]
fn invalidate_symbol_image_dependency_prunes_only_affected_tile_payloads() {
let tile_a = TileId::new(4, 8, 8);
let tile_b = TileId::new(4, 9, 8);
let mut state = MapState::new();
state.set_placed_symbols(vec![
placed_symbol_for_tile("poi-labels", tile_a, "a", Some("A"), Some("marker-a")),
placed_symbol_for_tile("poi-labels", tile_b, "b", Some("B"), Some("marker-b")),
]);
state.rebuild_symbol_query_payloads();
let removed = state.invalidate_symbol_image_dependency("marker-a");
assert_eq!(removed, 1);
assert_eq!(state.placed_symbols().len(), 1);
assert_eq!(state.placed_symbols()[0].source_tile, Some(tile_b));
assert_eq!(
state
.streamed_symbol_query_payloads
.get("poi-labels")
.expect("payloads")
.len(),
1
);
assert_eq!(
state
.symbol_assets()
.images()
.referenced()
.collect::<Vec<_>>(),
vec!["marker-b"]
);
}
#[test]
fn invalidate_symbol_glyph_dependency_prunes_only_matching_tile_payloads() {
let tile_a = TileId::new(4, 8, 8);
let tile_b = TileId::new(4, 9, 8);
let mut state = MapState::new();
state.set_placed_symbols(vec![
placed_symbol_for_tile("poi-labels", tile_a, "a", Some("Alpha"), None),
placed_symbol_for_tile("poi-labels", tile_b, "b", Some("Beta"), None),
]);
state.rebuild_symbol_query_payloads();
let removed = state.invalidate_symbol_glyph_dependency("Test Sans", 'A');
assert_eq!(removed, 1);
assert_eq!(state.placed_symbols().len(), 1);
assert_eq!(state.placed_symbols()[0].source_tile, Some(tile_b));
assert!(state
.symbol_assets()
.glyphs()
.requested()
.all(|glyph| glyph.codepoint != 'A'));
}
#[test]
fn async_symbol_dependency_invalidation_bumps_only_affected_layer_generation() {
let target = GeoCoord::from_lat_lon(51.5, -0.12);
let mut source_layers = StdHashMap::new();
source_layers.insert(
"labels-a".to_string(),
labeled_point_feature_collection(target, "Alpha"),
);
source_layers.insert(
"labels-e".to_string(),
labeled_point_feature_collection(target, "Echo"),
);
let mut layer_a = SymbolStyleLayer::new("labels-a", "streamed");
layer_a.source_layer = Some("labels-a".to_string());
layer_a.text_field = Some("name".to_string().into());
layer_a.font_stack = "Test Sans".to_string().into();
layer_a.allow_overlap = true.into();
let mut layer_e = SymbolStyleLayer::new("labels-e", "streamed");
layer_e.source_layer = Some("labels-e".to_string());
layer_e.text_field = Some("name".to_string().into());
layer_e.font_stack = "Test Sans".to_string().into();
layer_e.allow_overlap = true.into();
let mut document = StyleDocument::new();
document
.add_source(
"streamed",
StyleSource::VectorTile(VectorTileSource::streamed(move || {
Box::new(StaticVectorTileSource::new(source_layers.clone()))
})),
)
.expect("source added");
document
.add_layer(StyleLayer::Symbol(layer_a))
.expect("layer added");
document
.add_layer(StyleLayer::Symbol(layer_e))
.expect("layer added");
let mut state = MapState::new();
state.set_task_pool(Arc::new(ThreadDataTaskPool::new()));
state.set_viewport(800, 600);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
state
.set_style(MapStyle::from_document(document))
.expect("style applied");
for _ in 0..6 {
state.update();
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(
!state.placed_symbols().is_empty(),
"async symbol placement should complete before invalidation"
);
let generation_a_before = state
.async_pipeline
.as_ref()
.and_then(|pipeline| pipeline.current_layer_generation("labels-a"))
.expect("labels-a generation before invalidation");
let generation_e_before = state
.async_pipeline
.as_ref()
.and_then(|pipeline| pipeline.current_layer_generation("labels-e"))
.expect("labels-e generation before invalidation");
let removed = state.invalidate_symbol_glyph_dependency("Test Sans", 'A');
assert_eq!(removed, 1);
assert!(
state.dirty_streamed_symbol_layers.contains("labels-a")
|| state.dirty_streamed_symbol_tiles.contains_key("labels-a"),
"labels-a should be marked dirty (layer-level or tile-level) after glyph 'A' invalidation"
);
assert!(
!state.dirty_streamed_symbol_layers.contains("labels-e")
&& !state.dirty_streamed_symbol_tiles.contains_key("labels-e"),
"labels-e should not be affected by glyph 'A' invalidation"
);
for _ in 0..6 {
state.update();
std::thread::sleep(std::time::Duration::from_millis(5));
}
let generation_a_after = state
.async_pipeline
.as_ref()
.and_then(|pipeline| pipeline.current_layer_generation("labels-a"))
.expect("labels-a generation after invalidation");
let generation_e_after = state
.async_pipeline
.as_ref()
.and_then(|pipeline| pipeline.current_layer_generation("labels-e"))
.expect("labels-e generation after invalidation");
assert!(generation_a_after > generation_a_before);
assert_eq!(generation_e_after, generation_e_before);
assert!(state.dirty_streamed_symbol_layers.is_empty());
assert!(state.dirty_streamed_symbol_tiles.is_empty());
}
#[test]
fn async_streamed_vector_layers_dispatch_per_tile_bucket_tasks() {
let target = GeoCoord::from_lat_lon(51.5, -0.12);
let mut source_layers = StdHashMap::new();
source_layers.insert("poi".to_string(), point_feature_collection(target));
let mut circle = CircleStyleLayer::new("poi-circles", "streamed");
circle.source_layer = Some("poi".to_string());
let mut document = StyleDocument::new();
document
.add_source(
"streamed",
StyleSource::VectorTile(VectorTileSource::streamed(move || {
Box::new(StaticVectorTileSource::new(source_layers.clone()))
})),
)
.expect("source added");
document
.add_layer(StyleLayer::Circle(circle))
.expect("layer added");
let mut state = MapState::new();
state.set_task_pool(Arc::new(ThreadDataTaskPool::new()));
state.set_viewport(800, 600);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
state
.set_style(MapStyle::from_document(document))
.expect("style applied");
for _ in 0..6 {
state.update();
std::thread::sleep(std::time::Duration::from_millis(5));
}
let pipeline = state.async_pipeline.as_ref().expect("async pipeline");
assert!(
pipeline.bucket_cache_len() > 0,
"streamed layers should populate per-tile async buckets"
);
assert!(
!state.vector_meshes().is_empty(),
"visible bucket meshes should be collected for rendering"
);
}
#[test]
fn async_streamed_symbol_regeneration_uses_tile_owned_payloads_after_flattened_features_are_cleared(
) {
let target = GeoCoord::from_lat_lon(51.5, -0.12);
let mut source_layers = StdHashMap::new();
source_layers.insert(
"labels".to_string(),
labeled_point_feature_collection(target, "Hello"),
);
let mut symbol = SymbolStyleLayer::new("poi-labels", "streamed");
symbol.source_layer = Some("labels".to_string());
symbol.text_field = Some("name".to_string().into());
symbol.font_stack = "Test Sans".to_string().into();
symbol.allow_overlap = true.into();
let mut document = StyleDocument::new();
document
.add_source(
"streamed",
StyleSource::VectorTile(VectorTileSource::streamed(move || {
Box::new(StaticVectorTileSource::new(source_layers.clone()))
})),
)
.expect("source added");
document
.add_layer(StyleLayer::Symbol(symbol))
.expect("layer added");
let mut state = MapState::new();
state.set_task_pool(Arc::new(ThreadDataTaskPool::new()));
state.set_viewport(800, 600);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
state
.set_style(MapStyle::from_document(document))
.expect("style applied");
for _ in 0..10 {
state.update();
std::thread::sleep(std::time::Duration::from_millis(5));
if state
.async_pipeline
.as_ref()
.is_some_and(|pipeline| !pipeline.has_pending_tasks())
&& !state.placed_symbols().is_empty()
{
break;
}
}
assert!(
!state.placed_symbols().is_empty(),
"initial async streamed symbols should be placed"
);
let layer = state
.layers
.iter_mut()
.find_map(|layer| layer.as_any_mut().downcast_mut::<VectorLayer>())
.expect("vector layer");
layer.set_features_with_provenance(FeatureCollection::default(), Vec::new());
let removed = state.invalidate_symbol_glyph_dependency("Test Sans", 'H');
assert_eq!(removed, 1);
for _ in 0..10 {
state.update();
std::thread::sleep(std::time::Duration::from_millis(5));
if state
.async_pipeline
.as_ref()
.is_some_and(|pipeline| !pipeline.has_pending_tasks())
&& !state.placed_symbols().is_empty()
{
break;
}
}
assert!(!state.placed_symbols().is_empty(), "async streamed symbols should regenerate from tile-owned payloads after flattened features are cleared");
}
#[test]
fn async_symbol_dependency_invalidation_reissues_only_affected_streamed_buckets() {
let target = GeoCoord::from_lat_lon(51.5, -0.12);
let mut symbol = SymbolStyleLayer::new("poi-labels", "streamed");
symbol.source_layer = Some("labels".to_string());
symbol.text_field = Some("name".to_string().into());
symbol.font_stack = "Test Sans".to_string().into();
symbol.allow_overlap = true.into();
let mut document = StyleDocument::new();
document
.add_source(
"streamed",
StyleSource::VectorTile(VectorTileSource::streamed(move || {
Box::new(PatternVectorTileSource::new())
})),
)
.expect("source added");
document
.add_layer(StyleLayer::Symbol(symbol))
.expect("layer added");
let pool = Arc::new(CountingTaskPool::new());
let mut state = MapState::new();
state.set_task_pool(pool.clone());
state.set_viewport(800, 600);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
state
.set_style(MapStyle::from_document(document))
.expect("style applied");
for _ in 0..10 {
state.update();
std::thread::sleep(std::time::Duration::from_millis(5));
if state
.async_pipeline
.as_ref()
.is_some_and(|pipeline| !pipeline.has_pending_tasks())
&& !state.streamed_symbol_dependency_payloads.is_empty()
{
break;
}
}
let affected_tiles = state
.streamed_symbol_dependency_payloads
.get("poi-labels")
.expect("symbol dependency payloads")
.iter()
.filter(|payload| {
payload
.dependencies
.glyphs
.iter()
.any(|glyph| glyph.font_stack == "Test Sans" && glyph.codepoint == 'A')
})
.count();
assert!(affected_tiles > 0);
let spawns_before = pool.vector_spawn_count();
let removed = state.invalidate_symbol_glyph_dependency("Test Sans", 'A');
assert_eq!(removed, affected_tiles);
assert!(state.dirty_streamed_symbol_layers.is_empty());
assert_eq!(
state
.dirty_streamed_symbol_tiles
.get("poi-labels")
.map(|tiles| tiles.len())
.unwrap_or(0),
affected_tiles
);
for _ in 0..10 {
state.update();
std::thread::sleep(std::time::Duration::from_millis(5));
if state
.async_pipeline
.as_ref()
.is_some_and(|pipeline| !pipeline.has_pending_tasks())
&& state.dirty_streamed_symbol_tiles.is_empty()
{
break;
}
}
assert_eq!(pool.vector_spawn_count() - spawns_before, affected_tiles);
assert!(state.dirty_streamed_symbol_tiles.is_empty());
}
#[test]
fn map_state_reports_style_source_usage() {
let mut document = StyleDocument::new();
document
.add_source(
"places",
StyleSource::GeoJson(GeoJsonSource::new(point_collection(0.0, 0.0))),
)
.expect("source added");
document
.add_layer(StyleLayer::Fill(FillStyleLayer::new("fill", "places")))
.expect("layer added");
let mut state = MapState::new();
state
.set_style(MapStyle::from_document(document))
.expect("style applied");
assert!(state.style_source_is_used("places"));
assert_eq!(state.style_layer_ids_using_source("places"), vec!["fill"]);
assert!(!state.style_source_is_used("missing"));
}
#[test]
fn map_state_can_reload_style_source() {
let mut document = StyleDocument::new();
document
.add_source(
"places",
StyleSource::GeoJson(GeoJsonSource::new(point_collection(0.0, 0.0))),
)
.expect("source added");
document
.add_layer(StyleLayer::Fill(FillStyleLayer::new("fill", "places")))
.expect("layer added");
let mut state = MapState::new();
state
.set_style(MapStyle::from_document(document))
.expect("style applied");
let changed = state
.reload_style_source(
"places",
StyleSource::GeoJson(GeoJsonSource::new(point_collection(1.0, 2.0))),
)
.expect("reload style source");
assert!(changed);
assert!(state.style_source_is_used("places"));
}
#[test]
fn resolve_feature_style_uses_feature_state() {
use crate::geometry::PropertyValue;
use crate::style::StyleValue;
let mut document = StyleDocument::new();
document
.add_source(
"places",
StyleSource::GeoJson(GeoJsonSource::new(point_collection(0.0, 0.0))),
)
.expect("source added");
let mut fill = FillStyleLayer::new("buildings", "places");
fill.outline_width = StyleValue::feature_state_key("width", 1.0);
document
.add_layer(StyleLayer::Fill(fill))
.expect("layer added");
let mut state = MapState::new();
state.set_viewport(800, 600);
state
.set_style(MapStyle::from_document(document))
.expect("style applied");
state.update();
let style = state
.resolve_feature_style("buildings", "places", "0")
.expect("should resolve fill layer");
assert!((style.stroke_width - 1.0).abs() < f32::EPSILON);
state.set_feature_state_property("places", "0", "width", PropertyValue::Number(5.0));
let style = state
.resolve_feature_style("buildings", "places", "0")
.expect("should resolve fill layer");
assert!((style.stroke_width - 5.0).abs() < f32::EPSILON);
assert!(state
.resolve_feature_style("missing", "places", "0")
.is_none());
}
#[test]
fn query_rendered_features_in_box_returns_features_inside_rectangle() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
state.set_camera_distance(500.0);
let inside = Feature {
geometry: Geometry::Point(Point {
coord: GeoCoord::from_lat_lon(0.0, 0.0),
}),
properties: Default::default(),
};
let outside = Feature {
geometry: Geometry::Point(Point {
coord: GeoCoord::from_lat_lon(10.0, 10.0),
}),
properties: Default::default(),
};
let fc = FeatureCollection {
features: vec![inside, outside],
};
let vl = VectorLayer::new("points", fc, VectorStyle::default());
state.push_layer(Box::new(vl));
state.update();
let results =
state.query_rendered_features_in_box(300.0, 200.0, 500.0, 400.0, QueryOptions::new());
assert!(
!results.is_empty(),
"expected at least one feature in box query"
);
for r in &results {
assert_eq!(r.layer_id.as_deref(), Some("points"));
}
}
#[test]
fn query_rendered_features_in_box_respects_layer_filter() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
state.set_camera_distance(500.0);
let fc = FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(Point {
coord: GeoCoord::from_lat_lon(0.0, 0.0),
}),
properties: Default::default(),
}],
};
state.push_layer(Box::new(VectorLayer::new(
"roads",
fc.clone(),
VectorStyle::default(),
)));
state.push_layer(Box::new(VectorLayer::new(
"water",
fc,
VectorStyle::default(),
)));
state.update();
let all =
state.query_rendered_features_in_box(300.0, 200.0, 500.0, 400.0, QueryOptions::new());
let mut opts = QueryOptions::new();
opts.layers = vec!["roads".to_string()];
let filtered = state.query_rendered_features_in_box(300.0, 200.0, 500.0, 400.0, opts);
assert!(
all.len() >= 2,
"expected features from both layers, got {}",
all.len()
);
assert!(
filtered
.iter()
.all(|f| f.layer_id.as_deref() == Some("roads")),
"filtered results should only contain 'roads'"
);
}
#[test]
fn query_rendered_features_hits_model_layer() {
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 mut model_layer = ModelLayer::new("models");
model_layer
.instances
.push(ModelInstance::new(target, dummy_mesh()));
state.push_layer(Box::new(model_layer));
state.update();
let result = state.pick(PickQuery::geo(target), PickOptions::new());
assert!(!result.is_empty(), "should find model hit");
}
#[test]
fn query_rendered_features_along_ray_hits_vector_features() {
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 fc = point_feature_collection(target);
let vl = VectorLayer::new("vectors", fc, VectorStyle::default());
state.push_layer(Box::new(vl));
state.update();
let result = state.pick(PickQuery::geo(target), PickOptions::new());
assert!(!result.is_empty(), "should find vector hit");
}
#[test]
fn query_rendered_features_at_screen_matches_pick_query_screen() {
let mut state = MapState::new();
state.set_viewport(800, 600);
let target = GeoCoord::from_lat_lon(0.0, 0.0);
state.set_camera_target(target);
state.set_camera_distance(500.0);
let fc = point_feature_collection(target);
let vl = VectorLayer::new("points", fc, VectorStyle::default());
state.push_layer(Box::new(vl));
state.update();
let via_wrapper =
state.query_rendered_features_at_screen(400.0, 300.0, QueryOptions::new());
let via_query =
state.query_rendered_features(PickQuery::screen(400.0, 300.0), QueryOptions::new());
assert_eq!(via_wrapper.len(), via_query.len());
assert_eq!(
via_wrapper
.first()
.map(|feature| feature.feature_id.as_str()),
via_query.first().map(|feature| feature.feature_id.as_str())
);
assert_eq!(
via_wrapper
.first()
.and_then(|feature| feature.source_id.as_deref()),
via_query
.first()
.and_then(|feature| feature.source_id.as_deref())
);
}
#[test]
fn query_rendered_features_at_geo_matches_pick_query_geo() {
let mut state = MapState::new();
state.set_viewport(800, 600);
let target = GeoCoord::from_lat_lon(0.0, 0.0);
state.set_camera_target(target);
state.set_camera_distance(500.0);
let fc = point_feature_collection(target);
let vl = VectorLayer::new("points", fc, VectorStyle::default());
state.push_layer(Box::new(vl));
state.update();
let via_wrapper = state.query_rendered_features_at_geo(target, QueryOptions::new());
let via_query = state.query_rendered_features(PickQuery::geo(target), QueryOptions::new());
assert_eq!(via_wrapper.len(), via_query.len());
assert_eq!(
via_wrapper
.first()
.map(|feature| feature.feature_id.as_str()),
via_query.first().map(|feature| feature.feature_id.as_str())
);
assert_eq!(
via_wrapper.first().map(|feature| feature.from_symbol),
via_query.first().map(|feature| feature.from_symbol)
);
}
#[test]
fn query_rendered_features_along_ray_matches_pick_query_ray() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_mode(CameraMode::Orthographic);
let target = GeoCoord::from_lat_lon(51.5, -0.12);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
let fc = point_feature_collection(target);
let vl = VectorLayer::new("vectors", fc, VectorStyle::default());
state.push_layer(Box::new(vl));
state.update();
let (origin, direction) = state.camera().screen_to_ray(400.0, 300.0);
let via_wrapper =
state.query_rendered_features_along_ray(origin, direction, QueryOptions::new());
let via_query =
state.query_rendered_features(PickQuery::ray(origin, direction), QueryOptions::new());
assert_eq!(via_wrapper.len(), via_query.len());
assert_eq!(
via_wrapper
.first()
.map(|feature| feature.feature_id.as_str()),
via_query.first().map(|feature| feature.feature_id.as_str())
);
assert_eq!(
via_wrapper
.first()
.and_then(|feature| feature.source_id.as_deref()),
via_query
.first()
.and_then(|feature| feature.source_id.as_deref())
);
}
#[test]
fn style_model_layers_report_style_query_metadata() {
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 mut model_layer = ModelLayer::new("styled-model");
model_layer
.instances
.push(ModelInstance::new(target, dummy_mesh()));
state.push_layer(Box::new(model_layer));
state.update();
let result = state.pick(PickQuery::geo(target), PickOptions::new());
assert!(!result.is_empty());
assert_eq!(result.hits[0].layer_id.as_deref(), Some("styled-model"));
}
#[test]
fn pick_screen_hits_vector_feature() {
let mut state = MapState::new();
state.set_viewport(800, 600);
let target = GeoCoord::from_lat_lon(0.0, 0.0);
state.set_camera_target(target);
state.set_camera_distance(500.0);
let fc = point_feature_collection(target);
let vl = VectorLayer::new("points", fc, VectorStyle::default());
state.push_layer(Box::new(vl));
state.update();
let result = state.pick(PickQuery::screen(400.0, 300.0), PickOptions::new());
assert!(!result.is_empty(), "should find vector hit via screen pick");
}
#[test]
fn pick_at_screen_matches_pick_query_screen() {
let mut state = MapState::new();
state.set_viewport(800, 600);
let target = GeoCoord::from_lat_lon(0.0, 0.0);
state.set_camera_target(target);
state.set_camera_distance(500.0);
let fc = point_feature_collection(target);
let vl = VectorLayer::new("points", fc, VectorStyle::default());
state.push_layer(Box::new(vl));
state.update();
let via_wrapper = state.pick_at_screen(400.0, 300.0, PickOptions::new());
let via_query = state.pick(PickQuery::screen(400.0, 300.0), PickOptions::new());
assert_eq!(via_wrapper.query_coord, via_query.query_coord);
assert_eq!(via_wrapper.projection, via_query.projection);
assert_eq!(via_wrapper.hits.len(), via_query.hits.len());
assert_eq!(
via_wrapper
.first()
.and_then(|hit| hit.feature_id.as_deref()),
via_query.first().and_then(|hit| hit.feature_id.as_deref())
);
}
#[test]
fn pick_geo_hits_model() {
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(2_000.0);
let mut ml = ModelLayer::new("buildings");
ml.instances.push(ModelInstance::new(target, dummy_mesh()));
state.push_layer(Box::new(ml));
state.update();
let result = state.pick(PickQuery::geo(target), PickOptions::new());
assert!(!result.is_empty());
assert_eq!(result.hits[0].category, HitCategory::Model);
}
#[test]
fn pick_at_geo_matches_pick_query_geo() {
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(2_000.0);
let mut ml = ModelLayer::new("buildings");
ml.instances.push(ModelInstance::new(target, dummy_mesh()));
state.push_layer(Box::new(ml));
state.update();
let via_wrapper = state.pick_at_geo(target, PickOptions::new());
let via_query = state.pick(PickQuery::geo(target), PickOptions::new());
assert_eq!(via_wrapper.query_coord, via_query.query_coord);
assert_eq!(via_wrapper.projection, via_query.projection);
assert_eq!(via_wrapper.hits.len(), via_query.hits.len());
assert_eq!(
via_wrapper.first().map(|hit| hit.category),
via_query.first().map(|hit| hit.category)
);
}
#[test]
fn pick_with_terrain_surface_includes_terrain_hit() {
let mut state = MapState::new();
state.set_viewport(800, 600);
let target = GeoCoord::from_lat_lon(46.0, 7.0);
state.set_camera_target(target);
state.set_camera_distance(10_000.0);
let result = state.pick(
PickQuery::geo(target),
PickOptions::new().with_terrain_surface(),
);
assert!(!result.is_empty());
assert!(!result.terrain_hits().is_empty());
}
#[test]
fn pick_respects_limit() {
let mut state = MapState::new();
state.set_viewport(800, 600);
let target = GeoCoord::from_lat_lon(0.0, 0.0);
state.set_camera_target(target);
state.set_camera_distance(500.0);
let mut features = Vec::new();
for _ in 0..5 {
features.push(Feature {
geometry: Geometry::Point(Point { coord: target }),
properties: Default::default(),
});
}
let fc = FeatureCollection { features };
let vl = VectorLayer::new("multi", fc, VectorStyle::default());
state.push_layer(Box::new(vl));
state.update();
let result = state.pick(PickQuery::geo(target), PickOptions::new().with_limit(2));
assert!(result.hits.len() <= 2);
}
#[test]
fn pick_ray_hits_model_in_orthographic_mode() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_mode(CameraMode::Orthographic);
let target = GeoCoord::from_lat_lon(51.5, -0.12);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
let mut ml = ModelLayer::new("ortho-model");
ml.instances.push(ModelInstance::new(target, dummy_mesh()));
state.push_layer(Box::new(ml));
state.update();
let result = state.pick(PickQuery::geo(target), PickOptions::new());
assert!(!result.is_empty());
}
#[test]
fn pick_along_ray_matches_pick_query_ray() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_mode(CameraMode::Orthographic);
let target = GeoCoord::from_lat_lon(51.5, -0.12);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
let mut ml = ModelLayer::new("ortho-model");
ml.instances.push(ModelInstance::new(target, dummy_mesh()));
state.push_layer(Box::new(ml));
state.update();
let (origin, direction) = state.camera().screen_to_ray(400.0, 300.0);
let via_wrapper = state.pick_along_ray(origin, direction, PickOptions::new());
let via_query = state.pick(PickQuery::ray(origin, direction), PickOptions::new());
assert_eq!(via_wrapper.query_coord, via_query.query_coord);
assert_eq!(via_wrapper.projection, via_query.projection);
assert_eq!(via_wrapper.hits.len(), via_query.hits.len());
assert_eq!(
via_wrapper.first().map(|hit| hit.category),
via_query.first().map(|hit| hit.category)
);
}
#[test]
fn pick_ray_hits_model_in_orthographic_web_mercator_mode() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_mode(CameraMode::Orthographic);
state.set_camera_projection(CameraProjection::WebMercator);
let target = GeoCoord::from_lat_lon(51.5, -0.12);
state.set_camera_target(target);
state.set_camera_distance(1_000.0);
let mut ml = ModelLayer::new("ortho-merc-model");
ml.instances.push(ModelInstance::new(target, dummy_mesh()));
state.push_layer(Box::new(ml));
state.update();
let result = state.pick(PickQuery::geo(target), PickOptions::new());
assert!(!result.is_empty());
}
fn dummy_raster_tile_data() -> Option<TileData> {
Some(TileData::Raster(crate::tile_source::DecodedImage {
width: 1,
height: 1,
data: Arc::new(vec![255, 255, 255, 255]),
}))
}
#[test]
fn steep_pitch_terrain_retains_strict_footprint_tiles_beyond_visible_targets() {
let mut state = MapState::with_terrain(
TerrainConfig {
enabled: true,
source: Box::new(crate::terrain::FlatElevationSource::new(8, 8)),
..TerrainConfig::default()
},
256,
);
state.set_viewport(1280, 720);
state.set_camera_target(GeoCoord::from_lat_lon(47.1627, 9.8155));
state.set_camera_distance(1_839.0);
state.set_camera_pitch(47.3_f64.to_radians());
state.update_camera(1.0 / 60.0);
let near_targets = vec![
rustial_math::TileId::new(16, 34156, 22918),
rustial_math::TileId::new(16, 34157, 22918),
rustial_math::TileId::new(16, 34156, 22919),
];
let coarse_target = rustial_math::TileId::new(12, 2134, 1432);
let mut visible_tiles: Vec<VisibleTile> = near_targets
.iter()
.copied()
.map(|tile| VisibleTile {
target: tile,
actual: tile,
data: dummy_raster_tile_data(),
fade_opacity: 1.0,
})
.collect();
visible_tiles.push(VisibleTile {
target: coarse_target,
actual: coarse_target,
data: dummy_raster_tile_data(),
fade_opacity: 1.0,
});
state.set_visible_tiles(visible_tiles);
state.update_heavy_layers(1.0 / 60.0);
let terrain_tiles: HashSet<_> = state
.terrain_meshes()
.iter()
.map(|mesh| mesh.tile)
.collect();
for target in &near_targets {
assert!(
terrain_tiles.contains(target),
"terrain should include visible target {target:?}"
);
}
assert!(
terrain_tiles.len() > near_targets.len(),
"terrain tiles should extend beyond the visible targets"
);
let raster_actuals: HashSet<_> = near_targets
.iter()
.copied()
.chain(std::iter::once(coarse_target))
.collect();
for tile in &terrain_tiles {
let mut current = *tile;
let mut found = raster_actuals.contains(¤t);
for _ in 0..8u8 {
if found {
break;
}
let Some(parent) = current.parent() else {
break;
};
found = raster_actuals.contains(&parent);
current = parent;
}
assert!(found, "terrain tile {tile:?} should have a raster ancestor");
}
}
#[test]
fn async_steep_pitch_terrain_retains_strict_footprint_tiles_beyond_visible_targets() {
let mut state = MapState::with_terrain(
TerrainConfig {
enabled: true,
source: Box::new(crate::terrain::FlatElevationSource::new(8, 8)),
..TerrainConfig::default()
},
256,
);
state.set_viewport(1280, 720);
state.set_camera_target(GeoCoord::from_lat_lon(47.1627, 9.8155));
state.set_camera_distance(1_839.0);
state.set_camera_pitch(47.3_f64.to_radians());
state.update_camera(1.0 / 60.0);
state.set_task_pool(Arc::new(crate::async_data::ThreadDataTaskPool::new()));
let near_targets = [
rustial_math::TileId::new(16, 34156, 22918),
rustial_math::TileId::new(16, 34157, 22918),
rustial_math::TileId::new(16, 34156, 22919),
];
let coarse_target = rustial_math::TileId::new(12, 2134, 1432);
let mut visible_tiles: Vec<VisibleTile> = near_targets
.iter()
.copied()
.map(|tile| VisibleTile {
target: tile,
actual: tile,
data: dummy_raster_tile_data(),
fade_opacity: 1.0,
})
.collect();
visible_tiles.push(VisibleTile {
target: coarse_target,
actual: coarse_target,
data: dummy_raster_tile_data(),
fade_opacity: 1.0,
});
state.set_visible_tiles(visible_tiles);
state.dispatch_data_requests();
for _ in 0..180 {
state.poll_completed_results(1.0 / 60.0);
let terrain_tiles: HashSet<_> = state
.terrain_meshes()
.iter()
.map(|mesh| mesh.tile)
.collect();
if terrain_tiles.len() > near_targets.len() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
state.dispatch_data_requests();
}
let terrain_tiles: HashSet<_> = state
.terrain_meshes()
.iter()
.map(|mesh| mesh.tile)
.collect();
assert!(
terrain_tiles.len() > near_targets.len(),
"async terrain tiles should extend beyond the visible targets"
);
let raster_actuals: HashSet<_> = near_targets
.iter()
.copied()
.chain(std::iter::once(coarse_target))
.collect();
for tile in &terrain_tiles {
let mut current = *tile;
let mut found = raster_actuals.contains(¤t);
for _ in 0..8u8 {
if found {
break;
}
let Some(parent) = current.parent() else {
break;
};
found = raster_actuals.contains(&parent);
current = parent;
}
assert!(
found,
"async terrain tile {tile:?} should have a raster ancestor"
);
}
}
#[test]
fn steep_wide_view_terrain_budget_covers_large_strict_footprint() {
let mut state = MapState::with_terrain(
TerrainConfig {
enabled: true,
source: Box::new(crate::terrain::FlatElevationSource::new(8, 8)),
..TerrainConfig::default()
},
512,
);
state.set_viewport(1280, 720);
state.set_camera_target(GeoCoord::from_lat_lon(46.4971, 7.5357));
state.set_camera_distance(35_292.0);
state.set_camera_pitch(68.0_f64.to_radians());
state.set_camera_yaw(0.0);
state.update_camera(1.0 / 60.0);
let near_targets = [
rustial_math::TileId::new(12, 2132, 1445),
rustial_math::TileId::new(12, 2133, 1445),
rustial_math::TileId::new(12, 2132, 1446),
rustial_math::TileId::new(12, 2133, 1446),
];
let coarse_target = rustial_math::TileId::new(8, 133, 90);
let mut visible_tiles: Vec<VisibleTile> = near_targets
.iter()
.copied()
.map(|tile| VisibleTile {
target: tile,
actual: tile,
data: dummy_raster_tile_data(),
fade_opacity: 1.0,
})
.collect();
visible_tiles.push(VisibleTile {
target: coarse_target,
actual: coarse_target,
data: dummy_raster_tile_data(),
fade_opacity: 1.0,
});
state.set_visible_tiles(visible_tiles);
state.update_heavy_layers(1.0 / 60.0);
let terrain_tiles: HashSet<_> = state
.terrain_meshes()
.iter()
.map(|mesh| mesh.tile)
.collect();
assert!(
terrain_tiles.len() > near_targets.len(),
"terrain tiles should extend beyond the visible targets in steep wide views"
);
let raster_actuals: HashSet<_> = near_targets
.iter()
.copied()
.chain(std::iter::once(coarse_target))
.collect();
for tile in &terrain_tiles {
let mut current = *tile;
let mut found = raster_actuals.contains(¤t);
for _ in 0..8u8 {
if found {
break;
}
let Some(parent) = current.parent() else {
break;
};
found = raster_actuals.contains(&parent);
current = parent;
}
assert!(found, "terrain tile {tile:?} should have a raster ancestor");
}
}
#[test]
fn sync_vector_cache_reuses_mesh_across_frames() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
state.set_camera_distance(500.0);
let fc = FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(crate::geometry::Point {
coord: GeoCoord::from_lat_lon(0.0, 0.0),
}),
properties: Default::default(),
}],
};
state.push_layer(Box::new(VectorLayer::new(
"test",
fc,
VectorStyle::default(),
)));
state.update_heavy_layers(1.0 / 60.0);
assert!(
!state.vector_meshes().is_empty(),
"first update should produce meshes"
);
assert_eq!(
state.sync_vector_cache.len(),
1,
"cache should have one entry after first update"
);
state.update_heavy_layers(1.0 / 60.0);
assert!(
!state.vector_meshes().is_empty(),
"cached meshes should be returned on second update"
);
assert_eq!(
state.sync_vector_cache.len(),
1,
"cache should still have one entry"
);
}
#[test]
fn sync_vector_cache_invalidates_on_style_change() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
state.set_camera_distance(500.0);
let fc = FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(crate::geometry::Point {
coord: GeoCoord::from_lat_lon(0.0, 0.0),
}),
properties: Default::default(),
}],
};
state.push_layer(Box::new(VectorLayer::new(
"test",
fc,
VectorStyle::default(),
)));
state.update_heavy_layers(1.0 / 60.0);
let meshes_before = state.vector_meshes().len();
for layer in state.layers.iter_mut() {
if let Some(vl) = layer.as_any_mut().downcast_mut::<VectorLayer>() {
vl.style.fill_color = [1.0, 0.0, 0.0, 1.0];
}
}
state.update_heavy_layers(1.0 / 60.0);
assert!(
!state.vector_meshes().is_empty(),
"meshes should still be present after style change"
);
assert_eq!(
state.sync_vector_cache.len(),
1,
"stale cache entry should have been pruned"
);
assert_eq!(
state.vector_meshes().len(),
meshes_before,
"mesh count should be stable after style change"
);
}
#[test]
fn sync_vector_cache_invalidates_on_data_change() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
state.set_camera_distance(500.0);
let fc = FeatureCollection {
features: vec![Feature {
geometry: Geometry::Point(crate::geometry::Point {
coord: GeoCoord::from_lat_lon(0.0, 0.0),
}),
properties: Default::default(),
}],
};
state.push_layer(Box::new(VectorLayer::new(
"test",
fc,
VectorStyle::default(),
)));
state.update_heavy_layers(1.0 / 60.0);
assert_eq!(state.sync_vector_cache.len(), 1);
for layer in state.layers.iter_mut() {
if let Some(vl) = layer.as_any_mut().downcast_mut::<VectorLayer>() {
let new_fc = FeatureCollection {
features: vec![
Feature {
geometry: Geometry::Point(crate::geometry::Point {
coord: GeoCoord::from_lat_lon(0.0, 0.0),
}),
properties: Default::default(),
},
Feature {
geometry: Geometry::Point(crate::geometry::Point {
coord: GeoCoord::from_lat_lon(0.001, 0.001),
}),
properties: Default::default(),
},
],
};
vl.set_features_with_provenance(new_fc, Vec::new());
}
}
state.update_heavy_layers(1.0 / 60.0);
assert_eq!(
state.sync_vector_cache.len(),
1,
"old entry should be pruned and new entry should be present"
);
}
#[test]
fn camera_motion_state_stays_zero_when_camera_is_stationary() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
state.set_camera_distance(1_000.0);
state.update_camera(0.5);
state.update_camera(0.5);
let motion = state.camera_motion_state();
approx_eq(motion.pan_velocity_world.x, 0.0);
approx_eq(motion.pan_velocity_world.y, 0.0);
assert_eq!(motion.predicted_viewport_bounds, *state.viewport_bounds());
}
#[test]
fn camera_motion_state_predicts_translated_viewport_from_pan_velocity() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
state.set_camera_distance(1_000.0);
state.set_camera_velocity_config(CameraVelocityConfig {
sample_window: 4,
look_ahead_seconds: 0.5,
});
state.update_camera(0.5);
let start_world = WebMercator::project(&GeoCoord::from_lat_lon(0.0, 0.0));
let moved_target = WebMercator::unproject(&WorldCoord::new(
start_world.position.x + 100.0,
start_world.position.y,
0.0,
));
state.set_camera_target(moved_target);
state.update_camera(0.5);
let motion = state.camera_motion_state();
approx_eq(motion.pan_velocity_world.x, 200.0);
approx_eq(motion.pan_velocity_world.y, 0.0);
approx_eq(
motion.predicted_target_world.x,
start_world.position.x + 200.0,
);
approx_eq(
motion.predicted_viewport_bounds.min.position.x,
state.viewport_bounds().min.position.x + 100.0,
);
approx_eq(
motion.predicted_viewport_bounds.max.position.x,
state.viewport_bounds().max.position.x + 100.0,
);
}
#[test]
fn camera_motion_state_uses_trimmed_sample_window() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
state.set_camera_distance(1_000.0);
state.set_camera_velocity_config(CameraVelocityConfig {
sample_window: 1,
look_ahead_seconds: 0.5,
});
state.update_camera(1.0);
let start_world = WebMercator::project(&GeoCoord::from_lat_lon(0.0, 0.0));
let first_pan = WebMercator::unproject(&WorldCoord::new(
start_world.position.x + 100.0,
start_world.position.y,
0.0,
));
state.set_camera_target(first_pan);
state.update_camera(1.0);
let second_pan = WebMercator::unproject(&WorldCoord::new(
start_world.position.x + 250.0,
start_world.position.y,
0.0,
));
state.set_camera_target(second_pan);
state.update_camera(1.0);
let motion = state.camera_motion_state();
approx_eq(motion.pan_velocity_world.x, 150.0);
approx_eq(
motion.predicted_viewport_bounds.min.position.x,
state.viewport_bounds().min.position.x + 75.0,
);
}
#[test]
fn update_tile_layers_issues_speculative_prefetch_from_predicted_viewport() {
let source = RecordingTileSource::default();
let mut state = MapState::new();
state.set_coordinator_config(CoordinatorConfig {
global_request_budget: 0,
..CoordinatorConfig::default()
});
state.push_layer(Box::new(TileLayer::new(
"raster",
Box::new(source.clone()),
32,
)));
let current = TileId::new(2, 1, 1);
let predicted = TileId::new(2, 2, 1);
let current_bounds = inset_bounds(rustial_math::tile_bounds_world(¤t), 1.0);
let predicted_bounds = inset_bounds(rustial_math::tile_bounds_world(&predicted), 1.0);
let predicted_center = rustial_math::tile_bounds_world(&predicted);
let predicted_center = glam::DVec2::new(
(predicted_center.min.position.x + predicted_center.max.position.x) * 0.5,
(predicted_center.min.position.y + predicted_center.max.position.y) * 0.5,
);
state.set_camera_target(tile_center_geo(current));
state.zoom_level = 2;
state.viewport_bounds = current_bounds;
state.camera_motion_state = CameraMotionState {
pan_velocity_world: glam::DVec2::new(1.0, 0.0),
predicted_target_world: predicted_center,
predicted_viewport_bounds: predicted_bounds,
};
state.update_tile_layers();
let requested = source.requested_ids();
assert!(!requested.is_empty());
assert!(requested.contains(&predicted));
assert_eq!(requested.last().copied(), Some(predicted));
}
#[test]
fn update_tile_layers_issues_route_prefetch_along_polyline() {
let source = RecordingTileSource::default();
let mut state = MapState::new();
state.set_coordinator_config(CoordinatorConfig {
global_request_budget: 0,
..CoordinatorConfig::default()
});
state.push_layer(Box::new(TileLayer::new(
"raster",
Box::new(source.clone()),
64,
)));
let route = vec![
GeoCoord::from_lat_lon(0.0, 0.0),
GeoCoord::from_lat_lon(0.0, 30.0),
GeoCoord::from_lat_lon(0.0, 60.0),
];
state.set_prefetch_route(route);
let camera_geo = GeoCoord::from_lat_lon(0.0, 0.0);
let camera_target = rustial_math::geo_to_tile(&camera_geo, 4).tile_id();
let camera_bounds = inset_bounds(rustial_math::tile_bounds_world(&camera_target), 1.0);
state.set_camera_target(camera_geo);
state.zoom_level = 4;
state.viewport_bounds = camera_bounds;
state.update_tile_layers();
let requested = source.requested_ids();
let route_ahead_tile =
rustial_math::geo_to_tile(&GeoCoord::from_lat_lon(0.0, 30.0), 4).tile_id();
assert!(
requested.contains(&route_ahead_tile),
"route-ahead tile {route_ahead_tile:?} should be prefetched; got {requested:?}"
);
}
#[test]
fn fit_bounds_centers_on_bounds() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_distance(100_000.0);
state.update();
let bounds = GeoBounds::from_coords(10.0, 40.0, 20.0, 50.0);
let opts = super::FitBoundsOptions {
animate: false,
..Default::default()
};
state.fit_bounds(&bounds, &opts);
state.update();
let target = state.camera().target();
assert!(
(target.lat - 45.0).abs() < 0.5,
"expected lat ~45, got {}",
target.lat
);
assert!(
(target.lon - 15.0).abs() < 0.5,
"expected lon ~15, got {}",
target.lon
);
}
#[test]
fn fit_bounds_max_zoom_clamps() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_distance(100_000.0);
state.update();
let bounds = GeoBounds::from_coords(10.0, 40.0, 10.001, 40.001);
let opts = super::FitBoundsOptions {
animate: false,
max_zoom: Some(10.0),
..Default::default()
};
state.fit_bounds(&bounds, &opts);
state.update();
let zoom = state.fractional_zoom();
assert!(zoom <= 10.5, "expected zoom <= 10.5, got {zoom}");
}
#[test]
fn geo_to_screen_center_roundtrips() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(GeoCoord::from_lat_lon(48.0, 11.0));
state.set_camera_distance(500_000.0);
state.update();
let center = *state.camera().target();
let (px, py) = state.geo_to_screen(¢er).expect("center should project");
assert!((px - 400.0).abs() < 2.0, "expected px ~400, got {px}");
assert!((py - 300.0).abs() < 2.0, "expected py ~300, got {py}");
}
#[test]
fn on_off_event_subscription() {
let mut state = MapState::new();
let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
let c = counter.clone();
let id = state.on(crate::interaction::InteractionEventKind::Click, move |_| {
c.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
});
let event = crate::interaction::InteractionEvent::new(
crate::interaction::InteractionEventKind::Click,
crate::interaction::PointerKind::Mouse,
crate::interaction::ScreenPoint::new(0.0, 0.0),
);
state.dispatch_events(std::slice::from_ref(&event));
assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), 1);
state.off(id);
state.dispatch_events(std::slice::from_ref(&event));
assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), 1);
}
#[test]
fn once_fires_only_once() {
let mut state = MapState::new();
let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
let c = counter.clone();
state.once(crate::interaction::InteractionEventKind::Click, move |_| {
c.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
});
let event = crate::interaction::InteractionEvent::new(
crate::interaction::InteractionEventKind::Click,
crate::interaction::PointerKind::Mouse,
crate::interaction::ScreenPoint::new(0.0, 0.0),
);
state.dispatch_events(std::slice::from_ref(&event));
state.dispatch_events(std::slice::from_ref(&event));
assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), 1);
}
#[test]
fn computed_fog_has_zero_density_at_zero_pitch() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
state.set_camera_distance(5_000.0);
state.set_camera_pitch(0.0); state.update();
let fog = state.computed_fog();
assert!(
fog.fog_density < 0.01,
"top-down camera should have near-zero fog density, got {}",
fog.fog_density,
);
}
#[test]
fn computed_fog_has_positive_density_at_high_pitch() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
state.set_camera_distance(5_000.0);
state.set_camera_pitch(1.2); state.update();
let fog = state.computed_fog();
assert!(
fog.fog_density > 0.5,
"steep pitch should have significant fog density, got {}",
fog.fog_density,
);
}
#[test]
fn set_fog_overrides_density() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
state.set_camera_distance(5_000.0);
state.set_camera_pitch(0.0);
state.set_fog(Some(crate::style::FogConfig {
density: Some(0.75),
..Default::default()
}));
state.update();
let fog = state.computed_fog();
assert!(
(fog.fog_density - 0.75).abs() < 0.001,
"fog density override should be honoured, got {}",
fog.fog_density,
);
}
#[test]
fn set_fog_overrides_color() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
state.set_camera_distance(5_000.0);
state.set_camera_pitch(0.5);
let red = [1.0, 0.0, 0.0, 1.0];
state.set_fog(Some(crate::style::FogConfig {
color: Some(red),
..Default::default()
}));
state.update();
let fog = state.computed_fog();
assert_eq!(fog.fog_color, red, "fog colour override should be honoured");
}
#[test]
fn set_fog_overrides_range() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
state.set_camera_distance(5_000.0);
state.set_camera_pitch(0.5);
state.set_fog(Some(crate::style::FogConfig {
range: Some([0.1, 0.5]),
..Default::default()
}));
state.update();
let fog = state.computed_fog();
assert!(
fog.fog_start < 1_000.0 && fog.fog_end < 4_000.0,
"fog range override should produce small start/end, got start={} end={}",
fog.fog_start,
fog.fog_end,
);
}
#[test]
fn style_document_fog_applies_through_set_style() {
let mut document = StyleDocument::new();
document.set_fog(Some(crate::style::FogConfig {
density: Some(0.42),
..Default::default()
}));
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
state.set_camera_distance(5_000.0);
state.set_camera_pitch(0.0);
state
.set_style(MapStyle::from_document(document))
.expect("style applied");
state.update();
let fog = state.computed_fog();
assert!(
(fog.fog_density - 0.42).abs() < 0.001,
"style document fog should be honoured, got {}",
fog.fog_density,
);
}
#[test]
fn direct_fog_overrides_style_document_fog() {
let mut document = StyleDocument::new();
document.set_fog(Some(crate::style::FogConfig {
density: Some(0.1),
..Default::default()
}));
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
state.set_camera_distance(5_000.0);
state.set_camera_pitch(0.0);
state
.set_style(MapStyle::from_document(document))
.expect("style applied");
state.set_fog(Some(crate::style::FogConfig {
density: Some(0.88),
..Default::default()
}));
state.update();
let fog = state.computed_fog();
assert!(
(fog.fog_density - 0.88).abs() < 0.001,
"direct fog override should take priority over style document, got {}",
fog.fog_density,
);
}
#[test]
fn clearing_fog_reverts_to_auto() {
let mut state = MapState::new();
state.set_viewport(800, 600);
state.set_camera_target(GeoCoord::from_lat_lon(0.0, 0.0));
state.set_camera_distance(5_000.0);
state.set_camera_pitch(0.0); state.set_fog(Some(crate::style::FogConfig {
density: Some(0.8),
..Default::default()
}));
state.update();
assert!(state.computed_fog().fog_density > 0.7);
state.set_fog(None);
state.update();
assert!(
state.computed_fog().fog_density < 0.01,
"clearing fog should revert to auto-computed values"
);
}
}