//! **Public-API integration tests** — funnel-core is the shared core the nornir
//! funnel/Plan-DAG viz, the `funnel` CLI/RPC, and `ordning-funnel` all bind to, so
//! its public surface is a hard contract. These tests exercise the crate the way a
//! *consumer* does: only through the flat `funnel_core::*` re-exports (never the
//! module paths), building a realistic funnel from events and asserting every
//! projection + the stable wire tokens the UI keys on.
//!
//! Unit tests live next to each module; this file guards the *seam* — that the
//! re-exports resolve, compose, and stay deterministic across the whole pipeline.
use chrono::{Duration, Utc};
use funnel_core::{
board, board_to_json, cards_from_funnel, draw_wire, history, query_cards, reject_wire,
simulate_flow, topo_ready, CardType, ComponentDag, ComponentEdge, ComponentEdgeKind,
ComponentGraph, ComponentId, ComponentNode, DagGrowth, DagStatus, EdgeKind, Event, Funnel,
FunnelMode, HistoryStatus, IdeaId, ItemKind, Lane, NodeId, NodeStatus, PlanDag, PlanDagEdge,
PlanId, PlanStatus, Sort, TriageDecision, UnmappedReason, WireOrigin, WireStatus, Wiring,
WiringReport,
};
/// Build a realistic funnel entirely through the public `Event` + `apply` seam:
/// one accepted idea decomposed into a 3-node chain (a→b→c) in an Active plan with
/// `a` Done, plus a dropped idea and an untriaged error report. This is the exact
/// shape `nornir funnel prompt` produces, assembled through the public API only.
fn realistic_funnel() -> Funnel {
let mut f = Funnel::default();
let base = Utc::now() - Duration::hours(5);
let at = |h: i64| base + Duration::hours(h);
let idea = IdeaId::seq(1);
let plan = PlanId::seq(1);
// A feature idea, triaged accept, decomposed into a plan.
f.apply(&Event::IdeaSubmitted {
id: idea.clone(),
source: "human:alice".into(),
text: "Add a #board view".into(),
refs: vec!["https://example.com/n/1".into()],
item_kind: ItemKind::Idea,
ts: at(0),
})
.unwrap();
f.apply(&Event::IdeaTriaged {
idea_id: idea.clone(),
decision: TriageDecision::Accept,
why: Some("worth it".into()),
ts: at(1),
})
.unwrap();
f.apply(&Event::PlanCreated {
id: plan.clone(),
idea_id: idea.clone(),
summary: "decompose → 3 subtasks".into(),
planner: "decompose".into(),
ts: at(1),
})
.unwrap();
f.apply(&Event::PlanStatusChanged {
plan_id: plan.clone(),
status: PlanStatus::Active,
why: None,
ts: at(1),
})
.unwrap();
for (i, (title, kind)) in [
("implement foo", "code:write"),
("fix the bar bug", "code:fix"),
("test baz", "test:write"),
]
.iter()
.enumerate()
{
f.apply(&Event::NodeAdded {
plan_id: plan.clone(),
node_id: NodeId::seq(i as u64 + 1),
kind: (*kind).into(),
params: Default::default(),
targets: vec![format!("src/mod{i}.rs")],
prompt_excerpt: Some((*title).into()),
ts: at(2),
})
.unwrap();
}
f.apply(&Event::EdgeAdded {
plan_id: plan.clone(),
from_node: NodeId::seq(1),
to_node: NodeId::seq(2),
ts: at(2),
})
.unwrap();
f.apply(&Event::EdgeAdded {
plan_id: plan.clone(),
from_node: NodeId::seq(2),
to_node: NodeId::seq(3),
ts: at(2),
})
.unwrap();
// Finish the first task so `b` becomes ready.
f.apply(&Event::NodeStatusChanged {
plan_id: plan.clone(),
node_id: NodeId::seq(1),
status: NodeStatus::Done,
why: None,
ts: at(3),
})
.unwrap();
// A dropped idea + an untriaged error report (intake variety).
f.apply(&Event::IdeaSubmitted {
id: IdeaId::seq(2),
source: "human:alice".into(),
text: "rewrite in COBOL".into(),
refs: vec![],
item_kind: ItemKind::Idea,
ts: at(0),
})
.unwrap();
f.apply(&Event::IdeaTriaged {
idea_id: IdeaId::seq(2),
decision: TriageDecision::Drop,
why: None,
ts: at(1),
})
.unwrap();
f.apply(&Event::IdeaSubmitted {
id: IdeaId::seq(3),
source: "agent:autonom".into(),
text: "panic: index out of bounds in foo.rs:42".into(),
refs: vec![],
item_kind: ItemKind::Error,
ts: at(4),
})
.unwrap();
f
}
/// The whole projection pipeline is reachable through the flat re-exports and
/// composes end-to-end: history → cards → board → json, over one funnel.
#[test]
fn full_projection_pipeline_composes_via_reexports() {
let f = realistic_funnel();
// history(): 3 intake items, newest-first, derived status.
let hist = history(&f, None, None);
assert_eq!(hist.len(), 3, "two ideas + one error");
// The error (submitted last) is newest.
assert_eq!(hist[0].id, "i-003");
assert_eq!(hist[0].item_kind, ItemKind::Error);
assert_eq!(hist[0].status, HistoryStatus::Untriaged);
// The planned feature idea reads as Planned with its plan linked.
let planned = hist.iter().find(|h| h.id == "i-001").unwrap();
assert_eq!(planned.status, HistoryStatus::Planned);
assert_eq!(planned.plan_ids, vec!["p-001".to_string()]);
// cards_from_funnel(): 3 intake cards + 3 task-node cards.
let cards = cards_from_funnel(&f);
assert_eq!(cards.len(), 6, "3 ideas/errors + 3 task nodes");
assert!(cards.windows(2).all(|w| w[0].id <= w[1].id), "sorted by id");
// board(): five fixed lanes; the board total matches the card count.
let b = board(&cards);
assert_eq!(b.total, 6);
assert_eq!(
b.columns.iter().map(|c| c.lane).collect::<Vec<_>>(),
Lane::board_order(),
"fixed left→right lane order"
);
// board_to_json(): deterministic + shaped for the headless/viz surface.
let j1 = board_to_json(&board(&cards_from_funnel(&f)));
let j2 = board_to_json(&board(&cards_from_funnel(&f)));
assert_eq!(
j1.to_string(),
j2.to_string(),
"bit-identical re-projection"
);
assert_eq!(j1["total"], 6);
assert_eq!(j1["columns"].as_array().unwrap().len(), 5);
}
/// The smart-query operators the funnel UI's search box exposes all resolve
/// through `query_cards` and AND/OR compose as documented.
#[test]
fn smart_query_operators_compose() {
let f = realistic_funnel();
let cards = cards_from_funnel(&f);
let ids = |q: &str| {
query_cards(&cards, q)
.into_iter()
.map(|c| c.id)
.collect::<Vec<_>>()
};
// type:bug → the error card + the code:fix task node (both Bug).
let bugs = ids("type:bug");
assert!(bugs.contains(&"i-003".to_string()), "error report is a bug");
assert!(
bugs.contains(&"n-002".to_string()),
"code:fix task is a bug"
);
// A free-text term narrows across id/title/body/reporter.
assert_eq!(ids("cobol"), vec!["i-002".to_string()]);
// has:plan selects the planned idea + its task-node cards (each carries its
// plan id); no:plan is the complement (the dropped idea + the untriaged error).
let mut planned = ids("has:plan");
planned.sort();
assert_eq!(planned, ["i-001", "n-001", "n-002", "n-003"]);
let mut unplanned = ids("no:plan");
unplanned.sort();
assert_eq!(unplanned, ["i-002", "i-003"]);
// Sort is honoured with a stable id tie-break.
let by_id = ids("sort:id");
let mut sorted = by_id.clone();
sorted.sort();
assert_eq!(by_id, sorted, "sort:id is ascending + stable");
// An all-unknown query never errors — it degrades to free text and narrows.
assert!(query_cards(&cards, "zzz-nomatch-zzz").is_empty());
}
/// `topo_ready` + `simulate_flow` — the "what's next?" and flow-simulation seam
/// agents drive — work through the public API and respect dependency order.
#[test]
fn topo_ready_and_simulate_flow_via_reexports() {
let mut f = realistic_funnel();
// a is Done, so b (its only dependent) is the sole ready node; c waits on b.
let ready = topo_ready(&mut f);
assert_eq!(ready.len(), 1, "only b is actionable");
assert_eq!(ready[0].node_id.as_str(), "n-002");
assert_eq!(ready[0].plan_id.as_str(), "p-001");
// Drive the whole DAG to completion: b then c ⇒ 2 more waves, all Done.
let sim = simulate_flow(&mut f, 10);
assert_eq!(sim.total, 3, "three task nodes");
assert_eq!(sim.done, 3, "every node reaches Done");
assert_eq!(sim.failed, 0);
assert_eq!(sim.remaining, 0);
assert_eq!(sim.waves, 2, "b then c — a was already Done before the sim");
}
/// The Plan-DAG the viz panel renders assembles through `PlanDag::from_funnel`,
/// exposes counts + a topo order, and is deterministic.
#[test]
fn plan_dag_projection_via_reexports() {
let f = realistic_funnel();
let dag = PlanDag::from_funnel(&f, None);
// Whole funnel: 3 intake cards + 3 task nodes = 6 graph nodes.
assert_eq!(dag.node_count(), 6);
// 3 mother→child decomposition edges + 2 dependency edges.
assert_eq!(dag.mother_edges(), 3);
assert_eq!(dag.dep_edges(), 2);
let order = dag.topo_order().expect("acyclic");
let pos = |id: &str| order.iter().position(|x| x == id).unwrap();
assert!(pos("i-001") < pos("n-001"), "mother before its children");
assert!(pos("n-001") < pos("n-002"), "a before b");
assert!(pos("n-002") < pos("n-003"), "b before c");
// Scoping to the plan keeps its mother + children; a missing plan is empty.
let scoped = PlanDag::from_funnel(&f, Some("p-001"));
assert_eq!(scoped.node_count(), 4, "mother + 3 tasks");
assert_eq!(PlanDag::from_funnel(&f, Some("p-none")).node_count(), 0);
// state_json is deterministic across re-projection.
let a = PlanDag::from_funnel(&f, None).state_json().to_string();
let b = PlanDag::from_funnel(&f, None).state_json().to_string();
assert_eq!(a, b);
}
/// The lower-case wire tokens the UI / RPC / robot tests key on are STABLE — a
/// rename here silently breaks every downstream consumer, so lock them.
#[test]
fn wire_tokens_are_stable() {
// Lanes (board columns).
assert_eq!(
Lane::board_order().map(|l| l.as_str()),
["inbox", "accepted", "proposed", "planned", "dropped"]
);
// Card types.
assert_eq!(CardType::Feature.as_str(), "feature");
assert_eq!(CardType::Bug.as_str(), "bug");
assert_eq!(CardType::Test.as_str(), "test");
// Item kinds (funnel intake).
assert_eq!(ItemKind::Idea.as_str(), "idea");
assert_eq!(ItemKind::Error.as_str(), "error");
assert_eq!(ItemKind::Test.as_str(), "test");
// History statuses.
assert_eq!(HistoryStatus::Untriaged.as_str(), "untriaged");
assert_eq!(HistoryStatus::Planned.as_str(), "planned");
// Sort default is Newest (the board's default order).
assert_eq!(Sort::default(), Sort::Newest);
// Lane openness: the three actionable lanes are "open".
assert!(Lane::Inbox.is_open() && Lane::Accepted.is_open() && Lane::Proposed.is_open());
assert!(!Lane::Planned.is_open() && !Lane::Dropped.is_open());
}
/// The **Plan-DAG** viz keys node border colour on `DagStatus` and edge lineage on
/// `EdgeKind`. Both surface on TWO wire paths that must never drift: `state_json`
/// emits `.as_str()` (`"status"` / `"kind"`), while a serde-serialized `PlanDagEdge`
/// or `DagStatus` emits the `#[serde(rename_all = "snake_case")]` token. A rename on
/// either path silently breaks the renderer, so lock the literals AND assert the two
/// paths agree — the seam the other `wire_tokens_are_stable` test omits.
#[test]
fn dag_wire_tokens_are_stable() {
// Node run-status tokens (drive the node border colour).
assert_eq!(DagStatus::Idle.as_str(), "idle");
assert_eq!(DagStatus::Running.as_str(), "running");
assert_eq!(DagStatus::Done.as_str(), "done");
assert_eq!(DagStatus::Failed.as_str(), "failed");
// Edge lineage tokens (mother-decomposition vs task dependency).
assert_eq!(EdgeKind::Mother.as_str(), "mother");
assert_eq!(EdgeKind::Dep.as_str(), "dep");
// The `.as_str()` path and the serde path emit the SAME token for every variant —
// a rename that touches one but not the other makes the viz read one token from
// `state_json` and a different one from a serialized struct. RED if they drift.
for s in [
DagStatus::Idle,
DagStatus::Running,
DagStatus::Done,
DagStatus::Failed,
] {
assert_eq!(
serde_json::to_value(s).unwrap(),
serde_json::json!(s.as_str()),
"DagStatus {s:?}: serde ≠ as_str"
);
}
for k in [EdgeKind::Mother, EdgeKind::Dep] {
assert_eq!(
serde_json::to_value(k).unwrap(),
serde_json::json!(k.as_str()),
"EdgeKind {k:?}: serde ≠ as_str"
);
}
// And the tokens actually surface on the viz-facing `state_json`: the scoped plan
// (mother → a→b→c chain, a Done / b Ready / c Pending) carries BOTH edge kinds and
// its node statuses are all drawn from the locked vocabulary — the renderer never
// meets a token this test does not pin.
let dag = PlanDag::from_funnel(&realistic_funnel(), Some("p-001"));
let j = dag.state_json();
let edge_kinds = j["edges"]
.as_array()
.unwrap()
.iter()
.map(|e| e["kind"].as_str().unwrap().to_string())
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
edge_kinds,
["dep", "mother"].iter().map(|s| s.to_string()).collect(),
"both lineages present, only locked tokens"
);
let allowed: std::collections::BTreeSet<&str> =
["idle", "running", "done", "failed"].into_iter().collect();
for n in j["nodes"].as_array().unwrap() {
let st = n["status"].as_str().unwrap();
assert!(
allowed.contains(st),
"node {} status {st:?} is a locked token",
n["id"]
);
}
// The specific mapping the renderer relies on: a Done task → "done", a Pending task
// → "idle". Pins the stored-NodeStatus → token wiring, not just the vocabulary.
let status_of = |v: &serde_json::Value, id: &str| {
v["nodes"]
.as_array()
.unwrap()
.iter()
.find(|n| n["id"] == id)
.unwrap()["status"]
.as_str()
.unwrap()
.to_string()
};
assert_eq!(
status_of(&j, "n-001"),
"done",
"a Done task node reads done"
);
assert_eq!(
status_of(&j, "n-002"),
"idle",
"a Pending task node reads idle"
);
// Drive n-002 to InProgress and reproject → its token flips to "running". A rename
// of the Running token (or a broken NodeStatus→DagStatus mapping) fails here.
let mut f = realistic_funnel();
f.apply(&Event::NodeStatusChanged {
plan_id: PlanId::seq(1),
node_id: NodeId::seq(2),
status: NodeStatus::InProgress,
why: None,
ts: Utc::now(),
})
.unwrap();
let j2 = PlanDag::from_funnel(&f, Some("p-001")).state_json();
assert_eq!(
status_of(&j2, "n-002"),
"running",
"an InProgress task node reads running"
);
}
/// `funnel_core::Idea` is constructed field-by-field by consumers (ordning-funnel
/// builds one directly, nornir replays them), so its public fields + the id
/// newtypes are a hard contract. Lock the shape.
#[test]
fn idea_public_field_contract() {
let idea = funnel_core::Idea {
id: IdeaId::new("i-009"),
source: "nornir-funnel".into(),
text: "Extract the time axis".into(),
refs: vec!["https://example.com/n/9".into()],
item_kind: ItemKind::Error,
submitted_at: Utc::now(),
triage: None,
mode: None,
};
assert_eq!(idea.id.as_str(), "i-009");
assert_eq!(idea.item_kind, ItemKind::Error);
assert!(idea.triage.is_none() && idea.mode.is_none());
// The id newtypes carry the stable `<prefix>-NNN` shape consumers parse.
assert_eq!(IdeaId::seq(1).as_str(), "i-001");
assert_eq!(PlanId::seq(7).as_str(), "p-007");
assert_eq!(NodeId::seq(42).as_str(), "n-042");
}
/// The `FunnelMode` vocabulary is the typed reading of the free-form `Idea.mode`
/// token: an `IdeaModeSet` event drives it, `Idea::mode_kind` parses it back, and
/// the `child_lane` mode gate maps each mode to the lane its child tasks land in.
/// This is the seam whose *absence* previously broke `ordning-funnel`.
#[test]
fn funnel_mode_vocabulary_via_reexports() {
// Wire tokens + the mode gate are stable public contract.
assert_eq!(FunnelMode::Auto.as_str(), "auto");
assert_eq!(FunnelMode::Manual.as_str(), "manual");
assert_eq!(FunnelMode::default(), FunnelMode::Auto);
assert_eq!(FunnelMode::Auto.child_lane(), Lane::Planned);
assert_eq!(FunnelMode::Manual.child_lane(), Lane::Proposed);
assert_eq!(FunnelMode::parse("MANUAL"), Some(FunnelMode::Manual));
assert_eq!(
FunnelMode::parse("nonsense"),
None,
"unknown degrades, never errors"
);
// An `IdeaModeSet` event sets the field; `mode_kind` reads it back typed.
let mut f = Funnel::default();
let now = Utc::now();
let idea = IdeaId::seq(1);
f.apply(&Event::IdeaSubmitted {
id: idea.clone(),
source: "human:alice".into(),
text: "build the planner".into(),
refs: vec![],
item_kind: ItemKind::Idea,
ts: now,
})
.unwrap();
// Before any IdeaModeSet: no mode (migration-safe default).
assert_eq!(f.ideas[&idea].mode, None);
assert_eq!(f.ideas[&idea].mode_kind(), None);
f.apply(&Event::IdeaModeSet {
idea_id: idea.clone(),
mode: "manual".into(),
ts: now,
})
.unwrap();
assert_eq!(
f.ideas[&idea].mode.as_deref(),
Some("manual"),
"raw field preserved"
);
assert_eq!(
f.ideas[&idea].mode_kind(),
Some(FunnelMode::Manual),
"typed reading"
);
// A future/unknown token is stored verbatim but reads back as None (the
// field stays free-form so no schema bump is ever forced).
f.apply(&Event::IdeaModeSet {
idea_id: idea.clone(),
mode: "turbo".into(),
ts: now,
})
.unwrap();
assert_eq!(f.ideas[&idea].mode.as_deref(), Some("turbo"));
assert_eq!(f.ideas[&idea].mode_kind(), None);
}
/// The Plan-DAG's critical-path + depth vocabulary is reachable through the public
/// API and identifies the schedule-defining longest chain.
#[test]
fn plan_dag_critical_path_via_reexports() {
let f = realistic_funnel();
let dag = PlanDag::from_funnel(&f, Some("p-001"));
// The plan is a chain mother → a → b → c, so the critical path is the whole
// lineage and its length is the wave count `simulate_flow` needs (3).
assert_eq!(
dag.critical_path(),
vec!["i-001", "n-001", "n-002", "n-003"]
);
assert_eq!(dag.critical_path_len(), 3);
// The Dep-only twin drops the mother→child hop: pure task→task chain, one wave
// shorter than the mixed critical path.
assert_eq!(dag.critical_path_deps(), vec!["n-001", "n-002", "n-003"]);
assert_eq!(dag.critical_path_deps_len(), 2);
assert!(
dag.critical_path_deps_len() < dag.critical_path_len(),
"dep depth < mixed depth"
);
// Depths are a valid layout layering: every edge increases depth.
let d = dag.depths();
assert_eq!(d["i-001"], 0);
assert_eq!(d["n-003"], 3);
for e in &dag.edges {
assert!(
d[&e.from] < d[&e.to],
"edge {}→{} climbs a layer",
e.from,
e.to
);
}
// The layered layout groups the depths into render columns and the peak width
// sizes the canvas. This scoped plan is a pure chain, so each of its four
// layers holds exactly one card and the peak width is 1 (no concurrency).
let layers = dag.layers();
assert_eq!(
layers,
vec![vec!["i-001"], vec!["n-001"], vec!["n-002"], vec!["n-003"]]
.into_iter()
.map(|l| l.into_iter().map(String::from).collect::<Vec<_>>())
.collect::<Vec<_>>()
);
assert_eq!(dag.width(), 1, "a chain exposes no parallel work");
assert_eq!(
layers.len(),
dag.critical_path_len() + 1,
"height = critical_path_len + 1"
);
// Slack + critical set: this scoped plan is a pure chain, so every node has zero
// slack and the whole chain is the critical set (matches critical_path here).
assert!(
dag.slack().values().all(|s| *s == 0),
"a chain has no float"
);
assert_eq!(
dag.critical_nodes(),
dag.critical_path(),
"chain: critical set = critical path"
);
// The bottleneck ranking — "which node unblocks the most work" — is reachable
// through the public API. In this chain the mother gates the whole lineage
// (downstream = the 3 tasks behind it), so it is the top constraint; the leaf
// n-003 blocks nothing and ranks last.
let bn = dag.bottlenecks();
assert_eq!(
bn.first().map(|b| b.id.as_str()),
Some("i-001"),
"the mother is the top constraint"
);
assert_eq!(
bn.first().map(|b| b.downstream),
Some(3),
"the whole chain is stalled behind it"
);
assert_eq!(
bn.last().map(|b| (b.id.as_str(), b.downstream)),
Some(("n-003", 0)),
"the leaf blocks nothing"
);
}
/// `PlanDag::diff` — the replan "what changed" seam — classifies added nodes,
/// removed edges and changed-deps nodes through the public re-exports, and an
/// unchanged revision diffs to empty.
#[test]
fn plan_dag_diff_via_reexports() {
// Baseline: the realistic funnel (mother → a→b→c chain).
let baseline = PlanDag::from_funnel(&realistic_funnel(), Some("p-001"));
// Revised: an equivalent plan (mother + the same 3 task nodes + only the a→b
// edge) — i.e. the b→c dependency dropped and n-001 left Pending. Standing in
// for a second plan revision.
let revised = {
let mut g = Funnel::default();
let now = Utc::now();
let idea = IdeaId::seq(1);
let plan = PlanId::seq(1);
g.apply(&Event::IdeaSubmitted {
id: idea.clone(),
source: "human:alice".into(),
text: "Add a #board view".into(),
refs: vec![],
item_kind: ItemKind::Idea,
ts: now,
})
.unwrap();
g.apply(&Event::PlanCreated {
id: plan.clone(),
idea_id: idea,
summary: "decompose → 3 subtasks".into(),
planner: "decompose".into(),
ts: now,
})
.unwrap();
g.apply(&Event::PlanStatusChanged {
plan_id: plan.clone(),
status: PlanStatus::Active,
why: None,
ts: now,
})
.unwrap();
for (i, kind) in ["code:write", "code:fix", "test:write"].iter().enumerate() {
let title = ["implement foo", "fix the bar bug", "test baz"][i];
g.apply(&Event::NodeAdded {
plan_id: plan.clone(),
node_id: NodeId::seq(i as u64 + 1),
kind: (*kind).into(),
params: Default::default(),
targets: vec![format!("src/mod{i}.rs")],
prompt_excerpt: Some(title.into()),
ts: now,
})
.unwrap();
}
g.apply(&Event::EdgeAdded {
plan_id: plan.clone(),
from_node: NodeId::seq(1),
to_node: NodeId::seq(2),
ts: now,
})
.unwrap();
// n-002 → n-003 deliberately omitted; n-001 stays Pending (baseline had it Done).
PlanDag::from_funnel(&g, Some("p-001"))
};
let d = revised.diff(&baseline);
assert!(!d.is_empty(), "the revisions differ");
// The dropped dependency edge is reported as removed.
assert!(d.removed_edges.contains(&PlanDagEdge {
from: "n-002".into(),
to: "n-003".into(),
kind: EdgeKind::Dep
}));
// n-003 lost its only incoming dependency → a changed-deps node.
let n3 = d
.changed_nodes
.iter()
.find(|c| c.id == "n-003")
.expect("n-003 changed");
assert!(n3.changed.contains(&"deps".to_string()));
// n-001 went Done→Pending across the revisions → a status change.
let n1 = d
.changed_nodes
.iter()
.find(|c| c.id == "n-001")
.expect("n-001 changed");
assert!(n1.changed.contains(&"status".to_string()));
// An identical revision diffs to empty (the replan gate's "no change" case).
assert!(baseline.diff(&baseline).is_empty());
assert_eq!(baseline.diff(&baseline).change_count(), 0);
}
/// `DagGrowth` — the live-DAG growth timeline the funnel pane animates the graph
/// growing along — reconstructs, through the public re-exports, WHEN each node and
/// edge first entered the DAG, in birth order, with a monotone growth curve.
#[test]
fn plan_dag_growth_timeline_via_reexports() {
// A mother idea decomposed into a two-node dependency chain a→b, built through
// the public Event seam. Steps: 0 idea, 1 plan, 2 a, 3 b, 4 dep a→b.
let now = Utc::now();
let idea = IdeaId::seq(1);
let plan = PlanId::seq(1);
let mut evs = vec![
Event::IdeaSubmitted {
id: idea.clone(),
source: "human:alice".into(),
text: "grow me".into(),
refs: vec![],
item_kind: ItemKind::Idea,
ts: now,
},
Event::PlanCreated {
id: plan.clone(),
idea_id: idea,
summary: "decompose".into(),
planner: "decompose".into(),
ts: now + Duration::seconds(1),
},
];
for i in 1..=2u64 {
evs.push(Event::NodeAdded {
plan_id: plan.clone(),
node_id: NodeId::seq(i),
kind: "code:write".into(),
params: Default::default(),
targets: vec![],
prompt_excerpt: Some(format!("t{i}")),
ts: now + Duration::seconds(1 + i as i64),
});
}
evs.push(Event::EdgeAdded {
plan_id: plan.clone(),
from_node: NodeId::seq(1),
to_node: NodeId::seq(2),
ts: now + Duration::seconds(4),
});
let g = DagGrowth::from_events(&evs, None);
assert_eq!(g.steps, 5);
// Birth order: mother first (step 0), then the two tasks (steps 2, 3).
assert_eq!(
g.nodes.iter().map(|n| n.id.as_str()).collect::<Vec<_>>(),
["i-001", "n-001", "n-002"]
);
assert_eq!(g.nodes[0].step, 0, "the mother is born at submission");
// The dependency edge is born at ITS EdgeAdded (step 4), not at an endpoint's step.
let dep = g
.edges
.iter()
.find(|e| e.kind == EdgeKind::Dep)
.expect("a dep edge");
assert_eq!(
(dep.from.as_str(), dep.to.as_str(), dep.step),
("n-001", "n-002", 4)
);
// Monotone growth curve the history scrubber reads.
assert_eq!(g.node_count_at(0), 1);
assert_eq!(
g.node_count_at(3),
3,
"both tasks present after the last NodeAdded"
);
assert_eq!(g.edge_count_at(4), 3, "2 mother edges + 1 dep edge");
assert!(
g.edge_count_at(4) >= g.edge_count_at(3),
"the growth curve never decreases"
);
// The public state_json seam carries the births.
assert_eq!(g.state_json()["node_count"], 3);
// The incremental streaming seam (`DagGrowth::new` + `extend`) — the per-tick
// live-pane path — reconstructs the SAME timeline through the public re-exports:
// pushing the events one at a time yields byte-identical births to the batch fold.
let mut stream = DagGrowth::new(None);
for ev in &evs {
stream.extend(ev);
}
assert_eq!(
stream, g,
"streamed growth equals the batch growth via re-exports"
);
assert_eq!(stream.nodes, g.nodes, "byte-identical node births");
assert_eq!(stream.edges, g.edges, "byte-identical edge births");
}
/// `apply` rejects the invariant-violating events its doc promises: unknown-id
/// references, duplicate edges, and self-loops. The log stays clean because the
/// store calls `apply` before appending.
#[test]
fn apply_rejects_invariant_violations() {
let mut f = Funnel::default();
let now = Utc::now();
// A plan referencing a non-existent idea is rejected.
assert!(f
.apply(&Event::PlanCreated {
id: PlanId::seq(1),
idea_id: IdeaId::seq(99),
summary: "orphan".into(),
planner: "x".into(),
ts: now,
})
.is_err());
// Build a valid idea+plan+node, then prove edge invariants.
f.apply(&Event::IdeaSubmitted {
id: IdeaId::seq(1),
source: "x".into(),
text: "t".into(),
refs: vec![],
item_kind: ItemKind::Idea,
ts: now,
})
.unwrap();
f.apply(&Event::PlanCreated {
id: PlanId::seq(1),
idea_id: IdeaId::seq(1),
summary: "p".into(),
planner: "x".into(),
ts: now,
})
.unwrap();
for i in 1..=2u64 {
f.apply(&Event::NodeAdded {
plan_id: PlanId::seq(1),
node_id: NodeId::seq(i),
kind: "code:write".into(),
params: Default::default(),
targets: vec![],
prompt_excerpt: None,
ts: now,
})
.unwrap();
}
let edge = |from, to| Event::EdgeAdded {
plan_id: PlanId::seq(1),
from_node: NodeId::seq(from),
to_node: NodeId::seq(to),
ts: now,
};
f.apply(&edge(1, 2)).unwrap();
assert!(f.apply(&edge(1, 2)).is_err(), "duplicate edge rejected");
assert!(f.apply(&edge(1, 1)).is_err(), "self-loop rejected");
assert!(
f.apply(&edge(1, 9)).is_err(),
"edge to unknown node rejected"
);
// A back-edge that would close a cycle (1→2 already applied, so 2→1 makes an
// a→b→a loop) is rejected — a cyclic plan would otherwise stall `topo_ready`
// and `simulate_flow` forever. The log stays a DAG.
assert!(
f.apply(&edge(2, 1)).is_err(),
"cycle-closing back-edge rejected"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Plan-DAG ↔ Component-DAG wiring — the seam a consumer (the funnel pane,
// brokkr's wiring canvas) actually binds to.
// ─────────────────────────────────────────────────────────────────────────────
/// The nordisk component graph as a consumer would materialise it from Mímir's
/// `dep_graph_edges` rows: `from` depends on `to` (dependent → dependency).
fn nordisk_components() -> ComponentDag {
ComponentDag::new(
vec![
ComponentNode::krate("funnel-core").with_path("funnel-core/src"),
ComponentNode::krate("nornir").with_path("nornir/src"),
ComponentNode::krate("nornir-viz").with_path("nornir/src/viz"),
ComponentNode::krate("dwarves").with_path("dwarves/crates"),
ComponentNode::krate("ordning").with_path("ordning/src"),
],
vec![
ComponentEdge::dep("nornir", "funnel-core"),
ComponentEdge::dep("ordning", "funnel-core"),
ComponentEdge::dep("nornir-viz", "nornir"),
ComponentEdge::dep("dwarves", "nornir"),
],
)
}
#[test]
fn wiring_traversal_joins_the_plan_dag_to_the_component_dag() {
let mut f = realistic_funnel();
let now = Utc::now();
// The pane's drag gesture: n-001 touches funnel-core, n-003 touches nornir-viz.
f.apply(&draw_wire("n-001", "funnel-core", Some("drag".into()), now))
.unwrap();
f.apply(&draw_wire("n-003", "nornir-viz", None, now))
.unwrap();
let wiring = Wiring::from_funnel(&f);
let graph = nordisk_components();
let dag = PlanDag::from_funnel(&f, Some("p-001"));
// Single node: funnel-core's blast radius is every transitive DEPENDENT.
let r = wiring.blast_radius("n-001", &graph);
assert_eq!(r.touched, vec![ComponentId::new("funnel-core")]);
assert_eq!(
r.affected,
vec![
ComponentId::new("dwarves"),
ComponentId::new("nornir"),
ComponentId::new("nornir-viz"),
ComponentId::new("ordning"),
],
"blast radius must walk dependents, not dependencies"
);
// Reversing the traversal is the classic bug: the dependency closure of a
// root is a DIFFERENT (here empty) set. RED if the directions are swapped.
assert!(graph
.dependency_closure(&ComponentId::new("funnel-core"))
.is_empty());
// Plan cone: from n-001 the cone is {n-001, n-002, n-003}, so the union picks
// up nornir-viz too — and n-002, which nobody wired, is named as the hole.
let cone = wiring.cone_blast_radius(&dag, "n-001", &graph);
assert_eq!(cone.plan_nodes, vec!["n-001", "n-002", "n-003"]);
assert!(cone.touched.contains(&ComponentId::new("nornir-viz")));
assert_eq!(cone.unmapped_nodes, vec!["n-002".to_string()]);
assert!(
!cone.is_complete(),
"a hole in the cone makes the radius a lower bound"
);
// nornir-viz is directly touched, so it must not ALSO be listed as affected.
assert!(!cone.affected.contains(&ComponentId::new("nornir-viz")));
// The reverse overlay: which plan step claims this crate?
assert_eq!(
wiring.plan_nodes_for(&ComponentId::new("funnel-core")),
vec!["n-001"]
);
assert!(wiring
.plan_nodes_for(&ComponentId::new("dwarves"))
.is_empty());
}
#[test]
fn an_unwired_plan_node_never_silently_succeeds() {
let f = realistic_funnel();
let wiring = Wiring::from_funnel(&f);
let graph = nordisk_components();
let dag = PlanDag::from_funnel(&f, Some("p-001"));
// Nothing is wired at all, so EVERY plan-DAG node is reported unmapped —
// never dropped, never quietly counted as mapped.
let rep = WiringReport::of(&dag, &wiring, &graph);
assert!(!rep.is_fully_mapped());
assert!(rep.mapped.is_empty());
assert_eq!(rep.unmapped_ids(), vec!["i-001", "n-001", "n-002", "n-003"]);
assert_eq!(rep.coverage(), 0.0);
assert!(rep
.unmapped
.iter()
.all(|u| u.reason == UnmappedReason::NoWire));
// n-001 is Done and unwired — the loud row: work landed in code nobody
// can point at. (The mother idea reads Done too but is not finished work.)
assert_eq!(
rep.done_but_unmapped()
.iter()
.map(|u| u.id.clone())
.collect::<Vec<_>>(),
vec!["n-001".to_string()]
);
// The traversal tells the same truth: empty-because-UNKNOWN, not empty-because-none.
let r = wiring.blast_radius("n-002", &graph);
assert!(r.affected.is_empty() && r.touched.is_empty());
assert_eq!(r.unmapped_nodes, vec!["n-002".to_string()]);
assert!(!r.is_complete());
assert_eq!(r.state_json()["is_complete"], serde_json::json!(false));
// Every node mapped ⇒ the gate flips, and the two empty-`affected` cases stay
// distinguishable: dwarves is a real leaf, so its radius is empty AND complete.
let mut all = f.clone();
let now = Utc::now();
for id in ["i-001", "n-001", "n-002", "n-003"] {
all.apply(&draw_wire(id, "dwarves", None, now)).unwrap();
}
let full = WiringReport::of(
&PlanDag::from_funnel(&all, Some("p-001")),
&Wiring::from_funnel(&all),
&graph,
);
assert!(full.is_fully_mapped());
assert_eq!(full.coverage(), 1.0);
let leaf = Wiring::from_funnel(&all).blast_radius("n-002", &graph);
assert!(
leaf.affected.is_empty(),
"dwarves genuinely has no dependents"
);
assert!(
leaf.is_complete(),
"…and THAT emptiness is a fact, not a gap"
);
}
#[test]
fn unmapped_reasons_separate_a_gap_from_a_data_error() {
let mut f = realistic_funnel();
let now = Utc::now();
// A confirmed wire to a component the graph does not hold — it LOOKS mapped.
f.apply(&draw_wire("n-001", "ghost-crate", None, now))
.unwrap();
// A Mímir suggestion nobody confirmed yet.
f.apply(&Event::PlanComponentWired {
plan_node: "n-002".into(),
component: "nornir".into(),
origin: WireOrigin::Inferred.as_str().into(),
status: String::new(),
confidence: Some(0.71),
why: Some("plan text names `nornir`".into()),
ts: now,
})
.unwrap();
// A suggestion a human threw away.
f.apply(&reject_wire(
"n-003",
"ordning",
Some("wrong crate".into()),
now,
))
.unwrap();
let wiring = Wiring::from_funnel(&f);
let graph = nordisk_components();
let rep = WiringReport::of(&PlanDag::from_funnel(&f, Some("p-001")), &wiring, &graph);
let reason = |id: &str| rep.unmapped.iter().find(|u| u.id == id).unwrap().reason;
assert_eq!(reason("n-001"), UnmappedReason::Dangling);
assert_eq!(reason("n-002"), UnmappedReason::OnlyProposed);
assert_eq!(reason("n-003"), UnmappedReason::OnlyRejected);
assert_eq!(reason("i-001"), UnmappedReason::NoWire);
assert!(
rep.mapped.is_empty(),
"not one of those four is a real binding"
);
// The dangling wire is called out on its own so it can be fixed, and the
// proposal is surfaced for the confirm/reject cycle.
assert_eq!(rep.dangling.len(), 1);
assert_eq!(rep.dangling[0].component, ComponentId::new("ghost-crate"));
assert_eq!(rep.proposed.len(), 1);
assert_eq!(rep.proposed[0].confidence, Some(0.71));
// A rejected wire stays in the append-only log but does not bind …
assert!(wiring.bound_components("n-003").is_empty());
assert_eq!(wiring.wires_for("n-003").len(), 1);
// … and a dangling component is never followed into the graph.
let r = wiring.blast_radius("n-001", &graph);
assert_eq!(r.missing, vec![ComponentId::new("ghost-crate")]);
assert!(r.touched.is_empty() && r.affected.is_empty());
assert!(!r.is_complete());
// "Unplanned" must be honestly unknown when the graph cannot enumerate.
assert!(
rep.unplanned.is_some(),
"a materialised ComponentDag CAN enumerate"
);
assert_eq!(rep.unplanned.as_ref().unwrap().len(), graph.node_count());
}
#[test]
fn wiring_wire_tokens_are_stable() {
for o in [
WireOrigin::Drawn,
WireOrigin::Inferred,
WireOrigin::Declared,
] {
assert_eq!(
serde_json::to_value(o).unwrap(),
serde_json::json!(o.as_str())
);
assert_eq!(WireOrigin::parse(o.as_str()), Some(o));
}
for s in [
WireStatus::Proposed,
WireStatus::Confirmed,
WireStatus::Rejected,
] {
assert_eq!(
serde_json::to_value(s).unwrap(),
serde_json::json!(s.as_str())
);
assert_eq!(WireStatus::parse(s.as_str()), Some(s));
}
for k in [ComponentEdgeKind::Dep, ComponentEdgeKind::Call] {
assert_eq!(
serde_json::to_value(k).unwrap(),
serde_json::json!(k.as_str())
);
}
for r in [
UnmappedReason::Dangling,
UnmappedReason::OnlyProposed,
UnmappedReason::OnlyRejected,
UnmappedReason::NoWire,
] {
assert_eq!(
serde_json::to_value(r).unwrap(),
serde_json::json!(r.as_str())
);
}
// Only a Confirmed wire binds — the whole honesty contract in one line.
assert!(WireStatus::Confirmed.binds());
assert!(!WireStatus::Proposed.binds());
assert!(!WireStatus::Rejected.binds());
// And a drawn wire is confirmed by the act of drawing; a suggestion is not.
assert_eq!(WireOrigin::Drawn.default_status(), WireStatus::Confirmed);
assert_eq!(WireOrigin::Inferred.default_status(), WireStatus::Proposed);
assert_eq!(WireOrigin::Declared.default_status(), WireStatus::Proposed);
}
#[test]
fn a_wire_to_an_unknown_plan_node_is_rejected_at_write_time() {
let mut f = realistic_funnel();
let now = Utc::now();
// The COMPONENT side is unvalidated on purpose (a plan may be wired ahead of
// the code), but the PLAN side must name a real card — otherwise a wire to a
// step that does not exist would inflate the coverage of a plan that never
// had it.
assert!(
f.apply(&draw_wire("n-999", "funnel-core", None, now))
.is_err(),
"wire to an unknown plan node rejected"
);
assert!(
f.apply(&draw_wire("n-001", "", None, now)).is_err(),
"wire with an empty component id rejected"
);
assert!(f
.apply(&draw_wire("n-001", "not-in-the-graph-yet", None, now))
.is_ok());
assert!(
f.apply(&draw_wire("i-001", "funnel-core", None, now))
.is_ok(),
"an idea card is wireable"
);
assert_eq!(Wiring::from_funnel(&f).wires().len(), 2);
}
#[test]
fn wiring_survives_a_replay_and_stays_deterministic() {
// The wiring is a fold of the log like every other surface: replaying the
// same events must rebuild it byte-identically, and a rejected wire event
// must leave no trace.
let now = Utc::now();
let mut a = realistic_funnel();
let evs = vec![
draw_wire("n-003", "nornir-viz", None, now),
draw_wire("n-001", "funnel-core", None, now),
reject_wire("n-002", "ordning", None, now),
];
for e in &evs {
a.apply(e).unwrap();
}
let mut b = realistic_funnel();
// A bad event in the middle changes nothing (the store never appends one).
let _ = b.apply(&draw_wire("n-404", "funnel-core", None, now));
for e in &evs {
b.apply(e).unwrap();
}
let (wa, wb) = (Wiring::from_funnel(&a), Wiring::from_funnel(&b));
assert_eq!(wa, wb);
assert_eq!(wa.state_json(), wb.state_json());
// Projection order is (plan_node, component), not insertion order.
assert_eq!(
wa.wires()
.iter()
.map(|w| w.plan_node.as_str())
.collect::<Vec<_>>(),
vec!["n-001", "n-002", "n-003"]
);
assert_eq!(wa.state_json()["bound_count"], serde_json::json!(2));
let graph = nordisk_components();
let dag = PlanDag::from_funnel(&a, Some("p-001"));
assert_eq!(
WiringReport::of(&dag, &wa, &graph).state_json(),
WiringReport::of(&dag, &wb, &graph).state_json()
);
}
/// **The open-enum contract.** `Event` is `#[non_exhaustive]`: a new variant is a
/// routine, additive change, so consumers must never match it exhaustively. This
/// test guards the half a compiler cannot: that the discriminator vocabulary
/// stays complete and stable, and that a consumer never *needs* to match at all.
///
/// (The `#[non_exhaustive]` attribute itself is locked by the `compile_fail` doc
/// test on `Event` — an exhaustive match from outside the crate must not compile.
/// Removing the attribute makes that doc test compile, and therefore fail.)
#[test]
fn event_is_an_open_enum_with_a_total_kind_accessor() {
let now = Utc::now();
let one_of_each: Vec<Event> = vec![
Event::IdeaSubmitted {
id: IdeaId::seq(1),
source: "s".into(),
text: "t".into(),
refs: vec![],
item_kind: ItemKind::Idea,
ts: now,
},
Event::IdeaTriaged {
idea_id: IdeaId::seq(1),
decision: TriageDecision::Accept,
why: None,
ts: now,
},
Event::PlanCreated {
id: PlanId::seq(1),
idea_id: IdeaId::seq(1),
summary: "s".into(),
planner: "p".into(),
ts: now,
},
Event::NodeAdded {
plan_id: PlanId::seq(1),
node_id: NodeId::seq(1),
kind: "code:write".into(),
params: serde_json::Map::new(),
targets: vec![],
prompt_excerpt: None,
ts: now,
},
Event::EdgeAdded {
plan_id: PlanId::seq(1),
from_node: NodeId::seq(1),
to_node: NodeId::seq(2),
ts: now,
},
Event::NodeStatusChanged {
plan_id: PlanId::seq(1),
node_id: NodeId::seq(1),
status: NodeStatus::Done,
why: None,
ts: now,
},
Event::RunRecorded {
plan_id: PlanId::seq(1),
node_id: NodeId::seq(1),
run_id: funnel_core::RunId::seq(1),
ran_by: "agent".into(),
outcome: funnel_core::RunOutcome::Ok,
log_ref: None,
produced_commits: vec![],
produced_test_runs: vec![],
ts: now,
},
Event::PlanStatusChanged {
plan_id: PlanId::seq(1),
status: PlanStatus::Active,
why: None,
ts: now,
},
Event::IdeaModeSet {
idea_id: IdeaId::seq(1),
mode: "auto".into(),
ts: now,
},
Event::CardAssigned {
node: "n-001".into(),
member: "alice".into(),
role: None,
why: None,
ts: now,
},
Event::CardStateChanged {
node: "n-001".into(),
from_lane: None,
to_lane: "planned".into(),
why: None,
ts: now,
},
Event::PlanComponentWired {
plan_node: "n-001".into(),
component: "funnel-core".into(),
origin: "drawn".into(),
status: "confirmed".into(),
confidence: None,
why: None,
ts: now,
},
];
// `Event::KINDS` is exactly the set of tokens, in declaration order. A new
// variant that is not added to `KINDS` (or is added in the wrong place) is
// RED here — and the failure message is the reminder that consumers now need
// that token handled.
let observed: Vec<&str> = one_of_each.iter().map(Event::kind_str).collect();
assert_eq!(
observed,
Event::KINDS.to_vec(),
"Event::KINDS must list every variant's token in declaration order"
);
assert_eq!(
Event::KINDS.len(),
12,
"a variant was added or removed — Event is an OPEN enum, so this is allowed, \
but every consumer's wildcard arm and the warehouse schema must be checked \
(see .nornir/dag-wiring-design.md, the consumer obligations section)"
);
// The token a consumer stores MUST be the token serde writes, or a `kind`
// column and a serialised event silently disagree. Swap either side and this
// goes RED for every variant.
for ev in &one_of_each {
let tagged = serde_json::to_value(ev).unwrap();
assert_eq!(
tagged["kind"],
serde_json::json!(ev.kind_str()),
"kind_str() must equal the serde `kind` tag for {:?}",
ev.kind_str()
);
// …and it round-trips back to the same variant.
let back: Event = serde_json::from_value(tagged).unwrap();
assert_eq!(back.kind_str(), ev.kind_str());
assert_eq!(back.ts(), ev.ts());
}
// Every token is unique and non-empty (a duplicate would make two variants
// indistinguishable in a stored log).
let uniq: std::collections::BTreeSet<&str> = Event::KINDS.iter().copied().collect();
assert_eq!(uniq.len(), Event::KINDS.len(), "kind tokens must be unique");
assert!(uniq.iter().all(|k| !k.is_empty()));
// The consumer-shaped access pattern: no `match` on Event at all.
assert_eq!(one_of_each.last().unwrap().kind_str(), "PlanComponentWired");
}