1#![forbid(unsafe_code)]
2
3use std::panic::{AssertUnwindSafe, catch_unwind};
9use web_time::Instant;
10
11use ftui_core::geometry::Rect;
12use ftui_core::with_panic_cleanup_suppressed;
13use ftui_render::cell::{Cell, PackedRgba};
14use ftui_render::frame::Frame;
15use ftui_style::Style;
16use ftui_text::{display_width, grapheme_width};
17use unicode_segmentation::UnicodeSegmentation;
18
19use crate::{StatefulWidget, Widget, apply_style, draw_text_span, set_style_area};
20
21#[derive(Debug, Clone)]
23pub struct CapturedError {
24 pub message: String,
26 pub widget_name: &'static str,
28 pub area: Rect,
30 pub timestamp: Instant,
32}
33
34impl CapturedError {
35 fn from_panic(
36 payload: Box<dyn std::any::Any + Send>,
37 widget_name: &'static str,
38 area: Rect,
39 ) -> Self {
40 let mut message = if let Some(s) = payload.downcast_ref::<&str>() {
41 (*s).to_string()
42 } else if let Some(s) = payload.downcast_ref::<String>() {
43 s.clone()
44 } else {
45 "unknown panic".to_string()
46 };
47 if let Some(stripped) = message.strip_prefix("internal error: entered unreachable code: ") {
48 message = stripped.to_string();
49 }
50
51 Self {
52 message,
53 widget_name,
54 area,
55 timestamp: Instant::now(),
56 }
57 }
58}
59
60#[derive(Debug, Clone, Default)]
62pub enum ErrorBoundaryState {
63 #[default]
65 Healthy,
66 Failed(CapturedError),
68 Recovering {
70 attempts: u32,
72 last_error: CapturedError,
74 },
75}
76
77impl ErrorBoundaryState {
78 #[must_use = "use the returned error for diagnostics"]
80 pub fn error(&self) -> Option<&CapturedError> {
81 match self {
82 Self::Healthy => None,
83 Self::Failed(e) => Some(e),
84 Self::Recovering { last_error, .. } => Some(last_error),
85 }
86 }
87
88 pub fn is_failed(&self) -> bool {
90 !matches!(self, Self::Healthy)
91 }
92
93 pub fn reset(&mut self) {
95 *self = Self::Healthy;
96 }
97
98 pub fn try_recover(&mut self, max_attempts: u32) -> bool {
100 match self {
101 Self::Failed(error) => {
102 if max_attempts > 0 {
103 *self = Self::Recovering {
104 attempts: 1,
105 last_error: error.clone(),
106 };
107 true
108 } else {
109 false
110 }
111 }
112 Self::Recovering {
113 attempts,
114 last_error,
115 } => {
116 if *attempts < max_attempts {
117 *attempts += 1;
118 true
119 } else {
120 *self = Self::Failed(last_error.clone());
121 false
122 }
123 }
124 Self::Healthy => true,
125 }
126 }
127}
128
129#[derive(Debug, Clone)]
144pub struct ErrorBoundary<W> {
145 inner: W,
146 widget_name: &'static str,
147 max_recovery_attempts: u32,
148}
149
150impl<W: Widget> ErrorBoundary<W> {
151 pub fn new(inner: W, widget_name: &'static str) -> Self {
153 Self {
154 inner,
155 widget_name,
156 max_recovery_attempts: 3,
157 }
158 }
159
160 #[must_use]
162 pub fn max_recovery_attempts(mut self, max: u32) -> Self {
163 self.max_recovery_attempts = max;
164 self
165 }
166
167 pub fn widget_name(&self) -> &'static str {
169 self.widget_name
170 }
171}
172
173impl<W: Widget> StatefulWidget for ErrorBoundary<W> {
174 type State = ErrorBoundaryState;
175
176 fn render(&self, area: Rect, frame: &mut Frame, state: &mut ErrorBoundaryState) {
177 #[cfg(feature = "tracing")]
178 let _span = tracing::debug_span!(
179 "widget_render",
180 widget = "ErrorBoundary",
181 x = area.x,
182 y = area.y,
183 w = area.width,
184 h = area.height
185 )
186 .entered();
187
188 if area.is_empty() {
189 return;
190 }
191
192 match state {
193 ErrorBoundaryState::Healthy | ErrorBoundaryState::Recovering { .. } => {
194 let result = with_panic_cleanup_suppressed(|| {
195 catch_unwind(AssertUnwindSafe(|| {
196 self.inner.render(area, frame);
197 }))
198 });
199
200 match result {
201 Ok(()) => {
202 if matches!(state, ErrorBoundaryState::Recovering { .. }) {
203 *state = ErrorBoundaryState::Healthy;
204 }
205 }
206 Err(payload) => {
207 let error = CapturedError::from_panic(payload, self.widget_name, area);
208 clear_area(frame, area);
209 render_error_fallback(frame, area, &error);
210 *state = ErrorBoundaryState::Failed(error);
211 }
212 }
213 }
214 ErrorBoundaryState::Failed(error) => {
215 render_error_fallback(frame, area, error);
216 }
217 }
218 }
219}
220
221fn clear_area(frame: &mut Frame, area: Rect) {
223 let blank = Cell::from_char(' ');
224 for y in area.y..area.y.saturating_add(area.height) {
225 for x in area.x..area.x.saturating_add(area.width) {
226 frame.buffer.set_fast(x, y, blank);
227 }
228 }
229}
230
231fn render_error_fallback(frame: &mut Frame, area: Rect, error: &CapturedError) {
233 let error_fg = PackedRgba::rgb(255, 60, 60);
234 let error_bg = PackedRgba::rgb(40, 0, 0);
235 let error_style = Style::new().fg(error_fg).bg(error_bg);
236 let border_style = Style::new().fg(error_fg);
237
238 clear_area(frame, area);
241 set_style_area(&mut frame.buffer, area, Style::new().bg(error_bg));
242
243 if area.width < 3 || area.height < 1 {
244 if area.width >= 1 && area.height >= 1 {
246 let mut cell = Cell::from_char('!');
247 apply_style(&mut cell, error_style);
248 frame.buffer.set_fast(area.x, area.y, cell);
249 }
250 return;
251 }
252
253 let top = area.y;
254 let bottom = area.y.saturating_add(area.height).saturating_sub(1);
255 let left = area.x;
256 let right = area.x.saturating_add(area.width).saturating_sub(1);
257
258 for x in left..=right {
260 let c = if x == left && area.height > 1 {
261 '┌'
262 } else if x == right && area.height > 1 {
263 '┐'
264 } else {
265 '─'
266 };
267 let mut cell = Cell::from_char(c);
268 apply_style(&mut cell, border_style);
269 frame.buffer.set_fast(x, top, cell);
270 }
271
272 if area.height > 1 {
274 for x in left..=right {
275 let c = if x == left {
276 '└'
277 } else if x == right {
278 '┘'
279 } else {
280 '─'
281 };
282 let mut cell = Cell::from_char(c);
283 apply_style(&mut cell, border_style);
284 frame.buffer.set_fast(x, bottom, cell);
285 }
286 }
287
288 if area.height > 2 {
290 for y in (top + 1)..bottom {
291 let mut cell_l = Cell::from_char('│');
292 apply_style(&mut cell_l, border_style);
293 frame.buffer.set_fast(left, y, cell_l);
294
295 let mut cell_r = Cell::from_char('│');
296 apply_style(&mut cell_r, border_style);
297 frame.buffer.set_fast(right, y, cell_r);
298 }
299 }
300
301 if area.width >= 9 {
303 let title_x = left.saturating_add(1);
304 draw_text_span(frame, title_x, top, "[Error]", border_style, right);
305 }
306
307 if area.height >= 3 && area.width >= 5 {
309 let inner_left = left.saturating_add(2);
310 let inner_right = right;
311 let inner_y = top.saturating_add(1);
312 let max_chars = (inner_right.saturating_sub(inner_left)) as usize;
313
314 let msg: String = if display_width(error.message.as_str()) > max_chars.saturating_sub(2) {
315 let mut truncated = String::new();
316 let mut w = 0;
317 let limit = max_chars.saturating_sub(3);
318 for grapheme in error.message.graphemes(true) {
319 let gw = grapheme_width(grapheme);
320 if w + gw > limit {
321 break;
322 }
323 truncated.push_str(grapheme);
324 w += gw;
325 }
326 format!("! {truncated}\u{2026}")
327 } else {
328 format!("! {}", error.message)
329 };
330
331 draw_text_span(frame, inner_left, inner_y, &msg, error_style, inner_right);
332
333 if area.height >= 4 {
335 let name_msg = format!(" in: {}", error.widget_name);
336 let name_style = Style::new().fg(PackedRgba::rgb(180, 180, 180)).bg(error_bg);
337 draw_text_span(
338 frame,
339 inner_left,
340 inner_y.saturating_add(1),
341 &name_msg,
342 name_style,
343 inner_right,
344 );
345 }
346
347 if area.height >= 5 {
349 let hint_style = Style::new().fg(PackedRgba::rgb(120, 120, 120)).bg(error_bg);
350 draw_text_span(
351 frame,
352 inner_left,
353 inner_y.saturating_add(2),
354 " Press R to retry",
355 hint_style,
356 inner_right,
357 );
358 }
359 }
360}
361
362#[derive(Debug, Clone)]
367pub struct FallbackWidget {
368 error: CapturedError,
369 show_retry_hint: bool,
370}
371
372impl FallbackWidget {
373 pub fn new(error: CapturedError) -> Self {
375 Self {
376 error,
377 show_retry_hint: true,
378 }
379 }
380
381 pub fn from_message(message: impl Into<String>, widget_name: &'static str) -> Self {
383 Self::new(CapturedError {
384 message: message.into(),
385 widget_name,
386 area: Rect::default(),
387 timestamp: Instant::now(),
388 })
389 }
390
391 #[must_use]
393 pub fn without_retry_hint(mut self) -> Self {
394 self.show_retry_hint = false;
395 self
396 }
397}
398
399impl Widget for FallbackWidget {
400 fn render(&self, area: Rect, frame: &mut Frame) {
401 #[cfg(feature = "tracing")]
402 let _span = tracing::debug_span!(
403 "widget_render",
404 widget = "FallbackWidget",
405 x = area.x,
406 y = area.y,
407 w = area.width,
408 h = area.height
409 )
410 .entered();
411
412 if area.is_empty() {
413 return;
414 }
415 render_error_fallback(frame, area, &self.error);
416
417 if !self.show_retry_hint && area.height >= 5 {
419 let error_bg = PackedRgba::rgb(40, 0, 0);
420 let bg_style = Style::new().bg(error_bg);
421 let inner_y = area.y.saturating_add(3);
422 let inner_left = area.x.saturating_add(2);
423 let inner_right = area.x.saturating_add(area.width).saturating_sub(1);
424 for x in inner_left..inner_right {
426 let mut cell = Cell::new(ftui_render::cell::CellContent::from_char(' '));
427 crate::apply_style(&mut cell, bg_style);
428 frame.buffer.set_fast(x, inner_y, cell);
429 }
430 }
431 }
432}
433
434pub type FallbackFactory = Box<dyn Fn(&CapturedError) -> FallbackWidget + Send + Sync>;
436
437pub struct CustomErrorBoundary<W> {
442 inner: W,
443 widget_name: &'static str,
444 max_recovery_attempts: u32,
445 fallback_factory: Option<FallbackFactory>,
446}
447
448impl<W: Widget> CustomErrorBoundary<W> {
449 pub fn new(inner: W, widget_name: &'static str) -> Self {
451 Self {
452 inner,
453 widget_name,
454 max_recovery_attempts: 3,
455 fallback_factory: None,
456 }
457 }
458
459 #[must_use]
461 pub fn fallback_factory(
462 mut self,
463 factory: impl Fn(&CapturedError) -> FallbackWidget + Send + Sync + 'static,
464 ) -> Self {
465 self.fallback_factory = Some(Box::new(factory));
466 self
467 }
468
469 #[must_use]
471 pub fn max_recovery_attempts(mut self, max: u32) -> Self {
472 self.max_recovery_attempts = max;
473 self
474 }
475}
476
477impl<W: Widget> StatefulWidget for CustomErrorBoundary<W> {
478 type State = ErrorBoundaryState;
479
480 fn render(&self, area: Rect, frame: &mut Frame, state: &mut ErrorBoundaryState) {
481 #[cfg(feature = "tracing")]
482 let _span = tracing::debug_span!(
483 "widget_render",
484 widget = "CustomErrorBoundary",
485 x = area.x,
486 y = area.y,
487 w = area.width,
488 h = area.height
489 )
490 .entered();
491
492 if area.is_empty() {
493 return;
494 }
495
496 match state {
497 ErrorBoundaryState::Healthy | ErrorBoundaryState::Recovering { .. } => {
498 let result = with_panic_cleanup_suppressed(|| {
499 catch_unwind(AssertUnwindSafe(|| {
500 self.inner.render(area, frame);
501 }))
502 });
503
504 match result {
505 Ok(()) => {
506 if matches!(state, ErrorBoundaryState::Recovering { .. }) {
507 *state = ErrorBoundaryState::Healthy;
508 }
509 }
510 Err(payload) => {
511 let error = CapturedError::from_panic(payload, self.widget_name, area);
512 clear_area(frame, area);
513 if let Some(factory) = &self.fallback_factory {
514 let fallback = factory(&error);
515 fallback.render(area, frame);
516 } else {
517 render_error_fallback(frame, area, &error);
518 }
519 *state = ErrorBoundaryState::Failed(error);
520 }
521 }
522 }
523 ErrorBoundaryState::Failed(error) => {
524 if let Some(factory) = &self.fallback_factory {
525 let fallback = factory(error);
526 fallback.render(area, frame);
527 } else {
528 render_error_fallback(frame, area, error);
529 }
530 }
531 }
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use ftui_render::grapheme_pool::GraphemePool;
539
540 struct PanickingWidget;
541
542 impl Widget for PanickingWidget {
543 fn render(&self, _area: Rect, _frame: &mut Frame) {
544 unreachable!("widget exploded");
545 }
546 }
547
548 struct GoodWidget;
549
550 impl Widget for GoodWidget {
551 fn render(&self, area: Rect, frame: &mut Frame) {
552 if area.width > 0 && area.height > 0 {
553 frame.buffer.set(area.x, area.y, Cell::from_char('G'));
554 }
555 }
556 }
557
558 #[test]
559 fn healthy_widget_renders_normally() {
560 let boundary = ErrorBoundary::new(GoodWidget, "good");
561 let mut state = ErrorBoundaryState::default();
562 let area = Rect::new(0, 0, 10, 5);
563 let mut pool = GraphemePool::new();
564 let mut frame = Frame::new(10, 5, &mut pool);
565
566 boundary.render(area, &mut frame, &mut state);
567
568 assert!(!state.is_failed());
569 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('G'));
570 }
571
572 #[test]
573 fn catches_panic_without_propagating() {
574 let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
575 let mut state = ErrorBoundaryState::default();
576 let area = Rect::new(0, 0, 30, 5);
577 let mut pool = GraphemePool::new();
578 let mut frame = Frame::new(30, 5, &mut pool);
579
580 boundary.render(area, &mut frame, &mut state);
581
582 assert!(state.is_failed());
583 let err = state.error().unwrap();
584 assert_eq!(err.message, "widget exploded");
585 assert_eq!(err.widget_name, "panicker");
586 }
587
588 #[test]
589 fn failed_state_shows_fallback_on_rerender() {
590 let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
591 let mut state = ErrorBoundaryState::default();
592 let area = Rect::new(0, 0, 30, 5);
593 let mut pool = GraphemePool::new();
594 let mut frame = Frame::new(30, 5, &mut pool);
595
596 boundary.render(area, &mut frame, &mut state);
597
598 let mut pool2 = GraphemePool::new();
600 let mut frame2 = Frame::new(30, 5, &mut pool2);
601 boundary.render(area, &mut frame2, &mut state);
602
603 assert!(state.is_failed());
604 assert_eq!(
605 frame2.buffer.get(0, 0).unwrap().content.as_char(),
606 Some('┌')
607 );
608 }
609
610 #[test]
611 fn recovery_resets_on_success() {
612 let good = ErrorBoundary::new(GoodWidget, "good");
613 let mut state = ErrorBoundaryState::Failed(CapturedError {
614 message: "old error".to_string(),
615 widget_name: "old",
616 area: Rect::new(0, 0, 10, 5),
617 timestamp: Instant::now(),
618 });
619
620 assert!(state.try_recover(3));
621 assert!(matches!(state, ErrorBoundaryState::Recovering { .. }));
622
623 let area = Rect::new(0, 0, 10, 5);
624 let mut pool = GraphemePool::new();
625 let mut frame = Frame::new(10, 5, &mut pool);
626 good.render(area, &mut frame, &mut state);
627
628 assert!(!state.is_failed());
629 assert!(matches!(state, ErrorBoundaryState::Healthy));
630 }
631
632 #[test]
633 fn recovery_respects_max_attempts() {
634 let mut state = ErrorBoundaryState::Failed(CapturedError {
635 message: "error".to_string(),
636 widget_name: "w",
637 area: Rect::new(0, 0, 1, 1),
638 timestamp: Instant::now(),
639 });
640
641 assert!(state.try_recover(2));
642 assert!(matches!(
643 state,
644 ErrorBoundaryState::Recovering { attempts: 1, .. }
645 ));
646
647 assert!(state.try_recover(2));
648 assert!(matches!(
649 state,
650 ErrorBoundaryState::Recovering { attempts: 2, .. }
651 ));
652
653 assert!(!state.try_recover(2));
654 assert!(matches!(state, ErrorBoundaryState::Failed(_)));
655 }
656
657 #[test]
658 fn zero_max_recovery_denies_immediately() {
659 let mut state = ErrorBoundaryState::Failed(CapturedError {
660 message: "error".to_string(),
661 widget_name: "w",
662 area: Rect::new(0, 0, 1, 1),
663 timestamp: Instant::now(),
664 });
665
666 assert!(!state.try_recover(0));
667 assert!(matches!(state, ErrorBoundaryState::Failed(_)));
668 }
669
670 #[test]
671 fn reset_clears_error() {
672 let mut state = ErrorBoundaryState::Failed(CapturedError {
673 message: "error".to_string(),
674 widget_name: "w",
675 area: Rect::new(0, 0, 1, 1),
676 timestamp: Instant::now(),
677 });
678
679 state.reset();
680 assert!(!state.is_failed());
681 assert!(matches!(state, ErrorBoundaryState::Healthy));
682 }
683
684 #[test]
685 fn empty_area_is_noop() {
686 let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
687 let mut state = ErrorBoundaryState::default();
688 let area = Rect::new(0, 0, 0, 0);
689 let mut pool = GraphemePool::new();
690 let mut frame = Frame::new(1, 1, &mut pool);
691
692 boundary.render(area, &mut frame, &mut state);
693
694 assert!(!state.is_failed());
695 }
696
697 #[test]
698 fn small_area_shows_minimal_fallback() {
699 let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
700 let mut state = ErrorBoundaryState::default();
701 let area = Rect::new(0, 0, 2, 1);
702 let mut pool = GraphemePool::new();
703 let mut frame = Frame::new(2, 1, &mut pool);
704
705 boundary.render(area, &mut frame, &mut state);
706
707 assert!(state.is_failed());
708 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('!'));
709 }
710
711 #[test]
712 fn captured_error_extracts_string_panic() {
713 let payload: Box<dyn std::any::Any + Send> = Box::new("test error".to_string());
714 let error = CapturedError::from_panic(payload, "test", Rect::new(0, 0, 1, 1));
715 assert_eq!(error.message, "test error");
716 }
717
718 #[test]
719 fn captured_error_extracts_str_panic() {
720 let payload: Box<dyn std::any::Any + Send> = Box::new("static error");
721 let error = CapturedError::from_panic(payload, "test", Rect::new(0, 0, 1, 1));
722 assert_eq!(error.message, "static error");
723 }
724
725 #[test]
726 fn captured_error_handles_unknown_panic() {
727 let payload: Box<dyn std::any::Any + Send> = Box::new(42u32);
728 let error = CapturedError::from_panic(payload, "test", Rect::new(0, 0, 1, 1));
729 assert_eq!(error.message, "unknown panic");
730 }
731
732 #[test]
733 fn failed_state_renders_fallback_directly() {
734 let boundary = ErrorBoundary::new(GoodWidget, "good");
735 let mut state = ErrorBoundaryState::Failed(CapturedError {
736 message: "previous error".to_string(),
737 widget_name: "other",
738 area: Rect::new(0, 0, 30, 5),
739 timestamp: Instant::now(),
740 });
741
742 let area = Rect::new(0, 0, 30, 5);
743 let mut pool = GraphemePool::new();
744 let mut frame = Frame::new(30, 5, &mut pool);
745 boundary.render(area, &mut frame, &mut state);
746
747 assert!(state.is_failed());
748 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
750 }
751
752 #[test]
753 fn failed_state_rerender_clears_stale_content() {
754 let boundary = ErrorBoundary::new(GoodWidget, "good");
755 let mut state = ErrorBoundaryState::Failed(CapturedError {
756 message: "err".to_string(),
757 widget_name: "other",
758 area: Rect::new(0, 0, 30, 5),
759 timestamp: Instant::now(),
760 });
761
762 let area = Rect::new(0, 0, 30, 5);
763 let mut pool = GraphemePool::new();
764 let mut frame = Frame::new(30, 5, &mut pool);
765 for y in area.y..area.bottom() {
766 for x in area.x..area.right() {
767 frame.buffer.set(x, y, Cell::from_char('X'));
768 }
769 }
770
771 boundary.render(area, &mut frame, &mut state);
772
773 assert_eq!(
774 frame.buffer.get(20, 1).unwrap().content.as_char(),
775 Some(' ')
776 );
777 }
778
779 #[test]
780 fn fallback_widget_renders_standalone() {
781 let fallback = FallbackWidget::from_message("render failed", "my_widget");
782 let area = Rect::new(0, 0, 30, 5);
783 let mut pool = GraphemePool::new();
784 let mut frame = Frame::new(30, 5, &mut pool);
785 fallback.render(area, &mut frame);
786
787 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
789 }
790
791 #[test]
792 fn fallback_widget_clears_stale_content() {
793 let fallback = FallbackWidget::from_message("err", "w");
794 let area = Rect::new(0, 0, 30, 5);
795 let mut pool = GraphemePool::new();
796 let mut frame = Frame::new(30, 5, &mut pool);
797 for y in area.y..area.bottom() {
798 for x in area.x..area.right() {
799 frame.buffer.set(x, y, Cell::from_char('X'));
800 }
801 }
802
803 fallback.render(area, &mut frame);
804
805 assert_eq!(
806 frame.buffer.get(20, 1).unwrap().content.as_char(),
807 Some(' ')
808 );
809 }
810
811 #[test]
812 fn fallback_widget_without_retry_hint() {
813 let fallback = FallbackWidget::from_message("error", "w").without_retry_hint();
814 let area = Rect::new(0, 0, 30, 6);
815 let mut pool = GraphemePool::new();
816 let mut frame = Frame::new(30, 6, &mut pool);
817 fallback.render(area, &mut frame);
818
819 let hint_cell = frame.buffer.get(4, 3).unwrap();
822 assert_eq!(hint_cell.content.as_char(), Some(' '));
823 }
824
825 #[test]
826 fn fallback_widget_empty_area() {
827 let fallback = FallbackWidget::from_message("error", "w");
828 let area = Rect::new(0, 0, 0, 0);
829 let mut pool = GraphemePool::new();
830 let mut frame = Frame::new(1, 1, &mut pool);
831 fallback.render(area, &mut frame);
832 }
834
835 #[test]
836 fn custom_error_boundary_uses_factory() {
837 let boundary =
838 CustomErrorBoundary::new(PanickingWidget, "panicker").fallback_factory(|error| {
839 FallbackWidget::from_message(
840 format!("CUSTOM: {}", error.message),
841 error.widget_name,
842 )
843 .without_retry_hint()
844 });
845 let mut state = ErrorBoundaryState::default();
846 let area = Rect::new(0, 0, 40, 5);
847 let mut pool = GraphemePool::new();
848 let mut frame = Frame::new(40, 5, &mut pool);
849 boundary.render(area, &mut frame, &mut state);
850
851 assert!(state.is_failed());
852 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
854 }
855
856 #[test]
857 fn custom_error_boundary_default_fallback() {
858 let boundary = CustomErrorBoundary::new(PanickingWidget, "panicker");
859 let mut state = ErrorBoundaryState::default();
860 let area = Rect::new(0, 0, 30, 5);
861 let mut pool = GraphemePool::new();
862 let mut frame = Frame::new(30, 5, &mut pool);
863 boundary.render(area, &mut frame, &mut state);
864
865 assert!(state.is_failed());
866 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
867 }
868
869 #[test]
870 fn retry_hint_shows_in_tall_area() {
871 let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
872 let mut state = ErrorBoundaryState::default();
873 let area = Rect::new(0, 0, 30, 6);
874 let mut pool = GraphemePool::new();
875 let mut frame = Frame::new(30, 6, &mut pool);
876 boundary.render(area, &mut frame, &mut state);
877
878 assert!(state.is_failed());
879 let p_cell = frame.buffer.get(4, 3).unwrap();
883 assert_eq!(p_cell.content.as_char(), Some('P'));
884 }
885
886 #[test]
887 fn error_in_sibling_does_not_affect_other() {
888 let bad = ErrorBoundary::new(PanickingWidget, "bad");
889 let good = ErrorBoundary::new(GoodWidget, "good");
890 let mut bad_state = ErrorBoundaryState::default();
891 let mut good_state = ErrorBoundaryState::default();
892
893 let mut pool = GraphemePool::new();
894 let mut frame = Frame::new(20, 5, &mut pool);
895 let area_a = Rect::new(0, 0, 10, 5);
896 let area_b = Rect::new(10, 0, 10, 5);
897
898 bad.render(area_a, &mut frame, &mut bad_state);
899 good.render(area_b, &mut frame, &mut good_state);
900
901 assert!(bad_state.is_failed());
902 assert!(!good_state.is_failed());
903 assert_eq!(
904 frame.buffer.get(10, 0).unwrap().content.as_char(),
905 Some('G')
906 );
907 }
908
909 #[test]
910 fn max_recovery_attempts_builder() {
911 let boundary = ErrorBoundary::new(GoodWidget, "good").max_recovery_attempts(5);
912 assert_eq!(boundary.max_recovery_attempts, 5);
913 }
914
915 #[test]
916 fn widget_name_accessor() {
917 let boundary = ErrorBoundary::new(GoodWidget, "my_widget");
918 assert_eq!(boundary.widget_name(), "my_widget");
919 }
920
921 #[test]
922 fn error_state_error_accessor_recovering() {
923 let err = CapturedError {
924 message: "fail".to_string(),
925 widget_name: "w",
926 area: Rect::new(0, 0, 1, 1),
927 timestamp: Instant::now(),
928 };
929 let state = ErrorBoundaryState::Recovering {
930 attempts: 2,
931 last_error: err,
932 };
933 assert!(state.is_failed());
934 assert_eq!(state.error().unwrap().message, "fail");
935 }
936
937 #[test]
938 fn try_recover_on_healthy_returns_true() {
939 let mut state = ErrorBoundaryState::Healthy;
940 assert!(state.try_recover(3));
941 assert!(matches!(state, ErrorBoundaryState::Healthy));
942 }
943
944 #[test]
945 fn captured_error_strips_unreachable_prefix() {
946 let msg = "internal error: entered unreachable code: widget exploded";
947 let payload: Box<dyn std::any::Any + Send> = Box::new(msg.to_string());
948 let error = CapturedError::from_panic(payload, "test", Rect::new(0, 0, 1, 1));
949 assert_eq!(error.message, "widget exploded");
950 }
951
952 #[test]
953 fn default_state_is_healthy() {
954 let state = ErrorBoundaryState::default();
955 assert!(!state.is_failed());
956 assert!(state.error().is_none());
957 }
958
959 #[test]
960 fn custom_boundary_max_recovery_builder() {
961 let boundary = CustomErrorBoundary::new(GoodWidget, "good").max_recovery_attempts(7);
962 assert_eq!(boundary.max_recovery_attempts, 7);
963 }
964
965 #[test]
966 fn fallback_widget_new_directly() {
967 let err = CapturedError {
968 message: "direct error".to_string(),
969 widget_name: "direct",
970 area: Rect::new(0, 0, 10, 5),
971 timestamp: Instant::now(),
972 };
973 let fallback = FallbackWidget::new(err);
974 assert!(fallback.show_retry_hint);
975 assert_eq!(fallback.error.message, "direct error");
976 }
977
978 #[test]
979 fn recovering_state_panics_revert_to_failed() {
980 let boundary = ErrorBoundary::new(PanickingWidget, "bad");
981 let err = CapturedError {
982 message: "initial".to_string(),
983 widget_name: "bad",
984 area: Rect::new(0, 0, 30, 5),
985 timestamp: Instant::now(),
986 };
987 let mut state = ErrorBoundaryState::Recovering {
988 attempts: 1,
989 last_error: err,
990 };
991
992 let area = Rect::new(0, 0, 30, 5);
993 let mut pool = GraphemePool::new();
994 let mut frame = Frame::new(30, 5, &mut pool);
995 boundary.render(area, &mut frame, &mut state);
996
997 assert!(matches!(state, ErrorBoundaryState::Failed(_)));
999 }
1000}