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//!
5//! Uses presentar-terminal-compatible TextBuffer rendering
6//! instead of ratatui Frame/Widget pattern.
7
8use super::app::CalculatorApp;
9use super::keypad::{Keypad, KeypadWidget, Rect, TextBuffer};
10
11/// Renders the calculator UI into a TextBuffer
12pub fn render_to_buffer(app: &CalculatorApp, width: u16, height: u16) -> TextBuffer {
13    let area = Rect::new(0, 0, width, height);
14    let mut buf = TextBuffer::empty(area);
15    let ui = CalculatorUI::new(app);
16    ui.render(area, &mut buf);
17    buf
18}
19
20/// Calculator UI widget
21pub struct CalculatorUI<'a> {
22    app: &'a CalculatorApp,
23    keypad: Keypad,
24}
25
26impl<'a> CalculatorUI<'a> {
27    /// Creates a new calculator UI widget
28    #[must_use]
29    pub fn new(app: &'a CalculatorApp) -> Self {
30        Self {
31            app,
32            keypad: Keypad::new(),
33        }
34    }
35
36    /// Render the full calculator UI to a TextBuffer
37    pub fn render(&self, area: Rect, buf: &mut TextBuffer) {
38        // Title bar
39        buf.write_str(area.x + 1, area.y, DEMO_TITLE);
40
41        // Layout: main (left), keypad (center), help (right)
42        let margin = 1u16;
43        let keypad_w = 22u16;
44        let help_w = 22u16;
45        let main_w = area.width.saturating_sub(margin * 2 + keypad_w + help_w);
46
47        let content_x = area.x + margin;
48        let content_y = area.y + margin;
49
50        // Main area (4 sections stacked)
51        let input_h = 3u16;
52        let result_h = 3u16;
53        let jidoka_h = 5u16;
54        let history_h = area
55            .height
56            .saturating_sub(margin * 2 + input_h + result_h + jidoka_h);
57
58        self.render_input(Rect::new(content_x, content_y, main_w, input_h), buf);
59        self.render_result(
60            Rect::new(content_x, content_y + input_h, main_w, result_h),
61            buf,
62        );
63        self.render_history(
64            Rect::new(content_x, content_y + input_h + result_h, main_w, history_h),
65            buf,
66        );
67        self.render_jidoka_status(
68            Rect::new(
69                content_x,
70                content_y + input_h + result_h + history_h,
71                main_w,
72                jidoka_h,
73            ),
74            buf,
75        );
76
77        // Keypad
78        let keypad_x = content_x + main_w;
79        self.render_keypad(
80            Rect::new(
81                keypad_x,
82                content_y,
83                keypad_w,
84                area.height.saturating_sub(margin * 2),
85            ),
86            buf,
87        );
88
89        // Help sidebar
90        let help_x = keypad_x + keypad_w;
91        self.render_help_sidebar(
92            Rect::new(
93                help_x,
94                content_y,
95                help_w,
96                area.height.saturating_sub(margin * 2),
97            ),
98            buf,
99        );
100    }
101
102    /// Renders the keypad area
103    fn render_keypad(&self, area: Rect, buf: &mut TextBuffer) {
104        let widget = KeypadWidget::new(&self.keypad);
105        widget.render(area, buf);
106    }
107
108    /// Renders the help sidebar
109    fn render_help_sidebar(&self, area: Rect, buf: &mut TextBuffer) {
110        buf.write_str(area.x + 1, area.y, " Help ");
111
112        let mut y = area.y + 1;
113        for (key, desc) in HELP_SHORTCUTS {
114            let line = format!("{:>7} {}", key, desc);
115            buf.write_str(area.x + 1, y, &line);
116            y += 1;
117        }
118
119        // Operators
120        buf.write_str(area.x + 1, y + 1, HELP_OPERATORS);
121
122        // Probar badge
123        buf.write_str(area.x + 1, y + 3, PROBAR_BADGE);
124    }
125
126    /// Renders the input area
127    fn render_input(&self, area: Rect, buf: &mut TextBuffer) {
128        buf.write_str(area.x + 1, area.y, " Expression ");
129        let input_text = self.app.input();
130        buf.write_str(area.x + 1, area.y + 1, input_text);
131    }
132
133    /// Renders the result area
134    fn render_result(&self, area: Rect, buf: &mut TextBuffer) {
135        buf.write_str(area.x + 1, area.y, " Result ");
136        let result_text = self.app.result_display();
137        buf.write_str(area.x + 1, area.y + 1, &result_text);
138    }
139
140    /// Renders the history area
141    fn render_history(&self, area: Rect, buf: &mut TextBuffer) {
142        buf.write_str(area.x + 1, area.y, " History (newest first) ");
143        let history = self.app.history();
144
145        let mut y = area.y + 1;
146        for entry in history.iter_rev().take(10) {
147            let line = format!("{} = {}", entry.expression, entry.result);
148            buf.write_str(area.x + 1, y, &line);
149            y += 1;
150        }
151    }
152
153    /// Renders the Anomaly status area
154    fn render_jidoka_status(&self, area: Rect, buf: &mut TextBuffer) {
155        buf.write_str(area.x + 1, area.y, " Anomaly Status ");
156        let status_lines = self.app.jidoka_status();
157
158        let mut y = area.y + 1;
159        for line in &status_lines {
160            buf.write_str(area.x + 1, y, line);
161            y += 1;
162        }
163    }
164}
165
166/// Demo title for the calculator
167pub const DEMO_TITLE: &str = " Showcase Calculator - 100% Test Coverage Demo ";
168
169/// Help text for the calculator (compact, for sidebar)
170pub const HELP_SHORTCUTS: &[(&str, &str)] = &[
171    ("Enter", "Evaluate"),
172    ("Esc", "Clear"),
173    ("Up", "Recall"),
174    ("Left/Right", "Move cursor"),
175    ("Ctrl+C", "Quit"),
176    ("Ctrl+L", "Clear all"),
177    ("?", "Toggle help"),
178];
179
180/// Operators help
181pub const HELP_OPERATORS: &str = "Ops: + - * / % ^  ( )";
182
183/// Probar branding shown in demo
184pub const PROBAR_BADGE: &str = "Probar - paiml.com/probar";
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    // ===== CalculatorUI tests =====
191
192    #[test]
193    fn test_calculator_ui_new() {
194        let app = CalculatorApp::new();
195        let ui = CalculatorUI::new(&app);
196        // Verify it creates without panic
197        let _ = format!("{:p}", ui.app);
198    }
199
200    #[test]
201    fn test_calculator_ui_render() {
202        let app = CalculatorApp::new();
203        let buf = render_to_buffer(&app, 80, 24);
204        // Just verify it renders without panic
205        let _ = buf.to_string_content();
206    }
207
208    #[test]
209    fn test_render_with_input() {
210        let mut app = CalculatorApp::new();
211        app.set_input("2 + 3");
212        let buf = render_to_buffer(&app, 80, 24);
213
214        let content = buf.to_string_content();
215        assert!(content.contains("2 + 3"));
216    }
217
218    #[test]
219    fn test_render_with_result() {
220        let mut app = CalculatorApp::new();
221        app.set_input("2 + 3");
222        app.evaluate();
223        let buf = render_to_buffer(&app, 80, 24);
224
225        let content = buf.to_string_content();
226        assert!(content.contains('5'));
227    }
228
229    #[test]
230    fn test_render_with_error() {
231        let mut app = CalculatorApp::new();
232        app.set_input("1 / 0");
233        app.evaluate();
234        let buf = render_to_buffer(&app, 80, 24);
235
236        let content = buf.to_string_content();
237        assert!(content.contains("Error"));
238    }
239
240    #[test]
241    fn test_render_with_history() {
242        let mut app = CalculatorApp::new();
243        app.set_input("1 + 1");
244        app.evaluate();
245        app.set_input("2 + 2");
246        app.evaluate();
247        let buf = render_to_buffer(&app, 80, 24);
248
249        let content = buf.to_string_content();
250        // Should show history entries
251        assert!(content.contains("1 + 1"));
252    }
253
254    #[test]
255    fn test_render_jidoka_success() {
256        let mut app = CalculatorApp::new();
257        app.set_input("5 + 5");
258        app.evaluate();
259        let buf = render_to_buffer(&app, 80, 24);
260
261        let content = buf.to_string_content();
262        // Jidoka status should be present (checkmark or status text)
263        assert!(
264            content.contains('\u{2713}') || content.contains("Anomaly"),
265            "Should contain Anomaly status section"
266        );
267    }
268
269    #[test]
270    fn test_render_cursor_position() {
271        let mut app = CalculatorApp::new();
272        app.set_input("12345");
273        app.set_cursor(2); // After "12"
274        let buf = render_to_buffer(&app, 80, 24);
275
276        // Just verify it renders without panic
277        let _ = buf.to_string_content();
278    }
279
280    #[test]
281    fn test_render_cursor_at_end() {
282        let mut app = CalculatorApp::new();
283        app.set_input("abc");
284        // cursor is at end by default
285        let buf = render_to_buffer(&app, 80, 24);
286        let _ = buf.to_string_content();
287    }
288
289    #[test]
290    fn test_render_empty_input() {
291        let app = CalculatorApp::new();
292        let buf = render_to_buffer(&app, 80, 24);
293
294        let content = buf.to_string_content();
295        assert!(content.contains("Expression"));
296    }
297
298    #[test]
299    fn test_render_small_terminal() {
300        let app = CalculatorApp::new();
301        let buf = render_to_buffer(&app, 20, 10);
302        let _ = buf.to_string_content();
303    }
304
305    // ===== Demo/Help panel tests (PMAT-CALC-006) =====
306
307    #[test]
308    fn test_demo_title_constant() {
309        assert!(DEMO_TITLE.contains("Showcase Calculator"));
310        assert!(DEMO_TITLE.contains("100% Test Coverage"));
311        assert!(DEMO_TITLE.contains("Demo"));
312    }
313
314    #[test]
315    fn test_help_shortcuts_contains_essential_keys() {
316        let keys: Vec<&str> = HELP_SHORTCUTS.iter().map(|(k, _)| *k).collect();
317        assert!(keys.contains(&"Enter"));
318        assert!(keys.contains(&"Esc"));
319        assert!(keys.contains(&"Ctrl+C"));
320    }
321
322    #[test]
323    fn test_help_shortcuts_has_descriptions() {
324        for (key, desc) in HELP_SHORTCUTS {
325            assert!(!key.is_empty(), "Key should not be empty");
326            assert!(!desc.is_empty(), "Description should not be empty");
327        }
328    }
329
330    #[test]
331    fn test_help_shortcuts_count() {
332        assert!(
333            HELP_SHORTCUTS.len() >= 5,
334            "Should have at least 5 shortcuts"
335        );
336    }
337
338    #[test]
339    fn test_help_operators_contains_all_ops() {
340        assert!(HELP_OPERATORS.contains('+'));
341        assert!(HELP_OPERATORS.contains('-'));
342        assert!(HELP_OPERATORS.contains('*'));
343        assert!(HELP_OPERATORS.contains('/'));
344        assert!(HELP_OPERATORS.contains('%'));
345        assert!(HELP_OPERATORS.contains('^'));
346        assert!(HELP_OPERATORS.contains('('));
347        assert!(HELP_OPERATORS.contains(')'));
348    }
349
350    #[test]
351    fn test_probar_badge_contains_branding() {
352        assert!(PROBAR_BADGE.contains("Probar"));
353        assert!(PROBAR_BADGE.contains("paiml"));
354    }
355
356    #[test]
357    fn test_render_shows_demo_title() {
358        let app = CalculatorApp::new();
359        let buf = render_to_buffer(&app, 80, 24);
360
361        let content = buf.to_string_content();
362        assert!(content.contains("Showcase") || content.contains("Calculator"));
363    }
364
365    #[test]
366    fn test_render_shows_help_panel() {
367        let app = CalculatorApp::new();
368        let buf = render_to_buffer(&app, 100, 30);
369
370        let content = buf.to_string_content();
371        assert!(content.contains("Enter") || content.contains("Help") || content.contains("Esc"));
372    }
373
374    #[test]
375    fn test_render_shows_probar_badge() {
376        let app = CalculatorApp::new();
377        let buf = render_to_buffer(&app, 100, 30);
378
379        let content = buf.to_string_content();
380        assert!(content.contains("Probar") || content.contains("paiml"));
381    }
382
383    #[test]
384    fn test_render_help_sidebar_directly() {
385        let app = CalculatorApp::new();
386        let ui = CalculatorUI::new(&app);
387        let area = Rect::new(0, 0, 22, 20);
388        let mut buf = TextBuffer::empty(Rect::new(0, 0, 80, 24));
389
390        ui.render_help_sidebar(area, &mut buf);
391
392        let content = buf.to_string_content();
393        assert!(content.contains("Help"));
394        assert!(content.contains("Enter"));
395        assert!(content.contains("Esc"));
396    }
397
398    // ===== Widget implementation tests =====
399
400    #[test]
401    fn test_widget_render_direct() {
402        let app = CalculatorApp::new();
403        let ui = CalculatorUI::new(&app);
404        let area = Rect::new(0, 0, 80, 24);
405        let mut buf = TextBuffer::empty(area);
406
407        ui.render(area, &mut buf);
408
409        // Verify some content was rendered
410        let content = buf.to_string_content();
411        assert!(content.contains("Calculator"));
412    }
413
414    #[test]
415    fn test_render_sections_individually() {
416        let app = CalculatorApp::new();
417        let ui = CalculatorUI::new(&app);
418
419        let mut buf = TextBuffer::empty(Rect::new(0, 0, 80, 24));
420
421        let input_area = Rect::new(0, 0, 40, 3);
422        ui.render_input(input_area, &mut buf);
423
424        let result_area = Rect::new(0, 3, 40, 3);
425        ui.render_result(result_area, &mut buf);
426
427        let history_area = Rect::new(0, 6, 40, 10);
428        ui.render_history(history_area, &mut buf);
429
430        let jidoka_area = Rect::new(0, 16, 40, 5);
431        ui.render_jidoka_status(jidoka_area, &mut buf);
432    }
433
434    #[test]
435    fn test_render_history_many_entries() {
436        let mut app = CalculatorApp::new();
437        for i in 1..=20 {
438            app.set_input(&format!("{i} + {i}"));
439            app.evaluate();
440        }
441        let buf = render_to_buffer(&app, 80, 24);
442
443        let content = buf.to_string_content();
444        assert!(content.contains("20 + 20")); // Most recent
445    }
446
447    // ===== Keypad integration tests (PMAT-CALC-007) =====
448
449    #[test]
450    fn test_calculator_ui_has_keypad() {
451        let app = CalculatorApp::new();
452        let ui = CalculatorUI::new(&app);
453        // Verify keypad is initialized
454        assert_eq!(ui.keypad.button_count(), 20);
455    }
456
457    #[test]
458    fn test_render_keypad_directly() {
459        let app = CalculatorApp::new();
460        let ui = CalculatorUI::new(&app);
461        let area = Rect::new(0, 0, 22, 12);
462        let mut buf = TextBuffer::empty(Rect::new(0, 0, 80, 24));
463
464        ui.render_keypad(area, &mut buf);
465
466        let content = buf.to_string_content();
467        assert!(content.contains("Keypad"));
468        assert!(content.contains("[7]"));
469    }
470
471    #[test]
472    fn test_render_shows_keypad_in_full_layout() {
473        let app = CalculatorApp::new();
474        let buf = render_to_buffer(&app, 120, 30);
475
476        let content = buf.to_string_content();
477        // Should show keypad
478        assert!(content.contains("Keypad"));
479        // Should have some button labels
480        assert!(content.contains("[7]") || content.contains("[+]") || content.contains("[=]"));
481    }
482
483    #[test]
484    fn test_keypad_shows_operators() {
485        let app = CalculatorApp::new();
486        let buf = render_to_buffer(&app, 120, 30);
487
488        let content = buf.to_string_content();
489        // Keypad should show operator buttons
490        assert!(content.contains("[/]") || content.contains("[*]") || content.contains("[-]"));
491    }
492
493    #[test]
494    fn test_full_layout_three_columns() {
495        let app = CalculatorApp::new();
496        let ui = CalculatorUI::new(&app);
497        let area = Rect::new(0, 0, 120, 30);
498        let mut buf = TextBuffer::empty(area);
499
500        ui.render(area, &mut buf);
501
502        let content = buf.to_string_content();
503        // Should have all three areas
504        assert!(content.contains("Expression")); // Main area
505        assert!(content.contains("Keypad")); // Keypad
506        assert!(content.contains("Help")); // Help sidebar
507    }
508}