Files
rdbms-playground/tests/replay_command.rs
T
claude@clouddev1 d5c7f63513 grammar+walker: 3j — shared insert/update/delete entry words (ADR-0033 §2 / Amendments 1 & 3)
Wire `insert`/`update`/`delete` as shared DSL/SQL entry words through the
category-grouped dispatcher (ADR-0033 Amendment 1): the Advanced SQL nodes
move off the dev words (`sqlinsert`/`sql_update`/`sql_delete`) to the real
keywords, registered alongside the Simple DSL nodes. Remove the dev-word
scaffold; collapse build_sql_{insert,update,delete} to source.trim();
de-duplicate the two REGISTRY entry-word listing sites.

Dispatch model (ADR-0033 Amendment 3, written this round):
- A command is the mode-rooted grammar-path outcome; identity is intrinsic.
  Advanced mode tries SQL first, falling back to the Simple DSL command when
  no SQL branch matches a token (`delete … --all-rows` falls back;
  `update … --all-rows` does not — the SET expression absorbs it, harmless
  since the engine treats `--all-rows` as a comment).
- Simple mode commits the DSL candidate for a shared word, surfacing the real
  DSL error; bare "this is SQL" is reserved for SQL-only entry words
  (`select`/`with`). A content rejection on the SQL candidate (internal
  table) is committed, never masked by the DSL fallback.

Combined DSL-error + advanced-SQL pointer (ADR-0033 Amendment 3): a Simple-mode
definite DSL error that would run as SQL in advanced mode gains the
`advanced_mode.also_valid_sql` suffix — in the live hint (ambient_hint_in_mode)
and on submit (dispatch_dsl), via the shared advanced_alternative_note — so the
actionable DSL fix and the mode pointer coexist (submit covers constructs that
surface only on submit, e.g. `delete … returning`).

Internal-table rejection symmetrised (/runda finding B, ADR-0030 §6): the DSL
data-command target slots (insert/update/delete/show data/show table) gained
reject_internal_table, so `__rdbms_*` tables are refused in Simple mode too —
previously only the advanced SQL grammar rejected them.

Mode-awareness: classify_input_with_schema_in_mode and
invalid_ident_at_cursor_in_mode stop leaking the advanced SQL view into
simple-mode hints for shared words.

Tests: dev-word inputs migrated to the real words (advanced); DSL grammar /
completion / phase-D / db tests parse in Simple mode (the DSL surface); replay
keeps its advanced-mode model (one stale assertion fixed); dispatcher routing,
combined-pointer, and internal-table tests added. Suite 1626 pass / 0 fail /
1 ignored; clippy --all-targets -D warnings clean.

Defer M4 (execution-time mode side-channel; tracked in requirements.md) to its
own ADR.
2026-05-23 21:13:39 +00:00

372 lines
12 KiB
Rust

