1#![forbid(unsafe_code)]
2
3use crate::block::{Alignment, Block};
6use crate::borders::{BorderType, Borders};
7use crate::paragraph::Paragraph;
8use crate::{Widget, clear_text_area};
9use ftui_core::geometry::Rect;
10use ftui_render::cell::{Cell, PackedRgba};
11use ftui_render::frame::Frame;
12use ftui_style::Style;
13
14#[derive(Debug, Clone)]
16pub struct VoiPosteriorSummary {
17 pub alpha: f64,
18 pub beta: f64,
19 pub mean: f64,
20 pub variance: f64,
21 pub expected_variance_after: f64,
22 pub voi_gain: f64,
23}
24
25#[derive(Debug, Clone)]
27pub struct VoiDecisionSummary {
28 pub event_idx: u64,
29 pub should_sample: bool,
30 pub reason: String,
31 pub score: f64,
32 pub cost: f64,
33 pub log_bayes_factor: f64,
34 pub e_value: f64,
35 pub e_threshold: f64,
36 pub boundary_score: f64,
37}
38
39#[derive(Debug, Clone)]
41pub struct VoiObservationSummary {
42 pub sample_idx: u64,
43 pub violated: bool,
44 pub posterior_mean: f64,
45 pub alpha: f64,
46 pub beta: f64,
47}
48
49#[derive(Debug, Clone)]
51pub enum VoiLedgerEntry {
52 Decision {
53 event_idx: u64,
54 should_sample: bool,
55 voi_gain: f64,
56 log_bayes_factor: f64,
57 },
58 Observation {
59 sample_idx: u64,
60 violated: bool,
61 posterior_mean: f64,
62 },
63}
64
65#[derive(Debug, Clone)]
67pub struct VoiOverlayData {
68 pub title: String,
69 pub tick: Option<u64>,
70 pub source: Option<String>,
71 pub posterior: VoiPosteriorSummary,
72 pub decision: Option<VoiDecisionSummary>,
73 pub observation: Option<VoiObservationSummary>,
74 pub ledger: Vec<VoiLedgerEntry>,
75}
76
77#[derive(Debug, Clone)]
79pub struct VoiOverlayStyle {
80 pub border: Style,
81 pub text: Style,
82 pub background: Option<PackedRgba>,
83 pub border_type: BorderType,
84}
85
86impl Default for VoiOverlayStyle {
87 fn default() -> Self {
88 Self {
89 border: Style::new(),
90 text: Style::new(),
91 background: None,
92 border_type: BorderType::Rounded,
93 }
94 }
95}
96
97#[derive(Debug, Clone)]
99pub struct VoiDebugOverlay {
100 data: VoiOverlayData,
101 style: VoiOverlayStyle,
102}
103
104impl VoiDebugOverlay {
105 pub fn new(data: VoiOverlayData) -> Self {
107 Self {
108 data,
109 style: VoiOverlayStyle::default(),
110 }
111 }
112
113 #[must_use]
115 pub fn with_style(mut self, style: VoiOverlayStyle) -> Self {
116 self.style = style;
117 self
118 }
119
120 fn build_lines(&self, line_width: usize) -> Vec<String> {
121 let mut lines = Vec::with_capacity(20);
122 let divider = "-".repeat(line_width);
123
124 let mut header = self.data.title.clone();
125 if let Some(tick) = self.data.tick {
126 header.push_str(&format!(" (tick {})", tick));
127 }
128 if let Some(source) = &self.data.source {
129 header.push_str(&format!(" [{source}]"));
130 }
131
132 lines.push(header);
133 lines.push(divider.clone());
134
135 if let Some(decision) = &self.data.decision {
136 let verdict = if decision.should_sample {
137 "SAMPLE"
138 } else {
139 "SKIP"
140 };
141 lines.push(format!(
142 "Decision: {:<6} reason: {}",
143 verdict, decision.reason
144 ));
145 lines.push(format!(
146 "log10 BF: {:+.3} score/cost",
147 decision.log_bayes_factor
148 ));
149 lines.push(format!(
150 "E: {:.3} / {:.2} boundary: {:.3}",
151 decision.e_value, decision.e_threshold, decision.boundary_score
152 ));
153 } else {
154 lines.push("Decision: —".to_string());
155 }
156
157 lines.push(String::new());
158 lines.push("Posterior Core".to_string());
159 lines.push(divider.clone());
160 lines.push(format!(
161 "p ~ Beta(a,b) a={:.2} b={:.2}",
162 self.data.posterior.alpha, self.data.posterior.beta
163 ));
164 lines.push(format!(
165 "mu={:.4} Var={:.6}",
166 self.data.posterior.mean, self.data.posterior.variance
167 ));
168 lines.push("VOI = Var[p] - E[Var|1]".to_string());
169 lines.push(format!(
170 "VOI = {:.6} - {:.6} = {:.6}",
171 self.data.posterior.variance,
172 self.data.posterior.expected_variance_after,
173 self.data.posterior.voi_gain
174 ));
175
176 if let Some(decision) = &self.data.decision {
177 lines.push(String::new());
178 lines.push("Decision Equation".to_string());
179 lines.push(divider.clone());
180 lines.push(format!(
181 "score={:.6} cost={:.6}",
182 decision.score, decision.cost
183 ));
184 lines.push(format!(
185 "log10 BF = log10({:.6}/{:.6}) = {:+.3}",
186 decision.score, decision.cost, decision.log_bayes_factor
187 ));
188 }
189
190 if let Some(obs) = &self.data.observation {
191 lines.push(String::new());
192 lines.push("Last Sample".to_string());
193 lines.push(divider.clone());
194 lines.push(format!(
195 "violated: {} a={:.1} b={:.1} mu={:.3}",
196 obs.violated, obs.alpha, obs.beta, obs.posterior_mean
197 ));
198 }
199
200 if !self.data.ledger.is_empty() {
201 lines.push(String::new());
202 lines.push("Evidence Ledger (Recent)".to_string());
203 lines.push(divider);
204 for entry in &self.data.ledger {
205 match entry {
206 VoiLedgerEntry::Decision {
207 event_idx,
208 should_sample,
209 voi_gain,
210 log_bayes_factor,
211 } => {
212 let verdict = if *should_sample { "S" } else { "-" };
213 lines.push(format!(
214 "D#{:>3} {verdict} VOI={:.5} logBF={:+.2}",
215 event_idx, voi_gain, log_bayes_factor
216 ));
217 }
218 VoiLedgerEntry::Observation {
219 sample_idx,
220 violated,
221 posterior_mean,
222 } => {
223 lines.push(format!(
224 "O#{:>3} viol={} mu={:.3}",
225 sample_idx, violated, posterior_mean
226 ));
227 }
228 }
229 }
230 }
231
232 lines
233 }
234}
235
236impl Widget for VoiDebugOverlay {
237 fn render(&self, area: Rect, frame: &mut Frame) {
238 if area.is_empty() {
239 return;
240 }
241
242 if area.width < 20 || area.height < 6 {
243 clear_text_area(frame, area, Style::default());
244 return;
245 }
246
247 let deg = frame.buffer.degradation;
248 if !deg.render_content() {
249 clear_text_area(frame, area, Style::default());
250 return;
251 }
252
253 if deg.apply_styling()
254 && let Some(bg) = self.style.background
255 {
256 let cell = Cell::default().with_bg(bg);
257 frame.buffer.fill(area, cell);
258 }
259
260 let block = Block::new()
261 .borders(Borders::ALL)
262 .border_type(self.style.border_type)
263 .border_style(self.style.border)
264 .title(&self.data.title)
265 .title_alignment(Alignment::Center)
266 .style(self.style.text);
267
268 let inner = block.inner(area);
269 block.render(area, frame);
270
271 if inner.is_empty() {
272 return;
273 }
274
275 let line_width = inner.width.saturating_sub(2) as usize;
276 let lines = self.build_lines(line_width.max(1));
277 let text = lines.join("\n");
278 Paragraph::new(text)
279 .style(self.style.text)
280 .render(inner, frame);
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use ftui_render::budget::DegradationLevel;
288 use ftui_render::grapheme_pool::GraphemePool;
289
290 fn sample_posterior() -> VoiPosteriorSummary {
291 VoiPosteriorSummary {
292 alpha: 3.2,
293 beta: 7.4,
294 mean: 0.301,
295 variance: 0.0123,
296 expected_variance_after: 0.0101,
297 voi_gain: 0.0022,
298 }
299 }
300
301 fn sample_data() -> VoiOverlayData {
302 VoiOverlayData {
303 title: "VOI Overlay".to_string(),
304 tick: Some(42),
305 source: Some("budget".to_string()),
306 posterior: sample_posterior(),
307 decision: Some(VoiDecisionSummary {
308 event_idx: 7,
309 should_sample: true,
310 reason: "voi_gain > cost".to_string(),
311 score: 0.123456,
312 cost: 0.045,
313 log_bayes_factor: 0.437,
314 e_value: 1.23,
315 e_threshold: 0.95,
316 boundary_score: 0.77,
317 }),
318 observation: Some(VoiObservationSummary {
319 sample_idx: 4,
320 violated: false,
321 posterior_mean: 0.312,
322 alpha: 3.9,
323 beta: 8.2,
324 }),
325 ledger: vec![
326 VoiLedgerEntry::Decision {
327 event_idx: 5,
328 should_sample: true,
329 voi_gain: 0.0042,
330 log_bayes_factor: 0.31,
331 },
332 VoiLedgerEntry::Observation {
333 sample_idx: 3,
334 violated: true,
335 posterior_mean: 0.4,
336 },
337 ],
338 }
339 }
340
341 #[test]
342 fn build_lines_without_decision_or_ledger() {
343 let data = VoiOverlayData {
344 title: "VOI".to_string(),
345 tick: None,
346 source: None,
347 posterior: sample_posterior(),
348 decision: None,
349 observation: None,
350 ledger: Vec::new(),
351 };
352 let overlay = VoiDebugOverlay::new(data);
353 let lines = overlay.build_lines(24);
354
355 assert!(lines[0].contains("VOI"), "header missing title: {lines:?}");
356 assert_eq!(lines[1].len(), 24, "divider width mismatch: {lines:?}");
357 assert!(
358 lines.iter().any(|line| line.contains("Decision: —")),
359 "missing default decision line: {lines:?}"
360 );
361 assert!(
362 lines.iter().any(|line| line.contains("Posterior Core")),
363 "missing posterior section: {lines:?}"
364 );
365 assert!(
366 !lines.iter().any(|line| line.contains("Evidence Ledger")),
367 "unexpected ledger section: {lines:?}"
368 );
369 }
370
371 #[test]
372 fn build_lines_with_decision_and_observation() {
373 let overlay = VoiDebugOverlay::new(sample_data());
374 let lines = overlay.build_lines(30);
375
376 assert!(
377 lines.iter().any(|line| line.contains("Decision: SAMPLE")),
378 "missing decision summary: {lines:?}"
379 );
380 assert!(
381 lines.iter().any(|line| line.contains("Last Sample")),
382 "missing observation summary: {lines:?}"
383 );
384 assert!(
385 lines.iter().any(|line| line.contains("Evidence Ledger")),
386 "missing ledger header: {lines:?}"
387 );
388 assert!(
389 lines.iter().any(|line| line.contains("D# 5")),
390 "missing decision ledger entry: {lines:?}"
391 );
392 assert!(
393 lines.iter().any(|line| line.contains("O# 3")),
394 "missing observation ledger entry: {lines:?}"
395 );
396 }
397
398 #[test]
399 fn render_applies_background_and_border() {
400 let bg = PackedRgba::rgb(12, 34, 56);
401 let style = VoiOverlayStyle {
402 background: Some(bg),
403 ..VoiOverlayStyle::default()
404 };
405 let overlay = VoiDebugOverlay::new(sample_data()).with_style(style);
406
407 let mut pool = GraphemePool::new();
408 let mut frame = Frame::new(80, 32, &mut pool);
409 let area = Rect::new(0, 0, 80, 32);
410
411 overlay.render(area, &mut frame);
412
413 let top_left = frame.buffer.get(0, 0).unwrap();
414 assert_eq!(
415 top_left.content.as_char(),
416 Some('╭'),
417 "border not rendered as rounded: cell={top_left:?}"
418 );
419
420 let inner = Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2);
421 let lines = overlay.build_lines(inner.width.saturating_sub(2) as usize);
422 let extra_row = inner.y + (lines.len() as u16).saturating_add(1);
423 let bg_cell = frame.buffer.get(inner.x + 1, extra_row).unwrap();
424 assert_eq!(
425 bg_cell.bg,
426 bg,
427 "background not applied at ({}, {}): cell={bg_cell:?}",
428 inner.x + 1,
429 extra_row
430 );
431 }
432
433 #[test]
434 fn render_small_area_clears_previous_content() {
435 let overlay = VoiDebugOverlay::new(sample_data());
436 let mut pool = GraphemePool::new();
437 let mut frame = Frame::new(10, 4, &mut pool);
438 let sentinel = Cell::from_char('X').with_bg(PackedRgba::rgb(1, 2, 3));
439 frame.buffer.fill(Rect::new(0, 0, 10, 4), sentinel);
440
441 overlay.render(Rect::new(0, 0, 10, 4), &mut frame);
442
443 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some(' '));
444 assert_eq!(frame.buffer.get(9, 3).unwrap().content.as_char(), Some(' '));
445 }
446
447 #[test]
448 fn render_no_styling_drops_background_fill() {
449 let bg = PackedRgba::rgb(12, 34, 56);
450 let style = VoiOverlayStyle {
451 background: Some(bg),
452 ..VoiOverlayStyle::default()
453 };
454 let overlay = VoiDebugOverlay::new(sample_data()).with_style(style);
455
456 let mut pool = GraphemePool::new();
457 let mut frame = Frame::new(80, 32, &mut pool);
458 frame.buffer.degradation = DegradationLevel::NoStyling;
459 let area = Rect::new(0, 0, 80, 32);
460
461 overlay.render(area, &mut frame);
462
463 let bg_cell = frame.buffer.get(2, 2).unwrap();
464 let default_cell = Cell::default();
465 assert_eq!(bg_cell.bg, default_cell.bg);
466 }
467
468 #[test]
469 fn render_skeleton_clears_previous_overlay() {
470 let overlay = VoiDebugOverlay::new(sample_data());
471
472 let mut pool = GraphemePool::new();
473 let mut frame = Frame::new(80, 32, &mut pool);
474 overlay.render(Rect::new(0, 0, 80, 32), &mut frame);
475 frame.buffer.degradation = DegradationLevel::Skeleton;
476 let area = Rect::new(0, 0, 80, 32);
477
478 overlay.render(area, &mut frame);
479
480 let default_cell = Cell::default();
481 let corner = frame.buffer.get(0, 0).unwrap();
482 let inner = frame.buffer.get(10, 10).unwrap();
483 assert_eq!(corner.content.as_char(), Some(' '));
484 assert_eq!(corner.fg, default_cell.fg);
485 assert_eq!(corner.bg, default_cell.bg);
486 assert_eq!(inner.content.as_char(), Some(' '));
487 assert_eq!(inner.fg, default_cell.fg);
488 assert_eq!(inner.bg, default_cell.bg);
489 }
490
491 #[test]
494 fn overlay_style_default() {
495 let style = VoiOverlayStyle::default();
496 assert!(style.background.is_none());
497 assert!(matches!(style.border_type, BorderType::Rounded));
498 }
499
500 #[test]
503 fn build_lines_header_with_tick_and_source() {
504 let data = VoiOverlayData {
505 title: "Test".to_string(),
506 tick: Some(100),
507 source: Some("resize".to_string()),
508 posterior: sample_posterior(),
509 decision: None,
510 observation: None,
511 ledger: Vec::new(),
512 };
513 let overlay = VoiDebugOverlay::new(data);
514 let lines = overlay.build_lines(40);
515 assert!(lines[0].contains("Test (tick 100) [resize]"));
516 }
517
518 #[test]
519 fn build_lines_header_no_tick_no_source() {
520 let data = VoiOverlayData {
521 title: "Plain".to_string(),
522 tick: None,
523 source: None,
524 posterior: sample_posterior(),
525 decision: None,
526 observation: None,
527 ledger: Vec::new(),
528 };
529 let overlay = VoiDebugOverlay::new(data);
530 let lines = overlay.build_lines(20);
531 assert_eq!(lines[0], "Plain");
532 }
533
534 #[test]
537 fn build_lines_skip_verdict() {
538 let data = VoiOverlayData {
539 title: "Test".to_string(),
540 tick: None,
541 source: None,
542 posterior: sample_posterior(),
543 decision: Some(VoiDecisionSummary {
544 event_idx: 1,
545 should_sample: false,
546 reason: "cost_too_high".to_string(),
547 score: 0.01,
548 cost: 0.1,
549 log_bayes_factor: -1.0,
550 e_value: 0.5,
551 e_threshold: 0.95,
552 boundary_score: 0.2,
553 }),
554 observation: None,
555 ledger: Vec::new(),
556 };
557 let overlay = VoiDebugOverlay::new(data);
558 let lines = overlay.build_lines(40);
559 assert!(
560 lines.iter().any(|l| l.contains("Decision: SKIP")),
561 "expected SKIP verdict: {lines:?}"
562 );
563 }
564
565 #[test]
568 fn build_lines_observation_only() {
569 let data = VoiOverlayData {
570 title: "T".to_string(),
571 tick: None,
572 source: None,
573 posterior: sample_posterior(),
574 decision: None,
575 observation: Some(VoiObservationSummary {
576 sample_idx: 10,
577 violated: true,
578 posterior_mean: 0.456,
579 alpha: 5.0,
580 beta: 10.0,
581 }),
582 ledger: Vec::new(),
583 };
584 let overlay = VoiDebugOverlay::new(data);
585 let lines = overlay.build_lines(40);
586 assert!(
587 lines.iter().any(|l| l.contains("violated: true")),
588 "missing violated observation: {lines:?}"
589 );
590 assert!(
591 lines.iter().any(|l| l.contains("mu=0.456")),
592 "missing posterior mean: {lines:?}"
593 );
594 }
595
596 #[test]
599 fn build_lines_ledger_skip_entry() {
600 let data = VoiOverlayData {
601 title: "T".to_string(),
602 tick: None,
603 source: None,
604 posterior: sample_posterior(),
605 decision: None,
606 observation: None,
607 ledger: vec![VoiLedgerEntry::Decision {
608 event_idx: 99,
609 should_sample: false,
610 voi_gain: 0.001,
611 log_bayes_factor: -0.5,
612 }],
613 };
614 let overlay = VoiDebugOverlay::new(data);
615 let lines = overlay.build_lines(40);
616 assert!(
617 lines.iter().any(|l| l.contains("D# 99 -")),
618 "expected skip marker: {lines:?}"
619 );
620 }
621
622 #[test]
625 fn build_lines_posterior_values() {
626 let data = VoiOverlayData {
627 title: "T".to_string(),
628 tick: None,
629 source: None,
630 posterior: VoiPosteriorSummary {
631 alpha: 1.0,
632 beta: 1.0,
633 mean: 0.5,
634 variance: 0.0833,
635 expected_variance_after: 0.0500,
636 voi_gain: 0.0333,
637 },
638 decision: None,
639 observation: None,
640 ledger: Vec::new(),
641 };
642 let overlay = VoiDebugOverlay::new(data);
643 let lines = overlay.build_lines(40);
644 assert!(
645 lines
646 .iter()
647 .any(|l| l.contains("a=1.00") && l.contains("b=1.00")),
648 "missing alpha/beta: {lines:?}"
649 );
650 assert!(
651 lines.iter().any(|l| l.contains("mu=0.5000")),
652 "missing mean: {lines:?}"
653 );
654 }
655
656 #[test]
659 fn with_style_replaces_style() {
660 let overlay = VoiDebugOverlay::new(sample_data());
661 let custom = VoiOverlayStyle {
662 background: Some(PackedRgba::rgb(255, 0, 0)),
663 border_type: BorderType::Square,
664 ..VoiOverlayStyle::default()
665 };
666 let styled = overlay.with_style(custom);
667 assert_eq!(styled.style.background, Some(PackedRgba::rgb(255, 0, 0)));
668 }
669
670 #[test]
671 fn render_empty_area_is_noop() {
672 let overlay = VoiDebugOverlay::new(sample_data());
673 let mut pool = GraphemePool::new();
674 let mut frame = Frame::new(40, 10, &mut pool);
675
676 overlay.render(Rect::new(0, 0, 0, 10), &mut frame);
678 overlay.render(Rect::new(0, 0, 40, 0), &mut frame);
680 }
682
683 #[test]
684 fn render_narrow_area_where_inner_is_empty() {
685 let overlay = VoiDebugOverlay::new(sample_data());
686 let mut pool = GraphemePool::new();
687 let mut frame = Frame::new(80, 40, &mut pool);
688 overlay.render(Rect::new(0, 0, 20, 6), &mut frame);
691 }
693
694 #[test]
695 fn build_lines_ledger_observation_entry() {
696 let data = VoiOverlayData {
697 title: "T".to_string(),
698 tick: None,
699 source: None,
700 posterior: sample_posterior(),
701 decision: None,
702 observation: None,
703 ledger: vec![VoiLedgerEntry::Observation {
704 sample_idx: 42,
705 violated: false,
706 posterior_mean: 0.789,
707 }],
708 };
709 let overlay = VoiDebugOverlay::new(data);
710 let lines = overlay.build_lines(40);
711 assert!(
712 lines.iter().any(|l| l.contains("O# 42")),
713 "missing observation ledger entry: {lines:?}"
714 );
715 assert!(
716 lines.iter().any(|l| l.contains("viol=false")),
717 "missing violated=false: {lines:?}"
718 );
719 assert!(
720 lines.iter().any(|l| l.contains("mu=0.789")),
721 "missing posterior mean in ledger: {lines:?}"
722 );
723 }
724
725 #[test]
726 fn build_lines_decision_equation_section() {
727 let overlay = VoiDebugOverlay::new(sample_data());
728 let lines = overlay.build_lines(50);
729 assert!(
730 lines.iter().any(|l| l.contains("Decision Equation")),
731 "missing decision equation header: {lines:?}"
732 );
733 assert!(
734 lines
735 .iter()
736 .any(|l| l.contains("score=") && l.contains("cost=")),
737 "missing score/cost line: {lines:?}"
738 );
739 }
740
741 #[test]
742 fn build_lines_voi_equation_format() {
743 let data = VoiOverlayData {
744 title: "T".to_string(),
745 tick: None,
746 source: None,
747 posterior: VoiPosteriorSummary {
748 alpha: 2.0,
749 beta: 3.0,
750 mean: 0.4,
751 variance: 0.04,
752 expected_variance_after: 0.03,
753 voi_gain: 0.01,
754 },
755 decision: None,
756 observation: None,
757 ledger: Vec::new(),
758 };
759 let overlay = VoiDebugOverlay::new(data);
760 let lines = overlay.build_lines(50);
761 assert!(
763 lines.iter().any(|l| l.contains("VOI = Var[p] - E[Var|1]")),
764 "missing VOI equation label: {lines:?}"
765 );
766 assert!(
768 lines.iter().any(|l| l.contains("0.040000")
769 && l.contains("0.030000")
770 && l.contains("0.010000")),
771 "missing VOI computation line: {lines:?}"
772 );
773 }
774
775 #[test]
776 fn overlay_data_clone() {
777 let data = sample_data();
778 let cloned = data.clone();
779 assert_eq!(cloned.title, data.title);
780 assert_eq!(cloned.tick, data.tick);
781 assert_eq!(cloned.ledger.len(), data.ledger.len());
782 }
783
784 #[test]
787 fn build_lines_width_zero() {
788 let overlay = VoiDebugOverlay::new(sample_data());
789 let lines = overlay.build_lines(0);
790 assert!(!lines.is_empty());
792 }
793
794 #[test]
795 fn build_lines_width_one() {
796 let overlay = VoiDebugOverlay::new(sample_data());
797 let lines = overlay.build_lines(1);
798 assert_eq!(lines[1], "-", "divider should be single dash");
799 }
800
801 #[test]
802 fn build_lines_empty_title() {
803 let data = VoiOverlayData {
804 title: String::new(),
805 tick: None,
806 source: None,
807 posterior: sample_posterior(),
808 decision: None,
809 observation: None,
810 ledger: Vec::new(),
811 };
812 let overlay = VoiDebugOverlay::new(data);
813 let lines = overlay.build_lines(20);
814 assert_eq!(lines[0], "", "empty title should produce empty header");
815 }
816
817 #[test]
818 fn build_lines_tick_only_no_source() {
819 let data = VoiOverlayData {
820 title: "T".to_string(),
821 tick: Some(0),
822 source: None,
823 posterior: sample_posterior(),
824 decision: None,
825 observation: None,
826 ledger: Vec::new(),
827 };
828 let overlay = VoiDebugOverlay::new(data);
829 let lines = overlay.build_lines(30);
830 assert!(lines[0].contains("(tick 0)"));
831 assert!(!lines[0].contains('['));
832 }
833
834 #[test]
835 fn build_lines_source_only_no_tick() {
836 let data = VoiOverlayData {
837 title: "T".to_string(),
838 tick: None,
839 source: Some("src".to_string()),
840 posterior: sample_posterior(),
841 decision: None,
842 observation: None,
843 ledger: Vec::new(),
844 };
845 let overlay = VoiDebugOverlay::new(data);
846 let lines = overlay.build_lines(30);
847 assert!(lines[0].contains("[src]"));
848 assert!(!lines[0].contains("tick"));
849 }
850
851 #[test]
852 fn render_width_below_threshold() {
853 let overlay = VoiDebugOverlay::new(sample_data());
854 let mut pool = GraphemePool::new();
855 let mut frame = Frame::new(80, 40, &mut pool);
856 overlay.render(Rect::new(0, 0, 19, 10), &mut frame);
858 let cell = frame.buffer.get(0, 0).unwrap();
860 assert_ne!(
861 cell.content.as_char(),
862 Some('╭'),
863 "should not render border at width=19"
864 );
865 }
866
867 #[test]
868 fn render_height_below_threshold() {
869 let overlay = VoiDebugOverlay::new(sample_data());
870 let mut pool = GraphemePool::new();
871 let mut frame = Frame::new(80, 40, &mut pool);
872 overlay.render(Rect::new(0, 0, 40, 5), &mut frame);
874 let cell = frame.buffer.get(0, 0).unwrap();
875 assert_ne!(
876 cell.content.as_char(),
877 Some('╭'),
878 "should not render border at height=5"
879 );
880 }
881
882 #[test]
883 fn render_exact_minimum_size() {
884 let overlay = VoiDebugOverlay::new(sample_data());
885 let mut pool = GraphemePool::new();
886 let mut frame = Frame::new(80, 40, &mut pool);
887 overlay.render(Rect::new(0, 0, 20, 6), &mut frame);
889 let cell = frame.buffer.get(0, 0).unwrap();
890 assert_eq!(
891 cell.content.as_char(),
892 Some('╭'),
893 "should render border at exact minimum size"
894 );
895 }
896
897 #[test]
898 fn posterior_with_nan_values() {
899 let data = VoiOverlayData {
900 title: "T".to_string(),
901 tick: None,
902 source: None,
903 posterior: VoiPosteriorSummary {
904 alpha: f64::NAN,
905 beta: f64::INFINITY,
906 mean: f64::NEG_INFINITY,
907 variance: 0.0,
908 expected_variance_after: 0.0,
909 voi_gain: -0.0,
910 },
911 decision: None,
912 observation: None,
913 ledger: Vec::new(),
914 };
915 let overlay = VoiDebugOverlay::new(data);
916 let lines = overlay.build_lines(50);
917 assert!(
919 lines.iter().any(|l| l.contains("NaN") || l.contains("nan")),
920 "NaN alpha should appear in output: {lines:?}"
921 );
922 }
923
924 #[test]
925 fn large_event_idx_in_ledger() {
926 let data = VoiOverlayData {
927 title: "T".to_string(),
928 tick: None,
929 source: None,
930 posterior: sample_posterior(),
931 decision: None,
932 observation: None,
933 ledger: vec![VoiLedgerEntry::Decision {
934 event_idx: u64::MAX,
935 should_sample: true,
936 voi_gain: 0.0,
937 log_bayes_factor: 0.0,
938 }],
939 };
940 let overlay = VoiDebugOverlay::new(data);
941 let lines = overlay.build_lines(80);
942 assert!(
943 lines.iter().any(|l| l.contains(&u64::MAX.to_string())),
944 "large event_idx should appear: {lines:?}"
945 );
946 }
947
948 #[test]
949 fn multiple_ledger_entries_same_type() {
950 let data = VoiOverlayData {
951 title: "T".to_string(),
952 tick: None,
953 source: None,
954 posterior: sample_posterior(),
955 decision: None,
956 observation: None,
957 ledger: vec![
958 VoiLedgerEntry::Decision {
959 event_idx: 1,
960 should_sample: true,
961 voi_gain: 0.01,
962 log_bayes_factor: 0.5,
963 },
964 VoiLedgerEntry::Decision {
965 event_idx: 2,
966 should_sample: false,
967 voi_gain: 0.001,
968 log_bayes_factor: -0.3,
969 },
970 VoiLedgerEntry::Decision {
971 event_idx: 3,
972 should_sample: true,
973 voi_gain: 0.02,
974 log_bayes_factor: 1.0,
975 },
976 ],
977 };
978 let overlay = VoiDebugOverlay::new(data);
979 let lines = overlay.build_lines(50);
980 let decision_lines: Vec<_> = lines.iter().filter(|l| l.starts_with("D#")).collect();
981 assert_eq!(decision_lines.len(), 3, "expected 3 decision entries");
982 }
983
984 #[test]
985 fn negative_log_bayes_factor_format() {
986 let data = VoiOverlayData {
987 title: "T".to_string(),
988 tick: None,
989 source: None,
990 posterior: sample_posterior(),
991 decision: Some(VoiDecisionSummary {
992 event_idx: 1,
993 should_sample: false,
994 reason: "negative".to_string(),
995 score: 0.001,
996 cost: 0.1,
997 log_bayes_factor: -2.345,
998 e_value: 0.1,
999 e_threshold: 0.95,
1000 boundary_score: 0.05,
1001 }),
1002 observation: None,
1003 ledger: Vec::new(),
1004 };
1005 let overlay = VoiDebugOverlay::new(data);
1006 let lines = overlay.build_lines(50);
1007 assert!(
1008 lines.iter().any(|l| l.contains("-2.345")),
1009 "negative log BF should appear: {lines:?}"
1010 );
1011 }
1012
1013 #[test]
1014 fn voi_ledger_entry_clone() {
1015 let entry = VoiLedgerEntry::Decision {
1016 event_idx: 5,
1017 should_sample: true,
1018 voi_gain: 0.01,
1019 log_bayes_factor: 0.5,
1020 };
1021 let cloned = entry.clone();
1022 assert!(format!("{cloned:?}").contains("Decision"));
1023 }
1024
1025 #[test]
1026 fn voi_decision_summary_clone() {
1027 let d = VoiDecisionSummary {
1028 event_idx: 1,
1029 should_sample: true,
1030 reason: "test".to_string(),
1031 score: 1.0,
1032 cost: 0.5,
1033 log_bayes_factor: 0.3,
1034 e_value: 1.0,
1035 e_threshold: 0.95,
1036 boundary_score: 0.5,
1037 };
1038 let cloned = d.clone();
1039 assert_eq!(cloned.reason, "test");
1040 assert_eq!(cloned.event_idx, 1);
1041 }
1042
1043 #[test]
1044 fn voi_observation_summary_clone() {
1045 let o = VoiObservationSummary {
1046 sample_idx: 42,
1047 violated: true,
1048 posterior_mean: 0.5,
1049 alpha: 3.0,
1050 beta: 7.0,
1051 };
1052 let cloned = o.clone();
1053 assert!(cloned.violated);
1054 assert_eq!(cloned.sample_idx, 42);
1055 }
1056
1057 #[test]
1058 fn with_style_custom_border_type() {
1059 let overlay = VoiDebugOverlay::new(sample_data()).with_style(VoiOverlayStyle {
1060 border_type: BorderType::Double,
1061 ..VoiOverlayStyle::default()
1062 });
1063 assert!(matches!(overlay.style.border_type, BorderType::Double));
1064 }
1065
1066 #[test]
1067 fn render_no_background() {
1068 let data = sample_data();
1069 let overlay = VoiDebugOverlay::new(data);
1070 let mut pool = GraphemePool::new();
1071 let mut frame = Frame::new(80, 32, &mut pool);
1072 overlay.render(Rect::new(0, 0, 80, 32), &mut frame);
1074 let cell = frame.buffer.get(0, 0).unwrap();
1076 assert_eq!(cell.content.as_char(), Some('╭'));
1077 }
1078
1079 #[test]
1080 fn build_lines_divider_matches_width() {
1081 let overlay = VoiDebugOverlay::new(sample_data());
1082 let width = 37;
1083 let lines = overlay.build_lines(width);
1084 assert_eq!(
1086 lines[1].len(),
1087 width,
1088 "divider should match requested width"
1089 );
1090 }
1091
1092 #[test]
1097 fn structs_implement_debug() {
1098 let posterior = sample_posterior();
1099 let _ = format!("{posterior:?}");
1100
1101 let data = sample_data();
1102 let _ = format!("{data:?}");
1103
1104 let overlay = VoiDebugOverlay::new(data);
1105 let _ = format!("{overlay:?}");
1106
1107 let style = VoiOverlayStyle::default();
1108 let _ = format!("{style:?}");
1109 }
1110}