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

nordisk/dwarves

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