nornir 0.4.13

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! ๐Ÿงช **Test** tab โ€” the C6 test-matrix (`test_results`) as a viz surface.
//!
//! A per-repo green/red matrix over the warehouse
//! [`test_results`](crate::warehouse::test_results) table:
//!
//!   * **Run list** โ€” one row per `run_id`, **newest run on top**, with the
//!     repo, a green/red verdict, and pass/fail/ignored/stalled counts.
//!   * **Case matrix** โ€” for the selected run, every test case coloured by its
//!     status: pass = green, fail = red, ignored = grey, stalled = orange. The
//!     run lights up green or red as `nornir test <repo>` records rows.
//!
//! Read path: **local** mode opens the warehouse read-only and calls
//! [`query_test_results`] โ€” lock-tolerant, so it coexists with a live server
//! holding the catalog lock (the copied-aside snapshot fallback), exactly like
//! the Release tab. **Remote** mode shows a note (no `Test.Results` RPC yet).

use std::path::PathBuf;

use eframe::egui::{self, Color32, RichText, ScrollArea};

use crate::warehouse::iceberg::IcebergWarehouse;
use crate::warehouse::test_results::{
    query_test_results, status, summarize_runs, RunSummary, TestResultRow, TestSelector,
};

/// Where the test results come from (mirrors the other tabs' local/remote split).
enum Src {
    Local(PathBuf),
    /// No `Test.Results` RPC exists yet โ€” remote mode shows a note. The endpoint
    /// is kept only to render *which* server's results are unavailable.
    Remote { endpoint: String },
}

pub struct TestTabState {
    src: Src,
    loaded: bool,
    error: Option<String>,
    /// Every case row, sorted; grouped on demand.
    rows: Vec<TestResultRow>,
    /// Per-run summaries, newest first.
    summaries: Vec<RunSummary>,
    /// The selected run_id (defaults to newest).
    selected_run: Option<String>,
}

impl TestTabState {
    pub fn local(root: PathBuf) -> Self {
        Self::with(Src::Local(root))
    }

    pub fn remote(endpoint: String) -> Self {
        Self::with(Src::Remote { endpoint })
    }

    fn with(src: Src) -> Self {
        Self {
            src,
            loaded: false,
            error: None,
            rows: Vec::new(),
            summaries: Vec::new(),
            selected_run: None,
        }
    }

    /// Test-only: inject `test_results` rows directly (no warehouse on disk),
    /// marking the tab loaded so `draw`/`state_json` render the injected matrix.
    /// Mirrors the Release tab's `inject_for_test`.
    #[doc(hidden)]
    pub fn inject_for_test(&mut self, mut rows: Vec<TestResultRow>) {
        rows.sort_by(|a, b| {
            (a.ts_micros, &a.run_id, &a.suite, &a.test_name)
                .cmp(&(b.ts_micros, &b.run_id, &b.suite, &b.test_name))
        });
        self.summaries = summarize_runs(&rows);
        self.selected_run = self.summaries.first().map(|s| s.run_id.clone());
        self.rows = rows;
        self.loaded = true;
        self.error = None;
    }

    /// Re-scope (workspace switch / reload): drop the cache so the next draw reloads.
    pub fn reload(&mut self) {
        self.loaded = false;
        self.error = None;
        self.rows.clear();
        self.summaries.clear();
        self.selected_run = None;
    }

    /// Read every test result from the warehouse (local only).
    fn load(&mut self) {
        if self.loaded {
            return;
        }
        self.loaded = true;
        let rows = match &self.src {
            Src::Local(root) => match IcebergWarehouse::open_read_only(root)
                .and_then(|wh| wh.block_on(query_test_results(&wh, &TestSelector::All)))
            {
                Ok(rows) => rows,
                Err(e) => {
                    self.error = Some(format!("{e:#}"));
                    return;
                }
            },
            Src::Remote { .. } => return,
        };
        self.summaries = summarize_runs(&rows);
        self.selected_run = self.summaries.first().map(|s| s.run_id.clone());
        self.rows = rows;
    }

