Files
hemx/slhx-derive/tests/compile_fail.rs
T
slhx agent 21716882ab feat(derive): check form parser availability
Introduce FormValue as the parser-availability bound for #[slhx::form] models, blanket it for FromStr types, and add compile-fail coverage for a form field whose domain type lacks a parser.

req: form/004

req: form/006
2026-05-25 23:48:56 +02:00

896 lines
22 KiB
Rust

use std::path::{Path, PathBuf};
use std::process::Command;
#[test]
fn component_macro_reports_missing_handler_implementation() {
// req: component/005 req: check/002 req: test/003
let fixture = Fixture::new("slhx-derive-component-missing-handler-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-component-missing-handler-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\ttemplates/app.heml::delete\tdelete\t2\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"#[slhx::component]
mod todos {
#[slhx::handler]
fn create() -> impl slhx::IntoEffect {
slhx::EffectBatch::default()
}
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("#[slhx::component] missing handler implementation(s): delete"),
"missing component diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn handler_macro_reports_unknown_handle_and_bad_shape() {
// req: derive_handler/001 req: test/003
let fixture = Fixture::new("slhx-derive-handler-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-handler-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::known\tknown\t1\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"#[slhx::handler]
fn missing() -> impl slhx::IntoEffect {
slhx::EffectBatch::default()
}
#[slhx::handler]
fn known() {}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("unknown slhx handle `missing`; add `data-slhx-handle=\"missing\"`"),
"missing unknown-handle diagnostic in stderr:\n{stderr}"
);
assert!(
stderr.contains("slhx handler `known` must accept a form/context parameter or return a value implementing IntoEffect"),
"missing handler-shape diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn handler_macro_reports_missing_syms() {
// req: build/003 req: test/003
let fixture = Fixture::new("slhx-derive-handler-syms-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-handler-syms-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() {
println!("cargo:rerun-if-changed=build.rs");
}
"#,
);
fixture.write(
"src/lib.rs",
r#"#[slhx::handler]
fn create() -> impl slhx::IntoEffect {
slhx::EffectBatch::default()
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("#[slhx::handler] could not find"),
"missing handler syms diagnostic in stderr:\n{stderr}"
);
assert!(
stderr.contains("add slhx_build::app().run()? to build.rs"),
"missing build.rs hint in stderr:\n{stderr}"
);
}
#[test]
fn handler_requires_generated_param_arguments() {
// req: derive_handler/001 req: test/003
let fixture = Fixture::new("slhx-derive-param-handler-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-param-handler-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::show\tshow\t1\nhandle_param\tshow\ttodo_id\nhandle_param\tshow\tmode\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"#[slhx::handler]
fn show(todo_id: String) -> impl slhx::IntoEffect {
slhx::EffectBatch::default()
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("slhx handler `show` is missing generated param argument(s): mode"),
"missing param diagnostic in stderr:\n{stderr}"
);
}
#[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_struct_requires_field_parser() {
// req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("slhx-derive-form-struct-parser-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-struct-parser-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\temail\ttrue\tfalse\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"struct Email;
#[slhx::form("profile")]
struct Profile {
email: Email,
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Email: FormValue") || stderr.contains("Email: slhx::FormValue"),
"missing parser availability 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
let fixture = Fixture::new("slhx-derive-form-handler-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-handler-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#"#[slhx::handler]
fn create() -> impl slhx::IntoEffect {
slhx::EffectBatch::default()
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("slhx handler `create` handles a generated form and must accept slhx::Form<_>"),
"missing form-handler diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn form_handle_still_requires_generated_param_arguments() {
// req: form/006 req: derive_handler/003 req: test/003
let fixture = Fixture::new("slhx-derive-form-handler-param-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-handler-param-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\nhandle_param\tcreate\ttodo_id\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"struct CreateTodo;
#[slhx::handler]
fn create(_form: slhx::Form<CreateTodo>) -> impl slhx::IntoEffect {
slhx::EffectBatch::default()
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("slhx handler `create` is missing generated param argument(s): todo_id"),
"missing form-param diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn form_handle_rejects_nongeneric_form_impostor() {
// req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("slhx-derive-form-handler-nongeneric-impostor-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-handler-nongeneric-impostor-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 Form;
#[slhx::handler]
fn create(_form: Form) -> impl slhx::IntoEffect {
slhx::EffectBatch::default()
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("slhx handler `create` handles a generated form and must accept slhx::Form<_>"),
"missing nongeneric form diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn form_handle_rejects_form_name_suffix_impostor() {
// req: form/004 req: form/006 req: test/003
let fixture = Fixture::new("slhx-derive-form-impostor-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-form-impostor-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 CreateForm;
#[slhx::handler]
fn create(_form: CreateForm) -> impl slhx::IntoEffect {
slhx::EffectBatch::default()
}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("slhx handler `create` handles a generated form and must accept slhx::Form<_>"),
"missing form-impostor diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn surface_macro_reports_missing_generated_include() {
// req: build/004 req: test/003
let fixture = Fixture::new("slhx-derive-surface-include-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-surface-include-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() {
println!("cargo:rerun-if-changed=build.rs");
}
"#,
);
fixture.write(
"src/lib.rs",
r#"#[slhx::surface]
pub mod ui {}
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("#[slhx::surface] could not find"),
"missing surface include diagnostic in stderr:\n{stderr}"
);
assert!(
stderr.contains("add slhx_build::app().run()? to build.rs"),
"missing build.rs hint in stderr:\n{stderr}"
);
}
#[test]
fn surface_macro_requires_inline_module() {
// req: codegen/001 req: test/003
let fixture = Fixture::new("slhx-derive-surface-inline-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-surface-inline-fail"
version = "0.0.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[dependencies]
slhx = {{ path = {:?} }}
"#,
repo_path("slhx")
),
);
fixture.write(
"src/lib.rs",
r#"#[slhx::surface]
pub mod ui;
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("#[slhx::surface] must be used on an inline module"),
"missing inline-module diagnostic in stderr:\n{stderr}"
);
}
#[test]
fn generated_resource_references_fail_when_name_is_absent() {
// req: style/002 req: codegen/001 req: test/003
let fixture = Fixture::new("slhx-derive-generated-resource-fail");
fixture.write(
"Cargo.toml",
&format!(
r#"[package]
name = "slhx-derive-generated-resource-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.generated.rs"),
"pub mod classes { pub const card: ::slhx::CssClass = ::slhx::CssClass::new(\"card\"); }\n",
)
.unwrap();
}
"#,
);
fixture.write(
"src/lib.rs",
r#"#[slhx::surface]
pub mod ui {}
pub const KNOWN: slhx::CssClass = ui::classes::card;
pub const MISSING: slhx::CssClass = ui::classes::missing;
"#,
);
let output = check_fixture(&fixture);
assert!(!output.status.success(), "fixture unexpectedly compiled");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("cannot find value `missing` in module `ui::classes`"),
"missing generated-resource diagnostic in stderr:\n{stderr}"
);
}
fn check_fixture(fixture: &Fixture) -> std::process::Output {
Command::new("cargo")
.arg("check")
.arg("--quiet")
.arg("--manifest-path")
.arg(fixture.path.join("Cargo.toml"))
.env("CARGO_TARGET_DIR", fixture.path.join("target"))
.output()
.expect("cargo check fixture runs")
}
fn repo_path(crate_name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("crate has workspace parent")
.join(crate_name)
}
struct Fixture {
path: PathBuf,
}
impl Fixture {
fn new(name: &str) -> Self {
let path = std::env::temp_dir().join(format!(
"{name}-{}-{}",
std::process::id(),
std::thread::current().name().unwrap_or("test")
));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(path.join("src")).unwrap();
Self { path }
}
fn write(&self, relative: &str, contents: &str) {
let path = self.path.join(relative);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, contents).unwrap();
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}