e8fa859ab9
ADR-0052 moved success journaling out of the worker to the dispatch layer, leaving the `source` that handlers threaded purely for the worker's old history.log write dead. Remove it: - drop `_source` from finalize_persistence and do_rebuild_from_text - inline + delete the three read-only *_request wrappers - drop the now-unused `source` param from the ~30 forwarding worker handlers (leaf + composite), compiler-guided - remove the `source` field from the DescribeTable/QueryData/RunSelect requests and their DatabaseHandle methods (call sites updated) The only worker `source` left is the snapshot/undo label (snapshot_then / stage_pre_mutation / begin_batch). Purely mechanical, no behaviour change. 2471 pass / 0 fail / 1 ignored, clippy clean.
492 lines
15 KiB
Rust
492 lines
15 KiB
Rust
//! Integration tests for the `show <kind>` list commands (V5):
|
|
//! `show tables`, `show relationships`, `show indexes`.
|
|
//!
|
|
//! Covers:
|
|
//! - Parse layer: each form parses to `Command::ShowList { kind }`
|
|
//! in both simple and advanced mode (the forms are
|
|
//! `CommandCategory::Simple`, available in both).
|
|
//! - Worker round-trip: `Database::show_list` returns the correct
|
|
//! pre-formatted lines after real DDL (tables, a relationship,
|
|
//! an index), and the empty-collection wording.
|
|
//! - App end-to-end: submitting `show tables` reaches the output
|
|
//! panel as system lines and marks the echo complete.
|
|
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
|
|
|
use rdbms_playground::action::Action;
|
|
use rdbms_playground::app::App;
|
|
use rdbms_playground::db::Database;
|
|
use rdbms_playground::dsl::{
|
|
parse_command, ColumnSpec, Command, ReferentialAction, ShowListKind, Type,
|
|
};
|
|
use rdbms_playground::event::AppEvent;
|
|
use rdbms_playground::mode::Mode;
|
|
use rdbms_playground::persistence::Persistence;
|
|
use rdbms_playground::project;
|
|
|
|
// =================================================================
|
|
// Parse layer
|
|
// =================================================================
|
|
|
|
#[test]
|
|
fn show_tables_parses_as_show_list_tables() {
|
|
assert_eq!(
|
|
parse_command("show tables").expect("parses"),
|
|
Command::ShowList {
|
|
kind: ShowListKind::Tables,
|
|
name: None,
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn show_relationships_parses_as_show_list_relationships() {
|
|
assert_eq!(
|
|
parse_command("show relationships").expect("parses"),
|
|
Command::ShowList {
|
|
kind: ShowListKind::Relationships,
|
|
name: None,
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn show_indexes_parses_as_show_list_indexes() {
|
|
assert_eq!(
|
|
parse_command("show indexes").expect("parses"),
|
|
Command::ShowList {
|
|
kind: ShowListKind::Indexes,
|
|
name: None,
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn show_relationship_singular_parses_with_name() {
|
|
assert_eq!(
|
|
parse_command("show relationship orders_customer").expect("parses"),
|
|
Command::ShowList {
|
|
kind: ShowListKind::Relationships,
|
|
name: Some("orders_customer".to_string()),
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn show_index_singular_parses_with_name() {
|
|
assert_eq!(
|
|
parse_command("show index idx_orders_customer").expect("parses"),
|
|
Command::ShowList {
|
|
kind: ShowListKind::Indexes,
|
|
name: Some("idx_orders_customer".to_string()),
|
|
},
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn show_table_singular_still_parses_as_show_table() {
|
|
// The new plural keyword must not shadow the singular
|
|
// `show table <name>` form — `table` ≠ `tables`.
|
|
assert_eq!(
|
|
parse_command("show table Customers").expect("parses"),
|
|
Command::ShowTable {
|
|
name: "Customers".to_string()
|
|
},
|
|
);
|
|
}
|
|
|
|
// =================================================================
|
|
// Worker round-trip — real execution
|
|
// =================================================================
|
|
|
|
fn rt() -> tokio::runtime::Runtime {
|
|
tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.expect("tokio rt")
|
|
}
|
|
|
|
fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
|
|
let dir = tempfile::tempdir().expect("create tempdir");
|
|
let project =
|
|
project::open_or_create(None, Some(dir.path())).expect("open or create project");
|
|
let persistence = Persistence::new(project.path().to_path_buf());
|
|
let db = Database::open_with_persistence(project.db_path(), persistence)
|
|
.expect("open db with persistence");
|
|
(project, db, dir)
|
|
}
|
|
|
|
/// Create two related tables plus an index, so each list kind has
|
|
/// content. Returns once the worker has applied everything.
|
|
async fn seed_schema(db: &Database) {
|
|
db.create_table(
|
|
"Customers".to_string(),
|
|
vec![
|
|
ColumnSpec::new("id", Type::Serial),
|
|
ColumnSpec::new("Name", Type::Text),
|
|
],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.expect("create Customers");
|
|
db.create_table(
|
|
"Orders".to_string(),
|
|
vec![
|
|
ColumnSpec::new("id", Type::Serial),
|
|
ColumnSpec::new("customer_id", Type::Int),
|
|
],
|
|
vec!["id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.expect("create Orders");
|
|
db.add_relationship(
|
|
Some("orders_customer".to_string()),
|
|
"Customers".to_string(),
|
|
vec!["id".to_string()],
|
|
"Orders".to_string(),
|
|
vec!["customer_id".to_string()],
|
|
ReferentialAction::Cascade,
|
|
ReferentialAction::NoAction,
|
|
false,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("add relationship");
|
|
db.add_index(
|
|
Some("idx_orders_customer".to_string()),
|
|
"Orders".to_string(),
|
|
vec!["customer_id".to_string()],
|
|
None,
|
|
)
|
|
.await
|
|
.expect("add index");
|
|
}
|
|
|
|
#[test]
|
|
fn show_tables_lists_all_user_tables_with_count_header() {
|
|
let (_p, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
rt.block_on(seed_schema(&db));
|
|
let lines = rt
|
|
.block_on(db.show_list(ShowListKind::Tables, None))
|
|
.expect("show tables");
|
|
assert_eq!(lines[0], "Tables (2):", "count header");
|
|
assert!(
|
|
lines.iter().any(|l| l == " Customers"),
|
|
"Customers listed: {lines:?}",
|
|
);
|
|
assert!(
|
|
lines.iter().any(|l| l == " Orders"),
|
|
"Orders listed: {lines:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn show_relationships_lists_name_endpoints_and_nondefault_action() {
|
|
let (_p, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
rt.block_on(seed_schema(&db));
|
|
let lines = rt
|
|
.block_on(db.show_list(ShowListKind::Relationships, None))
|
|
.expect("show relationships");
|
|
assert_eq!(lines[0], "Relationships (1):");
|
|
// Name, both endpoints, and the non-default ON DELETE CASCADE
|
|
// (ON UPDATE NO ACTION is the default and is omitted).
|
|
assert_eq!(
|
|
lines[1],
|
|
" orders_customer: Customers.id → Orders.customer_id on delete cascade",
|
|
"relationship summary line: {lines:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn show_indexes_lists_qualified_name_and_columns() {
|
|
let (_p, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
rt.block_on(seed_schema(&db));
|
|
let lines = rt
|
|
.block_on(db.show_list(ShowListKind::Indexes, None))
|
|
.expect("show indexes");
|
|
assert_eq!(lines[0], "Indexes (1):");
|
|
assert_eq!(
|
|
lines[1], " Orders.idx_orders_customer (customer_id)",
|
|
"index summary line: {lines:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn show_lists_report_empty_collections_with_friendly_lines() {
|
|
let (_p, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
// No schema seeded — every kind is empty.
|
|
assert_eq!(
|
|
rt.block_on(db.show_list(ShowListKind::Tables, None)).unwrap(),
|
|
vec!["No tables in this project yet.".to_string()],
|
|
);
|
|
assert_eq!(
|
|
rt.block_on(db.show_list(ShowListKind::Relationships, None))
|
|
.unwrap(),
|
|
vec!["No relationships in this project yet.".to_string()],
|
|
);
|
|
assert_eq!(
|
|
rt.block_on(db.show_list(ShowListKind::Indexes, None)).unwrap(),
|
|
vec!["No indexes in this project yet.".to_string()],
|
|
);
|
|
}
|
|
|
|
// =================================================================
|
|
// V5a — singular per-item detail
|
|
// =================================================================
|
|
|
|
#[test]
|
|
fn show_one_relationship_renders_detail_block() {
|
|
let (_p, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
rt.block_on(seed_schema(&db));
|
|
let lines = rt
|
|
.block_on(db.show_list(ShowListKind::Relationships, Some("orders_customer".to_string())))
|
|
.expect("show relationship");
|
|
assert_eq!(lines[0], "Relationship `orders_customer`:");
|
|
assert_eq!(lines[1], " Customers.id → Orders.customer_id");
|
|
assert!(
|
|
lines.iter().any(|l| l == " on delete cascade"),
|
|
"on-delete shown: {lines:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn show_one_index_renders_detail_block() {
|
|
let (_p, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
rt.block_on(seed_schema(&db));
|
|
let lines = rt
|
|
.block_on(db.show_list(ShowListKind::Indexes, Some("idx_orders_customer".to_string())))
|
|
.expect("show index");
|
|
assert_eq!(lines[0], "Index `idx_orders_customer` on Orders:");
|
|
assert!(
|
|
lines.iter().any(|l| l == " columns: customer_id"),
|
|
"columns shown: {lines:?}",
|
|
);
|
|
assert!(
|
|
lines.iter().any(|l| l == " unique: no"),
|
|
"uniqueness shown: {lines:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn show_one_unknown_name_reports_friendly_not_found() {
|
|
let (_p, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
rt.block_on(seed_schema(&db));
|
|
assert_eq!(
|
|
rt.block_on(db.show_list(ShowListKind::Relationships, Some("nope".to_string())))
|
|
.unwrap(),
|
|
vec!["No relationship named `nope`.".to_string()],
|
|
);
|
|
assert_eq!(
|
|
rt.block_on(db.show_list(ShowListKind::Indexes, Some("nope".to_string())))
|
|
.unwrap(),
|
|
vec!["No index named `nope`.".to_string()],
|
|
);
|
|
}
|
|
|
|
// =================================================================
|
|
// App end-to-end
|
|
// =================================================================
|
|
|
|
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)));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn app_show_tables_dispatches_show_list_command() {
|
|
let mut app = App::new();
|
|
app.mode = Mode::Simple;
|
|
type_str(&mut app, "show tables");
|
|
let actions = app.update(key(KeyCode::Enter));
|
|
let dispatched = actions.iter().any(|a| {
|
|
matches!(
|
|
a,
|
|
Action::ExecuteDsl {
|
|
command: Command::ShowList {
|
|
kind: ShowListKind::Tables,
|
|
name: None,
|
|
},
|
|
..
|
|
}
|
|
)
|
|
});
|
|
assert!(dispatched, "submit dispatches ShowList(Tables): {actions:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn app_renders_show_list_lines_as_system_output() {
|
|
// Feed the success event directly so the test stays
|
|
// self-contained (the worker round-trip is covered above).
|
|
let mut app = App::new();
|
|
app.output.push_back(rdbms_playground::app::OutputLine::echo(
|
|
"show tables",
|
|
Mode::Simple,
|
|
));
|
|
app.update(AppEvent::DslShowListSucceeded {
|
|
command: Command::ShowList {
|
|
kind: ShowListKind::Tables,
|
|
name: None,
|
|
},
|
|
lines: vec!["Tables (1):".to_string(), " Customers".to_string()],
|
|
});
|
|
assert!(
|
|
app.output.iter().any(|l| l.text == "Tables (1):"),
|
|
"header line rendered",
|
|
);
|
|
assert!(
|
|
app.output.iter().any(|l| l.text == " Customers"),
|
|
"item line rendered",
|
|
);
|
|
}
|
|
|
|
// =================================================================
|
|
// ADR-0044 — `show relationship <name>` renders a diagram
|
|
// =================================================================
|
|
|
|
#[test]
|
|
fn show_relationship_worker_returns_structured_diagram_data() {
|
|
let (_p, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
rt.block_on(seed_schema(&db));
|
|
let data = rt
|
|
.block_on(db.show_relationship("orders_customer".to_string()))
|
|
.expect("show_relationship ok")
|
|
.expect("relationship found");
|
|
assert_eq!(data.rel.name, "orders_customer");
|
|
// child = FK holder, parent = referenced (ADR-0044 left/right).
|
|
assert_eq!(data.child.name, "Orders");
|
|
assert_eq!(data.parent.name, "Customers");
|
|
assert_eq!(data.rel.child_columns, vec!["customer_id".to_string()]);
|
|
assert_eq!(data.rel.parent_columns, vec!["id".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn show_relationship_worker_returns_none_for_unknown_name() {
|
|
let (_p, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
rt.block_on(seed_schema(&db));
|
|
assert!(
|
|
rt.block_on(db.show_relationship("nope".to_string()))
|
|
.expect("ok")
|
|
.is_none(),
|
|
"unknown relationship → None",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn app_renders_show_relationship_as_a_styled_diagram() {
|
|
let (_p, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
rt.block_on(seed_schema(&db));
|
|
let data = rt
|
|
.block_on(db.show_relationship("orders_customer".to_string()))
|
|
.expect("ok")
|
|
.expect("found");
|
|
|
|
let mut app = App::new();
|
|
app.output.push_back(rdbms_playground::app::OutputLine::echo(
|
|
"show relationship orders_customer",
|
|
Mode::Simple,
|
|
));
|
|
app.update(AppEvent::DslShowRelationshipSucceeded {
|
|
command: Command::ShowList {
|
|
kind: ShowListKind::Relationships,
|
|
name: Some("orders_customer".to_string()),
|
|
},
|
|
data: Some(data),
|
|
});
|
|
let text: String = app
|
|
.output
|
|
.iter()
|
|
.map(|l| l.text.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
// Both tables, box-drawing, the connector arrow, the actions line.
|
|
assert!(text.contains("Orders"), "child box: {text}");
|
|
assert!(text.contains("Customers"), "parent box: {text}");
|
|
assert!(text.contains('┌') && text.contains('│'), "box drawing: {text}");
|
|
assert!(text.contains('▶'), "connector arrow: {text}");
|
|
assert!(text.contains("on delete cascade"), "actions: {text}");
|
|
// The diagram lines are styled (per-span runs), not plain system.
|
|
assert!(
|
|
app.output.iter().any(|l| l.styled_runs.is_some()),
|
|
"diagram lines carry styled runs",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn app_show_relationship_not_found_shows_friendly_line() {
|
|
let mut app = App::new();
|
|
app.output.push_back(rdbms_playground::app::OutputLine::echo(
|
|
"show relationship nope",
|
|
Mode::Simple,
|
|
));
|
|
app.update(AppEvent::DslShowRelationshipSucceeded {
|
|
command: Command::ShowList {
|
|
kind: ShowListKind::Relationships,
|
|
name: Some("nope".to_string()),
|
|
},
|
|
data: None,
|
|
});
|
|
assert!(
|
|
app.output
|
|
.iter()
|
|
.any(|l| l.text == "No relationship named `nope`."),
|
|
"friendly not-found line",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn app_show_table_renders_relationships_as_compact_diagrams() {
|
|
let (_p, db, _dir) = open_project_db();
|
|
let rt = rt();
|
|
rt.block_on(seed_schema(&db));
|
|
// Orders holds the FK to Customers — an outbound relationship.
|
|
let desc = rt
|
|
.block_on(db.describe_table("Orders".to_string()))
|
|
.expect("describe Orders");
|
|
|
|
let mut app = App::new();
|
|
app.output.push_back(rdbms_playground::app::OutputLine::echo(
|
|
"show table Orders",
|
|
Mode::Simple,
|
|
));
|
|
app.update(AppEvent::DslSucceeded {
|
|
command: Command::ShowTable {
|
|
name: "Orders".to_string(),
|
|
},
|
|
description: Some(desc),
|
|
echo: None,
|
|
});
|
|
let text: String = app
|
|
.output
|
|
.iter()
|
|
.map(|l| l.text.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
// The focal structure box, then a diagram (not the prose block).
|
|
assert!(text.contains("Relationships"), "diagram heading: {text}");
|
|
assert!(!text.contains("References:"), "prose suppressed: {text}");
|
|
assert!(text.contains("Customers"), "neighbour box: {text}");
|
|
assert!(text.contains('▶'), "connector arrow: {text}");
|
|
}
|