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_garmin.rs

//! **Robot proof (LAW #4) — the OSM 2D Garmin-nav layers.**
//!
//! Task #50 turned the cartographic `OsmView` into a car-navigator basemap. This
//! test locks the new structural layers so a later change can't silently regress
//! them (all red-when-broken):
//!
//! 1. **Road-class hierarchy + casing** — a set of ways spanning every `highway`
//!    class renders non-blank through the wgpu backend with the dark casing band
//!    (the two-pass, class-sorted road painter ran).
//! 2. **One-way direction chevrons** — a `oneway` road parses its direction and
//!    renders (the arrow pass ran) without disturbing the two-way case.
//! 3. **Route-line highlight** — a `with_route` polyline is advertised in
//!    `state_json["route"]` and paints its bright azure ribbon (a band of route
//!    pixels appears that was absent with no route).
//! 4. **Road-name labels (priority + collision)** — named roads place labels
//!    (`state_json["visible_labels"] > 0`); stripping the names drops the count to
//!    zero (the label layer is driven by real data, not always-on chrome).
//!
//! Run: `cargo test -p facett-geomap --test osm_garmin`.

use egui_kittest::Harness;

use facett_core::Facet;
use facett_geomap::osm2d::OsmView;
use facett_map::Way;

fn tags(kv: &[(&str, &str)]) -> Vec<(String, String)> {
    kv.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}

/// A named road of an explicit `highway` class through a short poly-line near
/// Vaduz, optionally one-way.
fn road(id: i64, hw: &str, name: &str, oneway: bool, pts: &[(f64, f64)]) -> Way {
    let mut kv = vec![("highway", hw), ("name", name)];
    if oneway {
        kv.push(("oneway", "yes"));
    }
    Way::from_lonlat(id, pts, &tags(&kv), &facett_core::Theme::default()).unwrap()
}

/// A small multi-class street grid in a ~0.02° box (so the fit-zoom draws the
/// roads wide enough that casing / arrows / labels all engage).
fn street_grid(named: bool) -> Vec<Way> {
    let n = |s: &'static str| if named { s } else { "" };
    vec![
        road(1, "motorway", n("E4 Motorway"), true, &[(9.50, 47.10), (9.52, 47.115), (9.54, 47.12)]),
        road(2, "primary", n("Landstrasse"), false, &[(9.50, 47.12), (9.54, 47.10)]),
        road(3, "secondary", n("Bahnhofstrasse"), true, &[(9.51, 47.10), (9.51, 47.125)]),
        road(4, "tertiary", n("Kirchweg"), false, &[(9.505, 47.105), (9.535, 47.118)]),
        road(5, "residential", n("Wiesenweg"), false, &[(9.515, 47.108), (9.525, 47.122)]),
    ]
}

/// Fraction of pixels differing from the modal (≈background) colour — a blank
/// pane scores ~0.
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 usize as f64
}

/// Pixels within Manhattan distance `tol` of `target`.
fn count_near(img: &image::RgbaImage, target: (u8, u8, u8), tol: i32) -> usize {
    let (tr, tg, tb) = (target.0 as i32, target.1 as i32, target.2 as i32);
    img.pixels()
        .filter(|p| (p.0[0] as i32 - tr).abs() + (p.0[1] as i32 - tg).abs() + (p.0[2] as i32 - tb).abs() <= tol)
        .count()
}

#[test]
fn road_class_hierarchy_and_casing_render() {
    let img = render_img(street_grid(true), None);
    // Non-blank — the class-sorted two-pass road network drew on the paper.
    let ratio = content_ratio(&img);
    eprintln!("[garmin] content ratio {ratio:.4}");
    // Day casing #B7AE9E rings the cased roads (the casing pass ran).
    let casing = count_near(&img, (0xB7, 0xAE, 0x9E), 26);
    eprintln!("[garmin] casing px {casing}");
    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-geomap-garmin",
        "road_hierarchy_and_casing",
        ratio > 0.02 && casing > 20,
        &format!("content_ratio={ratio:.4} casing_px={casing}"),
    );
    assert!(ratio > 0.02, "the Garmin road network fills a visible region (got {ratio:.4})");
    assert!(casing > 20, "the class-sorted road casing paints a visible band (got {casing})");
}

#[test]
fn named_roads_place_priority_labels_unnamed_place_none() {
    let named_labels = render_and_read(street_grid(true), None)["visible_labels"].as_u64().unwrap();
    let unnamed_labels = render_and_read(street_grid(false), None)["visible_labels"].as_u64().unwrap();
    eprintln!("[garmin] labels named={named_labels} unnamed={unnamed_labels}");
    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-geomap-garmin",
        "road_name_labels",
        named_labels > 0 && unnamed_labels == 0,
        &format!("named_labels={named_labels} unnamed_labels={unnamed_labels}"),
    );
    assert!(named_labels > 0, "named roads place priority labels (got {named_labels})");
    assert_eq!(unnamed_labels, 0, "unnamed roads place no labels (the layer is data-driven)");
}

