facet-wrapped/src/ops.rs
//! **The operations family** — the surfaces korp and dwarves put a running system on
//! screen with: a pipeline of stages, a job list, a system chart, a kanban board and a
//! gantt plan.
//!
//! Every one of these already existed as a facett crate and every one of them was
//! previously unreachable from a web host, which is the condition that makes a caller
//! draw its own. They are wrapped here on the same terms as the rest of the registry:
//! the payload carries FACTS (a status word, a count, a date), never presentation. No
//! colour, no layout, no glyph crosses this boundary — the component decides those
//! from its own rules, so a host cannot drift the look of a facett surface.
//!
//! # The status vocabulary
//!
//! Three of these components have their own status enum and none of them ships a
//! string parser, so the mapping lives here — once, in [`pipeline_status`],
//! [`bar_status`] and (from the component) `JobStatus::parse`. Parsing is
//! case-insensitive and forgiving of the words a real scheduler emits (`ok`,
//! `success`, `error`, `crashed`), because a payload arriving from a foreign system
//! must land on the nearest honest status rather than be rejected. What it must never
//! do is land on a *flattering* one: anything unrecognised becomes the component's
//! neutral/unknown state, never `Done`.
use facett_core::Facet;
use serde_json::Value;
use crate::registry::{Entry, OPS, Registry};
/// Register the operations family.
pub(crate) fn register(r: &mut Registry) {
r.register(
Entry::new("pipeline", "A staged pipeline DAG with per-stage status + throughput.", || {
Box::new(facett_pipeline::PipelineView::new(
"pipeline",
vec![
("extract".to_string(), facett_pipeline::Status::Done),
("transform".to_string(), facett_pipeline::Status::Running),
("load".to_string(), facett_pipeline::Status::Idle),
],
vec![(0, 1), (1, 2)],
))
})
.in_group(OPS)
.with_data(pipeline),
);
r.register(
Entry::new("jobview", "A live job list: kind, target, status, elapsed, progress.", || {
let mut v = facett_jobview::JobList::new("jobs");
v.set_jobs(vec![
facett_jobview::JobEntry::new("1", "build", facett_jobview::JobStatus::Running)
.with_progress(0.4),
facett_jobview::JobEntry::new("2", "test", facett_jobview::JobStatus::Queued),
]);
Box::new(v)
})
.in_group(OPS)
.with_data(jobview),
);
r.register(
Entry::new("syschart", "A system chart: labelled nodes, badges and their links.", || {
Box::new(facett_syschart::SystemChart::demo())
})
.in_group(OPS)
.with_data(syschart),
);
r.register(
Entry::new("kanban", "A kanban board: columns with WIP limits, cards, swimlanes.", || {
Box::new(facett_kanban::Board::local())
})
.in_group(OPS)
.with_data(kanban),
);
r.register(
Entry::new("gantt", "A gantt plan: dated tasks, dependencies, progress, milestones.", || {
Box::new(facett_gantt::Gantt::local())
})
.in_group(OPS)
.with_data(gantt),
);
r.register(
Entry::new("progress", "Labelled progress: a phase header, an aggregate bar, one bar per row.", || {
let mut v = facett_pop::ProgressView::new("progress");
v.update(facett_pop::Msg::Upsert { label: "rows".into(), done: 3, total: 10 });
Box::new(v)
})
.in_group(OPS)
.with_data(progress),
);
}
/// **`progress`** — [`facett_pop::ProgressView`] (added 2026-09-29, inventory item 6).
///
/// ```json
/// { "title": "bulk load", "phase": "loading",
/// "items": [{ "label": "persons", "done": 400, "total": 1000 }] }
/// ```
///
/// A row needs a non-empty `label`; `done` is clamped to `total` by nothing here — the
/// component shows what it is told, and a caller that over-reports sees it. No rows is a
/// refusal (an empty progress pane is not data).
#[must_use]
pub fn progress(v: &Value) -> Option<Box<dyn Facet>> {
let items: Vec<facett_pop::ProgressItem> = v
.get("items")
.and_then(Value::as_array)?
.iter()
.filter_map(|i| {
let label = i.get("label").and_then(Value::as_str).filter(|s| !s.trim().is_empty())?;
Some(facett_pop::ProgressItem {
label: label.to_string(),
done: i.get("done").and_then(Value::as_u64).unwrap_or(0),
total: i.get("total").and_then(Value::as_u64).unwrap_or(0),
})
})
.collect();
if items.is_empty() {
return None;
}
let mut view = facett_pop::ProgressView::new(v.get("title").and_then(Value::as_str).unwrap_or("progress"));
if let Some(p) = v.get("phase").and_then(Value::as_str) {
view.update(facett_pop::Msg::SetPhase(p.to_string()));
}
view.update(facett_pop::Msg::SetItems(items));
Some(Box::new(view))
}
fn title_of(v: &Value, fallback: &str) -> String {
v.get("title")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.unwrap_or(fallback)
.to_string()
}
/// `[[from, to], …]` — also accepts `[{"src":0,"dst":1}]`. Out-of-range indices are
/// dropped rather than panicking: a partial DAG is still worth drawing, and an
/// index that does not exist is the caller's bug, not a reason to lose the render.
fn edges_of(v: &Value, n: usize) -> Vec<(usize, usize)> {
let Some(arr) = v.get("edges").and_then(Value::as_array) else { return Vec::new() };
arr.iter()
.filter_map(|e| {
let (a, b) = if let Some(p) = e.as_array() {
(p.first()?.as_u64()?, p.get(1)?.as_u64()?)
} else {
(e.get("src")?.as_u64()?, e.get("dst")?.as_u64()?)
};
let (a, b) = (a as usize, b as usize);
(a < n && b < n && a != b).then_some((a, b))
})
.collect()
}
/// A status word → [`facett_pipeline::Status`]. Unknown words land on `Unknown`
/// (with the component's own `NotProbed` reason), never on a flattering `Done`.
fn pipeline_status(s: &str) -> facett_pipeline::Status {
use facett_pipeline::Status;
match s.trim().to_ascii_lowercase().as_str() {
"running" | "active" | "in_progress" | "started" => Status::Running,
"done" | "ok" | "success" | "succeeded" | "complete" | "completed" | "passed" => {
Status::Done
}
"failed" | "fail" | "error" | "crashed" | "aborted" => Status::Failed,
"idle" | "pending" | "queued" | "waiting" | "" => Status::Idle,
_ => Status::Unknown(facett_pipeline::UnknownReason::NotProbed),
}
}
/// **`pipeline`** — [`facett_pipeline::PipelineView`].
///
/// ```json
/// { "title": "ingest",
/// "nodes": [{"name": "scan", "status": "running", "detail": "…", "rows": 1200,
/// "throughput_rows_s": 840.0, "lag_s": 1.5, "error": "…"}],
/// "edges": [[0, 1], [1, 2]] }
/// ```
#[must_use]
pub fn pipeline(v: &Value) -> Option<Box<dyn Facet>> {
// TWO accepted shapes, dispatched on which key is present.
//
// `stages` is the NORMALIZED `PipelineModel` wire (row 5) — what knut's Spark/SDP
// adapter produces and what `PipelineView::from_model` renders. It goes first
// because it is the richer one: it carries stable stage ids, kinds, per-stage
// metrics and `Unknown(reason)` statuses that the `nodes` shape has no room for.
//
// `nodes` is the older hand-written shape below, kept because the gallery, the
// robot and every existing consumer send it. Row 5 ADDS a shape; it does not
// replace one.
if v.get("stages").is_some() {
let model = pipeline_model_from_json(v)?;
return Some(Box::new(facett_pipeline::PipelineView::from_model(model)));
}
let arr = v.get("nodes").and_then(Value::as_array)?;
let nodes: Vec<facett_pipeline::PipelineNode> = arr
.iter()
.filter_map(|n| {
// A stage with no name is not a stage — an unnamed box on a DAG tells a
// reader nothing, so it is dropped rather than drawn blank.
let name = n
.get("name")
.or_else(|| n.get("label"))
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())?;
let status =
pipeline_status(n.get("status").and_then(Value::as_str).unwrap_or("idle"));
let mut node = facett_pipeline::PipelineNode::new(name, status);
if let Some(d) = n.get("detail").and_then(Value::as_str) {
node = node.with_detail(d);
}
if let Some(rows) = n.get("rows").and_then(Value::as_u64) {
node = node.with_rows(rows);
}
if let Some(e) = n.get("error").and_then(Value::as_str).filter(|s| !s.is_empty()) {
node = node.with_error(e);
}
if let Some(t) = n.get("throughput_rows_s").and_then(Value::as_f64) {
node = node.with_throughput_rows_s(t as f32);
}
if let Some(l) = n.get("lag_s").and_then(Value::as_f64) {
node = node.with_lag_s(l as f32);
}
Some(node)
})
.collect();
if nodes.is_empty() {
return None;
}
let edges = edges_of(v, nodes.len());
Some(Box::new(facett_pipeline::PipelineView::from_nodes(
title_of(v, "Pipeline"),
nodes,
edges,
)))
}
/// **`jobview`** — [`facett_jobview::JobList`].
///
/// ```json
/// { "title": "jobs",
/// "jobs": [{"id": "42", "kind": "build", "target": "korp", "status": "running",
/// "detail": "…", "elapsed_ms": 8200, "started_micros": 1699…,
/// "progress": 0.4, "parent_id": "41",
/// "meta": {"branch": "main"}}] }
/// ```
/// `progress` may also be `"indeterminate"` — a job that is working but cannot say
/// how far along it is, which the component draws differently from 0 %.
#[must_use]
pub fn jobview(v: &Value) -> Option<Box<dyn Facet>> {
let arr = v.get("jobs").and_then(Value::as_array)?;
let jobs: Vec<facett_jobview::JobEntry> = arr
.iter()
.filter_map(|j| {
let id = j.get("id").and_then(Value::as_str).filter(|s| !s.trim().is_empty())?;
let kind = j.get("kind").and_then(Value::as_str).unwrap_or("job");
// The component owns this parsing; unknown words fall to `Queued` there.
let status = facett_jobview::JobStatus::parse(
j.get("status").and_then(Value::as_str).unwrap_or(""),
);
let mut e = facett_jobview::JobEntry::new(id, kind, status);
if let Some(t) = j.get("target").and_then(Value::as_str) {
e.target = t.to_string();
}
if let Some(d) = j.get("detail").and_then(Value::as_str) {
e.detail = d.to_string();
}
if let Some(ms) = j.get("elapsed_ms").and_then(Value::as_i64) {
e.elapsed_ms = Some(ms);
}
if let Some(us) = j.get("started_micros").and_then(Value::as_i64) {
e.started_micros = us;
}
if let Some(p) = j.get("parent_id").and_then(Value::as_str) {
e.parent_id = Some(p.to_string());
}
if let Some(m) = j.get("meta").and_then(Value::as_object) {
e.meta = m
.iter()
.map(|(k, val)| {
let s = match val {
Value::String(s) => s.clone(),
other => other.to_string(),
};
(k.clone(), s)
})
.collect();
}
e = match j.get("progress") {
Some(Value::String(s)) if s.eq_ignore_ascii_case("indeterminate") => {
e.with_indeterminate_progress()
}
Some(p) => match p.as_f64() {
Some(f) => e.with_progress(f as f32),
None => e,
},
None => e,
};
Some(e)
})
.collect();
if jobs.is_empty() {
return None;
}
let mut view = facett_jobview::JobList::new(title_of(v, "Jobs"));
view.set_jobs(jobs);
Some(Box::new(view))
}
/// **`syschart`** — [`facett_syschart::SystemChart`].
///
/// ```json
/// { "title": "nordisk",
/// "nodes": [{"id": "korp", "label": "korp", "badge": 3, "detail": "…",
/// "pos": [0.2, 0.5]}],
/// "edges": [{"a": "korp", "b": "holger"}] }
/// ```
/// Node colour is **not** in the payload: it comes from
/// [`facett_core::hash_color`] of the node's label, so the same service is the same
/// colour everywhere in the suite without any host agreeing on a palette. Omitting
/// `pos` lays the nodes out on a ring — a caller who knows the topology can place
/// them, one who does not still gets a readable chart.
#[must_use]
pub fn syschart(v: &Value) -> Option<Box<dyn Facet>> {
let arr = v.get("nodes").and_then(Value::as_array)?;
let n = arr.len();
let nodes: Vec<facett_syschart::SysNode> = arr
.iter()
.enumerate()
.filter_map(|(i, node)| {
let id = node.get("id").and_then(Value::as_str).filter(|s| !s.trim().is_empty())?;
let label = node.get("label").and_then(Value::as_str).unwrap_or(id);
let pos = node
.get("pos")
.and_then(Value::as_array)
.and_then(|p| Some((p.first()?.as_f64()? as f32, p.get(1)?.as_f64()? as f32)))
.unwrap_or_else(|| ring_pos(i, n));
let mut sn = facett_syschart::SysNode::new(
id,
label,
facett_core::hash_color(label),
(pos.0.clamp(0.0, 1.0), pos.1.clamp(0.0, 1.0)),
);
if let Some(b) = node.get("badge").and_then(Value::as_u64) {
sn = sn.badge(b);
}
if let Some(d) = node.get("detail").and_then(Value::as_str) {
sn = sn.detail(d);
}
Some(sn)
})
.collect();
if nodes.is_empty() {
return None;
}
let edges: Vec<facett_syschart::SysEdge> = v
.get("edges")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|e| {
let a = e.get("a").or_else(|| e.get("src"))?.as_str()?;
let b = e.get("b").or_else(|| e.get("dst"))?.as_str()?;
(a != b && nodes.iter().any(|n| n.id == a) && nodes.iter().any(|n| n.id == b))
.then(|| facett_syschart::SysEdge::new(a, b))
})
.collect()
})
.unwrap_or_default();
Some(Box::new(facett_syschart::SystemChart::new(title_of(v, "System"), nodes, edges)))
}
/// Evenly spaced on a circle inset from the canvas edge — the fallback layout when a
/// caller supplies no `pos`.
fn ring_pos(i: usize, n: usize) -> (f32, f32) {
if n <= 1 {
return (0.5, 0.5);
}
let a = std::f32::consts::TAU * (i as f32) / (n as f32);
(0.5 + 0.35 * a.cos(), 0.5 + 0.35 * a.sin())
}
/// **`kanban`** — [`facett_kanban::Board`].
///
/// ```json
/// { "title": "sprint",
/// "columns": [{"id": "todo", "title": "To do", "wip_limit": 3}],
/// "cards": [{"id": "1", "title": "wrap osm3d", "column": "todo",
/// "assignee": "alice", "priority": "P1", "tags": ["wasm"],
/// "swimlane": "facett"}] }
/// ```
/// A card naming a column that does not exist is dropped: the board would otherwise
/// silently lose it at render time, and a card you cannot see is worse than one that
/// was reported missing.
#[must_use]
pub fn kanban(v: &Value) -> Option<Box<dyn Facet>> {
let cols_v = v.get("columns").and_then(Value::as_array)?;
let columns: Vec<facett_kanban::Column> = cols_v
.iter()
.filter_map(|c| {
// A bare string is a column whose id and title are the same word.
if let Some(s) = c.as_str() {
return (!s.trim().is_empty()).then(|| facett_kanban::Column::new(s, s));
}
let id = c.get("id").and_then(Value::as_str).filter(|s| !s.trim().is_empty())?;
let title = c.get("title").and_then(Value::as_str).unwrap_or(id);
let col = facett_kanban::Column::new(id, title);
Some(match c.get("wip_limit").and_then(Value::as_u64) {
Some(n) => col.wip(n as usize),
None => col,
})
})
.collect();
if columns.is_empty() {
return None;
}
let cards: Vec<facett_kanban::Card> = v
.get("cards")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|c| {
let id = c.get("id").and_then(Value::as_str)?;
let title = c.get("title").and_then(Value::as_str).unwrap_or(id);
let column = c.get("column").and_then(Value::as_str)?;
if !columns.iter().any(|col| col.id == column) {
return None;
}
let mut card = facett_kanban::Card::new(id, title, column);
card.assignee =
c.get("assignee").and_then(Value::as_str).map(ToString::to_string);
card.swimlane =
c.get("swimlane").and_then(Value::as_str).map(ToString::to_string);
card.priority =
priority(c.get("priority").and_then(Value::as_str).unwrap_or(""));
card.tags = c
.get("tags")
.and_then(Value::as_array)
.map(|t| {
t.iter().filter_map(Value::as_str).map(ToString::to_string).collect()
})
.unwrap_or_default();
Some(card)
})
.collect()
})
.unwrap_or_default();
let mut board = facett_kanban::Board::new(title_of(v, "Board"), columns);
board.set_cards(cards);
if let Some(lanes) = v.get("swimlanes").and_then(Value::as_array) {
let lanes: Vec<String> =
lanes.iter().filter_map(Value::as_str).map(ToString::to_string).collect();
if !lanes.is_empty() {
board = board.with_swimlanes(lanes);
}
}
Some(Box::new(board))
}
/// `P1`..`P5` (also `1`..`5`, `high`/`low`). Unknown → the component's default `P4`.
fn priority(s: &str) -> facett_kanban::Priority {
use facett_kanban::Priority;
match s.trim().to_ascii_lowercase().as_str() {
"p1" | "1" | "critical" | "urgent" => Priority::P1,
"p2" | "2" | "high" => Priority::P2,
"p3" | "3" | "medium" | "normal" => Priority::P3,
"p4" | "4" | "low" => Priority::P4,
"p5" | "5" | "lowest" | "trivial" => Priority::P5,
_ => Priority::P4,
}
}
/// **`gantt`** — [`facett_gantt::Gantt`].
///
/// ```json
/// { "title": "release",
/// "today": "2026-07-31",
/// "tasks": [{"id": "a", "label": "wrap", "start": "2026-07-01", "end": "2026-07-10",
/// "progress": 0.5, "deps": ["b"], "milestone": false}] }
/// ```
/// Dates are ISO `YYYY-MM-DD`. A task whose dates do not parse is dropped — a gantt
/// bar at a guessed date is a lie about a schedule, and the component's critical-path
/// walk would compound it.
#[must_use]
pub fn gantt(v: &Value) -> Option<Box<dyn Facet>> {
let arr = v.get("tasks").and_then(Value::as_array)?;
let tasks: Vec<facett_gantt::Task> = arr
.iter()
.filter_map(|t| {
let id = t.get("id").and_then(Value::as_str).filter(|s| !s.trim().is_empty())?;
let label = t.get("label").and_then(Value::as_str).unwrap_or(id);
let start = date(t.get("start"))?;
let end = date(t.get("end")).unwrap_or(start);
// An end before the start is a data error, not a zero-length bar to draw.
if end < start {
return None;
}
let mut task = facett_gantt::Task::new(id, label, start, end);
if let Some(p) = t.get("progress").and_then(Value::as_f64) {
task = task.progress(p as f32);
}
for d in t.get("deps").and_then(Value::as_array).into_iter().flatten() {
if let Some(d) = d.as_str() {
task = task.dep(d);
}
}
task.milestone = t.get("milestone").and_then(Value::as_bool).unwrap_or(false);
Some(task)
})
.collect();
if tasks.is_empty() {
return None;
}
let g = facett_gantt::Gantt::new(title_of(v, "Plan"), tasks);
Some(Box::new(match date(v.get("today")) {
Some(d) => g.with_today(d),
None => g,
}))
}
/// ISO `YYYY-MM-DD` → [`chrono::NaiveDate`].
fn date(v: Option<&Value>) -> Option<chrono::NaiveDate> {
let s = v?.as_str()?;
chrono::NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d").ok()
}
// ─────────────────────────────────────────────────────────────────────────────
// THE PipelineModel WIRE — row 5, the Spark/SDP contract. Row 6: it lives upstream.
// ─────────────────────────────────────────────────────────────────────────────
//
// `facett-pipeline` already owns the seam: `PipelineModel` is the normalized,
// engine-agnostic shape, `PipelineView::from_model` is the ONE viewer, and knut's
// `to_pipeline_model` is the Spark/SDP adapter that produces it. Row 5 gave the model
// a WIRE so it could reach this bundle; the codec was written here, in both
// directions beside each other, because "the wrapper lowers JSON into components".
//
// Row 6 (2026-09-05) moved that codec to `facett_pipeline::wire`, and these two names
// are now re-exports of it. The reason is the other end of the wire: knut's
// `knutpipe web` server ENCODES it, and the only way for knut to reuse the encoder
// from here was a dependency on this crate — an unpublished cdylib wrapper that would
// have dragged every wrapped component (map3d on wgpu, graphview on vello, arrow) into
// a server whose job is to hand a browser some bytes. `facett-pipeline` owns the model,
// is knut's dependency already, and is published. The codec is still ONE codec; the
// round-trip property tests in `tests/pipeline_model_wire.rs` drive it through these
// names exactly as before.
//
// The wire tolerates the human status spellings `pipeline_status` above accepts, so
// a hand-written `{stages: [...]}` payload with `"status": "ok"` renders as Done.
pub use facett_pipeline::wire::{
from_json as pipeline_model_from_json, to_json as pipeline_model_to_json,
};
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn pipeline_builds_stages_and_edges() {
let v = json!({
"title": "ingest",
"nodes": [
{ "name": "scan", "status": "done", "rows": 1200 },
{ "name": "embed", "status": "running", "throughput_rows_s": 840.0 },
{ "name": "index", "status": "queued" }
],
"edges": [[0, 1], [1, 2]]
});
let f = pipeline(&v).expect("builds");
assert_eq!(f.title(), "ingest");
assert!(f.state_json().is_object());
}
/// An unknown status word must NOT become `Done` — a flattering default would
/// report a broken stage as a finished one.
#[test]
fn unknown_pipeline_status_is_unknown_not_done() {
use facett_pipeline::Status;
assert!(matches!(pipeline_status("banana"), Status::Unknown(_)));
assert!(matches!(pipeline_status("SUCCESS"), Status::Done));
assert!(matches!(pipeline_status("crashed"), Status::Failed));
assert!(matches!(pipeline_status(""), Status::Idle));
}
#[test]
fn pipeline_drops_unnamed_stages_and_bogus_edges() {
let v = json!({
"nodes": [{ "name": "a" }, { "name": " " }, { "status": "done" }],
"edges": [[0, 9], [0, 0], [0, 1]]
});
// only "a" survives, so every edge is out of range or self-referential
assert!(pipeline(&v).is_some());
assert!(edges_of(&v, 1).is_empty());
assert_eq!(edges_of(&json!({"edges": [[0, 1]]}), 2), vec![(0, 1)]);
}
#[test]
fn jobview_builds_from_scheduler_rows() {
let v = json!({
"title": "dwarves jobs",
"jobs": [
{ "id": "42", "kind": "build", "target": "korp", "status": "running",
"elapsed_ms": 8200, "progress": 0.4, "meta": { "branch": "main" } },
{ "id": "43", "kind": "test", "status": "queued", "progress": "indeterminate" }
]
});
let f = jobview(&v).expect("builds");
assert_eq!(f.title(), "dwarves jobs");
assert!(f.state_json().is_object());
}
#[test]
fn syschart_colours_come_from_the_label_not_the_payload() {
let v = json!({
"nodes": [{ "id": "korp", "label": "korp" }, { "id": "holger", "label": "holger" }],
"edges": [{ "a": "korp", "b": "holger" }]
});
assert!(syschart(&v).is_some());
// the SAME label is the same colour, with no palette in the payload
assert_eq!(facett_core::hash_color("korp"), facett_core::hash_color("korp"));
assert_ne!(facett_core::hash_color("korp"), facett_core::hash_color("holger"));
}
#[test]
fn syschart_lays_nodes_out_when_the_caller_gives_no_positions() {
let (a, b) = (ring_pos(0, 4), ring_pos(2, 4));
assert_ne!(a, b, "different nodes land in different places");
for (x, y) in [a, b, ring_pos(0, 1)] {
assert!((0.0..=1.0).contains(&x) && (0.0..=1.0).contains(&y), "inside the canvas");
}
}
#[test]
fn kanban_drops_cards_pointing_at_a_column_that_does_not_exist() {
let v = json!({
"columns": [{ "id": "todo", "title": "To do", "wip_limit": 2 }, "doing"],
"cards": [
{ "id": "1", "title": "wrap osm3d", "column": "todo", "priority": "P1" },
{ "id": "2", "title": "orphan", "column": "nowhere" }
]
});
let f = kanban(&v).expect("builds");
let st = f.state_json();
// The board publishes card IDs per column (not titles), so assert on those.
assert_eq!(st["card_count"].as_u64(), Some(1), "only the placeable card survives: {st}");
let todo = &st["columns"][0];
assert_eq!(todo["id"].as_str(), Some("todo"));
assert_eq!(todo["cards"][0].as_str(), Some("1"), "card 1 landed in `todo`: {st}");
assert!(
!st.to_string().contains("\"2\""),
"a card in a nonexistent column must not appear anywhere: {st}"
);
// The bare-string column form works, and carries no WIP limit.
assert_eq!(st["columns"][1]["id"].as_str(), Some("doing"));
assert!(st["columns"][1]["wip_limit"].is_null());
}
#[test]
fn priorities_parse_forgivingly_but_never_wrongly() {
use facett_kanban::Priority;
assert!(matches!(priority("P1"), Priority::P1));
assert!(matches!(priority("high"), Priority::P2));
assert!(matches!(priority("3"), Priority::P3));
assert!(matches!(priority("nonsense"), Priority::P4), "unknown → the component default");
}
#[test]
fn gantt_builds_from_iso_dates() {
let v = json!({
"title": "release",
"today": "2026-07-31",
"tasks": [
{ "id": "a", "label": "wrap", "start": "2026-07-01", "end": "2026-07-10",
"progress": 0.5 },
{ "id": "b", "label": "ship", "start": "2026-07-11", "end": "2026-07-12",
"deps": ["a"], "milestone": true }
]
});
let f = gantt(&v).expect("builds");
assert_eq!(f.title(), "release");
assert!(f.state_json().is_object() || f.state_json().is_array());
}
/// A bar drawn at a guessed date is a lie about a schedule.
#[test]
fn gantt_drops_tasks_whose_dates_are_unusable() {
assert!(gantt(&json!({ "tasks": [{ "id": "a", "start": "not-a-date" }] })).is_none());
assert!(gantt(&json!({ "tasks": [{ "id": "a", "start": "2026-07-10",
"end": "2026-07-01" }] }))
.is_none(), "end before start");
// a missing end is the start (a one-day task), which IS renderable
assert!(gantt(&json!({ "tasks": [{ "id": "a", "start": "2026-07-10" }] })).is_some());
}
#[test]
fn empty_payloads_yield_none() {
for f in [
pipeline as fn(&Value) -> Option<Box<dyn Facet>>,
jobview,
syschart,
kanban,
gantt,
] {
assert!(f(&json!({})).is_none());
}
assert!(pipeline(&json!({ "nodes": [] })).is_none());
assert!(jobview(&json!({ "jobs": [] })).is_none());
assert!(syschart(&json!({ "nodes": [] })).is_none());
assert!(kanban(&json!({ "columns": [] })).is_none());
assert!(gantt(&json!({ "tasks": [] })).is_none());
}
}