dwarves-gui/tests/robot_funnel_live_wire.rs
//! **OVER-THE-WIRE robot proof that the funnel's READ leg is closed: submit → visible.**
//!
//! The defect this pins shut ("funnel-verdict"): the WRITE leg worked — the MCP
//! `funnel_submit_idea` tool submits over thin gRPC `Funnel.SubmitIdea` into nornir's
//! `funnel_events`-backed store — but the shipped shell always mounted the purely
//! embedded funnel pane, which replays a local log or seeds a demo fixture and NEVER
//! reads that store. An MCP-submitted idea landed where no shipped pane looked.
//!
//! The fix is the host-injection seam (`nornir_funnel_embed::LiveFunnelSource`, the
//! Warehouse Navigator's `RealFnSource` pattern): the pane stays embedded/offline-first,
//! and the SHIPPED construction path (`panel_embedded_live` — the exact call
//! `dwarves_gui::build_native_app` makes for the Funnel tab) injects a live source over
//! the host's one endpoint, consulted lazily with an honest NAMED outage.
//!
//! Driven with ONLY the sanctioned in-process test seam
//! (`nornir_funnel_embed::testkit::spawn_mock_funnel` — a real tonic `nornir.v1.Funnel`
//! server on an ephemeral port whose `SubmitIdea` grows the store `Show` returns, NOT a
//! stub), so nothing is twinned (L5):
//!
//! 1. [`an_mcp_submitted_idea_becomes_visible_in_the_shipped_funnel_pane`] — submit
//! over the SAME thin write leg the MCP tool calls, mount the SHIPPED pane, and the
//! idea's actual TITLE TEXT renders in the pane's plan-DAG (applied output, not a
//! count); the pane's own submit strip rides the same wire and is visible too.
//! 2. [`a_dead_server_keeps_the_embedded_funnel_and_names_the_outage`] — a dead
//! endpoint keeps the embedded fixture, `live_status` names `unreachable`, and
//! `funnel_source` says the content is embedded — never fabricated.
//!
//! Hermetic: `NORNIR_FUNNEL_LOG` is cleared so the embedded fallback is the compiled-in
//! seed, and live content can only have come over the wire. Each functional check emits
//! a `nornir-testmatrix` cell under `--features testmatrix`.
#![cfg(not(target_arch = "wasm32"))]
use facett_core::Panel;
use nornir_funnel_embed::testkit::spawn_mock_funnel;
use nornir_funnel_embed::IntakeKind;
#[allow(unused_variables)]
fn matrix(check: &str, ok: bool, detail: &str) {
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status("dwarves-gui", check, ok, detail);
}
/// Pin the env this pane reads: no local funnel event-log may leak into the embedded
/// engine (this box may have one configured). Same value in both tests ⇒ parallel-safe.
fn pin_env() {
std::env::remove_var("NORNIR_FUNNEL_LOG");
}
/// The rendered plan-DAG's node TITLES from the pane's `state_json` — the APPLIED
/// output (what the card lane actually draws), never a node count.
fn plandag_titles(s: &serde_json::Value) -> Vec<String> {
s["plandag"]["nodes"]
.as_array()
.expect("plandag nodes present")
.iter()
.filter_map(|n| n["title"].as_str().map(|t| t.to_string()))
.collect()
}
/// **1 — submit → visible, over the real wire, through the SHIPPED pane path.**
///
/// RED-when-broken: mounting the old purely-embedded pane (`panel_embedded`) — or a
/// `refresh` that ignores the injected source — leaves the demo fixture on screen and
/// the submitted title absent, failing the text assertion below (proved by sabotage:
/// with the shipped call swapped back to `panel_embedded`, this test fails exactly
/// there).
#[test]
fn an_mcp_submitted_idea_becomes_visible_in_the_shipped_funnel_pane() {
pin_env();
// A real tonic `nornir.v1.Funnel` server, empty store.
let srv = spawn_mock_funnel(Vec::new());
// (a) the WRITE leg — the SAME thin call dwarves' MCP `funnel_submit_idea` tool
// makes (`nornir_funnel_embed::submit_idea` → `Funnel.SubmitIdea`).
let id = nornir_funnel_embed::submit_idea(
&srv.endpoint,
"",
"nordisk",
"korp raven autopilot",
IntakeKind::Idea,
)
.expect("the write leg lands");
assert_eq!(id, "i-001", "the store assigned the first id");
// (b) the SHIPPED pane construction path — the exact call `build_native_app`
// makes for the Funnel tab (embedded-first + host-injected live source).
let mut pane: Box<dyn Panel> =
nornir_funnel_embed::panel_embedded_live(&srv.endpoint, "", "nordisk");
// LAZY by design: construction dialed nothing. The first poll tick consults the
// source; `{"refresh":true}` is that tick's headless twin.
let s = pane.state_json();
assert_eq!(
s["live_status"], "pending",
"nothing dialed at construction: {s}"
);
pane.update_json(r#"{"refresh":true}"#);
// (c) the idea's TITLE TEXT is rendered — applied output, and provenance says LIVE.
let s = pane.state_json();
assert_eq!(
s["status"], "live",
"the rendered funnel is the server's: {s}"
);
assert_eq!(s["live_status"], "live", "{s}");
let src = s["funnel_source"].as_str().unwrap_or_default().to_string();
assert!(
src.starts_with("live: "),
"provenance names the live source: {src}"
);
assert!(
src.contains(&srv.endpoint),
"provenance names the endpoint: {src}"
);
let titles = plandag_titles(&s);
let mcp_visible = titles.iter().any(|t| t.contains("korp raven autopilot"));
assert!(
mcp_visible,
"the MCP-submitted idea's words render in the shipped pane: {titles:?}"
);
assert!(
!titles.iter().any(|t| t.contains("release engine")),
"the embedded demo fixture is NOT what is shown while live: {titles:?}"
);
// (d) the pane's own submit strip rides the SAME wire — and is visible at once.
pane.update_json(r#"{"submit_text":"second idea typed in the pane"}"#);
let s = pane.state_json();
assert_eq!(s["submit_status"], "ok", "{s}");
assert_eq!(
s["submit_id"], "i-002",
"the wire assigned the next id: {s}"
);
let titles = plandag_titles(&s);
let pane_visible = titles
.iter()
.any(|t| t.contains("second idea typed in the pane"));
assert!(
pane_visible,
"the pane-submitted idea is visible too: {titles:?}"
);
drop(srv);
matrix(
"funnel_submit_becomes_visible_over_the_wire",
mcp_visible && pane_visible,
"the funnel's READ leg is closed: an idea submitted over the SAME thin gRPC write \
leg the MCP funnel_submit_idea tool uses (Funnel.SubmitIdea, against an in-process \
tonic mock Funnel server) renders its TITLE TEXT in the SHIPPED embedded+live pane \
(panel_embedded_live — the exact construction build_native_app mounts), with \
provenance `live: …`; the pane's own submit strip rides the same wire and its idea \
is visible on the next poll — submit → visible, end to end.",
);
}
/// **2 — a dead server keeps the embedded funnel and NAMES the outage.**
///
/// The honesty half: an unreachable host must not blank the pane or fabricate a live
/// funnel. The embedded funnel (empty here: no local log) stays, `live_status` says `unreachable`, and
/// `funnel_source` says the content is embedded (with the outage named inside it).
#[test]
fn a_dead_server_keeps_the_embedded_funnel_and_names_the_outage() {
pin_env();
// Port 9 (discard) on loopback: refused instantly — a dead host, not a slow one.
let mut pane: Box<dyn Panel> =
nornir_funnel_embed::panel_embedded_live("http://127.0.0.1:9", "", "nordisk");
pane.update_json(r#"{"refresh":true}"#);
let s = pane.state_json();
// `embedded_empty`: the in-process funnel is what is on screen, and it holds nothing
// (no local log on this box, and no sample is seeded any more).
let status_embedded = s["status"] == "embedded_empty";
assert!(
status_embedded,
"the pane stays on its embedded funnel and says it is empty: {}",
s["status"]
);
assert_eq!(s["live_status"], "unreachable", "the outage is NAMED: {s}");
let src = s["funnel_source"].as_str().unwrap_or_default().to_string();
assert!(
src.starts_with("embedded (live unreachable"),
"provenance says embedded + names the outage: {src}"
);
assert!(
s["live_error"].as_str().is_some(),
"the error text is surfaced: {s}"
);
// The embedded funnel of a box with no local funnel log is EMPTY: an outage shows
// the outage, not a sample idea a person could take for their own.
let titles = plandag_titles(&s);
let fixture_kept = !titles.iter().any(|t| t.contains("release engine"));
assert!(
fixture_kept,
"no sample idea is drawn on an outage: {titles:?}"
);
assert_eq!(
s["node_count"], 0,
"nothing stands in for the server's funnel: {s}"
);
matrix(
"funnel_dead_server_keeps_embedded_and_names_outage",
status_embedded && fixture_kept,
"against a dead host the shipped embedded+live funnel pane stays on its in-process \
funnel (status `embedded`; empty, no sample idea), names the outage distinctly \
(live_status `unreachable`, funnel_source `embedded (live unreachable — …)`) and \
surfaces the error text.",
);
}