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

nordisk/facett

facett-osm/tests/testmatrix.rs

//! **Functional test-matrix cell + headless data/render proof for facett-osm.**
//!
//! facett-osm is the GeoParquet reader on the map hot path — every basemap (facett
//! map / geomap / map3d, korp's Map tab) is drawn from the `(lon,lat)` vertices and
//! `Way`s it decodes out of `osm2geoparquet`/katana-osm WKB. Before this it had good
//! in-lib unit tests but emitted **no** `nornir test` functional row and had no
//! end-to-end "the reader produced real drawable geometry" proof, so a WKB-decode
//! regression (the exact class of bug the bounds-check fix `0b5f0a6` guarded) would
//! surface only downstream, invisibly, with no red cell.
//!
//! Each check below asserts a REAL measured signal (Law 1: never "didn't panic") and
//! emits one row via `facett_core::testmatrix::emit` / `emit_render` — a no-op in
//! release / when the `testmatrix` feature is off, exactly the leaf idiom
//! facett-lineage/facett-graphnav use. The whole pipeline runs **in memory** through
//! the `_bytes` readers (the same filesystem-free path the wasm web demo uses), so no
//! temp files and no `osm2geoparquet` binary are needed.

use std::io::Write as _;
use std::sync::Arc;

use arrow_array::{BinaryArray, RecordBatch, StringArray};
use arrow_schema::{DataType, Field, Schema};
use facett_core::Theme;
use facett_map::WayKind;
use facett_osm::{read_points_bytes, read_ways_bytes, read_ways_bytes_reported};

// ── WKB builders (little-endian) — the exact bytes osm2geoparquet writes. ──

fn wkb_point(lon: f64, lat: f64) -> Vec<u8> {
    let mut b = vec![1u8];
    b.extend_from_slice(&1u32.to_le_bytes()); // Point
    b.extend_from_slice(&lon.to_le_bytes());
    b.extend_from_slice(&lat.to_le_bytes());
    b
}

fn wkb_linestring(pts: &[(f64, f64)]) -> Vec<u8> {
    let mut b = vec![1u8];
    b.extend_from_slice(&2u32.to_le_bytes()); // LineString
    b.extend_from_slice(&(pts.len() as u32).to_le_bytes());
    for &(x, y) in pts {
        b.extend_from_slice(&x.to_le_bytes());
        b.extend_from_slice(&y.to_le_bytes());
    }
    b
}

/// A single-ring WKB Polygon (an osm2geoparquet *area* way); `ring` is closed.
fn wkb_polygon(ring: &[(f64, f64)]) -> Vec<u8> {
    let mut b = vec![1u8];
    b.extend_from_slice(&3u32.to_le_bytes()); // Polygon
    b.extend_from_slice(&1u32.to_le_bytes()); // 1 ring
    b.extend_from_slice(&(ring.len() as u32).to_le_bytes());
    for &(x, y) in ring {
        b.extend_from_slice(&x.to_le_bytes());
        b.extend_from_slice(&y.to_le_bytes());
    }
    b
}

/// Encode a `geometry`(WKB)+`tags`(JSON) ways.parquet **in memory** — the byte-for-byte
/// shape `read_ways_bytes` decodes, and the shape the wasm demo `include_bytes!`s.
fn ways_parquet(rows: &[(Vec<u8>, &str)]) -> Vec<u8> {
    let schema = Arc::new(Schema::new(vec![
        Field::new("geometry", DataType::Binary, false),
        Field::new("tags", DataType::Utf8, true),
    ]));
    let geom = BinaryArray::from(rows.iter().map(|(b, _)| Some(b.as_slice())).collect::<Vec<_>>());
    let tags = StringArray::from(rows.iter().map(|(_, t)| Some(*t)).collect::<Vec<_>>());
    let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(geom), Arc::new(tags)]).unwrap();
    let mut buf: Vec<u8> = Vec::new();
    {
        let mut w = parquet::arrow::ArrowWriter::try_new(&mut buf, schema, None).unwrap();
        w.write(&batch).unwrap();
        w.close().unwrap();
    }
    buf
}

/// Encode a `geometry`(WKB Points) nodes.parquet in memory.
fn nodes_parquet(pts: &[(f64, f64)]) -> Vec<u8> {
    let schema = Arc::new(Schema::new(vec![Field::new("geometry", DataType::Binary, false)]));
    let blobs: Vec<Vec<u8>> = pts.iter().map(|&(lon, lat)| wkb_point(lon, lat)).collect();
    let geom = BinaryArray::from(blobs.iter().map(|b| Some(b.as_slice())).collect::<Vec<_>>());
    let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(geom)]).unwrap();
    let mut buf: Vec<u8> = Vec::new();
    {
        let mut w = parquet::arrow::ArrowWriter::try_new(&mut buf, schema, None).unwrap();
        w.write(&batch).unwrap();
        w.close().unwrap();
    }
    let _ = buf.flush();
    buf
}

