feat(derive): check form model structure

Add #[slhx::form] for user-authored form models, emit form field metadata into slhx.syms, and require form handlers to accept models that passed generated field compatibility checks.

req: form/001

req: form/004

req: form/006

req: codegen/004
This commit is contained in:
slhx agent
2026-05-25 23:44:43 +02:00
parent 34d6757ccd
commit 274b8e9e55
6 changed files with 423 additions and 16 deletions
+219
View File
@@ -218,6 +218,225 @@ fn show(todo_id: String) -> impl slhx::IntoEffect {
);
}
#[test]
fn form_struct_rejects_missing_generated_field() {
// req: form/001 req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("slhx-derive-form-struct-missing-field-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-struct-missing-field-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
"#,
repo_path("slhx")
),
);
fixture.write(
"build.rs",
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nform\ttemplates/app.heml::new_todo\tnew_todo\t1\nform_field\tnew_todo\ttitle\ttrue\tfalse\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"#[slhx::form("new_todo")]
struct CreateTodo {}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("slhx form `new_todo` is missing field `title` for form control `title`"),
"missing form field diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn form_struct_rejects_wrong_optionality_and_multiplicity() {
// req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("slhx-derive-form-struct-shape-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-struct-shape-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
"#,
repo_path("slhx")
),
);
fixture.write(
"build.rs",
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nform\ttemplates/app.heml::profile\tprofile\t1\nform_field\tprofile\ttitle\ttrue\tfalse\nform_field\tprofile\tlabels\tfalse\ttrue\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"#[slhx::form("profile")]
struct Profile {
title: Option<String>,
labels: Option<String>,
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("field `title` is required in HTML and must not be Option<_>"),
"missing required-field diagnostic in stderr:\n{stderr}"
);
assert!(
stderr.contains("field `labels` accepts multiple values and must be Vec<_>"),
"missing multiplicity diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn form_handle_accepts_checked_form_model() {
// req: form/001 req: form/004 req: form/006
let fixture = Fixture::new("slhx-derive-form-handler-checked-model-pass");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-handler-checked-model-pass"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
"#,
repo_path("slhx")
),
);
fixture.write(
"build.rs",
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\nform\ttemplates/app.heml::new_todo\tnew_todo\t1\nform_field\tnew_todo\ttitle\ttrue\tfalse\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"#[slhx::form("new_todo")]
struct CreateTodo {
title: String,
}
#[slhx::handler]
fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
slhx::event("created", "")
}
"#,
);
let output = check_fixture(&fixture);
assert!(
output.status.success(),
"fixture failed to compile:\n{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn form_handle_requires_checked_form_model() {
// req: form/001 req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("slhx-derive-form-handler-unchecked-model-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-handler-unchecked-model-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
"#,
repo_path("slhx")
),
);
fixture.write(
"build.rs",
r#"fn main() {
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
std::fs::write(
out.join("slhx.syms"),
"slhx-syms-v1\nhandle\ttemplates/app.heml::create\tcreate\t1\nhandle_form\tcreate\tnew_todo\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"struct CreateTodo {
title: String,
}
#[slhx::handler]
fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
slhx::event("created", "")
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("CreateTodo: FormModel") || stderr.contains("CreateTodo: slhx::FormModel"),
"missing checked form model diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn form_handle_requires_form_parameter() {
// req: form/004 req: form/006 req: test/003