Skip to main content

aprender_test_showcase/tui/
ui.rs

1//! TUI rendering with 100% test coverage
2//!
3//! Probar: Visual feedback makes state visible
4
5use ratatui::{
6    buffer::Buffer,
7    layout::{Constraint, Direction, Layout, Rect},
8    style::{Color, Modifier, Style},
9    text::{Line, Span},
10    widgets::{Block, Borders, List, ListItem, Paragraph, Widget},
11    Frame,
12};
13
14use super::app::CalculatorApp;
15use super::keypad::{Keypad, KeypadWidget};
16
17/// Renders the calculator UI to the frame
18pub fn render(app: &CalculatorApp, frame: &mut Frame) {
19    let area = frame.area();
20    let ui = CalculatorUI::new(app);
21    frame.render_widget(ui, area);
22}
23
24/// Calculator UI widget
25pub struct CalculatorUI<'a> {
26    app: &'a CalculatorApp,
27    keypad: Keypad,
28}
29
30impl<'a> CalculatorUI<'a> {
31    /// Creates a new calculator UI widget
32    #[must_use]
33    pub fn new(app: &'a CalculatorApp) -> Self {
34        Self {
35            app,
36            keypad: Keypad::new(),
37        }
38    }
39
40    /// Creates the main horizontal layout (main + keypad + help sidebar)
41    fn create_horizontal_layout(&self, area: Rect) -> Vec<Rect> {
42        Layout::default()
43            .direction(Direction::Horizontal)
44            .margin(1)
45            .constraints([
46                Constraint::Min(35),    // Main calculator area
47                Constraint::Length(22), // Keypad
48                Constraint::Length(22), // Help sidebar
49            ])
50            .split(area)
51            .to_vec()
52    }
53
54    /// Renders the keypad area
55    fn render_keypad(&self, area: Rect, buf: &mut Buffer) {
56        let widget = KeypadWidget::new(&self.keypad);
57        widget.render(area, buf);
58    }
59
60    /// Creates the main layout chunks
61    fn create_layout(&self, area: Rect) -> Vec<Rect> {
62        Layout::default()
63            .direction(Direction::Vertical)
64            .constraints([
65                Constraint::Length(3), // Input
66                Constraint::Length(3), // Result
67                Constraint::Min(5),    // History
68                Constraint::Length(5), // Anomaly status
69            ])
70            .split(area)
71            .to_vec()
72    }
73
74    /// Renders the help sidebar
75    fn render_help_sidebar(&self, area: Rect, buf: &mut Buffer) {
76        let chunks = Layout::default()
77            .direction(Direction::Vertical)
78            .constraints([
79                Constraint::Min(10),   // Shortcuts
80                Constraint::Length(3), // Operators
81                Constraint::Length(2), // Probar badge
82            ])
83            .split(area);
84
85        // Shortcuts panel
86        let shortcuts: Vec<ListItem> = HELP_SHORTCUTS
87            .iter()
88            .map(|(key, desc)| {
89                ListItem::new(Line::from(vec![
90                    Span::styled(format!("{:>7}", key), Style::default().fg(Color::Yellow)),
91                    Span::raw(" "),
92                    Span::styled(*desc, Style::default().fg(Color::Gray)),
93                ]))
94            })
95            .collect();
96
97        let shortcuts_list = List::new(shortcuts).block(
98            Block::default()
99                .title(" Help ")
100                .borders(Borders::ALL)
101                .border_style(Style::default().fg(Color::DarkGray)),
102        );
103        shortcuts_list.render(chunks[0], buf);
104
105        // Operators
106        let ops = Paragraph::new(Span::styled(
107            HELP_OPERATORS,
108            Style::default().fg(Color::Cyan),
109        ))
110        .block(
111            Block::default()
112                .borders(Borders::LEFT | Borders::RIGHT)
113                .border_style(Style::default().fg(Color::DarkGray)),
114        );
115        ops.render(chunks[1], buf);
116
117        // Probar badge
118        let badge = Paragraph::new(Span::styled(
119            PROBAR_BADGE,
120            Style::default()
121                .fg(Color::Magenta)
122                .add_modifier(Modifier::ITALIC),
123        ))
124        .block(
125            Block::default()
126                .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
127                .border_style(Style::default().fg(Color::DarkGray)),
128        );
129        badge.render(chunks[2], buf);
130    }
131
132    /// Renders the input area
133    fn render_input(&self, area: Rect, buf: &mut Buffer) {
134        let input_text = self.app.input();
135        let cursor_pos = self.app.cursor();
136
137        // Create spans with cursor highlighting
138        let (before, after) = input_text.split_at(cursor_pos.min(input_text.len()));
139        let cursor_char = after.chars().next().unwrap_or(' ');
140        let after_cursor = if after.len() > 1 {
141            &after[cursor_char.len_utf8()..]
142        } else {
143            ""
144        };
145
146        let spans = vec![
147            Span::raw(before),
148            Span::styled(
149                cursor_char.to_string(),
150                Style::default().bg(Color::White).fg(Color::Black),
151            ),
152            Span::raw(after_cursor),
153        ];
154
155        let paragraph = Paragraph::new(Line::from(spans)).block(
156            Block::default()
157                .title(" Expression ")
158                .borders(Borders::ALL)
159                .border_style(Style::default().fg(Color::Cyan)),
160        );
161
162        paragraph.render(area, buf);
163    }
164
165    /// Renders the result area
166    fn render_result(&self, area: Rect, buf: &mut Buffer) {
167        let result_text = self.app.result_display();
168
169        let style = if result_text.starts_with("Error") {
170            Style::default().fg(Color::Red)
171        } else {
172            Style::default()
173                .fg(Color::Green)
174                .add_modifier(Modifier::BOLD)
175        };
176
177        let paragraph = Paragraph::new(Span::styled(result_text, style)).block(
178            Block::default()
179                .title(" Result ")
180                .borders(Borders::ALL)
181                .border_style(Style::default().fg(Color::Yellow)),
182        );
183
184        paragraph.render(area, buf);
185    }
186
187    /// Renders the history area
188    fn render_history(&self, area: Rect, buf: &mut Buffer) {
189        let history = self.app.history();
190
191        let items: Vec<ListItem> = history
192            .iter_rev()
193            .take(10)
194            .map(|entry| {
195                ListItem::new(Line::from(vec![
196                    Span::styled(&entry.expression, Style::default().fg(Color::Gray)),
197                    Span::raw(" = "),
198                    Span::styled(
199                        format!("{}", entry.result),
200                        Style::default().fg(Color::Cyan),
201                    ),
202                ]))
203            })
204            .collect();
205
206        let list = List::new(items).block(
207            Block::default()
208                .title(" History (newest first) ")
209                .borders(Borders::ALL)
210                .border_style(Style::default().fg(Color::Blue)),
211        );
212
213        list.render(area, buf);
214    }
215
216    /// Renders the Anomaly status area
217    fn render_jidoka_status(&self, area: Rect, buf: &mut Buffer) {
218        let status_lines = self.app.jidoka_status();
219
220        let items: Vec<ListItem> = status_lines
221            .iter()
222            .map(|line| {
223                let style = if line.starts_with('✓') {
224                    Style::default().fg(Color::Green)
225                } else if line.starts_with('✗') {
226                    Style::default().fg(Color::Red)
227                } else {
228                    Style::default().fg(Color::Gray)
229                };
230                ListItem::new(Span::styled(line.as_str(), style))
231            })
232            .collect();
233
234        let list = List::new(items).block(
235            Block::default()
236                .title(" Anomaly Status ")
237                .borders(Borders::ALL)
238                .border_style(Style::default().fg(Color::Magenta)),
239        );
240
241        list.render(area, buf);
242    }
243}
244
245impl Widget for CalculatorUI<'_> {
246    fn render(self, area: Rect, buf: &mut Buffer) {
247        // Render main border with demo title
248        Block::default()
249            .title(DEMO_TITLE)
250            .borders(Borders::ALL)
251            .border_style(Style::default().fg(Color::White))
252            .render(area, buf);
253
254        // Split into main area, keypad, and help sidebar
255        let h_chunks = self.create_horizontal_layout(area);
256
257        // Main calculator area with keypad and help
258        if h_chunks.len() >= 3 {
259            let main_area = h_chunks[0];
260            let keypad_area = h_chunks[1];
261            let help_area = h_chunks[2];
262
263            let chunks = self.create_layout(main_area);
264
265            // Render each section
266            if chunks.len() >= 4 {
267                self.render_input(chunks[0], buf);
268                self.render_result(chunks[1], buf);
269                self.render_history(chunks[2], buf);
270                self.render_jidoka_status(chunks[3], buf);
271            }
272
273            // Render keypad
274            self.render_keypad(keypad_area, buf);
275
276            // Render help sidebar
277            self.render_help_sidebar(help_area, buf);
278        }
279    }
280}
281
282/// Demo title for the calculator
283pub const DEMO_TITLE: &str = " Showcase Calculator - 100% Test Coverage Demo ";
284
285/// Help text for the calculator (compact, for sidebar)
286pub const HELP_SHORTCUTS: &[(&str, &str)] = &[
287    ("Enter", "Evaluate"),
288    ("Esc", "Clear"),
289    ("↑", "Recall"),
290    ("←/→", "Move cursor"),
291    ("Ctrl+C", "Quit"),
292    ("Ctrl+L", "Clear all"),
293    ("?", "Toggle help"),
294];
295
296/// Operators help
297pub const HELP_OPERATORS: &str = "Ops: + - * / % ^  ( )";
298
299/// Probar branding shown in demo
300pub const PROBAR_BADGE: &str = "Probar - paiml.com/probar";
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use ratatui::backend::TestBackend;
306    use ratatui::Terminal;
307
308    fn create_test_terminal() -> Terminal<TestBackend> {
309        let backend = TestBackend::new(80, 24);
310        Terminal::new(backend).unwrap()
311    }
312
313    // ===== CalculatorUI tests =====
314
315    #[test]
316    fn test_calculator_ui_new() {
317        let app = CalculatorApp::new();
318        let ui = CalculatorUI::new(&app);
319        // Verify it creates without panic
320        let _ = format!("{:p}", ui.app);
321    }
322
323    #[test]
324    fn test_calculator_ui_create_layout() {
325        let app = CalculatorApp::new();
326        let ui = CalculatorUI::new(&app);
327        let area = Rect::new(0, 0, 80, 24);
328        let chunks = ui.create_layout(area);
329        assert_eq!(chunks.len(), 4);
330    }
331
332    #[test]
333    fn test_calculator_ui_render() {
334        let app = CalculatorApp::new();
335        let mut terminal = create_test_terminal();
336
337        terminal
338            .draw(|frame| {
339                render(&app, frame);
340            })
341            .unwrap();
342    }
343
344    #[test]
345    fn test_render_with_input() {
346        let mut app = CalculatorApp::new();
347        app.set_input("2 + 3");
348        let mut terminal = create_test_terminal();
349
350        terminal
351            .draw(|frame| {
352                render(&app, frame);
353            })
354            .unwrap();
355
356        // Verify buffer contains input
357        let buffer = terminal.backend().buffer();
358        let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
359        assert!(content.contains("2 + 3"));
360    }
361
362    #[test]
363    fn test_render_with_result() {
364        let mut app = CalculatorApp::new();
365        app.set_input("2 + 3");
366        app.evaluate();
367        let mut terminal = create_test_terminal();
368
369        terminal
370            .draw(|frame| {
371                render(&app, frame);
372            })
373            .unwrap();
374
375        let buffer = terminal.backend().buffer();
376        let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
377        assert!(content.contains('5'));
378    }
379
380    #[test]
381    fn test_render_with_error() {
382        let mut app = CalculatorApp::new();
383        app.set_input("1 / 0");
384        app.evaluate();
385        let mut terminal = create_test_terminal();
386
387        terminal
388            .draw(|frame| {
389                render(&app, frame);
390            })
391            .unwrap();
392
393        let buffer = terminal.backend().buffer();
394        let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
395        assert!(content.contains("Error"));
396    }
397
398    #[test]
399    fn test_render_with_history() {
400        let mut app = CalculatorApp::new();
401        app.set_input("1 + 1");
402        app.evaluate();
403        app.set_input("2 + 2");
404        app.evaluate();
405        let mut terminal = create_test_terminal();
406
407        terminal
408            .draw(|frame| {
409                render(&app, frame);
410            })
411            .unwrap();
412
413        let buffer = terminal.backend().buffer();
414        let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
415        // Should show history entries
416        assert!(content.contains("1 + 1"));
417    }
418
419    #[test]
420    fn test_render_jidoka_success() {
421        let mut app = CalculatorApp::new();
422        app.set_input("5 + 5");
423        app.evaluate();
424        let mut terminal = create_test_terminal();
425
426        terminal
427            .draw(|frame| {
428                render(&app, frame);
429            })
430            .unwrap();
431
432        let buffer = terminal.backend().buffer();
433        let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
434        assert!(content.contains("✓"));
435    }
436
437    #[test]
438    fn test_render_cursor_position() {
439        let mut app = CalculatorApp::new();
440        app.set_input("12345");
441        app.set_cursor(2); // After "12"
442        let mut terminal = create_test_terminal();
443
444        terminal
445            .draw(|frame| {
446                render(&app, frame);
447            })
448            .unwrap();
449
450        // Just verify it renders without panic
451        let _ = terminal.backend().buffer();
452    }
453
454    #[test]
455    fn test_render_cursor_at_end() {
456        let mut app = CalculatorApp::new();
457        app.set_input("abc");
458        // cursor is at end by default
459        let mut terminal = create_test_terminal();
460
461        terminal
462            .draw(|frame| {
463                render(&app, frame);
464            })
465            .unwrap();
466    }
467
468    #[test]
469    fn test_render_empty_input() {
470        let app = CalculatorApp::new();
471        let mut terminal = create_test_terminal();
472
473        terminal
474            .draw(|frame| {
475                render(&app, frame);
476            })
477            .unwrap();
478
479        let buffer = terminal.backend().buffer();
480        let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
481        assert!(content.contains("Expression"));
482    }
483
484    #[test]
485    fn test_render_small_terminal() {
486        let app = CalculatorApp::new();
487        let backend = TestBackend::new(20, 10);
488        let mut terminal = Terminal::new(backend).unwrap();
489
490        terminal
491            .draw(|frame| {
492                render(&app, frame);
493            })
494            .unwrap();
495    }
496
497    // ===== Demo/Help panel tests (PMAT-CALC-006) =====
498
499    #[test]
500    fn test_demo_title_constant() {
501        assert!(DEMO_TITLE.contains("Showcase Calculator"));
502        assert!(DEMO_TITLE.contains("100% Test Coverage"));
503        assert!(DEMO_TITLE.contains("Demo"));
504    }
505
506    #[test]
507    fn test_help_shortcuts_contains_essential_keys() {
508        let keys: Vec<&str> = HELP_SHORTCUTS.iter().map(|(k, _)| *k).collect();
509        assert!(keys.contains(&"Enter"));
510        assert!(keys.contains(&"Esc"));
511        assert!(keys.contains(&"Ctrl+C"));
512    }
513
514    #[test]
515    fn test_help_shortcuts_has_descriptions() {
516        for (key, desc) in HELP_SHORTCUTS {
517            assert!(!key.is_empty(), "Key should not be empty");
518            assert!(!desc.is_empty(), "Description should not be empty");
519        }
520    }
521
522    #[test]
523    fn test_help_shortcuts_count() {
524        assert!(
525            HELP_SHORTCUTS.len() >= 5,
526            "Should have at least 5 shortcuts"
527        );
528    }
529
530    #[test]
531    fn test_help_operators_contains_all_ops() {
532        assert!(HELP_OPERATORS.contains('+'));
533        assert!(HELP_OPERATORS.contains('-'));
534        assert!(HELP_OPERATORS.contains('*'));
535        assert!(HELP_OPERATORS.contains('/'));
536        assert!(HELP_OPERATORS.contains('%'));
537        assert!(HELP_OPERATORS.contains('^'));
538        assert!(HELP_OPERATORS.contains('('));
539        assert!(HELP_OPERATORS.contains(')'));
540    }
541
542    #[test]
543    fn test_probar_badge_contains_branding() {
544        assert!(PROBAR_BADGE.contains("Probar"));
545        assert!(PROBAR_BADGE.contains("paiml"));
546    }
547
548    #[test]
549    fn test_render_shows_demo_title() {
550        let app = CalculatorApp::new();
551        let mut terminal = create_test_terminal();
552
553        terminal
554            .draw(|frame| {
555                render(&app, frame);
556            })
557            .unwrap();
558
559        let buffer = terminal.backend().buffer();
560        let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
561        assert!(content.contains("Showcase") || content.contains("Calculator"));
562    }
563
564    #[test]
565    fn test_render_shows_help_panel() {
566        let app = CalculatorApp::new();
567        let backend = TestBackend::new(100, 30); // Wider terminal for help panel
568        let mut terminal = Terminal::new(backend).unwrap();
569
570        terminal
571            .draw(|frame| {
572                render(&app, frame);
573            })
574            .unwrap();
575
576        let buffer = terminal.backend().buffer();
577        let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
578        // Should contain help-related content
579        assert!(content.contains("Enter") || content.contains("Help") || content.contains("Esc"));
580    }
581
582    #[test]
583    fn test_render_shows_probar_badge() {
584        let app = CalculatorApp::new();
585        let backend = TestBackend::new(100, 30);
586        let mut terminal = Terminal::new(backend).unwrap();
587
588        terminal
589            .draw(|frame| {
590                render(&app, frame);
591            })
592            .unwrap();
593
594        let buffer = terminal.backend().buffer();
595        let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
596        // Should show Probar reference
597        assert!(content.contains("Probar") || content.contains("paiml"));
598    }
599
600    #[test]
601    fn test_render_help_sidebar_directly() {
602        let app = CalculatorApp::new();
603        let ui = CalculatorUI::new(&app);
604        let area = Rect::new(0, 0, 22, 20);
605        let mut buf = Buffer::empty(Rect::new(0, 0, 80, 24));
606
607        ui.render_help_sidebar(area, &mut buf);
608
609        let content: String = buf.content().iter().map(|c| c.symbol()).collect();
610        assert!(content.contains("Help"));
611        assert!(content.contains("Enter"));
612        assert!(content.contains("Esc"));
613    }
614
615    #[test]
616    fn test_create_horizontal_layout() {
617        let app = CalculatorApp::new();
618        let ui = CalculatorUI::new(&app);
619        let area = Rect::new(0, 0, 100, 30);
620        let chunks = ui.create_horizontal_layout(area);
621        assert_eq!(chunks.len(), 3);
622        // Keypad should be 22 wide
623        assert_eq!(chunks[1].width, 22);
624        // Help sidebar should be 22 wide
625        assert_eq!(chunks[2].width, 22);
626    }
627
628    // ===== Widget implementation tests =====
629
630    #[test]
631    fn test_widget_render_direct() {
632        let app = CalculatorApp::new();
633        let ui = CalculatorUI::new(&app);
634        let area = Rect::new(0, 0, 80, 24);
635        let mut buf = Buffer::empty(area);
636
637        ui.render(area, &mut buf);
638
639        // Verify some content was rendered
640        let content: String = buf.content().iter().map(|c| c.symbol()).collect();
641        assert!(content.contains("Calculator"));
642    }
643
644    #[test]
645    fn test_render_sections_individually() {
646        let app = CalculatorApp::new();
647        let ui = CalculatorUI::new(&app);
648
649        let input_area = Rect::new(0, 0, 40, 3);
650        let mut buf = Buffer::empty(Rect::new(0, 0, 80, 24));
651        ui.render_input(input_area, &mut buf);
652
653        let result_area = Rect::new(0, 3, 40, 3);
654        ui.render_result(result_area, &mut buf);
655
656        let history_area = Rect::new(0, 6, 40, 10);
657        ui.render_history(history_area, &mut buf);
658
659        let jidoka_area = Rect::new(0, 16, 40, 5);
660        ui.render_jidoka_status(jidoka_area, &mut buf);
661    }
662
663    #[test]
664    fn test_render_history_many_entries() {
665        let mut app = CalculatorApp::new();
666        for i in 1..=20 {
667            app.set_input(&format!("{i} + {i}"));
668            app.evaluate();
669        }
670        let mut terminal = create_test_terminal();
671
672        terminal
673            .draw(|frame| {
674                render(&app, frame);
675            })
676            .unwrap();
677
678        // Should only show last 10
679        let buffer = terminal.backend().buffer();
680        let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
681        assert!(content.contains("20 + 20")); // Most recent
682    }
683
684    // ===== Keypad integration tests (PMAT-CALC-007) =====
685
686    #[test]
687    fn test_calculator_ui_has_keypad() {
688        let app = CalculatorApp::new();
689        let ui = CalculatorUI::new(&app);
690        // Verify keypad is initialized
691        assert_eq!(ui.keypad.button_count(), 20);
692    }
693
694    #[test]
695    fn test_render_keypad_directly() {
696        let app = CalculatorApp::new();
697        let ui = CalculatorUI::new(&app);
698        let area = Rect::new(0, 0, 22, 12);
699        let mut buf = Buffer::empty(Rect::new(0, 0, 80, 24));
700
701        ui.render_keypad(area, &mut buf);
702
703        let content: String = buf.content().iter().map(|c| c.symbol()).collect();
704        assert!(content.contains("Keypad"));
705        assert!(content.contains("[7]"));
706    }
707
708    #[test]
709    fn test_render_shows_keypad_in_full_layout() {
710        let app = CalculatorApp::new();
711        let backend = TestBackend::new(120, 30); // Wide enough for all panels
712        let mut terminal = Terminal::new(backend).unwrap();
713
714        terminal
715            .draw(|frame| {
716                render(&app, frame);
717            })
718            .unwrap();
719
720        let buffer = terminal.backend().buffer();
721        let content: String = buffer.content().iter().map(|c| c.symbol()).collect();
722        // Should show keypad
723        assert!(content.contains("Keypad"));
724        // Should have some button labels
725        assert!(content.contains("[7]") || content.contains("[+]") || content.contains("[=]"));
726    }
727
728    #[test]
729    fn test_keypad_shows_operators() {
730        let app = CalculatorApp::new();
731        let backend = TestBackend::new(120, 30);
732        let mut terminal = Terminal::new(backend).unwrap();
733
734        terminal
735            .draw(|frame| {
736                render(&app, frame);
737            })
738            .unwrap();
739
740        let buffer = terminal.backend().buffer();
741        let content: String = buf_to_string(buffer);
742        // Keypad should show operator buttons
743        assert!(content.contains("[/]") || content.contains("[*]") || content.contains("[-]"));
744    }
745
746    #[test]
747    fn test_full_layout_three_columns() {
748        let app = CalculatorApp::new();
749        let ui = CalculatorUI::new(&app);
750        let area = Rect::new(0, 0, 120, 30);
751        let mut buf = Buffer::empty(area);
752
753        ui.render(area, &mut buf);
754
755        let content: String = buf.content().iter().map(|c| c.symbol()).collect();
756        // Should have all three areas
757        assert!(content.contains("Expression")); // Main area
758        assert!(content.contains("Keypad")); // Keypad
759        assert!(content.contains("Help")); // Help sidebar
760    }
761
762    fn buf_to_string(buffer: &Buffer) -> String {
763        buffer.content().iter().map(|c| c.symbol()).collect()
764    }
765}