Skip to main content

ftui_widgets/
drift_visualization.rs

1#![forbid(unsafe_code)]
2
3//! Drift-triggered fallback visualization widget (bd-1lgz8.2).
4//!
5//! Renders live posterior sparklines per decision domain with color-coded
6//! confidence zones, fallback trigger indicators, and regime transition
7//! banners. Designed for the galaxy-brain transparency demo.
8//!
9//! # Components
10//!
11//! - **`DriftSnapshot`**: A single frame's worth of per-domain confidence data.
12//! - **`DriftTimeline`**: Ring buffer of recent snapshots for sparkline rendering.
13//! - **`DriftVisualization`**: Compound widget rendering domain sparklines with
14//!   fallback indicators and regime banners.
15
16use crate::borders::{BorderSet, BorderType};
17use crate::sparkline::Sparkline;
18use crate::{Widget, apply_style, clear_text_area, draw_text_span};
19use ftui_core::geometry::Rect;
20use ftui_render::cell::{Cell, PackedRgba};
21use ftui_render::frame::Frame;
22use ftui_runtime::transparency::TrafficLight;
23use ftui_runtime::unified_evidence::DecisionDomain;
24use ftui_style::Style;
25
26// ---------------------------------------------------------------------------
27// Color palette
28// ---------------------------------------------------------------------------
29
30const ZONE_GREEN: PackedRgba = PackedRgba::rgb(0, 180, 0);
31const ZONE_YELLOW: PackedRgba = PackedRgba::rgb(200, 180, 0);
32const ZONE_RED: PackedRgba = PackedRgba::rgb(200, 50, 50);
33const FALLBACK_FG: PackedRgba = PackedRgba::rgb(255, 80, 80);
34const FALLBACK_BG: PackedRgba = PackedRgba::rgb(80, 10, 10);
35const REGIME_FG: PackedRgba = PackedRgba::rgb(255, 200, 100);
36const DIM_FG: PackedRgba = PackedRgba::rgb(120, 120, 120);
37const LABEL_FG: PackedRgba = PackedRgba::rgb(160, 180, 200);
38
39// ---------------------------------------------------------------------------
40// DriftSnapshot — one frame's per-domain data
41// ---------------------------------------------------------------------------
42
43/// A single frame's confidence snapshot for one decision domain.
44#[derive(Debug, Clone, Copy)]
45pub struct DomainSnapshot {
46    /// The decision domain.
47    pub domain: DecisionDomain,
48    /// Confidence value (0.0 = no confidence, 1.0 = full confidence).
49    pub confidence: f64,
50    /// Current traffic light signal.
51    pub signal: TrafficLight,
52    /// Whether the domain is currently in fallback mode.
53    pub in_fallback: bool,
54    /// Active strategy/action label index (for regime display).
55    pub regime_label: &'static str,
56}
57
58/// Snapshot of all domains at a single point in time.
59#[derive(Debug, Clone)]
60pub struct DriftSnapshot {
61    /// Per-domain snapshots.
62    pub domains: Vec<DomainSnapshot>,
63    /// Frame number or tick count for timeline reference.
64    pub frame_id: u64,
65}
66
67// ---------------------------------------------------------------------------
68// DriftTimeline — ring buffer of snapshots
69// ---------------------------------------------------------------------------
70
71/// Ring buffer of recent drift snapshots for sparkline rendering.
72#[derive(Debug, Clone)]
73pub struct DriftTimeline {
74    /// Circular buffer of snapshots.
75    snapshots: Vec<DriftSnapshot>,
76    /// Write cursor (next position to write).
77    write_pos: usize,
78    /// Number of snapshots stored (≤ capacity).
79    len: usize,
80    /// Maximum capacity.
81    capacity: usize,
82}
83
84impl DriftTimeline {
85    /// Create a new timeline with the given capacity (max frames to retain).
86    #[must_use]
87    pub fn new(capacity: usize) -> Self {
88        let capacity = capacity.max(1);
89        Self {
90            snapshots: Vec::with_capacity(capacity),
91            write_pos: 0,
92            len: 0,
93            capacity,
94        }
95    }
96
97    /// Push a new snapshot into the timeline.
98    pub fn push(&mut self, snapshot: DriftSnapshot) {
99        if self.snapshots.len() < self.capacity {
100            self.snapshots.push(snapshot);
101        } else {
102            self.snapshots[self.write_pos] = snapshot;
103        }
104        self.write_pos = (self.write_pos + 1) % self.capacity;
105        self.len = (self.len + 1).min(self.capacity);
106    }
107
108    /// Number of snapshots stored.
109    #[must_use]
110    pub fn len(&self) -> usize {
111        self.len
112    }
113
114    /// Whether the timeline is empty.
115    #[must_use]
116    pub fn is_empty(&self) -> bool {
117        self.len == 0
118    }
119
120    /// Iterate snapshots in chronological order (oldest first).
121    pub fn iter_chronological(&self) -> impl Iterator<Item = &DriftSnapshot> {
122        let start = if self.len < self.capacity {
123            0
124        } else {
125            self.write_pos
126        };
127
128        (0..self.len).map(move |i| {
129            let idx = (start + i) % self.capacity;
130            &self.snapshots[idx]
131        })
132    }
133
134    /// Extract confidence values for a specific domain in chronological order.
135    pub fn confidence_series(&self, domain: DecisionDomain) -> Vec<f64> {
136        self.iter_chronological()
137            .map(|snap| {
138                snap.domains
139                    .iter()
140                    .find(|d| d.domain == domain)
141                    .map_or(0.0, |d| d.confidence)
142            })
143            .collect()
144    }
145
146    /// Find the most recent snapshot where a domain transitioned into fallback.
147    pub fn last_fallback_trigger(&self, domain: DecisionDomain) -> Option<usize> {
148        let series: Vec<bool> = self
149            .iter_chronological()
150            .map(|snap| {
151                snap.domains
152                    .iter()
153                    .find(|d| d.domain == domain)
154                    .is_some_and(|d| d.in_fallback)
155            })
156            .collect();
157
158        if series.first().copied().unwrap_or(false) {
159            return Some(0);
160        }
161
162        // Find the last rising edge (false -> true)
163        (1..series.len())
164            .rev()
165            .find(|&i| series[i] && !series[i - 1])
166    }
167
168    /// Get the latest snapshot, if any.
169    #[must_use]
170    pub fn latest(&self) -> Option<&DriftSnapshot> {
171        if self.len == 0 {
172            return None;
173        }
174        let idx = if self.write_pos == 0 {
175            self.capacity - 1
176        } else {
177            self.write_pos - 1
178        };
179        self.snapshots.get(idx)
180    }
181}
182
183// ---------------------------------------------------------------------------
184// DriftVisualization widget
185// ---------------------------------------------------------------------------
186
187/// Compound widget rendering drift-triggered fallback visualization.
188///
189/// Shows one sparkline row per decision domain, color-coded by confidence zone:
190/// - Green zone (>0.7): high confidence, Bayesian strategy active
191/// - Yellow zone (0.3–0.7): moderate confidence, potential drift
192/// - Red zone (<0.3): low confidence, fallback likely/active
193///
194/// When a domain enters fallback, a vertical marker appears on the sparkline
195/// and a regime banner flashes.
196#[derive(Debug, Clone)]
197pub struct DriftVisualization<'a> {
198    /// The timeline data source.
199    timeline: &'a DriftTimeline,
200    /// Which domains to display (None = all from latest snapshot).
201    domains: Option<Vec<DecisionDomain>>,
202    /// Border type for the widget.
203    border_type: BorderType,
204    /// Base style.
205    style: Style,
206    /// Whether to show the regime banner.
207    show_regime_banner: bool,
208    /// Fallback threshold (confidence below this = red zone).
209    fallback_threshold: f64,
210    /// Caution threshold (confidence below this = yellow zone).
211    caution_threshold: f64,
212}
213
214impl<'a> DriftVisualization<'a> {
215    /// Create a new drift visualization from a timeline.
216    #[must_use]
217    pub fn new(timeline: &'a DriftTimeline) -> Self {
218        Self {
219            timeline,
220            domains: None,
221            border_type: BorderType::Rounded,
222            style: Style::default(),
223            show_regime_banner: true,
224            fallback_threshold: 0.3,
225            caution_threshold: 0.7,
226        }
227    }
228
229    /// Only display the specified domains.
230    #[must_use]
231    pub fn domains(mut self, domains: Vec<DecisionDomain>) -> Self {
232        self.domains = Some(domains);
233        self
234    }
235
236    /// Set the border type.
237    #[must_use]
238    pub fn border_type(mut self, border_type: BorderType) -> Self {
239        self.border_type = border_type;
240        self
241    }
242
243    /// Set the base style.
244    #[must_use]
245    pub fn style(mut self, style: Style) -> Self {
246        self.style = style;
247        self
248    }
249
250    /// Enable or disable the regime banner row.
251    #[must_use]
252    pub fn show_regime_banner(mut self, show: bool) -> Self {
253        self.show_regime_banner = show;
254        self
255    }
256
257    /// Set the fallback confidence threshold (default 0.3).
258    #[must_use]
259    pub fn fallback_threshold(mut self, t: f64) -> Self {
260        self.fallback_threshold = t;
261        self
262    }
263
264    /// Set the caution confidence threshold (default 0.7).
265    #[must_use]
266    pub fn caution_threshold(mut self, t: f64) -> Self {
267        self.caution_threshold = t;
268        self
269    }
270
271    /// Determine which domains to render.
272    fn active_domains(&self) -> Vec<DecisionDomain> {
273        if let Some(ref domains) = self.domains {
274            return domains.clone();
275        }
276        if let Some(latest) = self.timeline.latest() {
277            latest.domains.iter().map(|d| d.domain).collect()
278        } else {
279            Vec::new()
280        }
281    }
282
283    /// Color for a confidence value.
284    fn confidence_color(&self, confidence: f64) -> PackedRgba {
285        let fallback = self.fallback_threshold.clamp(0.0, 1.0);
286        let caution = self.caution_threshold.clamp(fallback, 1.0);
287
288        if confidence >= caution {
289            ZONE_GREEN
290        } else if caution > fallback && confidence >= fallback {
291            // Interpolate yellow
292            let t = (confidence - fallback) / (caution - fallback);
293            lerp_color(ZONE_YELLOW, ZONE_GREEN, t)
294        } else {
295            // Interpolate red
296            let t = if fallback <= f64::EPSILON {
297                0.0
298            } else {
299                confidence / fallback
300            };
301            lerp_color(ZONE_RED, ZONE_YELLOW, t)
302        }
303    }
304
305    /// Minimum height needed for the widget.
306    #[must_use]
307    pub fn min_height(&self) -> u16 {
308        let domains = self.active_domains();
309        let domain_rows = domains.len() as u16;
310        // border_top + title + domains*(label_row + sparkline_row) + banner? + border_bottom
311        let mut h: u16 = 2; // top + bottom border
312        h += 1; // title row
313        h += domain_rows * 2; // label + sparkline per domain
314        if self.show_regime_banner {
315            h += 1;
316        }
317        h
318    }
319
320    fn render_domain_row(
321        &self,
322        domain: DecisionDomain,
323        x: u16,
324        y: u16,
325        width: u16,
326        frame: &mut Frame,
327    ) -> u16 {
328        let deg = frame.buffer.degradation;
329        let apply_styling = deg.apply_styling();
330        let max_x = x + width;
331
332        // Row 1: Domain label + current confidence badge
333        let label = domain.as_str();
334        let label_style = if apply_styling {
335            Style::new().fg(LABEL_FG)
336        } else {
337            Style::default()
338        };
339        let mut cx = draw_text_span(frame, x, y, label, label_style, max_x);
340
341        // Current confidence badge
342        if let Some(latest) = self.timeline.latest()
343            && let Some(ds) = latest.domains.iter().find(|d| d.domain == domain)
344        {
345            let conf_pct = format!(" {:.0}%", ds.confidence * 100.0);
346            let conf_color = self.confidence_color(ds.confidence);
347            let conf_style = if apply_styling {
348                Style::new().fg(conf_color).bold()
349            } else {
350                Style::default()
351            };
352            if cx < max_x {
353                cx = draw_text_span(frame, cx, y, " ", Style::default(), max_x);
354            }
355            cx = draw_text_span(frame, cx, y, &conf_pct, conf_style, max_x);
356
357            if ds.in_fallback {
358                let fb_style = if apply_styling {
359                    Style::new().fg(FALLBACK_FG).bg(FALLBACK_BG).bold()
360                } else {
361                    Style::default()
362                };
363                if cx < max_x {
364                    cx = draw_text_span(frame, cx, y, " ", Style::default(), max_x);
365                }
366                cx = draw_text_span(frame, cx, y, " FALLBACK ", fb_style, max_x);
367            }
368            let _ = cx;
369        }
370
371        // Row 2: Sparkline
372        let series = self.timeline.confidence_series(domain);
373        if !series.is_empty() {
374            let sparkline_width = width.min(series.len() as u16);
375            // Take the last `sparkline_width` values
376            let start = series.len().saturating_sub(sparkline_width as usize);
377            let visible = &series[start..];
378
379            let sparkline = Sparkline::new(visible)
380                .bounds(0.0, 1.0)
381                .gradient(ZONE_RED, ZONE_GREEN);
382            let spark_area = Rect::new(x, y + 1, sparkline_width, 1);
383            sparkline.render(spark_area, frame);
384
385            // Overlay fallback trigger marker (vertical bar at trigger point)
386            if let Some(trigger_idx) = self.timeline.last_fallback_trigger(domain) {
387                let visible_start = series.len().saturating_sub(sparkline_width as usize);
388                if trigger_idx >= visible_start {
389                    let marker_x = x + (trigger_idx - visible_start) as u16;
390                    if marker_x < max_x {
391                        let mut cell = Cell::from_char('|');
392                        if apply_styling {
393                            apply_style(&mut cell, Style::new().fg(FALLBACK_FG).bold());
394                        }
395                        frame.buffer.set_fast(marker_x, y + 1, cell);
396                    }
397                }
398            }
399        }
400
401        y + 2 // consumed 2 rows
402    }
403
404    fn render_regime_banner(&self, x: u16, y: u16, max_x: u16, frame: &mut Frame) {
405        let Some(latest) = self.timeline.latest() else {
406            return;
407        };
408        let apply_styling = frame.buffer.degradation.apply_styling();
409
410        // Find any domain in fallback
411        let fallback_domain = latest.domains.iter().find(|d| d.in_fallback);
412
413        if let Some(ds) = fallback_domain {
414            let banner = format!(
415                " REGIME: {} -> deterministic ({}) ",
416                ds.domain.as_str(),
417                ds.regime_label,
418            );
419            let style = if apply_styling {
420                Style::new().fg(REGIME_FG).bg(FALLBACK_BG).bold()
421            } else {
422                Style::default()
423            };
424            draw_text_span(frame, x, y, &banner, style, max_x);
425        } else {
426            // Normal operation
427            let style = if apply_styling {
428                Style::new().fg(DIM_FG)
429            } else {
430                Style::default()
431            };
432            draw_text_span(frame, x, y, "All domains: Bayesian (normal)", style, max_x);
433        }
434    }
435}
436
437impl Widget for DriftVisualization<'_> {
438    fn render(&self, area: Rect, frame: &mut Frame) {
439        if area.is_empty() {
440            return;
441        }
442
443        if area.width < 6 || area.height < 4 {
444            clear_text_area(frame, area, Style::default());
445            return;
446        }
447
448        let deg = frame.buffer.degradation;
449        if !deg.render_content() {
450            clear_text_area(frame, area, Style::default());
451            return;
452        }
453
454        let base_style = if deg.apply_styling() {
455            self.style
456        } else {
457            Style::default()
458        };
459        clear_text_area(frame, area, base_style);
460
461        // Draw border
462        if deg.render_decorative() {
463            let set = if deg.use_unicode_borders() {
464                self.border_type.to_border_set()
465            } else {
466                BorderSet::ASCII
467            };
468            let border_style = if deg.apply_styling() {
469                Style::new().fg(LABEL_FG)
470            } else {
471                Style::default()
472            };
473            render_border(area, frame, set, border_style);
474        }
475
476        // Inner area
477        let inner_x = area.x.saturating_add(1);
478        let inner_max_x = area.right().saturating_sub(1);
479        let inner_width = inner_max_x.saturating_sub(inner_x);
480        let mut y = area.y.saturating_add(1);
481        let max_y = area.bottom().saturating_sub(1);
482
483        if inner_width < 4 || y >= max_y {
484            return;
485        }
486
487        // Title row
488        let title_style = if deg.apply_styling() {
489            Style::new().fg(LABEL_FG).bold()
490        } else {
491            Style::default()
492        };
493        draw_text_span(frame, inner_x, y, "Drift Monitor", title_style, inner_max_x);
494        y += 1;
495
496        // Domain rows
497        let domains = self.active_domains();
498        for domain in &domains {
499            if y + 1 >= max_y {
500                break;
501            }
502            y = self.render_domain_row(*domain, inner_x, y, inner_width, frame);
503        }
504
505        // Regime banner
506        if self.show_regime_banner && y < max_y {
507            self.render_regime_banner(inner_x, y, inner_max_x, frame);
508        }
509    }
510
511    fn is_essential(&self) -> bool {
512        false
513    }
514}
515
516// ---------------------------------------------------------------------------
517// Helpers
518// ---------------------------------------------------------------------------
519
520fn lerp_color(a: PackedRgba, b: PackedRgba, t: f64) -> PackedRgba {
521    let t = t.clamp(0.0, 1.0) as f32;
522    let r = (a.r() as f32 * (1.0 - t) + b.r() as f32 * t).round() as u8;
523    let g = (a.g() as f32 * (1.0 - t) + b.g() as f32 * t).round() as u8;
524    let b_val = (a.b() as f32 * (1.0 - t) + b.b() as f32 * t).round() as u8;
525    PackedRgba::rgb(r, g, b_val)
526}
527
528fn render_border(area: Rect, frame: &mut Frame, set: BorderSet, style: Style) {
529    let border_cell = |c: char| -> Cell {
530        let mut cell = Cell::from_char(c);
531        apply_style(&mut cell, style);
532        cell
533    };
534
535    let right_x = area.right().saturating_sub(1);
536    let bottom_y = area.bottom().saturating_sub(1);
537
538    // Edges
539    for x in area.x..area.right() {
540        frame
541            .buffer
542            .set_fast(x, area.y, border_cell(set.horizontal));
543        frame
544            .buffer
545            .set_fast(x, bottom_y, border_cell(set.horizontal));
546    }
547    for y in area.y..area.bottom() {
548        frame.buffer.set_fast(area.x, y, border_cell(set.vertical));
549        frame.buffer.set_fast(right_x, y, border_cell(set.vertical));
550    }
551
552    // Corners
553    frame
554        .buffer
555        .set_fast(area.x, area.y, border_cell(set.top_left));
556    frame
557        .buffer
558        .set_fast(right_x, area.y, border_cell(set.top_right));
559    frame
560        .buffer
561        .set_fast(area.x, bottom_y, border_cell(set.bottom_left));
562    frame
563        .buffer
564        .set_fast(right_x, bottom_y, border_cell(set.bottom_right));
565}
566
567// ---------------------------------------------------------------------------
568// Tests
569// ---------------------------------------------------------------------------
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use ftui_render::budget::DegradationLevel;
575    use ftui_render::cell::Cell;
576    use ftui_render::grapheme_pool::GraphemePool;
577
578    fn make_snapshot(frame_id: u64, confidence: f64, in_fallback: bool) -> DriftSnapshot {
579        DriftSnapshot {
580            domains: vec![
581                DomainSnapshot {
582                    domain: DecisionDomain::DiffStrategy,
583                    confidence,
584                    signal: if confidence >= 0.7 {
585                        TrafficLight::Green
586                    } else if confidence >= 0.3 {
587                        TrafficLight::Yellow
588                    } else {
589                        TrafficLight::Red
590                    },
591                    in_fallback,
592                    regime_label: if in_fallback {
593                        "deterministic"
594                    } else {
595                        "bayesian"
596                    },
597                },
598                DomainSnapshot {
599                    domain: DecisionDomain::ResizeCoalescing,
600                    confidence: confidence * 0.9,
601                    signal: TrafficLight::Green,
602                    in_fallback: false,
603                    regime_label: "bayesian",
604                },
605            ],
606            frame_id,
607        }
608    }
609
610    fn make_drift_timeline() -> DriftTimeline {
611        let mut tl = DriftTimeline::new(60);
612        // Normal operation (frames 0-29)
613        for i in 0..30 {
614            tl.push(make_snapshot(i, 0.85, false));
615        }
616        // Drift onset (frames 30-39)
617        for i in 30..40 {
618            let conf = 0.85 - (i - 30) as f64 * 0.07;
619            tl.push(make_snapshot(i, conf, false));
620        }
621        // Fallback trigger (frames 40-49)
622        for i in 40..50 {
623            tl.push(make_snapshot(i, 0.15, true));
624        }
625        // Recovery (frames 50-59)
626        for i in 50..60 {
627            let conf = 0.15 + (i - 50) as f64 * 0.07;
628            tl.push(make_snapshot(i, conf, false));
629        }
630        tl
631    }
632
633    #[test]
634    fn timeline_push_and_len() {
635        let mut tl = DriftTimeline::new(10);
636        assert!(tl.is_empty());
637        assert_eq!(tl.len(), 0);
638
639        tl.push(make_snapshot(0, 0.8, false));
640        assert_eq!(tl.len(), 1);
641        assert!(!tl.is_empty());
642    }
643
644    #[test]
645    fn timeline_wraps_at_capacity() {
646        let mut tl = DriftTimeline::new(5);
647        for i in 0..10 {
648            tl.push(make_snapshot(i, 0.5, false));
649        }
650        assert_eq!(tl.len(), 5);
651
652        // Latest should be frame 9
653        assert_eq!(tl.latest().unwrap().frame_id, 9);
654    }
655
656    #[test]
657    fn timeline_chronological_order() {
658        let mut tl = DriftTimeline::new(5);
659        for i in 0..8 {
660            tl.push(make_snapshot(i, 0.5, false));
661        }
662        let ids: Vec<u64> = tl.iter_chronological().map(|s| s.frame_id).collect();
663        assert_eq!(ids, vec![3, 4, 5, 6, 7]);
664    }
665
666    #[test]
667    fn confidence_series_extraction() {
668        let tl = make_drift_timeline();
669        let series = tl.confidence_series(DecisionDomain::DiffStrategy);
670        assert_eq!(series.len(), 60);
671        // First value should be ~0.85 (normal)
672        assert!((series[0] - 0.85).abs() < 0.01);
673        // At frame 40: should be 0.15 (fallback)
674        assert!((series[40] - 0.15).abs() < 0.01);
675    }
676
677    #[test]
678    fn fallback_trigger_detection() {
679        let tl = make_drift_timeline();
680        let trigger = tl.last_fallback_trigger(DecisionDomain::DiffStrategy);
681        // First fallback entry is at index 40
682        assert_eq!(trigger, Some(40));
683    }
684
685    #[test]
686    fn no_fallback_trigger_when_none() {
687        let mut tl = DriftTimeline::new(10);
688        for i in 0..10 {
689            tl.push(make_snapshot(i, 0.8, false));
690        }
691        assert!(
692            tl.last_fallback_trigger(DecisionDomain::DiffStrategy)
693                .is_none()
694        );
695    }
696
697    #[test]
698    fn fallback_trigger_at_start_of_visible_timeline() {
699        let mut tl = DriftTimeline::new(5);
700        for i in 0..5 {
701            tl.push(make_snapshot(i, 0.15, true));
702        }
703
704        assert_eq!(
705            tl.last_fallback_trigger(DecisionDomain::DiffStrategy),
706            Some(0)
707        );
708    }
709
710    #[test]
711    fn render_empty_timeline() {
712        let tl = DriftTimeline::new(60);
713        let viz = DriftVisualization::new(&tl);
714        let mut pool = GraphemePool::new();
715        let mut frame = Frame::new(80, 24, &mut pool);
716        viz.render(Rect::new(0, 0, 80, 24), &mut frame);
717        // Should not panic
718    }
719
720    #[test]
721    fn render_populated_timeline() {
722        let tl = make_drift_timeline();
723        let viz = DriftVisualization::new(&tl);
724        let mut pool = GraphemePool::new();
725        let mut frame = Frame::new(80, 20, &mut pool);
726        viz.render(Rect::new(0, 0, 80, 20), &mut frame);
727
728        // Check title is present
729        let mut found_title = false;
730        for x in 0..80 {
731            if let Some(cell) = frame.buffer.get(x, 1)
732                && cell.content.as_char() == Some('D')
733            {
734                found_title = true;
735                break;
736            }
737        }
738        assert!(found_title, "should render title row");
739    }
740
741    #[test]
742    fn render_no_styling_drops_border_and_label_styles() {
743        let tl = make_drift_timeline();
744        let viz = DriftVisualization::new(&tl);
745        let mut pool = GraphemePool::new();
746        let mut frame = Frame::new(80, 20, &mut pool);
747        frame.buffer.degradation = DegradationLevel::NoStyling;
748
749        viz.render(Rect::new(0, 0, 80, 20), &mut frame);
750
751        let border = frame.buffer.get(0, 0).unwrap();
752        let border_default = Cell::from_char(border.content.as_char().unwrap());
753        assert_eq!(border.fg, border_default.fg);
754        assert_eq!(border.bg, border_default.bg);
755        assert_eq!(border.attrs, border_default.attrs);
756
757        let title = frame.buffer.get(1, 1).unwrap();
758        let title_default = Cell::from_char('D');
759        assert_eq!(title.content.as_char(), Some('D'));
760        assert_eq!(title.fg, title_default.fg);
761        assert_eq!(title.bg, title_default.bg);
762        assert_eq!(title.attrs, title_default.attrs);
763
764        let label = frame.buffer.get(1, 2).unwrap();
765        let label_default = Cell::from_char('d');
766        assert_eq!(label.content.as_char(), Some('d'));
767        assert_eq!(label.fg, label_default.fg);
768        assert_eq!(label.bg, label_default.bg);
769        assert_eq!(label.attrs, label_default.attrs);
770    }
771
772    #[test]
773    fn render_shows_fallback_indicator() {
774        let tl = make_drift_timeline();
775        let viz = DriftVisualization::new(&tl).domains(vec![DecisionDomain::DiffStrategy]);
776        let mut pool = GraphemePool::new();
777        let mut frame = Frame::new(80, 12, &mut pool);
778        viz.render(Rect::new(0, 0, 80, 12), &mut frame);
779
780        // Check that FALLBACK text appears (latest snapshot has in_fallback=false
781        // after recovery, so we won't see FALLBACK badge on the label row)
782        // but the sparkline should show the trigger marker
783    }
784
785    #[test]
786    fn render_regime_banner_in_fallback() {
787        // Create timeline where latest is in fallback
788        let mut tl = DriftTimeline::new(10);
789        for i in 0..10 {
790            tl.push(make_snapshot(i, 0.15, true));
791        }
792        let viz = DriftVisualization::new(&tl);
793        let mut pool = GraphemePool::new();
794        let mut frame = Frame::new(80, 12, &mut pool);
795        viz.render(Rect::new(0, 0, 80, 12), &mut frame);
796
797        // Should contain "REGIME" text
798        let mut found_regime = false;
799        for y in 0..12 {
800            let mut row = String::new();
801            for x in 0..80 {
802                if let Some(cell) = frame.buffer.get(x, y)
803                    && let Some(ch) = cell.content.as_char()
804                {
805                    row.push(ch);
806                }
807            }
808            if row.contains("REGIME") {
809                found_regime = true;
810                break;
811            }
812        }
813        assert!(found_regime, "should show regime banner when in fallback");
814    }
815
816    #[test]
817    fn render_regime_banner_normal() {
818        let mut tl = DriftTimeline::new(10);
819        for i in 0..10 {
820            tl.push(make_snapshot(i, 0.85, false));
821        }
822        let viz = DriftVisualization::new(&tl);
823        let mut pool = GraphemePool::new();
824        let mut frame = Frame::new(80, 12, &mut pool);
825        viz.render(Rect::new(0, 0, 80, 12), &mut frame);
826
827        // Should contain "Bayesian (normal)" text
828        let mut found_normal = false;
829        for y in 0..12 {
830            let mut row = String::new();
831            for x in 0..80 {
832                if let Some(cell) = frame.buffer.get(x, y)
833                    && let Some(ch) = cell.content.as_char()
834                {
835                    row.push(ch);
836                }
837            }
838            if row.contains("Bayesian") {
839                found_normal = true;
840                break;
841            }
842        }
843        assert!(found_normal, "should show normal regime banner");
844    }
845
846    #[test]
847    fn tiny_area_no_panic() {
848        let tl = make_drift_timeline();
849        let viz = DriftVisualization::new(&tl);
850        let mut pool = GraphemePool::new();
851        let mut frame = Frame::new(5, 3, &mut pool);
852        // Should not panic with tiny area
853        viz.render(Rect::new(0, 0, 5, 3), &mut frame);
854    }
855
856    #[test]
857    fn min_height_calculation() {
858        let mut tl = DriftTimeline::new(5);
859        tl.push(make_snapshot(0, 0.8, false)); // 2 domains
860        let viz = DriftVisualization::new(&tl);
861        // border_top + title + 2*domain_rows + banner + border_bottom
862        // = 1 + 1 + 2*2 + 1 + 1 = 8
863        assert_eq!(viz.min_height(), 8);
864    }
865
866    #[test]
867    fn min_height_no_banner() {
868        let mut tl = DriftTimeline::new(5);
869        tl.push(make_snapshot(0, 0.8, false));
870        let viz = DriftVisualization::new(&tl).show_regime_banner(false);
871        assert_eq!(viz.min_height(), 7);
872    }
873
874    #[test]
875    fn confidence_color_zones() {
876        let tl = DriftTimeline::new(1);
877        let viz = DriftVisualization::new(&tl);
878
879        let green = viz.confidence_color(0.9);
880        assert_eq!(green, ZONE_GREEN);
881
882        let red_ish = viz.confidence_color(0.1);
883        // Should be near ZONE_RED
884        assert!(red_ish.r() > 100);
885        assert!(red_ish.g() < 100);
886    }
887
888    #[test]
889    fn confidence_color_handles_degenerate_thresholds() {
890        let tl = DriftTimeline::new(1);
891        let viz = DriftVisualization::new(&tl)
892            .fallback_threshold(0.0)
893            .caution_threshold(0.0);
894
895        assert_eq!(viz.confidence_color(0.0), ZONE_GREEN);
896        let low = viz.confidence_color(-1.0);
897        assert!(low.r() >= ZONE_RED.r());
898    }
899
900    #[test]
901    fn builder_chain() {
902        let tl = DriftTimeline::new(10);
903        let viz = DriftVisualization::new(&tl)
904            .border_type(BorderType::Double)
905            .style(Style::new().bg(PackedRgba::rgb(10, 10, 10)))
906            .show_regime_banner(false)
907            .fallback_threshold(0.2)
908            .caution_threshold(0.8)
909            .domains(vec![DecisionDomain::DiffStrategy]);
910
911        let mut pool = GraphemePool::new();
912        let mut frame = Frame::new(80, 24, &mut pool);
913        viz.render(Rect::new(0, 0, 80, 24), &mut frame);
914    }
915
916    #[test]
917    fn is_not_essential() {
918        let tl = DriftTimeline::new(1);
919        let viz = DriftVisualization::new(&tl);
920        assert!(!viz.is_essential());
921    }
922
923    #[test]
924    fn lerp_color_endpoints() {
925        let a = PackedRgba::rgb(0, 0, 0);
926        let b = PackedRgba::rgb(255, 255, 255);
927        assert_eq!(lerp_color(a, b, 0.0), a);
928        assert_eq!(lerp_color(a, b, 1.0), b);
929    }
930
931    #[test]
932    fn lerp_color_clamps() {
933        let a = PackedRgba::rgb(0, 0, 0);
934        let b = PackedRgba::rgb(255, 255, 255);
935        assert_eq!(lerp_color(a, b, -1.0), a);
936        assert_eq!(lerp_color(a, b, 2.0), b);
937    }
938
939    #[test]
940    fn render_with_single_domain_filter() {
941        let tl = make_drift_timeline();
942        let viz = DriftVisualization::new(&tl).domains(vec![DecisionDomain::DiffStrategy]);
943        let mut pool = GraphemePool::new();
944        let mut frame = Frame::new(80, 10, &mut pool);
945        viz.render(Rect::new(0, 0, 80, 10), &mut frame);
946
947        // Should render DiffStrategy label
948        let mut found_diff = false;
949        for y in 0..10 {
950            let mut row = String::new();
951            for x in 0..80 {
952                if let Some(cell) = frame.buffer.get(x, y)
953                    && let Some(ch) = cell.content.as_char()
954                {
955                    row.push(ch);
956                }
957            }
958            if row.contains("diff_strategy") {
959                found_diff = true;
960                break;
961            }
962        }
963        assert!(found_diff, "should show DiffStrategy domain");
964    }
965
966    #[test]
967    fn render_fallback_badge_on_label_row() {
968        let mut tl = DriftTimeline::new(5);
969        for i in 0..5 {
970            tl.push(make_snapshot(i, 0.1, true));
971        }
972        let viz = DriftVisualization::new(&tl).domains(vec![DecisionDomain::DiffStrategy]);
973        let mut pool = GraphemePool::new();
974        let mut frame = Frame::new(80, 10, &mut pool);
975        viz.render(Rect::new(0, 0, 80, 10), &mut frame);
976
977        let mut found_fallback = false;
978        for y in 0..10 {
979            let mut row = String::new();
980            for x in 0..80 {
981                if let Some(cell) = frame.buffer.get(x, y)
982                    && let Some(ch) = cell.content.as_char()
983                {
984                    row.push(ch);
985                }
986            }
987            if row.contains("FALLBACK") {
988                found_fallback = true;
989                break;
990            }
991        }
992        assert!(
993            found_fallback,
994            "should show FALLBACK badge when in fallback"
995        );
996    }
997
998    #[test]
999    fn render_clears_gap_before_confidence_badge() {
1000        let tl = make_drift_timeline();
1001        let viz = DriftVisualization::new(&tl).domains(vec![DecisionDomain::DiffStrategy]);
1002        let mut pool = GraphemePool::new();
1003        let mut frame = Frame::new(80, 10, &mut pool);
1004        frame.buffer.set_fast(14, 2, Cell::from_char('X'));
1005
1006        viz.render(Rect::new(0, 0, 80, 10), &mut frame);
1007
1008        assert_eq!(
1009            frame.buffer.get(14, 2).unwrap().content.as_char(),
1010            Some(' ')
1011        );
1012    }
1013
1014    #[test]
1015    fn render_skeleton_clears_previous_visualization() {
1016        let tl = make_drift_timeline();
1017        let viz = DriftVisualization::new(&tl).show_regime_banner(false);
1018        let area = Rect::new(0, 0, 80, 10);
1019        let mut pool = GraphemePool::new();
1020        let mut frame = Frame::new(80, 10, &mut pool);
1021
1022        viz.render(area, &mut frame);
1023        frame.buffer.degradation = DegradationLevel::Skeleton;
1024        viz.render(area, &mut frame);
1025
1026        for y in 0..area.height {
1027            for x in 0..area.width {
1028                assert_eq!(frame.buffer.get(x, y).unwrap().content.as_char(), Some(' '));
1029            }
1030        }
1031    }
1032
1033    #[test]
1034    fn render_with_fewer_domains_clears_stale_rows() {
1035        let tl = make_drift_timeline();
1036        let full = DriftVisualization::new(&tl).show_regime_banner(false);
1037        let filtered = DriftVisualization::new(&tl)
1038            .domains(vec![DecisionDomain::DiffStrategy])
1039            .show_regime_banner(false);
1040        let area = Rect::new(0, 0, 80, 10);
1041        let mut pool = GraphemePool::new();
1042        let mut frame = Frame::new(80, 10, &mut pool);
1043
1044        full.render(area, &mut frame);
1045        filtered.render(area, &mut frame);
1046
1047        for y in 4..6u16 {
1048            for x in 1..area.width.saturating_sub(1) {
1049                assert_eq!(frame.buffer.get(x, y).unwrap().content.as_char(), Some(' '));
1050            }
1051        }
1052    }
1053}