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.
This commit is contained in:
@@ -0,0 +1,663 @@
|
||||
//! 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_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,
|
||||
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));
|
||||
|
||||
let rendered = rendered_text(&mut app, &theme, 80, 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());
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 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_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,
|
||||
},
|
||||
);
|
||||
|
||||
// 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_column: "CustId".to_string(),
|
||||
local_column: "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_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(customers),
|
||||
echo: None,
|
||||
});
|
||||
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
assert!(rendered.contains("Referenced by:"), "{rendered}");
|
||||
assert!(rendered.contains("Orders.CustId"), "{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_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,
|
||||
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_column: "CustId".to_string(),
|
||||
local_column: "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);
|
||||
assert!(rendered.contains("Referenced by:"), "{rendered}");
|
||||
assert!(rendered.contains("Orders.CustId → Id"), "{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(),
|
||||
});
|
||||
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}");
|
||||
}
|
||||
Reference in New Issue
Block a user