tests: ADR-0002 engine-vocabulary audit (A2)
Verifies the user-facing posture in ADR-0002 §"User-facing posture": no occurrence of SQLite, sqlite, rusqlite, STRICT, or PRAGMA may appear in any user-reachable string. The audit's mechanical sweep of `src/` confirmed the codebase already conforms — every appearance of those tokens is in either: - code comments / module-level docstrings (allowed by ADR-0002 explicitly), - DDL strings sent to the engine (not displayed to the user), - internal field/function names like `sqlite_type` / `sqlite_strict_type` (code identifiers, not user-visible). The previous session removed the last known leak in `do_add_column`. To stop a future change from quietly re-introducing one, this commit adds a regression test file covering a representative set of user surfaces: - `cli::HELP_TEXT` (`--help` banner). - The in-app `help` command output. - DSL parse errors for a battery of failing inputs (column-name-first typo, unknown type token, mutually exclusive flags, missing clause, garbage). - `DbError::friendly_message()` for realistic Sqlite, Unsupported, and InvalidValue payloads — the surface the runtime forwards via `AppEvent::DslFailed`. The forbidden-token list lives in one place (`engine_vocabulary_audit.rs::FORBIDDEN`) so future audits can extend it. Failure messages name the leaking token and its byte offset so a regressing edit pinpoints itself. Out of scope (and called out in the handoff for separate work): the H1 friendly-error layer that translates the remaining engine error wording into pedagogical English. That needs its own ADR. The local `friendly_change_column_engine_error` wrapper (db.rs §2354) is the prototype. 537 -> 541 passing (4 new), clippy clean.
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
//! ADR-0002 user-facing posture: regression audit.
|
||||
//!
|
||||
//! ADR-0002's "User-facing posture" section commits to never
|
||||
//! exposing the underlying engine's name in user-visible
|
||||
//! strings. The chosen product (and its idioms — STRICT,
|
||||
//! PRAGMA, the rusqlite crate) is an implementation detail;
|
||||
//! students should leave with knowledge of relational concepts,
|
||||
//! not of one specific RDBMS.
|
||||
//!
|
||||
//! This test file exists so that a future change can't silently
|
||||
//! regress that posture. The strings asserted here are a
|
||||
//! representative cross-section of user-reachable surfaces:
|
||||
//!
|
||||
//! - CLI usage banner (`HELP_TEXT`).
|
||||
//! - In-app `help` output (`note_help`).
|
||||
//! - DSL parse-error wording.
|
||||
//! - Realistic `DbError` payloads carried via
|
||||
//! `friendly_message()` (the surface the runtime forwards to
|
||||
//! `AppEvent::DslFailed`).
|
||||
//!
|
||||
//! See ADR-0002 §"User-facing posture" for the contract.
|
||||
//! Code comments and ADR prose are explicitly allowed to name
|
||||
//! the engine — only user-facing strings are policed.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
|
||||
use rdbms_playground::app::App;
|
||||
use rdbms_playground::cli::HELP_TEXT;
|
||||
use rdbms_playground::db::{DbError, SqliteErrorKind};
|
||||
use rdbms_playground::dsl::parse_command;
|
||||
use rdbms_playground::event::AppEvent;
|
||||
|
||||
const FORBIDDEN: &[&str] = &[
|
||||
// Product names.
|
||||
"SQLite", "sqlite",
|
||||
// Crate name.
|
||||
"rusqlite",
|
||||
// Engine-specific keywords / idioms.
|
||||
"STRICT", "PRAGMA",
|
||||
];
|
||||
|
||||
/// Report the first forbidden token found in `s`, with byte
|
||||
/// offset, so failure output points at exactly what leaked.
|
||||
fn engine_vocab_leak(s: &str) -> Option<(&'static str, usize)> {
|
||||
for needle in FORBIDDEN {
|
||||
if let Some(pos) = s.find(needle) {
|
||||
return Some((needle, pos));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn assert_clean(label: &str, s: &str) {
|
||||
if let Some((needle, pos)) = engine_vocab_leak(s) {
|
||||
panic!(
|
||||
"ADR-0002 leak in {label}: found `{needle}` at byte {pos} in:\n{s}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
app.update(key(KeyCode::Enter));
|
||||
}
|
||||
|
||||
fn collect_output(app: &App) -> String {
|
||||
app.output
|
||||
.iter()
|
||||
.map(|l| l.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_help_text_uses_no_engine_vocabulary() {
|
||||
assert_clean("CLI HELP_TEXT", HELP_TEXT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_app_help_uses_no_engine_vocabulary() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "help");
|
||||
submit(&mut app);
|
||||
assert_clean("in-app help", &collect_output(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_errors_use_no_engine_vocabulary() {
|
||||
// A representative set of failing inputs: structural
|
||||
// (missing colon, wrong keyword), unknown type, and the
|
||||
// change-column flag conflict. All must produce
|
||||
// engine-free messages.
|
||||
let inputs: &[&str] = &[
|
||||
// structural: column-name-first typo (the parser
|
||||
// tiny-win recipe from handoff-5).
|
||||
"change column Tag in Customers: Tag (text)",
|
||||
// unknown type token.
|
||||
"create table T with pk id:varchar",
|
||||
// mutually exclusive flags on change column.
|
||||
"change column T: c (int) --force-conversion --dont-convert",
|
||||
// missing required clause.
|
||||
"create table T",
|
||||
// garbage.
|
||||
"this is not a command",
|
||||
];
|
||||
for input in inputs {
|
||||
let err = parse_command(input)
|
||||
.expect_err(&format!("expected parse failure for `{input}`"));
|
||||
let rendered = format!("{err:?}");
|
||||
assert_clean(&format!("parse error for `{input}`"), &rendered);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_error_friendly_message_uses_no_engine_vocabulary() {
|
||||
// A representative set of `DbError` payloads, mirroring the
|
||||
// shapes the runtime actually surfaces via
|
||||
// `AppEvent::DslFailed { error: DbError::friendly_message }`.
|
||||
// These cover the three code-constructed variants: Sqlite
|
||||
// (engine-classified, message comes from rusqlite or our own
|
||||
// hand-rolled "no such ..."), Unsupported (refusals), and
|
||||
// InvalidValue (input validation).
|
||||
let cases: Vec<(&str, DbError)> = vec![
|
||||
(
|
||||
"no-such-table",
|
||||
DbError::Sqlite {
|
||||
message: "no such table: Customers".to_string(),
|
||||
kind: SqliteErrorKind::NoSuchTable,
|
||||
},
|
||||
),
|
||||
(
|
||||
"no-such-column",
|
||||
DbError::Sqlite {
|
||||
message: "no such column: Customers.zip".to_string(),
|
||||
kind: SqliteErrorKind::NoSuchColumn,
|
||||
},
|
||||
),
|
||||
(
|
||||
"unique-violation",
|
||||
DbError::Sqlite {
|
||||
message: "UNIQUE constraint failed: T.id".to_string(),
|
||||
kind: SqliteErrorKind::UniqueViolation,
|
||||
},
|
||||
),
|
||||
(
|
||||
"fk-violation",
|
||||
DbError::Sqlite {
|
||||
message: "FOREIGN KEY constraint failed".to_string(),
|
||||
kind: SqliteErrorKind::Other,
|
||||
},
|
||||
),
|
||||
(
|
||||
"unsupported-refusal",
|
||||
DbError::Unsupported(
|
||||
"cannot drop primary-key column `T.id`. \
|
||||
Drop the table or change the primary key first."
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
(
|
||||
"invalid-value",
|
||||
DbError::InvalidValue("expected 3 value(s), got 2".to_string()),
|
||||
),
|
||||
];
|
||||
for (label, err) in cases {
|
||||
assert_clean(label, &err.friendly_message());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user