facett-pipeline/src/promote_adapter.rs
//! **The promotion-ladder adapter** — a slot ladder → facett's engine-agnostic
//! [`PipelineModel`](crate::PipelineModel), so a promotion ladder renders on the
//! *existing* DAG viewer.
//!
//! A promotion ladder is a DAG with per-node status, which is precisely what
//! [`PipelineView`](crate::PipelineView) already draws. So showing one is **an
//! adapter, not a new crate** — the same rule that made runa's and knut's
//! pipelines share this viewer. (There are already three facett crates that draw
//! boxes-with-status; a fourth would be duplication, not capability.)
//!
//! ## The dependency rule this shape exists to obey
//!
//! facett is a **UI library**. The promotion *engine* — `skidbladnir::promote` —
//! is a heavy, headless provisioning library path-depended by four consumers that
//! must never be handed an `egui` stack. So the edge is wrong in **both**
//! directions: facett must not depend on the engine, and the engine must not
//! depend on facett.
//!
//! The adapter therefore reads a **small local input type** that the *caller*
//! populates ([`PromotionLadder`]) — exactly the way
//! [`RunaPipelineAdapter`](crate::RunaPipelineAdapter) consumes runa's model
//! without facett owning runa's engine. Unlike the runa adapter this module is
//! **not** feature-gated: that flag exists to keep an optional *dependency*
//! (`dwarves-runa`) out of a default build, and this module pulls in nothing at
//! all. Gating a zero-dependency adapter would be cargo-culting the flag instead
//! of its reason.
//!
//! ## Mapping the host writes (≈ a few lines)
//!
//! | promotion engine | → | this module |
//! |---|---|---|
//! | one slot / environment | → | a [`Rung`] (`id` = the slot key, `name` = what to show) |
//! | the slot's role in the constellation | → | [`SlotRole`] |
//! | the slot's guard flag (prod guard / two-key override) | → | [`Rung::guarded`] |
//! | the promotion order (`a` may promote into `b`) | → | a [`Promotion`] `a → b` |
//! | the observed state of a slot | → | a [`RungStatus`] |
//! | **no observation for a slot** | → | [`RungStatus::Unknown`] + why — never `Pending` |
//!
//! The rungs are **DATA**. Njord's ladder happens to be
//! `test2 → test1 → lab → g11.se`; someone else runs `dev → qa → staging → prod`;
//! a third has one rung and no prod. Nothing in this file knows any of those
//! names, and nothing in it may learn them.
//!
//! ## The law: an unobserved rung is `Unknown`, never `Idle`, never `Done`
//!
//! For a *pipeline*, a stage nobody has run is honestly `Idle` — the engine owns
//! the run and knows it has not started. For a **ladder** the nodes are live
//! environments that exist whether or not we looked, so "we have never observed
//! g11.se" and "g11.se is idle" are different facts. Rendering the first as the
//! second is a **fake green**: a calm neutral box over an environment nobody has
//! checked. That pattern has been found repeatedly in this codebase — a DHCP
//! *request* read as a lease, a handshake *initiation* read as a completed
//! handshake — and every instance shipped a green light over something that was
//! not working.
//!
//! So this adapter inverts the runa adapter's default. Where a runa stage missing
//! from the status map stays [`StageStatus::Idle`], a rung missing from
//! [`PromoteLadderAdapter::with_observations`] becomes
//! [`StageStatus::Unknown`]`(`[`UnknownReason::NotProbed`]`)`, which renders
//! amber, dashed and `?`-marked. [`RungStatus`]'s own `Default` is `Unknown` for
//! the same reason: the cheapest thing to write must be the honest thing.
use std::collections::HashMap;
use crate::{
PipelineAdapter, PipelineEdge, PipelineModel, Stage, StageStatus, UnknownReason,
};
/// What a slot **is** in the constellation. Purely descriptive — the adapter draws
/// it as part of the node's sublabel and never changes behaviour on it (a guard is
/// [`Rung::guarded`], an explicit flag, not something inferred from the role).
///
/// `#[non_exhaustive]`: roles will be added.
#[non_exhaustive]
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
pub enum SlotRole {
/// The live, customer-facing slot. Top of the ladder; data never flows up into it.
Prod,
/// A production-shaped rehearsal slot (staging / pre-prod / lab).
Preprod,
/// A test slot.
#[default]
Test,
/// A slot whose role the caller does not classify.
Other,
}
impl SlotRole {
/// A short, stable token for the sublabel and for text/JSON.
pub fn token(self) -> &'static str {
match self {
SlotRole::Prod => "prod",
SlotRole::Preprod => "preprod",
SlotRole::Test => "test",
SlotRole::Other => "slot",
}
}
}
/// The **observed** state of one rung, as the promotion engine reports it.
///
/// Deliberately ladder-shaped rather than a re-use of [`StageStatus`]: a ladder's
/// nodes are environments, not steps, so the vocabulary differs — and keeping it
/// separate is what forces the caller to answer *"did you actually look?"*.
///
/// `Default` is [`Unknown`](RungStatus::Unknown)`(NotProbed)` — **not** `Pending`.
/// A rung you have not said anything about is one you have not looked at.
///
/// `#[non_exhaustive]`: match with a `_ =>` arm.
#[non_exhaustive]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum RungStatus {
/// Observed, and holding the release it is supposed to hold. → `Done`.
Promoted,
/// A promotion into this rung is running right now. → `Running`.
Promoting,
/// The last promotion into this rung failed (put the message on
/// [`Rung::note`]). → `Failed`.
Failed,
/// Observed and healthy, but has **not** received the release yet — it is a
/// later rung on the ladder and is genuinely waiting. → `Idle`.
///
/// Only say this when you looked. If you did not look, that is
/// [`Unknown`](RungStatus::Unknown).
Pending,
/// **Could not be determined**, with the reason. → `Unknown`, rendered amber /
/// dashed / `?` — never the neutral grey of `Pending`.
Unknown(UnknownReason),
}
impl Default for RungStatus {
fn default() -> Self {
RungStatus::Unknown(UnknownReason::NotProbed)
}
}
impl RungStatus {
/// Map onto the neutral [`StageStatus`] the viewer renders. The only
/// interesting arm is the last one: an undetermined rung carries its reason
/// straight through, so it can be neither `Idle` nor `Done` by construction.
pub fn to_stage_status(self) -> StageStatus {
match self {
RungStatus::Promoted => StageStatus::Done,
RungStatus::Promoting => StageStatus::Running,
RungStatus::Failed => StageStatus::Failed,
RungStatus::Pending => StageStatus::Idle,
RungStatus::Unknown(r) => StageStatus::Unknown(r),
// No `_` arm: a rung state added later must be mapped DELIBERATELY.
// In-crate exhaustiveness still bites under `#[non_exhaustive]`, so
// the compiler stops anyone from letting a new state fall into `Idle`.
}
}
}
impl From<RungStatus> for StageStatus {
fn from(s: RungStatus) -> Self {
s.to_stage_status()
}
}
/// One rung of the ladder: a slot/environment the release is promoted through.
#[derive(Clone, Debug)]
pub struct Rung {
/// Stable identity — what [`Promotion`] endpoints reference (the slot key).
pub id: String,
/// Human display name (often the host, e.g. the slot's public name).
pub name: String,
/// What this slot is (prod / preprod / test).
pub role: SlotRole,
/// Whether promoting **into** this rung is guarded (a prod guard, a two-key
/// override, a manual approval). Descriptive: the adapter shows it, the
/// engine enforces it.
pub guarded: bool,
/// The observed state. Defaults to [`RungStatus::Unknown`].
pub status: RungStatus,
/// The release/version this rung is holding, when known (shown in the
/// sublabel). `None` = not reported.
pub release: Option<String>,
/// Free-text detail — the failure message for a [`RungStatus::Failed`] rung,
/// or any note the host wants in the detail panel.
pub note: Option<String>,
}
impl Rung {
/// A rung with `id` = `name`, the given role, unguarded, and — critically —
/// [`RungStatus::Unknown`] until someone says otherwise.
pub fn new(name: impl Into<String>, role: SlotRole) -> Self {
let name = name.into();
Rung {
id: name.clone(),
name,
role,
guarded: false,
status: RungStatus::default(),
release: None,
note: None,
}
}
/// Set a distinct stable id (when the slot key differs from the display name).
pub fn with_id(mut self, id: impl Into<String>) -> Self {
self.id = id.into();
self
}
/// Mark promotion into this rung as guarded.
pub fn guarded(mut self, guarded: bool) -> Self {
self.guarded = guarded;
self
}
/// Attach the observed status.
pub fn with_status(mut self, status: RungStatus) -> Self {
self.status = status;
self
}
/// Attach the release/version this rung holds.
pub fn with_release(mut self, release: impl Into<String>) -> Self {
self.release = Some(release.into());
self
}
/// Attach a note (failure message / operator remark).
pub fn with_note(mut self, note: impl Into<String>) -> Self {
self.note = Some(note.into());
self
}
/// The node sublabel: `role · guarded · release`, skipping the parts that are
/// absent (e.g. `"prod · guarded · v2.4.1"`, or just `"test"`).
fn kind(&self) -> String {
let mut parts: Vec<&str> = vec![self.role.token()];
if self.guarded {
parts.push("guarded");
}
match &self.release {
Some(r) => format!("{} · {}", parts.join(" · "), r),
None => parts.join(" · "),
}
}
}
/// One step of the promotion order: the release moves **`from` → `to`**. Endpoints
/// are [`Rung::id`]s. This is the ladder's edge set, and it is data — a ladder may
/// branch (one rung promoting into two) or converge; nothing here assumes a chain.
#[derive(Clone, Default, Debug, PartialEq, Eq)]
pub struct Promotion {
/// The upstream rung the release is promoted **from**.
pub from: String,
/// The downstream rung the release is promoted **into**.
pub to: String,
/// Optional edge label (e.g. `"guarded"`, `"manual"`, a gate name).
pub label: Option<String>,
}
impl Promotion {
/// A bare `from → to` promotion step.
pub fn new(from: impl Into<String>, to: impl Into<String>) -> Self {
Promotion {
from: from.into(),
to: to.into(),
label: None,
}
}
/// Attach an edge label (drawn at the edge midpoint).
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
}
/// **The adapter's input** — the whole ladder: a title, the rungs, and the
/// promotion order between them.
///
/// This is the type the Njord-side host builds from `skidbladnir::promote`'s serde
/// topology. It is intentionally tiny and owns no engine types, so that mapping is
/// a `map`/`collect` pair rather than a translation layer.
#[derive(Clone, Default, Debug)]
pub struct PromotionLadder {
/// Ladder title (e.g. the application or customer name).
pub title: String,
/// The rungs, in a stable display order.
pub rungs: Vec<Rung>,
/// The promotion order (edges between rung ids). Endpoints that name no rung
/// are dropped by the viewer, exactly as for any [`PipelineModel`].
pub promotions: Vec<Promotion>,
}
impl PromotionLadder {
/// An empty ladder with a title.
pub fn new(title: impl Into<String>) -> Self {
PromotionLadder {
title: title.into(),
..Default::default()
}
}
/// A **linear** ladder: the rungs in the given order, chained
/// `rungs[0] → rungs[1] → …`. The common case — and still data: the caller
/// supplies the rungs and hence the order.
pub fn linear(title: impl Into<String>, rungs: Vec<Rung>) -> Self {
let promotions = rungs
.windows(2)
.map(|w| Promotion::new(w[0].id.clone(), w[1].id.clone()))
.collect();
PromotionLadder {
title: title.into(),
rungs,
promotions,
}
}
/// Builder: append a rung.
pub fn with_rung(mut self, rung: Rung) -> Self {
self.rungs.push(rung);
self
}
/// Builder: append a promotion step.
pub fn with_promotion(mut self, promotion: Promotion) -> Self {
self.promotions.push(promotion);
self
}
/// Index of the rung with this id, if present.
pub fn rung_index(&self, id: &str) -> Option<usize> {
self.rungs.iter().position(|r| r.id == id)
}
}
/// Build the normalized [`PipelineModel`] from a [`PromotionLadder`] plus an
/// optional per-rung observation map (keyed by [`Rung::id`]).
///
/// **A rung absent from `observed` keeps the rung's own status**, which defaults
/// to [`RungStatus::Unknown`] — so the free-function form is safe to call with an
/// empty map: an unobserved ladder renders entirely amber-`?`, not grey-idle.
pub fn to_pipeline_model(
ladder: &PromotionLadder,
observed: &HashMap<String, RungStatus>,
) -> PipelineModel {
let stages: Vec<Stage> = ladder
.rungs
.iter()
.map(|r| {
// The observation wins when present; otherwise the rung's own status,
// which is Unknown unless the caller set it. There is no code path
// here that can invent Idle or Done for a rung nobody reported.
let status = observed.get(&r.id).copied().unwrap_or(r.status);
let mut stage = Stage::new(r.name.clone(), status.to_stage_status()).with_id(r.id.clone());
stage = stage.with_kind(r.kind());
if let Some(note) = &r.note {
stage = stage.with_error(note.clone());
}
stage
})
.collect();
let edges: Vec<PipelineEdge> = ladder
.promotions
.iter()
.map(|p| {
let e = PipelineEdge::new(p.from.clone(), p.to.clone());
match &p.label {
Some(l) => e.with_label(l.clone()),
None => e,
}
})
.collect();
PipelineModel {
title: ladder.title.clone(),
stages,
edges,
// A ladder carries no row/byte dataflow metrics — the progress bar stays
// off. A host running a live promotion layers its own.
metrics: None,
}
}
/// The promotion-ladder [`PipelineAdapter`]: a thin wrapper over a
/// `&PromotionLadder` (plus an optional per-rung observation map) so the viewer
/// takes `&impl PipelineAdapter` uniformly
/// ([`PipelineView::from_adapter`](crate::PipelineView::from_adapter)).
///
/// ```
/// use facett_pipeline::{
/// PipelineAdapter, PromoteLadderAdapter, PromotionLadder, Rung, RungStatus, SlotRole,
/// StageStatus, UnknownReason,
/// };
///
/// // The rungs are DATA — these names are this caller's, not facett's.
/// let ladder = PromotionLadder::linear(
/// "acme",
/// vec![
/// Rung::new("dev", SlotRole::Test).with_status(RungStatus::Promoted),
/// Rung::new("staging", SlotRole::Preprod).with_status(RungStatus::Promoting),
/// Rung::new("prod", SlotRole::Prod).guarded(true), // never probed
/// ],
/// );
/// let model = PromoteLadderAdapter::new(&ladder).to_pipeline_model();
///
/// assert_eq!(model.stages.len(), 3);
/// assert_eq!(model.edges.len(), 2); // dev → staging → prod
/// // The rung nobody looked at is UNKNOWN — not Idle, not Done.
/// assert_eq!(
/// model.stages[2].status,
/// StageStatus::Unknown(UnknownReason::NotProbed),
/// );
/// assert_eq!(model.stages[2].status.label(), "unknown:not-probed");
/// ```
pub struct PromoteLadderAdapter<'a> {
ladder: &'a PromotionLadder,
observed: HashMap<String, RungStatus>,
}
impl<'a> PromoteLadderAdapter<'a> {
/// Wrap a ladder for rendering. With no observations attached, each rung keeps
/// its own status — which defaults to [`RungStatus::Unknown`], so a ladder
/// built from topology alone renders as "we have not looked", not as "idle".
pub fn new(ladder: &'a PromotionLadder) -> Self {
Self {
ladder,
observed: HashMap::new(),
}
}
/// Attach a per-rung observation map (keyed by [`Rung::id`]).
///
/// **Note the difference from
/// [`RunaPipelineAdapter::with_status`](crate::RunaPipelineAdapter::with_status):**
/// there, a stage missing from the map is `Idle` ("not yet started" — the
/// engine owns the run and knows). Here, a rung missing from the map is
/// whatever the rung says, and a rung says `Unknown` unless told otherwise.
/// A live environment nobody probed is not idle; it is unobserved.
pub fn with_observations(mut self, observed: HashMap<String, RungStatus>) -> Self {
self.observed = observed;
self
}
/// Mark **every** rung not present in the observation map with an explicit
/// reason — the honest spelling for "the probe run itself failed / we were not
/// authorised", as opposed to the default "never probed".
pub fn unobserved_reason(mut self, reason: UnknownReason) -> Self {
for r in &self.ladder.rungs {
self.observed
.entry(r.id.clone())
.or_insert(RungStatus::Unknown(reason));
}
self
}
}
impl PipelineAdapter for PromoteLadderAdapter<'_> {
fn to_pipeline_model(&self) -> PipelineModel {
to_pipeline_model(self.ladder, &self.observed)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Njord's own ladder shape — but note the test builds it as DATA. The
/// `names_are_data` test below runs a completely different set of names
/// through the identical code path to prove nothing is baked in.
fn njord_ladder() -> PromotionLadder {
PromotionLadder::linear(
"yvra",
vec![
Rung::new("test2", SlotRole::Test),
Rung::new("test1", SlotRole::Test),
Rung::new("lab", SlotRole::Preprod),
Rung::new("g11.se", SlotRole::Prod).guarded(true),
],
)
}
#[test]
fn rungs_become_nodes_and_the_promotion_order_becomes_edges() {
let m = PromoteLadderAdapter::new(&njord_ladder()).to_pipeline_model();
assert_eq!(m.stages.len(), 4, "one node per rung");
assert_eq!(m.edges.len(), 3, "one edge per promotion step");
let ids: Vec<&str> = m.stages.iter().map(|s| s.id.as_str()).collect();
assert_eq!(ids, vec!["test2", "test1", "lab", "g11.se"]);
// Direction is `from → to`: the release is promoted UP the ladder.
let into_prod = m.edges.iter().find(|e| e.to == "g11.se").expect("an edge into prod");
assert_eq!(into_prod.from, "lab");
assert_eq!(m.title, "yvra");
}
/// ⚠ **The law.** A rung nobody observed must be `Unknown` with a reason —
/// not `Idle` (which claims we looked and it had not started) and not `Done`
/// (which is the fake green outright). Revert `RungStatus::default()` to
/// `Pending`, or map `Unknown` onto `Idle`, and this goes red.
#[test]
fn an_unobserved_rung_is_unknown_never_idle_never_done() {
let m = PromoteLadderAdapter::new(&njord_ladder()).to_pipeline_model();
for s in &m.stages {
assert_eq!(
s.status,
StageStatus::Unknown(UnknownReason::NotProbed),
"{} was never observed, so it must be Unknown(NotProbed)",
s.id
);
assert_ne!(s.status, StageStatus::Idle, "{} must NOT render as idle", s.id);
assert_ne!(s.status, StageStatus::Done, "{} must NOT render as done", s.id);
assert!(s.status.is_unknown());
assert_eq!(s.status.label(), "unknown:not-probed", "the label carries the reason");
}
// And the tally accounts for every one of them.
let t = m.status_tally();
assert_eq!(t.unknown, 4);
assert_eq!(t.idle, 0, "not one of them may be counted as idle");
assert_eq!(t.done, 0);
assert_eq!(t.total(), m.stages.len());
}
/// The default is the honest one: writing the least possible about a rung must
/// not yield a green (or even a calm grey) light.
#[test]
fn the_default_rung_status_is_unknown() {
assert_eq!(RungStatus::default(), RungStatus::Unknown(UnknownReason::NotProbed));
assert_eq!(
Rung::new("anything", SlotRole::Test).status,
RungStatus::Unknown(UnknownReason::NotProbed),
);
assert!(RungStatus::default().to_stage_status().is_unknown());
}
#[test]
fn observations_light_the_ladder_and_the_gap_stays_unknown() {
let ladder = njord_ladder();
let observed = HashMap::from([
("test2".to_string(), RungStatus::Promoted),
("test1".to_string(), RungStatus::Promoting),
("lab".to_string(), RungStatus::Pending),
// `g11.se` intentionally absent — the probe never reached prod.
]);
let m = PromoteLadderAdapter::new(&ladder)
.with_observations(observed)
.to_pipeline_model();
let by_id = |id: &str| m.stages.iter().find(|s| s.id == id).unwrap().status;
assert_eq!(by_id("test2"), StageStatus::Done);
assert_eq!(by_id("test1"), StageStatus::Running);
assert_eq!(by_id("lab"), StageStatus::Idle, "Pending means we LOOKED and it is waiting");
assert_eq!(
by_id("g11.se"),
StageStatus::Unknown(UnknownReason::NotProbed),
"the rung missing from the observation map stays Unknown — the exact \
place where a pipeline adapter would have said Idle",
);
let t = m.status_tally();
assert_eq!((t.done, t.running, t.idle, t.failed, t.unknown), (1, 1, 1, 0, 1));
}
/// The whole-ladder reason override: "we tried and could not reach anything"
/// is a different fact from "we never tried", and both must be sayable.
#[test]
fn unobserved_reason_overrides_the_default_not_probed() {
let ladder = njord_ladder();
let m = PromoteLadderAdapter::new(&ladder)
.with_observations(HashMap::from([("test2".to_string(), RungStatus::Promoted)]))
.unobserved_reason(UnknownReason::NotAuthorised)
.to_pipeline_model();
let by_id = |id: &str| m.stages.iter().find(|s| s.id == id).unwrap().status;
assert_eq!(by_id("test2"), StageStatus::Done, "an observed rung is untouched");
assert_eq!(by_id("g11.se"), StageStatus::Unknown(UnknownReason::NotAuthorised));
assert_eq!(by_id("g11.se").label(), "unknown:not-authorised");
}
/// ⚠ **The rungs are DATA.** A ladder with entirely different names, a
/// different length and a branch produces the same shape through the same
/// code. Hardcode `test2`/`lab`/`g11.se` anywhere in this module and this
/// goes red.
#[test]
fn names_and_topology_are_data_not_baked_in() {
let ladder = PromotionLadder::new("acme")
.with_rung(Rung::new("dev", SlotRole::Test).with_status(RungStatus::Promoted))
.with_rung(Rung::new("qa", SlotRole::Test).with_status(RungStatus::Promoted))
.with_rung(Rung::new("staging", SlotRole::Preprod).with_status(RungStatus::Promoting))
.with_rung(Rung::new("prod-eu", SlotRole::Prod).guarded(true))
.with_rung(Rung::new("prod-us", SlotRole::Prod).guarded(true))
// A BRANCH: staging promotes into two prods.
.with_promotion(Promotion::new("dev", "qa"))
.with_promotion(Promotion::new("qa", "staging"))
.with_promotion(Promotion::new("staging", "prod-eu").with_label("guarded"))
.with_promotion(Promotion::new("staging", "prod-us").with_label("guarded"));
let m = PromoteLadderAdapter::new(&ladder).to_pipeline_model();
assert_eq!(m.stages.len(), 5);
assert_eq!(m.edges.len(), 4);
let out_of_staging: Vec<&str> = m.edges.iter().filter(|e| e.from == "staging").map(|e| e.to.as_str()).collect();
assert_eq!(out_of_staging, vec!["prod-eu", "prod-us"], "a ladder may branch");
// Both unprobed prods are Unknown, exactly as Njord's would be.
for id in ["prod-eu", "prod-us"] {
let s = m.stages.iter().find(|s| s.id == id).unwrap();
assert!(s.status.is_unknown(), "{id} was never probed");
}
assert_eq!(m.edges[2].label.as_deref(), Some("guarded"));
}
#[test]
fn role_guard_and_release_ride_the_sublabel() {
let ladder = PromotionLadder::new("t")
.with_rung(Rung::new("p", SlotRole::Prod).guarded(true).with_release("v2.4.1"))
.with_rung(Rung::new("t1", SlotRole::Test));
let m = PromoteLadderAdapter::new(&ladder).to_pipeline_model();
assert_eq!(m.stages[0].kind.as_deref(), Some("prod · guarded · v2.4.1"));
assert_eq!(m.stages[1].kind.as_deref(), Some("test"), "unguarded, no release → just the role");
}
#[test]
fn a_failed_rungs_note_becomes_the_stage_error() {
let ladder = PromotionLadder::new("t").with_rung(
Rung::new("lab", SlotRole::Preprod)
.with_status(RungStatus::Failed)
.with_note("rollout timed out after 300s"),
);
let m = PromoteLadderAdapter::new(&ladder).to_pipeline_model();
assert_eq!(m.stages[0].status, StageStatus::Failed);
assert_eq!(m.stages[0].error.as_deref(), Some("rollout timed out after 300s"));
}
#[test]
fn a_distinct_slot_key_and_display_name_both_survive() {
let ladder = PromotionLadder::new("t")
.with_rung(Rung::new("Kundens produktion", SlotRole::Prod).with_id("slot-prod-1"))
.with_rung(Rung::new("Labb", SlotRole::Preprod).with_id("slot-lab"))
.with_promotion(Promotion::new("slot-lab", "slot-prod-1"));
let m = PromoteLadderAdapter::new(&ladder).to_pipeline_model();
assert_eq!(m.stage_index("slot-prod-1"), Some(0), "edges resolve on the ID");
assert_eq!(m.stages[0].name, "Kundens produktion", "the NAME is what is drawn");
}
#[test]
fn adapter_trait_and_free_fn_agree() {
let ladder = njord_ladder();
let empty = HashMap::new();
let a = to_pipeline_model(&ladder, &empty);
let b = PromoteLadderAdapter::new(&ladder).to_pipeline_model();
assert_eq!(a.stages.len(), b.stages.len());
assert_eq!(a.edges.len(), b.edges.len());
assert_eq!(a.title, b.title);
assert_eq!(a.status_tally(), b.status_tally());
}
#[test]
fn linear_chains_in_order_and_an_empty_ladder_has_no_edges() {
let one = PromotionLadder::linear("t", vec![Rung::new("only", SlotRole::Prod)]);
assert!(one.promotions.is_empty(), "a one-rung ladder has no promotion step");
let none = PromotionLadder::linear("t", vec![]);
assert!(none.promotions.is_empty());
assert!(PromoteLadderAdapter::new(&none).to_pipeline_model().stages.is_empty());
}
#[test]
fn rung_index_finds_by_id() {
let l = njord_ladder();
assert_eq!(l.rung_index("lab"), Some(2));
assert_eq!(l.rung_index("nope"), None);
}
}