Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

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