tests/funnel_planning.rs
//! PLAN-WITHOUT-BIG-AI — inject-assert tests for the funnel auto-planner driven
//! by the component dependency graph.
//!
//! The unit-level fallback chain (LLM → embedder → pick-list) and the
//! topo-affected logic are tested in `src/funnel/planner.rs`. This file covers:
//!
//! * the **end-to-end** store path: seed an idea + a real dep-graph snapshot,
//! classify with a mock LLM, generate a plan, and assert the EXACT plan
//! nodes/edges (one per topo-affected component, build order),
//! * the **viz** Funnel-tab surface: drive the planner panel's
//! classify → pick → generate via its test hooks and assert
//! `state_json()["funnel"]["planner"]` shows the proposal + affected set +
//! the generated plan — "robot sees what the user sees".
//!
//! No live LLM/ollama is required: the store path injects the proposer result
//! directly (a confident pick), and the viz path uses the pick-list leg (no
//! backend confident → user picks), so both run offline + deterministically.
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use nornir::funnel::{self, Event, IdeaId, ItemKind, NodeStatus, PlanStatus};
use nornir::warehouse::dep_graph::{CrossRepoEdge, WorkspaceGraph, record_dep_graph};
use nornir::warehouse::iceberg::IcebergWarehouse;
/// A diamond dep graph: app → liba → util, app → libb → util — recorded as a
/// warehouse snapshot under `workspace_name`. Returns the same query-only graph.
fn seed_diamond_snapshot(root: &std::path::Path, workspace_name: &str) -> WorkspaceGraph {
let edge = |from: &str, to: &str, via: &str| CrossRepoEdge {
from: from.to_string(),
to: to.to_string(),
via: [via.to_string()].into_iter().collect::<BTreeSet<_>>(),
kind: Default::default(),
};
let edges = vec![
edge("app", "liba", "a_c"),
edge("app", "libb", "b_c"),
edge("liba", "util", "util_c"),
edge("libb", "util", "util_c"),
];
let graph = WorkspaceGraph::from_query_parts(BTreeMap::new(), edges);
let wh = IcebergWarehouse::open(root).unwrap();
wh.block_on(record_dep_graph(&wh, workspace_name, &graph))
.unwrap();
graph
}
/// End-to-end store path: a TEST idea → classify (confident pick `util`) →
/// `plan_from_component` → exactly one node per topo-affected component, in
/// build order, chained by edges, all `test:write` targeting their component.
#[test]
fn store_plan_from_component_weaves_topo_plan() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("wh");
let graph = seed_diamond_snapshot(&root, "demo");
let mut store = funnel::Store::open(&root).unwrap();
let idea = IdeaId::seq(store.funnel.next_idea);
store
.record(Event::IdeaSubmitted {
id: idea.clone(),
source: "test".into(),
text: "add tests for the shared util layer".into(),
refs: vec![],
item_kind: ItemKind::Test,
ts: chrono::Utc::now(),
})
.unwrap();
// Proposer (pick-list leg here: no backend) returns the full component list;
// the user "picks" util — assert the full list is exactly the diamond's.
let prop = funnel::propose_components(&store.funnel.ideas[&idea].text, &graph, None, None);
assert!(!prop.confident, "no backend → pick-list");
assert_eq!(prop.all, vec!["app", "liba", "libb", "util"]);
// Generate the plan from the picked component.
let plan_id =
funnel::plan_from_component(&mut store, &idea, &["util".to_string()], &graph).unwrap();
let plan = store.funnel.plans.get(&plan_id).expect("plan created");
assert_eq!(plan.status, PlanStatus::Active);
assert_eq!(plan.idea_id, idea);
// One node per affected component (util, liba, libb, app).
assert_eq!(plan.nodes.len(), 4);
let mut targets: BTreeSet<String> = BTreeSet::new();
for n in plan.nodes.values() {
assert_eq!(n.kind, "test:write", "test idea → test:write nodes");
assert_eq!(n.targets.len(), 1);
targets.insert(n.targets[0].clone());
}
assert_eq!(
targets,
["app", "liba", "libb", "util"]
.iter()
.map(|s| s.to_string())
.collect()
);
assert_eq!(plan.edges.len(), 3, "4 nodes chained → 3 edges");
// The deps-first node (util) is the only one Ready.
store.funnel.promote_ready();
let plan = store.funnel.plans.get(&plan_id).unwrap();
let ready: Vec<String> = plan
.nodes
.values()
.filter(|n| n.status == NodeStatus::Ready)
.flat_map(|n| n.targets.clone())
.collect();
assert_eq!(ready, vec!["util".to_string()]);
// The plan persists across a reopen (real warehouse round-trip).
drop(store);
let store2 = funnel::Store::open(&root).unwrap();
assert!(
store2.funnel.plans.contains_key(&plan_id),
"plan survives reopen"
);
}
/// The error item kind plans into `code:fix` nodes (vs `code:write` for an idea).
#[test]
fn error_item_plans_into_code_fix_nodes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("wh");
let graph = seed_diamond_snapshot(&root, "demo");
let mut store = funnel::Store::open(&root).unwrap();
let idea = IdeaId::seq(store.funnel.next_idea);
store
.record(Event::IdeaSubmitted {
id: idea.clone(),
source: "test".into(),
text: "panic in liba on empty input".into(),
refs: vec![],
item_kind: ItemKind::Error,
ts: chrono::Utc::now(),
})
.unwrap();
let plan_id =
funnel::plan_from_component(&mut store, &idea, &["liba".to_string()], &graph).unwrap();
let plan = &store.funnel.plans[&plan_id];
// liba affects liba + app (its only dependent).
let targets: BTreeSet<String> = plan
.nodes
.values()
.flat_map(|n| n.targets.clone())
.collect();
assert_eq!(
targets,
["app", "liba"].iter().map(|s| s.to_string()).collect()
);
assert!(
plan.nodes.values().all(|n| n.kind == "code:fix"),
"error → code:fix nodes"
);
}
// ── viz Funnel-tab planner surface (state_json + test hooks) ────────────────
#[cfg(feature = "viz")]
#[test]
fn viz_planner_panel_classifies_picks_and_generates() {
use nornir::viz::UrdrThreadsApp;
// The viz's durable action-log sink would otherwise hold the catalog's
// exclusive redb lock for the app's lifetime, so the planner's writable
// `Store::open` (generate) would hit "Database already open" — exactly the
// case the `NORNIR_VIZ_NO_DURABLE_ACTIONLOG` escape hatch (src/viz/app.rs)
// exists for. Set it so this single-process test can open the same
// warehouse writably for `generate`. (file/stderr action-log stays live.)
// TODO: Audit that the environment access only happens in single-threaded code.
unsafe { std::env::set_var("NORNIR_VIZ_NO_DURABLE_ACTIONLOG", "1") };
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path().join("ws");
let warehouse = ws.join(".nornir").join("warehouse");
std::fs::create_dir_all(&warehouse).unwrap();
// Seed a dep-graph snapshot under "" (the local funnel tab's workspace name)
// so the viz planner's `load_graph` finds edges.
seed_diamond_snapshot(&warehouse, "");
// Seed a TEST funnel item the panel will plan.
{
let mut store = funnel::Store::open(&warehouse).unwrap();
let idea = IdeaId::seq(store.funnel.next_idea);
store
.record(Event::IdeaSubmitted {
id: idea.clone(),
source: "test".into(),
text: "cover the util retry path".into(),
refs: vec![],
item_kind: ItemKind::Test,
ts: chrono::Utc::now(),
})
.unwrap();
assert_eq!(idea.as_str(), "i-000");
}
let mut app = UrdrThreadsApp::with_repos(warehouse.clone(), String::new(), ws.clone(), vec![]);
// 🧭 classify → no backend confident → pick-list of the diamond components.
app.funnel_tab_for_test()
.classify_for_test("i-000")
.expect("classify");
let s = app.state_json();
let planner = &s["funnel"]["planner"];
assert_eq!(
planner["available"].as_bool(),
Some(true),
"planner local-available"
);
assert_eq!(planner["target"].as_str(), Some("i-000"));
let prop = &planner["proposal"];
assert_eq!(
prop["confident"].as_bool(),
Some(false),
"no LLM/embedder → pick-list"
);
assert_eq!(prop["source"].as_str(), Some("pick_list"));
let all: Vec<String> = prop["all"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert_eq!(
all,
vec!["app", "liba", "libb", "util"],
"pick-list = components"
);
// The user picks `util` → the panel derives the topo affected set.
app.funnel_tab_for_test()
.set_picks_for_test(&["util"])
.expect("pick util");
let s = app.state_json();
let derived = &s["funnel"]["planner"]["derived"];
assert_eq!(
derived["node_kind"].as_str(),
Some("test:write"),
"test idea kind"
);
let affected: Vec<String> = derived["affected"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
// Blast radius of util in build order: util first, app last.
assert_eq!(
affected.iter().cloned().collect::<BTreeSet<_>>(),
all.iter().cloned().collect()
);
assert_eq!(affected.first().map(String::as_str), Some("util"));
assert_eq!(affected.last().map(String::as_str), Some("app"));
// ⚙ generate → a real plan is woven; the funnel DAG now shows 4 nodes.
let pid = app
.funnel_tab_for_test()
.generate_plan_for_test()
.expect("generate");
let s = app.state_json();
let plans = s["funnel"]["plans"].as_array().expect("plans array");
assert_eq!(plans.len(), 1, "exactly one generated plan");
assert_eq!(plans[0]["id"].as_str(), Some(pid.as_str()));
assert_eq!(
plans[0]["nodes"].as_u64(),
Some(4),
"one node per affected component"
);
assert_eq!(
plans[0]["edges"].as_u64(),
Some(3),
"chained build-order edges"
);
assert!(
plans[0]["ready_count"].as_u64().unwrap() >= 1,
"the deps-first node is Ready"
);
// The planner status line reflects the generation (see-what-user-sees).
let status = s["funnel"]["planner"]["status"].as_str().unwrap_or("");
assert!(status.contains("generated plan"), "status: {status}");
}