facett-simview/tests/robot.rs
//! **The Simulate viewer, driven headless against the mock contract** (facett robot
//! style: every assertion reads `state_json` or the request log, never pixels).
//!
//! `cargo test -p facett-simview --test robot`
use std::cell::RefCell;
use facett_core::{harness, Facet};
use facett_graph3d::Msg as G3Msg;
use facett_simview::contract::{Req, Resp, VERB_GENERATE, VERB_PERMUTATE};
use facett_simview::mock::{MockBackend, MockServer};
use facett_simview::{Backend, SimView, POLL_S};
const PERSONS: usize = 5_000;
fn loaded() -> (SimView, MockBackend) {
let mock = MockBackend::new(MockServer::new(PERSONS));
let mut v = SimView::new(Box::new(mock.clone()));
v.step(0.0);
(v, mock)
}
/// Step the clock past each poll until the job is over and the refetch has landed.
fn run_job(v: &mut SimView, mut now: f64) -> f64 {
for _ in 0..20 {
now += POLL_S + 0.1;
v.step(now);
v.step(now); // the answers to what that step sent
let st = v.state_json();
if !st["busy"].as_bool().unwrap() && v.in_flight() == 0 {
return now;
}
}
panic!("the job never ended: {}", v.state_json());
}
fn count(log: &[Req], f: impl Fn(&Req) -> bool) -> usize {
log.iter().filter(|r| f(r)).count()
}
#[test]
fn the_3d_view_holds_every_person_and_lights_the_latest_round() {
let (mut v, mock) = loaded();
let st = v.state_json();
assert_eq!(st["nodes"], PERSONS, "{st}");
assert_eq!(st["graph3d_nodes"], PERSONS, "the 3D view holds every person");
assert_eq!(st["layout"], "given", "placed by kommun, not by a force layout");
// Round 0: everybody is the latest round.
assert_eq!(st["latest_round"], 0);
assert_eq!(st["highlighted"], mock.server.lock().unwrap().changed_in_latest_round());
// Permutate 10% → round 1 → exactly the moved tenth is lit.
v.update_json(r#"{"press":"permutate"}"#);
v.step(0.1);
run_job(&mut v, 0.1);
let st = v.state_json();
let changed = mock.server.lock().unwrap().changed_in_latest_round();
assert_eq!(changed, PERSONS / 10);
assert_eq!(st["latest_round"], 1, "{st}");
assert_eq!(st["highlighted"], changed, "highlighted == persons with round == latest round: {st}");
// And the paint says so: that many nodes carry the lit colour.
let lit = v.graph().nodes.iter().filter(|n| n.color == facett_simview::LIT).count();
assert_eq!(lit, changed);
assert!(st["status"].as_str().unwrap().contains("permutate: done"), "{st}");
}
#[test]
fn selecting_a_node_in_3d_fetches_its_ego_network_into_vg() {
let (mut v, mock) = loaded();
let id = 42_i64;
// A click in the 3D pane is `Msg::SelectNode(index)`; persons are in id order here.
let _ = v.graph_mut().update(G3Msg::SelectNode(id as usize));
// The frame notices the selection and asks for the ego network.
let r = harness::render_sized(&mut v, (1400.0, 800.0));
assert!(r.drew());
assert!(mock.log().contains(&Req::Ego(id)), "the selection fetched /sim/api/ego.arrow?id={id}");
v.step(0.0); // ego edges → asks for the node rows
assert!(mock.log().iter().any(|r| matches!(r, Req::NodesByIds(ids) if ids.contains(&id))));
v.step(0.0); // node rows → V.G
let st = v.state_json();
assert_eq!(st["selected"], id);
assert_eq!(st["selected_3d"], id as usize);
// The mock's person owns four records: the ego is 5 nodes, 4 edges.
assert_eq!(st["ego"]["of"], id);
assert_eq!(st["vg"]["nodes"], 5, "{st}");
assert_eq!(st["vg"]["edges"], 4, "{st}");
assert_eq!(st["vg"]["selected"], id.to_string());
// And V.G really renders it.
let r = harness::render_sized(&mut v, (1400.0, 800.0));
assert!(r.drew());
let nav = v.nav().expect("V.G is up");
assert!(nav.graph.nodes.iter().any(|n| n.id == id.to_string() && n.label == "Person"));
}
#[test]
fn a_typed_id_selects_too() {
let (mut v, mock) = loaded();
v.update_json(r#"{"select": 7}"#);
v.step(0.0);
v.step(0.0);
assert!(mock.log().contains(&Req::Ego(7)));
assert_eq!(v.state_json()["vg"]["nodes"], 5);
assert_eq!(v.state_json()["selected_3d"], 7);
}
#[test]
fn generate_posts_and_polls_every_two_seconds_until_done_then_refetches() {
let (mut v, mock) = loaded();
let before = mock.log().len();
v.press(VERB_GENERATE);
v.step(0.1);
let log = mock.log();
assert_eq!(log[before], Req::Press(VERB_GENERATE), "Generate is POST /power/falkordb/simulate");
assert_eq!(Req::Press(VERB_GENERATE).http(), ("POST", "/power/falkordb/simulate".to_string()));
assert!(v.state_json()["busy"].as_bool().unwrap());
// No poll before 2 s have passed…
v.step(1.0);
v.step(1.5);
assert_eq!(count(&mock.log()[before..], |r| *r == Req::Power), 0, "no poll inside the 2 s");
// …then one per 2 s while busy.
v.step(2.2);
assert_eq!(count(&mock.log()[before..], |r| *r == Req::Power), 1);
v.step(2.3);
v.step(3.0);
assert_eq!(count(&mock.log()[before..], |r| *r == Req::Power), 1, "one poll per period");
run_job(&mut v, 2.3);
let after = &mock.log()[before..];
let polls = count(after, |r| *r == Req::Power);
assert!(polls >= 3, "the mock's job is busy for 2 polls: {polls} polls");
// The job ended ok → meta + persons are fetched again.
let last_poll = after.iter().rposition(|r| *r == Req::Power).unwrap();
assert!(after[last_poll..].contains(&Req::Persons), "refetch after the job: {after:?}");
assert!(after[last_poll..].contains(&Req::Meta));
assert!(v.state_json()["status"].as_str().unwrap().contains("simulate: done"));
}
#[test]
fn a_second_press_while_running_is_a_409_and_says_so() {
let (mut v, mock) = loaded();
v.press(VERB_PERMUTATE);
v.step(0.1);
v.press(VERB_GENERATE);
v.step(0.2);
let st = v.state_json();
assert!(st["status"].as_str().unwrap().contains("409"), "{st}");
assert!(st["busy"].as_bool().unwrap(), "a 409 means a job runs: keep polling");
assert_eq!(count(&mock.log(), |r| matches!(r, Req::Press(_))), 2);
}
/// A server that answers every press with something that is neither 202 nor 409.
#[derive(Default)]
struct Teapot(RefCell<Vec<Resp>>);
impl Backend for Teapot {
fn send(&self, req: Req) {
let (status, body) = match req {
Req::Press(_) => (418, b"bench is a teapot today".to_vec()),
_ => (503, b"down for maintenance".to_vec()),
};
self.0.borrow_mut().push(Resp { req, status, body });
}
fn drain(&self) -> Vec<Resp> {
std::mem::take(&mut *self.0.borrow_mut())
}
}
#[test]
fn any_other_status_shows_the_body() {
let mut v = SimView::new(Box::new(Teapot::default()));
v.step(0.0);
assert!(v.state_json()["status"].as_str().unwrap().contains("down for maintenance"));
v.press(VERB_GENERATE);
v.step(0.1);
let st = v.state_json();
assert!(st["status"].as_str().unwrap().contains("HTTP 418: bench is a teapot today"), "{st}");
assert!(!st["busy"].as_bool().unwrap(), "not accepted → nothing to poll");
}
#[test]
fn the_cpu_lane_is_named_and_bounded_at_110k() {
let mock = MockBackend::new(MockServer::new(110_000));
let mut v = SimView::new(Box::new(mock));
v.step(0.0);
let r = harness::render_sized(&mut v, (1600.0, 900.0));
assert!(r.drew());
let st = &r.state;
assert_eq!(st["nodes"], 110_000);
// No GPU renderer installed in a headless host test: the strided CPU sample.
if !st["gpu_feature"].as_bool().unwrap() {
assert_eq!(st["render_lane"], "cpu-strided", "{st}");
assert!(st["cpu_paint_stride"][0].as_u64().unwrap() > 1, "{st}");
}
}