feat(nakui-ui): Action::Morphism wired al pipeline real (compute -> log -> apply)
Cierra el ultimo gran TODO de la metainterfaz Nakui: las acciones
Action::Morphism ya no son un toast informativo; despachan al
Executor cargado del manifest nakui-core (nsmc.json + schemas KCL +
scripts Rhai), pasando por el pipeline completo: compute (con
dry-run + KCL post-checks) -> log append -> store apply.
Schema nakui-ui-schema extendido:
- Module.nakui_module_dir: Option<String> nuevo. Path al modulo
nakui-core. Sin esto, Action::Morphism quedan no-op con toast.
SeedEntity sigue funcionando (alta administrativa sin manifest).
- Action::Morphism gano dos campos opcionales:
- inputs: BTreeMap<String, String> — mapeo role -> field_name.
- params: Vec<String> — fields cuyos values van al params JSON.
Si vacio, todos los fields no-input van a params.
Runtime nakui-ui:
- MetaUi.executors: BTreeMap<String, Arc<Executor>> nuevo. Carga
Executor::load_module(nakui_module_dir) en MetaUi::new.
- commit_morphism: resuelve inputs (parsea UUIDs), arma params
(Value object con tipos inferidos), llama
execute_and_log_with_recovery. Toast con count de ops o error.
- infer_param_value: heuristica i64 -> f64 -> bool -> string.
Tests: 2 nuevos. E2E morphism_pipeline_executes_real_sales_vender
carga el modulo real crates/modules/nakui/modules/sales, ejecuta
"vender" con inputs Stock+Caja y params (cantidad=5, precio=200,
venta_id, timestamp). Asserta:
- el morphism produce ops (no vacio).
- stock.cantidad: 100 -> 95.
- caja.saldo: 1_000_000 -> 1_001_000.
12 tests verdes en nakui-ui (+1). Schema extension no rompio nada
(6 unit + 5 integration siguen verdes).
Demo nuevo: examples/nakui-modules/sales_engine/module.json apunta
al sales real via nakui_module_dir. 6 vistas (list+form para Stock/
Caja/Venta + "Vender" con Action::Morphism). El user crea Stocks +
Cajas con seed_entity, copia los UUIDs a los inputs de "Vender", y
ejecuta el morphism real con KCL post-checks.
Activacion:
NAKUI_EVENT_LOG=~/.nakui/state.jsonl \\
NAKUI_MODULES_DIR=examples/nakui-modules \\
cargo run -p nakui-ui
Trade-offs:
- Inputs UUID a mano (no dropdown). Nice-to-have: FieldKind::EntityRef
que renderee selector.
- Inferencia de tipo en params es heuristica.
This commit is contained in:
@@ -6,6 +6,88 @@ ratio/diff ver `git show <sha>`.
|
|||||||
|
|
||||||
## 2026-05-09
|
## 2026-05-09
|
||||||
|
|
||||||
|
### feat(nakui-ui): Action::Morphism wired al pipeline real (compute → log → apply)
|
||||||
|
Cierra el último gran TODO de la metainterfaz Nakui: las acciones
|
||||||
|
`Action::Morphism` ya no son un toast informativo; despachan al
|
||||||
|
`Executor` cargado del manifest nakui-core (`nsmc.json` + schemas
|
||||||
|
KCL + scripts Rhai), pasando por el pipeline completo de Nakui:
|
||||||
|
compute (con dry-run + KCL post-checks) → log append → store apply.
|
||||||
|
|
||||||
|
Schema `nakui-ui-schema` extendido:
|
||||||
|
- **`Module.nakui_module_dir: Option<String>`** nuevo. Path
|
||||||
|
(relativo al directorio del `module.json` o absoluto) a un módulo
|
||||||
|
nakui-core. Sin esto, las Action::Morphism del módulo quedan
|
||||||
|
no-op con toast informativo. Las Action::SeedEntity siguen
|
||||||
|
funcionando sin manifest (alta administrativa).
|
||||||
|
- **`Action::Morphism`** ganó dos campos opcionales:
|
||||||
|
- `inputs: BTreeMap<String, String>` — mapeo `role → field_name`.
|
||||||
|
Por cada input declarado en el `MorphismSpec.inputs`, indica
|
||||||
|
qué field del form contiene el UUID del record. El runtime
|
||||||
|
parsea como `Uuid` y lo pasa al `execute_and_log`.
|
||||||
|
- `params: Vec<String>` — lista de fields cuyos values van al
|
||||||
|
`params` JSON. Si vacío, todos los fields no-input van a params.
|
||||||
|
|
||||||
|
Runtime `nakui-ui`:
|
||||||
|
- **`MetaUi.executors: BTreeMap<String, Arc<Executor>>`** nuevo.
|
||||||
|
Carga `Executor::load_module(nakui_module_dir)` en `MetaUi::new`
|
||||||
|
por cada módulo UI que declare la entry. Errores de carga van al
|
||||||
|
banner; el módulo sigue cargado para SeedEntity, sólo Morphism
|
||||||
|
queda no-op.
|
||||||
|
- **`commit_morphism(mod_idx, name, inputs_map, params_fields)`** nuevo.
|
||||||
|
Resuelve inputs (parsea cada field como Uuid), arma params (Value
|
||||||
|
object con tipos inferidos via `infer_param_value` — int/float/
|
||||||
|
bool/string), llama `execute_and_log_with_recovery`. Toast con
|
||||||
|
cantidad de ops aplicadas o el error tipado.
|
||||||
|
- **`infer_param_value`** nuevo helper: heurística simple para
|
||||||
|
pasar values del form al morphism con tipo inferido (i64 → f64 →
|
||||||
|
bool → string).
|
||||||
|
|
||||||
|
Tests: 2 nuevos:
|
||||||
|
- `infer_param_value_int_then_float_then_bool_then_string` —
|
||||||
|
cobertura de la heurística.
|
||||||
|
- **E2E `morphism_pipeline_executes_real_sales_vender`** —
|
||||||
|
carga el módulo real `crates/modules/nakui/modules/sales`,
|
||||||
|
arma store + log, ejecuta el morphism `vender` con inputs
|
||||||
|
Stock+Caja y params (cantidad=5, precio_unitario=200,
|
||||||
|
venta_id, timestamp). Asserta:
|
||||||
|
- el morphism produce ops (no vacío).
|
||||||
|
- stock.cantidad bajó 100 → 95.
|
||||||
|
- caja.saldo subió 1_000_000 → 1_001_000.
|
||||||
|
|
||||||
|
12 tests verdes en nakui-ui (+1 vs commit anterior). Schema
|
||||||
|
extension no rompió nada (6 unit + 5 integration siguen verdes).
|
||||||
|
|
||||||
|
Demo nuevo: **`examples/nakui-modules/sales_engine/module.json`**
|
||||||
|
- Apunta a `crates/modules/nakui/modules/sales` vía `nakui_module_dir`.
|
||||||
|
- 6 vistas: list + form para cada Stock, Caja, Venta + form
|
||||||
|
"Vender" con `Action::Morphism { name: "vender", inputs: {stock,
|
||||||
|
caja}, params: [venta_id, cantidad, precio_unitario, timestamp] }`.
|
||||||
|
- El user crea Stocks + Cajas con seed_entity, copia los UUIDs
|
||||||
|
cortos a los inputs de "Vender", y ejecuta el morphism real:
|
||||||
|
stock baja, caja sube, Venta se persiste, todo loggeado.
|
||||||
|
- Validaciones KCL fallan limpio (toast con error) si el morphism
|
||||||
|
rebota — p. ej. cantidad > stock disponible.
|
||||||
|
|
||||||
|
Activación full:
|
||||||
|
```sh
|
||||||
|
NAKUI_EVENT_LOG=~/.nakui/state.jsonl \
|
||||||
|
NAKUI_MODULES_DIR=examples/nakui-modules \
|
||||||
|
cargo run -p nakui-ui
|
||||||
|
# Sidebar gana "Ventas (con morphism)" — los 6 menús aparecen y
|
||||||
|
# el form "Vender" dispara el pipeline nakui-core completo.
|
||||||
|
```
|
||||||
|
|
||||||
|
Trade-offs documentados:
|
||||||
|
- **Inputs UUID a mano**: el form pide que el user copie el UUID de
|
||||||
|
un Stock/Caja existente. Para UX seria habría que agregar
|
||||||
|
`FieldKind::EntityRef { entity }` que renderiza un dropdown — no
|
||||||
|
hecho por scope, queda como nice-to-have.
|
||||||
|
- **Inferencia de tipo en params**: `infer_param_value` adivina
|
||||||
|
por shape del string. Para casos sutiles (ej. "true" como string
|
||||||
|
literal vs bool), el módulo nakui-core puede explicitar tipos
|
||||||
|
via `kind` en el FieldSpec — el form lo respeta para validación
|
||||||
|
pre-submit; la inferencia final sigue siendo heurística.
|
||||||
|
|
||||||
### feat(nakui-ui): edit + delete de records (ciclo CRUD completo)
|
### feat(nakui-ui): edit + delete de records (ciclo CRUD completo)
|
||||||
Cierra "no hay UI para editar/borrar records existentes" del commit
|
Cierra "no hay UI para editar/borrar records existentes" del commit
|
||||||
anterior. Cada fila de la lista gana dos botones (✎ edit, ✕ delete);
|
anterior. Cada fila de la lista gana dos botones (✎ edit, ✕ delete);
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ use gpui::{
|
|||||||
Render, SharedString, Window, WindowBounds, WindowOptions,
|
Render, SharedString, Window, WindowBounds, WindowOptions,
|
||||||
};
|
};
|
||||||
use nakui_core::delta::{FieldOp, FieldPath};
|
use nakui_core::delta::{FieldOp, FieldPath};
|
||||||
use nakui_core::event_log::{replay_into, EventLog, LogEntry};
|
use nakui_core::event_log::{execute_and_log_with_recovery, replay_into, EventLog, LogEntry};
|
||||||
|
use nakui_core::executor::Executor;
|
||||||
use nakui_core::store::{MemoryStore, Store};
|
use nakui_core::store::{MemoryStore, Store};
|
||||||
use nakui_ui_schema::{
|
use nakui_ui_schema::{
|
||||||
Action, FieldKind, FieldSpec, FormView, ListView, Module, View,
|
Action, FieldKind, FieldSpec, FormView, ListView, Module, View,
|
||||||
@@ -75,6 +76,11 @@ struct MetaUi {
|
|||||||
/// log falló — en ese caso el runtime degrada a in-memory only y
|
/// log falló — en ese caso el runtime degrada a in-memory only y
|
||||||
/// loggea un toast informativo.
|
/// loggea un toast informativo.
|
||||||
event_log: Option<Arc<Mutex<EventLog>>>,
|
event_log: Option<Arc<Mutex<EventLog>>>,
|
||||||
|
/// Executors nakui cargados, indexados por `module.id`. Sólo
|
||||||
|
/// existen los módulos que declaran `nakui_module_dir`. Las
|
||||||
|
/// acciones `Morphism` requieren que el módulo activo tenga
|
||||||
|
/// uno; sin él, despachan un toast informativo.
|
||||||
|
executors: BTreeMap<String, Arc<Executor>>,
|
||||||
/// (módulo idx, vista key) actualmente activos.
|
/// (módulo idx, vista key) actualmente activos.
|
||||||
active: Option<(usize, String)>,
|
active: Option<(usize, String)>,
|
||||||
/// Inputs vivos para el form actual: nombre del campo → TextInput.
|
/// Inputs vivos para el form actual: nombre del campo → TextInput.
|
||||||
@@ -169,6 +175,43 @@ impl MetaUi {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Cargar Executors para los módulos que declararon
|
||||||
|
// `nakui_module_dir`. Resolvemos paths relativos al
|
||||||
|
// directorio del modules (NAKUI_MODULES_DIR/<id>/), no al
|
||||||
|
// pwd. Cualquier error de carga deja la entry afuera y
|
||||||
|
// anota al banner — Action::Morphism queda no-op para ese
|
||||||
|
// módulo pero el resto sigue funcionando.
|
||||||
|
let mut executors: BTreeMap<String, Arc<Executor>> = BTreeMap::new();
|
||||||
|
for m in &modules {
|
||||||
|
let Some(rel) = &m.nakui_module_dir else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let module_root = modules_dir.join(&m.id);
|
||||||
|
let nakui_dir = if std::path::Path::new(rel).is_absolute() {
|
||||||
|
PathBuf::from(rel)
|
||||||
|
} else {
|
||||||
|
module_root.join(rel)
|
||||||
|
};
|
||||||
|
match Executor::load_module(&nakui_dir) {
|
||||||
|
Ok(exec) => {
|
||||||
|
executors.insert(m.id.clone(), Arc::new(exec));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let msg = format!(
|
||||||
|
"módulo {}: no pude cargar executor nakui en {}: {e}",
|
||||||
|
m.id,
|
||||||
|
nakui_dir.display()
|
||||||
|
);
|
||||||
|
match &load_error {
|
||||||
|
Some(prev) => {
|
||||||
|
load_error = Some(SharedString::from(format!("{prev}; {msg}")));
|
||||||
|
}
|
||||||
|
None => load_error = Some(SharedString::from(msg)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let active = modules
|
let active = modules
|
||||||
.first()
|
.first()
|
||||||
.and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
|
.and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
|
||||||
@@ -177,6 +220,7 @@ impl MetaUi {
|
|||||||
modules,
|
modules,
|
||||||
store: Arc::new(Mutex::new(store)),
|
store: Arc::new(Mutex::new(store)),
|
||||||
event_log,
|
event_log,
|
||||||
|
executors,
|
||||||
active,
|
active,
|
||||||
form_inputs: BTreeMap::new(),
|
form_inputs: BTreeMap::new(),
|
||||||
editing: None,
|
editing: None,
|
||||||
@@ -334,15 +378,136 @@ impl MetaUi {
|
|||||||
}
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
Action::Morphism { name, .. } => {
|
Action::Morphism {
|
||||||
self.toast = Some(SharedString::from(format!(
|
name,
|
||||||
"morphism '{name}': pendiente (requiere manifest nakui)"
|
inputs,
|
||||||
)));
|
params,
|
||||||
|
next_view,
|
||||||
|
} => {
|
||||||
|
match self.commit_morphism(mod_idx, &name, &inputs, ¶ms, cx) {
|
||||||
|
Ok(op_count) => {
|
||||||
|
self.toast = Some(SharedString::from(format!(
|
||||||
|
"morphism '{name}' OK ({op_count} op(s) aplicadas)"
|
||||||
|
)));
|
||||||
|
if let Some(v) = next_view {
|
||||||
|
self.select_view(mod_idx, v, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.toast = Some(SharedString::from(format!(
|
||||||
|
"morphism '{name}' falló: {e}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Despacha un morphism al pipeline real de nakui-core: lee
|
||||||
|
/// inputs (UUIDs) + params (Value object) del form, llama
|
||||||
|
/// `execute_and_log_with_recovery`. Devuelve la cantidad de ops
|
||||||
|
/// que el morphism produjo (para feedback).
|
||||||
|
fn commit_morphism(
|
||||||
|
&mut self,
|
||||||
|
mod_idx: usize,
|
||||||
|
morphism: &str,
|
||||||
|
inputs_map: &BTreeMap<String, String>,
|
||||||
|
params_fields: &[String],
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let _ = cx;
|
||||||
|
let module = self
|
||||||
|
.modules
|
||||||
|
.get(mod_idx)
|
||||||
|
.ok_or_else(|| "módulo inválido".to_string())?;
|
||||||
|
let executor = self
|
||||||
|
.executors
|
||||||
|
.get(&module.id)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"módulo '{}' no tiene executor nakui (falta nakui_module_dir o falló la carga)",
|
||||||
|
module.id
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.clone();
|
||||||
|
let log_arc = self
|
||||||
|
.event_log
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "morphism requiere event log activo".to_string())?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
// Resolver inputs: por cada (role, field_name), parsear el
|
||||||
|
// value del input como Uuid.
|
||||||
|
let mut input_pairs: Vec<(String, Uuid)> = Vec::with_capacity(inputs_map.len());
|
||||||
|
for (role, field_name) in inputs_map {
|
||||||
|
let raw = self
|
||||||
|
.form_inputs
|
||||||
|
.get(field_name)
|
||||||
|
.map(|inp| inp.read(&*cx).text().to_string())
|
||||||
|
.ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?;
|
||||||
|
let id = Uuid::parse_str(raw.trim()).map_err(|_| {
|
||||||
|
format!(
|
||||||
|
"input '{role}' (field '{field_name}'): '{raw}' no es UUID válido"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
input_pairs.push((role.clone(), id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolver params: si la lista está vacía, todos los fields
|
||||||
|
// del form que no estén en `inputs_map` van a params. Si
|
||||||
|
// hay lista, sólo esos.
|
||||||
|
let input_field_set: std::collections::BTreeSet<&String> = inputs_map.values().collect();
|
||||||
|
let mut params_obj = serde_json::Map::new();
|
||||||
|
let field_iter: Vec<String> = if params_fields.is_empty() {
|
||||||
|
self.form_inputs
|
||||||
|
.keys()
|
||||||
|
.filter(|k| !input_field_set.contains(*k))
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
params_fields.to_vec()
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
params_obj.insert(field_name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let inputs_ref: Vec<(&str, Uuid)> = input_pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(r, id)| (r.as_str(), *id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut log = log_arc
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "log mutex envenenado".to_string())?;
|
||||||
|
let mut store = self
|
||||||
|
.store
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "store mutex envenenado".to_string())?;
|
||||||
|
|
||||||
|
let ops = execute_and_log_with_recovery(
|
||||||
|
&executor,
|
||||||
|
&mut *store,
|
||||||
|
&mut *log,
|
||||||
|
morphism,
|
||||||
|
&inputs_ref,
|
||||||
|
Value::Object(params_obj),
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("{e}"))?;
|
||||||
|
Ok(ops.len())
|
||||||
|
}
|
||||||
|
|
||||||
/// Construye un Value desde los TextInput vivos y lo seedea al store.
|
/// Construye un Value desde los TextInput vivos y lo seedea al store.
|
||||||
fn commit_seed(
|
fn commit_seed(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -501,6 +666,31 @@ fn render_value(v: Option<&Value>) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<String>` con sólo los nombres,
|
||||||
|
/// no los kinds).
|
||||||
|
///
|
||||||
|
/// 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::<i64>() {
|
||||||
|
return json!(i);
|
||||||
|
}
|
||||||
|
if let Ok(f) = raw.parse::<f64>() {
|
||||||
|
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
|
/// Conversión inversa a `parse_field_value`: del JSON al texto raw
|
||||||
/// que un input puede tomar y volver a parsearse igual al submit.
|
/// que un input puede tomar y volver a parsearse igual al submit.
|
||||||
/// Usado para pre-llenar inputs en modo edit.
|
/// Usado para pre-llenar inputs en modo edit.
|
||||||
@@ -1049,6 +1239,16 @@ mod tests {
|
|||||||
assert!(lookup_field(&v, "address.zipcode").is_none());
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_value_handles_null_string_bool() {
|
fn render_value_handles_null_string_bool() {
|
||||||
assert_eq!(render_value(None), "");
|
assert_eq!(render_value(None), "");
|
||||||
@@ -1153,6 +1353,106 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// E2E del Action::Morphism: carga el módulo nakui-core real
|
||||||
|
/// `sales` (que vive en `crates/modules/nakui/modules/sales`),
|
||||||
|
/// arma store + log, y ejecuta el morphism `vender` vía
|
||||||
|
/// `execute_and_log_with_recovery` (la misma función que
|
||||||
|
/// `commit_morphism` invoca). Verifica que las ops esperadas
|
||||||
|
/// se loguean y aplican (stock decrementa, caja incrementa).
|
||||||
|
///
|
||||||
|
/// Reproduce el flujo del runtime sin necesitar GPUI.
|
||||||
|
#[test]
|
||||||
|
fn morphism_pipeline_executes_real_sales_vender() {
|
||||||
|
use nakui_core::event_log::{execute_and_log_with_recovery, EventLog};
|
||||||
|
use nakui_core::executor::Executor;
|
||||||
|
use nakui_core::store::{MemoryStore, Store};
|
||||||
|
|
||||||
|
// Path al módulo real (3 dirs arriba: crates/apps/nakui-ui/
|
||||||
|
// → crates/modules/nakui/modules/sales).
|
||||||
|
let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let sales_dir = here
|
||||||
|
.join("../../..")
|
||||||
|
.join("crates/modules/nakui/modules/sales");
|
||||||
|
if !sales_dir.join("nsmc.json").exists() {
|
||||||
|
// Si el módulo demo no está donde esperamos, skipeamos
|
||||||
|
// — no es regresión del feature, es ambiente.
|
||||||
|
eprintln!(
|
||||||
|
"skip: sales module no encontrado en {}",
|
||||||
|
sales_dir.display()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let executor = Executor::load_module(&sales_dir).expect("cargar sales executor");
|
||||||
|
|
||||||
|
let mut store = MemoryStore::new();
|
||||||
|
let stock_id = Uuid::new_v4();
|
||||||
|
let caja_id = Uuid::new_v4();
|
||||||
|
store.seed(
|
||||||
|
"Stock",
|
||||||
|
stock_id,
|
||||||
|
json!({
|
||||||
|
"id": stock_id.to_string(),
|
||||||
|
"sku_id": "test-sku",
|
||||||
|
"ubicacion": "loc-1",
|
||||||
|
"cantidad": 100_i64,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
store.seed(
|
||||||
|
"Caja",
|
||||||
|
caja_id,
|
||||||
|
json!({
|
||||||
|
"id": caja_id.to_string(),
|
||||||
|
"name": "Caja Test",
|
||||||
|
"currency": "USD",
|
||||||
|
"saldo": 1_000_000_i64,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
let log_path = tmp.path().to_path_buf();
|
||||||
|
drop(tmp);
|
||||||
|
let mut log = EventLog::open(&log_path).unwrap();
|
||||||
|
|
||||||
|
let venta_id = Uuid::new_v4();
|
||||||
|
let inputs = vec![("stock", stock_id), ("caja", caja_id)];
|
||||||
|
let params = json!({
|
||||||
|
"venta_id": venta_id.to_string(),
|
||||||
|
"cantidad": 5_i64,
|
||||||
|
"precio_unitario": 200_i64,
|
||||||
|
"timestamp": "2026-05-04T10:00:00Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
let ops = execute_and_log_with_recovery(
|
||||||
|
&executor,
|
||||||
|
&mut store,
|
||||||
|
&mut log,
|
||||||
|
"vender",
|
||||||
|
&inputs,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.expect("morphism vender debe ejecutar limpio");
|
||||||
|
|
||||||
|
assert!(!ops.is_empty(), "vender debería producir ops");
|
||||||
|
|
||||||
|
// Sanity post-condiciones esperadas del manifest sales:
|
||||||
|
// - stock.cantidad bajó (vendimos 5).
|
||||||
|
let stock_after = store
|
||||||
|
.load("Stock", stock_id)
|
||||||
|
.and_then(|v| v.get("cantidad").and_then(Value::as_i64))
|
||||||
|
.expect("stock con cantidad");
|
||||||
|
assert_eq!(stock_after, 95, "stock debería bajar de 100 a 95");
|
||||||
|
// - caja.saldo subió (cobramos 5*200 = 1000 sobre saldo
|
||||||
|
// inicial 1_000_000).
|
||||||
|
let caja_after = store
|
||||||
|
.load("Caja", caja_id)
|
||||||
|
.and_then(|v| v.get("saldo").and_then(Value::as_i64))
|
||||||
|
.expect("caja con saldo");
|
||||||
|
assert_eq!(caja_after, 1_001_000, "caja debería subir 5*200=1000");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&log_path);
|
||||||
|
}
|
||||||
|
|
||||||
/// E2E del ciclo CRUD vía log:
|
/// E2E del ciclo CRUD vía log:
|
||||||
/// 1. Seed un record.
|
/// 1. Seed un record.
|
||||||
/// 2. Morphism con Set ops (edit) — sobreescribe campos.
|
/// 2. Morphism con Set ops (edit) — sobreescribe campos.
|
||||||
|
|||||||
@@ -66,6 +66,22 @@ pub struct Module {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub entities: Vec<EntitySpec>,
|
pub entities: Vec<EntitySpec>,
|
||||||
|
|
||||||
|
/// Path a un módulo nakui-core (directorio con `nsmc.json` +
|
||||||
|
/// schemas KCL + scripts Rhai). Cuando está set, el runtime
|
||||||
|
/// carga un `Executor` para ese path y permite que las acciones
|
||||||
|
/// `Morphism { name }` despachen al pipeline real
|
||||||
|
/// (compute → log → apply).
|
||||||
|
///
|
||||||
|
/// Path resuelto relativo al directorio del `module.json`
|
||||||
|
/// o absoluto.
|
||||||
|
///
|
||||||
|
/// Si es `None`, las acciones `Morphism` quedan deshabilitadas
|
||||||
|
/// (toast informativo al usuario). Las acciones `SeedEntity`
|
||||||
|
/// siguen funcionando sin esto — son altas administrativas que
|
||||||
|
/// no necesitan validación de manifest.
|
||||||
|
#[serde(default)]
|
||||||
|
pub nakui_module_dir: Option<String>,
|
||||||
|
|
||||||
/// Items del menú. Cada uno apunta a una key de `views`. Orden
|
/// Items del menú. Cada uno apunta a una key de `views`. Orden
|
||||||
/// importa (es el orden en que se presentan en el sidebar).
|
/// importa (es el orden en que se presentan en el sidebar).
|
||||||
pub menu: Vec<MenuItem>,
|
pub menu: Vec<MenuItem>,
|
||||||
@@ -199,9 +215,27 @@ pub enum Action {
|
|||||||
next_view: Option<String>,
|
next_view: Option<String>,
|
||||||
},
|
},
|
||||||
/// Ejecuta un morphism declarado en el manifest del módulo
|
/// Ejecuta un morphism declarado en el manifest del módulo
|
||||||
/// nakui-core. Inputs y params se mapean desde los campos del form.
|
/// nakui-core (cuyo path vive en `Module.nakui_module_dir`).
|
||||||
|
/// Inputs (records existentes) y params (valores escalares) se
|
||||||
|
/// mapean desde los campos del form.
|
||||||
Morphism {
|
Morphism {
|
||||||
|
/// Nombre del morphism declarado en `nsmc.json` del manifest
|
||||||
|
/// nakui apuntado por el módulo.
|
||||||
name: String,
|
name: String,
|
||||||
|
/// Mapeo `role → field_name`: por cada input declarado en
|
||||||
|
/// el `MorphismSpec.inputs`, indica qué field del form
|
||||||
|
/// contiene el UUID del record. El runtime parsea el value
|
||||||
|
/// como `Uuid` y lo pasa como input al `execute_and_log`.
|
||||||
|
///
|
||||||
|
/// Ej: `{ "stock": "stock_id", "caja": "caja_id" }` para un
|
||||||
|
/// morphism `vender` que toma roles `stock` y `caja`.
|
||||||
|
#[serde(default)]
|
||||||
|
inputs: BTreeMap<String, String>,
|
||||||
|
/// Lista de fields del form cuyos values van al `params`
|
||||||
|
/// JSON object pasado al morphism. Si está vacío, todos los
|
||||||
|
/// fields que no estén en `inputs` van a params.
|
||||||
|
#[serde(default)]
|
||||||
|
params: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
next_view: Option<String>,
|
next_view: Option<String>,
|
||||||
},
|
},
|
||||||
@@ -329,6 +363,7 @@ mod tests {
|
|||||||
id: "customers".into(),
|
id: "customers".into(),
|
||||||
label: "Clientes".into(),
|
label: "Clientes".into(),
|
||||||
description: Some("Gestión de clientes".into()),
|
description: Some("Gestión de clientes".into()),
|
||||||
|
nakui_module_dir: None,
|
||||||
entities: vec![EntitySpec {
|
entities: vec![EntitySpec {
|
||||||
name: "customer".into(),
|
name: "customer".into(),
|
||||||
label: "Cliente".into(),
|
label: "Cliente".into(),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ fn examples_dir() -> std::path::PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn loads_all_six_demo_modules() {
|
fn loads_all_demo_modules() {
|
||||||
let dir = examples_dir();
|
let dir = examples_dir();
|
||||||
let mods = load_modules_from_dir(&dir).unwrap_or_else(|e| {
|
let mods = load_modules_from_dir(&dir).unwrap_or_else(|e| {
|
||||||
panic!("load failed for {}: {e}", dir.display());
|
panic!("load failed for {}: {e}", dir.display());
|
||||||
@@ -27,10 +27,37 @@ fn loads_all_six_demo_modules() {
|
|||||||
"inventory_movements",
|
"inventory_movements",
|
||||||
"invoices",
|
"invoices",
|
||||||
"products",
|
"products",
|
||||||
|
"sales_engine",
|
||||||
"sales_orders",
|
"sales_orders",
|
||||||
"suppliers",
|
"suppliers",
|
||||||
],
|
],
|
||||||
"expected 6 modules in alphabetical order"
|
"expected 7 modules in alphabetical order \
|
||||||
|
(sales_engine se sumó al wirear Action::Morphism)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sales_engine_declares_nakui_module_dir_and_morphism() {
|
||||||
|
// Sanity del módulo demo de morphism: nakui_module_dir set,
|
||||||
|
// y al menos una vista con Action::Morphism en su on_submit.
|
||||||
|
let mods = load_modules_from_dir(examples_dir()).unwrap();
|
||||||
|
let sales = mods
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.id == "sales_engine")
|
||||||
|
.expect("sales_engine debe estar");
|
||||||
|
assert!(
|
||||||
|
sales.nakui_module_dir.is_some(),
|
||||||
|
"sales_engine debería declarar nakui_module_dir"
|
||||||
|
);
|
||||||
|
let has_morphism_view = sales.views.values().any(|v| match v {
|
||||||
|
nakui_ui_schema::View::Form(form) => {
|
||||||
|
matches!(form.on_submit, nakui_ui_schema::Action::Morphism { .. })
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
has_morphism_view,
|
||||||
|
"sales_engine debería tener al menos una Form con Action::Morphism"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
{
|
||||||
|
"id": "sales_engine",
|
||||||
|
"label": "Ventas (con morphism)",
|
||||||
|
"description": "Módulo conectado al manifest nakui-core 'sales': el form 'Vender' dispara el morphism que valida + mueve stock y caja con KCL post-checks reales.",
|
||||||
|
"nakui_module_dir": "../../../crates/modules/nakui/modules/sales",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"name": "Stock",
|
||||||
|
"label": "Stock",
|
||||||
|
"fields": [
|
||||||
|
{ "name": "id", "label": "ID", "kind": "text" },
|
||||||
|
{ "name": "sku_id", "label": "SKU", "kind": "text", "required": true },
|
||||||
|
{ "name": "ubicacion", "label": "Ubicación", "kind": "text" },
|
||||||
|
{ "name": "cantidad", "label": "Cantidad", "kind": "number", "required": true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Caja",
|
||||||
|
"label": "Caja",
|
||||||
|
"fields": [
|
||||||
|
{ "name": "id", "label": "ID", "kind": "text" },
|
||||||
|
{ "name": "name", "label": "Nombre", "kind": "text", "required": true },
|
||||||
|
{ "name": "currency", "label": "Moneda", "kind": "text", "default": "USD" },
|
||||||
|
{ "name": "saldo", "label": "Saldo", "kind": "number", "default": "0" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Venta",
|
||||||
|
"label": "Venta",
|
||||||
|
"fields": [
|
||||||
|
{ "name": "id", "label": "ID", "kind": "text" },
|
||||||
|
{ "name": "stock_id", "label": "Stock ref", "kind": "text" },
|
||||||
|
{ "name": "caja_id", "label": "Caja ref", "kind": "text" },
|
||||||
|
{ "name": "cantidad", "label": "Cantidad vendida", "kind": "number" },
|
||||||
|
{ "name": "precio_unitario", "label": "Precio unit.", "kind": "number" },
|
||||||
|
{ "name": "total", "label": "Total", "kind": "number" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"menu": [
|
||||||
|
{ "label": "Stock", "view": "stock_list", "icon": "📦" },
|
||||||
|
{ "label": "+ Stock", "view": "stock_form", "icon": "✚" },
|
||||||
|
{ "label": "Cajas", "view": "caja_list", "icon": "💰" },
|
||||||
|
{ "label": "+ Caja", "view": "caja_form", "icon": "✚" },
|
||||||
|
{ "label": "Ventas registradas", "view": "venta_list", "icon": "🧾" },
|
||||||
|
{ "label": "Vender", "view": "vender_form", "icon": "⚡" }
|
||||||
|
],
|
||||||
|
"views": {
|
||||||
|
"stock_list": {
|
||||||
|
"kind": "list",
|
||||||
|
"title": "Stock",
|
||||||
|
"entity": "Stock",
|
||||||
|
"columns": [
|
||||||
|
{ "field": "sku_id", "label": "SKU", "weight": 1.5 },
|
||||||
|
{ "field": "ubicacion", "label": "Ubicación", "weight": 1.5 },
|
||||||
|
{ "field": "cantidad", "label": "Cantidad", "weight": 1.0 }
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{ "kind": "open_view", "view": "stock_form", "label": "✚ Stock" }
|
||||||
|
],
|
||||||
|
"search_in": ["sku_id", "ubicacion"]
|
||||||
|
},
|
||||||
|
"stock_form": {
|
||||||
|
"kind": "form",
|
||||||
|
"title": "Nuevo stock",
|
||||||
|
"entity": "Stock",
|
||||||
|
"fields": [
|
||||||
|
{ "name": "id", "label": "ID interno", "kind": "text", "help": "El UUID lo genera el runtime; este field es para el campo `id` de la entity (Nakui lo usa)." },
|
||||||
|
{ "name": "sku_id", "label": "SKU", "kind": "text", "required": true },
|
||||||
|
{ "name": "ubicacion", "label": "Ubicación", "kind": "text", "required": true, "default": "loc-default" },
|
||||||
|
{ "name": "cantidad", "label": "Cantidad inicial", "kind": "number", "required": true, "default": "0" }
|
||||||
|
],
|
||||||
|
"on_submit": {
|
||||||
|
"kind": "seed_entity",
|
||||||
|
"entity": "Stock",
|
||||||
|
"next_view": "stock_list"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"caja_list": {
|
||||||
|
"kind": "list",
|
||||||
|
"title": "Cajas",
|
||||||
|
"entity": "Caja",
|
||||||
|
"columns": [
|
||||||
|
{ "field": "name", "label": "Nombre", "weight": 2.0 },
|
||||||
|
{ "field": "currency", "label": "Moneda", "weight": 0.5 },
|
||||||
|
{ "field": "saldo", "label": "Saldo", "weight": 1.0 }
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{ "kind": "open_view", "view": "caja_form", "label": "✚ Caja" }
|
||||||
|
],
|
||||||
|
"search_in": ["name", "currency"]
|
||||||
|
},
|
||||||
|
"caja_form": {
|
||||||
|
"kind": "form",
|
||||||
|
"title": "Nueva caja",
|
||||||
|
"entity": "Caja",
|
||||||
|
"fields": [
|
||||||
|
{ "name": "id", "label": "ID interno", "kind": "text" },
|
||||||
|
{ "name": "name", "label": "Nombre", "kind": "text", "required": true },
|
||||||
|
{ "name": "currency", "label": "Moneda", "kind": "text", "default": "USD" },
|
||||||
|
{ "name": "saldo", "label": "Saldo inicial", "kind": "number", "default": "0" }
|
||||||
|
],
|
||||||
|
"on_submit": {
|
||||||
|
"kind": "seed_entity",
|
||||||
|
"entity": "Caja",
|
||||||
|
"next_view": "caja_list"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"venta_list": {
|
||||||
|
"kind": "list",
|
||||||
|
"title": "Ventas",
|
||||||
|
"entity": "Venta",
|
||||||
|
"columns": [
|
||||||
|
{ "field": "stock_id", "label": "Stock ref", "weight": 1.0 },
|
||||||
|
{ "field": "caja_id", "label": "Caja ref", "weight": 1.0 },
|
||||||
|
{ "field": "cantidad", "label": "Cant.", "weight": 0.6 },
|
||||||
|
{ "field": "precio_unitario", "label": "Precio", "weight": 0.8 },
|
||||||
|
{ "field": "total", "label": "Total", "weight": 1.0 }
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{ "kind": "open_view", "view": "vender_form", "label": "⚡ Vender" }
|
||||||
|
],
|
||||||
|
"search_in": ["stock_id", "caja_id"]
|
||||||
|
},
|
||||||
|
"vender_form": {
|
||||||
|
"kind": "form",
|
||||||
|
"title": "Vender (morphism)",
|
||||||
|
"entity": "Venta",
|
||||||
|
"fields": [
|
||||||
|
{ "name": "stock_id_input", "label": "Stock UUID", "kind": "text", "required": true, "help": "Copiá el UUID corto de la lista de Stock — el runtime lo parsea como Uuid completo si es válido." },
|
||||||
|
{ "name": "caja_id_input", "label": "Caja UUID", "kind": "text", "required": true, "help": "Idem para Caja." },
|
||||||
|
{ "name": "venta_id", "label": "Venta UUID (idempotencia)", "kind": "text", "required": true, "help": "UUID nuevo por cada intento; mismo UUID = idempotente." },
|
||||||
|
{ "name": "cantidad", "label": "Cantidad a vender", "kind": "number", "required": true, "default": "1" },
|
||||||
|
{ "name": "precio_unitario", "label": "Precio unitario", "kind": "number", "required": true, "default": "0" },
|
||||||
|
{ "name": "timestamp", "label": "Timestamp ISO", "kind": "text", "required": true, "default": "2026-05-09T12:00:00Z" }
|
||||||
|
],
|
||||||
|
"on_submit": {
|
||||||
|
"kind": "morphism",
|
||||||
|
"name": "vender",
|
||||||
|
"inputs": {
|
||||||
|
"stock": "stock_id_input",
|
||||||
|
"caja": "caja_id_input"
|
||||||
|
},
|
||||||
|
"params": ["venta_id", "cantidad", "precio_unitario", "timestamp"],
|
||||||
|
"next_view": "venta_list"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user