⎇ 6588d0ed6c355a57194a34a488c1ff2b3d8eb6b3
crates/modules/nahual/libs/meta-schema/src/lib.rs
Historia
Quién tocó qué
crudo
feat(nakui): Fase 3 del ERP — ficha de detalle
1//! Schema declarativo de la metainterfaz (nahual meta-schema).
2//!
3//! Cada **módulo** declara aquí qué menús, vistas, listas y
4//! formularios expone, sin escribir código GPUI ni Rust. Cualquier
5//! runtime de UI dirigida por datos (Nakui hoy, otros mañana) lo
6//! carga y monta la UI correspondiente.
7//!
8//! ## Filosofía
9//!
10//! - **UI como datos**: agregar un módulo = escribir un JSON o un
11//! `.ncl`. Ningún recompile, ningún acoplamiento con el binario
12//! del runtime.
13//! - **Backend-agnostic**: este crate sólo describe la *forma* de
14//! la UI. La conexión a un store/log/executor concretos vive en
15//! el runtime que lo consume (ej: el meta-runtime de Nakui que
16//! wirea esto a `nakui_core` + KCL post-checks).
17//! - **Schema primero, semántica después**: validación semántica
18//! (referencias rotas a entities, campos faltantes, etc.) vive
19//! en el runtime que lo carga, no acá.
20//!
21//! ## Anatomía de un módulo
22//!
23//! ```json
24//! {
25//! "id": "customers",
26//! "label": "Clientes",
27//! "entities": [
28//! { "name": "customer", "fields": [ ... ] }
29//! ],
30//! "menu": [
31//! { "label": "Listar", "view": "list" },
32//! { "label": "Nuevo", "view": "form" }
33//! ],
34//! "views": {
35//! "list": { "kind": "list", "entity": "customer", "columns": [...] },
36//! "form": { "kind": "form", "entity": "customer", "fields": [...] }
37//! }
38//! }
39//! ```
40
41#![forbid(unsafe_code)]
42#![warn(rust_2018_idioms)]
43
44use std::collections::BTreeMap;
45use std::path::{Path, PathBuf};
46
47use serde::{Deserialize, Serialize};
48use thiserror::Error;
49
50/// Manifiesto de un módulo declarativo de UI.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct Module {
53 /// Identificador estable. Único dentro del directorio cargado.
54 pub id: String,
55
56 /// Nombre legible para mostrar en el sidebar.
57 pub label: String,
58
59 /// Descripción corta opcional (tooltip / subtítulo).
60 #[serde(default)]
61 pub description: Option<String>,
62
63 /// Entities que el módulo introduce o consume. El runtime las
64 /// usa para validar columns/fields y para inicializar el store
65 /// cuando son nuevas.
66 #[serde(default)]
67 pub entities: Vec<EntitySpec>,
68
69 /// Path opaco al backend que va a manejar este módulo. Lo
70 /// interpreta el runtime concreto (no este schema).
71 ///
72 /// Convención actual de Nakui: directorio con `nsmc.json` +
73 /// schemas KCL + scripts Rhai. Cuando está set, el runtime
74 /// Nakui carga un `Executor` para ese path y permite que las
75 /// acciones `Morphism { name }` despachen al pipeline real
76 /// (compute → log → apply). Otro backend puede ignorar este
77 /// campo o darle un significado distinto.
78 ///
79 /// Path resuelto relativo al directorio del `module.json`
80 /// o absoluto.
81 ///
82 /// Si es `None`, los backends que requieren manifest deberían
83 /// degradar (toast informativo, deshabilitar morphisms, etc.);
84 /// los `SeedEntity` siguen funcionando — son altas
85 /// administrativas que no necesitan validación de manifest.
86 ///
87 /// Nombre conservado por compat con módulos ya escritos.
88 /// Renombrar a `backend_module_dir` o similar si emerge un
89 /// segundo backend que también lo use.
90 #[serde(default, alias = "backend_module_dir")]
91 pub nakui_module_dir: Option<String>,
92
93 /// Items del menú. Cada uno apunta a una key de `views`. Orden
94 /// importa (es el orden en que se presentan en el sidebar).
95 pub menu: Vec<MenuItem>,
96
97 /// Vistas indexadas por key. Las keys son referenciadas por
98 /// `MenuItem.view` y por `Action::OpenView.view`.
99 pub views: BTreeMap<String, View>,
100}
101
102/// Item del menú lateral.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct MenuItem {
105 pub label: String,
106 /// Key de la vista a abrir. Debe existir en `Module.views`.
107 pub view: String,
108 /// Icono opcional (texto unicode o emoji; el runtime decide
109 /// renderización).
110 #[serde(default)]
111 pub icon: Option<String>,
112}
113
114/// Una vista renderizada en el área principal cuando su menú es seleccionado.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(tag = "kind", rename_all = "snake_case")]
117pub enum View {
118 /// Tabla de instancias de una entity, columnas + acciones por fila.
119 List(ListView),
120 /// Formulario de creación / edición.
121 Form(FormView),
122 /// Ficha de un record: sus campos + listas de records relacionados.
123 Detail(DetailView),
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ListView {
128 pub title: String,
129 /// Entity (del nakui store) cuyas instancias se listan.
130 pub entity: String,
131 /// Columnas a mostrar. Orden importa.
132 pub columns: Vec<Column>,
133 /// Acciones disponibles a nivel de la lista (ej. "Nuevo" → form).
134 /// Renderizadas como botones en el header.
135 #[serde(default)]
136 pub actions: Vec<Action>,
137 /// Cuando está set, se muestra una caja de búsqueda que filtra
138 /// las filas por substring contra los valores de estas columnas.
139 #[serde(default)]
140 pub search_in: Vec<String>,
141 /// Si está set, cada fila gana un botón 👁 que abre esta vista
142 /// (debe ser una `View::Detail`) para el record de la fila.
143 #[serde(default)]
144 pub row_detail: Option<String>,
145}
146
147/// Ficha de un record: sus campos + listas de records relacionados.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct DetailView {
150 pub title: String,
151 /// Entity del record que se muestra.
152 pub entity: String,
153 /// Campos a mostrar, en orden. Reusa [`Column`] (label + field +
154 /// `ref_entity` + `format`; el `weight` se ignora en la ficha).
155 #[serde(default)]
156 pub fields: Vec<Column>,
157 /// Listas de records relacionados (back-references).
158 #[serde(default)]
159 pub related: Vec<RelatedList>,
160}
161
162/// Una lista de records relacionados dentro de una [`DetailView`]: los
163/// records de otra entity cuyo campo `via_field` apunta al record que
164/// se está viendo.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct RelatedList {
167 pub title: String,
168 /// Entity de los records relacionados.
169 pub entity: String,
170 /// Campo de esa entity cuyo valor (UUID) referencia al record
171 /// actual. El runtime filtra `record[via_field] == id_actual`.
172 pub via_field: String,
173 pub columns: Vec<Column>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct Column {
178 /// Path del campo dentro del record. Para tipos planos: nombre.
179 /// Para nested: puntos (`address.city`). El runtime navega.
180 pub field: String,
181 /// Texto del header.
182 pub label: String,
183 /// Ancho relativo (peso flex). Default 1.
184 #[serde(default = "default_weight")]
185 pub weight: f32,
186 /// Si está set, la celda resuelve su valor (un UUID) al label
187 /// legible del record de esta entity, en vez de mostrar el UUID
188 /// crudo. Para columnas que son referencias a otra entity.
189 #[serde(default)]
190 pub ref_entity: Option<String>,
191 /// Formato de presentación del valor de la celda.
192 #[serde(default)]
193 pub format: ValueFormat,
194}
195
196fn default_weight() -> f32 {
197 1.0
198}
199
200/// Formato de presentación de un valor en una celda de lista.
201#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(tag = "kind", rename_all = "snake_case")]
203pub enum ValueFormat {
204 /// Sin formato — el valor se muestra crudo. Default.
205 #[default]
206 Plain,
207 /// Entero/decimal con separador de miles (`12000` → `12,000`).
208 Number,
209 /// Moneda: separador de miles + símbolo prefijo (`12000` → `$12,000`).
210 Currency { symbol: String },
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct FormView {
215 pub title: String,
216 /// Entity destino del seed/morphism al submit.
217 pub entity: String,
218 pub fields: Vec<FieldSpec>,
219 /// Acción al submit. Típicamente `Action::SeedEntity` para alta
220 /// directa o `Action::Morphism` cuando hay validación/cálculo.
221 pub on_submit: Action,
222}
223
224/// Especificación de un campo de formulario o columna implícita.
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct FieldSpec {
227 /// Nombre del campo en el record (clave del JSON).
228 pub name: String,
229 /// Etiqueta legible.
230 pub label: String,
231 /// Tipo del valor — define el widget de input + parseo.
232 pub kind: FieldKind,
233 /// Valor por defecto al abrir el form (string raw; el parseo
234 /// según `kind` lo hace el runtime).
235 #[serde(default)]
236 pub default: Option<String>,
237 /// Si `true`, el form rechaza submit con campo vacío.
238 #[serde(default)]
239 pub required: bool,
240 /// Texto de ayuda mostrado bajo el input.
241 #[serde(default)]
242 pub help: Option<String>,
243 /// Si `kind == EntityRef`, indica qué entity referencia. Sin
244 /// esto, el runtime no sabe qué records ofrecer en el selector
245 /// y la validación `Module::validate` rechaza el manifest.
246 /// Para los demás kinds, este campo se ignora.
247 #[serde(default)]
248 pub ref_entity: Option<String>,
249 /// Opciones de un campo `kind == Select`. Ignorado para los demás
250 /// kinds. `Module::validate` exige que un Select las tenga.
251 #[serde(default)]
252 pub options: Vec<SelectOption>,
253}
254
255#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
256#[serde(rename_all = "snake_case")]
257pub enum FieldKind {
258 /// Texto libre.
259 Text,
260 /// Texto multilínea.
261 Multiline,
262 /// Número (i64 o f64; runtime intenta parsear como i64 primero).
263 Number,
264 /// Booleano (renderizado como checkbox).
265 Boolean,
266 /// Fecha (formato ISO YYYY-MM-DD; almacenada como string).
267 Date,
268 /// Referencia a otro record. El runtime renderiza un selector
269 /// clickable de records existentes de la entity declarada en
270 /// `FieldSpec.ref_entity`; el value almacenado es el UUID del
271 /// seleccionado, parseable como cualquier text/UUID al submit.
272 EntityRef,
273 /// Valor elegido de un conjunto cerrado declarado en
274 /// `FieldSpec.options`. El runtime lo renderiza como selección
275 /// (no texto libre). `Module::validate` exige `options` no vacío.
276 Select,
277 /// Identificador autogenerado (UUID v4). El runtime lo rellena al
278 /// abrir el formulario; el usuario no lo teclea ni lo edita. Para
279 /// los ids de idempotencia que piden los morfismos.
280 AutoId,
281}
282
283/// Una opción de un campo [`FieldKind::Select`].
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct SelectOption {
286 /// Valor que se guarda (lo que recibe el backend o el morfismo).
287 pub value: String,
288 /// Etiqueta legible. Si se omite, se muestra el `value` crudo.
289 #[serde(default)]
290 pub label: Option<String>,
291}
292
293impl SelectOption {
294 /// Texto a mostrar: `label` si está, sino el `value`.
295 pub fn display(&self) -> &str {
296 self.label.as_deref().unwrap_or(&self.value)
297 }
298}
299
300/// Acciones disparables por menús, botones o submit de formularios.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302#[serde(tag = "kind", rename_all = "snake_case")]
303pub enum Action {
304 /// Cambia la vista activa a otra del mismo módulo.
305 OpenView {
306 view: String,
307 /// Etiqueta del botón / item; default = nombre humano de la vista.
308 #[serde(default)]
309 label: Option<String>,
310 },
311 /// Crea un seed directo en la entity con los valores del form.
312 /// Equivalente a `nakui_core::event_log::seed_and_log`.
313 /// Sin pipeline de morphism — alta administrativa.
314 SeedEntity {
315 entity: String,
316 /// Tras el submit exitoso, opcionalmente abrir esta vista
317 /// (por convención: `"list"` para volver al listado).
318 #[serde(default)]
319 next_view: Option<String>,
320 },
321 /// Ejecuta un morphism declarado en el manifest del módulo
322 /// nakui-core (cuyo path vive en `Module.nakui_module_dir`).
323 /// Inputs (records existentes) y params (valores escalares) se
324 /// mapean desde los campos del form.
325 Morphism {
326 /// Nombre del morphism declarado en `nsmc.json` del manifest
327 /// nakui apuntado por el módulo.
328 name: String,
329 /// Mapeo `role → field_name`: por cada input declarado en
330 /// el `MorphismSpec.inputs`, indica qué field del form
331 /// contiene el UUID del record. El runtime parsea el value
332 /// como `Uuid` y lo pasa como input al `execute_and_log`.
333 ///
334 /// Ej: `{ "stock": "stock_id", "caja": "caja_id" }` para un
335 /// morphism `vender` que toma roles `stock` y `caja`.
336 #[serde(default)]
337 inputs: BTreeMap<String, String>,
338 /// Lista de fields del form cuyos values van al `params`
339 /// JSON object pasado al morphism. Si está vacío, todos los
340 /// fields que no estén en `inputs` van a params.
341 #[serde(default)]
342 params: Vec<String>,
343 #[serde(default)]
344 next_view: Option<String>,
345 },
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct EntitySpec {
350 /// Nombre de la entity (clave en el store). Único dentro del módulo.
351 pub name: String,
352 /// Label legible (singular).
353 pub label: String,
354 /// Campos esperados en records de esta entity. Usados por el
355 /// runtime para inferir columnas / validar formularios.
356 #[serde(default)]
357 pub fields: Vec<FieldSpec>,
358}
359
360#[derive(Debug, Error)]
361pub enum SchemaError {
362 #[error("io leyendo {path}: {source}")]
363 Io {
364 path: PathBuf,
365 #[source]
366 source: std::io::Error,
367 },
368 #[error("parseo de {path}: {source}")]
369 Parse {
370 path: PathBuf,
371 #[source]
372 source: serde_json::Error,
373 },
374 #[error("módulo {id}: la vista '{view}' referenciada por menu_item '{item}' no existe")]
375 DanglingMenuView {
376 id: String,
377 item: String,
378 view: String,
379 },
380 #[error("módulos con id duplicado: '{id}' aparece en {first} y {second}")]
381 DuplicateModuleId {
382 id: String,
383 first: PathBuf,
384 second: PathBuf,
385 },
386 #[error(
387 "módulo {id} vista '{view}': field '{field}' tiene kind=entity_ref \
388 pero no declaró ref_entity"
389 )]
390 EntityRefMissingTarget {
391 id: String,
392 view: String,
393 field: String,
394 },
395 #[error(
396 "módulo {id} vista '{view}': field '{field}' tiene kind=select \
397 pero no declaró options"
398 )]
399 SelectMissingOptions {
400 id: String,
401 view: String,
402 field: String,
403 },
404 #[error(
405 "módulo {id} vista '{view}': row_detail='{target}' no apunta a \
406 una vista kind=detail"
407 )]
408 RowDetailInvalid {
409 id: String,
410 view: String,
411 target: String,
412 },
413}
414
415impl Module {
416 /// Carga un module.json desde disco. Validación estructural
417 /// posterior (vistas referenciadas existen, etc.) la ejecuta el
418 /// runtime — acá sólo parseamos.
419 pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SchemaError> {
420 let path = path.as_ref();
421 let bytes = std::fs::read(path).map_err(|source| SchemaError::Io {
422 path: path.to_path_buf(),
423 source,
424 })?;
425 serde_json::from_slice(&bytes).map_err(|source| SchemaError::Parse {
426 path: path.to_path_buf(),
427 source,
428 })
429 }
430
431 /// Validación post-parse:
432 /// - Cada `MenuItem.view` debe existir en `views`.
433 /// - Cada `FieldSpec` con `kind=EntityRef` debe declarar
434 /// `ref_entity`.
435 pub fn validate(&self) -> Result<(), SchemaError> {
436 for item in &self.menu {
437 if !self.views.contains_key(&item.view) {
438 return Err(SchemaError::DanglingMenuView {
439 id: self.id.clone(),
440 item: item.label.clone(),
441 view: item.view.clone(),
442 });
443 }
444 }
445 for (view_key, view) in &self.views {
446 match view {
447 View::Form(form) => {
448 for f in &form.fields {
449 if f.kind == FieldKind::EntityRef && f.ref_entity.is_none() {
450 return Err(SchemaError::EntityRefMissingTarget {
451 id: self.id.clone(),
452 view: view_key.clone(),
453 field: f.name.clone(),
454 });
455 }
456 if f.kind == FieldKind::Select && f.options.is_empty() {
457 return Err(SchemaError::SelectMissingOptions {
458 id: self.id.clone(),
459 view: view_key.clone(),
460 field: f.name.clone(),
461 });
462 }
463 }
464 }
465 View::List(list) => {
466 if let Some(target) = &list.row_detail {
467 if !matches!(self.views.get(target), Some(View::Detail(_))) {
468 return Err(SchemaError::RowDetailInvalid {
469 id: self.id.clone(),
470 view: view_key.clone(),
471 target: target.clone(),
472 });
473 }
474 }
475 }
476 View::Detail(_) => {}
477 }
478 }
479 Ok(())
480 }
481}
482
483/// Carga todos los `module.json` encontrados bajo `dir` (recursivo
484/// 1 nivel — espera `dir/<modulo>/module.json`). Devuelve la lista
485/// ordenada por id, validada.
486///
487/// Falla si: I/O, parseo, módulo inválido, o ids duplicados.
488pub fn load_modules_from_dir(dir: impl AsRef<Path>) -> Result<Vec<Module>, SchemaError> {
489 let dir = dir.as_ref();
490 let mut modules: Vec<(PathBuf, Module)> = Vec::new();
491 let entries = std::fs::read_dir(dir).map_err(|source| SchemaError::Io {
492 path: dir.to_path_buf(),
493 source,
494 })?;
495 for entry in entries.flatten() {
496 let p = entry.path();
497 if p.is_dir() {
498 let manifest = p.join("module.json");
499 if manifest.exists() {
500 let m = Module::from_path(&manifest)?;
501 m.validate()?;
502 modules.push((manifest, m));
503 }
504 }
505 }
506 // Detectar duplicados de id.
507 modules.sort_by(|a, b| a.1.id.cmp(&b.1.id));
508 let mut prev: Option<&(PathBuf, Module)> = None;
509 for cur in &modules {
510 if let Some(p) = prev {
511 if p.1.id == cur.1.id {
512 return Err(SchemaError::DuplicateModuleId {
513 id: cur.1.id.clone(),
514 first: p.0.clone(),
515 second: cur.0.clone(),
516 });
517 }
518 }
519 prev = Some(cur);
520 }
521 Ok(modules.into_iter().map(|(_, m)| m).collect())
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527
528 fn sample_module() -> Module {
529 Module {
530 id: "customers".into(),
531 label: "Clientes".into(),
532 description: Some("Gestión de clientes".into()),
533 nakui_module_dir: None,
534 entities: vec![EntitySpec {
535 name: "customer".into(),
536 label: "Cliente".into(),
537 fields: vec![
538 FieldSpec {
539 name: "name".into(),
540 label: "Nombre".into(),
541 kind: FieldKind::Text,
542 default: None,
543 required: true,
544 help: None,
545 ref_entity: None,
546 options: Vec::new(),
547 },
548 FieldSpec {
549 name: "email".into(),
550 label: "Email".into(),
551 kind: FieldKind::Text,
552 default: None,
553 required: false,
554 help: Some("Opcional".into()),
555 ref_entity: None,
556 options: Vec::new(),
557 },
558 ],
559 }],
560 menu: vec![
561 MenuItem {
562 label: "Listar".into(),
563 view: "list".into(),
564 icon: None,
565 },
566 MenuItem {
567 label: "Nuevo".into(),
568 view: "form".into(),
569 icon: None,
570 },
571 ],
572 views: BTreeMap::from([
573 (
574 "list".into(),
575 View::List(ListView {
576 title: "Clientes".into(),
577 entity: "customer".into(),
578 columns: vec![
579 Column {
580 field: "name".into(),
581 label: "Nombre".into(),
582 weight: 2.0,
583 ref_entity: None,
584 format: ValueFormat::Plain,
585 },
586 Column {
587 field: "email".into(),
588 label: "Email".into(),
589 weight: 3.0,
590 ref_entity: None,
591 format: ValueFormat::Plain,
592 },
593 ],
594 actions: vec![Action::OpenView {
595 view: "form".into(),
596 label: Some("Nuevo".into()),
597 }],
598 search_in: vec!["name".into(), "email".into()],
599 row_detail: None,
600 }),
601 ),
602 (
603 "form".into(),
604 View::Form(FormView {
605 title: "Nuevo cliente".into(),
606 entity: "customer".into(),
607 fields: vec![FieldSpec {
608 name: "name".into(),
609 label: "Nombre".into(),
610 kind: FieldKind::Text,
611 default: None,
612 required: true,
613 help: None,
614 ref_entity: None,
615 options: Vec::new(),
616 }],
617 on_submit: Action::SeedEntity {
618 entity: "customer".into(),
619 next_view: Some("list".into()),
620 },
621 }),
622 ),
623 ]),
624 }
625 }
626
627 #[test]
628 fn module_serialize_deserialize_roundtrip() {
629 let m = sample_module();
630 let s = serde_json::to_string_pretty(&m).unwrap();
631 let m2: Module = serde_json::from_str(&s).unwrap();
632 assert_eq!(m.id, m2.id);
633 assert_eq!(m.menu.len(), m2.menu.len());
634 assert_eq!(m.views.len(), m2.views.len());
635 }
636
637 #[test]
638 fn validate_passes_for_well_formed_module() {
639 sample_module().validate().unwrap();
640 }
641
642 #[test]
643 fn validate_catches_dangling_menu_view() {
644 let mut m = sample_module();
645 m.menu.push(MenuItem {
646 label: "Roto".into(),
647 view: "no_existe".into(),
648 icon: None,
649 });
650 let err = m.validate().unwrap_err();
651 assert!(matches!(err, SchemaError::DanglingMenuView { .. }));
652 }
653
654 #[test]
655 fn from_path_loads_real_file() {
656 let tmp = tempfile::tempdir().unwrap();
657 let path = tmp.path().join("module.json");
658 std::fs::write(&path, serde_json::to_vec_pretty(&sample_module()).unwrap()).unwrap();
659 let m = Module::from_path(&path).unwrap();
660 assert_eq!(m.id, "customers");
661 }
662
663 #[test]
664 fn load_modules_from_dir_finds_subdirs() {
665 let tmp = tempfile::tempdir().unwrap();
666 let cust_dir = tmp.path().join("customers");
667 std::fs::create_dir(&cust_dir).unwrap();
668 let mut m1 = sample_module();
669 m1.id = "customers".into();
670 std::fs::write(
671 cust_dir.join("module.json"),
672 serde_json::to_vec_pretty(&m1).unwrap(),
673 )
674 .unwrap();
675 let prod_dir = tmp.path().join("products");
676 std::fs::create_dir(&prod_dir).unwrap();
677 let mut m2 = sample_module();
678 m2.id = "products".into();
679 std::fs::write(
680 prod_dir.join("module.json"),
681 serde_json::to_vec_pretty(&m2).unwrap(),
682 )
683 .unwrap();
684
685 let mods = load_modules_from_dir(tmp.path()).unwrap();
686 assert_eq!(mods.len(), 2);
687 assert_eq!(mods[0].id, "customers");
688 assert_eq!(mods[1].id, "products");
689 }
690
691 #[test]
692 fn validate_catches_entity_ref_without_target() {
693 let mut m = sample_module();
694 // Inyectamos un form con un campo EntityRef sin ref_entity.
695 m.views.insert(
696 "broken_form".into(),
697 View::Form(FormView {
698 title: "Roto".into(),
699 entity: "customer".into(),
700 fields: vec![FieldSpec {
701 name: "ref_to_nowhere".into(),
702 label: "Referencia".into(),
703 kind: FieldKind::EntityRef,
704 default: None,
705 required: true,
706 help: None,
707 ref_entity: None,
708 options: Vec::new(),
709 }],
710 on_submit: Action::SeedEntity {
711 entity: "customer".into(),
712 next_view: None,
713 },
714 }),
715 );
716 let err = m.validate().unwrap_err();
717 assert!(
718 matches!(err, SchemaError::EntityRefMissingTarget { .. }),
719 "got: {err:?}"
720 );
721 }
722
723 #[test]
724 fn entity_ref_with_target_validates_clean() {
725 let mut m = sample_module();
726 m.views.insert(
727 "ok_form".into(),
728 View::Form(FormView {
729 title: "OK".into(),
730 entity: "customer".into(),
731 fields: vec![FieldSpec {
732 name: "supplier".into(),
733 label: "Proveedor".into(),
734 kind: FieldKind::EntityRef,
735 default: None,
736 required: true,
737 help: None,
738 ref_entity: Some("supplier".into()),
739 options: Vec::new(),
740 }],
741 on_submit: Action::SeedEntity {
742 entity: "customer".into(),
743 next_view: None,
744 },
745 }),
746 );
747 m.menu.push(MenuItem {
748 label: "OK".into(),
749 view: "ok_form".into(),
750 icon: None,
751 });
752 m.validate().unwrap();
753 }
754
755 #[test]
756 fn select_without_options_is_rejected() {
757 let mut m = sample_module();
758 m.views.insert(
759 "sel_form".into(),
760 View::Form(FormView {
761 title: "Select roto".into(),
762 entity: "customer".into(),
763 fields: vec![FieldSpec {
764 name: "estado".into(),
765 label: "Estado".into(),
766 kind: FieldKind::Select,
767 default: None,
768 required: true,
769 help: None,
770 ref_entity: None,
771 options: Vec::new(),
772 }],
773 on_submit: Action::SeedEntity {
774 entity: "customer".into(),
775 next_view: None,
776 },
777 }),
778 );
779 let err = m.validate().unwrap_err();
780 assert!(
781 matches!(err, SchemaError::SelectMissingOptions { .. }),
782 "got: {err:?}"
783 );
784 }
785
786 #[test]
787 fn select_with_options_validates_clean() {
788 let mut m = sample_module();
789 m.views.insert(
790 "sel_form".into(),
791 View::Form(FormView {
792 title: "Select OK".into(),
793 entity: "customer".into(),
794 fields: vec![FieldSpec {
795 name: "estado".into(),
796 label: "Estado".into(),
797 kind: FieldKind::Select,
798 default: None,
799 required: true,
800 help: None,
801 ref_entity: None,
802 options: vec![
803 SelectOption {
804 value: "activo".into(),
805 label: Some("Activo".into()),
806 },
807 SelectOption {
808 value: "baja".into(),
809 label: None,
810 },
811 ],
812 }],
813 on_submit: Action::SeedEntity {
814 entity: "customer".into(),
815 next_view: None,
816 },
817 }),
818 );
819 m.menu.push(MenuItem {
820 label: "Sel".into(),
821 view: "sel_form".into(),
822 icon: None,
823 });
824 m.validate().unwrap();
825 }
826
827 #[test]
828 fn select_option_display_falls_back_to_value() {
829 let with_label = SelectOption {
830 value: "x".into(),
831 label: Some("Equis".into()),
832 };
833 let bare = SelectOption {
834 value: "y".into(),
835 label: None,
836 };
837 assert_eq!(with_label.display(), "Equis");
838 assert_eq!(bare.display(), "y");
839 }
840
841 #[test]
842 fn load_modules_detects_duplicate_id() {
843 let tmp = tempfile::tempdir().unwrap();
844 let a_dir = tmp.path().join("a");
845 let b_dir = tmp.path().join("b");
846 std::fs::create_dir_all(&a_dir).unwrap();
847 std::fs::create_dir_all(&b_dir).unwrap();
848 let m = sample_module(); // id = "customers"
849 std::fs::write(a_dir.join("module.json"), serde_json::to_vec(&m).unwrap()).unwrap();
850 std::fs::write(b_dir.join("module.json"), serde_json::to_vec(&m).unwrap()).unwrap();
851 let err = load_modules_from_dir(tmp.path()).unwrap_err();
852 assert!(matches!(err, SchemaError::DuplicateModuleId { .. }));
853 }
854}