facett-buildthing/src/deck.rs
//! **The build-thing DECK** — three real panes, not one body.
//!
//! A pane that merely renders is not a pane. The rule in this constellation is that a
//! pane has its own rect, its own chrome, and a divider a human can drag; anything else
//! is a section of one flow wearing a title, and the first readout that grows pushes
//! its neighbour off the bottom. This deck is the build-thing surface as **three**
//! panes:
//!
//! | idx | pane | what it owns |
//! |---|---|---|
//! | 0 | **Variants** | the role-grouped menu — server / client, linkage, the normal case |
//! | 1 | **Graph** | what the selected variant compiles in and needs running |
//! | 2 | **Result** | what the last build produced |
//!
//! # Two mounting paths, one owner
//!
//! * [`BuildThingDeck::view_pane`] renders **one** pane into a rect the host has
//! already decided — the seam `facett_app::scene` needs to make a deck out of REAL
//! scene panes, each with its own chrome, fold, minimise and maximise. This is the
//! primary path and the one that gets the scene's own splitters. It mirrors
//! `facett_warehousedeck::WarehouseDeck::view_pane` exactly.
//! * [`Facet::ui`] draws all three at once with its own draggable dividers, for a host
//! that mounts the deck as a single tab body (dwarves' ⚒ tab today, because its
//! shell keys pane decomposition off a table this crate does not own).
//!
//! The dividers in the second path are ~30 lines rather than a reuse of
//! `facett_app::SplitPane`, and that is a deliberate, narrow exception: `SplitPane`
//! takes ownership of two `Box<dyn Facet>` children, which is incompatible with also
//! serving `view_pane(idx)` from the same three panes. Two owners of one pane is the
//! twin this session has spent its length deleting, so the deck keeps ONE owner and
//! drives a divider itself. When a host mounts the primary path, the scene owns the
//! real splitters and this code is not reached.
//!
//! # `Facet`, deliberately, and not `Panel`
//!
//! [`BuildThingDeck`] implements **`Facet` only**. facett-core carries a blanket
//! `impl<T: Facet> Panel for T`, so it is a `Panel` for free with nothing to
//! disambiguate — implementing both by hand is what produced `E0034` on `ui` /
//! `update_json` in dwarves' pane earlier today. [`BuildGraphPane`] implements
//! neither: it stays inherent-methods-only and is driven by this shell.
use egui::{Sense, Ui};
use facett_core::Facet;
use nornir_build_thing::plugin::{BuildGraph, BuildMenu, Linkage, Role, VariantRef};
use crate::graph_pane::BuildGraphPane;
/// What a deck pane asks the host to do. Returned by [`BuildThingDeck::view_pane`] so
/// a scene host can act on a click in a pane it drew itself.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Msg {
/// The user picked a variant — from the menu, or by clicking a graph node.
SelectVariant(String),
/// The user pressed Build on the selected variant.
Build(String),
}
/// The three panes, by index. Named rather than bare `usize` so a host cannot mount
/// them in the wrong order without saying so.
pub const PANE_VARIANTS: usize = 0;
/// The graph pane's index.
pub const PANE_GRAPH: usize = 1;
/// The result pane's index.
pub const PANE_RESULT: usize = 2;
/// How many panes the deck holds.
pub const PANE_COUNT: usize = 3;
/// What the last build produced, as the deck shows it. Supplied by the host — this
/// crate runs no builds.
#[derive(Debug, Clone, Default)]
pub struct BuildResult {
/// The variant that was built.
pub variant: String,
/// Whether it produced an artifact (not merely whether a command exited zero).
pub green: bool,
/// The one-line outcome.
pub headline: String,
/// Produced artifacts as `(path, human size)`.
pub artifacts: Vec<(String, String)>,
/// Compiler diagnostics, verbatim.
pub errors: Vec<String>,
/// Whether a build is in flight.
pub running: bool,
}
/// **The build-thing deck.**
pub struct BuildThingDeck {
/// The role-grouped menu the plugin returned.
pub menu: BuildMenu,
/// The graph pane (inherent methods only — this shell drives it).
pub graph: BuildGraphPane,
/// The last build's outcome.
pub result: BuildResult,
/// The selected variant.
pub selected: Option<String>,
/// Divider fractions for the single-body path: menu | graph | result.
ratios: [f32; 2],
/// Messages raised this frame, drained by the host.
pending: Vec<Msg>,
}
impl Default for BuildThingDeck {
fn default() -> Self {
BuildThingDeck {
menu: BuildMenu {
repo: String::new(),
rev: String::new(),
roles: Vec::new(),
entries: Vec::new(),
cargo_set: None,
},
graph: BuildGraphPane::default(),
result: BuildResult::default(),
selected: None,
ratios: [0.34, 0.40],
pending: Vec::new(),
}
}
}
impl BuildThingDeck {
/// A deck over a plugin's menu. The default selection is the SERVER role's
/// declared normal case — not the first entry, because "first" is an accident of
/// declaration order and `default_variant` is a decision the app made.
pub fn new(menu: BuildMenu) -> Self {
let selected = menu
.default_for(Role::Server)
.or_else(|| menu.default_for(Role::Client))
.map(|s| s.to_string())
.or_else(|| menu.entries.first().map(|e| e.id.clone()));
BuildThingDeck { menu, selected, ..Default::default() }
}
/// How many panes — what a host declares scene slots from.
pub fn pane_count(&self) -> usize {
PANE_COUNT
}
/// The pane title at `idx`, for a host naming its panes from the deck.
pub fn pane_title(&self, idx: usize) -> Option<String> {
match idx {
PANE_VARIANTS => Some(format!(
"Variants — {}",
if self.menu.repo.is_empty() { "no build thing" } else { &self.menu.repo }
)),
PANE_GRAPH => Some(self.graph.title()),
PANE_RESULT => Some("Result".to_string()),
_ => None,
}
}
/// Show a variant's graph (the host fetched it from the plugin).
pub fn set_graph(&mut self, g: BuildGraph) {
self.graph.set_graph(g);
}
/// Record the selection. Returns `true` when it changed, so a host knows to fetch
/// a fresh graph rather than re-fetching every frame.
pub fn select(&mut self, variant: &str) -> bool {
if self.selected.as_deref() == Some(variant) {
return false;
}
self.selected = Some(variant.to_string());
true
}
/// `(compiled_in, gated_out, undecided)` over the graph's source-file layer — the
/// deck's own oracle for what this variant leaves out.
pub fn graph_counts(&self) -> (usize, usize, usize) {
self.graph.gated_counts()
}
/// **Set divider fraction `i`, clamped JOINTLY.** The two fractions
/// (`menu | graph`) can never sum past `1.0 - MIN_THIRD`, so the third
/// (result) pane always keeps at least [`MIN_THIRD`] of the height.
///
/// Clamping each fraction independently to `[MIN_RATIO, MAX_RATIO]` was **bug
/// F3**: `{set_ratio:[0,0.70]}` then `{set_ratio:[1,0.70]}` left `[0.70, 0.70]`
/// (sum 1.4), so the result pane's height (`1 - r0 - r1`) went negative, floored
/// to 48 px and the panes OVERLAPPED — and `state_json["ratios"]` reported a
/// layout that could not exist.
fn set_ratio(&mut self, i: usize, f: f32) {
if i >= self.ratios.len() {
return;
}
// ratios has exactly two entries; the "other" is the one not being set.
let other = self.ratios[1 - i];
// The most this fraction may take: leave `other` AND MIN_THIRD for the result
// pane. Never below MIN_RATIO, never above MAX_RATIO.
let hi = (1.0 - MIN_THIRD - other).clamp(MIN_RATIO, MAX_RATIO);
self.ratios[i] = f.clamp(MIN_RATIO, hi);
}
/// Take the messages raised since the last call.
pub fn take_msgs(&mut self) -> Vec<Msg> {
// A graph-node click is a selection too — this is the round trip that makes
// the graph a way to PICK a build rather than a picture beside one.
if let Some(v) = self.graph.take_selection() {
self.pending.push(Msg::SelectVariant(v));
}
std::mem::take(&mut self.pending)
}
/// **Render ONE pane into the rect the caller has already decided** — the seam a
/// scene host needs to make a deck out of real panes. No grid, no scroll area, no
/// title strip: the scene's chrome bar carries the title, and a second one would be
/// a second header row.
///
/// An out-of-range `idx` draws a visible marker rather than nothing, so a host
/// whose pane tree has drifted from the deck sees the drift instead of a blank rect
/// that reads as "this pane has no data".
pub fn view_pane(&mut self, idx: usize, ui: &mut Ui) -> Vec<Msg> {
match idx {
PANE_VARIANTS => self.ui_variants(ui),
PANE_GRAPH => self.graph.ui(ui),
PANE_RESULT => self.ui_result(ui),
_ => {
ui.weak(format!("no deck pane at index {idx} (deck holds {PANE_COUNT})"));
}
}
self.take_msgs()
}
// ── pane 0: the role-grouped menu ────────────────────────────────────────
fn ui_variants(&mut self, ui: &mut Ui) {
if self.menu.entries.is_empty() {
ui.label(
egui::RichText::new(
"no build thing loaded — this is an ABSENT declaration, not an empty \
build space",
)
.small(),
);
return;
}
ui.label(
egui::RichText::new(format!(
"{} @ {}",
self.menu.repo,
if self.menu.rev.is_empty() {
"<rev UNKNOWN>".to_string()
} else {
self.menu.rev.chars().take(12).collect::<String>()
}
))
.small()
.monospace(),
);
// A declaration bug is shown, not swallowed.
for problem in self.menu.check_defaults() {
ui.label(egui::RichText::new(format!("⚠ {problem}")).small().color(WARN));
}
ui.separator();
let mut pick: Option<String> = None;
egui::ScrollArea::vertical().id_salt("bt-variants").show(ui, |ui| {
for group in &self.menu.roles {
ui.label(
egui::RichText::new(format!("{} ", group.role.as_str().to_uppercase()))
.small()
.strong(),
);
ui.label(egui::RichText::new(&group.summary).small().weak());
for id in &group.variants {
let Some(e) = self.menu.entries.iter().find(|e| &e.id == id) else { continue };
ui.horizontal_wrapped(|ui| {
chip(ui, e.linkage.as_str(), linkage_color(e.linkage));
if e.is_default {
chip(ui, "normal", TEAL);
}
let sel = self.selected.as_deref() == Some(id.as_str());
if ui
.selectable_label(sel, egui::RichText::new(id).monospace())
.clicked()
{
pick = Some(id.clone());
}
});
// What this choice COSTS at runtime — the thing a caller needs.
ui.label(egui::RichText::new(format!(" {}", e.runtime)).small().weak());
}
ui.add_space(6.0);
}
});
if let Some(p) = pick && self.select(&p) {
self.pending.push(Msg::SelectVariant(p.clone()));
}
ui.separator();
let can = self.selected.clone();
ui.horizontal(|ui| {
let running = self.result.running;
let label = if running { "⏳ building…" } else { "▶ Build" };
if ui
.add_enabled(!running && can.is_some(), egui::Button::new(egui::RichText::new(label).strong()))
.clicked()
&& let Some(v) = can.clone()
{
self.pending.push(Msg::Build(v));
}
if let Some(v) = &can {
ui.label(egui::RichText::new(v).small().monospace());
}
});
}
// ── pane 2: the result ───────────────────────────────────────────────────
fn ui_result(&mut self, ui: &mut Ui) {
let r = &self.result;
if r.variant.is_empty() {
ui.label(egui::RichText::new("no build yet").small().weak());
return;
}
ui.horizontal(|ui| {
if r.running {
ui.spinner();
chip(ui, "BUILDING", WARN);
} else if r.green {
chip(ui, "BUILT", TEAL);
} else {
chip(ui, "FAILED", RED);
}
ui.label(egui::RichText::new(&r.headline).monospace().small());
});
for (path, size) in &r.artifacts {
ui.label(egui::RichText::new(format!("artifact {path} ({size})")).small().monospace());
}
if !r.green && r.artifacts.is_empty() && !r.running {
ui.label(
egui::RichText::new("no artifact on disk — whatever the exit code said")
.small()
.color(RED),
);
}
if !r.errors.is_empty() {
egui::ScrollArea::vertical().max_height(220.0).id_salt("bt-errors").show(ui, |ui| {
for e in &r.errors {
ui.label(egui::RichText::new(e).small().monospace().color(RED));
}
});
}
}
// ── the single-body path: three panes, two draggable dividers ────────────
/// Draw all three panes stacked with draggable dividers between them. Used only
/// when a host mounts the deck as ONE tab body; a scene host calls
/// [`view_pane`](Self::view_pane) per pane instead and gets the scene's own
/// splitters, chrome, fold and maximise.
fn ui_all(&mut self, ui: &mut Ui) {
let total = ui.available_height().max(120.0);
let mut heights =
[total * self.ratios[0], total * self.ratios[1], total * (1.0 - self.ratios[0] - self.ratios[1])];
for h in heights.iter_mut() {
*h = h.max(48.0);
}
for (idx, &height) in heights.iter().enumerate() {
let title = self.pane_title(idx).unwrap_or_default();
ui.allocate_ui(egui::vec2(ui.available_width(), height), |ui| {
ui.group(|ui| {
ui.set_min_height(height - 10.0);
ui.vertical(|ui| {
ui.strong(egui::RichText::new(title).small());
ui.separator();
let msgs = self.view_pane(idx, ui);
self.pending.extend(msgs);
});
});
});
if idx < PANE_COUNT - 1 && let Some(delta) = divider(ui, idx) {
// Joint clamp (F3): a drag can never push the two fractions past
// `1 - MIN_THIRD`, so the result pane never collapses/overlaps.
self.set_ratio(idx, self.ratios[idx] + delta / total);
}
}
}
/// The observable state (L4).
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"component": "BuildThingDeck",
"repo": self.menu.repo,
"rev": self.menu.rev,
"pane_count": self.pane_count(),
"panes": (0..PANE_COUNT).filter_map(|i| self.pane_title(i)).collect::<Vec<_>>(),
"ratios": self.ratios,
"selected": self.selected,
"roles": self.menu.roles.iter().map(|g| serde_json::json!({
"role": g.role.as_str(),
"variants": g.variants,
"default": g.default_variant,
})).collect::<Vec<_>>(),
"default_problems": self.menu.check_defaults(),
"graph": self.graph.state_json(),
"result": {
"variant": self.result.variant,
"green": self.result.green,
"running": self.result.running,
"headline": self.result.headline,
"artifact_count": self.result.artifacts.len(),
"error_count": self.result.errors.len(),
},
})
}
}
/// A draggable horizontal divider; returns the vertical drag delta when moved.
fn divider(ui: &mut Ui, idx: usize) -> Option<f32> {
let (rect, resp) =
ui.allocate_exact_size(egui::vec2(ui.available_width(), 6.0), Sense::drag());
let hot = resp.hovered() || resp.dragged();
let col = if hot { ui.visuals().selection.bg_fill } else { ui.visuals().weak_text_color() };
ui.painter().rect_filled(rect.shrink2(egui::vec2(0.0, 2.0)), 2.0, col);
if hot {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
}
let _ = idx;
resp.dragged().then(|| resp.drag_delta().y)
}
/// The minimum fraction either divider may take — a pane can shrink to this but
/// never past it, so no pane collapses to nothing.
const MIN_RATIO: f32 = 0.12;
/// The maximum a single divider fraction may take.
const MAX_RATIO: f32 = 0.70;
/// The floor the THIRD (result) pane keeps: the joint budget the first two
/// fractions must leave (`ratios[0] + ratios[1] <= 1.0 - MIN_THIRD`). See
/// [`BuildThingDeck::set_ratio`] — this is what bug F3 violated.
const MIN_THIRD: f32 = 0.12;
const TEAL: egui::Color32 = egui::Color32::from_rgb(0x00, 0x69, 0x5c);
const RED: egui::Color32 = egui::Color32::from_rgb(0xc6, 0x28, 0x28);
const WARN: egui::Color32 = egui::Color32::from_rgb(0x8d, 0x6e, 0x00);
fn linkage_color(l: Linkage) -> egui::Color32 {
match l {
Linkage::Fat => egui::Color32::from_rgb(0x6a, 0x1b, 0x9a),
Linkage::Thin => WARN,
Linkage::Mixed => egui::Color32::from_rgb(0x2e, 0x50, 0x6a),
// An inapplicable axis is drawn as its own thing, never as fat's colour —
// that conflation is the bug the value exists to prevent.
Linkage::NotApplicable => egui::Color32::from_rgb(0x45, 0x4a, 0x4d),
}
}
use facett_core::look::chip;
/// **`Facet` only** — facett-core's blanket `impl<T: Facet> Panel for T` supplies
/// `Panel`, so there is nothing to disambiguate at a call site. Implementing both by
/// hand is what produced `E0034` on `ui` / `update_json` in dwarves' pane.
impl Facet for BuildThingDeck {
fn title(&self) -> &str {
"Build Thing"
}
fn ui(&mut self, ui: &mut Ui) {
self.ui_all(ui);
}
fn state_json(&self) -> serde_json::Value {
BuildThingDeck::state_json(self)
}
fn update_json(&mut self, msg_json: &str) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else { return };
if let Some(s) = v.get("select_variant").and_then(|x| x.as_str()) && self.select(s) {
self.pending.push(Msg::SelectVariant(s.to_string()));
}
if let Some(s) = v.get("build").and_then(|x| x.as_str()) {
self.pending.push(Msg::Build(s.to_string()));
}
if let Some(r) = v.get("set_ratio").and_then(|x| x.as_array())
&& let (Some(i), Some(f)) = (r.first().and_then(|x| x.as_u64()), r.get(1).and_then(|x| x.as_f64()))
{
// Joint clamp (F3): the two fractions can never sum past 1 - MIN_THIRD.
self.set_ratio(i as usize, f as f32);
}
// F1: the graph FILTER is a driveable verb too (build / files / excluded), so a
// robot can drive the interaction the pane exists for — "what does this build
// leave out" — headlessly and assert it in `state_json["graph"]["filter"]`.
if let Some(s) = v.get("set_filter").and_then(|x| x.as_str()) {
let filter = match s {
"build" => Some(crate::graph_pane::Filter::Build),
"files" => Some(crate::graph_pane::Filter::Files),
"excluded" => Some(crate::graph_pane::Filter::Excluded),
_ => None,
};
if let Some(filter) = filter {
self.graph.set_filter(filter);
}
}
}
}
/// The entry a menu-less host uses: an honest empty deck that says so.
pub fn empty_deck() -> BuildThingDeck {
BuildThingDeck::default()
}
/// Build a deck over a menu, selecting its declared normal case.
pub fn deck_for(menu: BuildMenu) -> BuildThingDeck {
BuildThingDeck::new(menu)
}
/// The **seeded demo menu** — a holger-shaped [`BuildMenu`]: a SERVER role group
/// (embedded / mini / full, `full` the declared normal case) + a CLIENT group
/// (ui / agent), each entry carrying its linkage + runtime note. Promoted from the
/// `tests/robot_deck.rs` fixture (which now delegates here — one writer, LAW 5) so a
/// host-less gallery (facett-demo) mounts a POPULATED deck instead of the honest
/// empty one.
#[must_use]
pub fn demo_menu() -> BuildMenu {
use nornir_build_thing::plugin::{Ecosystem, RoleGroup};
let entry = |id: &str, role: Role, linkage: Linkage, is_default: bool| VariantRef {
id: id.into(),
role,
ecosystem: Ecosystem::Cargo,
linkage,
runtime: match linkage {
Linkage::Fat => "embeds skade — nothing else needs to be running".into(),
Linkage::Thin => "NEEDS SKADE RUNNING — reached over gRPC".into(),
_ => "—".into(),
},
is_default,
selector: serde_json::Value::Null,
};
BuildMenu {
repo: "holger".into(),
rev: "c0ffee".repeat(6) + "abcd",
roles: vec![
RoleGroup {
role: Role::Server,
// This fixture is a repo shipping ONE unnamed product, so the menu
// has no suite axis — `None` is the documented value for that
// (nornir_build_thing::plugin::RoleGroup::suite), not a placeholder.
suite: None,
summary: "three tiers".into(),
variants: vec!["embedded".into(), "mini".into(), "full".into()],
default_variant: Some("full".into()),
},
RoleGroup {
role: Role::Client,
suite: None,
summary: "two clients".into(),
variants: vec!["ui".into(), "agent".into()],
default_variant: Some("ui".into()),
},
],
entries: vec![
entry("embedded", Role::Server, Linkage::Thin, false),
entry("mini", Role::Server, Linkage::Thin, false),
entry("full", Role::Server, Linkage::Fat, true),
entry("ui", Role::Client, Linkage::Thin, true),
entry("agent", Role::Client, Linkage::Thin, false),
],
cargo_set: None,
}
}
/// A **seeded demo deck** over [`demo_menu`] — opens on the declared normal case
/// (`full`) with all three panes populated. The fixture the facett-demo gallery
/// mounts.
#[must_use]
pub fn demo() -> BuildThingDeck {
BuildThingDeck::new(demo_menu())
}
/// The variant entry for `id`, if the menu has one.
pub fn entry<'a>(menu: &'a BuildMenu, id: &str) -> Option<&'a VariantRef> {
menu.entries.iter().find(|e| e.id == id)
}
#[cfg(test)]
mod tests {
use super::*;
use nornir_build_thing::plugin::{Ecosystem, RoleGroup};
fn menu() -> BuildMenu {
let entry = |id: &str, role: Role, linkage: Linkage, is_default: bool| VariantRef {
id: id.into(),
role,
ecosystem: Ecosystem::Cargo,
linkage,
runtime: match linkage {
Linkage::Fat => "embeds skade — nothing else needs to be running".into(),
Linkage::Thin => "NEEDS SKADE RUNNING — reached over gRPC".into(),
_ => "—".into(),
},
is_default,
selector: serde_json::Value::Null,
};
BuildMenu {
repo: "holger".into(),
rev: "c0ffee".repeat(6) + "abcd",
roles: vec![
RoleGroup {
role: Role::Server,
// This fixture is a repo shipping ONE unnamed product, so the menu has
// no suite axis — `None` is the documented value for that
// (nornir_build_thing::plugin::RoleGroup::suite), not a placeholder.
suite: None,
summary: "three tiers".into(),
variants: vec!["embedded".into(), "mini".into(), "full".into()],
default_variant: Some("full".into()),
},
RoleGroup {
role: Role::Client,
suite: None,
summary: "two clients".into(),
variants: vec!["ui".into(), "agent".into()],
default_variant: Some("ui".into()),
},
],
entries: vec![
entry("embedded", Role::Server, Linkage::Thin, false),
entry("mini", Role::Server, Linkage::Thin, false),
entry("full", Role::Server, Linkage::Fat, true),
entry("ui", Role::Client, Linkage::Thin, true),
entry("agent", Role::Client, Linkage::Thin, false),
],
cargo_set: None,
}
}
/// **It is a pane SET.** Three panes, each with its own title and its own render
/// call — not one body with three sections in a shared flow.
#[test]
fn the_deck_is_three_addressable_panes() {
let d = BuildThingDeck::new(menu());
assert_eq!(d.pane_count(), 3);
assert!(d.pane_title(PANE_VARIANTS).unwrap().contains("holger"));
assert!(d.pane_title(PANE_GRAPH).unwrap().contains("Build graph"));
assert_eq!(d.pane_title(PANE_RESULT).as_deref(), Some("Result"));
assert_eq!(d.pane_title(3), None, "out of range is None, not a silent pane");
}
/// The default selection is the app's DECLARED normal case, not the first entry —
/// "first" is an accident of declaration order.
#[test]
fn the_deck_opens_on_the_declared_normal_case() {
let d = BuildThingDeck::new(menu());
assert_eq!(d.selected.as_deref(), Some("full"), "not `embedded`, which is merely first");
}
/// **The round trip that is the payoff for returning data instead of SVG**: a
/// graph-node click becomes a variant selection.
#[test]
fn clicking_a_graph_node_selects_that_variant() {
use nornir_build_thing::plugin::{GraphNode as BgNode, NodeKind};
let mut d = BuildThingDeck::new(menu());
d.set_graph(BuildGraph {
repo: "holger".into(),
root: "mini".into(),
nodes: vec![BgNode {
id: "variant:mini".into(),
label: "mini".into(),
kind: NodeKind::Variant,
detail: None,
selects: Some("mini".into()),
}],
edges: Vec::new(),
});
// What `BuildGraphPane::ui` does when the node is clicked.
d.graph.pending_select_for_test("mini");
let msgs = d.take_msgs();
assert_eq!(msgs, vec![Msg::SelectVariant("mini".into())]);
}
/// Selection is idempotent — a host must not re-fetch a graph every frame.
#[test]
fn selecting_the_same_variant_twice_reports_no_change() {
let mut d = BuildThingDeck::new(menu());
assert!(!d.select("full"), "already selected");
assert!(d.select("mini"));
assert!(!d.select("mini"));
}
/// The headless seam: the deck drives over `update_json` / `state_json` like every
/// other facet, so a robot can drive it with no window.
#[test]
fn the_deck_drives_headless_over_the_seam() {
let mut d = BuildThingDeck::new(menu());
Facet::update_json(&mut d, r#"{"select_variant":"embedded"}"#);
let s = Facet::state_json(&d);
assert_eq!(s["selected"], "embedded");
assert_eq!(s["pane_count"], 3);
assert_eq!(s["panes"].as_array().unwrap().len(), 3);
assert_eq!(s["roles"][0]["default"], "full");
assert!(s["default_problems"].as_array().unwrap().is_empty());
// F1: the graph filter is a driveable verb, surfaced in the graph pane state.
assert_eq!(
Facet::state_json(&d)["graph"]["filter"], "build",
"the deck opens on the Build filter (no file layer)"
);
Facet::update_json(&mut d, r#"{"set_filter":"excluded"}"#);
assert_eq!(
Facet::state_json(&d)["graph"]["filter"], "excluded",
"set_filter drives the graph pane's filter — the 'what does this build leave out' view"
);
// The divider is state, so a robot can assert a drag moved it — but the two
// fractions are clamped JOINTLY (F3): they can never sum past `1 - MIN_THIRD`,
// so the result pane keeps its floor. `ratios[1]` defaults to 0.40, so asking
// `ratios[0]` for 0.5 lands at `1 - MIN_THIRD - 0.40 = 0.48`, not 0.5.
Facet::update_json(&mut d, r#"{"set_ratio":[0,0.5]}"#);
let r0 = Facet::state_json(&d)["ratios"][0].as_f64().unwrap();
assert!(
(r0 - 0.48).abs() < 1e-6,
"joint clamp caps ratios[0] at 1 - MIN_THIRD - ratios[1], got {r0}"
);
// **F3 (bug) — the over-100% case.** Independent clamping let BOTH fractions
// reach MAX_RATIO (0.70) and sum to 1.4, driving the result pane's height
// (`1 - r0 - r1`) negative so the panes overlapped and `state_json["ratios"]`
// misreported the layout. Drive the exact reported sequence and assert the joint
// budget holds: the two fractions leave the result pane at least MIN_THIRD.
let mut d2 = BuildThingDeck::new(menu());
Facet::update_json(&mut d2, r#"{"set_ratio":[0,0.70]}"#);
Facet::update_json(&mut d2, r#"{"set_ratio":[1,0.70]}"#);
let rr = Facet::state_json(&d2)["ratios"].clone();
let (a, b) = (rr[0].as_f64().unwrap(), rr[1].as_f64().unwrap());
assert!(a + b <= 1.0 - 0.12 + 1e-6, "the two dividers leave the result pane its floor: {a}+{b}={}", a + b);
assert!(1.0 - a - b >= 0.12 - 1e-6, "the third (result) pane keeps at least MIN_THIRD — never negative");
let msgs = d.take_msgs();
assert!(msgs.contains(&Msg::SelectVariant("embedded".into())));
}
/// An empty deck says the declaration is ABSENT — never an empty build space.
#[test]
fn an_empty_deck_states_that_the_declaration_is_absent() {
let d = empty_deck();
let s = Facet::state_json(&d);
assert_eq!(s["pane_count"], 3, "the panes exist even with nothing to show");
assert_eq!(s["selected"], serde_json::Value::Null);
assert_eq!(s["graph"]["node_count"], 0);
}
/// A declaration bug (a role with no normal case) is SURFACED in the pane's state,
/// not swallowed.
#[test]
fn a_role_with_no_default_is_reported_in_the_pane_state() {
let mut m = menu();
m.roles[0].default_variant = None;
for e in m.entries.iter_mut() {
if e.id == "full" {
e.is_default = false;
}
}
let d = BuildThingDeck::new(m);
let problems = Facet::state_json(&d)["default_problems"].as_array().unwrap().clone();
assert!(!problems.is_empty());
assert!(problems[0].as_str().unwrap().contains("NO default"), "{problems:?}");
}
#[test]
fn an_inapplicable_linkage_is_not_drawn_as_fat() {
assert_ne!(
linkage_color(Linkage::NotApplicable),
linkage_color(Linkage::Fat),
"conflating 'the axis does not apply' with 'self-contained' is the bug \
NotApplicable exists to prevent"
);
}
}