649fdcb38e
Close the three remaining ADR-0042 triage gaps, each test-first, and lock the advanced-mode near-miss matrix. G2 — bare `select` dumped the 14-item expression first-set. Collapse it to "a projection: `*`, a column, or an expression" in the error message only (parser::format_walker_error), detected by the joint `distinct`+`all` quantifier signature unique to a projection start. Render-only: completion/hints still expand the full set (typing-surface matrix unchanged). G3 — the usage block was mode-blind: advanced `create table` showed the DSL `create table … with pk …` template. usage_key(s)_for_input gain mode-aware `_in_mode` variants selecting candidates by CommandCategory; render_usage_block and the typing-time ambient usage thread the submission mode. Advanced `create` now shows both SQL forms. A fallback covers shared SQL nodes (insert/update/delete) that declare no usage_ids of their own — without it they regressed to the available-commands fallback (caught by the new advanced matrix). G4 — `with` borrowed `select`'s usage template; give it its own parse.usage.with CTE template. Tests: new near_miss_matrix_advanced_mode (12 SQL-surface cases incl. the available-commands regression guard) + per-gap tests; removed the temporary baseline_dump. Full suite green (lib 1578 / it 386 / typing_surface_matrix 192); clippy clean.
432 lines
18 KiB
Rust
432 lines
18 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.
|
|
/// These are the cases triaged as already-good; the baseline dump
|
|
/// (`baseline_dump`, #[ignore]) shows the full rendering. Cases
|
|
/// flagged for wording fixes (bare `add` `1`, bare `select`
|
|
/// first-set, mode-blind usage) are deliberately NOT locked here
|
|
/// until their fixes land.
|
|
#[test]
|
|
fn near_miss_matrix_simple_mode() {
|
|
// (input, required-substrings-across-error-lines)
|
|
let matrix: &[(&str, &[&str])] = &[
|
|
// app-lifecycle arg errors
|
|
("quit now", &["after `quit`, expected end of input", " quit"]),
|
|
("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>"]),
|
|
("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()
|
|
}
|
|
|
|
#[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_templates_not_dsl() {
|
|
// 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 the DSL `create table
|
|
// … with pk …` template, which is not valid SQL. Mode-aware
|
|
// selection now shows the SQL forms.
|
|
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("with pk"),
|
|
"advanced `create` must NOT show the DSL `with pk` template\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 templates (G3)
|
|
("create", &["after `create`, expected `table`", "create table [if not exists]", "create [unique] index"]),
|
|
("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]"]),
|
|
("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:?}",
|
|
);
|
|
}
|