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 set_style_area(&mut frame.buffer, area, Style::new().bg(error_bg));
239
240 if area.width < 3 || area.height < 1 {
241 if area.width >= 1 && area.height >= 1 {
243 let mut cell = Cell::from_char('!');
244 apply_style(&mut cell, error_style);
245 frame.buffer.set_fast(area.x, area.y, cell);
246 }
247 return;
248 }
249
250 let top = area.y;
251 let bottom = area.y.saturating_add(area.height).saturating_sub(1);
252 let left = area.x;
253 let right = area.x.saturating_add(area.width).saturating_sub(1);
254
255 for x in left..=right {
257 let c = if x == left && area.height > 1 {
258 '┌'
259 } else if x == right && area.height > 1 {
260 '┐'
261 } else {
262 '─'
263 };
264 let mut cell = Cell::from_char(c);
265 apply_style(&mut cell, border_style);
266 frame.buffer.set_fast(x, top, cell);
267 }
268
269 if area.height > 1 {
271 for x in left..=right {
272 let c = if x == left {
273 '└'
274 } else if x == right {
275 '┘'
276 } else {
277 '─'
278 };
279 let mut cell = Cell::from_char(c);
280 apply_style(&mut cell, border_style);
281 frame.buffer.set_fast(x, bottom, cell);
282 }
283 }
284
285 if area.height > 2 {
287 for y in (top + 1)..bottom {
288 let mut cell_l = Cell::from_char('│');
289 apply_style(&mut cell_l, border_style);
290 frame.buffer.set_fast(left, y, cell_l);
291
292 let mut cell_r = Cell::from_char('│');
293 apply_style(&mut cell_r, border_style);
294 frame.buffer.set_fast(right, y, cell_r);
295 }
296 }
297
298 if area.width >= 9 {
300 let title_x = left.saturating_add(1);
301 draw_text_span(frame, title_x, top, "[Error]", border_style, right);
302 }
303
304 if area.height >= 3 && area.width >= 5 {
306 let inner_left = left.saturating_add(2);
307 let inner_right = right;
308 let inner_y = top.saturating_add(1);
309 let max_chars = (inner_right.saturating_sub(inner_left)) as usize;
310
311 let msg: String = if display_width(error.message.as_str()) > max_chars.saturating_sub(2) {
312 let mut truncated = String::new();
313 let mut w = 0;
314 let limit = max_chars.saturating_sub(3);
315 for grapheme in error.message.graphemes(true) {
316 let gw = grapheme_width(grapheme);
317 if w + gw > limit {
318 break;
319 }
320 truncated.push_str(grapheme);
321 w += gw;
322 }
323 format!("! {truncated}\u{2026}")
324 } else {
325 format!("! {}", error.message)
326 };
327
328 draw_text_span(frame, inner_left, inner_y, &msg, error_style, inner_right);
329
330 if area.height >= 4 {
332 let name_msg = format!(" in: {}", error.widget_name);
333 let name_style = Style::new().fg(PackedRgba::rgb(180, 180, 180)).bg(error_bg);
334 draw_text_span(
335 frame,
336 inner_left,
337 inner_y.saturating_add(1),
338 &name_msg,
339 name_style,
340 inner_right,
341 );
342 }
343
344 if area.height >= 5 {
346 let hint_style = Style::new().fg(PackedRgba::rgb(120, 120, 120)).bg(error_bg);
347 draw_text_span(
348 frame,
349 inner_left,
350 inner_y.saturating_add(2),
351 " Press R to retry",
352 hint_style,
353 inner_right,
354 );
355 }
356 }
357}
358
359#[derive(Debug, Clone)]
364pub struct FallbackWidget {
365 error: CapturedError,
366 show_retry_hint: bool,
367}
368
369impl FallbackWidget {
370 pub fn new(error: CapturedError) -> Self {
372 Self {
373 error,
374 show_retry_hint: true,
375 }
376 }
377
378 pub fn from_message(message: impl Into<String>, widget_name: &'static str) -> Self {
380 Self::new(CapturedError {
381 message: message.into(),
382 widget_name,
383 area: Rect::default(),
384 timestamp: Instant::now(),
385 })
386 }
387
388 #[must_use]
390 pub fn without_retry_hint(mut self) -> Self {
391 self.show_retry_hint = false;
392 self
393 }
394}
395
396impl Widget for FallbackWidget {
397 fn render(&self, area: Rect, frame: &mut Frame) {
398 #[cfg(feature = "tracing")]
399 let _span = tracing::debug_span!(
400 "widget_render",
401 widget = "FallbackWidget",
402 x = area.x,
403 y = area.y,
404 w = area.width,
405 h = area.height
406 )
407 .entered();
408
409 if area.is_empty() {
410 return;
411 }
412 render_error_fallback(frame, area, &self.error);
413
414 if !self.show_retry_hint && area.height >= 5 {
416 let error_bg = PackedRgba::rgb(40, 0, 0);
417 let bg_style = Style::new().bg(error_bg);
418 let inner_y = area.y.saturating_add(3);
419 let inner_left = area.x.saturating_add(2);
420 let inner_right = area.x.saturating_add(area.width).saturating_sub(1);
421 for x in inner_left..inner_right {
423 if let Some(cell) = frame.buffer.get_mut(x, inner_y) {
424 cell.content = ftui_render::cell::CellContent::from_char(' ');
425 apply_style(cell, bg_style);
426 }
427 }
428 }
429 }
430}
431
432pub type FallbackFactory = Box<dyn Fn(&CapturedError) -> FallbackWidget + Send + Sync>;
434
435pub struct CustomErrorBoundary<W> {
440 inner: W,
441 widget_name: &'static str,
442 max_recovery_attempts: u32,
443 fallback_factory: Option<FallbackFactory>,
444}
445
446impl<W: Widget> CustomErrorBoundary<W> {
447 pub fn new(inner: W, widget_name: &'static str) -> Self {
449 Self {
450 inner,
451 widget_name,
452 max_recovery_attempts: 3,
453 fallback_factory: None,
454 }
455 }
456
457 #[must_use]
459 pub fn fallback_factory(
460 mut self,
461 factory: impl Fn(&CapturedError) -> FallbackWidget + Send + Sync + 'static,
462 ) -> Self {
463 self.fallback_factory = Some(Box::new(factory));
464 self
465 }
466
467 #[must_use]
469 pub fn max_recovery_attempts(mut self, max: u32) -> Self {
470 self.max_recovery_attempts = max;
471 self
472 }
473}
474
475impl<W: Widget> StatefulWidget for CustomErrorBoundary<W> {
476 type State = ErrorBoundaryState;
477
478 fn render(&self, area: Rect, frame: &mut Frame, state: &mut ErrorBoundaryState) {
479 #[cfg(feature = "tracing")]
480 let _span = tracing::debug_span!(
481 "widget_render",
482 widget = "CustomErrorBoundary",
483 x = area.x,
484 y = area.y,
485 w = area.width,
486 h = area.height
487 )
488 .entered();
489
490 if area.is_empty() {
491 return;
492 }
493
494 match state {
495 ErrorBoundaryState::Healthy | ErrorBoundaryState::Recovering { .. } => {
496 let result = with_panic_cleanup_suppressed(|| {
497 catch_unwind(AssertUnwindSafe(|| {
498 self.inner.render(area, frame);
499 }))
500 });
501
502 match result {
503 Ok(()) => {
504 if matches!(state, ErrorBoundaryState::Recovering { .. }) {
505 *state = ErrorBoundaryState::Healthy;
506 }
507 }
508 Err(payload) => {
509 let error = CapturedError::from_panic(payload, self.widget_name, area);
510 clear_area(frame, area);
511 if let Some(factory) = &self.fallback_factory {
512 let fallback = factory(&error);
513 fallback.render(area, frame);
514 } else {
515 render_error_fallback(frame, area, &error);
516 }
517 *state = ErrorBoundaryState::Failed(error);
518 }
519 }
520 }
521 ErrorBoundaryState::Failed(error) => {
522 if let Some(factory) = &self.fallback_factory {
523 let fallback = factory(error);
524 fallback.render(area, frame);
525 } else {
526 render_error_fallback(frame, area, error);
527 }
528 }
529 }
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use ftui_render::grapheme_pool::GraphemePool;
537
538 struct PanickingWidget;
539
540 impl Widget for PanickingWidget {
541 fn render(&self, _area: Rect, _frame: &mut Frame) {
542 unreachable!("widget exploded");
543 }
544 }
545
546 struct GoodWidget;
547
548 impl Widget for GoodWidget {
549 fn render(&self, area: Rect, frame: &mut Frame) {
550 if area.width > 0 && area.height > 0 {
551 frame.buffer.set(area.x, area.y, Cell::from_char('G'));
552 }
553 }
554 }
555
556 #[test]
557 fn healthy_widget_renders_normally() {
558 let boundary = ErrorBoundary::new(GoodWidget, "good");
559 let mut state = ErrorBoundaryState::default();
560 let area = Rect::new(0, 0, 10, 5);
561 let mut pool = GraphemePool::new();
562 let mut frame = Frame::new(10, 5, &mut pool);
563
564 boundary.render(area, &mut frame, &mut state);
565
566 assert!(!state.is_failed());
567 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('G'));
568 }
569
570 #[test]
571 fn catches_panic_without_propagating() {
572 let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
573 let mut state = ErrorBoundaryState::default();
574 let area = Rect::new(0, 0, 30, 5);
575 let mut pool = GraphemePool::new();
576 let mut frame = Frame::new(30, 5, &mut pool);
577
578 boundary.render(area, &mut frame, &mut state);
579
580 assert!(state.is_failed());
581 let err = state.error().unwrap();
582 assert_eq!(err.message, "widget exploded");
583 assert_eq!(err.widget_name, "panicker");
584 }
585
586 #[test]
587 fn failed_state_shows_fallback_on_rerender() {
588 let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
589 let mut state = ErrorBoundaryState::default();
590 let area = Rect::new(0, 0, 30, 5);
591 let mut pool = GraphemePool::new();
592 let mut frame = Frame::new(30, 5, &mut pool);
593
594 boundary.render(area, &mut frame, &mut state);
595
596 let mut pool2 = GraphemePool::new();
598 let mut frame2 = Frame::new(30, 5, &mut pool2);
599 boundary.render(area, &mut frame2, &mut state);
600
601 assert!(state.is_failed());
602 assert_eq!(
603 frame2.buffer.get(0, 0).unwrap().content.as_char(),
604 Some('┌')
605 );
606 }
607
608 #[test]
609 fn recovery_resets_on_success() {
610 let good = ErrorBoundary::new(GoodWidget, "good");
611 let mut state = ErrorBoundaryState::Failed(CapturedError {
612 message: "old error".to_string(),
613 widget_name: "old",
614 area: Rect::new(0, 0, 10, 5),
615 timestamp: Instant::now(),
616 });
617
618 assert!(state.try_recover(3));
619 assert!(matches!(state, ErrorBoundaryState::Recovering { .. }));
620
621 let area = Rect::new(0, 0, 10, 5);
622 let mut pool = GraphemePool::new();
623 let mut frame = Frame::new(10, 5, &mut pool);
624 good.render(area, &mut frame, &mut state);
625
626 assert!(!state.is_failed());
627 assert!(matches!(state, ErrorBoundaryState::Healthy));
628 }
629
630 #[test]
631 fn recovery_respects_max_attempts() {
632 let mut state = ErrorBoundaryState::Failed(CapturedError {
633 message: "error".to_string(),
634 widget_name: "w",
635 area: Rect::new(0, 0, 1, 1),
636 timestamp: Instant::now(),
637 });
638
639 assert!(state.try_recover(2));
640 assert!(matches!(
641 state,
642 ErrorBoundaryState::Recovering { attempts: 1, .. }
643 ));
644
645 assert!(state.try_recover(2));
646 assert!(matches!(
647 state,
648 ErrorBoundaryState::Recovering { attempts: 2, .. }
649 ));
650
651 assert!(!state.try_recover(2));
652 assert!(matches!(state, ErrorBoundaryState::Failed(_)));
653 }
654
655 #[test]
656 fn zero_max_recovery_denies_immediately() {
657 let mut state = ErrorBoundaryState::Failed(CapturedError {
658 message: "error".to_string(),
659 widget_name: "w",
660 area: Rect::new(0, 0, 1, 1),
661 timestamp: Instant::now(),
662 });
663
664 assert!(!state.try_recover(0));
665 assert!(matches!(state, ErrorBoundaryState::Failed(_)));
666 }
667
668 #[test]
669 fn reset_clears_error() {
670 let mut state = ErrorBoundaryState::Failed(CapturedError {
671 message: "error".to_string(),
672 widget_name: "w",
673 area: Rect::new(0, 0, 1, 1),
674 timestamp: Instant::now(),
675 });
676
677 state.reset();
678 assert!(!state.is_failed());
679 assert!(matches!(state, ErrorBoundaryState::Healthy));
680 }
681
682 #[test]
683 fn empty_area_is_noop() {
684 let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
685 let mut state = ErrorBoundaryState::default();
686 let area = Rect::new(0, 0, 0, 0);
687 let mut pool = GraphemePool::new();
688 let mut frame = Frame::new(1, 1, &mut pool);
689
690 boundary.render(area, &mut frame, &mut state);
691
692 assert!(!state.is_failed());
693 }
694
695 #[test]
696 fn small_area_shows_minimal_fallback() {
697 let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
698 let mut state = ErrorBoundaryState::default();
699 let area = Rect::new(0, 0, 2, 1);
700 let mut pool = GraphemePool::new();
701 let mut frame = Frame::new(2, 1, &mut pool);
702
703 boundary.render(area, &mut frame, &mut state);
704
705 assert!(state.is_failed());
706 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('!'));
707 }
708
709 #[test]
710 fn captured_error_extracts_string_panic() {
711 let payload: Box<dyn std::any::Any + Send> = Box::new("test error".to_string());
712 let error = CapturedError::from_panic(payload, "test", Rect::new(0, 0, 1, 1));
713 assert_eq!(error.message, "test error");
714 }
715
716 #[test]
717 fn captured_error_extracts_str_panic() {
718 let payload: Box<dyn std::any::Any + Send> = Box::new("static error");
719 let error = CapturedError::from_panic(payload, "test", Rect::new(0, 0, 1, 1));
720 assert_eq!(error.message, "static error");
721 }
722
723 #[test]
724 fn captured_error_handles_unknown_panic() {
725 let payload: Box<dyn std::any::Any + Send> = Box::new(42u32);
726 let error = CapturedError::from_panic(payload, "test", Rect::new(0, 0, 1, 1));
727 assert_eq!(error.message, "unknown panic");
728 }
729
730 #[test]
731 fn failed_state_renders_fallback_directly() {
732 let boundary = ErrorBoundary::new(GoodWidget, "good");
733 let mut state = ErrorBoundaryState::Failed(CapturedError {
734 message: "previous error".to_string(),
735 widget_name: "other",
736 area: Rect::new(0, 0, 30, 5),
737 timestamp: Instant::now(),
738 });
739
740 let area = Rect::new(0, 0, 30, 5);
741 let mut pool = GraphemePool::new();
742 let mut frame = Frame::new(30, 5, &mut pool);
743 boundary.render(area, &mut frame, &mut state);
744
745 assert!(state.is_failed());
746 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
748 }
749
750 #[test]
751 fn fallback_widget_renders_standalone() {
752 let fallback = FallbackWidget::from_message("render failed", "my_widget");
753 let area = Rect::new(0, 0, 30, 5);
754 let mut pool = GraphemePool::new();
755 let mut frame = Frame::new(30, 5, &mut pool);
756 fallback.render(area, &mut frame);
757
758 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
760 }
761
762 #[test]
763 fn fallback_widget_without_retry_hint() {
764 let fallback = FallbackWidget::from_message("error", "w").without_retry_hint();
765 let area = Rect::new(0, 0, 30, 6);
766 let mut pool = GraphemePool::new();
767 let mut frame = Frame::new(30, 6, &mut pool);
768 fallback.render(area, &mut frame);
769
770 let hint_cell = frame.buffer.get(4, 3).unwrap();
773 assert_eq!(hint_cell.content.as_char(), Some(' '));
774 }
775
776 #[test]
777 fn fallback_widget_empty_area() {
778 let fallback = FallbackWidget::from_message("error", "w");
779 let area = Rect::new(0, 0, 0, 0);
780 let mut pool = GraphemePool::new();
781 let mut frame = Frame::new(1, 1, &mut pool);
782 fallback.render(area, &mut frame);
783 }
785
786 #[test]
787 fn custom_error_boundary_uses_factory() {
788 let boundary =
789 CustomErrorBoundary::new(PanickingWidget, "panicker").fallback_factory(|error| {
790 FallbackWidget::from_message(
791 format!("CUSTOM: {}", error.message),
792 error.widget_name,
793 )
794 .without_retry_hint()
795 });
796 let mut state = ErrorBoundaryState::default();
797 let area = Rect::new(0, 0, 40, 5);
798 let mut pool = GraphemePool::new();
799 let mut frame = Frame::new(40, 5, &mut pool);
800 boundary.render(area, &mut frame, &mut state);
801
802 assert!(state.is_failed());
803 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
805 }
806
807 #[test]
808 fn custom_error_boundary_default_fallback() {
809 let boundary = CustomErrorBoundary::new(PanickingWidget, "panicker");
810 let mut state = ErrorBoundaryState::default();
811 let area = Rect::new(0, 0, 30, 5);
812 let mut pool = GraphemePool::new();
813 let mut frame = Frame::new(30, 5, &mut pool);
814 boundary.render(area, &mut frame, &mut state);
815
816 assert!(state.is_failed());
817 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
818 }
819
820 #[test]
821 fn retry_hint_shows_in_tall_area() {
822 let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
823 let mut state = ErrorBoundaryState::default();
824 let area = Rect::new(0, 0, 30, 6);
825 let mut pool = GraphemePool::new();
826 let mut frame = Frame::new(30, 6, &mut pool);
827 boundary.render(area, &mut frame, &mut state);
828
829 assert!(state.is_failed());
830 let p_cell = frame.buffer.get(4, 3).unwrap();
834 assert_eq!(p_cell.content.as_char(), Some('P'));
835 }
836
837 #[test]
838 fn error_in_sibling_does_not_affect_other() {
839 let bad = ErrorBoundary::new(PanickingWidget, "bad");
840 let good = ErrorBoundary::new(GoodWidget, "good");
841 let mut bad_state = ErrorBoundaryState::default();
842 let mut good_state = ErrorBoundaryState::default();
843
844 let mut pool = GraphemePool::new();
845 let mut frame = Frame::new(20, 5, &mut pool);
846 let area_a = Rect::new(0, 0, 10, 5);
847 let area_b = Rect::new(10, 0, 10, 5);
848
849 bad.render(area_a, &mut frame, &mut bad_state);
850 good.render(area_b, &mut frame, &mut good_state);
851
852 assert!(bad_state.is_failed());
853 assert!(!good_state.is_failed());
854 assert_eq!(
855 frame.buffer.get(10, 0).unwrap().content.as_char(),
856 Some('G')
857 );
858 }
859
860 #[test]
861 fn max_recovery_attempts_builder() {
862 let boundary = ErrorBoundary::new(GoodWidget, "good").max_recovery_attempts(5);
863 assert_eq!(boundary.max_recovery_attempts, 5);
864 }
865
866 #[test]
867 fn widget_name_accessor() {
868 let boundary = ErrorBoundary::new(GoodWidget, "my_widget");
869 assert_eq!(boundary.widget_name(), "my_widget");
870 }
871
872 #[test]
873 fn error_state_error_accessor_recovering() {
874 let err = CapturedError {
875 message: "fail".to_string(),
876 widget_name: "w",
877 area: Rect::new(0, 0, 1, 1),
878 timestamp: Instant::now(),
879 };
880 let state = ErrorBoundaryState::Recovering {
881 attempts: 2,
882 last_error: err,
883 };
884 assert!(state.is_failed());
885 assert_eq!(state.error().unwrap().message, "fail");
886 }
887
888 #[test]
889 fn try_recover_on_healthy_returns_true() {
890 let mut state = ErrorBoundaryState::Healthy;
891 assert!(state.try_recover(3));
892 assert!(matches!(state, ErrorBoundaryState::Healthy));
893 }
894
895 #[test]
896 fn captured_error_strips_unreachable_prefix() {
897 let msg = "internal error: entered unreachable code: widget exploded";
898 let payload: Box<dyn std::any::Any + Send> = Box::new(msg.to_string());
899 let error = CapturedError::from_panic(payload, "test", Rect::new(0, 0, 1, 1));
900 assert_eq!(error.message, "widget exploded");
901 }
902
903 #[test]
904 fn default_state_is_healthy() {
905 let state = ErrorBoundaryState::default();
906 assert!(!state.is_failed());
907 assert!(state.error().is_none());
908 }
909
910 #[test]
911 fn custom_boundary_max_recovery_builder() {
912 let boundary = CustomErrorBoundary::new(GoodWidget, "good").max_recovery_attempts(7);
913 assert_eq!(boundary.max_recovery_attempts, 7);
914 }
915
916 #[test]
917 fn fallback_widget_new_directly() {
918 let err = CapturedError {
919 message: "direct error".to_string(),
920 widget_name: "direct",
921 area: Rect::new(0, 0, 10, 5),
922 timestamp: Instant::now(),
923 };
924 let fallback = FallbackWidget::new(err);
925 assert!(fallback.show_retry_hint);
926 assert_eq!(fallback.error.message, "direct error");
927 }
928
929 #[test]
930 fn recovering_state_panics_revert_to_failed() {
931 let boundary = ErrorBoundary::new(PanickingWidget, "bad");
932 let err = CapturedError {
933 message: "initial".to_string(),
934 widget_name: "bad",
935 area: Rect::new(0, 0, 30, 5),
936 timestamp: Instant::now(),
937 };
938 let mut state = ErrorBoundaryState::Recovering {
939 attempts: 1,
940 last_error: err,
941 };
942
943 let area = Rect::new(0, 0, 30, 5);
944 let mut pool = GraphemePool::new();
945 let mut frame = Frame::new(30, 5, &mut pool);
946 boundary.render(area, &mut frame, &mut state);
947
948 assert!(matches!(state, ErrorBoundaryState::Failed(_)));
950 }
951}