//! Integration tests for the `replay <path>` command (U4).
//!
//! Exercises the runtime's `run_replay` directly rather than
//! booting a Tokio event loop — the inner replay logic is the
//! interesting unit, and `spawn_replay` is just the mpsc shim
//! around it.
//!
//! Covers (per handoff §A3):
//! - Happy path: 3-line file dispatches 3 commands, project
//! state reflects the dispatched DDL/DML.
//! - Blank lines and `# comments` are skipped silently.
//! - Per-line failure: the runtime reports the line number of
//! the offending entry and stops without dispatching the rest.
//! Earlier successful commands are NOT rolled back.
//! - Empty file → ReplayCompleted with count 0.
//! - Missing file → ReplayFailed with line_number 0.
//! - Nested replay (`replay foo` inside the file being replayed)
//! is refused with a clear message.
//! - history.log invariant: replaying a file produces the same
//! per-command history entries as if the user had typed each
//! line interactively.
use std::fs;
use std::path::Path;
use tokio::runtime::Runtime;
use rdbms_playground::db::Database;
use rdbms_playground::event::AppEvent;
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project;
use rdbms_playground::runtime::run_replay;
fn rt() -> Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio rt")
}
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
/// Open a fresh project + persistence-wired database under
/// `data_root`, returning both. Used as the canonical test
/// harness — most tests only need to write a script file and
/// call `run_replay`.
fn open_project_db(data_root: &Path) -> (project::Project, Database) {
let project = project::open_or_create(None, Some(data_root))
.expect("open_or_create");
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(project.path().to_path_buf()),
)
.expect("open db");
(project, db)
}
fn write_script(project_path: &Path, name: &str, body: &str) {
fs::write(project_path.join(name), body).expect("write script");
}
fn assert_completed(events: &[AppEvent], expected_count: usize) {
let last = events.last().expect("at least one event");
match last {
AppEvent::ReplayCompleted { count, .. } => {
assert_eq!(
*count, expected_count,
"ReplayCompleted count mismatch (events: {events:?})"
);
}
other => panic!("expected ReplayCompleted, got {other:?}"),
}
}
fn assert_failed_at(events: &[AppEvent], expected_line: usize) -> &AppEvent {
let last = events.last().expect("at least one event");
match last {
AppEvent::ReplayFailed { line_number, .. } => {
assert_eq!(
*line_number, expected_line,
"ReplayFailed line_number mismatch (events: {events:?})"
);
last
}
other => panic!("expected ReplayFailed, got {other:?}"),
}
}
#[test]
fn replay_three_lines_dispatches_three_commands() {
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
project.path(),
"seed.commands",
"create table T with pk id(int)\n\
add column T: name (text)\n\
insert into T (1, 'Alice')\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "seed.commands").await
});
assert_completed(&events, 3);
// The dispatched commands actually mutated state.
let data_result = rt()
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
.expect("query_data");
assert_eq!(data_result.rows.len(), 1, "row inserted");
assert_eq!(data_result.rows[0][1].as_deref(), Some("Alice"));
}
#[test]
fn replay_skips_blank_lines_and_comments() {
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
project.path(),
"seed.commands",
"# this is a comment\n\
\n\
create table T with pk id(int)\n\
\n\
# another comment\n\
# comment with leading whitespace\n\
add column T: name (text)\n\
\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "seed.commands").await
});
// Only two non-blank, non-comment lines.
assert_completed(&events, 2);
}
#[test]
fn replay_empty_file_completes_with_zero_commands() {
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(project.path(), "empty.commands", "");
let events = rt().block_on(async {
run_replay(&db, project.path(), "empty.commands").await
});
assert_completed(&events, 0);
}
#[test]
fn replay_only_comments_completes_with_zero_commands() {
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
project.path(),
"comments.commands",
"# just\n# comments\n\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "comments.commands").await
});
assert_completed(&events, 0);
}
#[test]
fn replay_missing_file_fails_with_line_number_zero() {
let data = tempdir();
let (project, db) = open_project_db(data.path());
let events = rt().block_on(async {
run_replay(&db, project.path(), "no-such-file.commands").await
});
let failed = assert_failed_at(&events, 0);
let AppEvent::ReplayFailed { error, .. } = failed else {
unreachable!()
};
assert!(
error.contains("could not open"),
"expected `could not open` in error: {error}"
);
}
#[test]
fn replay_aborts_on_first_parse_failure_and_reports_line() {
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
project.path(),
"bad.commands",
// Line 1: ok. Line 2: ok. Line 3: parse error
// (`broken keyword X` — not a recognised command).
"create table T with pk id(int)\n\
add column T: name (text)\n\
this is not a command\n\
insert into T (1, 'should not happen')\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "bad.commands").await
});
let failed = assert_failed_at(&events, 3);
let AppEvent::ReplayFailed { error, command, .. } = failed else {
unreachable!()
};
assert!(error.contains("parse error"), "got: {error}");
assert_eq!(command, "this is not a command");
// The failing line stops dispatch — no row was inserted —
// but earlier commands stayed applied (table T exists with
// the `name` column).
let desc = rt()
.block_on(async { db.describe_table("T".to_string(), None).await })
.expect("describe_table");
assert!(
desc.columns.iter().any(|c| c.name == "name"),
"earlier add column should have stayed applied"
);
let data_result = rt()
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
.expect("query_data");
assert!(
data_result.rows.is_empty(),
"post-failure insert should not have run"
);
}
#[test]
fn replay_rejects_wrong_type_value_in_a_hand_built_script() {
// Replay parses each line with the SAME schema-aware parser the
// interactive path uses, in **advanced mode** (the full surface),
// and executes the result — so a replayed line behaves exactly as
// if it had been typed interactively in advanced mode. Nothing is
// skipped or simplified during replay (handoff-13 §2.1: the schema
// is threaded so the parser is fully schema-aware).
//
// A real journal only ever contains commands that already executed
// successfully (history.log is success-only; ADR-0034's deferred
// journal replays `ok` lines only), so a wrong-type line like this
// never arises from a genuine replay. It only arises from a
// *hand-built* `.commands` script — the robustness case this test
// exercises: replay must reject the bad line and stop, leaving
// state intact, with the same error a user would see typing it.
//
// Where the rejection lands depends on the grammar the line
// matches, exactly as interactively: `insert into T values (…)` is
// SQL in advanced mode, and SQL defers column-type checking to the
// engine, so `'not a number'` in the int `count` column is rejected
// at **execute** time (the engine's column-type enforcement) rather
// than at parse time. Either way the line fails and is not applied.
// (Before sub-phase 3j, `insert` was a DSL-only entry word, so even
// advanced-mode parsing hit the DSL typed-slot rail and this was a
// parse-time rejection — ADR-0033 Amendment 3.)
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
project.path(),
"typed.commands",
"create table T with pk id(int)\n\
add column T: count (int)\n\
insert into T values (1, 'not a number')\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "typed.commands").await
});
let failed = assert_failed_at(&events, 3);
let AppEvent::ReplayFailed { error, .. } = failed else {
unreachable!()
};
assert!(
!error.is_empty(),
"the rejected line must carry a reported error",
);
// The earlier two lines stayed applied; the failing insert
// did not run — state is intact.
let data_result = rt()
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
.expect("query_data");
assert!(
data_result.rows.is_empty(),
"the rejected insert must not have dispatched",
);
}
#[test]
fn replay_aborts_on_first_runtime_failure_and_reports_line() {
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
project.path(),
"bad.commands",
// Line 2 references a table that doesn't exist; the
// engine refuses, replay stops and reports line 2.
"create table T with pk id(int)\n\
add column NotATable: x (text)\n\
insert into T (1)\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "bad.commands").await
});
let _ = assert_failed_at(&events, 2);
}
#[test]
fn replay_refuses_nested_replay() {
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(project.path(), "inner.commands", "create table T with pk id(int)\n");
write_script(
project.path(),
"outer.commands",
"create table U with pk id(int)\nreplay inner.commands\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "outer.commands").await
});
let failed = assert_failed_at(&events, 2);
let AppEvent::ReplayFailed { error, .. } = failed else {
unreachable!()
};
assert!(
error.contains("nested `replay`"),
"expected nested-replay refusal: {error}"
);
}
#[test]
fn replay_history_log_records_subcommands_only() {
// Per handoff §A3: replaying produces the same per-command
// history.log entries as if each line had been typed
// interactively. The replay invocation itself MUST NOT
// appear in history.log (otherwise `replay history.log`
// would re-trigger itself recursively).
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
project.path(),
"seed.commands",
"create table T with pk id(int)\nadd column T: name (text)\n",
);
let events = rt().block_on(async {
run_replay(&db, project.path(), "seed.commands").await
});
assert_completed(&events, 2);
let history = fs::read_to_string(project.path().join("history.log"))
.expect("history.log exists");
// Per-command entries landed.
assert!(
history.lines().any(|l| l.contains("create table T with pk id(int)")),
"history.log missing create line:\n{history}"
);
assert!(
history.lines().any(|l| l.contains("add column T: name (text)")),
"history.log missing add column line:\n{history}"
);
// The replay invocation itself did NOT land — that's
// the App layer's responsibility (Action::Replay never
// reaches the per-command persistence path).
assert!(
!history.lines().any(|l| l.contains("replay seed.commands")),
"history.log unexpectedly contains the replay invocation:\n{history}"
);
}