//! Tier-3 integration tests for ADR-0021 (per-command usage in //! parse errors). Drives synthetic crossterm events through //! `App::update` and asserts on the rendered output lines. //! //! Each test exercises the full input → parse → error-render //! chain. The unit tests in `dsl::usage::tests` cover the //! registry logic in isolation; these tests pin the user-visible //! composition (caret + structural error + usage block, or the //! available-commands fallback). use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use rdbms_playground::action::Action; use rdbms_playground::app::{App, OutputKind}; use rdbms_playground::event::AppEvent; 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) { for c in s.chars() { app.update(key(KeyCode::Char(c))); } } fn submit(app: &mut App) -> Vec { app.update(key(KeyCode::Enter)) } /// Run `input` through the app and return every error-kind /// output line. Asserts the submission produced no actions /// (i.e. the parse failed). fn error_lines_for(input: &str) -> Vec { let mut app = App::new(); type_str(&mut app, input); let actions = submit(&mut app); assert!( actions.is_empty(), "expected parse failure (no actions) for {input:?}, got {actions:?}", ); app.output .iter() .filter(|l| l.kind == OutputKind::Error) .map(|l| l.text.clone()) .collect() } fn dump(input: &str, lines: &[String]) -> String { format!( "INPUT: {input:?}\nERROR LINES:\n{}", lines.join("\n"), ) } #[test] fn create_alone_renders_create_table_usage() { let lines = error_lines_for("create"); let dump_msg = dump("create", &lines); assert!( lines.iter().any(|l| l.starts_with("parse error")), "{dump_msg}", ); assert!( lines.iter().any(|l| l == "usage:"), "missing usage: header\n{dump_msg}", ); assert!( lines.iter().any(|l| l.contains("create table") && l.contains("with pk")), "missing create_table usage template\n{dump_msg}", ); } #[test] fn add_alone_renders_both_add_family_usages() { let lines = error_lines_for("add"); let dump_msg = dump("add", &lines); // Aggregation across `choice` (ADR-0020): the structural // error line lists both add-family entries. assert!( lines.iter().any(|l| { l.starts_with("parse error") && l.contains("`1`") && l.contains("`column`") }), "expected aggregated `1` or `column` in structural error\n{dump_msg}", ); // Usage block (ADR-0021): both add-* templates surface. assert!( lines.iter().any(|l| l.contains("add column")), "missing add_column usage\n{dump_msg}", ); assert!( lines.iter().any(|l| l.contains("add 1:n relationship")), "missing add_relationship usage\n{dump_msg}", ); } #[test] fn drop_alone_renders_all_three_drop_family_usages() { let lines = error_lines_for("drop"); let dump_msg = dump("drop", &lines); assert!( lines.iter().any(|l| l.contains("drop table")), "missing drop_table usage\n{dump_msg}", ); assert!( lines.iter().any(|l| l.contains("drop column")), "missing drop_column usage\n{dump_msg}", ); assert!( lines.iter().any(|l| l.contains("drop relationship")), "missing drop_relationship usage\n{dump_msg}", ); } #[test] fn show_alone_renders_both_show_family_usages() { let lines = error_lines_for("show"); let dump_msg = dump("show", &lines); assert!( lines.iter().any(|l| l.contains("show data")), "missing show_data usage\n{dump_msg}", ); assert!( lines.iter().any(|l| l.contains("show table")), "missing show_table usage\n{dump_msg}", ); } #[test] fn unknown_command_falls_back_to_available_commands_list() { let lines = error_lines_for("frobulate Customers"); let dump_msg = dump("frobulate Customers", &lines); // No "usage:" header — the no-prefix fallback path renders // the available-commands list instead. assert!( lines.iter().all(|l| l != "usage:"), "should not render usage: header for unknown command\n{dump_msg}", ); let available = lines .iter() .find(|l| l.starts_with("available commands:")) .unwrap_or_else(|| panic!("missing available commands line\n{dump_msg}")); // The list must include all ten command-entry keywords. for cmd in [ "add", "change", "create", "delete", "drop", "insert", "rename", "replay", "show", "update", ] { assert!( available.contains(&format!("`{cmd}`")), "available commands missing `{cmd}`: {available}", ); } } #[test] fn update_partial_renders_update_usage_template() { // `update Customers set Active=false` parses through to // end-of-input; the missing `where` / `--all-rows` clause // triggers the structural error. The entry keyword is // `update`, so the update usage template is shown. let lines = error_lines_for("update Customers set Active=false"); let dump_msg = dump("update Customers set Active=false", &lines); assert!( lines.iter().any(|l| l.contains("update set")), "missing update usage template\n{dump_msg}", ); } #[test] fn create_table_without_pk_renders_create_table_usage() { // The custom `try_map` error fires after `create table // Customers` is fully consumed; failure position points at // the start of the matched range, but matched_entry's `<=` // condition still resolves the entry keyword. let lines = error_lines_for("create table Customers"); let dump_msg = dump("create table Customers", &lines); // Custom error wording (not just structural) is preserved. assert!( lines .iter() .any(|l| l.starts_with("parse error") && l.contains("with pk")), "missing custom-error wording about with pk\n{dump_msg}", ); // And the usage template surfaces as well. assert!( lines .iter() .any(|l| l.contains("create table") && l.contains("with pk")), "missing create_table usage template\n{dump_msg}", ); } #[test] fn insert_partial_renders_insert_usage_template() { // `insert into T` needs either column-list or value-list to // follow. Parser reports a structural error; usage template // surfaces. let lines = error_lines_for("insert into T"); let dump_msg = dump("insert into T", &lines); assert!( lines.iter().any(|l| l.contains("insert into
")), "missing insert usage template\n{dump_msg}", ); } #[test] fn caret_aligns_under_offending_token() { // The caret line is whitespace + `^`. After the "running: " // prefix (9 chars) plus the byte offset of the failure // position, the `^` should sit directly under the // offending character. For `frobulate Customers`, the // failure is at position 0, so the caret is at column 9. let lines = error_lines_for("frobulate Customers"); let caret = lines .iter() .find(|l| l.trim_start_matches(' ').starts_with('^')) .expect("missing caret line"); let leading_spaces = caret.chars().take_while(|c| *c == ' ').count(); assert_eq!( leading_spaces, 9, "caret should sit at column 9 (under `f` of `frobulate` after the `running: ` prefix); got {leading_spaces} spaces in {caret:?}", ); }