    /// The currently-selected run's summary (or the newest if none chosen).
    fn current(&self) -> Option<&RunSummary> {
        let sel = self.selected_run.as_deref();
        sel.and_then(|id| self.summaries.iter().find(|s| s.run_id == id))
            .or_else(|| self.summaries.first())
    }

    /// The selected run's cases, in display order.
    fn current_cases(&self) -> Vec<&TestResultRow> {
        let Some(run) = self.current() else { return Vec::new() };
        self.rows.iter().filter(|r| r.run_id == run.run_id).collect()
    }

    pub fn draw(&mut self, ui: &mut egui::Ui) {
        self.load();

        if let Src::Remote { endpoint } = &self.src {
            ui.add_space(20.0);
            ui.heading("๐Ÿงช Test matrix");
            ui.label(format!(
                "Test results live in the warehouse on `{endpoint}`. A streaming \
                 Test.Results RPC is not wired yet โ€” point the viz at a local \
                 warehouse (NORNIR_WAREHOUSE=โ€ฆ) to browse the test matrix, or use \
                 `nornir test history <repo>` on the server host."
            ));
            return;
        }

        if let Some(err) = self.error.clone() {
            ui.colored_label(Color32::RED, format!("test_results read failed:\n{err}"));
            return;
        }
        if self.summaries.is_empty() {
            ui.vertical_centered(|ui| {
                ui.add_space(40.0);
                ui.heading("๐Ÿงช Test โ€” no test runs recorded yet");
                ui.label("Run the standardized matrix to light this up:");
                ui.monospace("nornir test <repo>      # one repo");
                ui.monospace("nornir test all          # every repo");
                ui.monospace("nornir test history <repo>   # CLI twin");
            });
            return;
        }

        // โ”€โ”€ run picker (newest first) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
        let runs: Vec<(String, String, bool, usize)> = self
            .summaries
            .iter()
            .map(|s| (s.run_id.clone(), s.repo.clone(), s.green(), s.total()))
            .collect();
        egui::TopBottomPanel::top("test_controls").show_inside(ui, |ui| {
            ui.horizontal_wrapped(|ui| {
                ui.label("run:");
                let sel = self
                    .selected_run
                    .clone()
                    .unwrap_or_else(|| runs.first().map(|r| r.0.clone()).unwrap_or_default());
                egui::ComboBox::from_id_salt("test_run")
                    .selected_text(short(&sel))
                    .show_ui(ui, |ui| {
                        for (id, repo, green, n) in &runs {
                            let mark = if *green { "โœ“" } else { "โœ—" };
                            ui.selectable_value(
                                &mut self.selected_run,
                                Some(id.clone()),
                                format!("{mark} {repo} ยท {} ยท {n} cases", short(id)),
                            );
                        }
                    });
                if ui.button("โ†ป reload").on_hover_text("re-read test_results").clicked() {
                    self.reload();
                }
            });
        });

        // โ”€โ”€ run list (left) + case matrix (right) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
        egui::SidePanel::left("test_runs")
            .resizable(true)
            .default_width(280.0)
            .show_inside(ui, |ui| {
                ui.strong("runs (newest first)");
                ui.separator();
                ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
                    for s in &self.summaries {
                        let green = s.green();
                        let dot = if green {
                            Color32::from_rgb(80, 180, 120)
                        } else {
                            Color32::from_rgb(220, 80, 80)
                        };
                        let label = format!(
                            "{}  {}   {}P {}F {}I {}S",
                            if green { "โœ“" } else { "โœ—" },
                            s.repo,
                            s.passed,
                            s.failed,
                            s.ignored,
                            s.stalled,
                        );
                        let selected = self.selected_run.as_deref() == Some(s.run_id.as_str());
                        if ui
                            .selectable_label(selected, RichText::new(label).color(dot))
                            .clicked()
                        {
                            self.selected_run = Some(s.run_id.clone());
                        }
                    }
                });
            });

        egui::CentralPanel::default().show_inside(ui, |ui| {
            let Some(run) = self.current().cloned() else { return };
            ui.horizontal(|ui| {
                ui.strong(format!("{} ยท run {}", run.repo, short(&run.run_id)));
                ui.separator();
                let (col, txt) = if run.green() {
                    (Color32::from_rgb(80, 180, 120), "โœ“ green")
                } else {
                    (Color32::from_rgb(220, 80, 80), "โœ— red")
                };
                ui.colored_label(col, txt);
                ui.separator();
                ui.weak(format!(
                    "{} passed ยท {} failed ยท {} ignored ยท {} stalled",
                    run.passed, run.failed, run.ignored, run.stalled
                ));
            });
            ui.separator();
            ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
                for c in self.current_cases() {
                    case_row(ui, c);
                }
            });
        });
    }

    /// Test/introspection hook: the matrix the tab renders, as JSON folded into
    /// the app's `state_json` (LAW #6 โ€” the rendered rows + green/red counts).
    pub fn state_json(&self) -> serde_json::Value {
        let current = self.current();
        let cases: Vec<serde_json::Value> = current
            .map(|run| {
                self.rows
                    .iter()
                    .filter(|r| r.run_id == run.run_id)
                    .map(|r| {
                        serde_json::json!({
                            "suite": r.suite,
                            "test_name": r.test_name,
                            "status": r.status,
                            "duration_ms": r.duration_ms,
                            "message": r.message,
                            "red": status::is_red(&r.status),
                        })
                    })
                    .collect()
            })
            .unwrap_or_default();
        let runs: Vec<serde_json::Value> = self
            .summaries
            .iter()
            .map(|s| {
                serde_json::json!({
                    "run_id": s.run_id,
                    "repo": s.repo,
                    "green": s.green(),
                    "passed": s.passed,
                    "failed": s.failed,
                    "ignored": s.ignored,
                    "stalled": s.stalled,
                    "total": s.total(),
                })
            })
            .collect();
        serde_json::json!({
            "source": match &self.src {
                Src::Local(p) => format!("local {}", p.display()),
                Src::Remote { endpoint } => format!("remote {endpoint} (Test.Results RPC TODO)"),
            },
            "error": self.error,
            "runs": runs,
            "run_count": self.summaries.len(),
            "selected_run": current.map(|s| s.run_id.clone()),
            "selected_repo": current.map(|s| s.repo.clone()),
            "selected_green": current.map(|s| s.green()),
            "cases": cases,
        })
    }
}

/// One case line: a coloured status chip + the suite::name + duration.
fn case_row(ui: &mut egui::Ui, r: &TestResultRow) {
    let (chip, col) = match r.status.as_str() {
        status::PASS => ("PASS", Color32::from_rgb(80, 180, 120)),
        status::FAIL => ("FAIL", Color32::from_rgb(220, 80, 80)),
        status::IGNORED => ("IGN ", Color32::from_rgb(150, 150, 150)),
        status::STALLED => ("STALL", Color32::from_rgb(230, 150, 60)),
        _ => ("?", Color32::from_rgb(180, 140, 60)),
    };
    ui.horizontal(|ui| {
        ui.label(RichText::new(chip).monospace().strong().color(col));
        ui.label(format!("{}::{}", r.suite, r.test_name));
        if r.duration_ms > 0.0 {
            ui.weak(format!("{:.1}ms", r.duration_ms));
        }
        if !r.message.is_empty() {
            ui.colored_label(Color32::from_rgb(200, 120, 120), &r.message);
        }
    });
}

/// Abbreviate a run id for compact display.
fn short(run_id: &str) -> String {
    crate::warehouse::test_results::short_run(run_id)
}