use crate::error::{CliError, Result};
use aprender::format::validation::{AprValidator, TensorStats};
use aprender::serialization::apr::AprReader;
use crossterm::{
cursor,
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{self, ClearType},
};
use humansize::{format_size, BINARY};
use presentar_core::{Canvas, Color, Point, Rect, TextStyle};
use presentar_terminal::direct::{CellBuffer, DiffRenderer, DirectTerminalCanvas};
use presentar_terminal::ColorMode;
use std::io::{self, Write};
use std::path::PathBuf;
use std::time::{Duration, Instant};
const CYAN: Color = Color {
r: 0.4,
g: 0.85,
b: 1.0,
a: 1.0,
};
const WHITE: Color = Color {
r: 1.0,
g: 1.0,
b: 1.0,
a: 1.0,
};
const DIM: Color = Color {
r: 0.5,
g: 0.5,
b: 0.5,
a: 1.0,
};
const YELLOW: Color = Color {
r: 1.0,
g: 0.85,
b: 0.3,
a: 1.0,
};
const GREEN: Color = Color {
r: 0.3,
g: 1.0,
b: 0.5,
a: 1.0,
};
const RED: Color = Color {
r: 1.0,
g: 0.3,
b: 0.3,
a: 1.0,
};
const SELECTED_BG: Color = Color {
r: 0.15,
g: 0.2,
b: 0.3,
a: 1.0,
};
const HEADER_BG: Color = Color {
r: 0.1,
g: 0.12,
b: 0.18,
a: 1.0,
};
fn text_style(color: Color) -> TextStyle {
TextStyle {
color,
..TextStyle::default()
}
}
fn bold_style(color: Color) -> TextStyle {
TextStyle {
color,
weight: presentar_core::FontWeight::Bold,
..TextStyle::default()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Tab {
Overview,
Tensors,
Stats,
Help,
}
impl Tab {
const ALL: [Tab; 4] = [Tab::Overview, Tab::Tensors, Tab::Stats, Tab::Help];
fn title(self) -> &'static str {
match self {
Tab::Overview => " Overview ",
Tab::Tensors => " Tensors ",
Tab::Stats => " Stats ",
Tab::Help => " Help ",
}
}
fn index(self) -> usize {
match self {
Tab::Overview => 0,
Tab::Tensors => 1,
Tab::Stats => 2,
Tab::Help => 3,
}
}
fn from_index(i: usize) -> Self {
match i % 4 {
0 => Tab::Overview,
1 => Tab::Tensors,
2 => Tab::Stats,
_ => Tab::Help,
}
}
}
#[derive(Debug, Clone)]
struct TensorInfo {
name: String,
shape: Vec<usize>,
dtype: String,
size_bytes: usize,
stats: Option<TensorStats>,
}
struct App {
file_path: Option<PathBuf>,
reader: Option<AprReader>,
validation_score: Option<u8>,
current_tab: Tab,
tensors: Vec<TensorInfo>,
selected: usize,
should_quit: bool,
error_message: Option<String>,
}
impl App {
fn new(file_path: Option<PathBuf>) -> Self {
let mut app = Self {
file_path,
reader: None,
validation_score: None,
current_tab: Tab::Overview,
tensors: Vec::new(),
selected: 0,
should_quit: false,
error_message: None,
};
app.load_model();
app
}
fn load_model(&mut self) {
let Some(path) = &self.file_path else { return };
match std::fs::read(path) {
Ok(data) => {
let mut validator = AprValidator::new();
let report = validator.validate_bytes(&data);
self.validation_score = Some(report.total_score);
match AprReader::from_bytes(data) {
Ok(reader) => {
self.load_tensors(&reader);
self.reader = Some(reader);
}
Err(e) => {
self.error_message = Some(format!("Failed to parse model: {e}"));
}
}
}
Err(e) => {
self.error_message = Some(format!("Failed to read file: {e}"));
}
}
}
fn load_tensors(&mut self, reader: &AprReader) {
self.tensors.clear();
for desc in &reader.tensors {
let stats = reader
.read_tensor_f32(&desc.name)
.ok()
.map(|data| TensorStats::compute(&desc.name, &data));
self.tensors.push(TensorInfo {
name: desc.name.clone(),
shape: desc.shape.clone(),
dtype: desc.dtype.clone(),
size_bytes: desc.size,
stats,
});
}
}
fn next_tab(&mut self) {
self.current_tab = Tab::from_index(self.current_tab.index() + 1);
}
fn prev_tab(&mut self) {
self.current_tab = Tab::from_index(self.current_tab.index() + 3);
}
fn select_next(&mut self) {
if !self.tensors.is_empty() {
self.selected = (self.selected + 1) % self.tensors.len();
}
}
fn select_prev(&mut self) {
if !self.tensors.is_empty() {
self.selected = if self.selected == 0 {
self.tensors.len() - 1
} else {
self.selected - 1
};
}
}
fn selected_tensor(&self) -> Option<&TensorInfo> {
self.tensors.get(self.selected)
}
fn handle_key(&mut self, code: KeyCode) -> bool {
match code {
KeyCode::Char('q') | KeyCode::Esc => return true,
KeyCode::Tab => self.next_tab(),
KeyCode::BackTab => self.prev_tab(),
KeyCode::Char('1') => self.current_tab = Tab::Overview,
KeyCode::Char('2') => self.current_tab = Tab::Tensors,
KeyCode::Char('3') => self.current_tab = Tab::Stats,
KeyCode::Char('?') | KeyCode::Char('4') => self.current_tab = Tab::Help,
KeyCode::Down | KeyCode::Char('j') => self.select_next(),
KeyCode::Up | KeyCode::Char('k') => self.select_prev(),
KeyCode::Home => self.selected = 0,
KeyCode::End => {
self.selected = self.tensors.len().saturating_sub(1);
}
_ => {}
}
false
}
}
#[provable_contracts_macros::contract("apr-cli-operations-v1", equation = "long_running_graceful")]
pub(crate) fn run(file: Option<PathBuf>) -> Result<()> {
let mut stdout = io::stdout();
setup_terminal(&mut stdout)?;
let mut app = App::new(file);
let color_mode = ColorMode::detect();
let mut renderer = DiffRenderer::with_color_mode(color_mode);
let result = run_loop(&mut stdout, &mut app, &mut renderer);
cleanup_terminal(&mut stdout);
result
}
fn setup_terminal(stdout: &mut io::Stdout) -> Result<()> {
terminal::enable_raw_mode()
.map_err(|e| CliError::ValidationFailed(format!("Failed to enable raw mode: {e}")))?;
execute!(
stdout,
terminal::EnterAlternateScreen,
cursor::Hide,
terminal::Clear(ClearType::All)
)
.map_err(|e| CliError::ValidationFailed(format!("Failed to setup terminal: {e}")))?;
Ok(())
}
fn cleanup_terminal(stdout: &mut io::Stdout) {
terminal::disable_raw_mode().ok();
execute!(stdout, terminal::LeaveAlternateScreen, cursor::Show).ok();
}
fn run_loop(stdout: &mut io::Stdout, app: &mut App, renderer: &mut DiffRenderer) -> Result<()> {
let render_interval = Duration::from_millis(16); let mut last_render = Instant::now();
let mut force_full = true;
loop {
if event::poll(Duration::from_millis(16))
.map_err(|e| CliError::ValidationFailed(format!("Event poll error: {e}")))?
{
match event::read()
.map_err(|e| CliError::ValidationFailed(format!("Event read error: {e}")))?
{
Event::Key(key) => {
if key.kind == KeyEventKind::Press && app.handle_key(key.code) {
return Ok(());
}
}
Event::Resize(_, _) => {
force_full = true;
}
_ => {}
}
}
if last_render.elapsed() >= render_interval {
let (width, height) = terminal::size()
.map_err(|e| CliError::ValidationFailed(format!("Terminal size error: {e}")))?;
let mut buffer = CellBuffer::new(width, height);
draw(app, &mut buffer);
execute!(stdout, cursor::MoveTo(0, 0)).ok();
let mut output = Vec::with_capacity(32768);
if force_full {
renderer.render_full(&mut buffer, &mut output).ok();
force_full = false;
} else {
renderer.flush(&mut buffer, &mut output).ok();
}
stdout.write_all(&output).ok();
stdout.flush().ok();
last_render = Instant::now();
}
}
}
fn draw(app: &App, buffer: &mut CellBuffer) {
let w = buffer.width() as f32;
let h = buffer.height() as f32;
if w < 20.0 || h < 8.0 {
return;
}
let mut canvas = DirectTerminalCanvas::new(buffer);
draw_header(app, &mut canvas, w);
draw_tabs(app, &mut canvas, w);
let body_rect = Rect::new(0.0, 2.0, w, h - 3.0);
match app.current_tab {
Tab::Overview => draw_overview(app, &mut canvas, body_rect),
Tab::Tensors => draw_tensors(app, &mut canvas, body_rect),
Tab::Stats => draw_stats(app, &mut canvas, body_rect),
Tab::Help => draw_help(&mut canvas, body_rect),
}
draw_footer(app, &mut canvas, w, h - 1.0);
}
fn draw_header(app: &App, canvas: &mut DirectTerminalCanvas<'_>, width: f32) {
canvas.fill_rect(Rect::new(0.0, 0.0, width, 1.0), HEADER_BG);
let title = match &app.file_path {
Some(p) => format!(" APR Model Inspector — {} ", p.display()),
None => " APR Model Inspector — No file loaded ".to_string(),
};
canvas.draw_text(&title, Point::new(0.0, 0.0), &bold_style(WHITE));
let version = format!("v{} ", env!("CARGO_PKG_VERSION"));
let vx = width - version.len() as f32;
canvas.draw_text(&version, Point::new(vx, 0.0), &text_style(DIM));
}
fn draw_tabs(app: &App, canvas: &mut DirectTerminalCanvas<'_>, width: f32) {
let y = 1.0;
let mut x = 1.0_f32;
for tab in Tab::ALL {
let title = tab.title();
let is_active = tab == app.current_tab;
if is_active {
canvas.fill_rect(Rect::new(x, y, title.len() as f32, 1.0), CYAN);
canvas.draw_text(
title,
Point::new(x, y),
&bold_style(Color {
r: 0.0,
g: 0.0,
b: 0.1,
a: 1.0,
}),
);
} else {
canvas.draw_text(title, Point::new(x, y), &text_style(DIM));
}
x += title.len() as f32 + 1.0;
}
let hint = "[1-4/Tab]";
let hx = width - hint.len() as f32 - 1.0;
canvas.draw_text(hint, Point::new(hx, y), &text_style(DIM));
}
fn draw_footer(app: &App, canvas: &mut DirectTerminalCanvas<'_>, width: f32, y: f32) {
canvas.fill_rect(Rect::new(0.0, y, width, 1.0), HEADER_BG);
let status = match app.current_tab {
Tab::Overview => " Tab:switch q:quit",
Tab::Tensors => " j/k:navigate Tab:switch q:quit",
Tab::Stats => " Tab:switch q:quit",
Tab::Help => " Tab:switch q:quit ?:help",
};
canvas.draw_text(status, Point::new(0.0, y), &text_style(DIM));
if !app.tensors.is_empty() {
let info = format!("{} tensors ", app.tensors.len());
let ix = width - info.len() as f32;
canvas.draw_text(&info, Point::new(ix, y), &text_style(DIM));
}
}
fn draw_overview(app: &App, canvas: &mut DirectTerminalCanvas<'_>, area: Rect) {
if let Some(ref error) = app.error_message {
canvas.draw_text(
error,
Point::new(area.x + 2.0, area.y + 1.0),
&text_style(RED),
);
return;
}
let Some(reader) = &app.reader else {
canvas.draw_text(
"No model loaded. Run: apr tui <model.apr>",
Point::new(area.x + 2.0, area.y + 1.0),
&text_style(YELLOW),
);
return;
};
canvas.stroke_rect(area, CYAN, 1.0);
canvas.draw_text(
" Overview ",
Point::new(area.x + 2.0, area.y),
&bold_style(WHITE),
);
let lx = area.x + 2.0;
let vx = area.x + 16.0;
let mut y = area.y + 1.0;
let fields: &[(&str, Option<String>)] = &[
(
"Model Type:",
reader
.metadata
.get("model_type")
.map(|v| v.to_string().trim_matches('"').to_string()),
),
(
"Model Name:",
reader
.metadata
.get("model_name")
.map(|v| v.to_string().trim_matches('"').to_string()),
),
(
"Framework:",
reader
.metadata
.get("framework")
.map(|v| v.to_string().trim_matches('"').to_string()),
),
];
for (label, value) in fields {
if let Some(v) = value {
canvas.draw_text(label, Point::new(lx, y), &text_style(CYAN));
canvas.draw_text(v, Point::new(vx, y), &text_style(WHITE));
y += 1.0;
}
}
y += 1.0;
canvas.draw_text("Tensors:", Point::new(lx, y), &text_style(CYAN));
canvas.draw_text(
&format!("{}", app.tensors.len()),
Point::new(vx, y),
&text_style(WHITE),
);
y += 1.0;
let total_size: usize = app.tensors.iter().map(|t| t.size_bytes).sum();
canvas.draw_text("Total Size:", Point::new(lx, y), &text_style(CYAN));
canvas.draw_text(
&format_size(total_size, BINARY),
Point::new(vx, y),
&text_style(WHITE),
);
y += 1.0;
if let Some(score) = app.validation_score {
let score_color = if score >= 90 {
GREEN
} else if score >= 70 {
YELLOW
} else {
RED
};
canvas.draw_text("QA Score:", Point::new(lx, y), &text_style(CYAN));
canvas.draw_text(
&format!("{score}/100"),
Point::new(vx, y),
&text_style(score_color),
);
y += 1.0;
}
if let Some(hp) = reader.metadata.get("hyperparameters") {
y += 1.0;
canvas.draw_text("Hyperparameters:", Point::new(lx, y), &bold_style(CYAN));
y += 1.0;
if let Some(obj) = hp.as_object() {
for (k, v) in obj {
if y >= area.y + area.height - 1.0 {
break;
}
canvas.draw_text(
&format!("{k}: {v}"),
Point::new(lx + 2.0, y),
&text_style(DIM),
);
y += 1.0;
}
}
}
}
fn draw_tensors(app: &App, canvas: &mut DirectTerminalCanvas<'_>, area: Rect) {
let split = if area.width >= 100.0 {
area.width * 0.6
} else {
area.width * 0.5
};
let left = Rect::new(area.x, area.y, split, area.height);
let right = Rect::new(area.x + split, area.y, area.width - split, area.height);
canvas.stroke_rect(left, CYAN, 1.0);
canvas.draw_text(
" Tensors (j/k) ",
Point::new(left.x + 2.0, left.y),
&bold_style(WHITE),
);
let visible_rows = (left.height as usize).saturating_sub(2);
let scroll = if app.selected >= visible_rows {
app.selected - visible_rows + 1
} else {
0
};
for (i, tensor) in app
.tensors
.iter()
.skip(scroll)
.take(visible_rows)
.enumerate()
{
let y = left.y + 1.0 + i as f32;
let is_selected = scroll + i == app.selected;
let shape_str = format!("{:?}", tensor.shape);
let max_name = (split as usize).saturating_sub(shape_str.len() + 6);
let line = format!("{} {}", truncate_name(&tensor.name, max_name), shape_str);
if is_selected {
canvas.fill_rect(
Rect::new(left.x + 1.0, y, left.width - 2.0, 1.0),
SELECTED_BG,
);
canvas.draw_text("▸ ", Point::new(left.x + 1.0, y), &text_style(CYAN));
canvas.draw_text(&line, Point::new(left.x + 3.0, y), &bold_style(WHITE));
} else {
canvas.draw_text(
&format!(" {line}"),
Point::new(left.x + 1.0, y),
&text_style(DIM),
);
}
}
canvas.stroke_rect(right, CYAN, 1.0);
canvas.draw_text(
" Details ",
Point::new(right.x + 2.0, right.y),
&bold_style(WHITE),
);
if let Some(tensor) = app.selected_tensor() {
let dx = right.x + 2.0;
let vx = right.x + 10.0;
let mut y = right.y + 1.0;
let details: &[(&str, String)] = &[
("Name:", tensor.name.clone()),
("Shape:", format!("{:?}", tensor.shape)),
("DType:", tensor.dtype.clone()),
("Size:", format_size(tensor.size_bytes, BINARY)),
];
for (label, value) in details {
if y >= right.y + right.height - 1.0 {
break;
}
canvas.draw_text(label, Point::new(dx, y), &text_style(CYAN));
canvas.draw_text(value, Point::new(vx, y), &text_style(WHITE));
y += 1.0;
}
if let Some(ref stats) = tensor.stats {
y += 1.0;
if y < right.y + right.height - 1.0 {
canvas.draw_text("Statistics:", Point::new(dx, y), &bold_style(CYAN));
y += 1.0;
}
let stat_lines: &[(&str, String)] = &[
("Min:", format!("{:.6}", stats.min)),
("Max:", format!("{:.6}", stats.max)),
("Mean:", format!("{:.6}", stats.mean)),
("Std:", format!("{:.6}", stats.std)),
("Zeros:", format!("{}", stats.zero_count)),
("NaNs:", format!("{}", stats.nan_count)),
("Infs:", format!("{}", stats.inf_count)),
];
for (label, value) in stat_lines {
if y >= right.y + right.height - 1.0 {
break;
}
canvas.draw_text(label, Point::new(dx + 2.0, y), &text_style(DIM));
canvas.draw_text(value, Point::new(dx + 10.0, y), &text_style(WHITE));
y += 1.0;
}
if y + 2.0 < right.y + right.height {
y += 1.0;
canvas.draw_text("Distribution:", Point::new(dx, y), &text_style(CYAN));
y += 1.0;
let spark_width = (right.width as usize).saturating_sub(6);
let spark = build_sparkline(stats, spark_width);
canvas.draw_text(&spark, Point::new(dx + 1.0, y), &text_style(CYAN));
}
}
} else {
canvas.draw_text(
"Select a tensor",
Point::new(right.x + 2.0, right.y + 2.0),
&text_style(DIM),
);
}
}
fn draw_stats(app: &App, canvas: &mut DirectTerminalCanvas<'_>, area: Rect) {
canvas.stroke_rect(area, CYAN, 1.0);
canvas.draw_text(
" Statistics ",
Point::new(area.x + 2.0, area.y),
&bold_style(WHITE),
);
if app.tensors.is_empty() {
canvas.draw_text(
"No tensor data available",
Point::new(area.x + 2.0, area.y + 2.0),
&text_style(YELLOW),
);
return;
}
let w = area.width;
let cx = [
area.x + 2.0,
area.x + w * 0.30,
area.x + w * 0.55,
area.x + w * 0.70,
area.x + w * 0.85,
];
let y = area.y + 1.0;
let headers = ["Tensor", "Shape", "Size", "Mean", "Std"];
for (i, h) in headers.iter().enumerate() {
canvas.draw_text(h, Point::new(cx[i], y), &bold_style(CYAN));
}
let sep = "─".repeat((w as usize).saturating_sub(2));
canvas.draw_text(
&sep,
Point::new(area.x + 1.0, area.y + 2.0),
&text_style(DIM),
);
let visible = (area.height as usize).saturating_sub(4);
for (i, tensor) in app.tensors.iter().take(visible).enumerate() {
let row_y = area.y + 3.0 + i as f32;
if row_y >= area.y + area.height - 1.0 {
break;
}
let name_max = (w * 0.28) as usize;
canvas.draw_text(
&truncate_name(&tensor.name, name_max),
Point::new(cx[0], row_y),
&text_style(WHITE),
);
canvas.draw_text(
&format!("{:?}", tensor.shape),
Point::new(cx[1], row_y),
&text_style(DIM),
);
canvas.draw_text(
&format_size(tensor.size_bytes, BINARY),
Point::new(cx[2], row_y),
&text_style(DIM),
);
let (mean, std) = match &tensor.stats {
Some(s) => (format!("{:.4}", s.mean), format!("{:.4}", s.std)),
None => ("-".to_string(), "-".to_string()),
};
canvas.draw_text(&mean, Point::new(cx[3], row_y), &text_style(WHITE));
canvas.draw_text(&std, Point::new(cx[4], row_y), &text_style(WHITE));
}
}
fn draw_help(canvas: &mut DirectTerminalCanvas<'_>, area: Rect) {
canvas.stroke_rect(area, CYAN, 1.0);
canvas.draw_text(
" Help ",
Point::new(area.x + 2.0, area.y),
&bold_style(WHITE),
);
let mut y = area.y + 1.0;
canvas.draw_text(
"Keyboard Shortcuts",
Point::new(area.x + 2.0, y),
&bold_style(CYAN),
);
y += 2.0;
let bindings: &[(&str, &str)] = &[
("1, 2, 3, ?", "Switch to Overview/Tensors/Stats/Help"),
("Tab / Shift-Tab", "Next / Previous tab"),
("j / Down", "Select next tensor"),
("k / Up", "Select previous tensor"),
("Home / End", "Jump to first / last tensor"),
("q / Esc", "Quit"),
];
for (key, desc) in bindings {
if y >= area.y + area.height - 1.0 {
break;
}
canvas.draw_text(key, Point::new(area.x + 4.0, y), &text_style(YELLOW));
canvas.draw_text(desc, Point::new(area.x + 22.0, y), &text_style(WHITE));
y += 1.0;
}
y += 1.0;
if y < area.y + area.height - 1.0 {
canvas.draw_text("About", Point::new(area.x + 2.0, y), &bold_style(CYAN));
y += 2.0;
}
if y < area.y + area.height - 1.0 {
canvas.draw_text(
"APR Model Inspector — presentar-terminal TUI",
Point::new(area.x + 4.0, y),
&text_style(WHITE),
);
y += 1.0;
}
if y < area.y + area.height - 1.0 {
canvas.draw_text(
&format!("Version: {}", env!("CARGO_PKG_VERSION")),
Point::new(area.x + 4.0, y),
&text_style(DIM),
);
}
}
fn truncate_name(name: &str, max_len: usize) -> String {
if name.len() <= max_len {
name.to_string()
} else if max_len < 4 {
name.chars().take(max_len).collect()
} else {
let mut end = max_len - 3;
while end > 0 && !name.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &name[..end])
}
}
fn build_sparkline(stats: &TensorStats, width: usize) -> String {
if width == 0 {
return String::new();
}
const SPARK: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let mut result = String::with_capacity(width);
for i in 0..width {
let x = (i as f64 / width as f64) * 6.0 - 3.0;
let gaussian = (-x * x / 2.0).exp();
let idx = (gaussian * 7.0).round() as usize;
result.push(SPARK[idx.min(7)]);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tab_titles() {
assert_eq!(Tab::Overview.title(), " Overview ");
assert_eq!(Tab::Help.title(), " Help ");
}
#[test]
fn test_tab_index_roundtrip() {
for tab in Tab::ALL {
assert_eq!(Tab::from_index(tab.index()), tab);
}
}
#[test]
fn test_app_new_no_file() {
let app = App::new(None);
assert!(app.file_path.is_none());
assert!(app.reader.is_none());
assert!(app.tensors.is_empty());
assert!(!app.should_quit);
}
#[test]
fn test_app_next_tab() {
let mut app = App::new(None);
assert_eq!(app.current_tab, Tab::Overview);
app.next_tab();
assert_eq!(app.current_tab, Tab::Tensors);
app.next_tab();
assert_eq!(app.current_tab, Tab::Stats);
app.next_tab();
assert_eq!(app.current_tab, Tab::Help);
app.next_tab();
assert_eq!(app.current_tab, Tab::Overview);
}
#[test]
fn test_app_prev_tab() {
let mut app = App::new(None);
app.prev_tab();
assert_eq!(app.current_tab, Tab::Help);
app.prev_tab();
assert_eq!(app.current_tab, Tab::Stats);
}
#[test]
fn test_handle_key_quit() {
let mut app = App::new(None);
assert!(app.handle_key(KeyCode::Char('q')));
let mut app2 = App::new(None);
assert!(app2.handle_key(KeyCode::Esc));
}
#[test]
fn test_handle_key_tab_switch() {
let mut app = App::new(None);
assert!(!app.handle_key(KeyCode::Char('2')));
assert_eq!(app.current_tab, Tab::Tensors);
assert!(!app.handle_key(KeyCode::Char('3')));
assert_eq!(app.current_tab, Tab::Stats);
}
#[test]
fn test_truncate_name() {
assert_eq!(truncate_name("short", 10), "short");
assert_eq!(truncate_name("very_long_tensor_name", 10), "very_lo...");
assert_eq!(truncate_name("ab", 2), "ab");
}
#[test]
fn test_build_sparkline() {
let stats = TensorStats {
name: "test".to_string(),
count: 100,
min: -1.0,
max: 1.0,
mean: 0.0,
std: 0.5,
zero_count: 0,
nan_count: 0,
inf_count: 0,
};
let spark = build_sparkline(&stats, 20);
assert_eq!(spark.chars().count(), 20);
}
#[test]
fn test_select_navigation_empty() {
let mut app = App::new(None);
app.select_next();
app.select_prev();
assert_eq!(app.selected, 0);
}
}