Files
rdbms-playground/tests/it/walking_skeleton.rs
T
claude@clouddev1 4aeea55984 feat(history): mode-tagged history + top-of-chain journaling (#30)
Record the submission mode per history entry so advanced commands are
reusable in simple mode, and fix the bug where a ':'-one-shot command
lost its ':' across sessions (ADR-0052, closing #30).

Format: the history.log status token gains an optional ':adv' suffix
(ok / ok:adv / err / err:adv); 'source' stays last and canonical, so
replay is unaffected. The in-memory ring (still Vec<String>) stores
advanced entries ': '-prefixed; recall strips the ':' in advanced mode
and keeps it in simple; hydration reconstructs the prefix from the tag.

Journaling moved from the worker to the dispatch layer (spawn_dsl_-
dispatch / run_replay / app-command sites), where the mode is in scope
with no worker plumbing; finalize_persistence writes only yaml/csv
(commit-db-last still atomic for state). The journal write is now
best-effort (command already committed), consistent with the failure
path. App commands journal simple, so they recall bare. Journaling is
now uniform (every successful command, per ADR-0034) — closing a gap
where show tables/relationships/explain didn't journal.

Amends ADR-0034 (status tag + journaling location), ADR-0015 §6
(history.log out of the worker tx), ADR-0040 (journal-write best-effort).
15 worker-level journaling tests retired, re-covered at the new layer
(history.rs format, app.rs recall matrix, iteration6 cross-session
regression, replay). 2471 pass / 0 fail / 0 skip, clippy clean.
2026-06-14 11:20:55 +00:00

696 lines
24 KiB
Rust

//! Tier 3 integration tests for the walking skeleton (per ADR-0008).
//!
//! These tests drive synthetic crossterm events through `App::update`
//! and assert on the resulting state and rendered buffer. They
//! exercise the full input → state → render path without a real
//! terminal, so they run on every commit and catch regressions in
//! the wiring between modules.
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use rdbms_playground::action::Action;
use rdbms_playground::app::{App, OutputKind};
use rdbms_playground::db::{
ColumnDescription, DataResult, InsertResult, RelationshipEnd, TableDescription,
};
use rdbms_playground::dsl::{ColumnSpec, Command, ReferentialAction, RowFilter, Type, Value};
use rdbms_playground::event::AppEvent;
use rdbms_playground::mode::Mode;
use rdbms_playground::theme::Theme;
use rdbms_playground::ui;
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) -> Vec<Action> {
let mut actions = Vec::new();
for c in s.chars() {
actions.extend(app.update(key(KeyCode::Char(c))));
}
actions
}
fn submit(app: &mut App) -> Vec<Action> {
app.update(key(KeyCode::Enter))
}
/// Assert that `actions` is exactly one `Action::ExecuteDsl`
/// whose parsed command equals `expected`. The original source
/// text carried alongside the command is allowed to be
/// anything — tests construct the expected `Command` directly
/// and don't care about the verbatim user input.
#[track_caller]
fn assert_one_execute_dsl(actions: &[Action], expected: &Command) {
assert_eq!(actions.len(), 1, "expected exactly one action; got {actions:?}");
match &actions[0] {
Action::ExecuteDsl { command, .. } => assert_eq!(command, expected),
other => panic!("expected ExecuteDsl, got {other:?}"),
}
}
fn rendered_text(app: &mut App, theme: &Theme, width: u16, height: u16) -> String {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("create terminal");
terminal
.draw(|f| ui::render(app, theme, f))
.expect("draw frame");
let buffer = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
out.push_str(buffer[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn typing_then_submitting_a_dsl_command_emits_execute_action() {
let mut app = App::new();
let theme = Theme::dark();
type_str(&mut app, "create table Customers with pk");
let pre_render = rendered_text(&mut app, &theme, 80, 24);
assert!(
pre_render.contains("create table Customers"),
"input field should display the typed text:\n{pre_render}"
);
let actions = submit(&mut app);
assert_one_execute_dsl(
&actions,
&Command::CreateTable {
name: "Customers".to_string(),
columns: vec![ColumnSpec::new("id".to_string(), Type::Serial)],
primary_key: vec!["id".to_string()],
},
);
assert!(app.input.is_empty(), "input buffer cleared on submit");
let post_render = rendered_text(&mut app, &theme, 80, 24);
assert!(
post_render.contains("running:"),
"output panel should show the running notice:\n{post_render}"
);
}
#[test]
fn typing_invalid_simple_input_shows_a_parse_error_not_an_echo() {
let mut app = App::new();
let theme = Theme::dark();
type_str(&mut app, "hello world");
let actions = submit(&mut app);
// The failed line journals `err` (ADR-0034) but does not echo
// or dispatch a command.
assert!(
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
"expected only a JournalFailure; got {actions:?}",
);
let rendered = rendered_text(&mut app, &theme, 80, 24);
assert!(
rendered.contains("parse error"),
"output panel should show the parse error:\n{rendered}"
);
}
#[test]
fn mode_switch_changes_label_and_subsequent_echoes() {
let mut app = App::new();
let theme = Theme::dark();
let initial = rendered_text(&mut app, &theme, 80, 24);
assert!(initial.contains("SIMPLE"));
assert!(!initial.contains("ADVANCED"));
type_str(&mut app, "mode advanced");
submit(&mut app);
assert_eq!(app.mode, Mode::Advanced);
let after_switch = rendered_text(&mut app, &theme, 80, 24);
assert!(after_switch.contains("ADVANCED"));
type_str(&mut app, "select 1");
submit(&mut app);
let last = app.output.back().expect("output present");
assert_eq!(last.mode_at_submission, Mode::Advanced);
assert_eq!(last.kind, OutputKind::Echo);
}
#[test]
fn colon_escape_in_simple_mode_is_one_shot() {
let mut app = App::new();
type_str(&mut app, ":select 1");
submit(&mut app);
assert_eq!(app.mode, Mode::Simple);
// The line ran under the one-shot effective Advanced mode
// (ADR-0030 §2): the `:` is stripped, the SQL grammar
// dispatches `select 1`, and the echoed line carries the
// submission's effective mode.
let echoed = app
.output
.iter()
.rfind(|l| l.kind == OutputKind::Echo)
.expect("echo output present");
assert_eq!(echoed.mode_at_submission, Mode::Advanced);
assert!(
echoed.text.contains("select 1") && !echoed.text.contains(":select"),
"echo carries the stripped input: {:?}",
echoed.text,
);
// Subsequent submission (unrecognised in simple mode) parse-errors,
// not echoes — confirming the mode reverted.
type_str(&mut app, "list things");
submit(&mut app);
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
assert_eq!(last.mode_at_submission, Mode::Simple);
}
#[test]
fn quit_command_returns_quit_action() {
let mut app = App::new();
type_str(&mut app, "quit");
let actions = submit(&mut app);
assert_eq!(actions, vec![Action::Quit]);
}
#[test]
fn rendering_works_at_minimum_useful_size() {
// Sanity check that the layout does not panic at small sizes.
let mut app = App::new();
let theme = Theme::dark();
let _ = rendered_text(&mut app, &theme, 40, 12);
}
#[test]
fn typing_colon_in_simple_mode_flips_prompt_to_advanced() {
let mut app = App::new();
let theme = Theme::dark();
// No `:` yet — prompt shows SIMPLE.
type_str(&mut app, "sel");
let before = rendered_text(&mut app, &theme, 80, 24);
assert!(before.contains("SIMPLE"));
assert!(!before.contains("Advanced:"));
// Reset and type `:` first — prompt should flip immediately.
app.input.clear();
type_str(&mut app, ":");
let after_colon = rendered_text(&mut app, &theme, 80, 24);
assert!(
after_colon.contains("Advanced:"),
"input panel should show 'Advanced:' once `:` is typed:\n{after_colon}"
);
assert!(!after_colon.contains("SIMPLE"));
// Backspace through both the auto-inserted space and the `:`
// itself reverts the prompt.
while !app.input.is_empty() {
app.update(key(KeyCode::Backspace));
}
let after_revert = rendered_text(&mut app, &theme, 80, 24);
assert!(after_revert.contains("SIMPLE"));
assert!(!after_revert.contains("Advanced:"));
}
#[test]
fn status_bar_is_keystroke_only_and_state_aware() {
// ADR-0051 (issue #27): the bottom strip is keystrokes-only and
// tracks the interaction state. Typed-command words (`:` advanced
// once, `mode advanced`/`mode simple` switch) and `Ctrl-C quit`
// leave the strip; mode discovery moves to the hint (locked by the
// ui.rs unit tests). This test exercises the real render path.
let mut app = App::new();
let theme = Theme::dark();
// Default (empty input): nav / complete / history / run keystrokes.
let default_view = rendered_text(&mut app, &theme, 80, 24);
assert!(default_view.contains("Ctrl-O sidebar"), "strip lists sidebar:\n{default_view}");
assert!(default_view.contains("Enter run"), "strip lists run:\n{default_view}");
assert!(!default_view.contains("Ctrl-C"), "quit dropped from the strip:\n{default_view}");
assert!(
!default_view.contains("advanced once"),
"`:` command word dropped from the strip:\n{default_view}",
);
// Editing (input has text): the #29 readline edit keys appear.
type_str(&mut app, "create");
let editing = rendered_text(&mut app, &theme, 80, 24);
assert!(editing.contains("Esc clear"), "editing strip lists clear:\n{editing}");
assert!(editing.contains("Ctrl-W del word"), "editing strip lists del word:\n{editing}");
}
// ---------------------------------------------------------------
// Full DSL flow tests.
//
// These tests simulate the runtime by feeding the AppEvent::Dsl*
// events that the runtime would post after dispatching a command
// to the database. That keeps these tests deterministic and runtime
// agnostic — the actual database is exercised in the db module's
// own #[tokio::test] suite.
// ---------------------------------------------------------------
fn fake_table(name: &str, columns: &[(&str, Type, bool)]) -> TableDescription {
TableDescription {
name: name.to_string(),
columns: columns
.iter()
.map(|(n, t, pk)| ColumnDescription {
name: (*n).to_string(),
user_type: Some(*t),
sqlite_type: t.sqlite_strict_type().to_string(),
notnull: false,
primary_key: *pk,
unique: false,
default: None,
check: None,
})
.collect(),
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
}
}
#[test]
fn create_table_flow_updates_tables_list_and_structure_view() {
let mut app = App::new();
let theme = Theme::dark();
// User types and submits.
type_str(&mut app, "create table Customers with pk");
let actions = submit(&mut app);
let expected_cmd = Command::CreateTable {
name: "Customers".to_string(),
columns: vec![ColumnSpec::new("id".to_string(), Type::Serial)],
primary_key: vec!["id".to_string()],
};
assert_one_execute_dsl(&actions, &expected_cmd);
// Runtime would now dispatch and feed back DslSucceeded + TablesRefreshed.
let desc = fake_table("Customers", &[("id", Type::Serial, true)]);
app.update(AppEvent::DslSucceeded {
command: expected_cmd,
description: Some(desc.clone()),
echo: None,
});
app.update(AppEvent::TablesRefreshed(vec!["Customers".to_string()]));
assert_eq!(app.tables, vec!["Customers".to_string()]);
assert_eq!(app.current_table, Some(desc));
// Width > 90 so the sidebar (items panel) is shown (ADR-0046 DB1).
let rendered = rendered_text(&mut app, &theme, 110, 24);
assert!(
rendered.contains("Customers"),
"items panel should list Customers:\n{rendered}"
);
// ADR-0040: success is the ✓ marker on the command's echo line
// (the `[ok] create table Customers` summary line was retired).
assert!(
rendered.contains("create table Customers with pk ✓"),
"the command echo should resolve to a success marker:\n{rendered}"
);
// The structure table renders one line per column; the
// `id` row shows both the name and its `serial` type
// separated by box-drawing characters.
assert!(
rendered.lines().any(|l| l.contains("id") && l.contains("serial")),
"output should show the id/serial column row:\n{rendered}"
);
}
#[test]
fn add_column_flow_updates_structure_view() {
let mut app = App::new();
// Simulate the prior create_table state.
app.tables = vec!["Customers".to_string()];
app.current_table = Some(fake_table(
"Customers",
&[("id", Type::Serial, true)],
));
type_str(&mut app, "add column to table Customers: Name (text)");
let actions = submit(&mut app);
assert_one_execute_dsl(
&actions,
&Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
not_null: false,
unique: false,
default: None,
check: None,
},
);
let updated = fake_table(
"Customers",
&[("id", Type::Serial, true), ("Name", Type::Text, false)],
);
app.update(AppEvent::DslSucceeded {
command: Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
not_null: false,
unique: false,
default: None,
check: None,
},
description: Some(updated.clone()),
echo: None,
});
assert_eq!(app.current_table, Some(updated));
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(
rendered.lines().any(|l| l.contains("Name") && l.contains("text")),
"expected the Name/text column row:\n{rendered}",
);
}
#[test]
fn drop_table_flow_clears_items_list() {
let mut app = App::new();
app.tables = vec!["Customers".to_string()];
app.current_table = Some(fake_table("Customers", &[("id", Type::Serial, true)]));
type_str(&mut app, "drop table Customers");
let actions = submit(&mut app);
assert_one_execute_dsl(
&actions,
&Command::DropTable {
name: "Customers".to_string(),
},
);
app.update(AppEvent::DslSucceeded {
command: Command::DropTable {
name: "Customers".to_string(),
},
description: None,
echo: None,
});
app.update(AppEvent::TablesRefreshed(Vec::new()));
assert!(app.tables.is_empty());
assert!(app.current_table.is_none());
// Width > 90 so the (now-empty) sidebar is shown (ADR-0046 DB1).
let rendered = rendered_text(&mut app, &Theme::dark(), 110, 24);
assert!(rendered.contains("(none yet)"));
// ADR-0040: `drop table` is content-less, so the echo's ✓ marker
// is the entire success signal (replacing `[ok] drop table …`).
assert!(
rendered.contains("drop table Customers ✓"),
"the drop echo should resolve to a success marker:\n{rendered}"
);
}
#[test]
fn add_relationship_flow_shows_parent_side_with_inbound_section() {
let mut app = App::new();
type_str(
&mut app,
"add 1:n relationship from Customers.Id to Orders.CustId on delete cascade",
);
let actions = submit(&mut app);
assert_one_execute_dsl(
&actions,
&Command::AddRelationship {
name: None,
parent_table: "Customers".to_string(),
parent_columns: vec!["Id".to_string()],
child_table: "Orders".to_string(),
child_columns: vec!["CustId".to_string()],
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
create_fk: false,
},
);
// The runtime now feeds back the parent (Customers) so the
// user sees the new relationship via the "Referenced by"
// section — same direction as the command's `from <Parent>`
// reading.
let customers = TableDescription {
name: "Customers".to_string(),
columns: vec![ColumnDescription {
name: "Id".to_string(),
user_type: Some(Type::Serial),
sqlite_type: "INTEGER".to_string(),
notnull: false,
primary_key: true,
unique: false,
default: None,
check: None,
}],
outbound_relationships: Vec::new(),
inbound_relationships: vec![RelationshipEnd {
name: "Customers_Id_to_Orders_CustId".to_string(),
other_table: "Orders".to_string(),
other_columns: vec!["CustId".to_string()],
local_columns: vec!["Id".to_string()],
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
}],
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
app.update(AppEvent::DslSucceeded {
command: Command::AddRelationship {
name: None,
parent_table: "Customers".to_string(),
parent_columns: vec!["Id".to_string()],
child_table: "Orders".to_string(),
child_columns: vec!["CustId".to_string()],
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
create_fk: false,
},
description: Some(customers),
echo: None,
});
// Tall viewport so the [ok] echo line stays visible above the
// (taller-than-prose) diagram for the endpoint-subject assertion.
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 40);
// ADR-0044: `add relationship` is relationship-relevant, so its echo
// renders the relationship as a compact diagram, not the prose block.
assert!(rendered.contains("Relationships"), "heading: {rendered}");
assert!(rendered.contains("Orders"), "neighbour box: {rendered}");
assert!(rendered.contains("CustId"), "FK column: {rendered}");
assert!(rendered.contains('▶'), "connector: {rendered}");
assert!(rendered.contains("on delete cascade"), "{rendered}");
// The [ok] subject lists the endpoints. Long lines wrap in
// the panel, so we check the first half of the phrase only.
assert!(
rendered.contains("from Customers.Id"),
"{rendered}"
);
}
#[test]
fn add_column_confirmation_omits_relationship_prose() {
// ADR-0050 (issue #28): an incidental-DDL confirmation echo (here
// `add column`) renders the structure only — never the
// `References:` / `Referenced by:` relationship block — even when
// the table carries relationships the user did not touch. The
// relationships remain one `show table` away.
let mut app = App::new();
let customers = TableDescription {
name: "Customers".to_string(),
columns: vec![ColumnDescription {
name: "Id".to_string(),
user_type: Some(Type::Serial),
sqlite_type: "INTEGER".to_string(),
notnull: false,
primary_key: true,
unique: false,
default: None,
check: None,
}],
outbound_relationships: Vec::new(),
inbound_relationships: vec![RelationshipEnd {
name: "Customers_Id_to_Orders_CustId".to_string(),
other_table: "Orders".to_string(),
other_columns: vec!["CustId".to_string()],
local_columns: vec!["Id".to_string()],
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
}],
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
app.update(AppEvent::DslSucceeded {
command: Command::AddColumn {
table: "Customers".to_string(),
column: "extra".to_string(),
ty: Type::Text,
not_null: false,
unique: false,
default: None,
check: None,
},
description: Some(customers),
echo: None,
});
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
// The structure box still renders (table name + the column box from
// the returned description).
assert!(rendered.contains("Customers"), "structure header:\n{rendered}");
assert!(rendered.contains("Constraints"), "structure box:\n{rendered}");
// The relationship block is gone — neither prose heading nor line.
assert!(!rendered.contains("Referenced by:"), "no prose heading:\n{rendered}");
assert!(!rendered.contains("References:"), "no prose heading:\n{rendered}");
assert!(
!rendered.contains("Orders.CustId → Id"),
"no prose line:\n{rendered}",
);
}
#[test]
fn insert_flow_emits_action_and_renders_data() {
let mut app = App::new();
type_str(&mut app, "insert into Customers values ('Alice')");
let actions = submit(&mut app);
assert_one_execute_dsl(
&actions,
&Command::Insert {
table: "Customers".to_string(),
columns: None,
values: vec![Value::Text("Alice".to_string())],
},
);
// Simulate the runtime feeding back an InsertResult.
let data = DataResult {
table_name: "Customers".to_string(),
columns: vec!["id".to_string(), "Name".to_string()],
column_types: vec![Some(Type::Serial), Some(Type::Text)],
rows: vec![vec![Some("1".to_string()), Some("Alice".to_string())]],
};
app.update(AppEvent::DslInsertSucceeded {
command: Command::Insert {
table: "Customers".to_string(),
columns: None,
values: vec![Value::Text("Alice".to_string())],
},
result: InsertResult {
rows_affected: 1,
data,
},
});
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(
rendered.contains("1 row(s) inserted"),
"should show row count:\n{rendered}"
);
assert!(
rendered.contains("Alice"),
"should auto-show new row:\n{rendered}"
);
assert!(
rendered.contains("id") && rendered.contains("Name"),
"should show column headers:\n{rendered}"
);
}
#[test]
fn delete_with_all_rows_emits_correct_action() {
let mut app = App::new();
type_str(&mut app, "delete from Customers --all-rows");
let actions = submit(&mut app);
assert_one_execute_dsl(
&actions,
&Command::Delete {
table: "Customers".to_string(),
filter: RowFilter::AllRows,
},
);
}
#[test]
fn show_data_for_empty_table_renders_placeholder() {
let mut app = App::new();
let data = DataResult {
table_name: "Customers".to_string(),
columns: vec!["id".to_string(), "Name".to_string()],
column_types: vec![Some(Type::Serial), Some(Type::Text)],
rows: Vec::new(),
};
app.update(AppEvent::DslDataSucceeded {
command: Command::ShowData {
name: "Customers".to_string(),
filter: None,
limit: None,
},
data,
echo: None,
});
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(rendered.contains("(no rows)"), "{rendered}");
}
#[test]
fn dsl_failure_shows_friendly_error_in_output() {
let mut app = App::new();
type_str(&mut app, "drop table Ghost");
submit(&mut app);
app.update(AppEvent::DslFailed {
command: Command::DropTable {
name: "Ghost".to_string(),
},
error: rdbms_playground::db::DbError::Sqlite {
message: "no such table: Ghost".to_string(),
kind: rdbms_playground::db::SqliteErrorKind::NoSuchTable,
},
facts: rdbms_playground::friendly::FailureContext::default(),
source: String::new(),
advanced: false,
});
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(
rendered.contains("Ghost"),
"error should mention the table:\n{rendered}"
);
assert!(
rendered.contains("no such table"),
"error should include the friendly message:\n{rendered}"
);
}
#[test]
fn validity_indicator_renders_err_and_wrn_labels() {
// ADR-0027 §4: the input row shows a `[ERR]` / `[WRN]`
// label at its right edge, or nothing when clean.
use rdbms_playground::dsl::walker::Severity;
let mut app = App::new();
let clean = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(!clean.contains("[ERR]"), "clean input shows no label:\n{clean}");
assert!(!clean.contains("[WRN]"), "clean input shows no label:\n{clean}");
app.input_indicator = Some(Severity::Error);
let err = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(err.contains("[ERR]"), "ERROR verdict shows [ERR]:\n{err}");
app.input_indicator = Some(Severity::Warning);
let wrn = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(wrn.contains("[WRN]"), "WARNING verdict shows [WRN]:\n{wrn}");
}