⎇ 932e7464d7574f32694619763f4561f17b63e5e7
crates/apps/nakui-ui/src/main.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(nakui-ui): Action::Morphism wired al pipeline real (compute -> log -> apply)
1//! `nakui-ui` — runtime GPUI de la metainterfaz Nakui.
2//!
3//! Carga módulos desde un directorio (cada módulo = un
4//! `module.json`), monta sidebar con sus menús, y renderea la vista
5//! activa en el panel principal:
6//!
7//! - **List**: tabla de instancias del entity. Botones de acción en
8//! el header (típicamente "Nuevo" → form).
9//! - **Form**: campos editables (con `yahweh-widget-text-input` para
10//! teclado real); al submit, escribe al `MemoryStore` in-process
11//! via `seed_entity` (alta directa) o por morphism (TODO en este
12//! iter).
13//!
14//! Todo el storage es in-memory por ahora — el escenario "save to
15//! disk" se materializa cuando el daemon Nakui exista. La
16//! arquitectura permite swap sin tocar la UI.
17//!
18//! ## Uso
19//!
20//! ```sh
21//! NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
22//! # default sin env: ./nakui-modules en pwd.
23//! ```
24
25use std::collections::BTreeMap;
26use std::path::PathBuf;
27use std::sync::{Arc, Mutex};
28
29use gpui::{
30 div, prelude::*, px, App, Application, Bounds, ClickEvent, Context, Entity, IntoElement,
31 Render, SharedString, Window, WindowBounds, WindowOptions,
32};
33use nakui_core::delta::{FieldOp, FieldPath};
34use nakui_core::event_log::{execute_and_log_with_recovery, replay_into, EventLog, LogEntry};
35use nakui_core::executor::Executor;
36use nakui_core::store::{MemoryStore, Store};
37use nakui_ui_schema::{
38 Action, FieldKind, FieldSpec, FormView, ListView, Module, View,
39};
40use serde_json::{json, Value};
41use uuid::Uuid;
42use yahweh_theme::Theme;
43use yahweh_widget_text_input::TextInput;
44
45fn main() {
46 Application::new().run(|cx: &mut App| {
47 // El text input pide Theme::global; instalarlo antes de
48 // crear el window evita que panicee.
49 Theme::install_default(cx);
50
51 let bounds = Bounds::centered(None, gpui::size(px(1100.), px(720.)), cx);
52 cx.open_window(
53 WindowOptions {
54 window_bounds: Some(WindowBounds::Windowed(bounds)),
55 titlebar: Some(gpui::TitlebarOptions {
56 title: Some(SharedString::from("Nakui")),
57 ..Default::default()
58 }),
59 ..Default::default()
60 },
61 |_w, cx| cx.new(MetaUi::new),
62 )
63 .expect("open window");
64 cx.activate(true);
65 });
66}
67
68/// Estado del runtime.
69struct MetaUi {
70 /// Módulos cargados, ordenados por id.
71 modules: Vec<Module>,
72 /// Store compartido. Mutado por el submit de los forms.
73 store: Arc<Mutex<MemoryStore>>,
74 /// Event log persistente compartido. Cada `seed_entity` se appende
75 /// acá antes de mutar el store (WAL). `None` si la apertura del
76 /// log falló — en ese caso el runtime degrada a in-memory only y
77 /// loggea un toast informativo.
78 event_log: Option<Arc<Mutex<EventLog>>>,
79 /// Executors nakui cargados, indexados por `module.id`. Sólo
80 /// existen los módulos que declaran `nakui_module_dir`. Las
81 /// acciones `Morphism` requieren que el módulo activo tenga
82 /// uno; sin él, despachan un toast informativo.
83 executors: BTreeMap<String, Arc<Executor>>,
84 /// (módulo idx, vista key) actualmente activos.
85 active: Option<(usize, String)>,
86 /// Inputs vivos para el form actual: nombre del campo → TextInput.
87 /// Se reemplaza al cambiar de vista (drop de los anteriores).
88 form_inputs: BTreeMap<String, Entity<TextInput>>,
89 /// Si está set, el próximo render del Form pre-llena los inputs
90 /// con los valores del record indicado, y `commit_seed` emite
91 /// un `LogEntry::Morphism { name: "ui.edit_record", ops: [Set...] }`
92 /// en lugar de un Seed nuevo. Limpia al cambiar de view o tras
93 /// submit exitoso.
94 editing: Option<(String, Uuid)>,
95 /// Mensaje toast al pie (success de submit, error de carga, etc.).
96 toast: Option<SharedString>,
97 /// Si la carga de módulos falló al inicio.
98 load_error: Option<SharedString>,
99}
100
101impl MetaUi {
102 fn new(_cx: &mut Context<Self>) -> Self {
103 let modules_dir = std::env::var("NAKUI_MODULES_DIR")
104 .ok()
105 .map(PathBuf::from)
106 .unwrap_or_else(|| PathBuf::from("nakui-modules"));
107
108 let (modules, mut load_error) =
109 match nakui_ui_schema::load_modules_from_dir(&modules_dir) {
110 Ok(m) => (m, None),
111 Err(e) => (
112 Vec::new(),
113 Some(SharedString::from(format!(
114 "no pude cargar módulos de {}: {e}",
115 modules_dir.display()
116 ))),
117 ),
118 };
119
120 // Persistencia: abrir/crear el event log y hacer replay al
121 // store. Path por env `NAKUI_EVENT_LOG`, default
122 // `./nakui-ui-state.jsonl`. Si abrir o replay falla, el
123 // runtime sigue en modo in-memory (sin persistencia) y el
124 // load_error se acumula al banner.
125 let log_path = std::env::var("NAKUI_EVENT_LOG")
126 .map(PathBuf::from)
127 .unwrap_or_else(|_| PathBuf::from("nakui-ui-state.jsonl"));
128 let mut store = MemoryStore::new();
129 let mut initial_toast: Option<SharedString> = None;
130 let event_log = match EventLog::open(&log_path) {
131 Ok(log) => {
132 match replay_into(&log, &mut store) {
133 Ok(()) => {
134 let n = log.next_seq();
135 if n > 0 {
136 initial_toast = Some(SharedString::from(format!(
137 "log {} cargado: {n} evento(s) replayed",
138 log_path.display()
139 )));
140 } else {
141 initial_toast = Some(SharedString::from(format!(
142 "log nuevo en {}",
143 log_path.display()
144 )));
145 }
146 Some(Arc::new(Mutex::new(log)))
147 }
148 Err(e) => {
149 let msg = format!(
150 "replay del log {} falló: {e} — running in-memory",
151 log_path.display()
152 );
153 match &load_error {
154 Some(prev) => {
155 load_error = Some(SharedString::from(format!("{prev}; {msg}")));
156 }
157 None => load_error = Some(SharedString::from(msg)),
158 }
159 None
160 }
161 }
162 }
163 Err(e) => {
164 let msg = format!(
165 "abrir log {}: {e} — running in-memory only",
166 log_path.display()
167 );
168 match &load_error {
169 Some(prev) => {
170 load_error = Some(SharedString::from(format!("{prev}; {msg}")));
171 }
172 None => load_error = Some(SharedString::from(msg)),
173 }
174 None
175 }
176 };
177
178 // Cargar Executors para los módulos que declararon
179 // `nakui_module_dir`. Resolvemos paths relativos al
180 // directorio del modules (NAKUI_MODULES_DIR/<id>/), no al
181 // pwd. Cualquier error de carga deja la entry afuera y
182 // anota al banner — Action::Morphism queda no-op para ese
183 // módulo pero el resto sigue funcionando.
184 let mut executors: BTreeMap<String, Arc<Executor>> = BTreeMap::new();
185 for m in &modules {
186 let Some(rel) = &m.nakui_module_dir else {
187 continue;
188 };
189 let module_root = modules_dir.join(&m.id);
190 let nakui_dir = if std::path::Path::new(rel).is_absolute() {
191 PathBuf::from(rel)
192 } else {
193 module_root.join(rel)
194 };
195 match Executor::load_module(&nakui_dir) {
196 Ok(exec) => {
197 executors.insert(m.id.clone(), Arc::new(exec));
198 }
199 Err(e) => {
200 let msg = format!(
201 "módulo {}: no pude cargar executor nakui en {}: {e}",
202 m.id,
203 nakui_dir.display()
204 );
205 match &load_error {
206 Some(prev) => {
207 load_error = Some(SharedString::from(format!("{prev}; {msg}")));
208 }
209 None => load_error = Some(SharedString::from(msg)),
210 }
211 }
212 }
213 }
214
215 let active = modules
216 .first()
217 .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone())));
218
219 Self {
220 modules,
221 store: Arc::new(Mutex::new(store)),
222 event_log,
223 executors,
224 active,
225 form_inputs: BTreeMap::new(),
226 editing: None,
227 toast: initial_toast,
228 load_error,
229 }
230 }
231
232 /// Cambia la vista activa. Si la nueva vista es un Form, crea
233 /// `TextInput` entities para cada field. Pre-llena con valores
234 /// del record si hay `editing` para esa entity; si no, usa el
235 /// `default` del schema.
236 /// Drop de los inputs anteriores ocurre al sobreescribir el map.
237 fn select_view(&mut self, mod_idx: usize, view_key: String, cx: &mut Context<Self>) {
238 self.active = Some((mod_idx, view_key.clone()));
239 self.toast = None;
240 self.form_inputs = BTreeMap::new();
241 if let Some(module) = self.modules.get(mod_idx) {
242 if let Some(View::Form(form)) = module.views.get(&view_key) {
243 // Snapshot del record si estamos editando esta entity.
244 let editing_record: Option<Value> = self.editing.as_ref().and_then(|(e, id)| {
245 if e == &form.entity {
246 let store = self.store.lock().ok()?;
247 store.load(e, *id)
248 } else {
249 None
250 }
251 });
252 for f in &form.fields {
253 let initial = if let Some(rec) = &editing_record {
254 rec.get(&f.name)
255 .map(value_to_input_text)
256 .unwrap_or_else(|| f.default.clone().unwrap_or_default())
257 } else {
258 f.default.clone().unwrap_or_default()
259 };
260 let input = cx.new(|cx| TextInput::new(initial, cx));
261 self.form_inputs.insert(f.name.clone(), input);
262 }
263 } else {
264 // Cambiar a una view que no es Form invalida el editing
265 // pendiente.
266 self.editing = None;
267 }
268 }
269 cx.notify();
270 }
271
272 /// Inicia un edit del record: setea `editing` y abre la primera
273 /// view de tipo Form del módulo (convención: la del schema).
274 fn open_edit(
275 &mut self,
276 mod_idx: usize,
277 entity: String,
278 id: Uuid,
279 cx: &mut Context<Self>,
280 ) {
281 self.editing = Some((entity.clone(), id));
282 let form_view_key = self.modules.get(mod_idx).and_then(|m| {
283 m.views
284 .iter()
285 .find_map(|(key, v)| match v {
286 View::Form(form) if form.entity == entity => Some(key.clone()),
287 _ => None,
288 })
289 });
290 match form_view_key {
291 Some(key) => self.select_view(mod_idx, key, cx),
292 None => {
293 self.toast = Some(SharedString::from(format!(
294 "no hay form view para entity '{entity}' en este módulo"
295 )));
296 self.editing = None;
297 cx.notify();
298 }
299 }
300 }
301
302 /// Borra un record. Emite Morphism con un FieldOp::Delete + lo
303 /// aplica al store via `apply` (no via remove directo, mantiene
304 /// el modelo de "todo cambio post-seed pasa por ops").
305 fn commit_delete(
306 &mut self,
307 entity: &str,
308 id: Uuid,
309 ) -> Result<(), String> {
310 let ops = vec![FieldOp::Delete {
311 entity: entity.to_string(),
312 id,
313 }];
314 if let Some(log_arc) = self.event_log.as_ref() {
315 let mut log = log_arc
316 .lock()
317 .map_err(|_| "log mutex envenenado".to_string())?;
318 let seq = log.next_seq();
319 log.append(LogEntry::Morphism {
320 seq,
321 morphism: "ui.delete_record".into(),
322 inputs: Default::default(),
323 params: json!({ "entity": entity, "id": id.to_string() }),
324 ops: ops.clone(),
325 schema_hash: None,
326 })
327 .map_err(|e| format!("append al log: {e}"))?;
328 }
329 let mut store = self
330 .store
331 .lock()
332 .map_err(|_| "store mutex envenenado".to_string())?;
333 store
334 .apply(&ops)
335 .map_err(|e| format!("apply Delete: {e}"))?;
336 Ok(())
337 }
338
339 /// Aplica una acción (click en menú, botón de form, action de
340 /// list). Mutaciones contra el store ocurren acá.
341 fn apply_action(&mut self, action: Action, cx: &mut Context<Self>) {
342 let mod_idx = match self.active.as_ref() {
343 Some((i, _)) => *i,
344 None => return,
345 };
346 // Snapshot del editing al entrar — si commit_seed modifica
347 // self.editing antes del toast, el mensaje refleja el modo
348 // correcto.
349 let was_editing = self.editing.is_some();
350 match action {
351 Action::OpenView { view, .. } => {
352 // Salir a otra view cancela el edit pendiente.
353 self.editing = None;
354 self.select_view(mod_idx, view, cx);
355 }
356 Action::SeedEntity { entity, next_view } => {
357 match self.commit_seed(mod_idx, &entity, cx) {
358 Ok(id) => {
359 let action_label = if was_editing { "actualizado" } else { "creado" };
360 self.toast = Some(SharedString::from(format!(
361 "{action_label} {entity} {}",
362 short_uuid(&id)
363 )));
364 // Limpia editing tras un commit exitoso —
365 // el record ya está sincronizado.
366 self.editing = None;
367 if let Some(v) = next_view {
368 self.select_view(mod_idx, v, cx);
369 } else {
370 for input in self.form_inputs.values() {
371 input.update(cx, |inp, cx| inp.set_text("", cx));
372 }
373 }
374 }
375 Err(e) => {
376 self.toast = Some(SharedString::from(format!("error: {e}")));
377 }
378 }
379 cx.notify();
380 }
381 Action::Morphism {
382 name,
383 inputs,
384 params,
385 next_view,
386 } => {
387 match self.commit_morphism(mod_idx, &name, &inputs, ¶ms, cx) {
388 Ok(op_count) => {
389 self.toast = Some(SharedString::from(format!(
390 "morphism '{name}' OK ({op_count} op(s) aplicadas)"
391 )));
392 if let Some(v) = next_view {
393 self.select_view(mod_idx, v, cx);
394 }
395 }
396 Err(e) => {
397 self.toast = Some(SharedString::from(format!(
398 "morphism '{name}' falló: {e}"
399 )));
400 }
401 }
402 cx.notify();
403 }
404 }
405 }
406
407 /// Despacha un morphism al pipeline real de nakui-core: lee
408 /// inputs (UUIDs) + params (Value object) del form, llama
409 /// `execute_and_log_with_recovery`. Devuelve la cantidad de ops
410 /// que el morphism produjo (para feedback).
411 fn commit_morphism(
412 &mut self,
413 mod_idx: usize,
414 morphism: &str,
415 inputs_map: &BTreeMap<String, String>,
416 params_fields: &[String],
417 cx: &mut Context<Self>,
418 ) -> Result<usize, String> {
419 let _ = cx;
420 let module = self
421 .modules
422 .get(mod_idx)
423 .ok_or_else(|| "módulo inválido".to_string())?;
424 let executor = self
425 .executors
426 .get(&module.id)
427 .ok_or_else(|| {
428 format!(
429 "módulo '{}' no tiene executor nakui (falta nakui_module_dir o falló la carga)",
430 module.id
431 )
432 })?
433 .clone();
434 let log_arc = self
435 .event_log
436 .as_ref()
437 .ok_or_else(|| "morphism requiere event log activo".to_string())?
438 .clone();
439
440 // Resolver inputs: por cada (role, field_name), parsear el
441 // value del input como Uuid.
442 let mut input_pairs: Vec<(String, Uuid)> = Vec::with_capacity(inputs_map.len());
443 for (role, field_name) in inputs_map {
444 let raw = self
445 .form_inputs
446 .get(field_name)
447 .map(|inp| inp.read(&*cx).text().to_string())
448 .ok_or_else(|| format!("input field '{field_name}' no existe en el form"))?;
449 let id = Uuid::parse_str(raw.trim()).map_err(|_| {
450 format!(
451 "input '{role}' (field '{field_name}'): '{raw}' no es UUID válido"
452 )
453 })?;
454 input_pairs.push((role.clone(), id));
455 }
456
457 // Resolver params: si la lista está vacía, todos los fields
458 // del form que no estén en `inputs_map` van a params. Si
459 // hay lista, sólo esos.
460 let input_field_set: std::collections::BTreeSet<&String> = inputs_map.values().collect();
461 let mut params_obj = serde_json::Map::new();
462 let field_iter: Vec<String> = if params_fields.is_empty() {
463 self.form_inputs
464 .keys()
465 .filter(|k| !input_field_set.contains(*k))
466 .cloned()
467 .collect()
468 } else {
469 params_fields.to_vec()
470 };
471 for field_name in field_iter {
472 let raw = self
473 .form_inputs
474 .get(&field_name)
475 .map(|inp| inp.read(&*cx).text().to_string())
476 .unwrap_or_default();
477 // Inferencia ligera: número si parsea, bool en
478 // true/false, string en cualquier otro caso. Coherente
479 // con el modelo "el morphism Rhai espera tipos", pero
480 // simple — para casos finos, el caller puede declarar
481 // `kind: Number` en el FieldSpec, y el form lo respeta.
482 let value = infer_param_value(&raw);
483 params_obj.insert(field_name, value);
484 }
485
486 let inputs_ref: Vec<(&str, Uuid)> = input_pairs
487 .iter()
488 .map(|(r, id)| (r.as_str(), *id))
489 .collect();
490
491 let mut log = log_arc
492 .lock()
493 .map_err(|_| "log mutex envenenado".to_string())?;
494 let mut store = self
495 .store
496 .lock()
497 .map_err(|_| "store mutex envenenado".to_string())?;
498
499 let ops = execute_and_log_with_recovery(
500 &executor,
501 &mut *store,
502 &mut *log,
503 morphism,
504 &inputs_ref,
505 Value::Object(params_obj),
506 )
507 .map_err(|e| format!("{e}"))?;
508 Ok(ops.len())
509 }
510
511 /// Construye un Value desde los TextInput vivos y lo seedea al store.
512 fn commit_seed(
513 &mut self,
514 mod_idx: usize,
515 entity: &str,
516 cx: &mut Context<Self>,
517 ) -> Result<Uuid, String> {
518 let module = &self.modules[mod_idx];
519 let spec_fields: Vec<FieldSpec> = match self.active.as_ref() {
520 Some((_, view_key)) => match module.views.get(view_key) {
521 Some(View::Form(f)) => f.fields.clone(),
522 _ => return Err("la vista activa no es un Form".into()),
523 },
524 None => return Err("ninguna vista activa".into()),
525 };
526 let mut obj = serde_json::Map::new();
527 for f in &spec_fields {
528 let raw = self
529 .form_inputs
530 .get(&f.name)
531 .map(|input| input.read(cx).text().to_string())
532 .unwrap_or_default();
533 if f.required && raw.trim().is_empty() {
534 return Err(format!("campo '{}' es obligatorio", f.label));
535 }
536 if raw.is_empty() && !f.required {
537 continue;
538 }
539 let value = parse_field_value(f.kind, &raw)
540 .map_err(|e| format!("campo '{}': {e}", f.label))?;
541 obj.insert(f.name.clone(), value);
542 }
543 // Ramificación: si `editing` está set para esta entity, es un
544 // edit de un record existente — emitimos Morphism con un
545 // FieldOp::Set por cada campo del form (sobreescribe). Si no,
546 // es alta nueva — emitimos Seed con UUID fresco.
547 let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned();
548
549 if let Some((_, id)) = editing_match {
550 // EDIT path: Morphism { ui.edit_record, ops: [Set...] }
551 let ops: Vec<FieldOp> = obj
552 .iter()
553 .map(|(field, value)| FieldOp::Set {
554 path: FieldPath {
555 entity: entity.to_string(),
556 id,
557 field: field.clone(),
558 },
559 value: value.clone(),
560 })
561 .collect();
562
563 if let Some(log_arc) = self.event_log.as_ref() {
564 let mut log = log_arc
565 .lock()
566 .map_err(|_| "log mutex envenenado".to_string())?;
567 let seq = log.next_seq();
568 log.append(LogEntry::Morphism {
569 seq,
570 morphism: "ui.edit_record".into(),
571 inputs: Default::default(),
572 params: json!({
573 "entity": entity,
574 "id": id.to_string(),
575 "fields": Value::Object(obj.clone()),
576 }),
577 ops: ops.clone(),
578 schema_hash: None,
579 })
580 .map_err(|e| format!("append al log: {e}"))?;
581 }
582 let mut store = self
583 .store
584 .lock()
585 .map_err(|_| "store mutex envenenado".to_string())?;
586 store.apply(&ops).map_err(|e| format!("apply Set: {e}"))?;
587 Ok(id)
588 } else {
589 // SEED path: alta nueva.
590 let id = Uuid::new_v4();
591 let data = Value::Object(obj);
592 if let Some(log_arc) = self.event_log.as_ref() {
593 let mut log = log_arc
594 .lock()
595 .map_err(|_| "log mutex envenenado".to_string())?;
596 let seq = log.next_seq();
597 log.append(LogEntry::Seed {
598 seq,
599 entity: entity.to_string(),
600 id,
601 data: data.clone(),
602 schema_hash: None,
603 })
604 .map_err(|e| format!("append al log: {e}"))?;
605 }
606 let mut store = self
607 .store
608 .lock()
609 .map_err(|_| "store mutex envenenado".to_string())?;
610 store.seed(entity, id, data);
611 Ok(id)
612 }
613 }
614
615 /// Snapshot ordenado de records de una entity.
616 fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> {
617 let store = match self.store.lock() {
618 Ok(g) => g,
619 Err(_) => return Vec::new(),
620 };
621 let it = match store.iter() {
622 Ok(i) => i,
623 Err(_) => return Vec::new(),
624 };
625 it.filter(|(e, _, _)| e == entity)
626 .map(|(_, id, v)| (id, v))
627 .collect()
628 }
629}
630
631fn parse_field_value(kind: FieldKind, raw: &str) -> Result<Value, String> {
632 match kind {
633 FieldKind::Text | FieldKind::Multiline | FieldKind::Date => Ok(json!(raw)),
634 FieldKind::Boolean => match raw.to_ascii_lowercase().as_str() {
635 "true" | "yes" | "1" | "on" | "y" => Ok(json!(true)),
636 "" | "false" | "no" | "0" | "off" | "n" => Ok(json!(false)),
637 other => Err(format!("'{other}' no es booleano")),
638 },
639 FieldKind::Number => {
640 if let Ok(i) = raw.parse::<i64>() {
641 Ok(json!(i))
642 } else if let Ok(f) = raw.parse::<f64>() {
643 Ok(json!(f))
644 } else {
645 Err(format!("'{raw}' no es número"))
646 }
647 }
648 }
649}
650
651fn lookup_field<'a>(v: &'a Value, path: &str) -> Option<&'a Value> {
652 let mut cur = v;
653 for seg in path.split('.') {
654 cur = cur.get(seg)?;
655 }
656 Some(cur)
657}
658
659fn render_value(v: Option<&Value>) -> String {
660 match v {
661 None | Some(Value::Null) => String::new(),
662 Some(Value::String(s)) => s.clone(),
663 Some(Value::Bool(b)) => if *b { "✓" } else { "✗" }.to_string(),
664 Some(Value::Number(n)) => n.to_string(),
665 Some(other) => other.to_string(),
666 }
667}
668
669/// Inferencia de tipo para values pasados como `params` a un
670/// morphism. Usada cuando el form no declara `FieldKind` explícito
671/// (Action::Morphism toma `params: Vec<String>` con sólo los nombres,
672/// no los kinds).
673///
674/// Heurística simple: int → i64, float → f64, "true"/"false" → bool,
675/// resto → string.
676fn infer_param_value(raw: &str) -> Value {
677 if raw.is_empty() {
678 return Value::Null;
679 }
680 if let Ok(i) = raw.parse::<i64>() {
681 return json!(i);
682 }
683 if let Ok(f) = raw.parse::<f64>() {
684 return json!(f);
685 }
686 match raw {
687 "true" => return json!(true),
688 "false" => return json!(false),
689 _ => {}
690 }
691 json!(raw)
692}
693
694/// Conversión inversa a `parse_field_value`: del JSON al texto raw
695/// que un input puede tomar y volver a parsearse igual al submit.
696/// Usado para pre-llenar inputs en modo edit.
697fn value_to_input_text(v: &Value) -> String {
698 match v {
699 Value::Null => String::new(),
700 Value::String(s) => s.clone(),
701 Value::Bool(b) => if *b { "true" } else { "false" }.to_string(),
702 Value::Number(n) => n.to_string(),
703 other => other.to_string(),
704 }
705}
706
707fn short_uuid(id: &Uuid) -> String {
708 id.to_string().chars().take(8).collect()
709}
710
711impl Render for MetaUi {
712 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
713 let bg = gpui::rgb(0x14171c);
714 let panel = gpui::rgb(0x1d2128);
715 let border = gpui::rgb(0x2a2f38);
716 let text = gpui::rgb(0xe6e8ec);
717 let text_dim = gpui::rgb(0x9ba1ad);
718 let accent = gpui::rgb(0x88c0d0);
719 let accent_active = gpui::rgb(0xa3be8c);
720
721 let sidebar = self.render_sidebar(cx, panel, border, text, text_dim, accent_active);
722 let main_panel = self.render_main(cx, panel, border, text, text_dim, accent);
723 let toast_div = self.toast.as_ref().map(|t| {
724 div()
725 .px(px(12.))
726 .py(px(6.))
727 .bg(gpui::rgb(0x2d3a2a))
728 .text_color(gpui::rgb(0xc0e0a0))
729 .text_size(px(11.))
730 .child(t.clone())
731 });
732 let error_banner = self.load_error.as_ref().map(|e| {
733 div()
734 .px(px(12.))
735 .py(px(6.))
736 .bg(gpui::rgb(0x4a2020))
737 .text_color(gpui::rgb(0xffd0d0))
738 .text_size(px(11.))
739 .child(e.clone())
740 });
741
742 div()
743 .flex()
744 .flex_col()
745 .size_full()
746 .bg(bg)
747 .when_some(error_banner, |d, b| d.child(b))
748 .child(
749 div()
750 .flex()
751 .flex_row()
752 .flex_grow()
753 .child(sidebar)
754 .child(main_panel),
755 )
756 .when_some(toast_div, |d, t| d.child(t))
757 }
758}
759
760impl MetaUi {
761 fn render_sidebar(
762 &self,
763 cx: &mut Context<Self>,
764 panel: gpui::Rgba,
765 border: gpui::Rgba,
766 text: gpui::Rgba,
767 text_dim: gpui::Rgba,
768 accent_active: gpui::Rgba,
769 ) -> gpui::Div {
770 let mut sidebar = div()
771 .w(px(240.))
772 .h_full()
773 .bg(panel)
774 .border_r_1()
775 .border_color(border)
776 .flex()
777 .flex_col();
778
779 sidebar = sidebar.child(
780 div()
781 .px(px(12.))
782 .py(px(10.))
783 .text_color(text)
784 .text_size(px(13.))
785 .child("Nakui"),
786 );
787
788 if self.modules.is_empty() {
789 return sidebar.child(
790 div()
791 .px(px(12.))
792 .py(px(8.))
793 .text_color(text_dim)
794 .text_size(px(11.))
795 .child("(no hay módulos cargados)"),
796 );
797 }
798
799 // Snapshot del active para evitar borrow del self adentro de la closure.
800 let active_snapshot = self.active.clone();
801
802 for (mod_idx, m) in self.modules.iter().enumerate() {
803 sidebar = sidebar.child(
804 div()
805 .px(px(12.))
806 .py(px(8.))
807 .border_t_1()
808 .border_color(border)
809 .text_color(text)
810 .text_size(px(12.))
811 .child(m.label.clone()),
812 );
813
814 for item in &m.menu {
815 let is_active = active_snapshot
816 .as_ref()
817 .map(|(i, v)| *i == mod_idx && v == &item.view)
818 .unwrap_or(false);
819 let label = item
820 .icon
821 .as_deref()
822 .map(|ic| format!("{ic} {}", item.label))
823 .unwrap_or_else(|| item.label.clone());
824
825 let view_key = item.view.clone();
826 sidebar = sidebar.child(
827 div()
828 .id(SharedString::from(format!(
829 "menu-{}-{}",
830 mod_idx, item.view
831 )))
832 .px(px(20.))
833 .py(px(6.))
834 .text_size(px(12.))
835 .text_color(if is_active { accent_active } else { text_dim })
836 .when(is_active, |d| {
837 d.bg(gpui::rgb(0x232a36)).text_color(text)
838 })
839 .hover(|d| d.bg(gpui::rgb(0x1f2630)))
840 .child(label)
841 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
842 this.select_view(mod_idx, view_key.clone(), cx);
843 })),
844 );
845 }
846 }
847 sidebar
848 }
849
850 fn render_main(
851 &mut self,
852 cx: &mut Context<Self>,
853 panel: gpui::Rgba,
854 border: gpui::Rgba,
855 text: gpui::Rgba,
856 text_dim: gpui::Rgba,
857 accent: gpui::Rgba,
858 ) -> gpui::Div {
859 let main = div()
860 .flex_grow()
861 .h_full()
862 .bg(panel)
863 .flex()
864 .flex_col()
865 .p(px(16.));
866
867 let (mod_idx, view_key) = match &self.active {
868 Some(a) => (a.0, a.1.clone()),
869 None => {
870 return main.child(
871 div()
872 .text_color(text_dim)
873 .child("Seleccioná un menú a la izquierda."),
874 );
875 }
876 };
877
878 let view = match self
879 .modules
880 .get(mod_idx)
881 .and_then(|m| m.views.get(&view_key))
882 {
883 Some(v) => v.clone(),
884 None => {
885 return main.child(
886 div()
887 .text_color(text_dim)
888 .child(format!("Vista no encontrada: {view_key}")),
889 );
890 }
891 };
892
893 match view {
894 View::List(lv) => self.render_list(cx, main, &lv, mod_idx, border, text, text_dim, accent),
895 View::Form(fv) => self.render_form(cx, main, &fv, mod_idx, border, text, text_dim, accent),
896 }
897 }
898
899 #[allow(clippy::too_many_arguments)]
900 fn render_list(
901 &self,
902 cx: &mut Context<Self>,
903 mut main: gpui::Div,
904 lv: &ListView,
905 mod_idx: usize,
906 border: gpui::Rgba,
907 text: gpui::Rgba,
908 text_dim: gpui::Rgba,
909 accent: gpui::Rgba,
910 ) -> gpui::Div {
911 let mut header = div()
912 .flex()
913 .flex_row()
914 .items_center()
915 .gap(px(12.))
916 .mb(px(12.))
917 .child(
918 div()
919 .text_color(text)
920 .text_size(px(18.))
921 .child(lv.title.clone()),
922 );
923 for (idx, action) in lv.actions.iter().enumerate() {
924 let label = match action {
925 Action::OpenView { label, view } => {
926 label.clone().unwrap_or_else(|| format!("→ {view}"))
927 }
928 Action::SeedEntity { entity, .. } => format!("Seed {entity}"),
929 Action::Morphism { name, .. } => format!("⚡ {name}"),
930 };
931 let action_clone = action.clone();
932 header = header.child(
933 div()
934 .id(SharedString::from(format!(
935 "list-action-{mod_idx}-{idx}"
936 )))
937 .px(px(10.))
938 .py(px(4.))
939 .bg(gpui::rgb(0x232a36))
940 .text_color(accent)
941 .text_size(px(11.))
942 .rounded(px(3.))
943 .hover(|d| d.bg(gpui::rgb(0x2c3540)))
944 .child(label)
945 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
946 this.apply_action(action_clone.clone(), cx);
947 })),
948 );
949 }
950 main = main.child(header);
951
952 let rows = self.list_rows(&lv.entity);
953 let total = rows.len();
954
955 let total_weight: f32 = lv.columns.iter().map(|c| c.weight).sum::<f32>().max(0.01);
956 let mut col_header = div()
957 .flex()
958 .flex_row()
959 .py(px(6.))
960 .border_b_1()
961 .border_color(border)
962 .text_color(text_dim)
963 .text_size(px(11.));
964 for c in &lv.columns {
965 let frac = c.weight / total_weight;
966 col_header = col_header.child(
967 div()
968 .flex_grow()
969 .flex_basis(px(100. * frac))
970 .child(c.label.clone()),
971 );
972 }
973 col_header = col_header
974 .child(div().w(px(80.)).text_color(text_dim).child("id"))
975 .child(div().w(px(70.)).text_color(text_dim).child("acciones"));
976 main = main.child(col_header);
977
978 let entity_name = lv.entity.clone();
979 for (id, value) in &rows {
980 let id_copy = *id;
981 let entity_for_edit = entity_name.clone();
982 let entity_for_delete = entity_name.clone();
983 let mut row = div()
984 .flex()
985 .flex_row()
986 .py(px(6.))
987 .border_b_1()
988 .border_color(gpui::rgb(0x232a36))
989 .text_color(text)
990 .text_size(px(12.));
991 for c in &lv.columns {
992 let frac = c.weight / total_weight;
993 let v = lookup_field(value, &c.field);
994 row = row.child(
995 div()
996 .flex_grow()
997 .flex_basis(px(100. * frac))
998 .child(render_value(v)),
999 );
1000 }
1001 row = row.child(
1002 div()
1003 .w(px(80.))
1004 .text_color(text_dim)
1005 .text_size(px(11.))
1006 .child(short_uuid(id)),
1007 );
1008 // Acciones: ✎ edit + ✕ delete por fila.
1009 row = row.child(
1010 div()
1011 .w(px(70.))
1012 .flex()
1013 .flex_row()
1014 .gap(px(4.))
1015 .child(
1016 div()
1017 .id(SharedString::from(format!(
1018 "row-edit-{mod_idx}-{id_copy}"
1019 )))
1020 .px(px(6.))
1021 .text_color(accent)
1022 .text_size(px(13.))
1023 .hover(|d| d.bg(gpui::rgb(0x2c3540)))
1024 .child("✎")
1025 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1026 this.open_edit(mod_idx, entity_for_edit.clone(), id_copy, cx);
1027 })),
1028 )
1029 .child(
1030 div()
1031 .id(SharedString::from(format!(
1032 "row-del-{mod_idx}-{id_copy}"
1033 )))
1034 .px(px(6.))
1035 .text_color(gpui::rgb(0xd07070))
1036 .text_size(px(13.))
1037 .hover(|d| d.bg(gpui::rgb(0x4a2020)))
1038 .child("✕")
1039 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1040 match this.commit_delete(&entity_for_delete, id_copy) {
1041 Ok(()) => {
1042 this.toast = Some(SharedString::from(format!(
1043 "borrado {entity_for_delete} {}",
1044 short_uuid(&id_copy)
1045 )));
1046 }
1047 Err(e) => {
1048 this.toast = Some(SharedString::from(format!(
1049 "error borrando: {e}"
1050 )));
1051 }
1052 }
1053 cx.notify();
1054 })),
1055 ),
1056 );
1057 main = main.child(row);
1058 }
1059
1060 if rows.is_empty() {
1061 main = main.child(
1062 div()
1063 .py(px(12.))
1064 .text_color(text_dim)
1065 .text_size(px(12.))
1066 .child(format!("(sin {})", lv.entity)),
1067 );
1068 } else {
1069 main = main.child(
1070 div()
1071 .mt(px(8.))
1072 .text_color(text_dim)
1073 .text_size(px(11.))
1074 .child(format!("{total} fila(s)")),
1075 );
1076 }
1077
1078 main
1079 }
1080
1081 #[allow(clippy::too_many_arguments)]
1082 fn render_form(
1083 &self,
1084 cx: &mut Context<Self>,
1085 mut main: gpui::Div,
1086 fv: &FormView,
1087 mod_idx: usize,
1088 _border: gpui::Rgba,
1089 text: gpui::Rgba,
1090 text_dim: gpui::Rgba,
1091 accent: gpui::Rgba,
1092 ) -> gpui::Div {
1093 // En modo edit, el título refleja eso para que el user no
1094 // se confunda creyendo que hace alta nueva.
1095 let title = match self.editing.as_ref() {
1096 Some((e, id)) if e == &fv.entity => {
1097 format!("Editar {} {}", fv.entity, short_uuid(id))
1098 }
1099 _ => fv.title.clone(),
1100 };
1101 main = main.child(
1102 div()
1103 .text_color(text)
1104 .text_size(px(18.))
1105 .mb(px(12.))
1106 .child(title),
1107 );
1108 for f in &fv.fields {
1109 let label = if f.required {
1110 format!("{} *", f.label)
1111 } else {
1112 f.label.clone()
1113 };
1114
1115 let mut field_box = div().flex().flex_col().mb(px(10.)).child(
1116 div()
1117 .text_color(text_dim)
1118 .text_size(px(11.))
1119 .mb(px(2.))
1120 .child(label),
1121 );
1122
1123 // Mount del TextInput vivo (creado en select_view).
1124 if let Some(input) = self.form_inputs.get(&f.name) {
1125 field_box = field_box.child(input.clone());
1126 } else {
1127 // No debería pasar — select_view crea inputs por cada
1128 // field. Fallback display estático por seguridad.
1129 field_box = field_box.child(
1130 div()
1131 .px(px(8.))
1132 .py(px(6.))
1133 .bg(gpui::rgb(0x171a20))
1134 .text_color(text_dim)
1135 .child("(input no inicializado)"),
1136 );
1137 }
1138
1139 if let Some(help) = &f.help {
1140 field_box = field_box.child(
1141 div()
1142 .mt(px(2.))
1143 .text_color(text_dim)
1144 .text_size(px(10.))
1145 .child(help.clone()),
1146 );
1147 }
1148 main = main.child(field_box);
1149 }
1150
1151 let editing_this = matches!(
1152 self.editing.as_ref(),
1153 Some((e, _)) if e == &fv.entity
1154 );
1155 let submit_label = match &fv.on_submit {
1156 Action::SeedEntity { entity, .. } => {
1157 if editing_this {
1158 format!("Guardar cambios en {entity}")
1159 } else {
1160 format!("Crear {entity}")
1161 }
1162 }
1163 Action::Morphism { name, .. } => format!("Ejecutar {name}"),
1164 Action::OpenView { label, view } => {
1165 label.clone().unwrap_or_else(|| format!("Ir a {view}"))
1166 }
1167 };
1168 let submit_action = fv.on_submit.clone();
1169 main = main.child(
1170 div().mt(px(12.)).child(
1171 div()
1172 .id(SharedString::from(format!("form-submit-{mod_idx}")))
1173 .px(px(12.))
1174 .py(px(6.))
1175 .bg(gpui::rgb(0x2c3540))
1176 .text_color(accent)
1177 .text_size(px(12.))
1178 .rounded(px(3.))
1179 .hover(|d| d.bg(gpui::rgb(0x3a4555)))
1180 .child(submit_label)
1181 .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
1182 this.apply_action(submit_action.clone(), cx);
1183 })),
1184 ),
1185 );
1186
1187 main = main.child(
1188 div()
1189 .mt(px(20.))
1190 .text_color(text_dim)
1191 .text_size(px(10.))
1192 .child(
1193 "Tip: click en el campo para enfocar; Enter no envía (todavía), \
1194 usá el botón. Backspace borra el último carácter.",
1195 ),
1196 );
1197 main
1198 }
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203 use super::*;
1204
1205 #[test]
1206 fn parse_field_text_returns_string() {
1207 assert_eq!(parse_field_value(FieldKind::Text, "hola").unwrap(), json!("hola"));
1208 }
1209
1210 #[test]
1211 fn parse_field_number_int_then_float() {
1212 assert_eq!(parse_field_value(FieldKind::Number, "42").unwrap(), json!(42));
1213 assert_eq!(parse_field_value(FieldKind::Number, "3.14").unwrap(), json!(3.14));
1214 }
1215
1216 #[test]
1217 fn parse_field_number_invalid_errors() {
1218 assert!(parse_field_value(FieldKind::Number, "not-a-number").is_err());
1219 }
1220
1221 #[test]
1222 fn parse_field_boolean_variants() {
1223 assert_eq!(parse_field_value(FieldKind::Boolean, "true").unwrap(), json!(true));
1224 assert_eq!(parse_field_value(FieldKind::Boolean, "yes").unwrap(), json!(true));
1225 assert_eq!(parse_field_value(FieldKind::Boolean, "false").unwrap(), json!(false));
1226 assert_eq!(parse_field_value(FieldKind::Boolean, "").unwrap(), json!(false));
1227 assert!(parse_field_value(FieldKind::Boolean, "maybe").is_err());
1228 }
1229
1230 #[test]
1231 fn lookup_field_simple_and_nested() {
1232 let v = json!({
1233 "name": "Acme",
1234 "address": { "city": "Bogotá", "country": "CO" }
1235 });
1236 assert_eq!(lookup_field(&v, "name").unwrap(), &json!("Acme"));
1237 assert_eq!(lookup_field(&v, "address.city").unwrap(), &json!("Bogotá"));
1238 assert!(lookup_field(&v, "missing").is_none());
1239 assert!(lookup_field(&v, "address.zipcode").is_none());
1240 }
1241
1242 #[test]
1243 fn infer_param_value_int_then_float_then_bool_then_string() {
1244 assert_eq!(infer_param_value(""), json!(null));
1245 assert_eq!(infer_param_value("42"), json!(42));
1246 assert_eq!(infer_param_value("3.14"), json!(3.14));
1247 assert_eq!(infer_param_value("true"), json!(true));
1248 assert_eq!(infer_param_value("false"), json!(false));
1249 assert_eq!(infer_param_value("hola"), json!("hola"));
1250 }
1251
1252 #[test]
1253 fn render_value_handles_null_string_bool() {
1254 assert_eq!(render_value(None), "");
1255 assert_eq!(render_value(Some(&json!(null))), "");
1256 assert_eq!(render_value(Some(&json!("x"))), "x");
1257 assert_eq!(render_value(Some(&json!(true))), "✓");
1258 assert_eq!(render_value(Some(&json!(false))), "✗");
1259 assert_eq!(render_value(Some(&json!(42))), "42");
1260 }
1261
1262 #[test]
1263 fn value_to_input_text_inverse_of_parse() {
1264 // text → text
1265 assert_eq!(value_to_input_text(&json!("hola")), "hola");
1266 // bool → "true"/"false" (parse_field_value lo acepta)
1267 assert_eq!(value_to_input_text(&json!(true)), "true");
1268 assert_eq!(value_to_input_text(&json!(false)), "false");
1269 // number → string
1270 assert_eq!(value_to_input_text(&json!(42)), "42");
1271 assert_eq!(value_to_input_text(&json!(3.14)), "3.14");
1272 // null → ""
1273 assert_eq!(value_to_input_text(&json!(null)), "");
1274 }
1275
1276 #[test]
1277 fn value_to_input_then_parse_round_trip() {
1278 // El round-trip es la propiedad fundamental: edit → text →
1279 // parse → mismo Value (modulo casts numéricos).
1280 let cases = vec![
1281 (FieldKind::Text, json!("hola")),
1282 (FieldKind::Boolean, json!(true)),
1283 (FieldKind::Boolean, json!(false)),
1284 (FieldKind::Number, json!(42)),
1285 ];
1286 for (kind, original) in cases {
1287 let text = value_to_input_text(&original);
1288 let parsed = parse_field_value(kind, &text).unwrap();
1289 assert_eq!(
1290 parsed, original,
1291 "round-trip text→parse falló para {original:?}"
1292 );
1293 }
1294 }
1295
1296 /// E2E mínimo del WAL: armamos un log a mano con dos seeds,
1297 /// abrimos con `EventLog::open` + `replay_into`, y verificamos
1298 /// que el `MemoryStore` queda con esos records aplicados.
1299 /// Esto reproduce el flujo del startup de `MetaUi::new` sin
1300 /// necesitar GPUI.
1301 #[test]
1302 fn event_log_replay_restores_memory_store() {
1303 use nakui_core::event_log::{replay_into, EventLog, LogEntry};
1304 use nakui_core::store::{MemoryStore, Store};
1305
1306 let tmp = tempfile::NamedTempFile::new().unwrap();
1307 let path = tmp.path().to_path_buf();
1308 // Cerramos el handle de tempfile pero conservamos el path
1309 // para que EventLog pueda re-abrir.
1310 drop(tmp);
1311
1312 // Escribimos dos seeds via EventLog::append.
1313 let id_a = Uuid::new_v4();
1314 let id_b = Uuid::new_v4();
1315 {
1316 let mut log = EventLog::open(&path).unwrap();
1317 log.append(LogEntry::Seed {
1318 seq: 0,
1319 entity: "customer".into(),
1320 id: id_a,
1321 data: json!({"name": "Acme"}),
1322 schema_hash: None,
1323 })
1324 .unwrap();
1325 log.append(LogEntry::Seed {
1326 seq: 1,
1327 entity: "customer".into(),
1328 id: id_b,
1329 data: json!({"name": "Globex"}),
1330 schema_hash: None,
1331 })
1332 .unwrap();
1333 }
1334
1335 // Re-abrir + replay (simula startup de MetaUi).
1336 let log = EventLog::open(&path).unwrap();
1337 assert_eq!(log.next_seq(), 2, "next_seq debe ser 2 tras 2 entries");
1338 let mut store = MemoryStore::new();
1339 replay_into(&log, &mut store).unwrap();
1340
1341 // Verificar que ambos records están en el store.
1342 assert_eq!(
1343 store.load("customer", id_a),
1344 Some(json!({"name": "Acme"})),
1345 "Acme debería estar tras replay"
1346 );
1347 assert_eq!(
1348 store.load("customer", id_b),
1349 Some(json!({"name": "Globex"})),
1350 "Globex debería estar tras replay"
1351 );
1352
1353 let _ = std::fs::remove_file(&path);
1354 }
1355
1356 /// E2E del Action::Morphism: carga el módulo nakui-core real
1357 /// `sales` (que vive en `crates/modules/nakui/modules/sales`),
1358 /// arma store + log, y ejecuta el morphism `vender` vía
1359 /// `execute_and_log_with_recovery` (la misma función que
1360 /// `commit_morphism` invoca). Verifica que las ops esperadas
1361 /// se loguean y aplican (stock decrementa, caja incrementa).
1362 ///
1363 /// Reproduce el flujo del runtime sin necesitar GPUI.
1364 #[test]
1365 fn morphism_pipeline_executes_real_sales_vender() {
1366 use nakui_core::event_log::{execute_and_log_with_recovery, EventLog};
1367 use nakui_core::executor::Executor;
1368 use nakui_core::store::{MemoryStore, Store};
1369
1370 // Path al módulo real (3 dirs arriba: crates/apps/nakui-ui/
1371 // → crates/modules/nakui/modules/sales).
1372 let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
1373 let sales_dir = here
1374 .join("../../..")
1375 .join("crates/modules/nakui/modules/sales");
1376 if !sales_dir.join("nsmc.json").exists() {
1377 // Si el módulo demo no está donde esperamos, skipeamos
1378 // — no es regresión del feature, es ambiente.
1379 eprintln!(
1380 "skip: sales module no encontrado en {}",
1381 sales_dir.display()
1382 );
1383 return;
1384 }
1385
1386 let executor = Executor::load_module(&sales_dir).expect("cargar sales executor");
1387
1388 let mut store = MemoryStore::new();
1389 let stock_id = Uuid::new_v4();
1390 let caja_id = Uuid::new_v4();
1391 store.seed(
1392 "Stock",
1393 stock_id,
1394 json!({
1395 "id": stock_id.to_string(),
1396 "sku_id": "test-sku",
1397 "ubicacion": "loc-1",
1398 "cantidad": 100_i64,
1399 }),
1400 );
1401 store.seed(
1402 "Caja",
1403 caja_id,
1404 json!({
1405 "id": caja_id.to_string(),
1406 "name": "Caja Test",
1407 "currency": "USD",
1408 "saldo": 1_000_000_i64,
1409 }),
1410 );
1411
1412 let tmp = tempfile::NamedTempFile::new().unwrap();
1413 let log_path = tmp.path().to_path_buf();
1414 drop(tmp);
1415 let mut log = EventLog::open(&log_path).unwrap();
1416
1417 let venta_id = Uuid::new_v4();
1418 let inputs = vec![("stock", stock_id), ("caja", caja_id)];
1419 let params = json!({
1420 "venta_id": venta_id.to_string(),
1421 "cantidad": 5_i64,
1422 "precio_unitario": 200_i64,
1423 "timestamp": "2026-05-04T10:00:00Z",
1424 });
1425
1426 let ops = execute_and_log_with_recovery(
1427 &executor,
1428 &mut store,
1429 &mut log,
1430 "vender",
1431 &inputs,
1432 params,
1433 )
1434 .expect("morphism vender debe ejecutar limpio");
1435
1436 assert!(!ops.is_empty(), "vender debería producir ops");
1437
1438 // Sanity post-condiciones esperadas del manifest sales:
1439 // - stock.cantidad bajó (vendimos 5).
1440 let stock_after = store
1441 .load("Stock", stock_id)
1442 .and_then(|v| v.get("cantidad").and_then(Value::as_i64))
1443 .expect("stock con cantidad");
1444 assert_eq!(stock_after, 95, "stock debería bajar de 100 a 95");
1445 // - caja.saldo subió (cobramos 5*200 = 1000 sobre saldo
1446 // inicial 1_000_000).
1447 let caja_after = store
1448 .load("Caja", caja_id)
1449 .and_then(|v| v.get("saldo").and_then(Value::as_i64))
1450 .expect("caja con saldo");
1451 assert_eq!(caja_after, 1_001_000, "caja debería subir 5*200=1000");
1452
1453 let _ = std::fs::remove_file(&log_path);
1454 }
1455
1456 /// E2E del ciclo CRUD vía log:
1457 /// 1. Seed un record.
1458 /// 2. Morphism con Set ops (edit) — sobreescribe campos.
1459 /// 3. Morphism con Delete op — borra el record.
1460 /// 4. Replay desde cero: el store queda como tras el delete (vacío).
1461 #[test]
1462 fn event_log_replay_handles_full_crud_cycle() {
1463 use nakui_core::delta::{FieldOp, FieldPath};
1464 use nakui_core::event_log::{replay_into, EventLog, LogEntry};
1465 use nakui_core::store::{MemoryStore, Store};
1466
1467 let tmp = tempfile::NamedTempFile::new().unwrap();
1468 let path = tmp.path().to_path_buf();
1469 drop(tmp);
1470
1471 let id = Uuid::new_v4();
1472
1473 // 1. Escribir 3 entries: seed, edit, delete.
1474 {
1475 let mut log = EventLog::open(&path).unwrap();
1476 log.append(LogEntry::Seed {
1477 seq: 0,
1478 entity: "customer".into(),
1479 id,
1480 data: json!({"name": "Acme", "active": true}),
1481 schema_hash: None,
1482 })
1483 .unwrap();
1484 log.append(LogEntry::Morphism {
1485 seq: 1,
1486 morphism: "ui.edit_record".into(),
1487 inputs: Default::default(),
1488 params: json!({}),
1489 ops: vec![
1490 FieldOp::Set {
1491 path: FieldPath {
1492 entity: "customer".into(),
1493 id,
1494 field: "name".into(),
1495 },
1496 value: json!("Acme S.A."),
1497 },
1498 FieldOp::Set {
1499 path: FieldPath {
1500 entity: "customer".into(),
1501 id,
1502 field: "active".into(),
1503 },
1504 value: json!(false),
1505 },
1506 ],
1507 schema_hash: None,
1508 })
1509 .unwrap();
1510 log.append(LogEntry::Morphism {
1511 seq: 2,
1512 morphism: "ui.delete_record".into(),
1513 inputs: Default::default(),
1514 params: json!({}),
1515 ops: vec![FieldOp::Delete {
1516 entity: "customer".into(),
1517 id,
1518 }],
1519 schema_hash: None,
1520 })
1521 .unwrap();
1522 }
1523
1524 // 2. Replay desde cero — debe terminar con store vacío
1525 // (el delete fue el último op).
1526 let log = EventLog::open(&path).unwrap();
1527 let mut store = MemoryStore::new();
1528 replay_into(&log, &mut store).unwrap();
1529 assert_eq!(
1530 store.load("customer", id),
1531 None,
1532 "tras seed + edit + delete, el record no debería existir"
1533 );
1534
1535 // 3. Sanity: si paramos en seq=1 (snapshot post-edit), el
1536 // record debería tener los valores editados.
1537 // (Construimos un store fresh y aplicamos sólo seq 0 y 1
1538 // a mano para verificar.)
1539 let mut store_partial = MemoryStore::new();
1540 store_partial.seed("customer", id, json!({"name": "Acme", "active": true}));
1541 store_partial
1542 .apply(&[
1543 FieldOp::Set {
1544 path: FieldPath {
1545 entity: "customer".into(),
1546 id,
1547 field: "name".into(),
1548 },
1549 value: json!("Acme S.A."),
1550 },
1551 FieldOp::Set {
1552 path: FieldPath {
1553 entity: "customer".into(),
1554 id,
1555 field: "active".into(),
1556 },
1557 value: json!(false),
1558 },
1559 ])
1560 .unwrap();
1561 assert_eq!(
1562 store_partial.load("customer", id),
1563 Some(json!({"name": "Acme S.A.", "active": false})),
1564 "tras seed + edit, el record debería tener los nuevos valores"
1565 );
1566
1567 let _ = std::fs::remove_file(&path);
1568 }
1569}