Files
rdbms-playground/tests/it/parse_error_pedagogy.rs
T
claude@clouddev1 41b7e9a049 style: format the whole tree with cargo fmt (stock defaults, #35)
One-time, mechanical reformat — no functional changes. The tree was not
rustfmt-clean (~1800 hunks across ~100 files); this brings it to stock
`cargo fmt` defaults so a `cargo fmt --check` CI gate can follow.
Behaviour-preserving: 2509 pass / 0 fail / 1 ignored (unchanged baseline),
clippy clean. A .git-blame-ignore-revs entry follows so `git blame`
skips this commit.
2026-06-17 21:39:19 +00:00

925 lines
30 KiB
Rust

//! 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<Action> {
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<String> {
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` now takes an optional single-word topic (H3), so
// `help foo` parses (topic lookup); only a *multi-word*
// topic is the near-miss that rejects trailing junk.
(
"help foo bar",
&[
"after `help foo`, expected end of input",
"help [<command>]",
],
),
(
"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 [<path>]",
],
),
(
"import a b c",
&[
"after `import a`, expected end of input",
"import <zip-path>",
],
),
(
"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 <Name> with pk",
],
),
(
"create table",
&[
"after `create table`, expected identifier",
"create table <Name> with pk",
],
),
(
"create table T",
&["with pk", "create table <Name> 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 <Name>",
],
),
(
"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 <Table>"],
),
(
"insert into",
&[
"after `insert into`, expected table name",
"insert into <Table>",
],
),
(
"insert into T",
&[
"after `insert into T`, expected `values` or `(`",
"insert into <Table>",
],
),
(
"update",
&["after `update`, expected table name", "update <Table> set"],
),
(
"update T",
&["after `update T`, expected `set`", "update <Table> set"],
),
(
"update T set x=1",
&["expected `where` or `--all-rows`", "update <Table> set"],
),
(
"delete",
&["after `delete`, expected `from`", "delete from <Table>"],
),
(
"delete from",
&[
"after `delete from`, expected table name",
"delete from <Table>",
],
),
(
"delete from T",
&["expected `where` or `--all-rows`", "delete from <Table>"],
),
(
"seed",
&["after `seed`, expected table name", "seed <Table> [count]"],
),
// Phase 2 (ADR-0048 D2/D1): malformed `set` clause + column-fill.
(
"seed T set",
&[
"after `seed T set`, expected column name",
"seed <Table>.<col>",
],
),
(
"seed T set role",
&[
"after `seed T set role`, expected `=`, `in`, `between`, or `as`",
"seed <Table>.<col>",
],
),
(
"seed T.",
&[
"after `seed T.`, expected column name",
"seed <Table>.<col>",
],
),
(
"replay",
&[
"after `replay`, expected string literal or path",
"replay <path>",
],
),
(
"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<String> {
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 <Name>] on",
],
),
(
"add index on T",
false,
&[
"after `add index on T`, expected `(`",
"add index [as <Name>] 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 <Parent>.<col>",
],
),
(
"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 <Name>",
"drop index on <Table>",
],
),
(
"drop index on T",
false,
&[
"after `drop index on T`, expected `(`",
"drop index on <Table>",
],
),
(
"drop relationship",
false,
&[
"after `drop relationship`, expected `from` or relationship name",
"drop relationship <Name>",
],
),
(
"show table",
false,
&[
"after `show table`, expected table name",
"show table <Table>",
],
),
(
"show relationship",
false,
&[
"after `show relationship`, expected relationship name",
"show relationship <name>",
],
),
(
"show index",
false,
&[
"after `show index`, expected index name",
"show index <name>",
],
),
(
"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 <Table> add column",
],
),
(
"alter table T drop",
true,
&[
"after `alter table T drop`, expected `column` or `constraint`",
"alter table <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 <Name> 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 <Name> with pk").unwrap();
assert!(
sql_at < dsl_at,
"SQL form should precede the DSL form\n{dump_msg}"
);
}
#[test]
fn advanced_cross_join_with_on_teaches_no_on_clause() {
// ADR-0042 §3: a CROSS JOIN has no ON clause. The grammar
// rejects a following `on`, but the bare structural error
// ("expected end of input") does not teach why. `on` is
// unexpected here only because the most recent join is a CROSS
// join — every other join flavour *requires* `on` — so the case
// is precisely detectable and gets a teaching message.
let input = "select * from a cross join b on x = y";
let lines = advanced_error_lines_for(input);
let joined = lines.join("\n");
let dump_msg = dump(input, &lines);
assert!(
joined.contains("a CROSS JOIN has no ON clause"),
"cross join + on should teach that CROSS JOIN takes no ON\n{dump_msg}",
);
// Misfire guard 1: a plain JOIN missing its ON still asks for `on`.
let plain = advanced_error_lines_for("select * from a join b").join("\n");
assert!(
plain.contains("expected `on`") && !plain.contains("CROSS JOIN"),
"a plain join must still ask for ON, not the cross-join message: {plain}",
);
// Misfire guard 2: a stray `on` with no join present must NOT
// claim a CROSS JOIN.
let stray = advanced_error_lines_for("select * from a on x = y").join("\n");
assert!(
!stray.contains("CROSS JOIN has no ON"),
"no cross join present — must not fire: {stray}",
);
}
/// 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 <Name> 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 <Table> add column",
],
),
(
"alter table T",
&[
"expected `add`, `drop`, `rename`, or `alter`",
"alter table <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 <Table>",
],
),
(
"update T",
&["after `update T`, expected `set`", "update <Table> set"],
),
(
"delete from",
&[
"after `delete from`, expected table name",
"delete from <Table>",
],
),
];
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<String> = 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 <Table> 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 <Table>")),
"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:?}",
);
}