use std::path::PathBuf;
use eframe::egui::{self, Color32, CornerRadius, FontId, Pos2, Rect, RichText, ScrollArea, Sense, Stroke, StrokeKind, Vec2};
use super::facett_theme::Theme;
use super::test_matrix_grid::{Cell, Matrix, RepoRow};
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>,
matrix: Matrix,
theme: Theme,
selected_cell: Option<(String, 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,
matrix: Matrix::build(&[]),
theme: Theme::default(),
selected_cell: 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.matrix = Matrix::build(&rows);
self.rows = rows;
self.loaded = true;
self.error = None;
self.selected_cell = None;
self.emit_trace();
}
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
}
pub fn reload(&mut self) {
self.loaded = false;
self.error = None;
self.rows.clear();
self.summaries.clear();
self.selected_run = None;
self.matrix = Matrix::build(&[]);
self.selected_cell = None;
}
fn load(&mut self) {
if self.loaded {
return;
}
self.loaded = true;
super::trace::emit_in(
"test.matrix.load",
&serde_json::json!({ "source": self.source_label() }),
);
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.matrix = Matrix::build(&rows);
self.rows = rows;
self.emit_trace();
}
fn source_label(&self) -> String {
match &self.src {
Src::Local(p) => format!("local {}", p.display()),
Src::Remote { endpoint } => format!("remote {endpoint} (Test.Results RPC TODO)"),
}
}
fn emit_trace(&self) {
super::trace::emit_out("test.matrix.build", &self.grid_json());
super::trace::emit_end(
"test.matrix",
&serde_json::json!({
"repos": self.matrix.rows.len(),
"aspects": self.matrix.aspects.len(),
"green": self.matrix.total_green,
"red": self.matrix.total_red,
"skip": self.matrix.total_skip,
"blank": self.matrix.total_blank,
}),
);
}
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())
}
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(super::facett_theme::RED, format!("test_results read failed:\n{err}"));
return;
}
if self.matrix.rows.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 theme = self.theme;
egui::TopBottomPanel::top("test_controls").show_inside(ui, |ui| {
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new("🧪 Test matrix").heading().color(theme.text));
ui.separator();
ui.label(RichText::new("palette:").color(theme.text_dim));
let mut chosen = self.theme.name.to_string();
egui::ComboBox::from_id_salt("test_palette")
.selected_text(self.theme.name)
.show_ui(ui, |ui| {
for name in Theme::names() {
ui.selectable_value(&mut chosen, name.to_string(), name);
}
});
if chosen != self.theme.name {
if let Some(t) = Theme::by_name(&chosen) {
self.theme = t;
super::trace::emit_event(
"test.matrix.palette",
&serde_json::json!({ "palette": t.name }),
);
}
}
ui.separator();
summary_strip(ui, &theme, &self.matrix);
if ui.button("↻ reload").on_hover_text("re-read test_results").clicked() {
self.reload();
}
});
ui.add_space(4.0);
});
if self.selected_cell.is_some() {
egui::TopBottomPanel::bottom("test_drilldown")
.resizable(true)
.default_height(180.0)
.show_inside(ui, |ui| {
self.draw_drilldown(ui, &theme);
});
}
egui::CentralPanel::default().show_inside(ui, |ui| {
let full = ui.available_rect_before_wrap();
ui.painter().rect_filled(full, CornerRadius::ZERO, theme.bg);
ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
self.draw_grid(ui, &theme);
});
});
}
fn draw_grid(&mut self, ui: &mut egui::Ui, theme: &Theme) {
let aspects = self.matrix.aspects.clone();
let n_cols = aspects.len();
let repo_w = 150.0_f32;
let health_w = 90.0_f32;
let cell_w = 84.0_f32;
let row_h = 46.0_f32;
let header_h = 30.0_f32;
let pad = 4.0_f32;
let total_w = repo_w + health_w + cell_w * n_cols as f32 + pad * 2.0;
let total_h = header_h + row_h * self.matrix.rows.len() as f32 + pad * 2.0;
let (rect, _resp) = ui.allocate_exact_size(Vec2::new(total_w, total_h), Sense::hover());
let painter = ui.painter_at(rect);
let origin = rect.min + Vec2::new(pad, pad);
let mut x = origin.x + repo_w + health_w;
let head_y = origin.y;
painter.text(
origin + Vec2::new(6.0, header_h * 0.5),
egui::Align2::LEFT_CENTER,
"repo",
FontId::proportional(13.0),
theme.text_dim,
);
painter.text(
origin + Vec2::new(repo_w + 6.0, header_h * 0.5),
egui::Align2::LEFT_CENTER,
"health",
FontId::proportional(13.0),
theme.text_dim,
);
for a in &aspects {
let r = Rect::from_min_size(Pos2::new(x, head_y), Vec2::new(cell_w, header_h));
painter.text(
r.center(),
egui::Align2::CENTER_CENTER,
short_aspect(a),
FontId::proportional(12.5),
theme.accent,
);
x += cell_w;
}
painter.line_segment(
[
Pos2::new(origin.x, head_y + header_h),
Pos2::new(origin.x + total_w - pad * 2.0, head_y + header_h),
],
Stroke::new(1.5, theme.panel_stroke),
);
let pointer = ui.input(|i| i.pointer.hover_pos());
let clicked = ui.input(|i| i.pointer.primary_clicked());
let mut new_selection: Option<(String, String)> = None;
for (ri, repo) in self.matrix.rows.iter().enumerate() {
let y = origin.y + header_h + ri as f32 * row_h;
let row_rect = Rect::from_min_size(
Pos2::new(origin.x, y),
Vec2::new(total_w - pad * 2.0, row_h),
);
if ri % 2 == 1 {
painter.rect_filled(row_rect, CornerRadius::same(2), theme.node_fill.linear_multiply(0.5));
}
let row_hovered = pointer.map(|p| row_rect.contains(p)).unwrap_or(false);
if row_hovered {
painter.rect_filled(row_rect, CornerRadius::same(2), theme.accent.linear_multiply(0.06));
}
painter.text(
Pos2::new(origin.x + 6.0, y + row_h * 0.5),
egui::Align2::LEFT_CENTER,
&repo.repo,
FontId::proportional(14.0),
theme.text,
);
draw_health_badge(&painter, theme, repo, Pos2::new(origin.x + repo_w, y), health_w, row_h);
let mut cx = origin.x + repo_w + health_w;
for (ci, cell) in repo.cells.iter().enumerate() {
let aspect = &aspects[ci];
let crect = Rect::from_min_size(Pos2::new(cx + 3.0, y + 4.0), Vec2::new(cell_w - 6.0, row_h - 8.0));
let hovered = pointer.map(|p| crect.contains(p)).unwrap_or(false);
let is_sel = self
.selected_cell
.as_ref()
.map(|(r, a)| r == &repo.repo && a == aspect)
.unwrap_or(false);
draw_cell(&painter, theme, cell, aspect, &repo.sparks[ci], crect, hovered, is_sel);
if hovered && clicked && cell.ran() {
new_selection = Some((repo.repo.clone(), aspect.clone()));
}
cx += cell_w;
}
}
if let Some(sel) = new_selection {
if self.selected_cell.as_ref() == Some(&sel) {
self.selected_cell = None;
} else {
super::trace::emit_event(
"test.matrix.drilldown",
&serde_json::json!({ "repo": sel.0, "aspect": sel.1 }),
);
self.selected_cell = Some(sel);
}
}
}
fn draw_drilldown(&mut self, ui: &mut egui::Ui, theme: &Theme) {
let Some((repo, aspect)) = self.selected_cell.clone() else { return };
ui.horizontal(|ui| {
ui.label(RichText::new(format!("🔎 {repo} · {aspect}")).strong().color(theme.accent));
ui.separator();
if ui.button("✕ close").clicked() {
self.selected_cell = None;
}
});
ui.separator();
let Some(row) = self.matrix.rows.iter().find(|r| r.repo == repo) else { return };
let Some(ci) = self.matrix.aspects.iter().position(|a| a == &aspect) else { return };
let cell = &row.cells[ci];
ui.horizontal(|ui| {
let (chip, col) = status_chip(theme, &cell.status);
ui.label(RichText::new(chip).monospace().strong().color(col));
let badge = cell.badge(&aspect);
if !badge.is_empty() {
ui.label(RichText::new(badge).strong().color(theme.text));
}
ui.weak(format!("{} case(s) in latest run", cell.count));
});
ui.add_space(4.0);
ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
if cell.red_cases.is_empty() {
ui.colored_label(theme.text_dim, "no failing cases — this aspect is clean ✓");
}
for rc in &cell.red_cases {
ui.horizontal_wrapped(|ui| {
let (chip, col) = status_chip(theme, &rc.status);
ui.label(RichText::new(chip).monospace().strong().color(col));
ui.label(RichText::new(format!("{}::{}", rc.suite, rc.test_name)).color(theme.text));
if !rc.message.is_empty() {
ui.colored_label(super::facett_theme::RED, &rc.message);
}
});
}
});
}
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,
"aspect": r.aspect,
"metric": r.metric,
"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,
"skipped": s.skipped,
"total": s.total(),
})
})
.collect();
serde_json::json!({
"source": self.source_label(),
"error": self.error,
"palette": self.theme.name,
"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()),
"selected_cell": self.selected_cell.as_ref().map(|(r, a)| serde_json::json!({ "repo": r, "aspect": a })),
"cases": cases,
"grid": self.grid_json(),
})
}
fn grid_json(&self) -> serde_json::Value {
let m = &self.matrix;
let rows: Vec<serde_json::Value> = m
.rows
.iter()
.map(|r| {
let cells: Vec<serde_json::Value> = r
.cells
.iter()
.enumerate()
.map(|(ci, c)| {
let aspect = &m.aspects[ci];
serde_json::json!({
"aspect": aspect,
"status": c.status,
"ran": c.ran(),
"red": c.is_red(),
"green": c.is_green(),
"metric": c.metric,
"badge": c.badge(aspect),
"count": c.count,
"passed": c.passed,
"failed": c.failed,
"red_cases": c.red_cases.iter().map(|rc| serde_json::json!({
"test_name": rc.test_name,
"suite": rc.suite,
"status": rc.status,
"message": rc.message,
})).collect::<Vec<_>>(),
"spark": r.sparks[ci],
})
})
.collect();
serde_json::json!({
"repo": r.repo,
"run_id": r.run_id,
"health": (r.health * 10.0).round() / 10.0,
"cells": cells,
})
})
.collect();
serde_json::json!({
"aspects": m.aspects,
"repos": m.rows.iter().map(|r| r.repo.clone()).collect::<Vec<_>>(),
"rows": rows,
"totals": {
"green": m.total_green,
"red": m.total_red,
"skip": m.total_skip,
"blank": m.total_blank,
},
})
}
}
fn summary_strip(ui: &mut egui::Ui, theme: &Theme, m: &Matrix) {
let chip = |ui: &mut egui::Ui, col: Color32, label: &str, n: usize| {
ui.label(RichText::new("●").color(col));
ui.label(RichText::new(format!("{n} {label}")).color(theme.text));
ui.add_space(6.0);
};
chip(ui, super::facett_theme::GREEN, "green", m.total_green);
chip(ui, super::facett_theme::RED, "red", m.total_red);
chip(ui, Color32::from_rgb(150, 150, 160), "skip", m.total_skip);
chip(ui, theme.node_fill, "—", m.total_blank);
}
fn draw_health_badge(painter: &egui::Painter, theme: &Theme, repo: &RepoRow, top_left: Pos2, w: f32, h: f32) {
let pill = Rect::from_min_size(Pos2::new(top_left.x + 4.0, top_left.y + 9.0), Vec2::new(w - 12.0, h - 18.0));
let col = theme.health_color(repo.health);
painter.rect_filled(pill, CornerRadius::same(7), col.linear_multiply(0.28));
painter.rect_stroke(pill, CornerRadius::same(7), Stroke::new(1.5, col), StrokeKind::Inside);
painter.text(
pill.center(),
egui::Align2::CENTER_CENTER,
format!("{:.0}", repo.health),
FontId::proportional(16.0),
col,
);
}
fn draw_cell(
painter: &egui::Painter,
theme: &Theme,
cell: &Cell,
aspect: &str,
spark: &[f64],
rect: Rect,
hovered: bool,
selected: bool,
) {
if !cell.ran() {
painter.rect_filled(rect, CornerRadius::same(6), theme.node_fill.linear_multiply(0.35));
painter.text(rect.center(), egui::Align2::CENTER_CENTER, "·", FontId::proportional(16.0), theme.text_dim);
return;
}
let fill = theme.status_fill(&cell.status);
let bg_alpha = if hovered { 0.45 } else { 0.30 };
painter.rect_filled(rect, CornerRadius::same(6), fill.linear_multiply(bg_alpha));
let stroke_w = if selected { 2.5 } else { 1.2 };
let stroke_col = if selected { theme.accent } else { fill };
painter.rect_stroke(rect, CornerRadius::same(6), Stroke::new(stroke_w, stroke_col), StrokeKind::Inside);
let mark_c = Pos2::new(rect.min.x + 11.0, rect.min.y + 12.0);
match cell.status.as_str() {
status::PASS => {
painter.circle_filled(mark_c, 4.0, fill);
}
status::FAIL => {
let r = 4.0;
painter.line_segment([mark_c + Vec2::new(-r, -r), mark_c + Vec2::new(r, r)], Stroke::new(2.0, fill));
painter.line_segment([mark_c + Vec2::new(-r, r), mark_c + Vec2::new(r, -r)], Stroke::new(2.0, fill));
}
status::STALLED => {
painter.circle_stroke(mark_c, 4.0, Stroke::new(2.0, fill));
}
_ => {
painter.circle_stroke(mark_c, 3.5, Stroke::new(1.5, fill)); }
}
if aspect == "unit" {
let txt = if cell.failed > 0 {
format!("{}p {}f", cell.passed, cell.failed)
} else {
format!("{}p", cell.passed)
};
let col = if cell.failed > 0 { super::facett_theme::RED } else { super::facett_theme::GREEN };
painter.text(
Pos2::new(rect.max.x - 5.0, rect.min.y + 8.0),
egui::Align2::RIGHT_TOP,
txt,
FontId::monospace(12.0),
col,
);
} else {
let badge = cell.badge(aspect);
if !badge.is_empty() {
painter.text(
Pos2::new(rect.max.x - 5.0, rect.min.y + 8.0),
egui::Align2::RIGHT_TOP,
badge,
FontId::monospace(12.0),
theme.text,
);
}
}
draw_sparkline(painter, theme, spark, Rect::from_min_max(
Pos2::new(rect.min.x + 5.0, rect.max.y - 12.0),
Pos2::new(rect.max.x - 5.0, rect.max.y - 3.0),
));
}
fn draw_sparkline(painter: &egui::Painter, theme: &Theme, series: &[f64], rect: Rect) {
if series.len() < 2 {
return;
}
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for &v in series {
lo = lo.min(v);
hi = hi.max(v);
}
let span = (hi - lo).max(1.0);
let n = series.len();
let pts: Vec<Pos2> = series
.iter()
.enumerate()
.map(|(i, &v)| {
let t = i as f32 / (n - 1) as f32;
let x = rect.min.x + t * rect.width();
let norm = ((v - lo) / span) as f32; let y = rect.max.y - norm * rect.height();
Pos2::new(x, y)
})
.collect();
for w in pts.windows(2) {
painter.line_segment([w[0], w[1]], Stroke::new(1.4, theme.point));
}
if let Some(last) = pts.last() {
painter.circle_filled(*last, 1.8, theme.point);
}
}
fn status_chip(theme: &Theme, status: &str) -> (&'static str, Color32) {
match status {
crate::warehouse::test_results::status::PASS => ("PASS", super::facett_theme::GREEN),
crate::warehouse::test_results::status::FAIL => ("FAIL", super::facett_theme::RED),
crate::warehouse::test_results::status::IGNORED => ("IGN ", theme.text_dim),
crate::warehouse::test_results::status::STALLED => ("STALL", super::facett_theme::AMBER),
crate::warehouse::test_results::status::SKIP => ("SKIP", Color32::from_rgb(150, 150, 160)),
_ => ("?", theme.text_dim),
}
}
fn short_aspect(a: &str) -> &str {
match a {
"bench-smoke" => "bench",
"feature-powerset" => "fpow",
"doctest" => "doc",
other => other,
}
}