Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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