dwarves-gui/tests/robot_funnel_off_thread.rs
//! **ROBOT proof that the Funnel pane does not fold on the egui thread.**
//!
//! The defect this pins shut ("mimir-panes-inline"): six panes read Mímir/nornir over
//! the wire, and four of them ran that read INLINE inside `Panel::ui`. Four moved off
//! (Data view, Coverage, Coderoom, Lenses/Dupes — all on the `pending: Option<(Receiver,
//! Instant)>` + `poll()` model); the **Funnel** pane was the last one left. Its
//! `Panel::ui` called `refresh()` on every auto-poll tick, and `refresh()` consulted the
//! injected [`LiveFunnelSource`] synchronously — a gRPC dial plus `Funnel.Show` through
//! `fetch_funnel`'s `block_on`. The frame carrying the poll could not paint until the
//! server answered, on a loop, forever; against a dead endpoint it held for the whole
//! 3 s connect timeout every interval.
//!
//! **RED-when-broken, and it asserts a MEASURED TIME, not a state round-trip.** A
//! `SlowLive` source stands in for the real consult with a known, deliberate latency.
//! The test drives the pane's REAL `Panel::ui` through the egui harness and times the
//! frame that carries the auto-poll:
//!
//! * folding INLINE ⇒ the frame takes at least the consult's latency;
//! * folding OFF-THREAD ⇒ the frame starts a worker and returns, and the answer lands
//! on a LATER frame.
//!
//! The assertion is a RATIO against that latency, never a millisecond threshold — a
//! wall-clock bound would be a machine constant, while "the frame did not pay for the
//! wire" is the actual claim. The consult's result is asserted too, on both counts:
//! cheap must not be bought with wrong, so the pane must still end up rendering the
//! source's funnel.
#![cfg(not(target_arch = "wasm32"))]
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use facett_core::Facet;
use nornir_funnel_embed::{pb, FunnelPanel, IntakeKind, LiveFunnelSource};
use nornir_robotui::egui_kittest::Harness;
use nornir_robotui::{Observable, RobotSession};
/// The harness state: one funnel pane, driven through its REAL `Panel::ui`.
struct FunnelApp(FunnelPanel);
impl Observable for FunnelApp {
fn state_json(&self) -> serde_json::Value {
Facet::state_json(&self.0)
}
}
#[allow(unused_variables)]
fn matrix(check: &str, ok: bool, detail: &str) {
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status("dwarves-gui::funnel_off_thread", check, ok, detail);
}
/// How long the stand-in consult takes. Large enough that an inline fold is
/// unmistakable in a frame time, small enough to keep the suite quick.
const CONSULT_MS: u64 = 1_500;
/// A [`LiveFunnelSource`] with a known, deliberate latency — the stand-in for the real
/// `Funnel.Show` gRPC round trip. It counts its consults, so the test can also prove
/// that a poll arriving while one is in flight is not a second round trip.
struct SlowLive {
consults: Arc<AtomicUsize>,
title: String,
}
impl LiveFunnelSource for SlowLive {
fn fetch(&self, _workspace: &str) -> Result<funnel_core::state::Funnel, anyhow::Error> {
self.consults.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(CONSULT_MS));
// A REAL funnel, reconstructed through the crate's own `funnel_from_dump` —
// the same lowering the live wire uses, never a hand-built twin.
let dump = pb::FunnelDump {
ideas: vec![pb::FunnelDumpIdea {
id: "i-slow-001".into(),
text: self.title.clone(),
plans: Vec::new(),
source: "slow-live".into(),
item_kind: "idea".into(),
}],
};
Ok(nornir_funnel_embed::funnel_from_dump(&dump))
}
fn submit(
&self,
_workspace: &str,
_text: &str,
_kind: IntakeKind,
) -> Result<String, anyhow::Error> {
Ok("i-slow-002".to_string())
}
fn describe(&self) -> String {
"slow live funnel".to_string()
}
}
/// Mount the pane in the robot harness — the SAME drive every other robot test uses
/// (`Harness` + `RobotSession`), so this measures the pane's real `Panel::ui`.
fn session(source: SlowLive) -> RobotSession<'static, FunnelApp> {
let pane = FunnelPanel::new_embedded_seeded("nordisk").with_live_source(Box::new(source));
let harness = Harness::builder()
.with_size(nornir_robotui::egui::Vec2::new(1200.0, 900.0))
.build_ui_state(
|ui, app: &mut FunnelApp| Facet::ui(&mut app.0, ui),
FunnelApp(pane),
);
RobotSession::new(harness, "dwarves-gui")
}
/// **The Funnel pane's live consult must not run on the egui thread.**
///
/// Reinstate the inline consult (`src.fetch(..)` inside `refresh`) and the polling
/// frame goes back to costing `CONSULT_MS`, which is what this fails on.
#[test]
fn the_funnel_pane_does_not_consult_the_wire_on_the_egui_thread() {
let consults = Arc::new(AtomicUsize::new(0));
let mut robot = session(SlowLive {
consults: Arc::clone(&consults),
title: "the consult landed off-thread".to_string(),
});
// Drive real frames one at a time, TIMING EACH, until one starts the consult —
// that is the frame the auto-poll lands on (`poll_due` goes true once egui's own
// clock has advanced one `poll_interval_s`), and it is the frame under test. The
// pane must have STARTED the consult and given the frame back before the source
// answers.
let mut polling = None;
for _ in 0..600 {
let t0 = Instant::now();
robot.run(1);
let held = t0.elapsed();
if consults.load(Ordering::SeqCst) > 0 {
polling = Some(held);
break;
}
}
let polling = polling.expect(
"no frame ever started the live consult — the pane's auto-poll never fired, so this test would be asserting nothing (a guard that cannot go red)",
);
assert_eq!(
consults.load(Ordering::SeqCst),
1,
"the polling frame must have STARTED exactly one consult"
);
// THE ASSERTION: a ratio against the consult's own latency, not a millisecond
// threshold (which would be a property of this machine, not of the code).
let consult = Duration::from_millis(CONSULT_MS);
let ratio = polling.as_secs_f64() / consult.as_secs_f64();
eprintln!(
"funnel polling frame: {polling:?} against a {CONSULT_MS} ms consult \
({:.1}% of it) — inline would be >= 100%",
ratio * 100.0
);
let off_thread = ratio < 0.10;
assert!(
off_thread,
"the frame carrying the funnel's auto-poll took {polling:?} against a {CONSULT_MS} ms \
consult ({:.0}% of it): `Panel::ui` is folding the wire read INLINE, so the window \
cannot paint until the server answers — every poll interval, forever. The live \
consult belongs on a worker (`start_live_consult` + `poll_live`), the way the Data \
view, Coverage, Coderoom and Lenses panes already do it.",
ratio * 100.0,
);
// …and CHEAP MUST NOT BE BOUGHT WITH WRONG: the consult still lands, and what it
// landed is the SOURCE's funnel, on a later frame. Wait it out through the
// scripted seam, then assert the applied output — the idea's own TITLE TEXT in
// the rendered plan-DAG, not a count and not a status flag.
robot.harness_mut().state_mut().0.wait_live();
robot.run(1);
let st = Facet::state_json(&robot.harness_mut().state_mut().0);
assert_eq!(st["live_status"], "live", "the consult landed: {st}");
let rendered = serde_json::to_string(&st["plandag"]).unwrap_or_default();
assert!(
rendered.contains("the consult landed off-thread"),
"the pane must render the funnel the slow source returned: {rendered}"
);
matrix(
"funnel_consult_is_off_the_egui_thread",
off_thread,
&format!(
"the frame carrying the Funnel pane's auto-poll cost {polling:?} against a \
{CONSULT_MS} ms live consult ({:.1}% of it) — the wire read runs on a named \
worker and lands via poll_live, so the window paints while the server is \
being asked; the source's funnel is still what ends up rendered",
ratio * 100.0
),
);
}
/// **A poll arriving while a consult is in flight is not a second round trip** — the
/// same rule the Coverage / Data view / Coderoom / Lenses panes hold. Without it an
/// auto-polling pane against a slow server would pile up one worker per frame.
#[test]
fn a_poll_during_an_in_flight_consult_is_not_a_second_round_trip() {
let consults = Arc::new(AtomicUsize::new(0));
let mut robot = session(SlowLive {
consults: Arc::clone(&consults),
title: "only one consult".to_string(),
});
// Many frames, all while the first (1.5 s) consult is still dialing.
robot.run(40);
let during = consults.load(Ordering::SeqCst);
assert!(
robot.harness_mut().state_mut().0.is_live_pending(),
"the slow consult is still in flight"
);
let single = during == 1;
assert!(
single,
"40 frames over one in-flight consult started {during} consults — an auto-polling \
pane must not spawn a worker per frame while one is already dialing"
);
robot.harness_mut().state_mut().0.wait_live();
assert!(
!robot.harness_mut().state_mut().0.is_live_pending(),
"the consult landed and the slot is clear"
);
matrix(
"funnel_in_flight_consult_is_not_restarted",
single,
&format!(
"40 real egui frames driven over a single in-flight {CONSULT_MS} ms consult \
started {during} consult(s) — the in-flight slot suppresses the duplicate \
round trips an auto-poll would otherwise fire every frame"
),
);
}