facet-wrapped/tests/graph_payload_carries_place_and_group.rs
//! **Row 4 — `graphnav` threw away the caller's coordinates.**
//!
//! The graph family's payload contract has documented `pos` and `group` since it was
//! written, and `graph3d`/`graphpan`/`graphview` all honour `pos`. `graphnav` — the
//! surface the owner names first ("prio FalkorDB graphs") — did not. It built a
//! `PropGraph` from `id`/`label`/`key`/`props` and dropped the rest, so a caller that
//! had already projected every node onto a map plane got a **force layout**.
//!
//! MEASURED before this row, feeding `pos: [111, 222]` and `[333, 444]`:
//!
//! ```json
//! "canvas": { "layout": "force",
//! "nodes": [ {"id":"0","x":0.8999999,"y":3.93e-8},
//! {"id":"1","x":-0.8999999,"y":-3.93e-8} ] }
//! ```
//!
//! Two nodes on a horizontal line, which is what `force` does with a single edge. The
//! coordinates went in and nothing came out that depended on them.
//!
//! ## The seam already existed on both sides
//!
//! `facett-graphnav` has `with_places(Vec<Place>)`, a `LayoutKind::Geographic`, and
//! `places_cover` as THE one predicate the switcher and the layout share — plus
//! `layout.has_places` / `layout.placed_nodes` in `state_json`. `GraphPayload` already
//! parses `pos`. Nothing needed inventing; the two halves were simply not connected.
//!
//! ## A correction to how this row was specified
//!
//! The brief's red test was `state_json.canvas.nodes[i].pos == input`. There is no
//! `pos` under a canvas node — graphnav reports `x` and `y` — and the values are
//! **normalised into the `[-1,1]²` box**, not echoed. Asserting equality with the
//! input would fail on a correct implementation. What is actually assertable, and
//! stronger, is that the layout is DERIVED from the input: relative order, aspect and
//! spread must follow the coordinates, and `has_places` must flip.
use facett_core::Facet;
use serde_json::{Value, json};
/// Four nodes in an L, deliberately NOT symmetric, so a force layout cannot
/// accidentally reproduce their arrangement.
fn placed_payload() -> Value {
json!({
"title": "placed",
"nodes": [
{"id": 0, "label": "Stad", "key": "Stockholm", "group": "city",
"pos": [18.07, 59.33], "props": {"kommun": "Stockholm", "invanare": "978770"}},
{"id": 1, "label": "Stad", "key": "Göteborg", "group": "city",
"pos": [11.97, 57.71], "props": {"kommun": "Göteborg"}},
{"id": 2, "label": "Stad", "key": "Malmö", "group": "city",
"pos": [13.00, 55.61], "props": {"kommun": "Malmö"}},
{"id": 3, "label": "Stad", "key": "Kiruna", "group": "city",
"pos": [20.22, 67.86], "props": {"kommun": "Kiruna"}}
],
"edges": [{"src": 0, "dst": 1, "rel": "VAG"}, {"src": 1, "dst": 2, "rel": "VAG"},
{"src": 0, "dst": 3, "rel": "VAG"}]
})
}
fn unplaced_payload() -> Value {
let mut v = placed_payload();
for n in v["nodes"].as_array_mut().unwrap() {
n.as_object_mut().unwrap().remove("pos");
}
v
}
fn state(v: &Value) -> Value {
facet_wrapped::graph::graphnav(v).expect("graphnav builds").state_json()
}
fn node_xy(st: &Value) -> Vec<(f64, f64)> {
st["canvas"]["nodes"]
.as_array()
.expect("canvas.nodes")
.iter()
.map(|n| (n["x"].as_f64().unwrap(), n["y"].as_f64().unwrap()))
.collect()
}
#[test]
fn a_payload_with_positions_makes_the_geographic_layout_available() {
let st = state(&placed_payload());
assert_eq!(
st["layout"]["has_places"], true,
"every node carried a `pos` and graphnav still reports no places — the \
coordinates were dropped between the payload and the model. layout: {}",
st["layout"]
);
assert_eq!(st["layout"]["placed_nodes"], 4);
assert!(
st["layout"]["options"]
.as_array()
.unwrap()
.iter()
.any(|o| o == "geographic"),
"the geographic layout must be OFFERED once the places are there: {}",
st["layout"]
);
}
#[test]
fn a_payload_without_positions_honestly_reports_none() {
// The other direction, and it matters as much: `has_places` must not become a
// constant `true`. A graph with no coordinates has no geographic lane, and
// offering one would put nodes somewhere they are not.
let st = state(&unplaced_payload());
assert_eq!(st["layout"]["has_places"], false);
assert_eq!(st["layout"]["placed_nodes"], 0);
assert!(
!st["layout"]["options"].as_array().unwrap().iter().any(|o| o == "geographic"),
"geographic must not be offered for a graph with no places"
);
}
#[test]
fn the_drawn_shape_follows_the_coordinates_and_not_the_edges() {
// THE assertion. Not equality with the input — graphnav normalises into `[-1,1]²`
// — but that the arrangement is DERIVED from it. Kiruna is far north of the other
// three and Göteborg is west of Stockholm; a layout that reproduces those
// relations cannot have come from the three edges, which say nothing about
// north or west.
let st = state(&placed_payload());
assert_eq!(st["canvas"]["layout"], "geographic", "state: {}", st["canvas"]);
let xy = node_xy(&st);
let (sthlm, gbg, malmo, kiruna) = (xy[0], xy[1], xy[2], xy[3]);
assert!(gbg.0 < sthlm.0, "Göteborg is west of Stockholm: {gbg:?} vs {sthlm:?}");
assert!(malmo.0 < sthlm.0, "Malmö is west of Stockholm: {malmo:?} vs {sthlm:?}");
// Screen y grows downward in this box, so "north" is the SMALLER y.
let north_is_smaller_y = kiruna.1 < malmo.1;
let north_is_larger_y = kiruna.1 > malmo.1;
assert!(
north_is_smaller_y || north_is_larger_y,
"Kiruna and Malmö must not land at the same latitude: {kiruna:?} {malmo:?}"
);
// Whichever way the axis points, the three southern cities must be on the SAME
// side of Kiruna — that is the property a force layout cannot fake.
let sthlm_same = (sthlm.1 < kiruna.1) == (malmo.1 < kiruna.1);
let gbg_same = (gbg.1 < kiruna.1) == (malmo.1 < kiruna.1);
assert!(sthlm_same && gbg_same, "Kiruna must be off on its own axis end: {xy:?}");
}
#[test]
fn the_layout_actually_changes_when_the_positions_do() {
// A geographic layout that ignored its input would still say `geographic` and
// still place nodes. Moving one city must move exactly that node.
let a = node_xy(&state(&placed_payload()));
let mut moved = placed_payload();
moved["nodes"][2]["pos"] = json!([24.0, 65.0]); // Malmö → far north-east
let b = node_xy(&state(&moved));
assert_ne!(a[2], b[2], "the moved node did not move: {:?} -> {:?}", a[2], b[2]);
assert!(
a.len() == b.len() && a.iter().zip(&b).filter(|(p, q)| p != q).count() >= 1,
"no node moved at all"
);
}
/// Build the graphnav through the ADAPTER'S OWN builder, select node `id`, and return
/// its state. The inspector only publishes a node's props when one is SELECTED, and a
/// `Box<dyn Facet>` cannot be downcast back to `GraphNav` to drive that.
///
/// The first version of this helper rebuilt the graph by copying the adapter's node
/// loop — which would have kept passing while the adapter drifted underneath it. It
/// now calls `graphnav_view`, the same function the adapter calls, so there is one
/// writer and nothing to drift (LAW 5).
fn state_with_selection(v: &Value, id: &str) -> Value {
use facett_graphnav::Msg;
let p = facet_wrapped::graph::GraphPayload::parse(v).expect("payload parses");
let mut nav = facet_wrapped::graph::graphnav_view(&p);
assert!(nav.update(Msg::SelectNode(id.to_string())), "selecting `{id}` changed nothing");
nav.state_json()
}
#[test]
fn props_reach_the_node_and_the_inspector_shows_them() {
// `props` already flowed into the model before this row. Asserted so that wiring
// `pos` and `group` in beside them cannot quietly drop them — and asserted through
// the INSPECTOR, which is the surface a person actually reads a node's data in.
let st = state_with_selection(&placed_payload(), "0");
assert_eq!(st["inspector"]["kind"], "node", "nothing selected: {}", st["inspector"]);
let props = &st["inspector"]["props"];
assert_eq!(props["kommun"], "Stockholm", "the caller's props: {props}");
assert_eq!(props["invanare"], "978770");
assert_eq!(props["key"], "Stockholm", "the business key is carried too");
}
#[test]
fn group_reaches_the_node_as_a_property() {
// `group` is the payload's word for "what kind of thing is this", and it is what
// `group_color` keys on for every other surface in the family. graphnav colours by
// LABEL and has its own pinned-ontology palette, so the group is carried as a
// PROPERTY rather than re-invented here as a second colour rule — one meaning, two
// readers, no third palette to drift.
let st = state_with_selection(&placed_payload(), "0");
assert_eq!(
st["inspector"]["props"]["group"], "city",
"the node `group` did not reach graphnav: {}", st["inspector"]["props"]
);
}
#[test]
fn an_explicit_props_group_still_wins_over_the_payload_field() {
// The group is written BEFORE the caller's own props, so a caller who puts a
// `group` in `props` deliberately is not overwritten by the shorthand field.
let mut v = placed_payload();
v["nodes"][0]["props"]["group"] = json!("kommun-huvudort");
let st = state_with_selection(&v, "0");
assert_eq!(st["inspector"]["props"]["group"], "kommun-huvudort");
}
#[test]
fn the_shipped_gallery_payload_actually_gets_the_geographic_layout() {
// The demo is the only place a person SEES this row. `gallery_covers_the_manifest`
// proves every payload BUILDS; that is not the same as proving this one is placed —
// a graphnav payload with no `pos` builds perfectly and shows a force layout.
//
// So the shipped table is read here and the outcome asserted, which also means the
// demo cannot quietly lose its coordinates in a future edit.
let table: Value = serde_json::from_str(
&std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("py/facett_demo/demo_payloads.json"),
)
.expect("the gallery payload table must exist"),
)
.expect("valid JSON");
let st = state(&table["payloads"]["graphnav"]);
assert_eq!(
st["layout"]["has_places"], true,
"the gallery's graphnav payload lost its coordinates"
);
assert_eq!(st["canvas"]["layout"], "geographic", "canvas: {}", st["canvas"]);
assert_eq!(st["canvas"]["total_nodes"], 5);
}