dwarves-gui/tests/robot_kernel_kind.rs
//! **ROBOT-UI: the `kernel` KIND is a clickable cell in the ⚒ matrix — and the
//! sugar spelling is not invisible.**
//!
//! edda's `kernel` KIND landed on main 2026-08-22 (`[[build.kernel]]`: a pinned
//! kernel.org tarball compiled inside a toolchain container, presets
//! full/normal/slim/nuked). Three real kernels have been built through it. None
//! of that is worth anything to an operator if the declaration has no cell to
//! click, and the ONLY thing dwarves knew about the kind was
//! `role_of_kind() => Role::Server` in the CLI.
//!
//! This file drives the REAL widgets (never `update_json`), the way
//! `robot_build_scene_ui.rs` does, and holds two claims apart:
//!
//! 1. a `kind = "kernel"` variant row resolves, is clickable, and opens NO pane —
//! a bare `bzImage` boots nothing without a medium or a rootfs around it, so
//! `PaneKind::for_artifact(Kernel)` is `None` ON PURPOSE. The counterweight is
//! the `exe` row in the same fixture opening the Exec pane, so "no pane" is a
//! statement about this kind rather than a pane that is always null.
//!
//! 2. **the two spellings are the same declaration.** edda calls
//! `[[build.kernel]]` "permanent sugar" for `kind = "kernel"`, exactly like
//! `[[build.iso]]` / `[[build.installer]]`. A repo that writes only the sugar —
//! which is what the design document tells appliances to write — used to
//! produce NO ROW AT ALL in this pane, because `menu_for` walked `set.variants`
//! and the sugar lands in `set.kernels`. Declared, listed by the CLI, and
//! unreachable in the matrix: the same defect as the 2026-08-16 installer
//! column, which is recorded in `build_forge.rs` a few lines away.
//!
//! Nothing here presses BYGG: that would start a real kernel compile.
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;
struct SceneApp(BuildScene);
impl Observable for SceneApp {
fn state_json(&self) -> serde_json::Value {
Facet::state_json(&self.0)
}
}
/// Stage a checkout on disk. `declaration` is the whole
/// `.nornir/build-variants.toml`, so each arm below states its own spelling and
/// nothing is shared but the crate skeleton.
///
/// The names are deliberately not any product's: the scene's own guard forbids a
/// product name in its source, and a fixture that spelled a real appliance would
/// make the claims here unfalsifiable.
fn staged(declaration: &str) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let repo = dir.path().join("orten");
std::fs::create_dir_all(repo.join(".nornir")).expect("mkdir");
std::fs::write(
repo.join("Cargo.toml"),
"[package]\nname = \"orten\"\nversion = \"1.0.0\"\nedition = \"2021\"\n\n[dependencies]\n",
)
.expect("manifest");
std::fs::write(
repo.join("Cargo.lock"),
"version = 4\n\n[[package]]\nname = \"orten\"\nversion = \"1.0.0\"\n",
)
.expect("lockfile");
std::fs::write(
repo.join(".nornir").join("build-variants.toml"),
declaration,
)
.expect("declaration");
dir
}
/// A syntactically valid pin. The kind REFUSES an unpinned kernel by name, so a
/// fixture without this parses into a declaration the pane would be right to
/// reject — and then this file would be testing the refusal, not the cell.
const PIN: &str = "8b8bb37e4ddbe4bc9b62931d29e0a58fda706a58bbebeba549e2274db34bb122";
/// Both spellings, side by side: one cargo row, one kernel row written the long
/// way, and the `[[build.kernel]]` payload the row resolves to at build time.
fn field_spelling() -> String {
format!(
r#"
[build]
base_suite = "ORIGINAL"
[[build.variant]]
name = "orten-server"
package = "orten"
bin = "orten"
role = "server"
default = true
suites = ["ORIGINAL"]
kind = "exe"
[[build.variant]]
name = "orten-kernel"
role = "server"
default = false
suites = ["ORIGINAL"]
kind = "kernel"
[[build.kernel]]
name = "orten-kernel"
version = "6.12.4"
tarball_sha256 = "{PIN}"
preset = "slim"
"#
)
}
/// The SUGAR alone — no `kind = "kernel"` variant row. This is what the design
/// document tells an appliance to write, and what produced nothing at all.
fn sugar_spelling() -> String {
format!(
r#"
[build]
base_suite = "ORIGINAL"
[[build.variant]]
name = "orten-server"
package = "orten"
bin = "orten"
role = "server"
default = true
suites = ["ORIGINAL"]
kind = "exe"
[[build.kernel]]
name = "orten-kernel"
version = "6.12.4"
tarball_sha256 = "{PIN}"
preset = "nuked"
"#
)
}
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))
.build_ui_state(
|ui, app: &mut SceneApp| Facet::ui(&mut app.0, ui),
SceneApp(scene),
);
let mut robot = RobotSession::new(harness, "dwarves-gui");
robot.run(2);
robot
}
/// The column strip on one row, by name, read out of the pane's own state.
fn strip(robot: &RobotSession<'_, SceneApp>, row: usize) -> Vec<String> {
robot
.state_at(&format!("/rows/{row}/targets"))
.and_then(|v| v.as_array().cloned())
.unwrap_or_default()
.iter()
.map(|t| t["name"].as_str().unwrap_or_default().to_string())
.collect()
}
/// Which row index carries `name` — the fixture's order is not this test's
/// business, and asserting on a hardcoded index is how a test starts passing for
/// the wrong reason.
fn row_index(robot: &RobotSession<'_, SceneApp>, name: &str) -> usize {
robot
.state_at("/rows")
.and_then(|v| v.as_array().cloned())
.unwrap_or_default()
.iter()
.position(|r| r["name"].as_str() == Some(name))
.unwrap_or_else(|| panic!("no row named {name:?} in the matrix"))
}
#[test]
fn a_robot_clicks_a_kernel_row_and_gets_a_cell_that_offers_no_run() {
let dir = staged(&field_spelling());
let mut robot = session(dir.path());
// RED — nothing is resolved before the search.
assert_eq!(
robot
.state_at("/rows")
.and_then(|v| v.as_array().map(Vec::len)),
Some(0)
);
assert_eq!(robot.state_at("/target"), Some(serde_json::Value::Null));
assert_eq!(robot.state_at("/pane"), Some(serde_json::Value::Null));
// Step 1 — the real field, the real button.
robot
.type_into("artefakt", "orten-kernel")
.expect("the artefakt field is reachable");
robot
.click_label("sök")
.expect("the sök button is reachable");
robot.run(2);
// Step 2 — the kernel is a ROW, and `kernel` is a COLUMN of the matrix.
let k = row_index(&robot, "orten-kernel");
let e = row_index(&robot, "orten-server");
assert!(
strip(&robot, k).contains(&"kernel".to_string()),
"{:?}",
strip(&robot, k)
);
assert_eq!(
strip(&robot, k),
strip(&robot, e),
"the strip is the MATRIX: every row carries every column, declared or greyed"
);
let kernel_col = strip(&robot, k)
.iter()
.position(|c| c == "kernel")
.expect("the column");
assert_eq!(
robot.state_at(&format!("/rows/{k}/targets/{kernel_col}/declared")),
Some(serde_json::json!(true)),
"the kernel row declares its own kernel cell"
);
assert_eq!(
robot.state_at(&format!("/rows/{e}/targets/{kernel_col}/declared")),
Some(serde_json::json!(false)),
"…and the cargo row carries the same cell GREYED — that pair is what makes \
`declared` mean something"
);
// Step 3 — CLICK the row, then the column, through the live widget tree.
robot
.click_label("orten-kernel")
.expect("the kernel row is a real, clickable widget");
robot.run(2);
assert_eq!(
robot.state_at("/row"),
Some(serde_json::json!("orten-kernel"))
);
robot
.click_label("kernel")
.expect("the kernel column is a real, clickable widget");
robot.run(2);
assert_eq!(robot.state_at("/target"), Some(serde_json::json!("kernel")));
assert_eq!(
robot.state_at(&format!("/rows/{k}/targets/{kernel_col}/kind")),
Some(serde_json::json!("kernel"))
);
// THE CLAIM: a built kernel offers NO run pane. A bare bzImage boots nothing
// without a medium or a rootfs around it, so an "▶ run" here would be a
// button that cannot work.
assert_eq!(
robot.state_at("/pane"),
Some(serde_json::json!("none")),
"a bzImage is not a runnable artifact and must not be offered as one"
);
// …and it IS buildable: the cell has no unmet declared input.
assert_eq!(robot.state_at("/can_build"), Some(serde_json::json!(true)));
// Step 4 — THE COUNTERWEIGHT. The same gestures on the cargo row DO open a
// pane, so step 3's absence is about this KIND and not about a pane that is
// always null in this fixture.
robot
.click_label("orten-server")
.expect("the cargo row is clickable");
robot.run(2);
robot
.click_label("exe")
.expect("the exe column is clickable");
robot.run(2);
assert_eq!(robot.state_at("/target"), Some(serde_json::json!("exe")));
assert_eq!(
robot.state_at("/pane"),
Some(serde_json::json!("exec")),
"the counterweight is dead: this fixture opens no pane for anything"
);
}
#[test]
fn the_sugar_spelling_is_the_same_declaration_and_gets_the_same_row() {
let dir = staged(&sugar_spelling());
let mut robot = session(dir.path());
robot.type_into("artefakt", "orten-kernel").expect("field");
robot.click_label("sök").expect("button");
robot.run(2);
// ★ RED-FIRST 2026-08-22. Before the fix this found NO ROW: `menu_for`
// walked `set.variants`, and a `[[build.kernel]]` table lands in
// `set.kernels`. The declaration was valid, the CLI listed it, and the
// matrix had nowhere to click — the 2026-08-16 installer defect again.
let rows: Vec<String> = robot
.state_at("/rows")
.and_then(|v| v.as_array().cloned())
.unwrap_or_default()
.iter()
.map(|r| r["name"].as_str().unwrap_or_default().to_string())
.collect();
assert!(
rows.contains(&"orten-kernel".to_string()),
"a `[[build.kernel]]` declaration has no row in the matrix — rows: {rows:?}"
);
let k = row_index(&robot, "orten-kernel");
let kernel_col = strip(&robot, k)
.iter()
.position(|c| c == "kernel")
.expect("the column");
assert_eq!(
robot.state_at(&format!("/rows/{k}/targets/{kernel_col}/declared")),
Some(serde_json::json!(true)),
"the sugar row must DECLARE its cell, not carry it greyed like a bystander"
);
// It clicks like any other cell, and answers the same way.
robot
.click_label("orten-kernel")
.expect("the sugar row is clickable");
robot.run(2);
robot.click_label("kernel").expect("its cell is clickable");
robot.run(2);
assert_eq!(
robot.state_at("/row"),
Some(serde_json::json!("orten-kernel"))
);
assert_eq!(robot.state_at("/target"), Some(serde_json::json!("kernel")));
assert_eq!(robot.state_at("/pane"), Some(serde_json::json!("none")));
// The DECLARED preset reaches the operator. `nuked` and `slim` build kernels
// that differ by 86 KiB and by what an appliance can do, so a pane that
// showed the kind and hid the preset would be showing the half that does not
// decide anything.
let settings = robot
.state_at(&format!("/rows/{k}/targets/{kernel_col}/settings"))
.and_then(|v| v.as_array().cloned())
.unwrap_or_default();
let shown: Vec<(String, String)> = settings
.iter()
.map(|s| {
(
s["key"].as_str().unwrap_or_default().to_string(),
s["value"].as_str().unwrap_or_default().to_string(),
)
})
.collect();
assert!(
shown.iter().any(|(n, v)| n == "kind" && v == "kernel"),
"the cell must say what it is: {shown:?}"
);
assert!(
shown.iter().any(|(n, v)| n == "preset" && v == "nuked"),
"the DECLARED preset must reach the operator: {shown:?}"
);
assert!(
shown.iter().any(|(n, v)| n == "version" && v == "6.12.4"),
"…and which kernel it is: {shown:?}"
);
}