dwarves-gui/tests/robot_build_graph_camera.rs
//! **⚒ Build Thing: the graph opens centred, and a click never moves it** — rendered,
//! with real data (a staged constellation the forge reads off disk), in the tab's own
//! leaf beside a side rail (so the canvas is NOT at the screen's origin), real clicks.
//!
//! The owner (loki, 0aa9c50): "the graph opens about 10 cm to the right, outside the
//! visible area" and "pressing things in it moves the graph to the right". Both were
//! `DagPane::set_spec`: every new spec (the first, and every one a click re-lowers)
//! reset the camera to zoom 1 with world (0, 0) at the canvas centre.
use egui::Pos2;
use facett_core::fly::NavMode;
use facett_core::Facet;
use nornir_robotui::egui_kittest::Harness;
use nornir_robotui::{Observable, RobotSession};
use dwarves_gui::build_forge::DeclaredForge;
use dwarves_gui::build_scene::BuildScene;
use dwarves_gui::tabbed::LeafPanel;
struct SceneApp(BuildScene);
impl Observable for SceneApp {
fn state_json(&self) -> serde_json::Value {
Facet::state_json(&self.0)
}
}
/// Stage a constellation with ONE checkout declaring two atoms and two delivery kinds.
///
/// Deliberately not any product's names: the scene under test must not contain one, and
/// a fixture that spelled a real atom would make that guarantee unfalsifiable here.
fn staged() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let repo = dir.path().join("orten");
std::fs::create_dir_all(repo.join(".nornir")).expect("mkdir");
// A REAL crate graph, because the dependency picture is derived from manifests
// and a lockfile rather than described here. Three optional deps behind three
// features, one each. Nothing below hand-writes what the resolver should
// conclude from that.
//
// `gpu` is deliberately the one NO atom builds: it is the open switch, and an
// open switch is OFF until somebody turns it on. That is why it never appears on
// a dependency axis — and it is also what keeps the two subjects this file tests
// from sharing a name on screen.
std::fs::write(
repo.join("Cargo.toml"),
r#"[workspace.package]
version = "2.4.1"
[workspace]
members = ["paint", "wire", "shade"]
[package]
name = "orten"
version = "2.4.1"
edition = "2021"
[features]
gpu = ["dep:paint"]
client = ["dep:wire"]
tune = ["dep:shade"]
[dependencies]
paint = { path = "paint", version = "0.1.0", optional = true }
wire = { path = "wire", version = "0.1.0", optional = true }
shade = { path = "shade", version = "0.1.0", optional = true }
"#,
)
.expect("manifest");
for leaf in ["paint", "wire", "shade"] {
std::fs::create_dir_all(repo.join(leaf).join("src")).expect("mkdir leaf");
std::fs::write(
repo.join(leaf).join("Cargo.toml"),
format!(
"[package]\nname = \"{leaf}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\
[dependencies]\n"
),
)
.expect("leaf manifest");
std::fs::write(repo.join(leaf).join("src/lib.rs"), "").expect("leaf src");
}
std::fs::write(
repo.join("Cargo.lock"),
r#"version = 4
[[package]]
name = "orten"
version = "2.4.1"
dependencies = [
"paint",
"shade",
"wire",
]
[[package]]
name = "paint"
version = "0.1.0"
[[package]]
name = "shade"
version = "0.1.0"
[[package]]
name = "wire"
version = "0.1.0"
"#,
)
.expect("lockfile");
std::fs::write(
repo.join(".nornir").join("build-variants.toml"),
r#"
[build]
base_suite = "ORIGINAL"
# The repo's feature SPACE. One atom below leaves `gpu` open and the other does
# not, out of this same space — which is what makes the absence on that other row
# a decision rather than a feature nothing here has ever heard of.
offers = ["gpu", "client", "tune"]
[[build.variant]]
name = "orten-server"
package = "orten"
bin = "orten-server"
role = "server"
default = true
suites = ["ORIGINAL"]
kind = "exe"
features = ["client"]
[[build.variant]]
name = "orten-image"
package = "orten"
role = "server"
default = false
suites = ["ORIGINAL", "MVP"]
kind = "container"
# The [[build.container]] below is bound BY NAME, and the loader refuses a
# `kind = "container"` row that does not say which — matching names is not the
# same as declaring the edge. Without this line the whole file is rejected at
# load, the scene draws an empty space, and every row-level assertion in this
# module reads as "the recipe declares nothing" (lane AU, 2026-08-30).
container_ref = "orten-image"
features = ["client", "tune"]
open = ["gpu"]
# An atom that builds with NO features at all. There is no feature axis to click
# on such a row, and saying that in words is the point: a blank space where a graph
# should be reads exactly like a graph that could not be derived.
[[build.variant]]
name = "orten-tool"
package = "orten"
bin = "orten-tool"
role = "tool"
default = true
suites = ["ORIGINAL"]
kind = "exe"
[[build.container]]
name = "orten-image"
base = "scratch"
expose = [8080]
"#,
)
.expect("declaration");
dir
}
/// The tab's graph leaf beside a 320 px rail, in a 1400 × 1000 window.
fn session(root: &std::path::Path) -> RobotSession<'_, SceneApp> {
let scene = BuildScene::new(Box::new(DeclaredForge::at(root.to_path_buf())));
let harness = Harness::builder()
.with_size(nornir_robotui::egui::Vec2::new(1400.0, 1000.0))
.with_step_dt(1.0 / 60.0)
.build_ui_state(
|ui, app: &mut SceneApp| {
egui::Panel::left("rail").exact_size(320.0).show_inside(ui, |ui| {
ui.label("rail");
});
let _ = LeafPanel::ui_leaf(&mut app.0, dwarves_gui::build_thing_scene::GRAPH, ui);
},
SceneApp(scene),
);
let mut robot = RobotSession::new(harness, "dwarves-gui");
robot.run(2);
robot
}
/// Search the staged artifact so the graph is there.
fn with_graph(robot: &mut RobotSession<'_, SceneApp>) {
robot.type_into("artefakt", "orten-server").expect("the artefakt field");
robot.click_label("sök").expect("the sök button");
robot.run(6);
assert!(robot.harness_mut().state_mut().0.graph_pane().node_count() > 0, "the graph is there");
}
fn pane<'r>(robot: &'r mut RobotSession<'_, SceneApp>) -> &'r dwarves_gui::dag_graph::DagPane {
robot.harness_mut().state_mut().0.graph_pane()
}
fn screen_of(robot: &mut RobotSession<'_, SceneApp>, id: &str) -> Pos2 {
let p = pane(robot);
let w = p.positions().into_iter().find(|(i, _)| i == id).unwrap_or_else(|| panic!("no node {id}")).1;
p.world_to_screen(w).unwrap()
}
fn click_at(robot: &mut RobotSession<'_, SceneApp>, p: Pos2) {
let h = robot.harness_mut();
h.input_mut().events.push(egui::Event::PointerMoved(p));
h.step();
let b = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
h.input_mut().events.push(b(true));
h.step();
h.input_mut().events.push(b(false));
h.step();
}
/// **First load: fitted and centred in the visible canvas** (±2 px), fully visible.
#[test]
fn the_build_graph_opens_centred_and_fully_visible() {
let dir = staged();
let mut robot = session(dir.path());
with_graph(&mut robot);
let p = pane(&mut robot);
let (seen, b) = (p.last_visible().expect("painted"), p.screen_box().expect("nodes on screen"));
assert!((b.center() - seen.center()).length() <= 2.0, "the graph is centred: box {b:?} in {seen:?}");
assert!(seen.expand(0.5).contains_rect(b), "the whole graph is visible: box {b:?} in {seen:?}");
assert!(seen.height() >= 0.6 * 1000.0 * 0.85, "the graph covers most of the tab's height: {seen:?}");
}
/// **📌 Stay: a click never moves the graph** — clicking the image variant re-picks the
/// row and re-lowers the graph; the clicked node stays where it was (±0.5 px), the zoom
/// is untouched.
#[test]
fn stay_a_click_on_the_build_graph_never_moves_it() {
let dir = staged();
let mut robot = session(dir.path());
with_graph(&mut robot);
let id = "orten-image";
let before = screen_of(&mut robot, id);
let zoom = pane(&mut robot).camera().0;
click_at(&mut robot, before);
robot.run(6);
assert_eq!(robot.state_at("/row"), Some(serde_json::json!("orten-image")), "the click picked the row");
let after = screen_of(&mut robot, id);
assert!((after - before).length() <= 0.5, "the clicked node stayed put: {before:?} → {after:?}");
assert_eq!(pane(&mut robot).camera().0, zoom, "the zoom did not move");
}
/// **🎯 Focus: a click flies the node to the centre** of the visible canvas (±2 px).
#[test]
fn focus_a_click_on_the_build_graph_flies_the_node_to_the_centre() {
let dir = staged();
let mut robot = session(dir.path());
with_graph(&mut robot);
robot.harness_mut().state_mut().0.graph_pane_mut().set_nav_mode(NavMode::Focus);
let id = "orten-image";
let before = screen_of(&mut robot, id);
click_at(&mut robot, before);
robot.run(60);
let seen = pane(&mut robot).last_visible().unwrap();
let at = screen_of(&mut robot, id);
assert!((at - seen.center()).length() <= 2.0, "the node is at the visible centre: {at:?} vs {:?}", seen.center());
}