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