Skip to main content

batuta/oracle/rag/
tui.rs

1//! TUI Dashboard for RAG Oracle
2//!
3//! Interactive terminal UI for visualizing index health, query results,
4//! and system metrics. Implements Toyota Way Principle 7: Visual Control.
5//!
6//! ## Architecture (PROBAR-SPEC-009)
7//!
8//! Migrated from ratatui to presentar-terminal for stack consistency.
9//! Uses Brick Architecture with Jidoka verification gates.
10
11#[cfg(feature = "presentar-terminal")]
12use std::collections::VecDeque;
13#[cfg(feature = "presentar-terminal")]
14use std::io::{self, Write};
15#[cfg(feature = "presentar-terminal")]
16use std::time::Duration;
17
18#[cfg(feature = "presentar-terminal")]
19use crossterm::{
20    cursor,
21    event::{self, Event, KeyCode, KeyEventKind},
22    execute,
23    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
24};
25
26#[cfg(feature = "presentar-terminal")]
27use presentar_terminal::{CellBuffer, Color, DiffRenderer, Modifiers};
28
29#[cfg(feature = "presentar-terminal")]
30use super::types::{IndexHealthMetrics, RelevanceMetrics};
31
32/// CYAN color constant (not in presentar-terminal)
33#[cfg(feature = "presentar-terminal")]
34const CYAN: Color = Color { r: 0.0, g: 1.0, b: 1.0, a: 1.0 };
35
36/// Query record for history display
37#[derive(Debug, Clone)]
38pub struct QueryRecord {
39    /// Query timestamp (Unix epoch ms)
40    pub timestamp_ms: u64,
41    /// Query text (truncated)
42    pub query: String,
43    /// Primary component matched
44    pub component: String,
45    /// Query latency (ms)
46    pub latency_ms: u64,
47    /// Success flag
48    pub success: bool,
49}
50
51/// TUI Dashboard state
52#[cfg(feature = "presentar-terminal")]
53pub struct OracleDashboard {
54    /// Index health metrics
55    pub index_health: IndexHealthMetrics,
56    /// Query history (most recent first)
57    pub query_history: VecDeque<QueryRecord>,
58    /// Latency samples for sparkline
59    pub latency_samples: Vec<u64>,
60    /// Retrieval quality metrics
61    pub retrieval_metrics: RelevanceMetrics,
62    /// Selected component index
63    selected_component: usize,
64    /// Max history size
65    max_history: usize,
66    /// Refresh interval
67    refresh_interval: Duration,
68    /// Cell buffer for rendering
69    buffer: CellBuffer,
70    /// Diff renderer for efficient updates
71    renderer: DiffRenderer,
72    /// Terminal width
73    width: u16,
74    /// Terminal height
75    height: u16,
76}
77
78#[cfg(feature = "presentar-terminal")]
79impl OracleDashboard {
80    /// Create a new dashboard
81    pub fn new() -> Self {
82        let (width, height) = crossterm::terminal::size().unwrap_or((100, 30));
83        Self {
84            index_health: IndexHealthMetrics::default(),
85            query_history: VecDeque::new(),
86            latency_samples: Vec::new(),
87            retrieval_metrics: RelevanceMetrics::default(),
88            selected_component: 0,
89            max_history: 100,
90            refresh_interval: Duration::from_millis(100),
91            buffer: CellBuffer::new(width, height),
92            renderer: DiffRenderer::new(),
93            width,
94            height,
95        }
96    }
97
98    /// Add a query to history
99    pub fn record_query(&mut self, record: QueryRecord) {
100        self.latency_samples.push(record.latency_ms);
101        if self.latency_samples.len() > 50 {
102            self.latency_samples.remove(0);
103        }
104        self.query_history.push_front(record);
105        if self.query_history.len() > self.max_history {
106            self.query_history.pop_back();
107        }
108    }
109
110    /// Update index health metrics
111    pub fn update_health(&mut self, health: IndexHealthMetrics) {
112        self.index_health = health;
113    }
114
115    /// Run the TUI dashboard
116    pub fn run(&mut self) -> anyhow::Result<()> {
117        enable_raw_mode()?;
118        let mut stdout = io::stdout();
119        execute!(stdout, EnterAlternateScreen, cursor::Hide)?;
120
121        let result = self.run_loop(&mut stdout);
122
123        disable_raw_mode()?;
124        execute!(stdout, LeaveAlternateScreen, cursor::Show)?;
125
126        result
127    }
128
129    /// Main event loop
130    fn run_loop(&mut self, stdout: &mut io::Stdout) -> anyhow::Result<()> {
131        loop {
132            // Update terminal size
133            let (w, h) = crossterm::terminal::size().unwrap_or((100, 30));
134            if w != self.width || h != self.height {
135                self.width = w;
136                self.height = h;
137                self.buffer.resize(w, h);
138                self.renderer.reset();
139            }
140
141            // Clear and render
142            self.buffer.clear();
143            self.render();
144
145            // Flush to terminal
146            self.renderer.flush(&mut self.buffer, stdout)?;
147            stdout.flush()?;
148
149            if event::poll(self.refresh_interval)? {
150                let event = event::read()?;
151                let Event::Key(key) = event else { continue };
152                if key.kind != KeyEventKind::Press {
153                    continue;
154                }
155                match key.code {
156                    KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
157                    KeyCode::Up | KeyCode::Char('k') => {
158                        if self.selected_component > 0 {
159                            self.selected_component -= 1;
160                        }
161                    }
162                    KeyCode::Down | KeyCode::Char('j') => {
163                        let max = self.index_health.docs_per_component.len().saturating_sub(1);
164                        if self.selected_component < max {
165                            self.selected_component += 1;
166                        }
167                    }
168                    KeyCode::Char('r') => {
169                        // Trigger refresh - placeholder
170                    }
171                    _ => {}
172                }
173            }
174        }
175    }
176
177    /// Render the dashboard
178    fn render(&mut self) {
179        let w = self.width;
180        let h = self.height;
181
182        // Layout: Header(3) | Panels(12+) | History(8) | Help(1)
183        let header_h: u16 = 3;
184        let help_h: u16 = 1;
185        let history_h: u16 = 8;
186        let panels_h = h.saturating_sub(header_h + history_h + help_h);
187
188        self.render_header(0, 0, w, header_h);
189        self.render_panels(0, header_h, w, panels_h);
190        self.render_history(0, header_h + panels_h, w, history_h);
191        self.render_help(0, h.saturating_sub(help_h), w, help_h);
192    }
193
194    /// Write a string with color
195    fn write_str(&mut self, x: u16, y: u16, s: &str, fg: Color) {
196        let mut cx = x;
197        for ch in s.chars() {
198            if cx >= self.width {
199                break;
200            }
201            let mut buf = [0u8; 4];
202            let s = ch.encode_utf8(&mut buf);
203            self.buffer.update(cx, y, s, fg, Color::TRANSPARENT, Modifiers::NONE);
204            cx = cx.saturating_add(1);
205        }
206    }
207
208    /// Write a string clipped to panel content width (panel_w minus 2 for borders)
209    fn write_str_clipped(&mut self, x: u16, y: u16, s: &str, panel_w: u16, fg: Color) {
210        let max_len = panel_w.saturating_sub(2) as usize;
211        self.write_str(x, y, &s[..s.len().min(max_len)], fg);
212    }
213
214    /// Set a single character with color
215    fn set_char(&mut self, x: u16, y: u16, ch: char, fg: Color) {
216        if x < self.width && y < self.height {
217            let mut buf = [0u8; 4];
218            let s = ch.encode_utf8(&mut buf);
219            self.buffer.update(x, y, s, fg, Color::TRANSPARENT, Modifiers::NONE);
220        }
221    }
222
223    /// Render header with overall health
224    fn render_header(&mut self, x: u16, y: u16, w: u16, _h: u16) {
225        let coverage = self.index_health.coverage_percent;
226        let total_docs: usize = self.index_health.docs_per_component.iter().map(|(_, c)| c).sum();
227
228        // Draw border
229        self.draw_box(x, y, w, 3, " Oracle RAG Dashboard ");
230
231        // Draw gauge bar inside
232        let bar_width = w.saturating_sub(4) as usize;
233        let filled = ((coverage as usize) * bar_width / 100).min(bar_width);
234        let color = self.health_color(coverage);
235
236        let label = format!("Index Health: {}%  |  Docs: {}", coverage, total_docs);
237
238        // Draw progress bar
239        let bar = format_bar_segments(filled, bar_width);
240        self.write_str(x + 2, y + 1, &bar[..bar_width.min(bar.len())], color);
241
242        // Center the label
243        let label_x = x + 2 + ((bar_width.saturating_sub(label.len())) / 2) as u16;
244        self.write_str(label_x, y + 1, &label, color);
245    }
246
247    /// Render main panels (index status, latency, quality)
248    fn render_panels(&mut self, x: u16, y: u16, w: u16, h: u16) {
249        let panel_w = w / 3;
250
251        self.render_index_status(x, y, panel_w, h);
252        self.render_latency(x + panel_w, y, panel_w, h);
253        self.render_quality(x + 2 * panel_w, y, w.saturating_sub(2 * panel_w), h);
254    }
255
256    /// Render index status by component
257    fn render_index_status(&mut self, x: u16, y: u16, w: u16, h: u16) {
258        self.draw_box(x, y, w, h, " Index Status ");
259
260        let content_y = y + 1;
261        let content_h = h.saturating_sub(2) as usize;
262
263        // Collect data first to avoid borrow conflict
264        let rows: Vec<_> = self
265            .index_health
266            .docs_per_component
267            .iter()
268            .take(content_h)
269            .enumerate()
270            .map(|(i, (name, count))| {
271                let bar = render_bar(*count, 500, 15);
272                let (marker, color) = if i == self.selected_component {
273                    (">", Color::YELLOW)
274                } else {
275                    (" ", Color::WHITE)
276                };
277                let line = format!("{} {:12} {} {}", marker, name, bar, count);
278                (i, line, color)
279            })
280            .collect();
281
282        for (i, line, color) in rows {
283            self.write_str_clipped(x + 1, content_y + i as u16, &line, w, color);
284        }
285    }
286
287    /// Render latency sparkline
288    fn render_latency(&mut self, x: u16, y: u16, w: u16, h: u16) {
289        self.draw_box(x, y, w, h, " Query Latency ");
290
291        // Draw sparkline - collect points first to avoid borrow conflict
292        let sparkline_h = h.saturating_sub(4) as usize;
293        let spark_w = w.saturating_sub(2) as usize;
294
295        let points: Vec<(u16, u16)> = if !self.latency_samples.is_empty() && sparkline_h > 0 {
296            let max_val = *self.latency_samples.iter().max().unwrap_or(&1);
297            self.latency_samples
298                .iter()
299                .rev()
300                .take(spark_w)
301                .enumerate()
302                .flat_map(|(i, &val)| {
303                    let bar_h = if max_val > 0 {
304                        ((val as usize) * sparkline_h / (max_val as usize)).min(sparkline_h)
305                    } else {
306                        0
307                    };
308                    (0..bar_h).filter_map(move |j| {
309                        let cy = y + 1 + (sparkline_h - 1 - j) as u16;
310                        let cx = x + 1 + i as u16;
311                        if cx < x + w - 1 {
312                            Some((cx, cy))
313                        } else {
314                            None
315                        }
316                    })
317                })
318                .collect()
319        } else {
320            Vec::new()
321        };
322
323        for (cx, cy) in points {
324            self.set_char(cx, cy, '▄', CYAN);
325        }
326
327        // Stats
328        let (avg, p99) = if !self.latency_samples.is_empty() {
329            let sum: u64 = self.latency_samples.iter().sum();
330            let avg = sum / self.latency_samples.len() as u64;
331            let mut sorted = self.latency_samples.clone();
332            sorted.sort();
333            let p99_idx = (sorted.len() as f64 * 0.99) as usize;
334            let p99 = sorted.get(p99_idx.min(sorted.len() - 1)).copied().unwrap_or(0);
335            (avg, p99)
336        } else {
337            (0, 0)
338        };
339
340        let stats = format!("avg: {}ms  p99: {}ms", avg, p99);
341        self.write_str(x + 1, y + h - 2, &stats, Color::WHITE);
342    }
343
344    /// Render quality metrics
345    fn render_quality(&mut self, x: u16, y: u16, w: u16, h: u16) {
346        self.draw_box(x, y, w, h, " Retrieval Quality ");
347
348        let metrics = &self.retrieval_metrics;
349        let content_y = y + 1;
350
351        let rows =
352            [("MRR", metrics.mrr), ("NDCG", metrics.ndcg_at_k), ("R@10", metrics.recall_at_k)];
353
354        for (i, (label, value)) in rows.iter().enumerate() {
355            let bar = render_bar((*value * 100.0) as usize, 100, 12);
356            let line = format!("{:5} {:.3} {}", label, value, bar);
357            self.write_str_clipped(x + 1, content_y + i as u16, &line, w, Color::WHITE);
358        }
359    }
360
361    /// Render query history
362    fn render_history(&mut self, x: u16, y: u16, w: u16, h: u16) {
363        self.draw_box(x, y, w, h, " Recent Queries ");
364
365        // Header
366        let header = "Time       Query                          Component    Latency";
367        self.write_str_clipped(x + 1, y + 1, header, w, Color::YELLOW);
368
369        // Collect data first to avoid borrow conflict
370        let rows: Vec<_> = self
371            .query_history
372            .iter()
373            .take(h.saturating_sub(3) as usize)
374            .enumerate()
375            .map(|(i, record)| {
376                let time = format_timestamp(record.timestamp_ms);
377                let (status_char, color) =
378                    if record.success { ('+', Color::GREEN) } else { ('x', Color::RED) };
379                let line = format!(
380                    "{} {:30} {:12} {:>6}ms {}",
381                    time,
382                    truncate_query(&record.query, 30),
383                    record.component,
384                    record.latency_ms,
385                    status_char
386                );
387                (i, line, color)
388            })
389            .collect();
390
391        let content_y = y + 2;
392        for (i, line, color) in rows {
393            self.write_str_clipped(x + 1, content_y + i as u16, &line, w, color);
394        }
395    }
396
397    /// Render help bar
398    fn render_help(&mut self, x: u16, y: u16, w: u16, _h: u16) {
399        let help = " [q]uit  [r]efresh  [↑/↓]navigate ";
400        let gray = Color::new(0.5, 0.5, 0.5, 1.0);
401        self.write_str(x, y, &help[..help.len().min(w as usize)], gray);
402    }
403
404    /// Draw a box with border and title
405    fn draw_box(&mut self, x: u16, y: u16, w: u16, h: u16, title: &str) {
406        if w < 2 || h < 2 {
407            return;
408        }
409
410        // Top border
411        self.set_char(x, y, '┌', Color::WHITE);
412        for i in 1..w - 1 {
413            self.set_char(x + i, y, '─', Color::WHITE);
414        }
415        self.set_char(x + w - 1, y, '┐', Color::WHITE);
416
417        // Title
418        if !title.is_empty() && w > title.len() as u16 + 2 {
419            let title_x = x + 2;
420            self.write_str(title_x, y, title, CYAN);
421        }
422
423        // Sides
424        for i in 1..h - 1 {
425            self.set_char(x, y + i, '│', Color::WHITE);
426            self.set_char(x + w - 1, y + i, '│', Color::WHITE);
427        }
428
429        // Bottom border
430        self.set_char(x, y + h - 1, '└', Color::WHITE);
431        for i in 1..w - 1 {
432            self.set_char(x + i, y + h - 1, '─', Color::WHITE);
433        }
434        self.set_char(x + w - 1, y + h - 1, '┘', Color::WHITE);
435    }
436
437    /// Get color based on health percentage
438    fn health_color(&self, percent: u16) -> Color {
439        match percent {
440            0..=60 => Color::RED,
441            61..=80 => Color::YELLOW,
442            _ => Color::GREEN,
443        }
444    }
445}
446
447#[cfg(feature = "presentar-terminal")]
448impl Default for OracleDashboard {
449    fn default() -> Self {
450        Self::new()
451    }
452}
453
454/// Build a bar string from pre-computed filled count and total width.
455fn format_bar_segments(filled: usize, width: usize) -> String {
456    let clamped = filled.min(width);
457    let empty = width.saturating_sub(clamped);
458    format!("{}{}", "\u{2588}".repeat(clamped), "\u{2591}".repeat(empty))
459}
460
461/// Render a horizontal bar
462fn render_bar(value: usize, max: usize, width: usize) -> String {
463    let filled = if max > 0 { (value * width / max).min(width) } else { 0 };
464    format_bar_segments(filled, width)
465}
466
467/// Format timestamp for display
468fn format_timestamp(timestamp_ms: u64) -> String {
469    let secs = timestamp_ms / 1000;
470    let hours = (secs / 3600) % 24;
471    let mins = (secs / 60) % 60;
472    let secs = secs % 60;
473    format!("{:02}:{:02}:{:02}", hours, mins, secs)
474}
475
476/// Truncate query for display
477fn truncate_query(query: &str, max_len: usize) -> String {
478    if query.len() <= max_len {
479        query.to_string()
480    } else {
481        format!("{}...", &query[..max_len - 3])
482    }
483}
484
485/// Inline visualizations for CLI output
486pub mod inline {
487    use super::format_bar_segments;
488
489    /// Render a horizontal bar chart
490    pub fn bar(value: f64, max: f64, width: usize) -> String {
491        let filled = if max > 0.0 { ((value / max) * width as f64) as usize } else { 0 };
492        format_bar_segments(filled, width)
493    }
494
495    /// Render a sparkline from values
496    pub fn sparkline(values: &[f64]) -> String {
497        const BARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
498
499        if values.is_empty() {
500            return String::new();
501        }
502
503        let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
504        let min = values.iter().copied().fold(f64::INFINITY, f64::min);
505        let range = max - min;
506
507        values
508            .iter()
509            .map(|v| {
510                let idx = if range == 0.0 { 0 } else { ((v - min) / range * 7.0) as usize };
511                BARS[idx.min(7)]
512            })
513            .collect()
514    }
515
516    /// Format a score as a bar with percentage
517    pub fn score_bar(score: f64, width: usize) -> String {
518        let pct = (score * 100.0) as usize;
519        format!("{} {:3}%", bar(score, 1.0, width), pct)
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    /// Helper: build a `QueryRecord` with defaults, overriding specific fields.
528    fn make_record(query: &str, latency_ms: u64, success: bool) -> QueryRecord {
529        QueryRecord {
530            timestamp_ms: 0,
531            query: query.to_string(),
532            component: "test".to_string(),
533            latency_ms,
534            success,
535        }
536    }
537
538    /// Helper: count occurrences of a character in a bar string.
539    fn count_bar_char(bar: &str, ch: char) -> usize {
540        bar.chars().filter(|c| *c == ch).count()
541    }
542
543    #[test]
544    fn test_render_bar() {
545        let bar = render_bar(50, 100, 10);
546        assert_eq!(count_bar_char(&bar, '\u{2588}'), 5);
547        assert_eq!(count_bar_char(&bar, '\u{2591}'), 5);
548    }
549
550    #[test]
551    fn test_render_bar_full() {
552        let bar = render_bar(100, 100, 10);
553        assert_eq!(count_bar_char(&bar, '\u{2588}'), 10);
554    }
555
556    #[test]
557    fn test_render_bar_empty() {
558        let bar = render_bar(0, 100, 10);
559        assert_eq!(count_bar_char(&bar, '\u{2591}'), 10);
560    }
561
562    #[test]
563    fn test_format_timestamp() {
564        let ts = format_timestamp(45296000); // 12:34:56
565        assert_eq!(ts, "12:34:56");
566    }
567
568    #[test]
569    fn test_truncate_query_short() {
570        let q = truncate_query("short", 10);
571        assert_eq!(q, "short");
572    }
573
574    #[test]
575    fn test_truncate_query_long() {
576        let q = truncate_query("this is a very long query", 15);
577        assert!(q.ends_with("..."));
578        assert!(q.len() <= 15);
579    }
580
581    #[test]
582    fn test_inline_bar() {
583        let bar = inline::bar(0.5, 1.0, 10);
584        assert_eq!(count_bar_char(&bar, '\u{2588}'), 5);
585    }
586
587    #[test]
588    fn test_inline_sparkline() {
589        let spark = inline::sparkline(&[0.0, 0.5, 1.0, 0.5, 0.0]);
590        assert_eq!(spark.chars().count(), 5);
591        assert!(spark.contains('\u{2581}'));
592        assert!(spark.contains('\u{2588}'));
593    }
594
595    #[test]
596    fn test_inline_sparkline_empty() {
597        let spark = inline::sparkline(&[]);
598        assert!(spark.is_empty());
599    }
600
601    #[test]
602    fn test_inline_score_bar() {
603        let bar = inline::score_bar(0.85, 10);
604        assert!(bar.contains("85%"));
605    }
606
607    #[cfg(feature = "presentar-terminal")]
608    #[test]
609    fn test_dashboard_creation() {
610        let dashboard = OracleDashboard::new();
611        assert!(dashboard.query_history.is_empty());
612        assert!(dashboard.latency_samples.is_empty());
613    }
614
615    #[cfg(feature = "presentar-terminal")]
616    #[test]
617    fn test_dashboard_record_query() {
618        let mut dashboard = OracleDashboard::new();
619        let mut record = make_record("test query", 50, true);
620        record.timestamp_ms = 1234567890;
621        record.component = "trueno".to_string();
622        dashboard.record_query(record);
623
624        assert_eq!(dashboard.query_history.len(), 1);
625        assert_eq!(dashboard.latency_samples.len(), 1);
626    }
627
628    #[cfg(feature = "presentar-terminal")]
629    #[test]
630    fn test_dashboard_default() {
631        let dashboard = OracleDashboard::default();
632        assert!(dashboard.query_history.is_empty());
633        assert_eq!(dashboard.selected_component, 0);
634    }
635
636    #[cfg(feature = "presentar-terminal")]
637    #[test]
638    fn test_dashboard_update_health() {
639        let mut dashboard = OracleDashboard::new();
640        let health = IndexHealthMetrics {
641            coverage_percent: 85,
642            docs_per_component: vec![("trueno".to_string(), 100)],
643            component_names: vec!["trueno".to_string()],
644            latency_samples: vec![10, 20, 30],
645            mrr_history: vec![0.8, 0.85],
646            ndcg_history: vec![0.9, 0.92],
647            freshness_score: 95.0,
648        };
649
650        dashboard.update_health(health);
651        assert_eq!(dashboard.index_health.coverage_percent, 85);
652        assert_eq!(dashboard.index_health.docs_per_component.len(), 1);
653    }
654
655    #[cfg(feature = "presentar-terminal")]
656    #[test]
657    fn test_dashboard_latency_samples_bounded() {
658        let mut dashboard = OracleDashboard::new();
659
660        for i in 0..60 {
661            let mut record = make_record(&format!("query {}", i), i as u64 * 10, true);
662            record.timestamp_ms = i as u64;
663            dashboard.record_query(record);
664        }
665
666        assert_eq!(dashboard.latency_samples.len(), 50);
667    }
668
669    #[cfg(feature = "presentar-terminal")]
670    #[test]
671    fn test_dashboard_query_history_bounded() {
672        let mut dashboard = OracleDashboard::new();
673
674        for i in 0..110 {
675            let mut record = make_record(&format!("query {}", i), 10, i % 2 == 0);
676            record.timestamp_ms = i as u64;
677            dashboard.record_query(record);
678        }
679
680        assert_eq!(dashboard.query_history.len(), 100);
681    }
682
683    #[cfg(feature = "presentar-terminal")]
684    #[test]
685    fn test_dashboard_query_order() {
686        let mut dashboard = OracleDashboard::new();
687
688        let mut first = make_record("first", 10, true);
689        first.timestamp_ms = 100;
690        dashboard.record_query(first);
691
692        let mut second = make_record("second", 20, true);
693        second.timestamp_ms = 200;
694        dashboard.record_query(second);
695
696        assert_eq!(dashboard.query_history.front().expect("unexpected failure").query, "second");
697        assert_eq!(dashboard.query_history.back().expect("unexpected failure").query, "first");
698    }
699
700    #[test]
701    fn test_render_bar_overflow() {
702        let bar = render_bar(200, 100, 10);
703        assert_eq!(count_bar_char(&bar, '\u{2588}'), 10);
704    }
705
706    #[test]
707    fn test_render_bar_zero_max() {
708        let bar = render_bar(50, 0, 10);
709        assert_eq!(count_bar_char(&bar, '\u{2591}'), 10);
710    }
711
712    #[test]
713    fn test_format_timestamp_edge() {
714        assert_eq!(format_timestamp(0), "00:00:00");
715        assert_eq!(format_timestamp(86399000), "23:59:59");
716    }
717
718    #[test]
719    fn test_truncate_query_exact() {
720        let q = truncate_query("exactly_ten", 10);
721        assert!(q.len() <= 10);
722    }
723
724    #[test]
725    fn test_truncate_query_unicode() {
726        let q = truncate_query("hello world test", 10);
727        assert!(q.len() <= 10);
728    }
729
730    #[test]
731    fn test_inline_bar_zero() {
732        let bar = inline::bar(0.0, 1.0, 10);
733        assert_eq!(count_bar_char(&bar, '\u{2591}'), 10);
734    }
735
736    #[test]
737    fn test_inline_bar_full() {
738        let bar = inline::bar(1.0, 1.0, 10);
739        assert_eq!(count_bar_char(&bar, '\u{2588}'), 10);
740    }
741
742    #[test]
743    fn test_inline_bar_zero_max() {
744        let bar = inline::bar(0.5, 0.0, 10);
745        assert_eq!(count_bar_char(&bar, '\u{2591}'), 10);
746    }
747
748    #[test]
749    fn test_inline_sparkline_constant() {
750        let spark = inline::sparkline(&[5.0, 5.0, 5.0]);
751        assert_eq!(spark.chars().count(), 3);
752        let chars: Vec<char> = spark.chars().collect();
753        assert_eq!(chars[0], chars[1]);
754        assert_eq!(chars[1], chars[2]);
755    }
756
757    #[test]
758    fn test_inline_sparkline_single() {
759        let spark = inline::sparkline(&[1.0]);
760        assert_eq!(spark.chars().count(), 1);
761    }
762
763    #[test]
764    fn test_inline_score_bar_zero() {
765        let bar = inline::score_bar(0.0, 10);
766        assert!(bar.contains("0%"));
767    }
768
769    #[test]
770    fn test_inline_score_bar_full() {
771        let bar = inline::score_bar(1.0, 10);
772        assert!(bar.contains("100%"));
773    }
774
775    #[test]
776    fn test_query_record_fields() {
777        let mut record = make_record("test", 50, false);
778        record.timestamp_ms = 1000;
779        record.component = "comp".to_string();
780
781        assert_eq!(record.timestamp_ms, 1000);
782        assert_eq!(record.query, "test");
783        assert_eq!(record.component, "comp");
784        assert_eq!(record.latency_ms, 50);
785        assert!(!record.success);
786    }
787
788    #[cfg(feature = "presentar-terminal")]
789    #[test]
790    fn test_health_color_red() {
791        let dashboard = OracleDashboard::new();
792        let color = dashboard.health_color(50);
793        assert_eq!(color, Color::RED);
794    }
795
796    #[cfg(feature = "presentar-terminal")]
797    #[test]
798    fn test_health_color_yellow() {
799        let dashboard = OracleDashboard::new();
800        let color = dashboard.health_color(75);
801        assert_eq!(color, Color::YELLOW);
802    }
803
804    #[cfg(feature = "presentar-terminal")]
805    #[test]
806    fn test_health_color_green() {
807        let dashboard = OracleDashboard::new();
808        let color = dashboard.health_color(90);
809        assert_eq!(color, Color::GREEN);
810    }
811}