1#![forbid(unsafe_code)]
2
3use std::panic::{AssertUnwindSafe, catch_unwind};
9use std::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 pub fn error(&self) -> Option<&CapturedError> {
79 match self {
80 Self::Healthy => None,
81 Self::Failed(e) => Some(e),
82 Self::Recovering { last_error, .. } => Some(last_error),
83 }
84 }
85
86 pub fn is_failed(&self) -> bool {
88 !matches!(self, Self::Healthy)
89 }
90
91 pub fn reset(&mut self) {
93 *self = Self::Healthy;
94 }
95
96 pub fn try_recover(&mut self, max_attempts: u32) -> bool {
98 match self {
99 Self::Failed(error) => {
100 if max_attempts > 0 {
101 *self = Self::Recovering {
102 attempts: 1,
103 last_error: error.clone(),
104 };
105 true
106 } else {
107 false
108 }
109 }
110 Self::Recovering {
111 attempts,
112 last_error,
113 } => {
114 if *attempts < max_attempts {
115 *attempts += 1;
116 true
117 } else {
118 *self = Self::Failed(last_error.clone());
119 false
120 }
121 }
122 Self::Healthy => true,
123 }
124 }
125}
126
127#[derive(Debug, Clone)]
142pub struct ErrorBoundary<W> {
143 inner: W,
144 widget_name: &'static str,
145 max_recovery_attempts: u32,
146}
147
148impl<W: Widget> ErrorBoundary<W> {
149 pub fn new(inner: W, widget_name: &'static str) -> Self {
151 Self {
152 inner,
153 widget_name,
154 max_recovery_attempts: 3,
155 }
156 }
157
158 pub fn max_recovery_attempts(mut self, max: u32) -> Self {
160 self.max_recovery_attempts = max;
161 self
162 }
163
164 pub fn widget_name(&self) -> &'static str {
166 self.widget_name
167 }
168}
169
170impl<W: Widget> StatefulWidget for ErrorBoundary<W> {
171 type State = ErrorBoundaryState;
172
173 fn render(&self, area: Rect, frame: &mut Frame, state: &mut ErrorBoundaryState) {
174 #[cfg(feature = "tracing")]
175 let _span = tracing::debug_span!(
176 "widget_render",
177 widget = "ErrorBoundary",
178 x = area.x,
179 y = area.y,
180 w = area.width,
181 h = area.height
182 )
183 .entered();
184
185 if area.is_empty() {
186 return;
187 }
188
189 match state {
190 ErrorBoundaryState::Healthy | ErrorBoundaryState::Recovering { .. } => {
191 let result = catch_unwind(AssertUnwindSafe(|| {
192 self.inner.render(area, frame);
193 }));
194
195 match result {
196 Ok(()) => {
197 if matches!(state, ErrorBoundaryState::Recovering { .. }) {
198 *state = ErrorBoundaryState::Healthy;
199 }
200 }
201 Err(payload) => {
202 let error = CapturedError::from_panic(payload, self.widget_name, area);
203 clear_area(frame, area);
204 render_error_fallback(frame, area, &error);
205 *state = ErrorBoundaryState::Failed(error);
206 }
207 }
208 }
209 ErrorBoundaryState::Failed(error) => {
210 render_error_fallback(frame, area, error);
211 }
212 }
213 }
214}
215
216fn clear_area(frame: &mut Frame, area: Rect) {
218 let blank = Cell::from_char(' ');
219 for y in area.y..area.y.saturating_add(area.height) {
220 for x in area.x..area.x.saturating_add(area.width) {
221 frame.buffer.set(x, y, blank);
222 }
223 }
224}
225
226fn render_error_fallback(frame: &mut Frame, area: Rect, error: &CapturedError) {
228 let error_fg = PackedRgba::rgb(255, 60, 60);
229 let error_bg = PackedRgba::rgb(40, 0, 0);
230 let error_style = Style::new().fg(error_fg).bg(error_bg);
231 let border_style = Style::new().fg(error_fg);
232
233 set_style_area(&mut frame.buffer, area, Style::new().bg(error_bg));
234
235 if area.width < 3 || area.height < 1 {
236 if area.width >= 1 && area.height >= 1 {
238 let mut cell = Cell::from_char('!');
239 apply_style(&mut cell, error_style);
240 frame.buffer.set(area.x, area.y, cell);
241 }
242 return;
243 }
244
245 let top = area.y;
246 let bottom = area.y.saturating_add(area.height).saturating_sub(1);
247 let left = area.x;
248 let right = area.x.saturating_add(area.width).saturating_sub(1);
249
250 for x in left..=right {
252 let c = if x == left && area.height > 1 {
253 '┌'
254 } else if x == right && area.height > 1 {
255 '┐'
256 } else {
257 '─'
258 };
259 let mut cell = Cell::from_char(c);
260 apply_style(&mut cell, border_style);
261 frame.buffer.set(x, top, cell);
262 }
263
264 if area.height > 1 {
266 for x in left..=right {
267 let c = if x == left {
268 '└'
269 } else if x == right {
270 '┘'
271 } else {
272 '─'
273 };
274 let mut cell = Cell::from_char(c);
275 apply_style(&mut cell, border_style);
276 frame.buffer.set(x, bottom, cell);
277 }
278 }
279
280 if area.height > 2 {
282 for y in (top + 1)..bottom {
283 let mut cell_l = Cell::from_char('│');
284 apply_style(&mut cell_l, border_style);
285 frame.buffer.set(left, y, cell_l);
286
287 let mut cell_r = Cell::from_char('│');
288 apply_style(&mut cell_r, border_style);
289 frame.buffer.set(right, y, cell_r);
290 }
291 }
292
293 if area.width >= 9 {
295 let title_x = left.saturating_add(1);
296 draw_text_span(frame, title_x, top, "[Error]", border_style, right);
297 }
298
299 if area.height >= 3 && area.width >= 5 {
301 let inner_left = left.saturating_add(2);
302 let inner_right = right;
303 let inner_y = top.saturating_add(1);
304 let max_chars = (inner_right.saturating_sub(inner_left)) as usize;
305
306 let msg: String = if display_width(error.message.as_str()) > max_chars.saturating_sub(2) {
307 let mut truncated = String::new();
308 let mut w = 0;
309 let limit = max_chars.saturating_sub(3);
310 for grapheme in error.message.graphemes(true) {
311 let gw = grapheme_width(grapheme);
312 if w + gw > limit {
313 break;
314 }
315 truncated.push_str(grapheme);
316 w += gw;
317 }
318 format!("! {truncated}\u{2026}")
319 } else {
320 format!("! {}", error.message)
321 };
322
323 draw_text_span(frame, inner_left, inner_y, &msg, error_style, inner_right);
324
325 if area.height >= 4 {
327 let name_msg = format!(" in: {}", error.widget_name);
328 let name_style = Style::new().fg(PackedRgba::rgb(180, 180, 180)).bg(error_bg);
329 draw_text_span(
330 frame,
331 inner_left,
332 inner_y.saturating_add(1),
333 &name_msg,
334 name_style,
335 inner_right,
336 );
337 }
338
339 if area.height >= 5 {
341 let hint_style = Style::new().fg(PackedRgba::rgb(120, 120, 120)).bg(error_bg);
342 draw_text_span(
343 frame,
344 inner_left,
345 inner_y.saturating_add(2),
346 " Press R to retry",
347 hint_style,
348 inner_right,
349 );
350 }
351 }
352}
353
354#[derive(Debug, Clone)]
359pub struct FallbackWidget {
360 error: CapturedError,
361 show_retry_hint: bool,
362}
363
364impl FallbackWidget {
365 pub fn new(error: CapturedError) -> Self {
367 Self {
368 error,
369 show_retry_hint: true,
370 }
371 }
372
373 pub fn from_message(message: impl Into<String>, widget_name: &'static str) -> Self {
375 Self::new(CapturedError {
376 message: message.into(),
377 widget_name,
378 area: Rect::default(),
379 timestamp: Instant::now(),
380 })
381 }
382
383 pub fn without_retry_hint(mut self) -> Self {
385 self.show_retry_hint = false;
386 self
387 }
388}
389
390impl Widget for FallbackWidget {
391 fn render(&self, area: Rect, frame: &mut Frame) {
392 #[cfg(feature = "tracing")]
393 let _span = tracing::debug_span!(
394 "widget_render",
395 widget = "FallbackWidget",
396 x = area.x,
397 y = area.y,
398 w = area.width,
399 h = area.height
400 )
401 .entered();
402
403 if area.is_empty() {
404 return;
405 }
406 render_error_fallback(frame, area, &self.error);
407
408 if !self.show_retry_hint && area.height >= 5 {
410 let error_bg = PackedRgba::rgb(40, 0, 0);
411 let bg_style = Style::new().bg(error_bg);
412 let inner_y = area.y.saturating_add(3);
413 let inner_left = area.x.saturating_add(2);
414 let inner_right = area.x.saturating_add(area.width).saturating_sub(1);
415 for x in inner_left..inner_right {
417 if let Some(cell) = frame.buffer.get_mut(x, inner_y) {
418 cell.content = ftui_render::cell::CellContent::from_char(' ');
419 apply_style(cell, bg_style);
420 }
421 }
422 }
423 }
424}
425
426pub type FallbackFactory = Box<dyn Fn(&CapturedError) -> FallbackWidget + Send + Sync>;
428
429pub struct CustomErrorBoundary<W> {
434 inner: W,
435 widget_name: &'static str,
436 max_recovery_attempts: u32,
437 fallback_factory: Option<FallbackFactory>,
438}
439
440impl<W: Widget> CustomErrorBoundary<W> {
441 pub fn new(inner: W, widget_name: &'static str) -> Self {
443 Self {
444 inner,
445 widget_name,
446 max_recovery_attempts: 3,
447 fallback_factory: None,
448 }
449 }
450
451 pub fn fallback_factory(
453 mut self,
454 factory: impl Fn(&CapturedError) -> FallbackWidget + Send + Sync + 'static,
455 ) -> Self {
456 self.fallback_factory = Some(Box::new(factory));
457 self
458 }
459
460 pub fn max_recovery_attempts(mut self, max: u32) -> Self {
462 self.max_recovery_attempts = max;
463 self
464 }
465}
466
467impl<W: Widget> StatefulWidget for CustomErrorBoundary<W> {
468 type State = ErrorBoundaryState;
469
470 fn render(&self, area: Rect, frame: &mut Frame, state: &mut ErrorBoundaryState) {
471 #[cfg(feature = "tracing")]
472 let _span = tracing::debug_span!(
473 "widget_render",
474 widget = "CustomErrorBoundary",
475 x = area.x,
476 y = area.y,
477 w = area.width,
478 h = area.height
479 )
480 .entered();
481
482 if area.is_empty() {
483 return;
484 }
485
486 match state {
487 ErrorBoundaryState::Healthy | ErrorBoundaryState::Recovering { .. } => {
488 let result = catch_unwind(AssertUnwindSafe(|| {
489 self.inner.render(area, frame);
490 }));
491
492 match result {
493 Ok(()) => {
494 if matches!(state, ErrorBoundaryState::Recovering { .. }) {
495 *state = ErrorBoundaryState::Healthy;
496 }
497 }
498 Err(payload) => {
499 let error = CapturedError::from_panic(payload, self.widget_name, area);
500 clear_area(frame, area);
501 if let Some(factory) = &self.fallback_factory {
502 let fallback = factory(&error);
503 fallback.render(area, frame);
504 } else {
505 render_error_fallback(frame, area, &error);
506 }
507 *state = ErrorBoundaryState::Failed(error);
508 }
509 }
510 }
511 ErrorBoundaryState::Failed(error) => {
512 if let Some(factory) = &self.fallback_factory {
513 let fallback = factory(error);
514 fallback.render(area, frame);
515 } else {
516 render_error_fallback(frame, area, error);
517 }
518 }
519 }
520 }
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use ftui_render::grapheme_pool::GraphemePool;
527
528 struct PanickingWidget;
529
530 impl Widget for PanickingWidget {
531 fn render(&self, _area: Rect, _frame: &mut Frame) {
532 unreachable!("widget exploded");
533 }
534 }
535
536 struct GoodWidget;
537
538 impl Widget for GoodWidget {
539 fn render(&self, area: Rect, frame: &mut Frame) {
540 if area.width > 0 && area.height > 0 {
541 frame.buffer.set(area.x, area.y, Cell::from_char('G'));
542 }
543 }
544 }
545
546 #[test]
547 fn healthy_widget_renders_normally() {
548 let boundary = ErrorBoundary::new(GoodWidget, "good");
549 let mut state = ErrorBoundaryState::default();
550 let area = Rect::new(0, 0, 10, 5);
551 let mut pool = GraphemePool::new();
552 let mut frame = Frame::new(10, 5, &mut pool);
553
554 boundary.render(area, &mut frame, &mut state);
555
556 assert!(!state.is_failed());
557 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('G'));
558 }
559
560 #[test]
561 fn catches_panic_without_propagating() {
562 let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
563 let mut state = ErrorBoundaryState::default();
564 let area = Rect::new(0, 0, 30, 5);
565 let mut pool = GraphemePool::new();
566 let mut frame = Frame::new(30, 5, &mut pool);
567
568 boundary.render(area, &mut frame, &mut state);
569
570 assert!(state.is_failed());
571 let err = state.error().unwrap();
572 assert_eq!(err.message, "widget exploded");
573 assert_eq!(err.widget_name, "panicker");
574 }
575
576 #[test]
577 fn failed_state_shows_fallback_on_rerender() {
578 let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
579 let mut state = ErrorBoundaryState::default();
580 let area = Rect::new(0, 0, 30, 5);
581 let mut pool = GraphemePool::new();
582 let mut frame = Frame::new(30, 5, &mut pool);
583
584 boundary.render(area, &mut frame, &mut state);
585
586 let mut pool2 = GraphemePool::new();
588 let mut frame2 = Frame::new(30, 5, &mut pool2);
589 boundary.render(area, &mut frame2, &mut state);
590
591 assert!(state.is_failed());
592 assert_eq!(
593 frame2.buffer.get(0, 0).unwrap().content.as_char(),
594 Some('┌')
595 );
596 }
597
598 #[test]
599 fn recovery_resets_on_success() {
600 let good = ErrorBoundary::new(GoodWidget, "good");
601 let mut state = ErrorBoundaryState::Failed(CapturedError {
602 message: "old error".to_string(),
603 widget_name: "old",
604 area: Rect::new(0, 0, 10, 5),
605 timestamp: Instant::now(),
606 });
607
608 assert!(state.try_recover(3));
609 assert!(matches!(state, ErrorBoundaryState::Recovering { .. }));
610
611 let area = Rect::new(0, 0, 10, 5);
612 let mut pool = GraphemePool::new();
613 let mut frame = Frame::new(10, 5, &mut pool);
614 good.render(area, &mut frame, &mut state);
615
616 assert!(!state.is_failed());
617 assert!(matches!(state, ErrorBoundaryState::Healthy));
618 }
619
620 #[test]
621 fn recovery_respects_max_attempts() {
622 let mut state = ErrorBoundaryState::Failed(CapturedError {
623 message: "error".to_string(),
624 widget_name: "w",
625 area: Rect::new(0, 0, 1, 1),
626 timestamp: Instant::now(),
627 });
628
629 assert!(state.try_recover(2));
630 assert!(matches!(
631 state,
632 ErrorBoundaryState::Recovering { attempts: 1, .. }
633 ));
634
635 assert!(state.try_recover(2));
636 assert!(matches!(
637 state,
638 ErrorBoundaryState::Recovering { attempts: 2, .. }
639 ));
640
641 assert!(!state.try_recover(2));
642 assert!(matches!(state, ErrorBoundaryState::Failed(_)));
643 }
644
645 #[test]
646 fn zero_max_recovery_denies_immediately() {
647 let mut state = ErrorBoundaryState::Failed(CapturedError {
648 message: "error".to_string(),
649 widget_name: "w",
650 area: Rect::new(0, 0, 1, 1),
651 timestamp: Instant::now(),
652 });
653
654 assert!(!state.try_recover(0));
655 assert!(matches!(state, ErrorBoundaryState::Failed(_)));
656 }
657
658 #[test]
659 fn reset_clears_error() {
660 let mut state = ErrorBoundaryState::Failed(CapturedError {
661 message: "error".to_string(),
662 widget_name: "w",
663 area: Rect::new(0, 0, 1, 1),
664 timestamp: Instant::now(),
665 });
666
667 state.reset();
668 assert!(!state.is_failed());
669 assert!(matches!(state, ErrorBoundaryState::Healthy));
670 }
671
672 #[test]
673 fn empty_area_is_noop() {
674 let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
675 let mut state = ErrorBoundaryState::default();
676 let area = Rect::new(0, 0, 0, 0);
677 let mut pool = GraphemePool::new();
678 let mut frame = Frame::new(1, 1, &mut pool);
679
680 boundary.render(area, &mut frame, &mut state);
681
682 assert!(!state.is_failed());
683 }
684
685 #[test]
686 fn small_area_shows_minimal_fallback() {
687 let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
688 let mut state = ErrorBoundaryState::default();
689 let area = Rect::new(0, 0, 2, 1);
690 let mut pool = GraphemePool::new();
691 let mut frame = Frame::new(2, 1, &mut pool);
692
693 boundary.render(area, &mut frame, &mut state);
694
695 assert!(state.is_failed());
696 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('!'));
697 }
698
699 #[test]
700 fn captured_error_extracts_string_panic() {
701 let payload: Box<dyn std::any::Any + Send> = Box::new("test error".to_string());
702 let error = CapturedError::from_panic(payload, "test", Rect::new(0, 0, 1, 1));
703 assert_eq!(error.message, "test error");
704 }
705
706 #[test]
707 fn captured_error_extracts_str_panic() {
708 let payload: Box<dyn std::any::Any + Send> = Box::new("static error");
709 let error = CapturedError::from_panic(payload, "test", Rect::new(0, 0, 1, 1));
710 assert_eq!(error.message, "static error");
711 }
712
713 #[test]
714 fn captured_error_handles_unknown_panic() {
715 let payload: Box<dyn std::any::Any + Send> = Box::new(42u32);
716 let error = CapturedError::from_panic(payload, "test", Rect::new(0, 0, 1, 1));
717 assert_eq!(error.message, "unknown panic");
718 }
719
720 #[test]
721 fn failed_state_renders_fallback_directly() {
722 let boundary = ErrorBoundary::new(GoodWidget, "good");
723 let mut state = ErrorBoundaryState::Failed(CapturedError {
724 message: "previous error".to_string(),
725 widget_name: "other",
726 area: Rect::new(0, 0, 30, 5),
727 timestamp: Instant::now(),
728 });
729
730 let area = Rect::new(0, 0, 30, 5);
731 let mut pool = GraphemePool::new();
732 let mut frame = Frame::new(30, 5, &mut pool);
733 boundary.render(area, &mut frame, &mut state);
734
735 assert!(state.is_failed());
736 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
738 }
739
740 #[test]
741 fn fallback_widget_renders_standalone() {
742 let fallback = FallbackWidget::from_message("render failed", "my_widget");
743 let area = Rect::new(0, 0, 30, 5);
744 let mut pool = GraphemePool::new();
745 let mut frame = Frame::new(30, 5, &mut pool);
746 fallback.render(area, &mut frame);
747
748 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
750 }
751
752 #[test]
753 fn fallback_widget_without_retry_hint() {
754 let fallback = FallbackWidget::from_message("error", "w").without_retry_hint();
755 let area = Rect::new(0, 0, 30, 6);
756 let mut pool = GraphemePool::new();
757 let mut frame = Frame::new(30, 6, &mut pool);
758 fallback.render(area, &mut frame);
759
760 let hint_cell = frame.buffer.get(4, 3).unwrap();
763 assert_eq!(hint_cell.content.as_char(), Some(' '));
764 }
765
766 #[test]
767 fn fallback_widget_empty_area() {
768 let fallback = FallbackWidget::from_message("error", "w");
769 let area = Rect::new(0, 0, 0, 0);
770 let mut pool = GraphemePool::new();
771 let mut frame = Frame::new(1, 1, &mut pool);
772 fallback.render(area, &mut frame);
773 }
775
776 #[test]
777 fn custom_error_boundary_uses_factory() {
778 let boundary =
779 CustomErrorBoundary::new(PanickingWidget, "panicker").fallback_factory(|error| {
780 FallbackWidget::from_message(
781 format!("CUSTOM: {}", error.message),
782 error.widget_name,
783 )
784 .without_retry_hint()
785 });
786 let mut state = ErrorBoundaryState::default();
787 let area = Rect::new(0, 0, 40, 5);
788 let mut pool = GraphemePool::new();
789 let mut frame = Frame::new(40, 5, &mut pool);
790 boundary.render(area, &mut frame, &mut state);
791
792 assert!(state.is_failed());
793 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
795 }
796
797 #[test]
798 fn custom_error_boundary_default_fallback() {
799 let boundary = CustomErrorBoundary::new(PanickingWidget, "panicker");
800 let mut state = ErrorBoundaryState::default();
801 let area = Rect::new(0, 0, 30, 5);
802 let mut pool = GraphemePool::new();
803 let mut frame = Frame::new(30, 5, &mut pool);
804 boundary.render(area, &mut frame, &mut state);
805
806 assert!(state.is_failed());
807 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('┌'));
808 }
809
810 #[test]
811 fn retry_hint_shows_in_tall_area() {
812 let boundary = ErrorBoundary::new(PanickingWidget, "panicker");
813 let mut state = ErrorBoundaryState::default();
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 boundary.render(area, &mut frame, &mut state);
818
819 assert!(state.is_failed());
820 let p_cell = frame.buffer.get(4, 3).unwrap();
824 assert_eq!(p_cell.content.as_char(), Some('P'));
825 }
826
827 #[test]
828 fn error_in_sibling_does_not_affect_other() {
829 let bad = ErrorBoundary::new(PanickingWidget, "bad");
830 let good = ErrorBoundary::new(GoodWidget, "good");
831 let mut bad_state = ErrorBoundaryState::default();
832 let mut good_state = ErrorBoundaryState::default();
833
834 let mut pool = GraphemePool::new();
835 let mut frame = Frame::new(20, 5, &mut pool);
836 let area_a = Rect::new(0, 0, 10, 5);
837 let area_b = Rect::new(10, 0, 10, 5);
838
839 bad.render(area_a, &mut frame, &mut bad_state);
840 good.render(area_b, &mut frame, &mut good_state);
841
842 assert!(bad_state.is_failed());
843 assert!(!good_state.is_failed());
844 assert_eq!(
845 frame.buffer.get(10, 0).unwrap().content.as_char(),
846 Some('G')
847 );
848 }
849}