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