165068269b
DSL:
- add 1:n relationship [as <name>] from <P>.<col> to <C>.<col>
[on delete <action>] [on update <action>] [--create-fk]
- drop relationship <name> | from <P>.<col> to <C>.<col>
- show table <name> for re-displaying a structure on demand
Database (ADR-0013):
- Rebuild-table primitive following SQLite's
ALTER-via-rebuild recipe (foreign_keys=OFF outside tx,
copy-by-name, foreign_key_check before commit). Reusable for
B2 (column drops/renames/type changes).
- ReferentialAction enum (no action / restrict / set null /
cascade); SET DEFAULT awaits column DEFAULTs.
- __rdbms_playground_relationships metadata table -- names,
auto-generated as <Parent>_<pcol>_to_<Child>_<ccol>.
- Type::fk_target_type() validation at declaration; friendly
errors for type mismatch, non-PK target, missing column,
duplicate name.
- describe_table populates symmetric outbound + inbound
relationship lists. drop_table refuses while inbound
references exist; outbound metadata cleaned up alongside drop.
App / UI:
- In-line cursor editing in the input field: Left, Right,
Home, End, Delete, Backspace honoring UTF-8 boundaries.
- PageUp / PageDown scrolls the output buffer; viewport row
count fed back from the renderer via App::note_output_viewport
so scroll is capped against the actual visible area
(regression-tested) and snaps to the bottom on new output.
- Failure messages quote the command portion ("verb target"
failed: ...) for visual clarity; RelationshipSelector has a
proper Display impl so "no such relationship" reads cleanly.
- Structure rendering shows References / Referenced by sections.
Docs:
- ADR-0013 covers naming, metadata table, symmetric view, and
the rebuild-table strategy.
- requirements.md updates: C3 (FK done), B2 (primitive in),
T3 (compound-PK FK still pending). New entries: I1a (cursor
editing -- landed), I1b (Ctrl-A/E and readline shortcuts --
pending), V4 partial scroll, V5 (show family), C3a (modify
relationship -- deferred).
Tests: 154 passing (140 lib + 14 integration), 0 skipped.
Clippy clean with nursery enabled.
487 lines
16 KiB
Rust
487 lines
16 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, RelationshipEnd, TableDescription};
|
|
use rdbms_playground::dsl::{ColumnSpec, Command, ReferentialAction, Type};
|
|
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))
|
|
}
|
|
|
|
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_eq!(
|
|
actions,
|
|
vec![Action::ExecuteDsl(Command::CreateTable {
|
|
name: "Customers".to_string(),
|
|
columns: vec![ColumnSpec {
|
|
name: "id".to_string(),
|
|
ty: 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);
|
|
assert!(actions.is_empty());
|
|
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);
|
|
// Advanced mode currently echoes (SQL handling lands later);
|
|
// the echoed line should carry the advanced submission 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_eq!(echoed.text, "select 1");
|
|
|
|
// 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_lists_quit_and_submit_in_all_modes() {
|
|
let mut app = App::new();
|
|
let theme = Theme::dark();
|
|
|
|
let simple = rendered_text(&mut app, &theme, 80, 24);
|
|
assert!(simple.contains("Enter"), "status bar lists Enter");
|
|
assert!(simple.contains("Ctrl-C"), "status bar lists Ctrl-C");
|
|
assert!(simple.contains("mode advanced"));
|
|
|
|
type_str(&mut app, "mode advanced");
|
|
submit(&mut app);
|
|
let advanced = rendered_text(&mut app, &theme, 80, 24);
|
|
assert!(advanced.contains("Enter"));
|
|
assert!(advanced.contains("Ctrl-C"));
|
|
assert!(advanced.contains("mode simple"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// 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,
|
|
})
|
|
.collect(),
|
|
outbound_relationships: Vec::new(),
|
|
inbound_relationships: 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 {
|
|
name: "id".to_string(),
|
|
ty: Type::Serial,
|
|
}],
|
|
primary_key: vec!["id".to_string()],
|
|
};
|
|
assert_eq!(actions, vec![Action::ExecuteDsl(expected_cmd.clone())]);
|
|
|
|
// 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()),
|
|
});
|
|
app.update(AppEvent::TablesRefreshed(vec!["Customers".to_string()]));
|
|
|
|
assert_eq!(app.tables, vec!["Customers".to_string()]);
|
|
assert_eq!(app.current_table, Some(desc));
|
|
|
|
let rendered = rendered_text(&mut app, &theme, 80, 24);
|
|
assert!(
|
|
rendered.contains("Customers"),
|
|
"items panel should list Customers:\n{rendered}"
|
|
);
|
|
assert!(
|
|
rendered.contains("[ok] create table Customers"),
|
|
"output should confirm success:\n{rendered}"
|
|
);
|
|
assert!(
|
|
rendered.contains("id serial"),
|
|
"output should show the structure with the user-facing type:\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_eq!(
|
|
actions,
|
|
vec![Action::ExecuteDsl(Command::AddColumn {
|
|
table: "Customers".to_string(),
|
|
column: "Name".to_string(),
|
|
ty: Type::Text,
|
|
})]
|
|
);
|
|
|
|
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,
|
|
},
|
|
description: Some(updated.clone()),
|
|
});
|
|
assert_eq!(app.current_table, Some(updated));
|
|
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
|
assert!(rendered.contains("Name text"));
|
|
}
|
|
|
|
#[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_eq!(
|
|
actions,
|
|
vec![Action::ExecuteDsl(Command::DropTable {
|
|
name: "Customers".to_string()
|
|
})]
|
|
);
|
|
|
|
app.update(AppEvent::DslSucceeded {
|
|
command: Command::DropTable {
|
|
name: "Customers".to_string(),
|
|
},
|
|
description: None,
|
|
});
|
|
app.update(AppEvent::TablesRefreshed(Vec::new()));
|
|
|
|
assert!(app.tables.is_empty());
|
|
assert!(app.current_table.is_none());
|
|
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
|
assert!(rendered.contains("(none yet)"));
|
|
assert!(rendered.contains("[ok] drop table Customers"));
|
|
}
|
|
|
|
#[test]
|
|
fn add_relationship_flow_shows_outbound_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_eq!(
|
|
actions,
|
|
vec![Action::ExecuteDsl(Command::AddRelationship {
|
|
name: None,
|
|
parent_table: "Customers".to_string(),
|
|
parent_column: "Id".to_string(),
|
|
child_table: "Orders".to_string(),
|
|
child_column: "CustId".to_string(),
|
|
on_delete: ReferentialAction::Cascade,
|
|
on_update: ReferentialAction::NoAction,
|
|
create_fk: false,
|
|
})]
|
|
);
|
|
|
|
// Simulate the runtime feeding back a description with the
|
|
// outbound relationship populated.
|
|
let orders = TableDescription {
|
|
name: "Orders".to_string(),
|
|
columns: vec![
|
|
ColumnDescription {
|
|
name: "id".to_string(),
|
|
user_type: Some(Type::Serial),
|
|
sqlite_type: "INTEGER".to_string(),
|
|
notnull: false,
|
|
primary_key: true,
|
|
},
|
|
ColumnDescription {
|
|
name: "CustId".to_string(),
|
|
user_type: Some(Type::Int),
|
|
sqlite_type: "INTEGER".to_string(),
|
|
notnull: false,
|
|
primary_key: false,
|
|
},
|
|
],
|
|
outbound_relationships: vec![RelationshipEnd {
|
|
name: "Customers_Id_to_Orders_CustId".to_string(),
|
|
other_table: "Customers".to_string(),
|
|
other_column: "Id".to_string(),
|
|
local_column: "CustId".to_string(),
|
|
on_delete: ReferentialAction::Cascade,
|
|
on_update: ReferentialAction::NoAction,
|
|
}],
|
|
inbound_relationships: Vec::new(),
|
|
};
|
|
app.update(AppEvent::DslSucceeded {
|
|
command: Command::AddRelationship {
|
|
name: None,
|
|
parent_table: "Customers".to_string(),
|
|
parent_column: "Id".to_string(),
|
|
child_table: "Orders".to_string(),
|
|
child_column: "CustId".to_string(),
|
|
on_delete: ReferentialAction::Cascade,
|
|
on_update: ReferentialAction::NoAction,
|
|
create_fk: false,
|
|
},
|
|
description: Some(orders),
|
|
});
|
|
|
|
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
|
assert!(rendered.contains("References:"), "{rendered}");
|
|
assert!(rendered.contains("CustId → Customers.Id"), "{rendered}");
|
|
assert!(rendered.contains("on delete cascade"), "{rendered}");
|
|
}
|
|
|
|
#[test]
|
|
fn add_relationship_flow_shows_inbound_section_on_parent() {
|
|
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,
|
|
}],
|
|
outbound_relationships: Vec::new(),
|
|
inbound_relationships: vec![RelationshipEnd {
|
|
name: "Customers_Id_to_Orders_CustId".to_string(),
|
|
other_table: "Orders".to_string(),
|
|
other_column: "CustId".to_string(),
|
|
local_column: "Id".to_string(),
|
|
on_delete: ReferentialAction::Cascade,
|
|
on_update: ReferentialAction::NoAction,
|
|
}],
|
|
};
|
|
app.update(AppEvent::DslSucceeded {
|
|
command: Command::AddColumn {
|
|
table: "Customers".to_string(),
|
|
column: "extra".to_string(),
|
|
ty: Type::Text,
|
|
},
|
|
description: Some(customers),
|
|
});
|
|
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
|
assert!(rendered.contains("Referenced by:"), "{rendered}");
|
|
assert!(rendered.contains("Orders.CustId → Id"), "{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: "no such table: Ghost".to_string(),
|
|
});
|
|
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}"
|
|
);
|
|
}
|