Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

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