tests/viz_law4b_screenshot.rs
//! **LAW4b** inject-assert test: the viz app's self-screenshot control channel.
//!
//! Tests the complete `VizCommand::Screenshot` → `apply_command` → `state_json`
//! path WITHOUT requiring a real GPU or a running display. The test drives
//! the headless `UrdrThreadsApp` purely via its public API (inject-assert LAW):
//!
//! 1. Write a real `VizCommand::Screenshot { out_path: Some(...) }` via the
//! control channel file.
//! 2. Call `apply_command` (the same path the running viz runs each frame).
//! 3. Assert `state_json["screenshot"]["pending"] == true` — the command was
//! consumed and the screenshot state machine is armed.
//! 4. Assert `state_json["screenshot"]["last_path"] == null` — no image has
//! arrived yet (that requires a real GPU frame).
//!
//! No GPU, no eframe::run, no window — pure control+state_json path.
//!
//! Run:
//! cargo test --features viz --test viz_law4b_screenshot
#![cfg(feature = "viz")]
use std::path::PathBuf;
use nornir::viz::{
UrdrThreadsApp,
control::{ScreenshotRequest, VizCommand},
};
/// Build a minimal headless app (no display, no warehouse data needed).
fn headless_app() -> UrdrThreadsApp {
// Skip the durable action-log warehouse drain so the test doesn't need an
// Iceberg catalog lock.
// TODO: Audit that the environment access only happens in single-threaded code.
unsafe { std::env::set_var("NORNIR_VIZ_NO_DURABLE_ACTIONLOG", "1") };
let warehouse_root = std::env::temp_dir().join(format!(
"nornir-law4b-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0),
));
let _ = std::fs::create_dir_all(&warehouse_root);
UrdrThreadsApp::with_repos(warehouse_root, "nornir".to_string(), PathBuf::new(), vec![])
}
/// LAW4b — inject a `VizCommand::Screenshot` via `apply_command`, then assert
/// `state_json["screenshot"]["pending"] == true` and `["last_path"] == null`.
///
/// This is the inject-assert proof that the screenshot command wires through the
/// control+state_json contract without a GPU: the command sets `pending = true`,
/// which `state_json` exposes, so a robot-driver can poll for the screenshot
/// arriving in a live session.
#[test]
fn screenshot_command_sets_pending_and_state_json_reflects_it() {
let mut app = headless_app();
// Pre-condition: no screenshot pending.
let before = app.state_json();
assert_eq!(
before["screenshot"]["pending"],
serde_json::json!(false),
"screenshot.pending is false before any command"
);
assert_eq!(
before["screenshot"]["last_path"],
serde_json::json!(null),
"screenshot.last_path is null before any screenshot"
);
// Inject a Screenshot command via apply_command (same path as the live
// control channel).
let cmd = VizCommand {
screenshot: Some(ScreenshotRequest {
out_path: Some("/tmp/nornir_law4b_test.png".into()),
}),
..Default::default()
};
app.apply_command(&cmd);
// Assert: state_json["screenshot"]["pending"] == true.
let after = app.state_json();
assert_eq!(
after["screenshot"]["pending"],
serde_json::json!(true),
"apply_command(Screenshot) must set screenshot.pending = true in state_json"
);
// last_path is still null — the image hasn't arrived (no GPU frame yet).
assert_eq!(
after["screenshot"]["last_path"],
serde_json::json!(null),
"screenshot.last_path remains null until Event::Screenshot is consumed"
);
}
/// LAW4b — `VizCommand::Screenshot { out_path: None }` (warehouse-only mode)
/// also sets `pending = true` with no out_path.
#[test]
fn screenshot_command_warehouse_only_also_sets_pending() {
let mut app = headless_app();
let cmd = VizCommand {
screenshot: Some(ScreenshotRequest { out_path: None }),
..Default::default()
};
app.apply_command(&cmd);
let state = app.state_json();
assert_eq!(
state["screenshot"]["pending"],
serde_json::json!(true),
"warehouse-only Screenshot command also sets pending = true"
);
}
/// LAW4b — the control channel round-trip: `write_command` + `take_pending`
/// deserializes `ScreenshotRequest` correctly.
#[test]
fn screenshot_request_roundtrips_through_control_channel() {
use nornir::viz::control::{take_pending, write_command};
// Use a unique tmp file so parallel test runs don't conflict.
let tmp = format!("/tmp/nornir_law4b_ctrl_{}.json", std::process::id());
// TODO: Audit that the environment access only happens in single-threaded code.
unsafe { std::env::set_var("NORNIR_VIZ_CMD", &tmp) };
let _ = std::fs::remove_file(&tmp);
// Nothing pending yet.
assert!(take_pending().is_none(), "channel starts empty");
// Write a Screenshot command.
let cmd = VizCommand {
screenshot: Some(ScreenshotRequest {
out_path: Some("/tmp/roundtrip.png".into()),
}),
..Default::default()
};
write_command(&cmd).expect("write_command succeeds");
// Read it back (consumes the file).
let got = take_pending()
.expect("a command is pending")
.expect("deserializes without error");
assert_eq!(
got.screenshot,
Some(ScreenshotRequest {
out_path: Some("/tmp/roundtrip.png".into())
}),
"ScreenshotRequest round-trips through the control channel"
);
// Consume-once: the file was removed.
assert!(
take_pending().is_none(),
"consume-once: second take is None"
);
let _ = std::fs::remove_file(&tmp);
}
/// LAW4b — `request_screenshot` public method arms the state machine directly
/// (without going through the control-channel file), used by the keyboard shortcut.
#[test]
fn request_screenshot_pub_method_arms_pending() {
let mut app = headless_app();
app.request_screenshot(Some("/tmp/direct.png".into()));
let state = app.state_json();
assert_eq!(
state["screenshot"]["pending"],
serde_json::json!(true),
"request_screenshot() sets screenshot.pending = true in state_json"
);
assert_eq!(
state["screenshot"]["last_path"],
serde_json::json!(null),
"last_path stays null until an egui Event::Screenshot arrives"
);
}