//! 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::db::{ ColumnDescription, DataResult, InsertResult, RelationshipEnd, TableDescription, }; use rdbms_playground::dsl::{ColumnSpec, Command, ReferentialAction, RowFilter, Type, Value}; 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)) } /// Assert that `actions` is exactly one `Action::ExecuteDsl` /// whose parsed command equals `expected`. The original source /// text carried alongside the command is allowed to be /// anything — tests construct the expected `Command` directly /// and don't care about the verbatim user input. #[track_caller] fn assert_one_execute_dsl(actions: &[Action], expected: &Command) { assert_eq!(actions.len(), 1, "expected exactly one action; got {actions:?}"); match &actions[0] { Action::ExecuteDsl { command, .. } => assert_eq!(command, expected), other => panic!("expected ExecuteDsl, got {other:?}"), } } fn rendered_text(app: &mut 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_a_dsl_command_emits_execute_action() { let mut app = App::new(); let theme = Theme::dark(); type_str(&mut app, "create table Customers with pk"); let pre_render = rendered_text(&mut app, &theme, 80, 24); assert!( pre_render.contains("create table Customers"), "input field should display the typed text:\n{pre_render}" ); let actions = submit(&mut app); assert_one_execute_dsl( &actions, &Command::CreateTable { name: "Customers".to_string(), columns: vec![ColumnSpec::new("id".to_string(), Type::Serial)], primary_key: vec!["id".to_string()], }, ); assert!(app.input.is_empty(), "input buffer cleared on submit"); let post_render = rendered_text(&mut app, &theme, 80, 24); assert!( post_render.contains("running:"), "output panel should show the running notice:\n{post_render}" ); } #[test] fn typing_invalid_simple_input_shows_a_parse_error_not_an_echo() { let mut app = App::new(); let theme = Theme::dark(); type_str(&mut app, "hello world"); let actions = submit(&mut app); assert!(actions.is_empty()); let rendered = rendered_text(&mut app, &theme, 80, 24); assert!( rendered.contains("parse error"), "output panel should show the parse error:\n{rendered}" ); } #[test] fn mode_switch_changes_label_and_subsequent_echoes() { let mut app = App::new(); let theme = Theme::dark(); let initial = rendered_text(&mut 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(&mut 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); // Advanced mode currently echoes (SQL handling lands later); // the echoed line should carry the advanced submission mode. let echoed = app .output .iter() .rfind(|l| l.kind == OutputKind::Echo) .expect("echo output present"); assert_eq!(echoed.mode_at_submission, Mode::Advanced); assert_eq!(echoed.text, "select 1"); // Subsequent submission (unrecognised in simple mode) parse-errors, // not echoes — confirming the mode reverted. type_str(&mut app, "list things"); submit(&mut app); let last = app.output.back().unwrap(); assert_eq!(last.kind, OutputKind::Error); assert_eq!(last.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 mut app = App::new(); let theme = Theme::dark(); let _ = rendered_text(&mut 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(&mut 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(&mut 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(&mut 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(&mut 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(&mut app, &theme, 80, 24); assert!(advanced.contains("Enter")); assert!(advanced.contains("Ctrl-C")); assert!(advanced.contains("mode simple")); } // --------------------------------------------------------------- // Full DSL flow tests. // // These tests simulate the runtime by feeding the AppEvent::Dsl* // events that the runtime would post after dispatching a command // to the database. That keeps these tests deterministic and runtime // agnostic — the actual database is exercised in the db module's // own #[tokio::test] suite. // --------------------------------------------------------------- fn fake_table(name: &str, columns: &[(&str, Type, bool)]) -> TableDescription { TableDescription { name: name.to_string(), columns: columns .iter() .map(|(n, t, pk)| ColumnDescription { name: (*n).to_string(), user_type: Some(*t), sqlite_type: t.sqlite_strict_type().to_string(), notnull: false, primary_key: *pk, unique: false, default: None, }) .collect(), outbound_relationships: Vec::new(), inbound_relationships: Vec::new(), indexes: Vec::new(), } } #[test] fn create_table_flow_updates_tables_list_and_structure_view() { let mut app = App::new(); let theme = Theme::dark(); // User types and submits. type_str(&mut app, "create table Customers with pk"); let actions = submit(&mut app); let expected_cmd = Command::CreateTable { name: "Customers".to_string(), columns: vec![ColumnSpec::new("id".to_string(), Type::Serial)], primary_key: vec!["id".to_string()], }; assert_one_execute_dsl(&actions, &expected_cmd); // Runtime would now dispatch and feed back DslSucceeded + TablesRefreshed. let desc = fake_table("Customers", &[("id", Type::Serial, true)]); app.update(AppEvent::DslSucceeded { command: expected_cmd, description: Some(desc.clone()), }); app.update(AppEvent::TablesRefreshed(vec!["Customers".to_string()])); assert_eq!(app.tables, vec!["Customers".to_string()]); assert_eq!(app.current_table, Some(desc)); let rendered = rendered_text(&mut app, &theme, 80, 24); assert!( rendered.contains("Customers"), "items panel should list Customers:\n{rendered}" ); assert!( rendered.contains("[ok] create table Customers"), "output should confirm success:\n{rendered}" ); // The structure table renders one line per column; the // `id` row shows both the name and its `serial` type // separated by box-drawing characters. assert!( rendered.lines().any(|l| l.contains("id") && l.contains("serial")), "output should show the id/serial column row:\n{rendered}" ); } #[test] fn add_column_flow_updates_structure_view() { let mut app = App::new(); // Simulate the prior create_table state. app.tables = vec!["Customers".to_string()]; app.current_table = Some(fake_table( "Customers", &[("id", Type::Serial, true)], )); type_str(&mut app, "add column to table Customers: Name (text)"); let actions = submit(&mut app); assert_one_execute_dsl( &actions, &Command::AddColumn { table: "Customers".to_string(), column: "Name".to_string(), ty: Type::Text, not_null: false, unique: false, default: None, check: None, }, ); let updated = fake_table( "Customers", &[("id", Type::Serial, true), ("Name", Type::Text, false)], ); app.update(AppEvent::DslSucceeded { command: Command::AddColumn { table: "Customers".to_string(), column: "Name".to_string(), ty: Type::Text, not_null: false, unique: false, default: None, check: None, }, description: Some(updated.clone()), }); assert_eq!(app.current_table, Some(updated)); let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); assert!( rendered.lines().any(|l| l.contains("Name") && l.contains("text")), "expected the Name/text column row:\n{rendered}", ); } #[test] fn drop_table_flow_clears_items_list() { let mut app = App::new(); app.tables = vec!["Customers".to_string()]; app.current_table = Some(fake_table("Customers", &[("id", Type::Serial, true)])); type_str(&mut app, "drop table Customers"); let actions = submit(&mut app); assert_one_execute_dsl( &actions, &Command::DropTable { name: "Customers".to_string(), }, ); app.update(AppEvent::DslSucceeded { command: Command::DropTable { name: "Customers".to_string(), }, description: None, }); app.update(AppEvent::TablesRefreshed(Vec::new())); assert!(app.tables.is_empty()); assert!(app.current_table.is_none()); let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); assert!(rendered.contains("(none yet)")); assert!(rendered.contains("[ok] drop table Customers")); } #[test] fn add_relationship_flow_shows_parent_side_with_inbound_section() { let mut app = App::new(); type_str( &mut app, "add 1:n relationship from Customers.Id to Orders.CustId on delete cascade", ); let actions = submit(&mut app); assert_one_execute_dsl( &actions, &Command::AddRelationship { name: None, parent_table: "Customers".to_string(), parent_column: "Id".to_string(), child_table: "Orders".to_string(), child_column: "CustId".to_string(), on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::NoAction, create_fk: false, }, ); // The runtime now feeds back the parent (Customers) so the // user sees the new relationship via the "Referenced by" // section — same direction as the command's `from ` // reading. let customers = TableDescription { name: "Customers".to_string(), columns: vec![ColumnDescription { name: "Id".to_string(), user_type: Some(Type::Serial), sqlite_type: "INTEGER".to_string(), notnull: false, primary_key: true, unique: false, default: None, }], outbound_relationships: Vec::new(), inbound_relationships: vec![RelationshipEnd { name: "Customers_Id_to_Orders_CustId".to_string(), other_table: "Orders".to_string(), other_column: "CustId".to_string(), local_column: "Id".to_string(), on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::NoAction, }], indexes: Vec::new(), }; app.update(AppEvent::DslSucceeded { command: Command::AddRelationship { name: None, parent_table: "Customers".to_string(), parent_column: "Id".to_string(), child_table: "Orders".to_string(), child_column: "CustId".to_string(), on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::NoAction, create_fk: false, }, description: Some(customers), }); let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); assert!(rendered.contains("Referenced by:"), "{rendered}"); assert!(rendered.contains("Orders.CustId"), "{rendered}"); assert!(rendered.contains("on delete cascade"), "{rendered}"); // The [ok] subject lists the endpoints. Long lines wrap in // the panel, so we check the first half of the phrase only. assert!( rendered.contains("from Customers.Id"), "{rendered}" ); } #[test] fn add_relationship_flow_shows_inbound_section_on_parent() { let mut app = App::new(); let customers = TableDescription { name: "Customers".to_string(), columns: vec![ColumnDescription { name: "Id".to_string(), user_type: Some(Type::Serial), sqlite_type: "INTEGER".to_string(), notnull: false, primary_key: true, unique: false, default: None, }], outbound_relationships: Vec::new(), inbound_relationships: vec![RelationshipEnd { name: "Customers_Id_to_Orders_CustId".to_string(), other_table: "Orders".to_string(), other_column: "CustId".to_string(), local_column: "Id".to_string(), on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::NoAction, }], indexes: Vec::new(), }; app.update(AppEvent::DslSucceeded { command: Command::AddColumn { table: "Customers".to_string(), column: "extra".to_string(), ty: Type::Text, not_null: false, unique: false, default: None, check: None, }, description: Some(customers), }); let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); assert!(rendered.contains("Referenced by:"), "{rendered}"); assert!(rendered.contains("Orders.CustId → Id"), "{rendered}"); } #[test] fn insert_flow_emits_action_and_renders_data() { let mut app = App::new(); type_str(&mut app, "insert into Customers values ('Alice')"); let actions = submit(&mut app); assert_one_execute_dsl( &actions, &Command::Insert { table: "Customers".to_string(), columns: None, values: vec![Value::Text("Alice".to_string())], }, ); // Simulate the runtime feeding back an InsertResult. let data = DataResult { table_name: "Customers".to_string(), columns: vec!["id".to_string(), "Name".to_string()], column_types: vec![Some(Type::Serial), Some(Type::Text)], rows: vec![vec![Some("1".to_string()), Some("Alice".to_string())]], }; app.update(AppEvent::DslInsertSucceeded { command: Command::Insert { table: "Customers".to_string(), columns: None, values: vec![Value::Text("Alice".to_string())], }, result: InsertResult { rows_affected: 1, data, }, }); let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); assert!( rendered.contains("1 row(s) inserted"), "should show row count:\n{rendered}" ); assert!( rendered.contains("Alice"), "should auto-show new row:\n{rendered}" ); assert!( rendered.contains("id") && rendered.contains("Name"), "should show column headers:\n{rendered}" ); } #[test] fn delete_with_all_rows_emits_correct_action() { let mut app = App::new(); type_str(&mut app, "delete from Customers --all-rows"); let actions = submit(&mut app); assert_one_execute_dsl( &actions, &Command::Delete { table: "Customers".to_string(), filter: RowFilter::AllRows, }, ); } #[test] fn show_data_for_empty_table_renders_placeholder() { let mut app = App::new(); let data = DataResult { table_name: "Customers".to_string(), columns: vec!["id".to_string(), "Name".to_string()], column_types: vec![Some(Type::Serial), Some(Type::Text)], rows: Vec::new(), }; app.update(AppEvent::DslDataSucceeded { command: Command::ShowData { name: "Customers".to_string(), filter: None, limit: None, }, data, }); let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); assert!(rendered.contains("(no rows)"), "{rendered}"); } #[test] fn dsl_failure_shows_friendly_error_in_output() { let mut app = App::new(); type_str(&mut app, "drop table Ghost"); submit(&mut app); app.update(AppEvent::DslFailed { command: Command::DropTable { name: "Ghost".to_string(), }, error: rdbms_playground::db::DbError::Sqlite { message: "no such table: Ghost".to_string(), kind: rdbms_playground::db::SqliteErrorKind::NoSuchTable, }, facts: rdbms_playground::friendly::FailureContext::default(), }); let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); assert!( rendered.contains("Ghost"), "error should mention the table:\n{rendered}" ); assert!( rendered.contains("no such table"), "error should include the friendly message:\n{rendered}" ); } #[test] fn validity_indicator_renders_err_and_wrn_labels() { // ADR-0027 §4: the input row shows a `[ERR]` / `[WRN]` // label at its right edge, or nothing when clean. use rdbms_playground::dsl::walker::Severity; let mut app = App::new(); let clean = rendered_text(&mut app, &Theme::dark(), 80, 24); assert!(!clean.contains("[ERR]"), "clean input shows no label:\n{clean}"); assert!(!clean.contains("[WRN]"), "clean input shows no label:\n{clean}"); app.input_indicator = Some(Severity::Error); let err = rendered_text(&mut app, &Theme::dark(), 80, 24); assert!(err.contains("[ERR]"), "ERROR verdict shows [ERR]:\n{err}"); app.input_indicator = Some(Severity::Warning); let wrn = rendered_text(&mut app, &Theme::dark(), 80, 24); assert!(wrn.contains("[WRN]"), "WARNING verdict shows [WRN]:\n{wrn}"); }