//! 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; use rdbms_playground::mode::Mode; 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 parse-failed — which now /// emits exactly a `JournalFailure` (ADR-0034: the failed line is /// journalled `err`) and dispatches no command to the worker. fn error_lines_for(input: &str) -> Vec { let mut app = App::new(); type_str(&mut app, input); let actions = submit(&mut app); assert!( matches!(actions.as_slice(), [Action::JournalFailure { .. }]), "expected parse failure (only a JournalFailure) 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"), ) } /// The simple-mode near-miss matrix (ADR-0042 §1). Each row is a /// near-correct input plus substrings that MUST appear across its /// rendered error lines — the structural "name the missing /// keyword/clause" message and the per-command usage template. #[test] fn near_miss_matrix_simple_mode() { // (input, required-substrings-across-error-lines) let matrix: &[(&str, &[&str])] = &[ // app-lifecycle arg errors. The arg-less commands all reject // trailing junk with "expected end of input" + their usage // (audited 2026-06-05); locked here as regression insurance. ("quit now", &["after `quit`, expected end of input", " quit"]), ("help foo", &["after `help`, expected end of input", " help"]), ("rebuild now", &["after `rebuild`, expected end of input", " rebuild"]), ("new foo", &["after `new`, expected end of input", " new"]), ("load foo", &["after `load`, expected end of input", " load"]), ("undo foo", &["after `undo`, expected end of input", " undo"]), ("redo foo", &["after `redo`, expected end of input", " redo"]), ("export foo bar", &["after `export foo`, expected end of input", "export []"]), ("import a b c", &["after `import a`, expected end of input", "import "]), ("save sideways", &["after `save`, expected end of input", "save | save as"]), ("mode sideways", &["unknown mode 'sideways'", "mode simple | mode advanced"]), ("messages louder", &["unknown messages mode 'louder'", "messages short"]), ("copy everything", &["unknown copy target 'everything'", "copy all"]), // DDL bare + missing-slot ("create", &["after `create`, expected `table`", "create table with pk"]), ("create table", &["after `create table`, expected identifier", "create table with pk"]), ("create table T", &["with pk", "create table with pk"]), // G1: relationship cardinality reads as the named construct. ("add", &["after `add`, expected `column`, `1:n relationship`", "add 1:n relationship"]), ("drop table", &["after `drop table`, expected table name", "drop table "]), ("add column", &["after `add column`, expected table name", "add column [to] [table]"]), ("rename", &["after `rename`, expected `column`", "rename column [in] [table]"]), ("rename column", &["after `rename column`, expected table name", "rename column [in] [table]"]), ("change", &["after `change`, expected `column`", "change column [in] [table]"]), ("change column", &["after `change column`, expected table name", "change column [in] [table]"]), // data bare + missing-clause ("insert", &["after `insert`, expected `into`", "insert into "]), ("insert into", &["after `insert into`, expected table name", "insert into
"]), ("insert into T", &["after `insert into T`, expected `values` or `(`", "insert into
"]), ("update", &["after `update`, expected table name", "update
set"]), ("update T", &["after `update T`, expected `set`", "update
set"]), ("update T set x=1", &["expected `where` or `--all-rows`", "update
set"]), ("delete", &["after `delete`, expected `from`", "delete from
"]), ("delete from", &["after `delete from`, expected table name", "delete from
"]), ("delete from T", &["expected `where` or `--all-rows`", "delete from
"]), ("replay", &["after `replay`, expected string literal or path", "replay "]), ("explain", &["after `explain`, expected `show`, `update`, or `delete`", "explain show data"]), // advanced-only entry word typed in simple mode → "this is SQL" rail ("select * from T", &["`select` is SQL", "mode advanced"]), ("alter table T add column c int", &["`alter` is SQL", "mode advanced"]), ]; for (input, needles) in matrix { let lines = error_lines_for(input); let dump_msg = dump(input, &lines); assert!( lines.iter().any(|l| l.starts_with("parse error")), "missing `parse error` line for {input:?}\n{dump_msg}", ); let joined = lines.join("\n"); for needle in *needles { assert!( joined.contains(needle), "near-miss {input:?} missing expected substring {needle:?}\n{dump_msg}", ); } } } /// Helper: advanced-mode error lines for `input`. fn advanced_error_lines_for(input: &str) -> Vec { let mut app = App::new(); app.mode = Mode::Advanced; type_str(&mut app, input); let _ = submit(&mut app); app.output .iter() .filter(|l| l.kind == OutputKind::Error) .map(|l| l.text.clone()) .collect() } /// Committed multi-form near-misses (ADR-0042 §1). A user who has /// already chosen a form (`add index …`, `drop constraint …`, /// `alter table T add …`) must get that form's specific /// missing-keyword/clause message and usage — not the whole family /// generically. Audited 2026-06-05; these render well today and are /// locked here as the residual systematic-pass tail. #[test] fn near_miss_matrix_committed_multiforms() { // (input, advanced?, required-substrings) let matrix: &[(&str, bool, &[&str])] = &[ // add / drop multi-forms (simple) ("add index", false, &["after `add index`, expected `on` or `as`", "add index [as ] on"]), ("add index on T", false, &["after `add index on T`, expected `(`", "add index [as ] on"]), ("add constraint", false, &["after `add constraint`, expected `not`, `unique`, `default`, or `check`", "add constraint not null to"]), ("add constraint not null", false, &["after `add constraint not null`, expected `to`", "add constraint not null to"]), ("add 1:n relationship", false, &["after `add 1:n relationship`, expected `from` or `as`", "add 1:n relationship"]), ("add 1:n relationship from", false, &["after `add 1:n relationship from`, expected table name", "from ."]), ("drop constraint", false, &["after `drop constraint`, expected `not`, `unique`, `default`, or `check`", "drop constraint (not null"]), ("drop constraint not null", false, &["after `drop constraint not null`, expected `from`", "drop constraint (not null"]), ("drop index", false, &["after `drop index`, expected `on` or index name", "drop index ", "drop index on
"]), ("drop index on T", false, &["after `drop index on T`, expected `(`", "drop index on
"]), ("drop relationship", false, &["after `drop relationship`, expected `from` or relationship name", "drop relationship "]), ("show table", false, &["after `show table`, expected table name", "show table
"]), ("change column in table T: c", false, &["after `change column in table T: c`, expected `(`", "change column [in] [table]"]), // advanced committed multi-forms ("create index on", true, &["after `create index on`, expected table name", "create [unique] index"]), ("create unique index", true, &["after `create unique index`, expected `on`, identifier, or `if`", "create [unique] index"]), ("alter table T add", true, &["after `alter table T add`, expected `column`, `constraint`, `check`, `unique`, `foreign`, or `primary`", "alter table
add column"]), ("alter table T drop", true, &["after `alter table T drop`, expected `column` or `constraint`", "alter table
drop column"]), ]; for (input, advanced, needles) in matrix { let lines = if *advanced { advanced_error_lines_for(input) } else { error_lines_for(input) }; let dump_msg = dump(input, &lines); assert!( lines.iter().any(|l| l.starts_with("parse error")), "missing `parse error` line for {input:?}\n{dump_msg}", ); assert!( !lines.iter().any(|l| l.starts_with("available commands:")), "committed form {input:?} fell back to available-commands\n{dump_msg}", ); let joined = lines.join("\n"); for needle in *needles { assert!( joined.contains(needle), "committed form {input:?} missing expected substring {needle:?}\n{dump_msg}", ); } } } #[test] fn advanced_bare_select_collapses_projection_first_set() { // ADR-0042 G2: bare `select` dumped the full 14-item // expression first-set ("`not`, `-`, …, `case`, column name, // `distinct`, or `all`"). Collapse it to a learner-sized // projection gloss in the error MESSAGE only — completion // still expands the raw set (locked by the typing-surface // matrix). let lines = advanced_error_lines_for("select"); let joined = lines.join("\n"); let dump_msg = dump("select", &lines); assert!( joined.contains("a projection: `*`, a column, or an expression"), "bare `select` should collapse to the projection gloss\n{dump_msg}", ); let err_line = lines .iter() .find(|l| l.starts_with("parse error")) .expect("parse error line"); assert!( !err_line.contains("`exists`") && !err_line.contains("`case`"), "projection gloss should replace the raw expression first-set\n{dump_msg}", ); } #[test] fn advanced_mode_usage_block_shows_sql_and_dsl_forms() { // ADR-0042 G3: `render_usage_block` was mode-blind — it // resolved shared entry words to the first-registered (Simple) // node, so advanced-mode `create` showed ONLY the DSL `create // table … with pk …` template and none of the SQL forms. // Mode-aware selection shows every form valid in the mode, // SQL-primary first. In advanced mode the DSL forms remain // valid input (verified: `create table Foo with pk` parses and // runs in advanced mode), so they MUST still appear — a usage // hint never hides input that works. let lines = advanced_error_lines_for("create"); let joined = lines.join("\n"); let dump_msg = dump("create", &lines); assert!( joined.contains("create table [if not exists]"), "advanced `create` should show the SQL create-table usage\n{dump_msg}", ); assert!( joined.contains("create [unique] index"), "advanced `create` should show the SQL create-index usage\n{dump_msg}", ); assert!( joined.contains("create table with pk"), "advanced `create` should ALSO show the DSL `with pk` form (valid in advanced mode)\n{dump_msg}", ); // Ordering: the SQL form is listed before the DSL form // (mode-primary first). let sql_at = joined.find("create table [if not exists]").unwrap(); let dsl_at = joined.find("create table with pk").unwrap(); assert!(sql_at < dsl_at, "SQL form should precede the DSL form\n{dump_msg}"); } /// The advanced-mode near-miss matrix (ADR-0042 §1/§3). Mirrors /// the simple-mode matrix for the SQL surface. Every row must show /// a per-command `usage:` block (never the available-commands /// fallback — that is for unconsumed entry words only). #[test] fn near_miss_matrix_advanced_mode() { let matrix: &[(&str, &[&str])] = &[ // SQL select / with (G2, G4) ("select", &["expected a projection: `*`, a column, or an expression", "select (* |"]), ("select * from", &["after `select * from`, expected table name", "select (* |"]), ("with", &["after `with`, expected identifier or `recursive`", "with [recursive]", "as ("]), // create / drop / alter — SQL forms AND the still-valid DSL // fallback forms, SQL-primary first (G3). ("create", &["after `create`, expected `table`", "create table [if not exists]", "create [unique] index", "create table with pk"]), ("create table", &["after `create table`, expected identifier or `if`", "create table [if not exists]"]), ("create index", &["after `create index`, expected `on`", "create [unique] index"]), ("drop", &["after `drop`, expected `table`", "drop table [if exists]", "drop column [from]", "drop relationship"]), ("alter", &["after `alter`, expected `table`", "alter table
add column"]), ("alter table T", &["expected `add`, `drop`, `rename`, or `alter`", "alter table
"]), // shared insert/update/delete — must show usage, not the // available-commands fallback (regression guard for the // empty-usage_ids SQL nodes). ("insert into T", &["after `insert into T`, expected `values`, `with`, `select`, or `(`", "insert into
"]), ("update T", &["after `update T`, expected `set`", "update
set"]), ("delete from", &["after `delete from`, expected table name", "delete from
"]), ]; for (input, needles) in matrix { let lines = advanced_error_lines_for(input); let dump_msg = dump(input, &lines); assert!( lines.iter().any(|l| l.starts_with("parse error")), "missing `parse error` line for {input:?}\n{dump_msg}", ); // A consumed entry word must yield a usage block, never the // available-commands fallback. assert!( !lines.iter().any(|l| l.starts_with("available commands:")), "advanced {input:?} fell back to available-commands instead of a usage block\n{dump_msg}", ); let joined = lines.join("\n"); for needle in *needles { assert!( joined.contains(needle), "advanced near-miss {input:?} missing expected substring {needle:?}\n{dump_msg}", ); } } } #[test] fn with_alone_renders_cte_usage_not_select() { // ADR-0042 G4: `with` (advanced-only CTE entry word) borrowed // the `select` usage template, which never mentions the CTE // shape. It now carries its own `parse.usage.with`. let mut app = App::new(); app.mode = Mode::Advanced; type_str(&mut app, "with"); let _ = submit(&mut app); let lines: Vec = app .output .iter() .filter(|l| l.kind == OutputKind::Error) .map(|l| l.text.clone()) .collect(); let dump_msg = dump("with", &lines); assert!( lines.iter().any(|l| l.trim_start().starts_with("with ") && l.contains("as (")), "missing CTE-specific `with … as (…)` usage template\n{dump_msg}", ); } #[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 the top-level choice: the structural // error line lists every add-family branch. ADR-0042 G1: the // relationship branch renders as the friendly `1:n // relationship` rather than the cryptic bare `1` cardinality // literal. assert!( lines.iter().any(|l| { l.starts_with("parse error") && l.contains("`1:n relationship`") && l.contains("`column`") }), "expected aggregated `1:n relationship` and `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:?}", ); }