Files
rdbms-playground/tests/parse_error_pedagogy.rs
T
claude@clouddev1 11071ae164 ADR-0021 implementation: per-command usage templates in parse errors
New `dsl::usage` module: registry pairing each command's
entry-keyword with a `parse.usage.*` catalog key.
`matched_entry()` resolves the entry keyword from the
consumed token prefix; multi-entry families (add, drop,
show) return all matching keys.

Catalog: new `parse.usage.<command>` keys (one per command),
`parse.token.{keyword,punct,...}` vocabulary (one per
Keyword/Punct variant + token-class labels + LexError
kinds), and `parse.available_commands` for the no-prefix
fallback. Catalog grows ~60 entries.

Validator: extended KEYS_AND_PLACEHOLDERS; new completeness
test asserts every Keyword and Punct variant has its
`parse.token.*` entry.

`app::dispatch_dsl` rewritten to compose three blocks per
ADR-0021 §2: caret + structural/custom error + usage block
(or available-commands fallback per §5). Caret math fixed
to use original-input byte position rather than
trimmed-input position (the lexer no longer trims before
lexing). Three pre-existing app tests adjusted to look
across all error lines instead of `output.back()` (the
usage block is now the last line).

`dsl::usage::matched_entry` uses `<=` rather than `<` for
position comparison so custom errors raised by `try_map`
(whose span starts at the first consumed token) still
resolve to the entry keyword.

Tests: 668 passing, 0 failing, 1 ignored (650 baseline →
+18: 8 usage + 1 token-vocab completeness + 9 new
integration tests in tests/parse_error_pedagogy.rs
covering create/add/drop/show/frobulate/update/insert
cases). Clippy clean.
2026-05-10 14:41:32 +00:00

231 lines
7.6 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;
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 produced no actions
/// (i.e. the parse failed).
fn error_lines_for(input: &str) -> Vec<String> {
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 <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:?}",
);
}