Skip to main content

ftui_widgets/
validation_error.rs

1#![forbid(unsafe_code)]
2
3//! Inline validation error display widget.
4//!
5//! Displays validation errors near form fields with:
6//! - Configurable error styling (default: red text with icon)
7//! - Smooth appearance animation via opacity interpolation
8//! - Screen reader accessibility (ARIA error association via semantic info)
9//!
10//! # Example
11//!
12//! ```ignore
13//! use ftui_widgets::{ValidationErrorDisplay, ValidationErrorState};
14//!
15//! // Simple inline error
16//! let error = ValidationErrorDisplay::new("This field is required");
17//! let mut state = ValidationErrorState::default();
18//! error.render(area, &mut frame, &mut state);
19//!
20//! // With custom styling
21//! let error = ValidationErrorDisplay::new("Invalid email address")
22//!     .with_icon("!")
23//!     .with_style(Style::new().fg(PackedRgba::rgb(255, 100, 100)));
24//! ```
25//!
26//! # Invariants (Alien Artifact)
27//!
28//! 1. **Icon presence**: Icon is always rendered when error is visible
29//! 2. **Animation bounds**: Opacity is clamped to [0.0, 1.0]
30//! 3. **Width calculation**: Total width = icon_width + 1 + message_width
31//! 4. **Accessibility**: When rendered, screen readers can announce error text
32//!
33//! # Failure Modes
34//!
35//! | Scenario | Behavior |
36//! |----------|----------|
37//! | Empty message | Renders icon only |
38//! | Zero-width area | No-op, state unchanged |
39//! | Very narrow area | Truncates message with ellipsis |
40//! | Animation overflow | Saturates at 0.0 or 1.0 |
41
42use web_time::{Duration, Instant};
43
44use ftui_core::geometry::Rect;
45use ftui_render::cell::PackedRgba;
46use ftui_render::frame::Frame;
47use ftui_style::Style;
48use ftui_text::{display_width, grapheme_width};
49
50use crate::{StatefulWidget, Widget, clear_text_row, draw_text_span};
51
52// ---------------------------------------------------------------------------
53// Constants
54// ---------------------------------------------------------------------------
55
56/// Default error foreground color (red).
57pub const ERROR_FG_DEFAULT: PackedRgba = PackedRgba::rgb(220, 60, 60);
58
59/// Default error background color (dark red).
60pub const ERROR_BG_DEFAULT: PackedRgba = PackedRgba::rgb(40, 0, 0);
61
62/// Default error icon.
63pub const ERROR_ICON_DEFAULT: &str = "⚠";
64
65/// Default animation duration.
66pub const ANIMATION_DURATION_MS: u64 = 150;
67
68// ---------------------------------------------------------------------------
69// ValidationErrorDisplay
70// ---------------------------------------------------------------------------
71
72/// A widget for displaying inline validation errors.
73///
74/// Renders an error message with an icon, styled for visibility and
75/// accessibility. Supports smooth appearance/disappearance animations.
76#[derive(Debug, Clone)]
77pub struct ValidationErrorDisplay {
78    /// The error message to display.
79    message: String,
80    /// Optional error code (for programmatic handling).
81    error_code: Option<&'static str>,
82    /// Icon to show before the message.
83    icon: String,
84    /// Style for the error text.
85    style: Style,
86    /// Style for the icon.
87    icon_style: Style,
88    /// Animation duration for appearance.
89    animation_duration: Duration,
90    /// Whether to show the message (vs icon only when narrow).
91    show_message: bool,
92}
93
94impl Default for ValidationErrorDisplay {
95    fn default() -> Self {
96        Self {
97            message: String::new(),
98            error_code: None,
99            icon: ERROR_ICON_DEFAULT.to_string(),
100            style: Style::new().fg(ERROR_FG_DEFAULT),
101            icon_style: Style::new().fg(ERROR_FG_DEFAULT),
102            animation_duration: Duration::from_millis(ANIMATION_DURATION_MS),
103            show_message: true,
104        }
105    }
106}
107
108impl ValidationErrorDisplay {
109    /// Create a new validation error display with the given message.
110    #[must_use]
111    pub fn new(message: impl Into<String>) -> Self {
112        Self {
113            message: message.into(),
114            ..Default::default()
115        }
116    }
117
118    /// Create from an error code and message.
119    #[must_use]
120    pub fn with_code(error_code: &'static str, message: impl Into<String>) -> Self {
121        Self {
122            message: message.into(),
123            error_code: Some(error_code),
124            ..Default::default()
125        }
126    }
127
128    /// Set a custom icon (default: "⚠").
129    #[must_use]
130    pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
131        self.icon = icon.into();
132        self
133    }
134
135    /// Set the error text style.
136    #[must_use]
137    pub fn with_style(mut self, style: Style) -> Self {
138        self.style = style;
139        self
140    }
141
142    /// Set the icon style.
143    #[must_use]
144    pub fn with_icon_style(mut self, style: Style) -> Self {
145        self.icon_style = style;
146        self
147    }
148
149    /// Set the animation duration.
150    #[must_use]
151    pub fn with_animation_duration(mut self, duration: Duration) -> Self {
152        self.animation_duration = duration;
153        self
154    }
155
156    /// Disable message display (icon only).
157    #[must_use]
158    pub fn icon_only(mut self) -> Self {
159        self.show_message = false;
160        self
161    }
162
163    /// Get the error message.
164    #[must_use]
165    pub fn message(&self) -> &str {
166        &self.message
167    }
168
169    /// Get the error code, if any.
170    #[must_use = "use the error code (if any) for telemetry/diagnostics"]
171    pub fn error_code(&self) -> Option<&'static str> {
172        self.error_code
173    }
174
175    /// Calculate the minimum width needed to display the error.
176    #[must_use]
177    pub fn min_width(&self) -> u16 {
178        let icon_width = display_width(self.icon.as_str()) as u16;
179        if self.show_message && !self.message.is_empty() {
180            let msg_width = display_width(self.message.as_str()) as u16;
181            icon_width.saturating_add(1).saturating_add(msg_width)
182        } else {
183            icon_width
184        }
185    }
186}
187
188// ---------------------------------------------------------------------------
189// ValidationErrorState
190// ---------------------------------------------------------------------------
191
192/// State for validation error animation and accessibility.
193#[derive(Debug, Clone)]
194pub struct ValidationErrorState {
195    /// Whether the error is currently visible.
196    visible: bool,
197    /// Animation start time.
198    animation_start: Option<Instant>,
199    /// Current opacity (0.0 = hidden, 1.0 = fully visible).
200    opacity: f32,
201    /// Whether the error was just shown (for screen reader announcement).
202    just_shown: bool,
203    /// Unique ID for ARIA association.
204    aria_id: u32,
205}
206
207impl Default for ValidationErrorState {
208    fn default() -> Self {
209        Self {
210            visible: false,
211            animation_start: None,
212            opacity: 0.0,
213            just_shown: false,
214            aria_id: 0,
215        }
216    }
217}
218
219impl ValidationErrorState {
220    /// Create a new state with the given ARIA ID.
221    #[must_use]
222    pub fn with_aria_id(mut self, id: u32) -> Self {
223        self.aria_id = id;
224        self
225    }
226
227    /// Show the error (triggers animation).
228    pub fn show(&mut self) {
229        if !self.visible {
230            self.visible = true;
231            self.animation_start = Some(Instant::now());
232            self.just_shown = true;
233        }
234    }
235
236    /// Hide the error (triggers fade-out animation).
237    pub fn hide(&mut self) {
238        if self.visible {
239            self.visible = false;
240            self.animation_start = Some(Instant::now());
241        }
242    }
243
244    /// Set visibility directly (for immediate show/hide).
245    pub fn set_visible(&mut self, visible: bool) {
246        if visible {
247            self.show();
248        } else {
249            self.hide();
250        }
251    }
252
253    /// Check if the error is currently visible.
254    #[inline]
255    #[must_use]
256    pub fn is_visible(&self) -> bool {
257        self.visible
258    }
259
260    /// Check if the error is fully visible (animation complete).
261    #[must_use]
262    pub fn is_fully_visible(&self) -> bool {
263        self.visible && self.opacity >= 1.0
264    }
265
266    /// Get the current opacity.
267    #[must_use]
268    pub fn opacity(&self) -> f32 {
269        self.opacity
270    }
271
272    /// Check and clear the "just shown" flag (for screen reader announcements).
273    pub fn take_just_shown(&mut self) -> bool {
274        std::mem::take(&mut self.just_shown)
275    }
276
277    /// Get the ARIA ID for accessibility association.
278    #[must_use]
279    pub fn aria_id(&self) -> u32 {
280        self.aria_id
281    }
282
283    /// Update animation state. Call this each frame.
284    pub fn tick(&mut self, animation_duration: Duration) {
285        if let Some(start) = self.animation_start {
286            let elapsed = start.elapsed();
287            let progress = if animation_duration.is_zero() {
288                1.0
289            } else {
290                (elapsed.as_secs_f32() / animation_duration.as_secs_f32()).clamp(0.0, 1.0)
291            };
292
293            if self.visible {
294                // Fade in
295                self.opacity = progress;
296            } else {
297                // Fade out
298                self.opacity = 1.0 - progress;
299            }
300
301            // Animation complete
302            if progress >= 1.0 {
303                self.animation_start = None;
304                self.opacity = if self.visible { 1.0 } else { 0.0 };
305            }
306        }
307    }
308
309    /// Check if an animation is currently in progress.
310    #[must_use]
311    pub fn is_animating(&self) -> bool {
312        self.animation_start.is_some()
313    }
314}
315
316// ---------------------------------------------------------------------------
317// Widget Implementation
318// ---------------------------------------------------------------------------
319
320impl StatefulWidget for ValidationErrorDisplay {
321    type State = ValidationErrorState;
322
323    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
324        #[cfg(feature = "tracing")]
325        let _span = tracing::debug_span!(
326            "widget_render",
327            widget = "ValidationErrorDisplay",
328            x = area.x,
329            y = area.y,
330            w = area.width,
331            h = area.height
332        )
333        .entered();
334
335        if area.is_empty() || area.height < 1 {
336            return;
337        }
338
339        let row_area = Rect::new(area.x, area.y, area.width, 1);
340
341        // Update animation
342        state.tick(self.animation_duration);
343
344        // Skip rendering if fully invisible
345        if state.opacity <= 0.0 && !state.visible {
346            clear_text_row(frame, row_area, Style::default());
347            return;
348        }
349
350        let deg = frame.buffer.degradation;
351
352        // Calculate effective opacity for styling
353        let effective_opacity = (state.opacity * 255.0) as u8;
354
355        // Adjust style with opacity
356        let icon_style = if deg.apply_styling() && effective_opacity < 255 {
357            let fg = self.icon_style.fg.unwrap_or(ERROR_FG_DEFAULT);
358            Style::new().fg(PackedRgba::rgba(fg.r(), fg.g(), fg.b(), effective_opacity))
359        } else if deg.apply_styling() {
360            self.icon_style
361        } else {
362            Style::default()
363        };
364
365        let text_style = if deg.apply_styling() && effective_opacity < 255 {
366            let fg = self.style.fg.unwrap_or(ERROR_FG_DEFAULT);
367            Style::new().fg(PackedRgba::rgba(fg.r(), fg.g(), fg.b(), effective_opacity))
368        } else if deg.apply_styling() {
369            self.style
370        } else {
371            Style::default()
372        };
373
374        clear_text_row(frame, row_area, Style::default());
375
376        // Draw icon
377        let y = area.y;
378        let mut x = area.x;
379        let max_x = area.right();
380
381        x = draw_text_span(frame, x, y, &self.icon, icon_style, max_x);
382
383        // Draw space separator
384        if x < max_x && self.show_message && !self.message.is_empty() {
385            x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
386
387            // Draw message (truncate with ellipsis if needed)
388            let remaining_width = max_x.saturating_sub(x) as usize;
389            let msg_width = display_width(self.message.as_str());
390
391            if msg_width <= remaining_width {
392                draw_text_span(frame, x, y, &self.message, text_style, max_x);
393            } else if remaining_width >= 4 {
394                // Truncate with ellipsis
395                let mut truncated = String::new();
396                let mut w = 0;
397                let limit = remaining_width.saturating_sub(1); // Leave room for "…"
398
399                for grapheme in unicode_segmentation::UnicodeSegmentation::graphemes(
400                    self.message.as_str(),
401                    true,
402                ) {
403                    let gw = grapheme_width(grapheme);
404                    if w + gw > limit {
405                        break;
406                    }
407                    truncated.push_str(grapheme);
408                    w += gw;
409                }
410                truncated.push('…');
411
412                draw_text_span(frame, x, y, &truncated, text_style, max_x);
413            } else if remaining_width >= 1 {
414                // Just ellipsis
415                draw_text_span(frame, x, y, "…", text_style, max_x);
416            }
417        }
418    }
419}
420
421impl Widget for ValidationErrorDisplay {
422    fn render(&self, area: Rect, frame: &mut Frame) {
423        let mut state = ValidationErrorState {
424            visible: true,
425            opacity: 1.0,
426            ..Default::default()
427        };
428        StatefulWidget::render(self, area, frame, &mut state);
429    }
430
431    fn is_essential(&self) -> bool {
432        // Errors are essential - users need to see them
433        true
434    }
435}
436
437// ---------------------------------------------------------------------------
438// Tests
439// ---------------------------------------------------------------------------
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use ftui_render::cell::Cell;
445    use ftui_render::grapheme_pool::GraphemePool;
446
447    // -- Construction tests --
448
449    #[test]
450    fn new_creates_with_message() {
451        let error = ValidationErrorDisplay::new("Required field");
452        assert_eq!(error.message(), "Required field");
453        assert_eq!(error.error_code(), None);
454    }
455
456    #[test]
457    fn with_code_sets_error_code() {
458        let error = ValidationErrorDisplay::with_code("required", "This field is required");
459        assert_eq!(error.error_code(), Some("required"));
460        assert_eq!(error.message(), "This field is required");
461    }
462
463    #[test]
464    fn with_icon_overrides_default() {
465        let error = ValidationErrorDisplay::new("Error").with_icon("!");
466        assert_eq!(error.icon, "!");
467    }
468
469    #[test]
470    fn icon_only_disables_message() {
471        let error = ValidationErrorDisplay::new("Error").icon_only();
472        assert!(!error.show_message);
473    }
474
475    #[test]
476    fn default_uses_warning_icon() {
477        let error = ValidationErrorDisplay::default();
478        assert_eq!(error.icon, ERROR_ICON_DEFAULT);
479    }
480
481    // -- Min width calculation --
482
483    #[test]
484    fn min_width_icon_only() {
485        let error = ValidationErrorDisplay::new("Error").icon_only();
486        // Warning icon is 1 cell wide (may vary by font but assume 2 for emoji)
487        let icon_width = display_width(ERROR_ICON_DEFAULT) as u16;
488        assert_eq!(error.min_width(), icon_width);
489    }
490
491    #[test]
492    fn min_width_with_message() {
493        let error = ValidationErrorDisplay::new("Error");
494        let icon_width = display_width(ERROR_ICON_DEFAULT) as u16;
495        let msg_width = 5u16; // "Error"
496        assert_eq!(error.min_width(), icon_width + 1 + msg_width);
497    }
498
499    #[test]
500    fn min_width_empty_message() {
501        let error = ValidationErrorDisplay::new("");
502        let icon_width = display_width(ERROR_ICON_DEFAULT) as u16;
503        assert_eq!(error.min_width(), icon_width);
504    }
505
506    // -- State tests --
507
508    #[test]
509    fn state_default_is_hidden() {
510        let state = ValidationErrorState::default();
511        assert!(!state.is_visible());
512        assert_eq!(state.opacity(), 0.0);
513    }
514
515    #[test]
516    fn show_sets_visible_and_starts_animation() {
517        let mut state = ValidationErrorState::default();
518        state.show();
519        assert!(state.is_visible());
520        assert!(state.is_animating());
521        assert!(state.take_just_shown());
522    }
523
524    #[test]
525    fn hide_clears_visible() {
526        let mut state = ValidationErrorState::default();
527        state.show();
528        state.opacity = 1.0;
529        state.animation_start = None;
530        state.hide();
531        assert!(!state.is_visible());
532        assert!(state.is_animating());
533    }
534
535    #[test]
536    fn show_twice_is_noop() {
537        let mut state = ValidationErrorState::default();
538        state.show();
539        let start1 = state.animation_start;
540        state.just_shown = false;
541        state.show();
542        assert_eq!(state.animation_start, start1);
543        assert!(!state.just_shown); // Not re-triggered
544    }
545
546    #[test]
547    fn take_just_shown_clears_flag() {
548        let mut state = ValidationErrorState::default();
549        state.show();
550        assert!(state.take_just_shown());
551        assert!(!state.take_just_shown());
552    }
553
554    #[test]
555    fn tick_advances_opacity() {
556        let mut state = ValidationErrorState::default();
557        state.show();
558        // Simulate time passing
559        state.animation_start = Some(Instant::now() - Duration::from_millis(100));
560        state.tick(Duration::from_millis(150));
561        assert!(state.opacity > 0.0);
562        assert!(state.opacity < 1.0);
563    }
564
565    #[test]
566    fn tick_completes_animation() {
567        let mut state = ValidationErrorState::default();
568        state.show();
569        state.animation_start = Some(Instant::now() - Duration::from_millis(200));
570        state.tick(Duration::from_millis(150));
571        assert_eq!(state.opacity, 1.0);
572        assert!(!state.is_animating());
573    }
574
575    #[test]
576    fn tick_fade_out() {
577        let mut state = ValidationErrorState {
578            visible: false,
579            opacity: 1.0,
580            animation_start: Some(Instant::now() - Duration::from_millis(75)),
581            ..Default::default()
582        };
583        state.tick(Duration::from_millis(150));
584        assert!(state.opacity < 1.0);
585        assert!(state.opacity > 0.0);
586    }
587
588    #[test]
589    fn is_fully_visible_requires_complete_animation() {
590        let mut state = ValidationErrorState::default();
591        state.show();
592        assert!(!state.is_fully_visible());
593        state.opacity = 1.0;
594        state.animation_start = None;
595        assert!(state.is_fully_visible());
596    }
597
598    #[test]
599    fn aria_id_can_be_set() {
600        let state = ValidationErrorState::default().with_aria_id(42);
601        assert_eq!(state.aria_id(), 42);
602    }
603
604    // -- Rendering tests --
605
606    #[test]
607    fn render_draws_icon() {
608        let error = ValidationErrorDisplay::new("Error");
609        let area = Rect::new(0, 0, 20, 1);
610        let mut pool = GraphemePool::new();
611        let mut frame = Frame::new(20, 1, &mut pool);
612        Widget::render(&error, area, &mut frame);
613
614        // Icon should be at position 0
615        // Note: emoji width varies, but we check something was drawn
616        let cell = frame.buffer.get(0, 0).unwrap();
617        assert!(!cell.is_empty());
618    }
619
620    #[test]
621    fn render_draws_message() {
622        let error = ValidationErrorDisplay::new("Required").with_icon("!");
623        let area = Rect::new(0, 0, 20, 1);
624        let mut pool = GraphemePool::new();
625        let mut frame = Frame::new(20, 1, &mut pool);
626        Widget::render(&error, area, &mut frame);
627
628        // "!" at 0, space at 1, "Required" starts at 2
629        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('!'));
630        assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('R'));
631    }
632
633    #[test]
634    fn render_clears_separator_cell() {
635        let error = ValidationErrorDisplay::new("Required").with_icon("!");
636        let area = Rect::new(0, 0, 20, 1);
637        let mut pool = GraphemePool::new();
638        let mut frame = Frame::new(20, 1, &mut pool);
639        frame.buffer.set_fast(1, 0, Cell::from_char('X'));
640        Widget::render(&error, area, &mut frame);
641
642        assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some(' '));
643    }
644
645    #[test]
646    fn render_truncates_long_message() {
647        let error = ValidationErrorDisplay::new("This is a very long error message").with_icon("!");
648        let area = Rect::new(0, 0, 12, 1);
649        let mut pool = GraphemePool::new();
650        let mut frame = Frame::new(12, 1, &mut pool);
651        Widget::render(&error, area, &mut frame);
652
653        // Should end with ellipsis somewhere
654        let mut found_ellipsis = false;
655        for x in 0..12 {
656            if let Some(cell) = frame.buffer.get(x, 0)
657                && cell.content.as_char() == Some('…')
658            {
659                found_ellipsis = true;
660                break;
661            }
662        }
663        assert!(found_ellipsis);
664    }
665
666    #[test]
667    fn render_empty_area_is_noop() {
668        let error = ValidationErrorDisplay::new("Error");
669        let area = Rect::new(0, 0, 0, 0);
670        let mut pool = GraphemePool::new();
671        let mut frame = Frame::new(1, 1, &mut pool);
672        let mut state = ValidationErrorState::default();
673        StatefulWidget::render(&error, area, &mut frame, &mut state);
674        // Should not panic
675    }
676
677    #[test]
678    fn render_hidden_state_draws_nothing() {
679        let error = ValidationErrorDisplay::new("Error");
680        let area = Rect::new(0, 0, 20, 1);
681        let mut pool = GraphemePool::new();
682        let mut frame = Frame::new(20, 1, &mut pool);
683        let mut state = ValidationErrorState::default(); // Not visible
684        StatefulWidget::render(&error, area, &mut frame, &mut state);
685
686        // Hidden state should leave a cleared row behind.
687        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some(' '));
688    }
689
690    #[test]
691    fn render_hidden_state_clears_stale_row() {
692        let error = ValidationErrorDisplay::new("Error").with_icon("!");
693        let area = Rect::new(0, 0, 20, 1);
694        let mut pool = GraphemePool::new();
695        let mut frame = Frame::new(20, 1, &mut pool);
696        let mut visible = ValidationErrorState {
697            visible: true,
698            opacity: 1.0,
699            ..Default::default()
700        };
701        let mut hidden = ValidationErrorState::default();
702
703        StatefulWidget::render(&error, area, &mut frame, &mut visible);
704        StatefulWidget::render(&error, area, &mut frame, &mut hidden);
705
706        for x in 0..20u16 {
707            assert_eq!(frame.buffer.get(x, 0).unwrap().content.as_char(), Some(' '));
708        }
709    }
710
711    #[test]
712    fn render_visible_state_draws_content() {
713        let error = ValidationErrorDisplay::new("Error").with_icon("!");
714        let area = Rect::new(0, 0, 20, 1);
715        let mut pool = GraphemePool::new();
716        let mut frame = Frame::new(20, 1, &mut pool);
717        let mut state = ValidationErrorState {
718            visible: true,
719            opacity: 1.0,
720            ..Default::default()
721        };
722        StatefulWidget::render(&error, area, &mut frame, &mut state);
723
724        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('!'));
725    }
726
727    #[test]
728    fn render_icon_only_mode() {
729        let error = ValidationErrorDisplay::new("This error should not appear")
730            .with_icon("X")
731            .icon_only();
732        let area = Rect::new(0, 0, 20, 1);
733        let mut pool = GraphemePool::new();
734        let mut frame = Frame::new(20, 1, &mut pool);
735        Widget::render(&error, area, &mut frame);
736
737        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('X'));
738        // Position 1+ should be cleared (no message)
739        assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some(' '));
740    }
741
742    #[test]
743    fn render_shorter_message_clears_stale_suffix() {
744        let long = ValidationErrorDisplay::new("Long validation error").with_icon("!");
745        let short = ValidationErrorDisplay::new("No").with_icon("!");
746        let area = Rect::new(0, 0, 20, 1);
747        let mut pool = GraphemePool::new();
748        let mut frame = Frame::new(20, 1, &mut pool);
749
750        Widget::render(&long, area, &mut frame);
751        Widget::render(&short, area, &mut frame);
752
753        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('!'));
754        assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some(' '));
755        assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('N'));
756        assert_eq!(frame.buffer.get(3, 0).unwrap().content.as_char(), Some('o'));
757        assert_eq!(frame.buffer.get(4, 0).unwrap().content.as_char(), Some(' '));
758        assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some(' '));
759    }
760
761    #[test]
762    fn is_essential_returns_true() {
763        let error = ValidationErrorDisplay::new("Error");
764        assert!(error.is_essential());
765    }
766
767    // -- Property tests --
768
769    #[test]
770    fn opacity_always_clamped() {
771        let mut state = ValidationErrorState::default();
772        state.show();
773        state.animation_start = Some(Instant::now() - Duration::from_secs(10));
774        state.tick(Duration::from_millis(100));
775        assert!(state.opacity >= 0.0);
776        assert!(state.opacity <= 1.0);
777    }
778
779    #[test]
780    fn animation_duration_zero_is_immediate() {
781        let mut state = ValidationErrorState::default();
782        state.show();
783        state.tick(Duration::ZERO);
784        assert_eq!(state.opacity, 1.0);
785        assert!(!state.is_animating());
786    }
787
788    // -- Style tests --
789
790    #[test]
791    fn style_is_applied_to_message() {
792        let custom_style = Style::new().fg(PackedRgba::rgb(100, 200, 50));
793        let error = ValidationErrorDisplay::new("Error")
794            .with_icon("!")
795            .with_style(custom_style);
796        let area = Rect::new(0, 0, 20, 1);
797        let mut pool = GraphemePool::new();
798        let mut frame = Frame::new(20, 1, &mut pool);
799        Widget::render(&error, area, &mut frame);
800
801        // Check message cell has custom fg color
802        let cell = frame.buffer.get(2, 0).unwrap(); // 'R' of "Required"
803        assert_eq!(
804            cell.fg,
805            PackedRgba::rgb(100, 200, 50),
806            "message cell should use the custom fg color"
807        );
808    }
809
810    #[test]
811    fn icon_style_separate_from_message_style() {
812        let error = ValidationErrorDisplay::new("Error")
813            .with_icon("!")
814            .with_icon_style(Style::new().fg(PackedRgba::rgb(255, 255, 0)))
815            .with_style(Style::new().fg(PackedRgba::rgb(255, 0, 0)));
816        let area = Rect::new(0, 0, 20, 1);
817        let mut pool = GraphemePool::new();
818        let mut frame = Frame::new(20, 1, &mut pool);
819        Widget::render(&error, area, &mut frame);
820
821        let icon_cell = frame.buffer.get(0, 0).unwrap();
822        let msg_cell = frame.buffer.get(2, 0).unwrap();
823        // Icon should have yellow, message should have red
824        assert_ne!(icon_cell.fg, msg_cell.fg);
825    }
826}