facett-geomap/tests/osm_pins.rs
//! **Robot proof (LAW #4) — the OSM 2D address-pin overlay.**
//!
//! `OsmView` can carry a labelled **address-pin** overlay registered to the map
//! transform. This test asserts the four things a beautify PR must not regress:
//!
//! 1. **state_json** reports the overlay (`pins`, `visible_pins`, `selected_pin`)
//! and `caps().selectable` is advertised (the pin overlay resolves a click to
//! a stable id — FC-5).
//! 2. **selection is data** — driving `Msg::SelectPin` / `Msg::ClearPin` moves
//! the observable `selected_pin`, headlessly.
//! 3. **non-blank render** — the markers paint through egui_kittest's wgpu
//! backend and at least one pin is on-screen.
//! 4. **alignment guard (<1px)** — a pin and a co-located way vertex project to
//! the SAME screen point at max zoom (the pin rides the CPU f64 `to_px` lane,
//! sharing the way/point coordinate space — it does not drift off on the f32
//! line lane).
//!
//! Run: `cargo test -p facett-geomap --test osm_pins`.
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use facett_core::Facet;
use facett_geomap::mercator;
use facett_geomap::osm2d::{MercatorCamera, Msg, OsmView, Pin, sample_dataset};
fn content_ratio(img: &image::RgbaImage) -> f64 {
let mut hist = std::collections::HashMap::<u32, u32>::new();
for p in img.pixels() {
let key = ((p.0[0] as u32 >> 3) << 10) | ((p.0[1] as u32 >> 3) << 5) | (p.0[2] as u32 >> 3);
*hist.entry(key).or_default() += 1;
}
let (modal, _) = hist.iter().max_by_key(|(_, c)| **c).map(|(k, c)| (*k, *c)).unwrap();
let (mr, mg, mb) = (((modal >> 10) & 0x1f) as i32, ((modal >> 5) & 0x1f) as i32, (modal & 0x1f) as i32);
let mut content = 0usize;
for p in img.pixels() {
let (r, g, b) = ((p.0[0] >> 3) as i32, (p.0[1] >> 3) as i32, (p.0[2] >> 3) as i32);
if (r - mr).abs() + (g - mg).abs() + (b - mb).abs() > 3 {
content += 1;
}
}
content as f64 / (img.width() * img.height()) as f64
}
#[test]
fn osm_pins_are_observable_selectable_and_render() {
let (points, _ways) = sample_dataset(2_000);
let pins = vec![Pin::new(9.53, 47.15, "Vaduz"), Pin::new(9.50, 47.10, "Balzers")];
let mut view = OsmView::from_geometry("OSM 2D", points, Vec::new()).with_pins(pins);
// The overlay + the selectable cap are advertised.
let s = view.state_json();
assert_eq!(s["pins"], 2, "two pins in the overlay");
assert!(s["selected_pin"].is_null(), "nothing selected initially");
assert!(view.caps().selectable, "the pin overlay makes the view selectable");
// Selection is data (FC-5): drive it headlessly through the Msg boundary.
view.update(Msg::SelectPin("Vaduz".to_string()));
assert_eq!(view.state_json()["selected_pin"], "Vaduz", "SelectPin is observable");
view.update(Msg::ClearPin);
assert!(view.state_json()["selected_pin"].is_null(), "ClearPin empties the selection");
// Non-blank render with the markers on-screen.
let mut harness = Harness::builder().with_size(egui::vec2(640.0, 640.0)).build_ui(move |ui| {
facett_core::set_theme(ui.ctx(), facett_core::Theme::default());
view.ui(ui);
// After a fitted frame at least one pin is visible.
let vis = view.state_json()["visible_pins"].as_u64().unwrap_or(0);
assert!(vis >= 1, "at least one pin projects on-screen (got {vis})");
});
harness.run_steps(3);
let img = harness.render().expect("wgpu render produced an image");
let ratio = content_ratio(&img);
eprintln!("[osm-pins] content ratio {ratio:.4}");
assert!(ratio > 0.01, "the pin markers must paint (got {ratio:.4})");
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit_non_blank("OsmView.address_pins", ratio, 0.01, 2);
}
#[test]
fn pin_registers_to_the_map_transform_within_1px_at_max_zoom() {
// A pin and a co-located way vertex, both at this lon/lat.
let lonlat = (9.5123, 47.1456);
let pin = Pin::new(lonlat.0, lonlat.1, "Rathaus");
let mut view = OsmView::from_geometry("OSM 2D", Vec::new(), Vec::new()).with_pins(vec![pin.clone()]);
// Park the camera on the pin at the maximum street zoom (where the f32 line
// lane would catastrophically cancel).
let (mx, my) = mercator(lonlat.0, lonlat.1);
view.camera = MercatorCamera { center: (mx, my), zoom: 8.0e7, target_zoom: 8.0e7, pitch_deg: 0.0 };
let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(400.0, 400.0));
// The pin's marker position …
let pin_px = view.pin_screen_pos(&pin, rect);
// … and the co-located way vertex through the CPU f64 lane (the fill/line
// reprojection the landed correctness fix uses) must agree to sub-pixel.
let way_vertex_px = view.camera.to_px(mx, my, rect);
assert!(
pin_px.distance(way_vertex_px) < 1.0,
"pin ({pin_px:?}) drifts from the co-located way vertex ({way_vertex_px:?}) at max zoom"
);
// And since the camera is centred on the pin, it lands at the pane centre.
assert!(pin_px.distance(rect.center()) < 1.0, "a centred pin projects to the pane centre");
}
/// **The pins are painted from THIS frame's camera, not the previous frame's.**
///
/// `OsmView::ui` paints every frame through a local copy of the camera with the
/// frame's transitions (the damped zoom tick, the drag) replayed onto it; the Model
/// `self.camera` catches up a frame later through `Msg::TickZoom`. The pin markers,
/// labels and hit rects were the ONE overlay still projected through `self.camera`,
/// so during a zoom ease the basemap moved this frame and the pins the next — every
/// pin slid off its anchor until the ease settled (shit list #7).
///
/// The observable is the pin's AccessKit hit rect, whose bottom edge is the marker's
/// TIP + 3 px and whose centre x is the anchor — i.e. the painted anchor `p` read back
/// from the accessibility tree. Seen RED on the code this replaces: with the zoom
/// mid-ease (`target_zoom = 4 × zoom`, one tick = 35 % of the gap) the painted anchor
/// was the PREVIOUS camera's projection, 40+ px from where this frame's camera (the
/// Model after the frame's `TickZoom`) puts it.
#[test]
fn pins_are_painted_from_the_frame_camera_during_a_zoom_ease() {
use std::cell::RefCell;
use std::rc::Rc;
let (points, _ways) = sample_dataset(500);
let vaduz = Pin::new(9.53, 47.15, "Vaduz");
let balzers = Pin::new(9.50, 47.10, "Balzers");
let view = Rc::new(RefCell::new(
OsmView::from_geometry("OSM 2D", points, Vec::new()).with_pins(vec![vaduz.clone(), balzers.clone()]),
));
let v = view.clone();
let mut harness = Harness::builder().with_size(egui::vec2(640.0, 640.0)).build_ui(move |ui| {
facett_core::set_theme(ui.ctx(), facett_core::Theme::default());
v.borrow_mut().ui(ui);
});
// Settle the first-paint fit.
harness.run_steps(3);
// The MAP rect (below the deck strip) is the pane summary node's own bounds — the
// `Image` node `OsmView::ui` registers on the rect it allocated for the map.
let pane_rect = |h: &Harness<'_>| -> egui::Rect {
h.get_all_by_role(egui::accesskit::Role::Image)
.find(|n| n.accesskit_node().label().is_some_and(|l| l.starts_with("OSM 2D — ")))
.expect("the OSM 2D pane summary node")
.rect()
};
let rect = pane_rect(&harness);
assert!(rect.width() > 100.0 && rect.height() > 100.0, "a real pane rect was captured: {rect:?}");
// Start a zoom ease: the Model's zoom is far from its target, so the NEXT frame's
// paint camera is `camera.tick()` and the Model catches up via `Msg::TickZoom`.
{
let mut vb = view.borrow_mut();
let z = vb.camera.zoom;
vb.camera.target_zoom = z * 4.0;
// Make sure the ease is genuinely mid-flight (35 % of a 3× gap is a big step).
assert!((vb.camera.target_zoom - vb.camera.zoom).abs() > vb.camera.target_zoom * 1e-2);
}
harness.run_steps(1);
assert_eq!(pane_rect(&harness), rect, "the map rect is stable across the frame");
// After the frame the Model IS the camera the frame painted with (the tick was
// replayed on the paint copy and persisted by `Msg::TickZoom`).
let painted_cam = view.borrow().camera;
for pin in [&vaduz, &balzers] {
let (mx, my) = mercator(pin.lonlat.0, pin.lonlat.1);
let expect = painted_cam.to_px(mx, my, rect);
let node_rect = harness.get_by_label(&format!("pin {}", pin.label)).rect();
// The hit rect is `[p.x-(r+3), p.y-(2r+5+3)] .. [p.x+(r+3), p.y+3]` with r = 6.
let painted = egui::pos2(node_rect.center().x, node_rect.max.y - 3.0);
let d = painted.distance(expect);
eprintln!(
"[osm-pins] {}: node rect {node_rect:?} → painted anchor {painted:?} vs frame-camera projection {expect:?} — {d:.2} px",
pin.label
);
assert!(
d < 1.0,
"pin {} is painted {d:.1} px from where THIS frame's camera projects it (painted {painted:?}, \
expected {expect:?}) — it rides the previous frame's camera",
pin.label
);
}
}