feat(nakui-ui): validación estricta de params del morphism vía FieldKind
Reemplaza la heurística `infer_param_value` por parseo estricto basado en el `FieldKind` declarado en el `FieldSpec` correspondiente. Un Boolean con value "abc" ahora rebota en la UI con mensaje claro en lugar de fallar opacamente dentro del morphism Rhai. Cambios: - Nuevo helper `resolve_param_value(field_name, raw, spec)`: - Required + empty → error con label legible. - Optional + empty → Value::Null. - Spec presente → parse_field_value(spec.kind, raw) estricto. - Spec ausente (módulo mal-formado) → fallback a infer_param_value. - `commit_morphism` simplificado: el loop de params ahora delega al helper, que es testable sin GPUI. - 6 tests nuevos cubriendo: número estricto, boolean rechaza "abc", required vacío, optional vacío → null, fallback a infer sin spec. 22 tests verdes (+6 nuevos). E2E del morphism real `morphism_pipeline_executes_real_sales_vender` sigue verde — la validación estricta no afecta el path correcto, sólo agrega rebotes tempranos a values mal-tipados. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,66 @@ ratio/diff ver `git show <sha>`.
|
|||||||
|
|
||||||
## 2026-05-09
|
## 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
|
### feat(nakui-ui): FieldKind::EntityRef — selector clickable de records existentes
|
||||||
Cierra el principal trade-off documentado del commit anterior:
|
Cierra el principal trade-off documentado del commit anterior:
|
||||||
"Inputs UUID a mano (no dropdown)". Los formularios pueden declarar
|
"Inputs UUID a mano (no dropdown)". Los formularios pueden declarar
|
||||||
|
|||||||
Generated
+2
@@ -6155,6 +6155,8 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"yahweh-theme",
|
||||||
|
"yahweh-widget-text-input",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -468,18 +468,29 @@ impl MetaUi {
|
|||||||
} else {
|
} else {
|
||||||
params_fields.to_vec()
|
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<Vec<FieldSpec>> = 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 {
|
for field_name in field_iter {
|
||||||
let raw = self
|
let raw = self
|
||||||
.form_inputs
|
.form_inputs
|
||||||
.get(&field_name)
|
.get(&field_name)
|
||||||
.map(|inp| inp.read(&*cx).text().to_string())
|
.map(|inp| inp.read(&*cx).text().to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
// Inferencia ligera: número si parsea, bool en
|
let spec = active_form_fields
|
||||||
// true/false, string en cualquier otro caso. Coherente
|
.as_ref()
|
||||||
// con el modelo "el morphism Rhai espera tipos", pero
|
.and_then(|fs| fs.iter().find(|f| f.name == field_name));
|
||||||
// simple — para casos finos, el caller puede declarar
|
let value = resolve_param_value(&field_name, &raw, spec)?;
|
||||||
// `kind: Number` en el FieldSpec, y el form lo respeta.
|
|
||||||
let value = infer_param_value(&raw);
|
|
||||||
params_obj.insert(field_name, value);
|
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<Value, String> {
|
||||||
|
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
|
/// Inferencia de tipo para values pasados como `params` a un
|
||||||
/// morphism. Usada cuando el form no declara `FieldKind` explícito
|
/// morphism. Usada como fallback en `resolve_param_value` cuando el
|
||||||
/// (Action::Morphism toma `params: Vec<String>` con sólo los nombres,
|
/// param declarado en `Action::Morphism.params` no aparece en los
|
||||||
/// no los kinds).
|
/// `form.fields` (módulo mal-formado).
|
||||||
///
|
///
|
||||||
/// Heurística simple: int → i64, float → f64, "true"/"false" → bool,
|
/// Heurística simple: int → i64, float → f64, "true"/"false" → bool,
|
||||||
/// resto → string.
|
/// resto → string.
|
||||||
@@ -1365,6 +1406,72 @@ mod tests {
|
|||||||
assert_eq!(infer_param_value("hola"), json!("hola"));
|
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]
|
#[test]
|
||||||
fn parse_field_entity_ref_returns_string() {
|
fn parse_field_entity_ref_returns_string() {
|
||||||
// EntityRef se almacena como string del UUID. parse_field_value
|
// EntityRef se almacena como string del UUID. parse_field_value
|
||||||
|
|||||||
Reference in New Issue
Block a user