Skip to main content

ftui_widgets/
decision_card.rs

1#![forbid(unsafe_code)]
2
3//! Galaxy-brain decision card widget (bd-1lg.8).
4//!
5//! A standalone, reusable widget that renders progressive-disclosure
6//! decision transparency from the runtime's Bayesian decision engine.
7//!
8//! # Disclosure Levels
9//!
10//! - **Level 0 (Traffic Light)**: Green/yellow/red badge with action label.
11//! - **Level 1 (Plain English)**: One-sentence human-readable explanation.
12//! - **Level 2 (Evidence Terms)**: Bayes factors with direction indicators.
13//! - **Level 3 (Full Bayesian)**: Log-posterior, CI, expected loss breakdown.
14//!
15//! Each level includes all information from lower levels.
16//!
17//! # Usage
18//!
19//! ```ignore
20//! use ftui_runtime::transparency::{Disclosure, disclose, DisclosureLevel};
21//! use ftui_widgets::decision_card::DecisionCard;
22//!
23//! let disc = disclose(&decision, domain, DisclosureLevel::FullBayesian);
24//! let card = DecisionCard::new(&disc);
25//! card.render(area, &mut frame);
26//! ```
27
28use crate::borders::{BorderSet, BorderType};
29use crate::{Widget, apply_style, clear_text_area, draw_text_span};
30use ftui_core::geometry::Rect;
31use ftui_render::cell::{Cell, PackedRgba};
32use ftui_render::frame::Frame;
33use ftui_runtime::transparency::{Disclosure, DisclosureLevel, EvidenceDirection, TrafficLight};
34use ftui_style::Style;
35
36/// Traffic-light color palette.
37const GREEN_FG: PackedRgba = PackedRgba::rgb(0, 200, 0);
38const GREEN_BG: PackedRgba = PackedRgba::rgb(0, 60, 0);
39const YELLOW_FG: PackedRgba = PackedRgba::rgb(220, 200, 0);
40const YELLOW_BG: PackedRgba = PackedRgba::rgb(60, 50, 0);
41const RED_FG: PackedRgba = PackedRgba::rgb(220, 50, 50);
42const RED_BG: PackedRgba = PackedRgba::rgb(60, 10, 10);
43
44const EVIDENCE_SUPPORTING_FG: PackedRgba = PackedRgba::rgb(100, 200, 100);
45const EVIDENCE_OPPOSING_FG: PackedRgba = PackedRgba::rgb(200, 100, 100);
46const EVIDENCE_NEUTRAL_FG: PackedRgba = PackedRgba::rgb(160, 160, 160);
47const DETAIL_FG: PackedRgba = PackedRgba::rgb(140, 160, 180);
48const DIM_FG: PackedRgba = PackedRgba::rgb(120, 120, 120);
49
50/// A decision card widget showing progressive-disclosure transparency.
51///
52/// Renders a bordered card with traffic-light badge, explanation,
53/// evidence terms, and/or full Bayesian details depending on the
54/// disclosure level of the provided [`Disclosure`].
55#[derive(Debug, Clone)]
56pub struct DecisionCard<'a> {
57    disclosure: &'a Disclosure,
58    border_type: BorderType,
59    style: Style,
60    title_style: Style,
61}
62
63impl<'a> DecisionCard<'a> {
64    /// Create a new decision card from a disclosure snapshot.
65    #[must_use]
66    pub fn new(disclosure: &'a Disclosure) -> Self {
67        Self {
68            disclosure,
69            border_type: BorderType::Rounded,
70            style: Style::default(),
71            title_style: Style::default().bold(),
72        }
73    }
74
75    /// Set the border style (Square, Rounded, Double, Heavy, Ascii).
76    #[must_use]
77    pub fn border_type(mut self, border_type: BorderType) -> Self {
78        self.border_type = border_type;
79        self
80    }
81
82    /// Set the base background/foreground style for the card area.
83    #[must_use]
84    pub fn style(mut self, style: Style) -> Self {
85        self.style = style;
86        self
87    }
88
89    /// Set the style for the title row.
90    #[must_use]
91    pub fn title_style(mut self, style: Style) -> Self {
92        self.title_style = style;
93        self
94    }
95
96    /// Minimum height needed to render the card at the current disclosure level.
97    #[must_use]
98    pub fn min_height(&self) -> u16 {
99        // Border top + signal line + border bottom = 3
100        let mut h: u16 = 3;
101        if self.disclosure.explanation.is_some() {
102            h += 1; // explanation line
103        }
104        if let Some(ref terms) = self.disclosure.evidence_terms
105            && !terms.is_empty()
106        {
107            h += 1; // "Evidence:" header
108            h += terms.len() as u16; // one line per term
109        }
110        if self.disclosure.bayesian_details.is_some() {
111            h += 2; // separator + stats line
112        }
113        h
114    }
115
116    fn signal_style(signal: TrafficLight) -> (Style, Style) {
117        let (fg, bg) = match signal {
118            TrafficLight::Green => (GREEN_FG, GREEN_BG),
119            TrafficLight::Yellow => (YELLOW_FG, YELLOW_BG),
120            TrafficLight::Red => (RED_FG, RED_BG),
121        };
122        let badge_style = Style::new().fg(fg).bg(bg).bold();
123        let border_style = Style::new().fg(fg);
124        (badge_style, border_style)
125    }
126
127    fn render_border(&self, area: Rect, frame: &mut Frame, border_style: Style) {
128        let deg = frame.buffer.degradation;
129        let set = if deg.use_unicode_borders() {
130            self.border_type.to_border_set()
131        } else {
132            BorderSet::ASCII
133        };
134
135        let border_cell = |c: char| -> Cell {
136            let mut cell = Cell::from_char(c);
137            apply_style(&mut cell, border_style);
138            cell
139        };
140
141        // Top edge
142        for x in area.x..area.right() {
143            frame
144                .buffer
145                .set_fast(x, area.y, border_cell(set.horizontal));
146        }
147        // Bottom edge
148        let bottom_y = area.bottom().saturating_sub(1);
149        for x in area.x..area.right() {
150            frame
151                .buffer
152                .set_fast(x, bottom_y, border_cell(set.horizontal));
153        }
154        // Left edge
155        for y in area.y..area.bottom() {
156            frame.buffer.set_fast(area.x, y, border_cell(set.vertical));
157        }
158        // Right edge
159        let right_x = area.right().saturating_sub(1);
160        for y in area.y..area.bottom() {
161            frame.buffer.set_fast(right_x, y, border_cell(set.vertical));
162        }
163        // Corners
164        frame
165            .buffer
166            .set_fast(area.x, area.y, border_cell(set.top_left));
167        frame
168            .buffer
169            .set_fast(right_x, area.y, border_cell(set.top_right));
170        frame
171            .buffer
172            .set_fast(area.x, bottom_y, border_cell(set.bottom_left));
173        frame
174            .buffer
175            .set_fast(right_x, bottom_y, border_cell(set.bottom_right));
176    }
177
178    fn render_signal_row(&self, x: u16, y: u16, max_x: u16, frame: &mut Frame) {
179        let deg = frame.buffer.degradation;
180        let (badge_style, _) = Self::signal_style(self.disclosure.signal);
181        let badge_style = if deg.apply_styling() {
182            badge_style
183        } else {
184            Style::default()
185        };
186        let title_style = if deg.apply_styling() {
187            self.title_style
188        } else {
189            Style::default()
190        };
191        let label = self.disclosure.signal.label();
192
193        // Render badge: " OK " / " WARN " / " ALERT "
194        let badge_text = format!(" {label} ");
195        let mut cx = draw_text_span(frame, x, y, &badge_text, badge_style, max_x);
196
197        // Action label after badge
198        if cx < max_x {
199            cx = draw_text_span(frame, cx, y, " ", Style::default(), max_x);
200        }
201        cx = draw_text_span(
202            frame,
203            cx,
204            y,
205            &self.disclosure.action_label,
206            title_style,
207            max_x,
208        );
209        let _ = cx;
210    }
211
212    fn render_explanation(&self, x: u16, y: u16, max_x: u16, frame: &mut Frame) {
213        if let Some(ref explanation) = self.disclosure.explanation {
214            let style = if frame.buffer.degradation.apply_styling() {
215                Style::new().fg(DIM_FG)
216            } else {
217                Style::default()
218            };
219            draw_text_span(frame, x, y, explanation, style, max_x);
220        }
221    }
222
223    fn render_evidence(&self, x: u16, mut y: u16, max_x: u16, frame: &mut Frame) -> u16 {
224        let terms = match self.disclosure.evidence_terms {
225            Some(ref t) if !t.is_empty() => t,
226            _ => return y,
227        };
228
229        let apply_styling = frame.buffer.degradation.apply_styling();
230        let header_style = if apply_styling {
231            Style::new().fg(DETAIL_FG).bold()
232        } else {
233            Style::default()
234        };
235        draw_text_span(frame, x, y, "Evidence:", header_style, max_x);
236        y += 1;
237
238        for term in terms {
239            let (dir_char, dir_style) = match term.direction {
240                EvidenceDirection::Supporting => ('+', Style::new().fg(EVIDENCE_SUPPORTING_FG)),
241                EvidenceDirection::Opposing => ('-', Style::new().fg(EVIDENCE_OPPOSING_FG)),
242                EvidenceDirection::Neutral => ('~', Style::new().fg(EVIDENCE_NEUTRAL_FG)),
243            };
244            let dir_style = if apply_styling {
245                dir_style
246            } else {
247                Style::default()
248            };
249            let line = format!("  {dir_char} {}: BF={:.2}", term.label, term.bayes_factor);
250            draw_text_span(frame, x, y, &line, dir_style, max_x);
251            y += 1;
252        }
253        y
254    }
255
256    fn render_bayesian(&self, x: u16, y: u16, max_x: u16, frame: &mut Frame) {
257        let details = match self.disclosure.bayesian_details {
258            Some(ref d) => d,
259            None => return,
260        };
261
262        let deg = frame.buffer.degradation;
263        let style = if deg.apply_styling() {
264            Style::new().fg(DETAIL_FG)
265        } else {
266            Style::default()
267        };
268
269        // Horizontal rule
270        let rule_style = if deg.apply_styling() {
271            Style::new().fg(DIM_FG)
272        } else {
273            Style::default()
274        };
275        let rule_len = (max_x.saturating_sub(x)) as usize;
276        let rule_ch = if deg.use_unicode_borders() {
277            '─'
278        } else {
279            '-'
280        };
281        let rule: String = std::iter::repeat_n(rule_ch, rule_len).collect();
282        draw_text_span(frame, x, y, &rule, rule_style, max_x);
283
284        // Stats line
285        let stats = format!(
286            "log_post={:.3} CI=[{:.3},{:.3}] loss={:.4} avoided={:.4}",
287            details.log_posterior,
288            details.confidence_interval.0,
289            details.confidence_interval.1,
290            details.expected_loss,
291            details.loss_avoided,
292        );
293        draw_text_span(frame, x, y + 1, &stats, style, max_x);
294    }
295}
296
297impl Widget for DecisionCard<'_> {
298    fn render(&self, area: Rect, frame: &mut Frame) {
299        if area.is_empty() {
300            return;
301        }
302
303        if area.width < 4 || area.height < 3 {
304            clear_text_area(frame, area, Style::default());
305            return;
306        }
307
308        let deg = frame.buffer.degradation;
309        if !deg.render_content() {
310            clear_text_area(frame, area, Style::default());
311            return;
312        }
313
314        let base_style = if deg.apply_styling() {
315            self.style
316        } else {
317            Style::default()
318        };
319        clear_text_area(frame, area, base_style);
320
321        // Determine border color from signal
322        let (_, border_style) = Self::signal_style(self.disclosure.signal);
323        let border_style = if deg.apply_styling() {
324            border_style
325        } else {
326            Style::default()
327        };
328
329        // Draw borders
330        if deg.render_decorative() {
331            self.render_border(area, frame, border_style);
332        }
333
334        // Inner area (1-cell border on each side)
335        let inner_x = area.x.saturating_add(1);
336        let inner_max_x = area.right().saturating_sub(1);
337        let mut y = area.y.saturating_add(1);
338        let max_y = area.bottom().saturating_sub(1);
339
340        if y >= max_y || inner_x >= inner_max_x {
341            return;
342        }
343
344        // Row 1: Traffic light badge + action label
345        self.render_signal_row(inner_x, y, inner_max_x, frame);
346        y += 1;
347
348        // Row 2: Plain English explanation (level >= 1)
349        if y < max_y && self.disclosure.level >= DisclosureLevel::PlainEnglish {
350            self.render_explanation(inner_x, y, inner_max_x, frame);
351            if self.disclosure.explanation.is_some() {
352                y += 1;
353            }
354        }
355
356        // Rows 3+: Evidence terms (level >= 2)
357        if y < max_y && self.disclosure.level >= DisclosureLevel::EvidenceTerms {
358            y = self.render_evidence(inner_x, y, inner_max_x, frame);
359        }
360
361        // Final rows: Full Bayesian details (level >= 3)
362        if y + 1 < max_y && self.disclosure.level >= DisclosureLevel::FullBayesian {
363            self.render_bayesian(inner_x, y, inner_max_x, frame);
364        }
365    }
366
367    fn is_essential(&self) -> bool {
368        false
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use ftui_render::budget::DegradationLevel;
376    use ftui_render::cell::PackedRgba;
377    use ftui_render::grapheme_pool::GraphemePool;
378    use ftui_runtime::transparency::{BayesianDetails, DisclosureEvidence, EvidenceDirection};
379    use ftui_runtime::unified_evidence::DecisionDomain;
380
381    fn make_disclosure(level: DisclosureLevel) -> Disclosure {
382        let explanation = if level >= DisclosureLevel::PlainEnglish {
383            Some("Diff strategy: chose 'full_redraw' with high confidence.".to_string())
384        } else {
385            None
386        };
387
388        let evidence_terms = if level >= DisclosureLevel::EvidenceTerms {
389            Some(vec![
390                DisclosureEvidence {
391                    label: "change_rate",
392                    bayes_factor: 3.5,
393                    direction: EvidenceDirection::Supporting,
394                },
395                DisclosureEvidence {
396                    label: "frame_cost",
397                    bayes_factor: 0.8,
398                    direction: EvidenceDirection::Opposing,
399                },
400                DisclosureEvidence {
401                    label: "stability",
402                    bayes_factor: 1.0,
403                    direction: EvidenceDirection::Neutral,
404                },
405            ])
406        } else {
407            None
408        };
409
410        let bayesian_details = if level >= DisclosureLevel::FullBayesian {
411            Some(BayesianDetails {
412                log_posterior: 2.0,
413                confidence_interval: (0.7, 0.95),
414                expected_loss: 0.1,
415                next_best_loss: 0.5,
416                loss_avoided: 0.4,
417            })
418        } else {
419            None
420        };
421
422        Disclosure {
423            domain: DecisionDomain::DiffStrategy,
424            level,
425            signal: TrafficLight::Green,
426            action_label: "full_redraw".to_string(),
427            explanation,
428            evidence_terms,
429            bayesian_details,
430        }
431    }
432
433    fn extract_row(frame: &Frame, y: u16, width: u16) -> String {
434        let mut row = String::new();
435        for x in 0..width {
436            if let Some(cell) = frame.buffer.get(x, y) {
437                if let Some(ch) = cell.content.as_char() {
438                    row.push(ch);
439                } else {
440                    row.push(' ');
441                }
442            }
443        }
444        row
445    }
446
447    #[test]
448    fn level_0_renders_badge_and_action() {
449        let disc = make_disclosure(DisclosureLevel::TrafficLight);
450        let mut pool = GraphemePool::new();
451        let mut frame = Frame::new(40, 5, &mut pool);
452        DecisionCard::new(&disc).render(Rect::new(0, 0, 40, 5), &mut frame);
453        let row1 = extract_row(&frame, 1, 40);
454        assert!(
455            row1.contains("OK"),
456            "should contain traffic light label: {row1}"
457        );
458        assert!(
459            row1.contains("full_redraw"),
460            "should contain action: {row1}"
461        );
462    }
463
464    #[test]
465    fn level_1_includes_explanation() {
466        let disc = make_disclosure(DisclosureLevel::PlainEnglish);
467        let mut pool = GraphemePool::new();
468        let mut frame = Frame::new(60, 6, &mut pool);
469        DecisionCard::new(&disc).render(Rect::new(0, 0, 60, 6), &mut frame);
470        let row2 = extract_row(&frame, 2, 60);
471        assert!(
472            row2.contains("Diff strategy"),
473            "should contain explanation: {row2}"
474        );
475    }
476
477    #[test]
478    fn level_2_includes_evidence() {
479        let disc = make_disclosure(DisclosureLevel::EvidenceTerms);
480        let mut pool = GraphemePool::new();
481        let mut frame = Frame::new(60, 10, &mut pool);
482        DecisionCard::new(&disc).render(Rect::new(0, 0, 60, 10), &mut frame);
483
484        let mut found_evidence = false;
485        let mut found_change_rate = false;
486        for y in 0..10 {
487            let row = extract_row(&frame, y, 60);
488            if row.contains("Evidence:") {
489                found_evidence = true;
490            }
491            if row.contains("change_rate") {
492                found_change_rate = true;
493            }
494        }
495        assert!(found_evidence, "should show Evidence header");
496        assert!(found_change_rate, "should show change_rate term");
497    }
498
499    #[test]
500    fn level_3_includes_bayesian() {
501        let disc = make_disclosure(DisclosureLevel::FullBayesian);
502        let mut pool = GraphemePool::new();
503        let mut frame = Frame::new(60, 12, &mut pool);
504        DecisionCard::new(&disc).render(Rect::new(0, 0, 60, 12), &mut frame);
505
506        let mut found_log_post = false;
507        for y in 0..12 {
508            let row = extract_row(&frame, y, 60);
509            if row.contains("log_post") {
510                found_log_post = true;
511            }
512        }
513        assert!(found_log_post, "should show log_post in Bayesian details");
514    }
515
516    #[test]
517    fn tiny_area_no_panic() {
518        let disc = make_disclosure(DisclosureLevel::FullBayesian);
519        let mut pool = GraphemePool::new();
520        // Should not panic with tiny areas
521        let mut frame = Frame::new(3, 2, &mut pool);
522        DecisionCard::new(&disc).render(Rect::new(0, 0, 3, 2), &mut frame);
523        let mut frame = Frame::new(1, 1, &mut pool);
524        DecisionCard::new(&disc).render(Rect::new(0, 0, 1, 1), &mut frame);
525    }
526
527    #[test]
528    fn min_height_level_0() {
529        let disc = make_disclosure(DisclosureLevel::TrafficLight);
530        let card = DecisionCard::new(&disc);
531        assert_eq!(card.min_height(), 3); // border + signal + border
532    }
533
534    #[test]
535    fn min_height_level_3() {
536        let disc = make_disclosure(DisclosureLevel::FullBayesian);
537        let card = DecisionCard::new(&disc);
538        // 3 base + 1 explanation + 1 evidence header + 3 terms + 2 bayesian = 10
539        assert_eq!(card.min_height(), 10);
540    }
541
542    #[test]
543    fn signal_colors_differ() {
544        let (green_badge, green_border) = DecisionCard::signal_style(TrafficLight::Green);
545        let (yellow_badge, _) = DecisionCard::signal_style(TrafficLight::Yellow);
546        let (red_badge, red_border) = DecisionCard::signal_style(TrafficLight::Red);
547        assert_ne!(green_badge.fg, yellow_badge.fg);
548        assert_ne!(yellow_badge.fg, red_badge.fg);
549        assert_ne!(green_border.fg, red_border.fg);
550    }
551
552    #[test]
553    fn yellow_signal_shows_warn() {
554        let mut disc = make_disclosure(DisclosureLevel::TrafficLight);
555        disc.signal = TrafficLight::Yellow;
556        let mut pool = GraphemePool::new();
557        let mut frame = Frame::new(40, 5, &mut pool);
558        DecisionCard::new(&disc).render(Rect::new(0, 0, 40, 5), &mut frame);
559        let row1 = extract_row(&frame, 1, 40);
560        assert!(row1.contains("WARN"), "should contain WARN: {row1}");
561    }
562
563    #[test]
564    fn red_signal_shows_alert() {
565        let mut disc = make_disclosure(DisclosureLevel::TrafficLight);
566        disc.signal = TrafficLight::Red;
567        let mut pool = GraphemePool::new();
568        let mut frame = Frame::new(40, 5, &mut pool);
569        DecisionCard::new(&disc).render(Rect::new(0, 0, 40, 5), &mut frame);
570        let row1 = extract_row(&frame, 1, 40);
571        assert!(row1.contains("ALERT"), "should contain ALERT: {row1}");
572    }
573
574    #[test]
575    fn builder_methods() {
576        let disc = make_disclosure(DisclosureLevel::TrafficLight);
577        let card = DecisionCard::new(&disc)
578            .border_type(BorderType::Double)
579            .style(Style::new().bg(PackedRgba::rgb(10, 10, 10)))
580            .title_style(Style::new().bold());
581        // Should compile and not panic when rendered
582        let mut pool = GraphemePool::new();
583        let mut frame = Frame::new(40, 5, &mut pool);
584        card.render(Rect::new(0, 0, 40, 5), &mut frame);
585    }
586
587    #[test]
588    fn is_not_essential() {
589        let disc = make_disclosure(DisclosureLevel::TrafficLight);
590        let card = DecisionCard::new(&disc);
591        assert!(!card.is_essential());
592    }
593
594    #[test]
595    fn render_no_styling_drops_configured_and_signal_styles() {
596        let disclosure = make_disclosure(DisclosureLevel::EvidenceTerms);
597        let card = DecisionCard::new(&disclosure)
598            .style(Style::new().bg(PackedRgba::rgb(10, 20, 30)))
599            .title_style(Style::new().fg(PackedRgba::rgb(200, 0, 0)).bold());
600        let area = Rect::new(0, 0, 40, card.min_height());
601        let mut pool = GraphemePool::new();
602        let mut frame = Frame::new(40, area.height, &mut pool);
603        frame.buffer.degradation = DegradationLevel::NoStyling;
604
605        card.render(area, &mut frame);
606
607        let border = frame.buffer.get(0, 0).unwrap();
608        let border_default = Cell::from_char(border.content.as_char().unwrap());
609        assert_eq!(border.fg, border_default.fg);
610        assert_eq!(border.bg, border_default.bg);
611        assert_eq!(border.attrs, border_default.attrs);
612
613        let badge = frame.buffer.get(1, 1).unwrap();
614        let badge_default = Cell::from_char(' ');
615        assert_eq!(badge.content.as_char(), Some(' '));
616        assert_eq!(badge.fg, badge_default.fg);
617        assert_eq!(badge.bg, badge_default.bg);
618        assert_eq!(badge.attrs, badge_default.attrs);
619
620        let action = frame.buffer.get(6, 1).unwrap();
621        let action_default = Cell::from_char(action.content.as_char().unwrap());
622        assert_eq!(action.fg, action_default.fg);
623        assert_eq!(action.bg, action_default.bg);
624        assert_eq!(action.attrs, action_default.attrs);
625    }
626
627    #[test]
628    fn render_clears_gap_between_badge_and_action() {
629        let disclosure = make_disclosure(DisclosureLevel::TrafficLight);
630        let card = DecisionCard::new(&disclosure);
631        let area = Rect::new(0, 0, 40, 5);
632        let mut pool = GraphemePool::new();
633        let mut frame = Frame::new(40, 5, &mut pool);
634        frame.buffer.set_fast(5, 1, Cell::from_char('X'));
635
636        card.render(area, &mut frame);
637
638        assert_eq!(frame.buffer.get(5, 1).unwrap().content.as_char(), Some(' '));
639    }
640
641    #[test]
642    fn render_simple_borders_use_ascii_separator_rule() {
643        let disclosure = make_disclosure(DisclosureLevel::FullBayesian);
644        let card = DecisionCard::new(&disclosure);
645        let area = Rect::new(0, 0, 60, card.min_height());
646        let mut pool = GraphemePool::new();
647        let mut frame = Frame::new(60, area.height, &mut pool);
648        frame.buffer.degradation = DegradationLevel::SimpleBorders;
649
650        card.render(area, &mut frame);
651
652        let rule_y = area.y + area.height - 3;
653        assert_eq!(
654            frame.buffer.get(1, rule_y).unwrap().content.as_char(),
655            Some('-')
656        );
657    }
658
659    #[test]
660    fn render_skeleton_clears_previous_card() {
661        let disclosure = make_disclosure(DisclosureLevel::FullBayesian);
662        let card = DecisionCard::new(&disclosure);
663        let area = Rect::new(0, 0, 60, card.min_height());
664        let mut pool = GraphemePool::new();
665        let mut frame = Frame::new(60, area.height, &mut pool);
666
667        card.render(area, &mut frame);
668        frame.buffer.degradation = DegradationLevel::Skeleton;
669        card.render(area, &mut frame);
670
671        for y in 0..area.height {
672            for x in 0..area.width {
673                assert_eq!(frame.buffer.get(x, y).unwrap().content.as_char(), Some(' '));
674            }
675        }
676    }
677
678    #[test]
679    fn render_shorter_disclosure_clears_stale_rows() {
680        let full = make_disclosure(DisclosureLevel::FullBayesian);
681        let short = make_disclosure(DisclosureLevel::TrafficLight);
682        let area = Rect::new(0, 0, 60, DecisionCard::new(&full).min_height());
683        let mut pool = GraphemePool::new();
684        let mut frame = Frame::new(60, area.height, &mut pool);
685
686        DecisionCard::new(&full).render(area, &mut frame);
687        DecisionCard::new(&short).render(area, &mut frame);
688
689        for y in 4..area.height.saturating_sub(1) {
690            for x in 1..area.width.saturating_sub(1) {
691                assert_eq!(frame.buffer.get(x, y).unwrap().content.as_char(), Some(' '));
692            }
693        }
694    }
695}