Files
rdbms-playground/tests/it/engine_vocabulary_audit.rs
T
claude@clouddev1 9efae59c3c test: consolidate 25 integration crates into one it binary
Each top-level tests/*.rs was its own crate → its own binary, each
statically linking the bundled engine + every dep. 26 of them, so an
edit to the lib relinked all 26. Moved the 25 standalone files into
tests/it/ under one tests/it/main.rs (the pattern typing_surface
already uses); cargo auto-detects it as the `it` target. End state: 2
integration-test binaries instead of 26.

Result: target/debug/deps 1.5 GB → 629 MB (-58%). Build time barely
moved (clean 22.9s→22.4s, lib-edit relink 13.3s→12.4s) — wall-clock is
dominated by compiling, not linking, so this is a disk win, not a speed
win (see docs/plans/20260602-test-consolidation.md). Tests unchanged at
2151/0/1; clippy clean; no fixups needed. typing_surface_matrix stays
its own already-consolidated binary.

Tradeoff: the 25 files now share one crate (a compile error fails the
whole `it` binary; module-scoped namespaces, no clashes) — negligible
for a solo project.
2026-06-02 22:13:03 +00:00

183 lines
5.7 KiB
Rust

//! 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());
}
}