#[test]
fn route_highlight_is_advertised_and_paints() {
    // The route follows the primary road across the grid.
    let route = vec![(9.50, 47.12), (9.52, 47.11), (9.54, 47.10)];
    let route_len = route.len() as u64;
    let with = render_and_read(street_grid(true), Some(route.clone()));
    let without = render_and_read(street_grid(true), None);
    let advertised = with["route"].as_u64().unwrap();
    let none_advertised = without["route"].as_u64().unwrap();

    // The bright azure route fill (#3AA0FF) paints a band only when a route is set.
    let img_with = render_img(street_grid(true), Some(route));
    let img_without = render_img(street_grid(true), None);
    let route_px = count_near(&img_with, (0x3A, 0xA0, 0xFF), 40);
    let none_px = count_near(&img_without, (0x3A, 0xA0, 0xFF), 40);
    eprintln!("[garmin] route px with={route_px} without={none_px}");
    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-geomap-garmin",
        "route_highlight",
        advertised == route_len && none_advertised == 0 && route_px > 40 && route_px > none_px + 30,
        &format!("advertised={advertised}/{route_len} route_px={route_px} none_px={none_px}"),
    );
    assert_eq!(advertised, route_len, "route vertex count is advertised");
    assert_eq!(none_advertised, 0, "no route → route == 0");
    assert!(route_px > 40, "the route ribbon paints its bright azure line (got {route_px})");
    assert!(route_px > none_px + 30, "the route line is present only when a route is set");
}

#[test]
fn oneway_parses_direction_and_two_way_stays_neutral() {
    let ow = Way::from_lonlat(1, &[(9.5, 47.1), (9.6, 47.2)], &tags(&[("highway", "primary"), ("oneway", "yes")]), &facett_core::Theme::default()).unwrap();
    let rev = Way::from_lonlat(2, &[(9.5, 47.1), (9.6, 47.2)], &tags(&[("highway", "primary"), ("oneway", "-1")]), &facett_core::Theme::default()).unwrap();
    let two = Way::from_lonlat(3, &[(9.5, 47.1), (9.6, 47.2)], &tags(&[("highway", "primary")]), &facett_core::Theme::default()).unwrap();
    let rbt = Way::from_lonlat(4, &[(9.5, 47.1), (9.51, 47.1), (9.51, 47.11), (9.5, 47.1)], &tags(&[("highway", "residential"), ("junction", "roundabout")]), &facett_core::Theme::default()).unwrap();
    // A one-way street grid renders non-blank (the chevron pass ran).
    let img = render_img(street_grid(true), None);
    let cr = content_ratio(&img);
    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-geomap-garmin",
        "oneway_direction",
        ow.oneway == 1 && rev.oneway == -1 && two.oneway == 0 && rbt.oneway == 1 && cr > 0.02,
        &format!("yes={} rev={} two={} rbt={} content_ratio={cr:.4}", ow.oneway, rev.oneway, two.oneway, rbt.oneway),
    );
    assert_eq!(ow.oneway, 1, "oneway=yes → forward");
    assert_eq!(rev.oneway, -1, "oneway=-1 → reverse");
    assert_eq!(two.oneway, 0, "no oneway tag → two-way");
    assert_eq!(rbt.oneway, 1, "a roundabout is implicitly one-way");
    assert!(cr > 0.02, "the one-way network renders");
}

// ── helpers that own the view so state_json can be read after the paint ──────

fn render_and_read(ways: Vec<Way>, route: Option<Vec<(f64, f64)>>) -> serde_json::Value {
    let state = std::rc::Rc::new(std::cell::RefCell::new(serde_json::Value::Null));
    let sink = state.clone();
    let mut view = OsmView::from_geometry("garmin", Vec::new(), ways).cartographic(true);
    if let Some(r) = route {
        view.set_route(r);
    }
    let mut h = Harness::builder().with_size(egui::vec2(680.0, 680.0)).build_ui(move |ui| {
        facett_core::set_theme(ui.ctx(), facett_core::Theme::default());
        view.ui(ui);
        *sink.borrow_mut() = view.state_json();
    });
    h.run_steps(3);
    let _ = h.render();
    state.borrow().clone()
}

fn render_img(ways: Vec<Way>, route: Option<Vec<(f64, f64)>>) -> image::RgbaImage {
    let mut view = OsmView::from_geometry("garmin", Vec::new(), ways).cartographic(true);
    if let Some(r) = route {
        view.set_route(r);
    }
    let mut h = Harness::builder().with_size(egui::vec2(680.0, 680.0)).build_ui(move |ui| {
        facett_core::set_theme(ui.ctx(), facett_core::Theme::default());
        view.ui(ui);
    });
    h.run_steps(3);
    h.render().expect("wgpu render produced an image")
}