1#![forbid(unsafe_code)]
2
3use 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
26const 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#[derive(Debug, Clone, Copy)]
45pub struct DomainSnapshot {
46 pub domain: DecisionDomain,
48 pub confidence: f64,
50 pub signal: TrafficLight,
52 pub in_fallback: bool,
54 pub regime_label: &'static str,
56}
57
58#[derive(Debug, Clone)]
60pub struct DriftSnapshot {
61 pub domains: Vec<DomainSnapshot>,
63 pub frame_id: u64,
65}
66
67#[derive(Debug, Clone)]
73pub struct DriftTimeline {
74 snapshots: Vec<DriftSnapshot>,
76 write_pos: usize,
78 len: usize,
80 capacity: usize,
82}
83
84impl DriftTimeline {
85 #[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 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 #[must_use]
110 pub fn len(&self) -> usize {
111 self.len
112 }
113
114 #[must_use]
116 pub fn is_empty(&self) -> bool {
117 self.len == 0
118 }
119
120 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 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 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 (1..series.len())
164 .rev()
165 .find(|&i| series[i] && !series[i - 1])
166 }
167
168 #[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#[derive(Debug, Clone)]
197pub struct DriftVisualization<'a> {
198 timeline: &'a DriftTimeline,
200 domains: Option<Vec<DecisionDomain>>,
202 border_type: BorderType,
204 style: Style,
206 show_regime_banner: bool,
208 fallback_threshold: f64,
210 caution_threshold: f64,
212}
213
214impl<'a> DriftVisualization<'a> {
215 #[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 #[must_use]
231 pub fn domains(mut self, domains: Vec<DecisionDomain>) -> Self {
232 self.domains = Some(domains);
233 self
234 }
235
236 #[must_use]
238 pub fn border_type(mut self, border_type: BorderType) -> Self {
239 self.border_type = border_type;
240 self
241 }
242
243 #[must_use]
245 pub fn style(mut self, style: Style) -> Self {
246 self.style = style;
247 self
248 }
249
250 #[must_use]
252 pub fn show_regime_banner(mut self, show: bool) -> Self {
253 self.show_regime_banner = show;
254 self
255 }
256
257 #[must_use]
259 pub fn fallback_threshold(mut self, t: f64) -> Self {
260 self.fallback_threshold = t;
261 self
262 }
263
264 #[must_use]
266 pub fn caution_threshold(mut self, t: f64) -> Self {
267 self.caution_threshold = t;
268 self
269 }
270
271 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 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 let t = (confidence - fallback) / (caution - fallback);
293 lerp_color(ZONE_YELLOW, ZONE_GREEN, t)
294 } else {
295 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 #[must_use]
307 pub fn min_height(&self) -> u16 {
308 let domains = self.active_domains();
309 let domain_rows = domains.len() as u16;
310 let mut h: u16 = 2; h += 1; h += domain_rows * 2; 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 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 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 let series = self.timeline.confidence_series(domain);
373 if !series.is_empty() {
374 let sparkline_width = width.min(series.len() as u16);
375 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 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 }
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 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 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 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 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 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 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 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
516fn 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 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 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#[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 for i in 0..30 {
614 tl.push(make_snapshot(i, 0.85, false));
615 }
616 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 for i in 40..50 {
623 tl.push(make_snapshot(i, 0.15, true));
624 }
625 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 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 assert!((series[0] - 0.85).abs() < 0.01);
673 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 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 }
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 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 }
784
785 #[test]
786 fn render_regime_banner_in_fallback() {
787 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 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 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 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)); let viz = DriftVisualization::new(&tl);
861 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 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 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}