Matrix: typing-surface infrastructure + insert Form A coverage
Per docs/handoff/20260515-handoff-12.md §1. Systematic per-position coverage of (state, hint, completion, parse_result) across canonical schema shapes; submodule per command family. Insert Form A covers 23 cursor positions across serial-PK, text-PK, multi-table, and every-Type schemas. Both bugs fixed in the previous commit were surfaced by these tests. Shared helpers under tests/typing_surface/mod.rs: 5 canonical schema shapes, assess() helper, property-assertion shortcuts, and a snap! macro that wraps insta with a stable per-cell suffix. 859 -> 885 tests passing; 1 ignored (pre-existing doc-test).
This commit is contained in:
@@ -0,0 +1 @@
|
||||
//! Submodule stub — populated in subsequent tasks.
|
||||
@@ -0,0 +1 @@
|
||||
//! Submodule stub — populated in subsequent tasks.
|
||||
@@ -0,0 +1 @@
|
||||
//! Submodule stub — populated in subsequent tasks.
|
||||
@@ -0,0 +1 @@
|
||||
//! Submodule stub — populated in subsequent tasks.
|
||||
@@ -0,0 +1 @@
|
||||
//! Submodule stub — populated in subsequent tasks.
|
||||
@@ -0,0 +1 @@
|
||||
//! Submodule stub — populated in subsequent tasks.
|
||||
@@ -0,0 +1 @@
|
||||
//! Submodule stub — populated in subsequent tasks.
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Matrix coverage for `insert into T (cols) values (vals)`
|
||||
//! (Form A — explicit column list).
|
||||
//!
|
||||
//! Form A is the most complex insert form: the column list
|
||||
//! `(cols)` constrains which values are required and in what
|
||||
//! order, and the dispatch must mirror `do_insert`'s `user_cols`
|
||||
//! contract (ADR-0018 §3, handoff-12 §B). Bugs unique to Form A
|
||||
//! from handoff-12:
|
||||
//!
|
||||
//! - **E2**: `insert into T (` showed no column candidates
|
||||
//! because the value-literal suppression masked the
|
||||
//! `Ident{Columns}` expectation. Fixed in commit 619a8bd.
|
||||
//! - **D2**: `insert into T (a, b, c) values (1, 2, 3` (no
|
||||
//! closing paren) classified as `DefiniteErrorAt(<values>)`
|
||||
//! because `walk_optional` was rolling back the partial value
|
||||
//! list. Fixed in commit 5815918.
|
||||
//!
|
||||
//! Every meaningful cursor position is exercised against the
|
||||
//! relevant schema shapes; per-cell snapshots pin behaviour
|
||||
//! and explicit property assertions document the invariants
|
||||
//! that matter most.
|
||||
|
||||
use crate::typing_surface::*;
|
||||
use rdbms_playground::input_render::InputState;
|
||||
|
||||
// =========================================================
|
||||
// Cursor at the entry-word boundary.
|
||||
// =========================================================
|
||||
|
||||
#[test]
|
||||
fn after_insert_keyword_expects_into() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end("insert", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
crate::snap!("after_insert_keyword", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_insert_space_expects_into() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end("insert ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["into"]);
|
||||
crate::snap!("after_insert_space", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_into_offers_table_candidates() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end("insert into ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["Customers"]);
|
||||
crate::snap!("after_into_serial_pk", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_into_with_multi_table_offers_both() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("insert into ", &schema);
|
||||
assert_candidate_present(&a, &["Customers", "Orders"]);
|
||||
crate::snap!("after_into_multi_table", a);
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Cursor immediately after the table-name boundary.
|
||||
//
|
||||
// At `insert into Customers ` the walker must offer two
|
||||
// branches: `values` (Form B/A pivot) and `(` (Form A/C pivot).
|
||||
// Handoff §D1 fixed this: the `(` punctuation now surfaces as
|
||||
// a `CandidateKind::Punct` candidate.
|
||||
// =========================================================
|
||||
|
||||
#[test]
|
||||
fn after_table_name_offers_values_and_open_paren() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end("insert into Customers ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["values", "("]);
|
||||
crate::snap!("after_table_name", a);
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Form A path enters at the `(`.
|
||||
//
|
||||
// The §1 bug E2 was that `insert into T (` showed nothing —
|
||||
// no column candidates surfaced. Reproduces here as a property
|
||||
// assertion (must include at least one column from the table)
|
||||
// and the inverse leakage check (must NOT include columns
|
||||
// from sibling tables).
|
||||
// =========================================================
|
||||
|
||||
#[test]
|
||||
fn after_open_paren_offers_active_table_columns_only() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("insert into Customers (", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["id", "Name"]);
|
||||
assert_no_candidate_named(&a, &["OrderId", "CustId", "Total"]);
|
||||
crate::snap!("after_open_paren_multi_table", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_open_paren_serial_pk_offers_all_columns_including_serial() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end("insert into Customers (", &schema);
|
||||
assert_candidate_present(&a, &["id", "Name", "Email"]);
|
||||
crate::snap!("after_open_paren_serial_pk", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_open_paren_text_pk_offers_text_pk_columns() {
|
||||
let schema = schema_text_pk();
|
||||
let a = assess_at_end("insert into Items (", &schema);
|
||||
assert_candidate_present(&a, &["Code", "Title"]);
|
||||
crate::snap!("after_open_paren_text_pk", a);
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Mid-column-list positions.
|
||||
// =========================================================
|
||||
|
||||
#[test]
|
||||
fn mid_column_list_after_comma_offers_remaining_columns() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end("insert into Customers (id, ", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["Name", "Email"]);
|
||||
crate::snap!("after_comma_in_column_list", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_close_paren_expects_values_keyword() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end("insert into Customers (Name)", &schema);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["values"]);
|
||||
crate::snap!("after_close_paren", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_values_keyword_expects_open_paren() {
|
||||
let schema = schema_serial_pk();
|
||||
// Trailing space so we're past the `values` word boundary
|
||||
// — without it the partial-prefix logic re-offers `values`
|
||||
// itself as the candidate that matches the typed prefix.
|
||||
let a = assess_at_end(
|
||||
"insert into Customers (Name) values ",
|
||||
&schema,
|
||||
);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
assert_candidate_present(&a, &["("]);
|
||||
crate::snap!("after_values_keyword", a);
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Value-list positions — typed-slot prose.
|
||||
// =========================================================
|
||||
|
||||
#[test]
|
||||
fn after_values_open_paren_form_a_text_column_prose_names_column() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers (Name) values (",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
hint_prose_contains(&a, "Name"),
|
||||
"expected column name in prose, got {:?}",
|
||||
a.hint,
|
||||
);
|
||||
assert!(
|
||||
hint_prose_contains(&a, "quoted string"),
|
||||
"expected text-slot prose, got {:?}",
|
||||
a.hint,
|
||||
);
|
||||
crate::snap!("after_values_open_paren_text_col", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_values_open_paren_form_a_serial_column_offers_null_to_auto_generate() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers (id, Name) values (",
|
||||
&schema,
|
||||
);
|
||||
let prose = hint_prose(&a)
|
||||
.unwrap_or_else(|| panic!("expected Prose hint, got {:?}", a.hint));
|
||||
assert!(prose.contains("id"), "prose should name `id`, got {prose:?}");
|
||||
assert!(
|
||||
prose.contains("null") && prose.contains("auto-generate"),
|
||||
"prose should mention `null` to auto-generate, got {prose:?}",
|
||||
);
|
||||
crate::snap!("after_values_open_paren_serial_col", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mid_value_list_after_comma_advances_to_next_column_prose() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers (Name, Email) values ('Alice', ",
|
||||
&schema,
|
||||
);
|
||||
let prose = hint_prose(&a)
|
||||
.unwrap_or_else(|| panic!("expected Prose hint, got {:?}", a.hint));
|
||||
assert!(
|
||||
prose.contains("Email"),
|
||||
"prose should name `Email`, got {prose:?}",
|
||||
);
|
||||
crate::snap!("after_first_value_advance_to_email", a);
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// In-progress Form A — regression for handoff §1 bug D2.
|
||||
//
|
||||
// `insert into Orders (...) values (..., ...` (no closing
|
||||
// paren) must classify as IncompleteAtEof, not
|
||||
// DefiniteErrorAt.
|
||||
// =========================================================
|
||||
|
||||
#[test]
|
||||
fn form_a_in_progress_values_list_is_incomplete_not_definite_error() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end(
|
||||
"insert into Orders (OrderId, CustId, Total) values (42, 89, 17.59",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
matches!(a.state, InputState::IncompleteAtEof),
|
||||
"expected IncompleteAtEof (bug §1-D2 regression), got {:?}",
|
||||
a.state,
|
||||
);
|
||||
crate::snap!("form_a_in_progress_no_red", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_a_in_progress_with_one_value_is_incomplete() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end(
|
||||
"insert into Orders (OrderId, CustId, Total) values (42",
|
||||
&schema,
|
||||
);
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
crate::snap!("form_a_in_progress_one_value", a);
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Form A: complete inputs parse to Insert.
|
||||
// =========================================================
|
||||
|
||||
#[test]
|
||||
fn form_a_complete_parses_to_insert() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers (Name, Email) values ('Alice', 'a@b.c')",
|
||||
&schema,
|
||||
);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("Insert"));
|
||||
crate::snap!("form_a_complete_two_values", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_a_complete_with_serial_in_list_parses() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers (id, Name, Email) values (1, 'Alice', 'a@b.c')",
|
||||
&schema,
|
||||
);
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("Insert"));
|
||||
crate::snap!("form_a_complete_with_serial", a);
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Per-column-type prose across the Type axis (every_type
|
||||
// schema). Each call to `Things.<col>` value position
|
||||
// exercises one Type variant's catalog prose.
|
||||
// =========================================================
|
||||
|
||||
#[test]
|
||||
fn form_a_int_slot_prose_says_integer() {
|
||||
let schema = schema_every_type();
|
||||
let a = assess_at_end("insert into Things (k) values (", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose for int slot, got {:?}", a.hint)
|
||||
});
|
||||
assert!(
|
||||
prose.contains("integer"),
|
||||
"int-slot prose should say `integer`, got {prose:?}",
|
||||
);
|
||||
crate::snap!("form_a_int_slot", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_a_date_slot_prose_says_yyyy_mm_dd() {
|
||||
let schema = schema_every_type();
|
||||
let a = assess_at_end("insert into Things (dt) values (", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose for date slot, got {:?}", a.hint)
|
||||
});
|
||||
assert!(
|
||||
prose.contains("YYYY-MM-DD"),
|
||||
"date-slot prose should reference YYYY-MM-DD format, got {prose:?}",
|
||||
);
|
||||
crate::snap!("form_a_date_slot", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_a_bool_slot_prose_mentions_true_false() {
|
||||
let schema = schema_every_type();
|
||||
let a = assess_at_end("insert into Things (b) values (", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose for bool slot, got {:?}", a.hint)
|
||||
});
|
||||
assert!(
|
||||
prose.contains("true") && prose.contains("false"),
|
||||
"bool-slot prose should mention `true`/`false`, got {prose:?}",
|
||||
);
|
||||
crate::snap!("form_a_bool_slot", a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_a_shortid_slot_prose_mentions_null_to_auto_generate() {
|
||||
let schema = schema_every_type();
|
||||
let a = assess_at_end("insert into Things (sid) values (", &schema);
|
||||
let prose = hint_prose(&a).unwrap_or_else(|| {
|
||||
panic!("expected Prose for shortid slot, got {:?}", a.hint)
|
||||
});
|
||||
assert!(
|
||||
prose.contains("null") && prose.contains("auto-generate"),
|
||||
"shortid-slot prose should mention `null` to auto-generate, got {prose:?}",
|
||||
);
|
||||
crate::snap!("form_a_shortid_slot", a);
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Column-name leakage: Form A's column list must not offer
|
||||
// columns from sibling tables in a multi-table schema.
|
||||
// =========================================================
|
||||
|
||||
#[test]
|
||||
fn form_a_open_paren_no_leakage_from_other_tables() {
|
||||
let schema = schema_multi_table();
|
||||
let a = assess_at_end("insert into Orders (", &schema);
|
||||
assert_no_candidate_named(&a, &["Name"]);
|
||||
assert_candidate_present(&a, &["OrderId", "CustId", "Total"]);
|
||||
crate::snap!("form_a_open_paren_orders", a);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
//! Submodule stub — populated in subsequent tasks.
|
||||
@@ -0,0 +1 @@
|
||||
//! Submodule stub — populated in subsequent tasks.
|
||||
@@ -0,0 +1,401 @@
|
||||
//! Shared helpers + canonical schema shapes for the
|
||||
//! typing-surface matrix.
|
||||
//!
|
||||
//! See `tests/typing_surface.rs` for the entry point and
|
||||
//! `docs/handoff/20260515-handoff-12.md` §1 for the design
|
||||
//! rationale.
|
||||
|
||||
// Helpers below are `pub` so submodules can use them. The
|
||||
// `unreachable_pub` lint trips because Cargo sees the test
|
||||
// binary's crate root as the only consumer, but submodule
|
||||
// access requires `pub`. `dead_code` similarly trips during
|
||||
// incremental work when not every helper has callers yet.
|
||||
#![allow(dead_code, unreachable_pub)]
|
||||
|
||||
use rdbms_playground::completion::{
|
||||
Completion, SchemaCache, TableColumn, candidates_at_cursor,
|
||||
};
|
||||
use rdbms_playground::dsl::parser::parse_command_with_schema;
|
||||
use rdbms_playground::dsl::types::Type;
|
||||
use rdbms_playground::input_render::{
|
||||
AmbientHint, InputState, ambient_hint, classify_input,
|
||||
};
|
||||
|
||||
pub mod insert_form_a;
|
||||
pub mod insert_form_b;
|
||||
pub mod insert_form_c;
|
||||
pub mod update_with_where;
|
||||
pub mod update_all_rows;
|
||||
pub mod delete_with_where;
|
||||
pub mod delete_all_rows;
|
||||
pub mod create_table;
|
||||
pub mod drop_column;
|
||||
pub mod drop_relationship;
|
||||
pub mod add_relationship;
|
||||
pub mod rename_change_column;
|
||||
pub mod app_commands;
|
||||
|
||||
// =========================================================
|
||||
// Canonical schema shapes (handoff §1 — CANONICAL_SCHEMA_SHAPES)
|
||||
// =========================================================
|
||||
//
|
||||
// Used in snapshot names so failing cases identify the schema.
|
||||
|
||||
pub const SHAPE_EMPTY: &str = "empty";
|
||||
pub const SHAPE_SERIAL_PK: &str = "serial_pk";
|
||||
pub const SHAPE_TEXT_PK: &str = "text_pk";
|
||||
pub const SHAPE_MULTI_TABLE: &str = "multi_table";
|
||||
pub const SHAPE_EVERY_TYPE: &str = "every_type";
|
||||
|
||||
/// Empty schema — no tables at all.
|
||||
pub fn schema_empty() -> SchemaCache {
|
||||
SchemaCache::default()
|
||||
}
|
||||
|
||||
/// Single table with a `serial` PK + two text columns.
|
||||
/// `Customers(id:serial, Name:text, Email:text)`.
|
||||
///
|
||||
/// Exercises auto-gen-skip behaviour: in Form B `id` is omitted
|
||||
/// from the value list; in Form A the user can list it explicitly.
|
||||
pub fn schema_serial_pk() -> SchemaCache {
|
||||
build_schema(&[(
|
||||
"Customers",
|
||||
&[
|
||||
("id", Type::Serial),
|
||||
("Name", Type::Text),
|
||||
("Email", Type::Text),
|
||||
],
|
||||
)])
|
||||
}
|
||||
|
||||
/// Single table with a text PK and no auto-gen columns.
|
||||
/// `Items(Code:text, Title:text)`.
|
||||
///
|
||||
/// Exercises the no-auto-gen path: Form A and Form B both
|
||||
/// require values for every column.
|
||||
pub fn schema_text_pk() -> SchemaCache {
|
||||
build_schema(&[(
|
||||
"Items",
|
||||
&[("Code", Type::Text), ("Title", Type::Text)],
|
||||
)])
|
||||
}
|
||||
|
||||
/// Two tables sharing no column names.
|
||||
/// `Customers(id:serial, Name:text)` +
|
||||
/// `Orders(OrderId:serial, CustId:int, Total:real)`.
|
||||
///
|
||||
/// Exercises column-name-leakage detection: column candidates at
|
||||
/// a position scoped to one table must not include columns from
|
||||
/// the other table.
|
||||
pub fn schema_multi_table() -> SchemaCache {
|
||||
build_schema(&[
|
||||
(
|
||||
"Customers",
|
||||
&[("id", Type::Serial), ("Name", Type::Text)],
|
||||
),
|
||||
(
|
||||
"Orders",
|
||||
&[
|
||||
("OrderId", Type::Serial),
|
||||
("CustId", Type::Int),
|
||||
("Total", Type::Real),
|
||||
],
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
/// One table with one column of every `Type` variant.
|
||||
/// Exercises per-type slot prose at every value position.
|
||||
pub fn schema_every_type() -> SchemaCache {
|
||||
build_schema(&[(
|
||||
"Things",
|
||||
&[
|
||||
("k", Type::Int),
|
||||
("r", Type::Real),
|
||||
("d", Type::Decimal),
|
||||
("b", Type::Bool),
|
||||
("dt", Type::Date),
|
||||
("ts", Type::DateTime),
|
||||
("data", Type::Blob),
|
||||
("sid", Type::ShortId),
|
||||
("auto", Type::Serial),
|
||||
("note", Type::Text),
|
||||
],
|
||||
)])
|
||||
}
|
||||
|
||||
fn build_schema(tables: &[(&str, &[(&str, Type)])]) -> SchemaCache {
|
||||
let mut cache = SchemaCache::default();
|
||||
for (table, cols) in tables {
|
||||
let table_cols: Vec<TableColumn> = cols
|
||||
.iter()
|
||||
.map(|(n, t)| TableColumn {
|
||||
name: (*n).to_string(),
|
||||
user_type: *t,
|
||||
})
|
||||
.collect();
|
||||
cache.tables.push((*table).to_string());
|
||||
for c in &table_cols {
|
||||
if !cache.columns.contains(&c.name) {
|
||||
cache.columns.push(c.name.clone());
|
||||
}
|
||||
}
|
||||
cache.table_columns.insert((*table).to_string(), table_cols);
|
||||
}
|
||||
cache
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Assessment helper — runs the four typing-surface APIs at a
|
||||
// (input, cursor, schema) cell and packages the results for
|
||||
// snapshot + property assertions.
|
||||
// =========================================================
|
||||
|
||||
/// Compact, Debug-printable assessment of the typing surface
|
||||
/// at one cell.
|
||||
#[derive(Debug)]
|
||||
pub struct Assessment {
|
||||
pub input: String,
|
||||
pub cursor: usize,
|
||||
pub state: InputState,
|
||||
pub hint: Option<AmbientHint>,
|
||||
pub completion: Option<Completion>,
|
||||
/// Parse result rendered as `Ok(<short label>)` or
|
||||
/// `Err(<short error label>)` to keep snapshots stable as
|
||||
/// the `Command` AST evolves.
|
||||
pub parse_result: Result<String, String>,
|
||||
}
|
||||
|
||||
/// Assess the typing surface at the given cell.
|
||||
pub fn assess(input: &str, cursor: usize, schema: &SchemaCache) -> Assessment {
|
||||
let state = classify_input(input);
|
||||
let hint = ambient_hint(input, cursor, None, schema);
|
||||
let completion = candidates_at_cursor(input, cursor, schema);
|
||||
let parse_result = match parse_command_with_schema(input, schema) {
|
||||
Ok(cmd) => Ok(command_kind_label(&cmd)),
|
||||
Err(e) => Err(parse_error_label(&e)),
|
||||
};
|
||||
Assessment {
|
||||
input: input.into(),
|
||||
cursor,
|
||||
state,
|
||||
hint,
|
||||
completion,
|
||||
parse_result,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: assess at the end of input.
|
||||
pub fn assess_at_end(input: &str, schema: &SchemaCache) -> Assessment {
|
||||
assess(input, input.len(), schema)
|
||||
}
|
||||
|
||||
fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
|
||||
use rdbms_playground::dsl::AppCommand;
|
||||
use rdbms_playground::dsl::Command::*;
|
||||
match cmd {
|
||||
CreateTable { .. } => "CreateTable".into(),
|
||||
DropTable { .. } => "DropTable".into(),
|
||||
AddColumn { .. } => "AddColumn".into(),
|
||||
DropColumn { .. } => "DropColumn".into(),
|
||||
RenameColumn { .. } => "RenameColumn".into(),
|
||||
ChangeColumnType { .. } => "ChangeColumnType".into(),
|
||||
AddRelationship { .. } => "AddRelationship".into(),
|
||||
DropRelationship { .. } => "DropRelationship".into(),
|
||||
ShowTable { .. } => "ShowTable".into(),
|
||||
Insert { .. } => "Insert".into(),
|
||||
Update { .. } => "Update".into(),
|
||||
Delete { .. } => "Delete".into(),
|
||||
ShowData { .. } => "ShowData".into(),
|
||||
Replay { .. } => "Replay".into(),
|
||||
App(app) => match app {
|
||||
AppCommand::Quit => "App(Quit)".into(),
|
||||
AppCommand::Help => "App(Help)".into(),
|
||||
AppCommand::Rebuild => "App(Rebuild)".into(),
|
||||
AppCommand::Save => "App(Save)".into(),
|
||||
AppCommand::SaveAs => "App(SaveAs)".into(),
|
||||
AppCommand::New => "App(New)".into(),
|
||||
AppCommand::Load => "App(Load)".into(),
|
||||
AppCommand::Export { .. } => "App(Export)".into(),
|
||||
AppCommand::Import { .. } => "App(Import)".into(),
|
||||
AppCommand::Mode { .. } => "App(Mode)".into(),
|
||||
AppCommand::Messages { .. } => "App(Messages)".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_error_label(err: &rdbms_playground::dsl::ParseError) -> String {
|
||||
use rdbms_playground::dsl::ParseError::*;
|
||||
match err {
|
||||
Empty => "Empty".into(),
|
||||
Invalid { at_eof, .. } => {
|
||||
if *at_eof {
|
||||
"Invalid(at_eof)".into()
|
||||
} else {
|
||||
"Invalid(definite)".into()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Property-assertion helpers.
|
||||
//
|
||||
// Inline tests in submodules combine these with insta
|
||||
// snapshots — the snapshot detects drift; the assertions
|
||||
// detect bug-class regressions (no column leakage, hint
|
||||
// surfaces the right column, etc.).
|
||||
// =========================================================
|
||||
|
||||
/// Returns the list of candidate texts from the ambient hint,
|
||||
/// or an empty Vec if the hint isn't in Candidates mode.
|
||||
pub fn hint_candidate_texts(a: &Assessment) -> Vec<String> {
|
||||
match &a.hint {
|
||||
Some(AmbientHint::Candidates { items, .. }) => {
|
||||
items.iter().map(|c| c.text.clone()).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the list of completion candidate texts (from the
|
||||
/// `candidates_at_cursor` surface), or an empty Vec if there
|
||||
/// is no completion.
|
||||
pub fn completion_candidate_texts(a: &Assessment) -> Vec<String> {
|
||||
a.completion.as_ref().map_or_else(Vec::new, |c| {
|
||||
c.candidates.iter().map(|c| c.text.clone()).collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// True if the hint is Prose containing the given substring.
|
||||
pub fn hint_prose_contains(a: &Assessment, needle: &str) -> bool {
|
||||
matches!(&a.hint, Some(AmbientHint::Prose(p)) if p.contains(needle))
|
||||
}
|
||||
|
||||
/// True if the hint is Prose; returns the prose string.
|
||||
pub const fn hint_prose(a: &Assessment) -> Option<&str> {
|
||||
match &a.hint {
|
||||
Some(AmbientHint::Prose(p)) => Some(p.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert state matches the given variant (by Debug name).
|
||||
pub fn assert_state(a: &Assessment, expected: &str) {
|
||||
let actual = format!("{:?}", a.state);
|
||||
// DefiniteErrorAt carries a position — compare prefix.
|
||||
assert!(
|
||||
actual.starts_with(expected),
|
||||
"state: expected {expected}, got {actual} (input: {:?}, cursor: {})",
|
||||
a.input,
|
||||
a.cursor,
|
||||
);
|
||||
}
|
||||
|
||||
/// Assert that no candidate (either hint Candidates or
|
||||
/// completion) contains any of the given names. Used to detect
|
||||
/// column-name leakage from foreign tables.
|
||||
pub fn assert_no_candidate_named(a: &Assessment, forbidden: &[&str]) {
|
||||
let hint_cands = hint_candidate_texts(a);
|
||||
let comp_cands = completion_candidate_texts(a);
|
||||
for name in forbidden {
|
||||
assert!(
|
||||
!hint_cands.iter().any(|c| c == name),
|
||||
"hint candidates contain forbidden {name:?}: {hint_cands:?} (input: {:?})",
|
||||
a.input,
|
||||
);
|
||||
assert!(
|
||||
!comp_cands.iter().any(|c| c == name),
|
||||
"completion candidates contain forbidden {name:?}: {comp_cands:?} (input: {:?})",
|
||||
a.input,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert that at least one candidate matches each expected name.
|
||||
pub fn assert_candidate_present(a: &Assessment, expected: &[&str]) {
|
||||
let hint_cands = hint_candidate_texts(a);
|
||||
let comp_cands = completion_candidate_texts(a);
|
||||
let all: Vec<&String> = hint_cands.iter().chain(comp_cands.iter()).collect();
|
||||
for name in expected {
|
||||
assert!(
|
||||
all.iter().any(|c| *c == name),
|
||||
"expected candidate {name:?} not present. hint: {hint_cands:?}, completion: {comp_cands:?} (input: {:?})",
|
||||
a.input,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot helper — takes a stable name + the Assessment.
|
||||
/// Wraps `insta::assert_debug_snapshot!` with a deterministic
|
||||
/// snapshot suffix so each cell has its own file.
|
||||
#[macro_export]
|
||||
macro_rules! snap {
|
||||
($name:expr, $assessment:expr) => {
|
||||
::insta::with_settings!({
|
||||
snapshot_suffix => $name,
|
||||
description => format!("input={:?} cursor={}", $assessment.input, $assessment.cursor),
|
||||
}, {
|
||||
::insta::assert_debug_snapshot!(&$assessment);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================
|
||||
// Infrastructure smoke tests — verify the helper itself
|
||||
// behaves before submodules rely on it.
|
||||
// =========================================================
|
||||
|
||||
#[test]
|
||||
fn smoke_each_schema_shape_builds() {
|
||||
// Each canonical schema must produce a SchemaCache the
|
||||
// walker can probe without panicking. Run a trivial
|
||||
// assess() against each.
|
||||
for (label, schema) in [
|
||||
(SHAPE_EMPTY, schema_empty()),
|
||||
(SHAPE_SERIAL_PK, schema_serial_pk()),
|
||||
(SHAPE_TEXT_PK, schema_text_pk()),
|
||||
(SHAPE_MULTI_TABLE, schema_multi_table()),
|
||||
(SHAPE_EVERY_TYPE, schema_every_type()),
|
||||
] {
|
||||
let a = assess("insert", 6, &schema);
|
||||
// `insert` alone is incomplete regardless of schema —
|
||||
// a useful invariant to pin so any regression in the
|
||||
// entry-word path surfaces immediately.
|
||||
assert!(
|
||||
matches!(a.state, InputState::IncompleteAtEof),
|
||||
"{label}: expected IncompleteAtEof for 'insert', got {:?}",
|
||||
a.state,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoke_assess_at_end_returns_each_field() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end("insert into Customers values (", &schema);
|
||||
// The four surfaces are populated:
|
||||
assert_eq!(a.input, "insert into Customers values (");
|
||||
assert_eq!(a.cursor, "insert into Customers values (".len());
|
||||
// The walker recognises this as incomplete (more tokens
|
||||
// wanted), not a definite error.
|
||||
assert!(matches!(a.state, InputState::IncompleteAtEof));
|
||||
// The hint surfaces per-column prose for the first
|
||||
// non-auto-gen column (Name).
|
||||
assert!(
|
||||
hint_prose_contains(&a, "Name"),
|
||||
"expected hint prose to mention `Name`, got {:?}",
|
||||
a.hint,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoke_assess_parse_label_round_trips() {
|
||||
let schema = schema_serial_pk();
|
||||
let a = assess_at_end(
|
||||
"insert into Customers values ('Alice', 'a@b.c')",
|
||||
&schema,
|
||||
);
|
||||
assert_eq!(a.parse_result.as_deref(), Ok("Insert"));
|
||||
assert!(matches!(a.state, InputState::Valid));
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
//! Submodule stub — populated in subsequent tasks.
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into Customers (Name)\" cursor=28"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Customers (Name)",
|
||||
cursor: 28,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Candidates {
|
||||
items: [
|
||||
Candidate {
|
||||
text: "values",
|
||||
kind: Keyword,
|
||||
},
|
||||
],
|
||||
selected: None,
|
||||
},
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
28,
|
||||
28,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "values",
|
||||
kind: Keyword,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert\" cursor=6"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert",
|
||||
cursor: 6,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Candidates {
|
||||
items: [
|
||||
Candidate {
|
||||
text: "insert",
|
||||
kind: Keyword,
|
||||
},
|
||||
],
|
||||
selected: None,
|
||||
},
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
0,
|
||||
6,
|
||||
),
|
||||
partial_prefix: "insert",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "insert",
|
||||
kind: Keyword,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert \" cursor=7"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert ",
|
||||
cursor: 7,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Candidates {
|
||||
items: [
|
||||
Candidate {
|
||||
text: "into",
|
||||
kind: Keyword,
|
||||
},
|
||||
],
|
||||
selected: None,
|
||||
},
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
7,
|
||||
7,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "into",
|
||||
kind: Keyword,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into \" cursor=12"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into ",
|
||||
cursor: 12,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Candidates {
|
||||
items: [
|
||||
Candidate {
|
||||
text: "Customers",
|
||||
kind: Identifier,
|
||||
},
|
||||
],
|
||||
selected: None,
|
||||
},
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
12,
|
||||
12,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "Customers",
|
||||
kind: Identifier,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into \" cursor=12"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into ",
|
||||
cursor: 12,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Candidates {
|
||||
items: [
|
||||
Candidate {
|
||||
text: "Customers",
|
||||
kind: Identifier,
|
||||
},
|
||||
Candidate {
|
||||
text: "Orders",
|
||||
kind: Identifier,
|
||||
},
|
||||
],
|
||||
selected: None,
|
||||
},
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
12,
|
||||
12,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "Customers",
|
||||
kind: Identifier,
|
||||
},
|
||||
Candidate {
|
||||
text: "Orders",
|
||||
kind: Identifier,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into Customers (\" cursor=23"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Customers (",
|
||||
cursor: 23,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"Type a value: number, 'text', true/false, null (dates as 'YYYY-MM-DD', datetimes as 'YYYY-MM-DDTHH:MM:SS')",
|
||||
),
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
23,
|
||||
23,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "null",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "true",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "false",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "Name",
|
||||
kind: Identifier,
|
||||
},
|
||||
Candidate {
|
||||
text: "id",
|
||||
kind: Identifier,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into Customers (\" cursor=23"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Customers (",
|
||||
cursor: 23,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"Type a value: number, 'text', true/false, null (dates as 'YYYY-MM-DD', datetimes as 'YYYY-MM-DDTHH:MM:SS')",
|
||||
),
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
23,
|
||||
23,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "null",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "true",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "false",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "Email",
|
||||
kind: Identifier,
|
||||
},
|
||||
Candidate {
|
||||
text: "Name",
|
||||
kind: Identifier,
|
||||
},
|
||||
Candidate {
|
||||
text: "id",
|
||||
kind: Identifier,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into Items (\" cursor=19"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Items (",
|
||||
cursor: 19,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"Type a value: number, 'text', true/false, null (dates as 'YYYY-MM-DD', datetimes as 'YYYY-MM-DDTHH:MM:SS')",
|
||||
),
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
19,
|
||||
19,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "null",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "true",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "false",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "Code",
|
||||
kind: Identifier,
|
||||
},
|
||||
Candidate {
|
||||
text: "Title",
|
||||
kind: Identifier,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into Customers \" cursor=22"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Customers ",
|
||||
cursor: 22,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Candidates {
|
||||
items: [
|
||||
Candidate {
|
||||
text: "values",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "(",
|
||||
kind: Punct,
|
||||
},
|
||||
],
|
||||
selected: None,
|
||||
},
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
22,
|
||||
22,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "values",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "(",
|
||||
kind: Punct,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into Customers (Name) values \" cursor=36"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Customers (Name) values ",
|
||||
cursor: 36,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Candidates {
|
||||
items: [
|
||||
Candidate {
|
||||
text: "(",
|
||||
kind: Punct,
|
||||
},
|
||||
],
|
||||
selected: None,
|
||||
},
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
36,
|
||||
36,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "(",
|
||||
kind: Punct,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into Customers (id, Name) values (\" cursor=41"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Customers (id, Name) values (",
|
||||
cursor: 41,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"for `id`: Type null to auto-generate, or an explicit integer",
|
||||
),
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
41,
|
||||
41,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "null",
|
||||
kind: Keyword,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into Customers (Name) values (\" cursor=37"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Customers (Name) values (",
|
||||
cursor: 37,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"for `Name`: Type a quoted string (e.g. 'Alice') or null",
|
||||
),
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
37,
|
||||
37,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "null",
|
||||
kind: Keyword,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into Things (b) values (\" cursor=31"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Things (b) values (",
|
||||
cursor: 31,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"for `b`: Type true, false, or null",
|
||||
),
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
31,
|
||||
31,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "true",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "false",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "null",
|
||||
kind: Keyword,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into Customers (Name, Email) values ('Alice', 'a@b.c')\" cursor=61"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Customers (Name, Email) values ('Alice', 'a@b.c')",
|
||||
cursor: 61,
|
||||
state: Valid,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"Submit with Enter",
|
||||
),
|
||||
),
|
||||
completion: None,
|
||||
parse_result: Ok(
|
||||
"Insert",
|
||||
),
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into Customers (id, Name, Email) values (1, 'Alice', 'a@b.c')\" cursor=68"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Customers (id, Name, Email) values (1, 'Alice', 'a@b.c')",
|
||||
cursor: 68,
|
||||
state: Valid,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"Submit with Enter",
|
||||
),
|
||||
),
|
||||
completion: None,
|
||||
parse_result: Ok(
|
||||
"Insert",
|
||||
),
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into Things (dt) values (\" cursor=32"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Things (dt) values (",
|
||||
cursor: 32,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"for `dt`: Type a quoted date as 'YYYY-MM-DD' or null",
|
||||
),
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
32,
|
||||
32,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "null",
|
||||
kind: Keyword,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into Orders (OrderId, CustId, Total) values (42, 89, 17.59\" cursor=65"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Orders (OrderId, CustId, Total) values (42, 89, 17.59",
|
||||
cursor: 65,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"Next: `)`",
|
||||
),
|
||||
),
|
||||
completion: None,
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into Orders (OrderId, CustId, Total) values (42\" cursor=54"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Orders (OrderId, CustId, Total) values (42",
|
||||
cursor: 54,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"Next: `)`",
|
||||
),
|
||||
),
|
||||
completion: None,
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into Things (k) values (\" cursor=31"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Things (k) values (",
|
||||
cursor: 31,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"for `k`: Type an integer (e.g. 42, -7) or null",
|
||||
),
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
31,
|
||||
31,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "null",
|
||||
kind: Keyword,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into Orders (\" cursor=20"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Orders (",
|
||||
cursor: 20,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"Type a value: number, 'text', true/false, null (dates as 'YYYY-MM-DD', datetimes as 'YYYY-MM-DDTHH:MM:SS')",
|
||||
),
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
20,
|
||||
20,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "null",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "true",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "false",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "CustId",
|
||||
kind: Identifier,
|
||||
},
|
||||
Candidate {
|
||||
text: "OrderId",
|
||||
kind: Identifier,
|
||||
},
|
||||
Candidate {
|
||||
text: "Total",
|
||||
kind: Identifier,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into Things (sid) values (\" cursor=33"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Things (sid) values (",
|
||||
cursor: 33,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"for `sid`: Type null to auto-generate, or a quoted shortid",
|
||||
),
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
33,
|
||||
33,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "null",
|
||||
kind: Keyword,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into Customers (id, \" cursor=27"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Customers (id, ",
|
||||
cursor: 27,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"Type a value: number, 'text', true/false, null (dates as 'YYYY-MM-DD', datetimes as 'YYYY-MM-DDTHH:MM:SS')",
|
||||
),
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
27,
|
||||
27,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "null",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "true",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "false",
|
||||
kind: Keyword,
|
||||
},
|
||||
Candidate {
|
||||
text: "Email",
|
||||
kind: Identifier,
|
||||
},
|
||||
Candidate {
|
||||
text: "Name",
|
||||
kind: Identifier,
|
||||
},
|
||||
Candidate {
|
||||
text: "id",
|
||||
kind: Identifier,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
---
|
||||
source: tests/typing_surface/insert_form_a.rs
|
||||
description: "input=\"insert into Customers (Name, Email) values ('Alice', \" cursor=53"
|
||||
expression: "& a"
|
||||
---
|
||||
Assessment {
|
||||
input: "insert into Customers (Name, Email) values ('Alice', ",
|
||||
cursor: 53,
|
||||
state: IncompleteAtEof,
|
||||
hint: Some(
|
||||
Prose(
|
||||
"for `Email`: Type a quoted string (e.g. 'Alice') or null",
|
||||
),
|
||||
),
|
||||
completion: Some(
|
||||
Completion {
|
||||
replaced_range: (
|
||||
53,
|
||||
53,
|
||||
),
|
||||
partial_prefix: "",
|
||||
candidates: [
|
||||
Candidate {
|
||||
text: "null",
|
||||
kind: Keyword,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
parse_result: Err(
|
||||
"Invalid(at_eof)",
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
//! Submodule stub — populated in subsequent tasks.
|
||||
@@ -0,0 +1 @@
|
||||
//! Submodule stub — populated in subsequent tasks.
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Typing-surface matrix — systematic per-position coverage of
|
||||
//! what the user sees while typing a command (state, hint,
|
||||
//! completion candidates, parse outcome), against canonical
|
||||
//! schema shapes.
|
||||
//!
|
||||
//! Per `docs/handoff/20260515-handoff-12.md` §1. The matrix exists
|
||||
//! to catch the class of bug where automated tests pass but real
|
||||
//! users hit issues at specific typing positions — every commit
|
||||
//! in handoff-12's session fixed a bug that the 800+ existing
|
||||
//! tests had missed. Each row in this matrix is one (input,
|
||||
//! cursor, schema) cell; failures here mean the typing surface
|
||||
//! drifted or regressed at that exact cell.
|
||||
//!
|
||||
//! Layout: helpers and per-command submodules live under
|
||||
//! `tests/typing_surface/`. Each submodule exercises one command
|
||||
//! family across the canonical schema shapes defined in
|
||||
//! `mod.rs`.
|
||||
|
||||
mod typing_surface;
|
||||
Reference in New Issue
Block a user