Gunnar

mikronova/funnel-core

tests/public_api.rs

//! **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");
}