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,
};
enum Src {
Local(PathBuf),
Remote { endpoint: String },
}
pub struct TestTabState {
src: Src,
loaded: bool,
error: Option<String>,
rows: Vec<TestResultRow>,
summaries: Vec<RunSummary>,
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,
}
}
#[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;
}
pub fn reload(&mut self) {
self.loaded = false;
self.error = None;
self.rows.clear();
self.summaries.clear();
self.selected_run = None;
}
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;
}
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())
}
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;
}
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();
}
});
});
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);
}
});
});
}
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,
})
}
}
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);
}
});
}
fn short(run_id: &str) -> String {
crate::warehouse::test_results::short_run(run_id)
}