From 61044844980dd4f8505d9ba94600d9b92a85423e Mon Sep 17 00:00:00 2001 From: Sergio Date: Sun, 10 May 2026 01:17:17 +0000 Subject: [PATCH] =?UTF-8?q?refactor(yahweh):=20Fase=202=20=E2=80=94=20extr?= =?UTF-8?q?aer=20helpers=20puros=20a=20yahweh-meta-runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sigue de la Fase 1 (lift del schema). Ahora los helpers puros que cualquier widget renderer o backend ejecutor consume sobre el schema viven en yahweh-meta-runtime. Sin GPUI, sin nakui — usa cierres en lugar de traits para decoupling máximo. Crate nuevo crates/modules/ui_engine/libs/meta-runtime: - parse.rs: parse_field_value, infer_param_value, resolve_param_value. - delta.rs: compute_field_delta, compute_clear_fields. - refs.rs: validate_entity_refs(load: F, refs) con cierre Fn(&str, Uuid) -> Option en vez de trait Store. - format.rs: human_label_for_record, render_value, value_to_input_text, short_uuid. - 33 tests propios. nakui-ui: - Nueva dep yahweh-meta-runtime. - Borrado código local equivalente (~200 líneas) + 34 tests duplicados. - validate_entity_refs callsite usa cierre: validate_entity_refs(|e, id| store.load(e, id), &refs). - 14 tests runtime-específicos quedan (compact/snapshot/event-log/ morphism pipeline/load_ui_modules). Distribución tests: 48 → 14 nakui-ui; +33 yahweh-meta-runtime. Cada crate afectado builds + tests limpio individualmente. Workspace build full no completó esta corrida por OOM al compilar surrealdb-core (ambiental, no relacionado). Fase 2b pendiente: extraer render widgets (form/list/modal/ EntityRef selector) a yahweh — requiere diseñar MetaBackend trait. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 89 ++- Cargo.lock | 11 + Cargo.toml | 1 + crates/apps/nakui-ui/Cargo.toml | 1 + crates/apps/nakui-ui/src/main.rs | 611 +----------------- .../ui_engine/libs/meta-runtime/Cargo.toml | 12 + .../ui_engine/libs/meta-runtime/src/delta.rs | 134 ++++ .../ui_engine/libs/meta-runtime/src/format.rs | 116 ++++ .../ui_engine/libs/meta-runtime/src/lib.rs | 33 + .../ui_engine/libs/meta-runtime/src/parse.rs | 231 +++++++ .../ui_engine/libs/meta-runtime/src/refs.rs | 108 ++++ 11 files changed, 755 insertions(+), 592 deletions(-) create mode 100644 crates/modules/ui_engine/libs/meta-runtime/Cargo.toml create mode 100644 crates/modules/ui_engine/libs/meta-runtime/src/delta.rs create mode 100644 crates/modules/ui_engine/libs/meta-runtime/src/format.rs create mode 100644 crates/modules/ui_engine/libs/meta-runtime/src/lib.rs create mode 100644 crates/modules/ui_engine/libs/meta-runtime/src/parse.rs create mode 100644 crates/modules/ui_engine/libs/meta-runtime/src/refs.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c24edfd..1cea0a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,94 @@ Registro cronológico de cambios sustantivos en el monorepo Brahman. Cada entrada lista las acciones concretas tras un commit; para detalles de ratio/diff ver `git show `. -## 2026-05-09 +## 2026-05-10 + +### refactor(yahweh): Fase 2 — extraer helpers puros a `yahweh-meta-runtime` +Sigue de la Fase 1 (lift del schema a yahweh). Ahora extraemos los +**helpers puros** que cualquier widget renderer o backend ejecutor +necesita sobre el schema: parse, delta, validation, format. Sin +GPUI, sin acoplamiento a un backend específico. + +Crate nuevo: `crates/modules/ui_engine/libs/meta-runtime/` +(`yahweh-meta-runtime`): +- **Deps**: `serde_json`, `thiserror`, `uuid`, `yahweh-meta-schema`. + NO GPUI, NO nakui. +- **Módulos**: + - `parse.rs` — `parse_field_value(kind, raw)`, + `infer_param_value(raw)`, `resolve_param_value(name, raw, spec)`. + - `delta.rs` — `compute_field_delta(current, proposed)`, + `compute_clear_fields(current, to_clear)`. + - `refs.rs` — `validate_entity_refs(load: F, refs)` donde `F` + es un cierre `Fn(&str, Uuid) -> Option`. Decoupling vía + closure en lugar de trait — evita atar el crate a cualquier + backend específico (no hay `Store` trait acá), y los callers + pasan `|e, id| store.load(e, id)` trivialmente. + - `format.rs` — `human_label_for_record(value, id)`, + `render_value(opt_value)`, `value_to_input_text(value)`, + `short_uuid(id)`. +- **33 tests propios** en el crate nuevo (cubren todos los helpers + movidos + edge cases). + +Cambios en `nakui-ui`: +- **Nueva dep** `yahweh-meta-runtime` en `Cargo.toml`. +- **Imports**: agrega `use yahweh_meta_runtime::{...}` con todos los + helpers extraídos. Borrado el código local equivalente + (~200 líneas). +- **`validate_entity_refs` callsite**: pasa de + `validate_entity_refs(&*store, &refs)` a + `validate_entity_refs(|e, id| store.load(e, id), &refs)` — el + closure es ergonómico sobre cualquier `Store`. +- **Tests duplicados borrados** (~34 tests que ahora viven en + `yahweh-meta-runtime`): + - `parse_field_*` (text/number/boolean variants) + - `infer_param_value_*` + - `delta_*` (5 tests) + - `clear_fields_*` (3 tests) + - `validate_entity_refs_*` (5 tests) + - `resolve_param_*` (6 tests) + - `parse_field_entity_ref_*` (4 tests) + - `human_label_*` (3 tests), `render_value_*`, + `value_to_input_text_inverse_of_parse` +- **Tests que se quedan en nakui-ui** (runtime-específicos): + - `lookup_field_simple_and_nested` — helper local del list renderer. + - `append_compact_msg_handles_both_branches`, + `runtime_compact_cycle_resets_counter_after_threshold`, + `snapshot_path_for_replaces_extension`, + `maybe_compact_log_*` (3) — wiring de persistencia a EventLog. + - `load_ui_modules_via_brahman_cards_*` (3) — integración con el + brazo de cards. + - `value_to_input_then_parse_round_trip` — round-trip del par + `value_to_input_text + parse_field_value` (toca ambos lados). + - `event_log_replay_restores_memory_store`, + `morphism_pipeline_executes_real_sales_vender`, + `event_log_replay_handles_full_crud_cycle` — E2E nakui-core. + +Distribución de tests: +- `nakui-ui`: 48 → 14 (los 34 movidos viven en runtime). +- `yahweh-meta-runtime`: 33 (nuevos). +- `yahweh-meta-schema`: 8 (sin cambio). +- `brahman-cards`: 26 (sin cambio). +- Total cubriendo el área: 81. + +Build: cada crate afectado compila y testea limpio individualmente. +Workspace build full no se completó esta corrida por OOM al +compilar `surrealdb-core` (problema ambiental no relacionado al +refactor). + +Lo que NO hace Fase 2: +- No mueve los widgets render (`render_form`/`render_list`/ + `render_entity_ref_selector`/`render_confirm_delete_banner`) a + yahweh — eso es Fase 2b/3, requiere diseñar el `MetaBackend` + trait porque las render functions tocan el state de `MetaUi` + (form_inputs, pending_delete, executors). + +**Pendientes** (orden): +1. **Fase 2b**: extraer widget render a un crate yahweh nuevo + (sugerencia: `yahweh-widget-meta-form`). Requiere diseñar + `MetaBackend` trait. +2. **Fase 3**: thin shell — `nakui-ui` queda reducido a una impl + de backend wireada a `nakui-core`. +3. **KCL → Nickel** + **card.k eliminado**. ### refactor(yahweh): Fase 1 — `nakui-ui-schema` → `yahweh-meta-schema` Primer paso del refactor yahweh. El schema de UI declarativa diff --git a/Cargo.lock b/Cargo.lock index 542cb0b..4caecf6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6399,6 +6399,7 @@ dependencies = [ "serde_json", "tempfile", "uuid", + "yahweh-meta-runtime", "yahweh-meta-schema", "yahweh-theme", "yahweh-widget-text-input", @@ -12830,6 +12831,16 @@ dependencies = [ "yahweh-theme", ] +[[package]] +name = "yahweh-meta-runtime" +version = "0.1.0" +dependencies = [ + "serde_json", + "thiserror 2.0.18", + "uuid", + "yahweh-meta-schema", +] + [[package]] name = "yahweh-meta-schema" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index a4e8e12..cd9e1cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ members = [ "crates/modules/ui_engine/libs/theme", "crates/modules/ui_engine/libs/bus", "crates/modules/ui_engine/libs/meta-schema", + "crates/modules/ui_engine/libs/meta-runtime", "crates/modules/ui_engine/libs/providers/fs", "crates/modules/ui_engine/libs/providers/sqlite", "crates/modules/ui_engine/widgets/tree", diff --git a/crates/apps/nakui-ui/Cargo.toml b/crates/apps/nakui-ui/Cargo.toml index 3a0c684..9fe6b12 100644 --- a/crates/apps/nakui-ui/Cargo.toml +++ b/crates/apps/nakui-ui/Cargo.toml @@ -8,6 +8,7 @@ description = "Nakui — runtime GPUI de la metainterfaz: carga module.json desd [dependencies] nakui-core = { path = "../../modules/nakui/core" } yahweh-meta-schema = { path = "../../modules/ui_engine/libs/meta-schema" } +yahweh-meta-runtime = { path = "../../modules/ui_engine/libs/meta-runtime" } brahman-cards = { path = "../../core/brahman-cards" } yahweh-widget-text-input = { path = "../../modules/ui_engine/widgets/text_input" } yahweh-theme = { path = "../../modules/ui_engine/libs/theme" } diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs index 3f6b979..a62d504 100644 --- a/crates/apps/nakui-ui/src/main.rs +++ b/crates/apps/nakui-ui/src/main.rs @@ -37,6 +37,10 @@ use nakui_core::event_log::{ use brahman_cards::CardBody; use nakui_core::executor::Executor; use nakui_core::store::{MemoryStore, Store}; +use yahweh_meta_runtime::{ + compute_clear_fields, compute_field_delta, human_label_for_record, parse_field_value, + render_value, resolve_param_value, short_uuid, validate_entity_refs, value_to_input_text, +}; use yahweh_meta_schema::{ Action, FieldKind, FieldSpec, FormView, ListView, Module, View, }; @@ -764,7 +768,10 @@ impl MetaUi { .store .lock() .map_err(|_| "store mutex envenenado".to_string())?; - validate_entity_refs(&*store, &entity_refs)?; + // El helper de yahweh-meta-runtime es store-agnóstico — + // toma un cierre `Fn(&str, Uuid) -> Option` que + // wrappea el store concreto. + validate_entity_refs(|e, id| store.load(e, id), &entity_refs)?; } // Ramificación: si `editing` está set para esta entity, es un // edit de un record existente — emitimos Morphism con un @@ -1029,121 +1036,9 @@ fn maybe_compact_log( ))) } -/// Valida que cada UUID en `refs` apunte a un record que realmente -/// existe en el store bajo la entity esperada. Devuelve el primer -/// error encontrado (fail-fast). -/// -/// `refs` es una lista de `(label, target_entity, uuid)`. El label -/// va al error message, así que conviene que sea legible (ej: -/// `FieldSpec.label` en lugar de `FieldSpec.name`). -/// -/// Sólo se llama desde el SEED path de la UI. Los inputs de morphism -/// no necesitan este check porque `Executor::compute` ya valida cada -/// input via `store.load(...).ok_or(EntityMissing)` antes de correr -/// el script Rhai. -fn validate_entity_refs( - store: &S, - refs: &[(String, String, Uuid)], -) -> Result<(), String> { - for (label, target, id) in refs { - if store.load(target, *id).is_none() { - return Err(format!( - "campo '{label}': record {} de '{target}' no existe en el store", - short_uuid(id) - )); - } - } - Ok(()) -} - -/// Decide cuáles fields del `to_clear` candidate list ameritan -/// realmente un `FieldOp::Clear`: sólo los que existen en el current -/// con un valor non-null. Para fields ausentes o ya null, Clear es -/// no-op semántico (el post-state es el mismo) y dropearlos -/// preserva la propiedad "1 op = 1 cambio efectivo" del log. -/// -/// Preserva el orden del input para que el log entry sea estable. -fn compute_clear_fields(current: &Value, to_clear: &[String]) -> Vec { - to_clear - .iter() - .filter(|f| match current.get(f.as_str()) { - None | Some(Value::Null) => false, - Some(_) => true, - }) - .cloned() - .collect() -} - -/// Calcula el delta entre el record actual y los valores propuestos -/// del form. Devuelve un Map con sólo los campos cuyo valor difiere. -/// -/// Comparación: igualdad estructural sobre `serde_json::Value`. Un -/// `current=Value::Null` (record no encontrado) hace que todos los -/// campos del `proposed` sean considerados nuevos. Un campo del -/// proposed que coincide con el del current se omite. Campos que -/// están en current pero NO en proposed se preservan tal cual (el -/// edit no los toca; ver el comentario en commit_seed sobre por qué -/// no clearemos campos vacíos). -fn compute_field_delta( - current: &Value, - proposed: &serde_json::Map, -) -> serde_json::Map { - proposed - .iter() - .filter(|(field, value)| current.get(field.as_str()) != Some(*value)) - .map(|(k, v)| (k.clone(), v.clone())) - .collect() -} - -fn parse_field_value(kind: FieldKind, raw: &str) -> Result { - match kind { - FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)), - // EntityRef se almacena como string del UUID seleccionado. - // Validamos que parsee como UUID al submit — antes esto se - // chequeaba sólo para morphism inputs (línea ~540), pero un - // EntityRef como SEED field o como param de morphism caía - // de la heurística silenciosa. Ahora rebota con mensaje - // claro acá, antes de tocar el log o el morphism Rhai. - // El selector clickable garantiza UUIDs válidos en happy - // path; este check protege paste manual o garbage. - FieldKind::EntityRef => { - let trimmed = raw.trim(); - Uuid::parse_str(trimmed).map_err(|_| { - format!("'{raw}' no es UUID válido (usá el selector de records)") - })?; - Ok(json!(trimmed)) - } - FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() { - "true" | "yes" | "1" | "on" | "y" => Ok(json!(true)), - "" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)), - other => Err(format!("'{other}' no es booleano")), - }, - FieldKind::Number => { - if let Ok(i) = raw.parse::() { - Ok(json!(i)) - } else if let Ok(f) = raw.parse::() { - Ok(json!(f)) - } else { - Err(format!("'{raw}' no es número")) - } - } - } -} - -/// Etiqueta humana para representar un record en el selector de -/// EntityRef. Heurística: prefiere campos comunes en este orden: -/// `name`, `label`, `title`, `sku`, `sku_id`. Fallback al UUID corto. -fn human_label_for_record(value: &Value, id: &Uuid) -> String { - for key in ["name", "label", "title", "sku", "sku_id"] { - if let Some(v) = value.get(key).and_then(Value::as_str) { - if !v.is_empty() { - return format!("{} ({})", v, short_uuid(id)); - } - } - } - short_uuid(id) -} - +/// Walker dentro de un `Value` por path con `.` como separador. +/// Local porque sólo lo usa la lista renderer y no tiene tests +/// dedicados afuera. Si crece su uso se puede mover a meta-runtime. fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> { let mut cur = v; for seg in path.split('.') { @@ -1152,88 +1047,6 @@ fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> { Some(cur) } -fn render_value(v: Option<&Value>) -> String { - match v { - None | Some(Value::Null) => String::new(), - Some(Value::String(s)) => s.clone(), - Some(Value::Bool(b)) => if *b { "✓" } else { "✗" }.to_string(), - Some(Value::Number(n)) => n.to_string(), - Some(other) => other.to_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 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. -fn infer_param_value(raw: &str) -> Value { - if raw.is_empty() { - return Value::Null; - } - if let Ok(i) = raw.parse::() { - return json!(i); - } - if let Ok(f) = raw.parse::() { - return json!(f); - } - match raw { - "true" => return json!(true), - "false" => return json!(false), - _ => {} - } - json!(raw) -} - -/// Conversión inversa a `parse_field_value`: del JSON al texto raw -/// que un input puede tomar y volver a parsearse igual al submit. -/// Usado para pre-llenar inputs en modo edit. -fn value_to_input_text(v: &Value) -> String { - match v { - Value::Null => String::new(), - Value::String(s) => s.clone(), - Value::Bool(b) => if *b { "true" } else { "false" }.to_string(), - Value::Number(n) => n.to_string(), - other => other.to_string(), - } -} - -fn short_uuid(id: &Uuid) -> String { - id.to_string().chars().take(8).collect() -} - impl Render for MetaUi { fn render(&mut self, _w: &mut Window, cx: &mut Context) -> impl IntoElement { let bg = gpui::rgb(0x14171c); @@ -1944,30 +1757,9 @@ impl MetaUi { mod tests { use super::*; - #[test] - fn parse_field_text_returns_string() { - assert_eq!(parse_field_value(FieldKind::Text, "hola").unwrap(), json!("hola")); - } - - #[test] - fn parse_field_number_int_then_float() { - assert_eq!(parse_field_value(FieldKind::Number, "42").unwrap(), json!(42)); - assert_eq!(parse_field_value(FieldKind::Number, "3.14").unwrap(), json!(3.14)); - } - - #[test] - fn parse_field_number_invalid_errors() { - assert!(parse_field_value(FieldKind::Number, "not-a-number").is_err()); - } - - #[test] - fn parse_field_boolean_variants() { - assert_eq!(parse_field_value(FieldKind::Boolean, "true").unwrap(), json!(true)); - assert_eq!(parse_field_value(FieldKind::Boolean, "yes").unwrap(), json!(true)); - assert_eq!(parse_field_value(FieldKind::Boolean, "false").unwrap(), json!(false)); - assert_eq!(parse_field_value(FieldKind::Boolean, "").unwrap(), json!(false)); - assert!(parse_field_value(FieldKind::Boolean, "maybe").is_err()); - } + // NOTA: `parse_field_value` / `parse_field_*` viven y se testean + // en `yahweh-meta-runtime`. Tests duplicados aquí se borraron en + // la Fase 2 del refactor yahweh. #[test] fn lookup_field_simple_and_nested() { @@ -1981,118 +1773,12 @@ mod tests { assert!(lookup_field(&v, "address.zipcode").is_none()); } - #[test] - fn infer_param_value_int_then_float_then_bool_then_string() { - assert_eq!(infer_param_value(""), json!(null)); - assert_eq!(infer_param_value("42"), json!(42)); - assert_eq!(infer_param_value("3.14"), json!(3.14)); - assert_eq!(infer_param_value("true"), json!(true)); - assert_eq!(infer_param_value("false"), json!(false)); - 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, - } - } - - fn map(items: &[(&str, Value)]) -> serde_json::Map { - items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect() - } - - #[test] - fn delta_empty_when_all_fields_match() { - let current = json!({ - "name": "Acme", - "saldo": 100_i64, - "currency": "USD", - }); - let proposed = map(&[ - ("name", json!("Acme")), - ("saldo", json!(100_i64)), - ("currency", json!("USD")), - ]); - let delta = compute_field_delta(¤t, &proposed); - assert!(delta.is_empty(), "no-op edit debería dar delta vacío"); - } - - #[test] - fn delta_includes_only_changed_field() { - let current = json!({ - "name": "Acme", - "saldo": 100_i64, - "currency": "USD", - }); - // El usuario sólo cambió saldo. - let proposed = map(&[ - ("name", json!("Acme")), - ("saldo", json!(200_i64)), - ("currency", json!("USD")), - ]); - let delta = compute_field_delta(¤t, &proposed); - assert_eq!(delta.len(), 1, "sólo saldo debería estar en delta"); - assert_eq!(delta.get("saldo"), Some(&json!(200_i64))); - assert!(!delta.contains_key("name")); - assert!(!delta.contains_key("currency")); - } - - #[test] - fn delta_treats_missing_record_as_all_new() { - // Record no existe en el store (load → None → Value::Null). - // Todos los campos del proposed deberían entrar al delta. - let current = Value::Null; - let proposed = map(&[ - ("name", json!("Acme")), - ("saldo", json!(0_i64)), - ]); - let delta = compute_field_delta(¤t, &proposed); - assert_eq!(delta.len(), 2); - } - - #[test] - fn delta_distinguishes_int_from_string_repr() { - // Sanity: si el form devuelve "100" como Number → json!(100_i64) - // y el store tiene json!(100), comparan iguales (PartialEq de - // Value normaliza). Si el store tuviera "100" string, NO igualan. - let current = json!({"qty": 100_i64}); - let proposed = map(&[("qty", json!(100_i64))]); - assert!(compute_field_delta(¤t, &proposed).is_empty()); - - let current_str = json!({"qty": "100"}); - let proposed_int = map(&[("qty", json!(100_i64))]); - assert_eq!( - compute_field_delta(¤t_str, &proposed_int).len(), - 1, - "string '100' vs int 100 sí debería contar como cambio" - ); - } - - #[test] - fn delta_skips_fields_absent_from_proposed() { - // Si el form omite un field (porque el FieldSpec no lo - // declara), no lo deberíamos mencionar en el delta — el edit - // sólo toca los fields del form. - let current = json!({ - "name": "Acme", - "saldo": 100_i64, - "internal_marker": "x", - }); - let proposed = map(&[ - ("name", json!("Acme")), - ("saldo", json!(150_i64)), - ]); - let delta = compute_field_delta(¤t, &proposed); - assert_eq!(delta.len(), 1); - assert_eq!(delta.get("saldo"), Some(&json!(150_i64))); - assert!(!delta.contains_key("internal_marker")); - } + // `infer_param_value`, helpers `spec`/`map`, todos los tests + // delta_* / clear_fields_* / parse_field_* / resolve_param_* / + // human_label_* / render_value / value_to_input_text / validate_entity_refs_* + // viven en `yahweh-meta-runtime`. Borrados en Fase 2 — quedan acá + // sólo tests de funcionalidad runtime-específica (compact, snapshot, + // event log, morphism pipeline, load_ui_modules). #[test] fn append_compact_msg_handles_both_branches() { @@ -2174,71 +1860,6 @@ mod tests { let _ = std::fs::remove_file(&snap_path); } - #[test] - fn validate_entity_refs_passes_when_all_records_exist() { - let mut store = MemoryStore::new(); - let stock_id = Uuid::new_v4(); - let caja_id = Uuid::new_v4(); - store.seed("Stock", stock_id, json!({"sku_id": "abc"})); - store.seed("Caja", caja_id, json!({"name": "Principal"})); - let refs = vec![ - ("Stock".into(), "Stock".into(), stock_id), - ("Caja".into(), "Caja".into(), caja_id), - ]; - assert!(validate_entity_refs(&store, &refs).is_ok()); - } - - #[test] - fn validate_entity_refs_fails_on_first_missing() { - let mut store = MemoryStore::new(); - let stock_id = Uuid::new_v4(); - store.seed("Stock", stock_id, json!({"sku_id": "abc"})); - let missing_caja = Uuid::new_v4(); - let refs = vec![ - ("Stock".into(), "Stock".into(), stock_id), - ("Caja".into(), "Caja".into(), missing_caja), - ]; - let err = validate_entity_refs(&store, &refs).unwrap_err(); - assert!(err.contains("Caja"), "msg debe nombrar la entity: {err}"); - assert!( - err.contains(&short_uuid(&missing_caja)), - "msg debe incluir el UUID corto: {err}" - ); - } - - #[test] - fn validate_entity_refs_uses_label_not_entity_in_msg() { - // Si el FieldSpec.label es distinto de la entity (ej: - // "Stock origen" en lugar de "Stock"), el error debería usar - // el label legible. - let store = MemoryStore::new(); - let id = Uuid::new_v4(); - let refs = vec![("Stock origen".into(), "Stock".into(), id)]; - let err = validate_entity_refs(&store, &refs).unwrap_err(); - assert!( - err.contains("Stock origen"), - "msg debe incluir el label: {err}" - ); - } - - #[test] - fn validate_entity_refs_empty_list_is_ok() { - let store = MemoryStore::new(); - assert!(validate_entity_refs(&store, &[]).is_ok()); - } - - #[test] - fn validate_entity_refs_distinguishes_target_from_other_entities() { - // Sanity: un UUID que existe bajo entity X pero NO bajo Y - // debería fallar la validación contra Y. - let mut store = MemoryStore::new(); - let id = Uuid::new_v4(); - store.seed("Customer", id, json!({"name": "Acme"})); - // Mismo UUID, target distinto. - let refs = vec![("Stock".into(), "Stock".into(), id)]; - assert!(validate_entity_refs(&store, &refs).is_err()); - } - /// E2E del nuevo `load_ui_modules` que pasa por /// `brahman_cards::load_cards_from_dir`. Verifica: /// 1. UiModules cargados ordenados por id. @@ -2335,44 +1956,6 @@ mod tests { assert!(err.contains("dup"), "msg debe nombrar el id: {err}"); } - #[test] - fn clear_fields_skips_absent_and_null() { - let current = json!({ - "name": "Acme", - "notes": "lorem", - "tag": null, - }); - let to_clear = vec![ - "name".to_string(), - "notes".to_string(), - "tag".to_string(), - "missing".to_string(), - ]; - let actual = compute_clear_fields(¤t, &to_clear); - assert_eq!( - actual, - vec!["name".to_string(), "notes".to_string()], - "tag (null) y missing (ausente) deberían filtrarse — Clear sería no-op" - ); - } - - #[test] - fn clear_fields_preserves_input_order() { - let current = json!({"a": 1, "b": 2, "c": 3}); - let to_clear = vec!["c".to_string(), "a".to_string(), "b".to_string()]; - let actual = compute_clear_fields(¤t, &to_clear); - assert_eq!(actual, vec!["c", "a", "b"], "orden del input se preserva"); - } - - #[test] - fn clear_fields_empty_when_current_is_null() { - // Record no existe en el store (load → None → Value::Null - // upstream). Ningún clear debería emitirse. - let current = Value::Null; - let to_clear = vec!["name".to_string()]; - assert!(compute_clear_fields(¤t, &to_clear).is_empty()); - } - #[test] fn snapshot_path_for_replaces_extension() { use std::path::Path; @@ -2520,160 +2103,6 @@ mod tests { let _ = std::fs::remove_file(&snap_path); } - #[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_accepts_valid_uuid() { - let id = Uuid::new_v4(); - let v = parse_field_value(FieldKind::EntityRef, &id.to_string()).unwrap(); - assert_eq!(v, json!(id.to_string())); - } - - #[test] - fn parse_field_entity_ref_trims_whitespace() { - // El selector clickable garantiza el value pelado; este check - // protege contra paste manual con espacios accidentales. - let id = Uuid::new_v4(); - let padded = format!(" {id}\n"); - let v = parse_field_value(FieldKind::EntityRef, &padded).unwrap(); - assert_eq!(v, json!(id.to_string()), "debería trimear y devolver el UUID limpio"); - } - - #[test] - fn parse_field_entity_ref_rejects_non_uuid() { - let err = parse_field_value(FieldKind::EntityRef, "abc-123").unwrap_err(); - assert!(err.contains("'abc-123'"), "msg debe mencionar el value: {err}"); - assert!( - err.contains("UUID") || err.contains("uuid"), - "msg debe mencionar UUID: {err}" - ); - } - - #[test] - fn parse_field_entity_ref_rejects_empty_string() { - // Un EntityRef vacío al submit: el form lo manda como "" - // si el usuario no clickeó nada. Debería rebotar acá en - // lugar de loguear "" como un record id basura. - let err = parse_field_value(FieldKind::EntityRef, "").unwrap_err(); - assert!(err.contains("UUID"), "msg debe mencionar UUID: {err}"); - } - - #[test] - fn resolve_param_strict_entity_ref_propagates_error() { - // Sanity: resolve_param_value con kind=EntityRef invoca - // parse_field_value y propaga el error de UUID inválido, - // con el label del FieldSpec en el mensaje. - let s = spec("stock_ref", FieldKind::EntityRef, true); - let err = resolve_param_value("stock_ref", "not-a-uuid", Some(&s)).unwrap_err(); - assert!(err.contains("stock_ref"), "msg debe incluir label: {err}"); - assert!(err.contains("UUID"), "msg debe mencionar UUID: {err}"); - } - - #[test] - fn human_label_for_record_prefers_name_over_id() { - let id = Uuid::new_v4(); - let with_name = json!({"name": "Acme S.A.", "email": "x@y.z"}); - let label = human_label_for_record(&with_name, &id); - assert!(label.starts_with("Acme S.A."), "got: {label}"); - assert!(label.contains(&short_uuid(&id))); - } - - #[test] - fn human_label_falls_back_through_label_title_sku() { - let id = Uuid::new_v4(); - let only_label = json!({"label": "X"}); - assert!(human_label_for_record(&only_label, &id).starts_with("X ")); - let only_title = json!({"title": "Y"}); - assert!(human_label_for_record(&only_title, &id).starts_with("Y ")); - let only_sku = json!({"sku": "Z-001"}); - assert!(human_label_for_record(&only_sku, &id).starts_with("Z-001 ")); - let only_sku_id = json!({"sku_id": "W-002"}); - assert!(human_label_for_record(&only_sku_id, &id).starts_with("W-002 ")); - } - - #[test] - fn human_label_falls_back_to_id_when_no_known_keys() { - let id = Uuid::new_v4(); - let v = json!({"weird_field": "val"}); - assert_eq!(human_label_for_record(&v, &id), short_uuid(&id)); - } - - #[test] - fn render_value_handles_null_string_bool() { - assert_eq!(render_value(None), ""); - assert_eq!(render_value(Some(&json!(null))), ""); - assert_eq!(render_value(Some(&json!("x"))), "x"); - assert_eq!(render_value(Some(&json!(true))), "✓"); - assert_eq!(render_value(Some(&json!(false))), "✗"); - assert_eq!(render_value(Some(&json!(42))), "42"); - } - - #[test] - fn value_to_input_text_inverse_of_parse() { - // text → text - assert_eq!(value_to_input_text(&json!("hola")), "hola"); - // bool → "true"/"false" (parse_field_value lo acepta) - assert_eq!(value_to_input_text(&json!(true)), "true"); - assert_eq!(value_to_input_text(&json!(false)), "false"); - // number → string - assert_eq!(value_to_input_text(&json!(42)), "42"); - assert_eq!(value_to_input_text(&json!(3.14)), "3.14"); - // null → "" - assert_eq!(value_to_input_text(&json!(null)), ""); - } - #[test] fn value_to_input_then_parse_round_trip() { // El round-trip es la propiedad fundamental: edit → text → diff --git a/crates/modules/ui_engine/libs/meta-runtime/Cargo.toml b/crates/modules/ui_engine/libs/meta-runtime/Cargo.toml new file mode 100644 index 0000000..9437516 --- /dev/null +++ b/crates/modules/ui_engine/libs/meta-runtime/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "yahweh-meta-runtime" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Yahweh — meta-runtime: helpers puros (parse, delta, validación, formato) que cualquier widget metainterfaz consume sobre `yahweh-meta-schema`. Sin GPUI, sin backend específico — toma cierres/closures para acceder al store." + +[dependencies] +serde_json = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true, features = ["serde"] } +yahweh-meta-schema = { path = "../meta-schema" } diff --git a/crates/modules/ui_engine/libs/meta-runtime/src/delta.rs b/crates/modules/ui_engine/libs/meta-runtime/src/delta.rs new file mode 100644 index 0000000..bccb5f7 --- /dev/null +++ b/crates/modules/ui_engine/libs/meta-runtime/src/delta.rs @@ -0,0 +1,134 @@ +//! Cálculo del delta entre el record actual y la propuesta del form. +//! +//! Sirve a un runtime de edición para emitir SOLO los Set/Clear que +//! cambian algo: log + apply minimales, no-op edits = 0 entries. + +use serde_json::Value; + +/// Calcula el delta entre el record actual y los valores propuestos +/// del form. Devuelve un Map con sólo los campos cuyo valor difiere. +/// +/// Comparación: igualdad estructural sobre `serde_json::Value`. Un +/// `current=Value::Null` (record no encontrado) hace que todos los +/// campos del `proposed` sean considerados nuevos. Un campo del +/// proposed que coincide con el del current se omite. Campos que +/// están en current pero NO en proposed se preservan tal cual (el +/// edit no los toca; ver [`compute_clear_fields`] para borrar +/// explícito desde un input vacío). +pub fn compute_field_delta( + current: &Value, + proposed: &serde_json::Map, +) -> serde_json::Map { + proposed + .iter() + .filter(|(field, value)| current.get(field.as_str()) != Some(*value)) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() +} + +/// Decide cuáles fields del `to_clear` candidate list ameritan +/// realmente un `FieldOp::Clear`: sólo los que existen en el current +/// con un valor non-null. Para fields ausentes o ya null, Clear es +/// no-op semántico (el post-state es el mismo) y dropearlos +/// preserva la propiedad "1 op = 1 cambio efectivo" del log. +/// +/// Preserva el orden del input para que el log entry sea estable. +pub fn compute_clear_fields(current: &Value, to_clear: &[String]) -> Vec { + to_clear + .iter() + .filter(|f| match current.get(f.as_str()) { + None | Some(Value::Null) => false, + Some(_) => true, + }) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn map(items: &[(&str, Value)]) -> serde_json::Map { + items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect() + } + + #[test] + fn delta_empty_when_all_fields_match() { + let current = json!({"name": "Acme", "saldo": 100_i64, "currency": "USD"}); + let proposed = map(&[ + ("name", json!("Acme")), + ("saldo", json!(100_i64)), + ("currency", json!("USD")), + ]); + assert!(compute_field_delta(¤t, &proposed).is_empty()); + } + + #[test] + fn delta_includes_only_changed_field() { + let current = json!({"name": "Acme", "saldo": 100_i64}); + let proposed = map(&[("name", json!("Acme")), ("saldo", json!(200_i64))]); + let d = compute_field_delta(¤t, &proposed); + assert_eq!(d.len(), 1); + assert_eq!(d.get("saldo"), Some(&json!(200_i64))); + } + + #[test] + fn delta_treats_missing_record_as_all_new() { + let current = Value::Null; + let proposed = map(&[("name", json!("Acme")), ("saldo", json!(0_i64))]); + assert_eq!(compute_field_delta(¤t, &proposed).len(), 2); + } + + #[test] + fn delta_distinguishes_int_from_string_repr() { + let current = json!({"qty": 100_i64}); + let proposed = map(&[("qty", json!(100_i64))]); + assert!(compute_field_delta(¤t, &proposed).is_empty()); + + let current_str = json!({"qty": "100"}); + let proposed_int = map(&[("qty", json!(100_i64))]); + assert_eq!(compute_field_delta(¤t_str, &proposed_int).len(), 1); + } + + #[test] + fn delta_skips_fields_absent_from_proposed() { + let current = json!({"name": "Acme", "saldo": 100_i64, "extra": "x"}); + let proposed = map(&[("name", json!("Acme")), ("saldo", json!(150_i64))]); + let d = compute_field_delta(¤t, &proposed); + assert_eq!(d.len(), 1); + assert!(!d.contains_key("extra")); + } + + #[test] + fn clear_fields_skips_absent_and_null() { + let current = json!({"name": "Acme", "notes": "lorem", "tag": null}); + let to_clear = vec![ + "name".into(), + "notes".into(), + "tag".into(), + "missing".into(), + ]; + assert_eq!( + compute_clear_fields(¤t, &to_clear), + vec!["name".to_string(), "notes".to_string()] + ); + } + + #[test] + fn clear_fields_preserves_input_order() { + let current = json!({"a": 1, "b": 2, "c": 3}); + let to_clear = vec!["c".into(), "a".into(), "b".into()]; + assert_eq!( + compute_clear_fields(¤t, &to_clear), + vec!["c", "a", "b"] + ); + } + + #[test] + fn clear_fields_empty_when_current_is_null() { + let current = Value::Null; + let to_clear = vec!["name".into()]; + assert!(compute_clear_fields(¤t, &to_clear).is_empty()); + } +} diff --git a/crates/modules/ui_engine/libs/meta-runtime/src/format.rs b/crates/modules/ui_engine/libs/meta-runtime/src/format.rs new file mode 100644 index 0000000..e827b9a --- /dev/null +++ b/crates/modules/ui_engine/libs/meta-runtime/src/format.rs @@ -0,0 +1,116 @@ +//! Helpers de presentación humana para records y values. +//! +//! Sin GPUI: devuelven `String`s. El widget renderer los wrap-ea +//! en `div().child(...)` o equivalente. + +use serde_json::Value; +use uuid::Uuid; + +/// Etiqueta humana para representar un record en el selector de +/// EntityRef. Heurística: prefiere campos comunes en este orden: +/// `name`, `label`, `title`, `sku`, `sku_id`. Fallback al UUID corto. +pub fn human_label_for_record(value: &Value, id: &Uuid) -> String { + for key in ["name", "label", "title", "sku", "sku_id"] { + if let Some(v) = value.get(key).and_then(Value::as_str) { + if !v.is_empty() { + return format!("{} ({})", v, short_uuid(id)); + } + } + } + short_uuid(id) +} + +/// Render legible de un `Value` arbitrario para mostrar en una celda +/// de lista. Strings van pelados; bools como ✓/✗; el resto via +/// `Display`. +pub fn render_value(v: Option<&Value>) -> String { + match v { + None | Some(Value::Null) => String::new(), + Some(Value::String(s)) => s.clone(), + Some(Value::Bool(b)) => if *b { "✓" } else { "✗" }.to_string(), + Some(Value::Number(n)) => n.to_string(), + Some(other) => other.to_string(), + } +} + +/// Conversión inversa a `parse_field_value`: del JSON al texto raw +/// que un input puede tomar y volver a parsearse igual al submit. +/// Usado para pre-llenar inputs en modo edit. +pub fn value_to_input_text(v: &Value) -> String { + match v { + Value::Null => String::new(), + Value::String(s) => s.clone(), + Value::Bool(b) => if *b { "true" } else { "false" }.to_string(), + Value::Number(n) => n.to_string(), + other => other.to_string(), + } +} + +/// Primeros 8 chars del UUID en forma canónica. Útil para logs y UI +/// donde el UUID full es ruido visual. +pub fn short_uuid(id: &Uuid) -> String { + id.to_string().chars().take(8).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn human_label_prefers_name_over_id() { + let id = Uuid::new_v4(); + let v = json!({"name": "Acme S.A.", "email": "x@y.z"}); + let label = human_label_for_record(&v, &id); + assert!(label.starts_with("Acme S.A.")); + assert!(label.contains(&short_uuid(&id))); + } + + #[test] + fn human_label_falls_back_through_label_title_sku() { + let id = Uuid::new_v4(); + let only_label = json!({"label": "X"}); + assert!(human_label_for_record(&only_label, &id).starts_with("X ")); + let only_title = json!({"title": "Y"}); + assert!(human_label_for_record(&only_title, &id).starts_with("Y ")); + let only_sku = json!({"sku": "Z"}); + assert!(human_label_for_record(&only_sku, &id).starts_with("Z ")); + let only_sku_id = json!({"sku_id": "W"}); + assert!(human_label_for_record(&only_sku_id, &id).starts_with("W ")); + } + + #[test] + fn human_label_falls_back_to_short_uuid_when_no_keys_match() { + let id = Uuid::new_v4(); + let v = json!({"random": "field"}); + assert_eq!(human_label_for_record(&v, &id), short_uuid(&id)); + } + + #[test] + fn render_value_handles_basic_kinds() { + assert_eq!(render_value(None), ""); + assert_eq!(render_value(Some(&Value::Null)), ""); + assert_eq!(render_value(Some(&json!("hola"))), "hola"); + assert_eq!(render_value(Some(&json!(true))), "✓"); + assert_eq!(render_value(Some(&json!(false))), "✗"); + assert_eq!(render_value(Some(&json!(42))), "42"); + } + + #[test] + fn value_to_input_text_round_trip_with_strings_and_numbers() { + assert_eq!(value_to_input_text(&Value::Null), ""); + assert_eq!(value_to_input_text(&json!("x")), "x"); + assert_eq!(value_to_input_text(&json!(true)), "true"); + assert_eq!(value_to_input_text(&json!(false)), "false"); + assert_eq!(value_to_input_text(&json!(42)), "42"); + } + + #[test] + fn short_uuid_returns_first_8_chars() { + let id = Uuid::parse_str("01ARZ3ND-EKTS-V4RR-FFQ6-9G5FAV000000").ok(); + // Si el parse falla, usamos uno fresco — el invariant es la + // longitud, no el contenido. + let id = id.unwrap_or_else(Uuid::new_v4); + assert_eq!(short_uuid(&id).len(), 8); + } +} diff --git a/crates/modules/ui_engine/libs/meta-runtime/src/lib.rs b/crates/modules/ui_engine/libs/meta-runtime/src/lib.rs new file mode 100644 index 0000000..788f854 --- /dev/null +++ b/crates/modules/ui_engine/libs/meta-runtime/src/lib.rs @@ -0,0 +1,33 @@ +//! `yahweh-meta-runtime` — helpers puros para runtimes metainterfaz. +//! +//! Consume [`yahweh_meta_schema`] (los tipos `Module`/`View`/`FieldSpec`/ +//! `FieldKind`/`Action`/etc.) y aporta funciones puras que cualquier +//! widget renderer o backend ejecutor necesita: +//! +//! - **Parse**: convertir el texto de un input a `serde_json::Value` +//! tipado según el `FieldKind` del spec. +//! - **Delta**: calcular qué cambió entre el estado actual y la +//! propuesta del form (Set + Clear). +//! - **Validation**: verificar que cada EntityRef apunte a un record +//! que existe (toma cierre `load`, no trait). +//! - **Format**: presentación humana de records (label heurístico, +//! render de values, UUID corto, round-trip a input text). +//! +//! Sin GPUI, sin acoplamiento a un backend específico. Cualquier +//! implementación de store/log puede consumirlos. +//! +//! El widget render (form/list/modal) vive en otro crate yahweh +//! que esto consume; el runtime concreto (`nakui-ui`) implementa la +//! conexión a su event-log/executor y compone ambos. + +#![forbid(unsafe_code)] + +pub mod delta; +pub mod format; +pub mod parse; +pub mod refs; + +pub use delta::{compute_clear_fields, compute_field_delta}; +pub use format::{human_label_for_record, render_value, short_uuid, value_to_input_text}; +pub use parse::{infer_param_value, parse_field_value, resolve_param_value}; +pub use refs::validate_entity_refs; diff --git a/crates/modules/ui_engine/libs/meta-runtime/src/parse.rs b/crates/modules/ui_engine/libs/meta-runtime/src/parse.rs new file mode 100644 index 0000000..3f0274f --- /dev/null +++ b/crates/modules/ui_engine/libs/meta-runtime/src/parse.rs @@ -0,0 +1,231 @@ +//! Parseo de inputs del form a `serde_json::Value` tipado. + +use serde_json::{json, Value}; +use uuid::Uuid; + +use yahweh_meta_schema::{FieldKind, FieldSpec}; + +/// Convierte el texto raw de un input al `Value` tipado según el +/// `kind` del spec. +/// +/// - `Text` / `Multiline` / `Date` → string passthrough. +/// - `EntityRef` → string del UUID **trimmed**, validado como UUID +/// parseable. Falla con mensaje claro si no parsea. +/// - `Boolean` → variantes comunes (`true/yes/1/on/y` y `false/no/0/off/n`). +/// - `Number` → i64 si parsea, sino f64. +pub fn parse_field_value(kind: FieldKind, raw: &str) -> Result { + match kind { + FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)), + // EntityRef se almacena como string del UUID seleccionado. + // El selector clickable garantiza UUIDs válidos en happy + // path; este check protege paste manual o garbage tipeado. + FieldKind::EntityRef => { + let trimmed = raw.trim(); + Uuid::parse_str(trimmed).map_err(|_| { + format!("'{raw}' no es UUID válido (usá el selector de records)") + })?; + Ok(json!(trimmed)) + } + FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() { + "true" | "yes" | "1" | "on" | "y" => Ok(json!(true)), + "" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)), + other => Err(format!("'{other}' no es booleano")), + }, + FieldKind::Number => { + if let Ok(i) = raw.parse::() { + Ok(json!(i)) + } else if let Ok(f) = raw.parse::() { + Ok(json!(f)) + } else { + Err(format!("'{raw}' no es número")) + } + } + } +} + +/// 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. +pub 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 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. +pub fn infer_param_value(raw: &str) -> Value { + if raw.is_empty() { + return Value::Null; + } + if let Ok(i) = raw.parse::() { + return json!(i); + } + if let Ok(f) = raw.parse::() { + return json!(f); + } + match raw { + "true" => return json!(true), + "false" => return json!(false), + _ => {} + } + json!(raw) +} + +#[cfg(test)] +mod tests { + use super::*; + use yahweh_meta_schema::FieldSpec; + + 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 infer_handles_basic_types() { + assert_eq!(infer_param_value(""), Value::Null); + assert_eq!(infer_param_value("42"), json!(42)); + assert_eq!(infer_param_value("3.14"), json!(3.14)); + assert_eq!(infer_param_value("true"), json!(true)); + assert_eq!(infer_param_value("false"), json!(false)); + assert_eq!(infer_param_value("hola"), json!("hola")); + } + + #[test] + fn parse_text_passthrough() { + let v = parse_field_value(FieldKind::Text, "hola").unwrap(); + assert_eq!(v, json!("hola")); + } + + #[test] + fn parse_number_i64_or_f64() { + assert_eq!(parse_field_value(FieldKind::Number, "42").unwrap(), json!(42)); + assert_eq!( + parse_field_value(FieldKind::Number, "3.14").unwrap(), + json!(3.14) + ); + assert!(parse_field_value(FieldKind::Number, "abc").is_err()); + } + + #[test] + fn parse_boolean_recognizes_variants() { + for s in ["true", "yes", "1", "on", "y"] { + assert_eq!(parse_field_value(FieldKind::Boolean, s).unwrap(), json!(true)); + } + for s in ["false", "no", "0", "off", "n", ""] { + assert_eq!( + parse_field_value(FieldKind::Boolean, s).unwrap(), + json!(false) + ); + } + assert!(parse_field_value(FieldKind::Boolean, "abc").is_err()); + } + + #[test] + fn parse_entity_ref_accepts_valid_uuid() { + let id = Uuid::new_v4(); + let v = parse_field_value(FieldKind::EntityRef, &id.to_string()).unwrap(); + assert_eq!(v, json!(id.to_string())); + } + + #[test] + fn parse_entity_ref_trims_whitespace() { + let id = Uuid::new_v4(); + let padded = format!(" {id}\n"); + let v = parse_field_value(FieldKind::EntityRef, &padded).unwrap(); + assert_eq!(v, json!(id.to_string())); + } + + #[test] + fn parse_entity_ref_rejects_non_uuid() { + let err = parse_field_value(FieldKind::EntityRef, "abc-123").unwrap_err(); + assert!(err.contains("'abc-123'")); + assert!(err.contains("UUID") || err.contains("uuid")); + } + + #[test] + fn parse_entity_ref_rejects_empty_string() { + let err = parse_field_value(FieldKind::EntityRef, "").unwrap_err(); + assert!(err.contains("UUID")); + } + + #[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")); + } + + #[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")); + } + + #[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, Value::Null); + } + + #[test] + fn resolve_param_no_spec_falls_back_to_infer() { + 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 resolve_param_strict_entity_ref_propagates_error() { + let s = spec("stock_ref", FieldKind::EntityRef, true); + let err = resolve_param_value("stock_ref", "not-a-uuid", Some(&s)).unwrap_err(); + assert!(err.contains("stock_ref")); + assert!(err.contains("UUID")); + } +} diff --git a/crates/modules/ui_engine/libs/meta-runtime/src/refs.rs b/crates/modules/ui_engine/libs/meta-runtime/src/refs.rs new file mode 100644 index 0000000..47d679f --- /dev/null +++ b/crates/modules/ui_engine/libs/meta-runtime/src/refs.rs @@ -0,0 +1,108 @@ +//! Validación cross-field de EntityRefs contra el store actual. +//! +//! Decoupling: en vez de un `trait Store` que ate este crate a un +//! backend específico, tomamos un cierre `load: Fn(&str, Uuid) -> +//! Option`. El caller (nakui-ui o cualquier otro runtime) +//! puede pasarlo trivialmente sobre cualquier store (MemoryStore, +//! SurrealStore, mock, ...). + +use serde_json::Value; +use uuid::Uuid; + +use crate::format::short_uuid; + +/// Valida que cada UUID en `refs` apunte a un record que realmente +/// existe en el store bajo la entity esperada. Devuelve el primer +/// error encontrado (fail-fast). +/// +/// `refs` es una lista de `(label, target_entity, uuid)`. El label +/// va al error message, así que conviene que sea legible (ej: +/// `FieldSpec.label` en lugar de `FieldSpec.name`). +/// +/// `load` es el cierre que el caller usa para mirar el store — +/// típicamente `|e, id| store.load(e, id)`. +pub fn validate_entity_refs(load: F, refs: &[(String, String, Uuid)]) -> Result<(), String> +where + F: Fn(&str, Uuid) -> Option, +{ + for (label, target, id) in refs { + if load(target, *id).is_none() { + return Err(format!( + "campo '{label}': record {} de '{target}' no existe en el store", + short_uuid(id) + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::HashMap; + + /// "Mock store" minimalista para tests: HashMap por (entity, uuid). + fn mk_load(records: HashMap<(String, Uuid), Value>) -> impl Fn(&str, Uuid) -> Option { + move |e, id| records.get(&(e.to_string(), id)).cloned() + } + + #[test] + fn passes_when_all_records_exist() { + let stock = Uuid::new_v4(); + let caja = Uuid::new_v4(); + let mut records = HashMap::new(); + records.insert(("Stock".into(), stock), json!({"sku_id": "abc"})); + records.insert(("Caja".into(), caja), json!({"name": "Principal"})); + let load = mk_load(records); + + let refs = vec![ + ("Stock".into(), "Stock".into(), stock), + ("Caja".into(), "Caja".into(), caja), + ]; + assert!(validate_entity_refs(load, &refs).is_ok()); + } + + #[test] + fn fails_on_first_missing() { + let stock = Uuid::new_v4(); + let mut records = HashMap::new(); + records.insert(("Stock".into(), stock), json!({"sku_id": "abc"})); + let load = mk_load(records); + + let missing_caja = Uuid::new_v4(); + let refs = vec![ + ("Stock".into(), "Stock".into(), stock), + ("Caja".into(), "Caja".into(), missing_caja), + ]; + let err = validate_entity_refs(load, &refs).unwrap_err(); + assert!(err.contains("Caja")); + assert!(err.contains(&short_uuid(&missing_caja))); + } + + #[test] + fn uses_label_not_entity_in_msg() { + let load = |_: &str, _: Uuid| -> Option { None }; + let id = Uuid::new_v4(); + let refs = vec![("Stock origen".into(), "Stock".into(), id)]; + let err = validate_entity_refs(load, &refs).unwrap_err(); + assert!(err.contains("Stock origen")); + } + + #[test] + fn empty_list_is_ok() { + let load = |_: &str, _: Uuid| -> Option { None }; + assert!(validate_entity_refs(load, &[]).is_ok()); + } + + #[test] + fn distinguishes_target_from_other_entities() { + let id = Uuid::new_v4(); + let mut records = HashMap::new(); + // Mismo UUID bajo Customer pero NO bajo Stock. + records.insert(("Customer".into(), id), json!({"name": "Acme"})); + let load = mk_load(records); + let refs = vec![("Stock".into(), "Stock".into(), id)]; + assert!(validate_entity_refs(load, &refs).is_err()); + } +}