//! Tier 3 integration tests for the walking skeleton (per ADR-0008). //! //! These tests drive synthetic crossterm events through `App::update` //! and assert on the resulting state and rendered buffer. They //! exercise the full input → state → render path without a real //! terminal, so they run on every commit and catch regressions in //! the wiring between modules. use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::Terminal; use ratatui::backend::TestBackend; use rdbms_playground::action::Action; use rdbms_playground::app::{App, OutputKind}; use rdbms_playground::event::AppEvent; use rdbms_playground::mode::Mode; use rdbms_playground::theme::Theme; use rdbms_playground::ui; const fn key(code: KeyCode) -> AppEvent { AppEvent::Key(KeyEvent { code, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: crossterm::event::KeyEventState::NONE, }) } fn type_str(app: &mut App, s: &str) -> Vec { let mut actions = Vec::new(); for c in s.chars() { actions.extend(app.update(key(KeyCode::Char(c)))); } actions } fn submit(app: &mut App) -> Vec { app.update(key(KeyCode::Enter)) } fn rendered_text(app: &App, theme: &Theme, width: u16, height: u16) -> String { let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend).expect("create terminal"); terminal .draw(|f| ui::render(app, theme, f)) .expect("draw frame"); let buffer = terminal.backend().buffer().clone(); let mut out = String::new(); for y in 0..buffer.area.height { for x in 0..buffer.area.width { out.push_str(buffer[(x, y)].symbol()); } out.push('\n'); } out } #[test] fn typing_then_submitting_produces_an_echo_in_the_output_panel() { let mut app = App::new(); let theme = Theme::dark(); type_str(&mut app, "hello world"); let pre_render = rendered_text(&app, &theme, 80, 24); assert!( pre_render.contains("hello world"), "input field should display the typed text:\n{pre_render}" ); let actions = submit(&mut app); assert!(actions.is_empty()); assert!(app.input.is_empty(), "input buffer cleared on submit"); assert_eq!(app.output.len(), 1); let post_render = rendered_text(&app, &theme, 80, 24); assert!( post_render.contains("hello world"), "output panel should display the echoed line:\n{post_render}" ); assert!( post_render.contains("[simple]"), "echo should be tagged with the submission mode:\n{post_render}" ); } #[test] fn mode_switch_changes_label_and_subsequent_echoes() { let mut app = App::new(); let theme = Theme::dark(); let initial = rendered_text(&app, &theme, 80, 24); assert!(initial.contains("SIMPLE")); assert!(!initial.contains("ADVANCED")); type_str(&mut app, "mode advanced"); submit(&mut app); assert_eq!(app.mode, Mode::Advanced); let after_switch = rendered_text(&app, &theme, 80, 24); assert!(after_switch.contains("ADVANCED")); type_str(&mut app, "select 1"); submit(&mut app); let last = app.output.back().expect("output present"); assert_eq!(last.mode_at_submission, Mode::Advanced); assert_eq!(last.kind, OutputKind::Echo); } #[test] fn colon_escape_in_simple_mode_is_one_shot() { let mut app = App::new(); type_str(&mut app, ":select 1"); submit(&mut app); assert_eq!(app.mode, Mode::Simple); assert_eq!(app.output[0].mode_at_submission, Mode::Advanced); assert_eq!(app.output[0].text, "select 1"); type_str(&mut app, "another line"); submit(&mut app); assert_eq!(app.output[1].mode_at_submission, Mode::Simple); } #[test] fn quit_command_returns_quit_action() { let mut app = App::new(); type_str(&mut app, "quit"); let actions = submit(&mut app); assert_eq!(actions, vec![Action::Quit]); } #[test] fn rendering_works_at_minimum_useful_size() { // Sanity check that the layout does not panic at small sizes. let app = App::new(); let theme = Theme::dark(); let _ = rendered_text(&app, &theme, 40, 12); } #[test] fn typing_colon_in_simple_mode_flips_prompt_to_advanced() { let mut app = App::new(); let theme = Theme::dark(); // No `:` yet — prompt shows SIMPLE. type_str(&mut app, "sel"); let before = rendered_text(&app, &theme, 80, 24); assert!(before.contains("SIMPLE")); assert!(!before.contains("Advanced:")); // Reset and type `:` first — prompt should flip immediately. app.input.clear(); type_str(&mut app, ":"); let after_colon = rendered_text(&app, &theme, 80, 24); assert!( after_colon.contains("Advanced:"), "input panel should show 'Advanced:' once `:` is typed:\n{after_colon}" ); assert!(!after_colon.contains("SIMPLE")); // Backspace through both the auto-inserted space and the `:` // itself reverts the prompt. while !app.input.is_empty() { app.update(key(KeyCode::Backspace)); } let after_revert = rendered_text(&app, &theme, 80, 24); assert!(after_revert.contains("SIMPLE")); assert!(!after_revert.contains("Advanced:")); } #[test] fn status_bar_lists_quit_and_submit_in_all_modes() { let mut app = App::new(); let theme = Theme::dark(); let simple = rendered_text(&app, &theme, 80, 24); assert!(simple.contains("Enter"), "status bar lists Enter"); assert!(simple.contains("Ctrl-C"), "status bar lists Ctrl-C"); assert!(simple.contains("mode advanced")); type_str(&mut app, "mode advanced"); submit(&mut app); let advanced = rendered_text(&app, &theme, 80, 24); assert!(advanced.contains("Enter")); assert!(advanced.contains("Ctrl-C")); assert!(advanced.contains("mode simple")); }