diff --git a/CHANGELOG.md b/CHANGELOG.md index 07b49df..9c77496 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,66 @@ ratio/diff ver `git show `. ## 2026-05-09 +### feat(nakui-ui): validación estricta de params del morphism vía FieldKind del FieldSpec +Cierra el último trade-off documentado: `infer_param_value` adivinaba +el tipo de cada param por la shape del string (i64 → f64 → bool → +string). Ahora cuando hay `FieldSpec` declarado, usamos +`parse_field_value(spec.kind, raw)` — un Boolean field con value +"abc" rebota con mensaje claro en la UI antes de llegar al morphism +Rhai (donde el error sería opaco como "Function not found: * ((), ())"). + +Cambios: +- **Nuevo helper `resolve_param_value(field_name, raw, spec)`**: + - Si hay `FieldSpec`: validación de `required` (rebota empty con + "param 'X' es obligatorio y está vacío") + parseo estricto via + `parse_field_value(spec.kind, raw)`. Errores incluyen el `label` + del spec para que el toast sea interpretable. + - Si NO hay spec (param declarado en `Action::Morphism.params` + que no existe en `form.fields` — módulo mal-formado): fallback + a `infer_param_value` como red de seguridad. + - Empty + opcional → `Value::Null`. +- **`commit_morphism` simplificado**: el loop de params ahora es + 3 líneas (lookup spec + llamada a `resolve_param_value` + + inserción al map). La lógica vive en el helper standalone, + testable sin GPUI. + +Tests: 6 nuevos en `tests` mod, todos contra `resolve_param_value`: +- `resolve_param_strict_number_parses_i64` — happy path. +- `resolve_param_strict_boolean_rejects_non_boolean` — un Boolean + con "abc" rebota con mensaje que incluye el label. +- `resolve_param_strict_number_rejects_garbage` — Number con "abc" + rebota. +- `resolve_param_required_empty_rejected` — required vacío rebota + con "obligatorio". +- `resolve_param_optional_empty_returns_null` — optional vacío + → null. +- `resolve_param_no_spec_falls_back_to_infer` — el fallback + preserva el comportamiento anterior para back-compat. + +22 tests verdes en nakui-ui (+6). E2E del morphism real +(`morphism_pipeline_executes_real_sales_vender`) sigue verde — la +validación estricta no rompe el path correcto, sólo agrega rebotes +tempranos a values mal-tipados. + +Beneficio operativo: +- Mensaje de error en la UI ahora identifica el field problemático + por su label legible ("param 'Cantidad': 'abc' no es número") + en lugar del error opaco del morphism Rhai. +- Errores se ven antes de tocar el log o el store — ningún cambio + parcial. +- El módulo Nakui ya no tiene que defender contra inputs garbage + desde la UI: la metainterfaz se vuelve la primera línea de + validación tipada. + +Pendientes futuros (orden de prioridad): +- **Confirmación de delete** — modal antes de borrar. +- **Snapshot/compaction** del log para repos grandes. +- **Edit delta-only** — sólo campos modificados, no todos. +- **EntityRef validation post-submit**: hoy `parse_field_value` + para EntityRef devuelve string raw; el commit_morphism luego + valida como Uuid sólo cuando es input del morphism. Para + EntityRef como param, podríamos validar UUID al submit. + ### feat(nakui-ui): FieldKind::EntityRef — selector clickable de records existentes Cierra el principal trade-off documentado del commit anterior: "Inputs UUID a mano (no dropdown)". Los formularios pueden declarar diff --git a/Cargo.lock b/Cargo.lock index d9eb8dd..bac7bcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6155,6 +6155,8 @@ dependencies = [ "serde_json", "tempfile", "uuid", + "yahweh-theme", + "yahweh-widget-text-input", ] [[package]] diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs index 2bdd1a4..7110838 100644 --- a/crates/apps/nakui-ui/src/main.rs +++ b/crates/apps/nakui-ui/src/main.rs @@ -468,18 +468,29 @@ impl MetaUi { } else { params_fields.to_vec() }; + + // Buscamos los FieldSpec del Form view activo para conocer + // el `kind` declarado de cada param. Usamos `parse_field_value` + // estricto en lugar de la heurística `infer_param_value` — + // así un "abc" en un campo Boolean rebota en la UI con un + // mensaje claro ANTES de llegar al morphism Rhai. + let active_form_fields: Option> = self.active.as_ref().and_then(|(_, vk)| { + module.views.get(vk).and_then(|v| match v { + View::Form(f) => Some(f.fields.clone()), + _ => None, + }) + }); + for field_name in field_iter { let raw = self .form_inputs .get(&field_name) .map(|inp| inp.read(&*cx).text().to_string()) .unwrap_or_default(); - // Inferencia ligera: número si parsea, bool en - // true/false, string en cualquier otro caso. Coherente - // con el modelo "el morphism Rhai espera tipos", pero - // simple — para casos finos, el caller puede declarar - // `kind: Number` en el FieldSpec, y el form lo respeta. - let value = infer_param_value(&raw); + let spec = active_form_fields + .as_ref() + .and_then(|fs| fs.iter().find(|f| f.name == field_name)); + let value = resolve_param_value(&field_name, &raw, spec)?; params_obj.insert(field_name, value); } @@ -684,10 +695,40 @@ fn render_value(v: Option<&Value>) -> String { } } +/// Resuelve un param de morphism a su `Value` según el `FieldSpec` +/// del form. **Strict path**: si hay spec, valida `required` y parsea +/// con el `kind` declarado (ej. Boolean rebota con "abc" antes de +/// llegar al morphism). **Fallback path**: si no hay spec (param +/// declarado en `Action::Morphism.params` que no aparece en +/// `form.fields`), usa la heurística `infer_param_value` para no +/// quedar atado a un schema mal-formado. +/// +/// Errores tienen el label legible del spec, así el toast de la UI +/// es interpretable. +fn resolve_param_value( + field_name: &str, + raw: &str, + spec: Option<&FieldSpec>, +) -> Result { + let Some(s) = spec else { + return Ok(infer_param_value(raw)); + }; + + let label = if s.label.is_empty() { field_name } else { &s.label }; + + if s.required && raw.trim().is_empty() { + return Err(format!("param '{label}' es obligatorio y está vacío")); + } + if raw.is_empty() && !s.required { + return Ok(Value::Null); + } + parse_field_value(s.kind, raw).map_err(|e| format!("param '{label}': {e}")) +} + /// Inferencia de tipo para values pasados como `params` a un -/// morphism. Usada cuando el form no declara `FieldKind` explícito -/// (Action::Morphism toma `params: Vec` con sólo los nombres, -/// no los kinds). +/// morphism. Usada como fallback en `resolve_param_value` cuando el +/// param declarado en `Action::Morphism.params` no aparece en los +/// `form.fields` (módulo mal-formado). /// /// Heurística simple: int → i64, float → f64, "true"/"false" → bool, /// resto → string. @@ -1365,6 +1406,72 @@ mod tests { assert_eq!(infer_param_value("hola"), json!("hola")); } + fn spec(name: &str, kind: FieldKind, required: bool) -> FieldSpec { + FieldSpec { + name: name.into(), + label: name.into(), + kind, + default: None, + required, + help: None, + ref_entity: None, + } + } + + #[test] + fn resolve_param_strict_number_parses_i64() { + let s = spec("qty", FieldKind::Number, true); + let v = resolve_param_value("qty", "42", Some(&s)).unwrap(); + assert_eq!(v, json!(42)); + } + + #[test] + fn resolve_param_strict_boolean_rejects_non_boolean() { + let s = spec("active", FieldKind::Boolean, true); + let err = resolve_param_value("active", "abc", Some(&s)).unwrap_err(); + assert!(err.contains("active"), "msg debe mencionar el label: {err}"); + assert!( + err.to_lowercase().contains("bool") || err.contains("'abc'"), + "msg debe explicar el tipo o value: {err}" + ); + } + + #[test] + fn resolve_param_strict_number_rejects_garbage() { + let s = spec("qty", FieldKind::Number, true); + let err = resolve_param_value("qty", "abc", Some(&s)).unwrap_err(); + assert!(err.contains("qty"), "msg debe mencionar el label: {err}"); + } + + #[test] + fn resolve_param_required_empty_rejected() { + let s = spec("name", FieldKind::Text, true); + let err = resolve_param_value("name", " ", Some(&s)).unwrap_err(); + assert!( + err.contains("obligatorio"), + "msg debe decir obligatorio: {err}" + ); + } + + #[test] + fn resolve_param_optional_empty_returns_null() { + let s = spec("notes", FieldKind::Text, false); + let v = resolve_param_value("notes", "", Some(&s)).unwrap(); + assert_eq!(v, json!(null)); + } + + #[test] + fn resolve_param_no_spec_falls_back_to_infer() { + // Sin FieldSpec (módulo mal-formado): infer_param_value + // se usa como red de seguridad. + let v = resolve_param_value("foo", "42", None).unwrap(); + assert_eq!(v, json!(42)); + let v = resolve_param_value("foo", "true", None).unwrap(); + assert_eq!(v, json!(true)); + let v = resolve_param_value("foo", "x", None).unwrap(); + assert_eq!(v, json!("x")); + } + #[test] fn parse_field_entity_ref_returns_string() { // EntityRef se almacena como string del UUID. parse_field_value