/// **The functional test-matrix cell for facett-osm.** Decodes a synthetic ways +
/// nodes GeoParquet end-to-end (in memory) and emits one `nornir test` row per
/// meaningful, measured signal: nodes decode to the right point count; ways decode +
/// tag-classify (a water Polygon → filled area, a highway LineString → stroked line);
/// and — the render-proof the map hot path needs — the reader produced **non-blank
/// drawable geometry** (`emit_render`: total decoded way vertices > 0 over a
/// non-degenerate bbox), so a decode regression that zeroes the geometry reddens the
/// cell instead of silently blanking every downstream map.
#[test]
fn testmatrix_cell_facett_osm_decode() {
    // ── 1. Nodes: N WKB Points decode to N (lon,lat) vertices. ──
    let node_pts = [(9.50, 47.10), (9.55, 47.15), (9.60, 47.20), (9.52, 47.12)];
    let nodes = read_points_bytes(nodes_parquet(&node_pts), 1_000).expect("decode nodes.parquet");
    let nodes_ok = nodes.len() == node_pts.len() && (nodes[0].0 - 9.50).abs() < 1e-9;
    facett_core::testmatrix::emit(
        "facett-osm/nodes",
        "decodes_wkb_points",
        nodes_ok,
        &format!("{}/{} points, first lon={:.2}", nodes.len(), node_pts.len(), nodes[0].0),
    );
    assert!(nodes_ok, "all {} node points decoded", node_pts.len());

    // ── 2. Ways: a water Polygon classifies as a filled AREA, a highway LineString as
    // a stroked LINE — the tag-driven area-vs-line split the whole basemap render keys on.
    let water_ring = [(9.40, 47.00), (9.45, 47.00), (9.45, 47.05), (9.40, 47.05), (9.40, 47.00)];
    let rows: Vec<(Vec<u8>, &str)> = vec![
        (wkb_polygon(&water_ring), r#"{"natural":"water"}"#),
        (wkb_linestring(&[(9.50, 47.10), (9.55, 47.15), (9.60, 47.20)]), r#"{"highway":"primary"}"#),
        (wkb_linestring(&[(9.41, 47.01), (9.42, 47.02)]), r#"{"natural":"coastline"}"#),
    ];
    let ways = read_ways_bytes(ways_parquet(&rows), 0, &Theme::default()).expect("decode ways.parquet");
    let water = ways.iter().find(|w| w.style.kind == WayKind::Water);
    let road = ways.iter().find(|w| w.style.kind == WayKind::Road);
    let classified = ways.len() == 3
        && water.is_some_and(|w| w.style.is_area && w.fill.is_some())
        && road.is_some_and(|w| !w.style.is_area && w.fill.is_none());
    facett_core::testmatrix::emit(
        "facett-osm/ways",
        "classifies_area_vs_line",
        classified,
        &format!("{} ways: water-area={} road-line={}", ways.len(), water.is_some(), road.is_some()),
    );
    assert!(classified, "water Polygon → filled area, highway LineString → stroked line");

    // ── 3. Render proof: the reader produced real, non-blank drawable geometry. ──
    // `vertices` = every decoded Mercator vertex the map would tessellate; `geometry` =
    // the way count. Both must be > 0 (emit_render reddens on either == 0), and the
    // vertices must span a non-degenerate bbox (not all one point).
    let vertices: usize = ways.iter().map(|w| w.points.len()).sum();
    let (mut minx, mut maxx, mut miny, mut maxy) = (f32::MAX, f32::MIN, f32::MAX, f32::MIN);
    for w in &ways {
        for &(x, y) in &w.points {
            minx = minx.min(x);
            maxx = maxx.max(x);
            miny = miny.min(y);
            maxy = maxy.max(y);
        }
    }
    let non_degenerate = vertices > 0 && (maxx - minx) > 1e-6 && (maxy - miny) > 1e-6;
    facett_core::testmatrix::emit_render("facett-osm", vertices, ways.len());
    assert!(non_degenerate, "decoded {vertices} vertices spanning a real bbox ({minx}..{maxx}, {miny}..{maxy})");
}

/// A truncated / malformed WKB blob must decode to nothing WITHOUT panicking — the
/// bounds-safety guarantee (`0b5f0a6`) a hostile or corrupt parquet relies on. Emits a
/// robustness row so a regression that reintroduces an out-of-bounds panic goes red.
#[test]
fn testmatrix_cell_truncated_wkb_is_safe() {
    // A ways.parquet whose one geometry is a LineString header with its point-count
    // word cut off (len 5..9) — the classic OOB trigger.
    let mut clipped = vec![1u8];
    clipped.extend_from_slice(&2u32.to_le_bytes()); // LineString type
    clipped.truncate(6); // header present, count word severed
    let rows: Vec<(Vec<u8>, &str)> = vec![(clipped, "{}")];
    let parquet = ways_parquet(&rows);

    // The property under test is BOUNDS SAFETY: the walk stops at the severed field
    // instead of reading past it. The reporting reader is where that is observable
    // without an opinion about what the caller should be told — it returns the data
    // AND the account, so a panic is the only way to fail it.
    let (ways, rr) = read_ways_bytes_reported(parquet.clone(), 0, &Theme::default())
        .expect("a truncated blob is a content fault, not a schema fault");
    let safe = ways.is_empty() && rr.rows == 1 && rr.refused.truncated;

    // …and the caller who did NOT ask for the account must not be handed an empty
    // Vec that looks exactly like a clean read of an empty file. That was stinky
    // `korpdep-3`: this assertion used to read `Ok(w) if w.is_empty()`, which
    // codified the silent failure as the expected outcome. Every row was refused and
    // nothing was kept, so it is an Err now, and it NAMES the fault.
    let named = match read_ways_bytes(parquet, 0, &Theme::default()) {
        Err(e) => e.contains(facett_osm::ERR_MALFORMED),
        Ok(_) => false,
    };

    facett_core::testmatrix::emit(
        "facett-osm/wkb",
        "truncated_blob_does_not_panic",
        safe && named,
        "clipped LineString header → 0 ways, no OOB, and the refusal is named",
    );
    assert!(safe, "a truncated WKB blob is walked safely and counted, got {rr:?}");
    assert!(named, "…and the plain reader names it instead of returning a silent empty Ok");
}