⎇ eba629a806c7f861dd00a37c20b0f9e9d1ef3a52
crates/modules/nahual/libs/meta-schema/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(nakui): Fase 2 del ERP — relaciones legibles + formato
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}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct ListView {
126 pub title: String,
127 /// Entity (del nakui store) cuyas instancias se listan.
128 pub entity: String,
129 /// Columnas a mostrar. Orden importa.
130 pub columns: Vec<Column>,
131 /// Acciones disponibles a nivel de la lista (ej. "Nuevo" → form).
132 /// Renderizadas como botones en el header.
133 #[serde(default)]
134 pub actions: Vec<Action>,
135 /// Cuando está set, se muestra una caja de búsqueda que filtra
136 /// las filas por substring contra los valores de estas columnas.
137 #[serde(default)]
138 pub search_in: Vec<String>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct Column {
143 /// Path del campo dentro del record. Para tipos planos: nombre.
144 /// Para nested: puntos (`address.city`). El runtime navega.
145 pub field: String,
146 /// Texto del header.
147 pub label: String,
148 /// Ancho relativo (peso flex). Default 1.
149 #[serde(default = "default_weight")]
150 pub weight: f32,
151 /// Si está set, la celda resuelve su valor (un UUID) al label
152 /// legible del record de esta entity, en vez de mostrar el UUID
153 /// crudo. Para columnas que son referencias a otra entity.
154 #[serde(default)]
155 pub ref_entity: Option<String>,
156 /// Formato de presentación del valor de la celda.
157 #[serde(default)]
158 pub format: ValueFormat,
159}
160
161fn default_weight() -> f32 {
162 1.0
163}
164
165/// Formato de presentación de un valor en una celda de lista.
166#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(tag = "kind", rename_all = "snake_case")]
168pub enum ValueFormat {
169 /// Sin formato — el valor se muestra crudo. Default.
170 #[default]
171 Plain,
172 /// Entero/decimal con separador de miles (`12000` → `12,000`).
173 Number,
174 /// Moneda: separador de miles + símbolo prefijo (`12000` → `$12,000`).
175 Currency { symbol: String },
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct FormView {
180 pub title: String,
181 /// Entity destino del seed/morphism al submit.
182 pub entity: String,
183 pub fields: Vec<FieldSpec>,
184 /// Acción al submit. Típicamente `Action::SeedEntity` para alta
185 /// directa o `Action::Morphism` cuando hay validación/cálculo.
186 pub on_submit: Action,
187}
188
189/// Especificación de un campo de formulario o columna implícita.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct FieldSpec {
192 /// Nombre del campo en el record (clave del JSON).
193 pub name: String,
194 /// Etiqueta legible.
195 pub label: String,
196 /// Tipo del valor — define el widget de input + parseo.
197 pub kind: FieldKind,
198 /// Valor por defecto al abrir el form (string raw; el parseo
199 /// según `kind` lo hace el runtime).
200 #[serde(default)]
201 pub default: Option<String>,
202 /// Si `true`, el form rechaza submit con campo vacío.
203 #[serde(default)]
204 pub required: bool,
205 /// Texto de ayuda mostrado bajo el input.
206 #[serde(default)]
207 pub help: Option<String>,
208 /// Si `kind == EntityRef`, indica qué entity referencia. Sin
209 /// esto, el runtime no sabe qué records ofrecer en el selector
210 /// y la validación `Module::validate` rechaza el manifest.
211 /// Para los demás kinds, este campo se ignora.
212 #[serde(default)]
213 pub ref_entity: Option<String>,
214 /// Opciones de un campo `kind == Select`. Ignorado para los demás
215 /// kinds. `Module::validate` exige que un Select las tenga.
216 #[serde(default)]
217 pub options: Vec<SelectOption>,
218}
219
220#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
221#[serde(rename_all = "snake_case")]
222pub enum FieldKind {
223 /// Texto libre.
224 Text,
225 /// Texto multilínea.
226 Multiline,
227 /// Número (i64 o f64; runtime intenta parsear como i64 primero).
228 Number,
229 /// Booleano (renderizado como checkbox).
230 Boolean,
231 /// Fecha (formato ISO YYYY-MM-DD; almacenada como string).
232 Date,
233 /// Referencia a otro record. El runtime renderiza un selector
234 /// clickable de records existentes de la entity declarada en
235 /// `FieldSpec.ref_entity`; el value almacenado es el UUID del
236 /// seleccionado, parseable como cualquier text/UUID al submit.
237 EntityRef,
238 /// Valor elegido de un conjunto cerrado declarado en
239 /// `FieldSpec.options`. El runtime lo renderiza como selección
240 /// (no texto libre). `Module::validate` exige `options` no vacío.
241 Select,
242 /// Identificador autogenerado (UUID v4). El runtime lo rellena al
243 /// abrir el formulario; el usuario no lo teclea ni lo edita. Para
244 /// los ids de idempotencia que piden los morfismos.
245 AutoId,
246}
247
248/// Una opción de un campo [`FieldKind::Select`].
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct SelectOption {
251 /// Valor que se guarda (lo que recibe el backend o el morfismo).
252 pub value: String,
253 /// Etiqueta legible. Si se omite, se muestra el `value` crudo.
254 #[serde(default)]
255 pub label: Option<String>,
256}
257
258impl SelectOption {
259 /// Texto a mostrar: `label` si está, sino el `value`.
260 pub fn display(&self) -> &str {
261 self.label.as_deref().unwrap_or(&self.value)
262 }
263}
264
265/// Acciones disparables por menús, botones o submit de formularios.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267#[serde(tag = "kind", rename_all = "snake_case")]
268pub enum Action {
269 /// Cambia la vista activa a otra del mismo módulo.
270 OpenView {
271 view: String,
272 /// Etiqueta del botón / item; default = nombre humano de la vista.
273 #[serde(default)]
274 label: Option<String>,
275 },
276 /// Crea un seed directo en la entity con los valores del form.
277 /// Equivalente a `nakui_core::event_log::seed_and_log`.
278 /// Sin pipeline de morphism — alta administrativa.
279 SeedEntity {
280 entity: String,
281 /// Tras el submit exitoso, opcionalmente abrir esta vista
282 /// (por convención: `"list"` para volver al listado).
283 #[serde(default)]
284 next_view: Option<String>,
285 },
286 /// Ejecuta un morphism declarado en el manifest del módulo
287 /// nakui-core (cuyo path vive en `Module.nakui_module_dir`).
288 /// Inputs (records existentes) y params (valores escalares) se
289 /// mapean desde los campos del form.
290 Morphism {
291 /// Nombre del morphism declarado en `nsmc.json` del manifest
292 /// nakui apuntado por el módulo.
293 name: String,
294 /// Mapeo `role → field_name`: por cada input declarado en
295 /// el `MorphismSpec.inputs`, indica qué field del form
296 /// contiene el UUID del record. El runtime parsea el value
297 /// como `Uuid` y lo pasa como input al `execute_and_log`.
298 ///
299 /// Ej: `{ "stock": "stock_id", "caja": "caja_id" }` para un
300 /// morphism `vender` que toma roles `stock` y `caja`.
301 #[serde(default)]
302 inputs: BTreeMap<String, String>,
303 /// Lista de fields del form cuyos values van al `params`
304 /// JSON object pasado al morphism. Si está vacío, todos los
305 /// fields que no estén en `inputs` van a params.
306 #[serde(default)]
307 params: Vec<String>,
308 #[serde(default)]
309 next_view: Option<String>,
310 },
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct EntitySpec {
315 /// Nombre de la entity (clave en el store). Único dentro del módulo.
316 pub name: String,
317 /// Label legible (singular).
318 pub label: String,
319 /// Campos esperados en records de esta entity. Usados por el
320 /// runtime para inferir columnas / validar formularios.
321 #[serde(default)]
322 pub fields: Vec<FieldSpec>,
323}
324
325#[derive(Debug, Error)]
326pub enum SchemaError {
327 #[error("io leyendo {path}: {source}")]
328 Io {
329 path: PathBuf,
330 #[source]
331 source: std::io::Error,
332 },
333 #[error("parseo de {path}: {source}")]
334 Parse {
335 path: PathBuf,
336 #[source]
337 source: serde_json::Error,
338 },
339 #[error("módulo {id}: la vista '{view}' referenciada por menu_item '{item}' no existe")]
340 DanglingMenuView {
341 id: String,
342 item: String,
343 view: String,
344 },
345 #[error("módulos con id duplicado: '{id}' aparece en {first} y {second}")]
346 DuplicateModuleId {
347 id: String,
348 first: PathBuf,
349 second: PathBuf,
350 },
351 #[error(
352 "módulo {id} vista '{view}': field '{field}' tiene kind=entity_ref \
353 pero no declaró ref_entity"
354 )]
355 EntityRefMissingTarget {
356 id: String,
357 view: String,
358 field: String,
359 },
360 #[error(
361 "módulo {id} vista '{view}': field '{field}' tiene kind=select \
362 pero no declaró options"
363 )]
364 SelectMissingOptions {
365 id: String,
366 view: String,
367 field: String,
368 },
369}
370
371impl Module {
372 /// Carga un module.json desde disco. Validación estructural
373 /// posterior (vistas referenciadas existen, etc.) la ejecuta el
374 /// runtime — acá sólo parseamos.
375 pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SchemaError> {
376 let path = path.as_ref();
377 let bytes = std::fs::read(path).map_err(|source| SchemaError::Io {
378 path: path.to_path_buf(),
379 source,
380 })?;
381 serde_json::from_slice(&bytes).map_err(|source| SchemaError::Parse {
382 path: path.to_path_buf(),
383 source,
384 })
385 }
386
387 /// Validación post-parse:
388 /// - Cada `MenuItem.view` debe existir en `views`.
389 /// - Cada `FieldSpec` con `kind=EntityRef` debe declarar
390 /// `ref_entity`.
391 pub fn validate(&self) -> Result<(), SchemaError> {
392 for item in &self.menu {
393 if !self.views.contains_key(&item.view) {
394 return Err(SchemaError::DanglingMenuView {
395 id: self.id.clone(),
396 item: item.label.clone(),
397 view: item.view.clone(),
398 });
399 }
400 }
401 for (view_key, view) in &self.views {
402 if let View::Form(form) = view {
403 for f in &form.fields {
404 if f.kind == FieldKind::EntityRef && f.ref_entity.is_none() {
405 return Err(SchemaError::EntityRefMissingTarget {
406 id: self.id.clone(),
407 view: view_key.clone(),
408 field: f.name.clone(),
409 });
410 }
411 if f.kind == FieldKind::Select && f.options.is_empty() {
412 return Err(SchemaError::SelectMissingOptions {
413 id: self.id.clone(),
414 view: view_key.clone(),
415 field: f.name.clone(),
416 });
417 }
418 }
419 }
420 }
421 Ok(())
422 }
423}
424
425/// Carga todos los `module.json` encontrados bajo `dir` (recursivo
426/// 1 nivel — espera `dir/<modulo>/module.json`). Devuelve la lista
427/// ordenada por id, validada.
428///
429/// Falla si: I/O, parseo, módulo inválido, o ids duplicados.
430pub fn load_modules_from_dir(dir: impl AsRef<Path>) -> Result<Vec<Module>, SchemaError> {
431 let dir = dir.as_ref();
432 let mut modules: Vec<(PathBuf, Module)> = Vec::new();
433 let entries = std::fs::read_dir(dir).map_err(|source| SchemaError::Io {
434 path: dir.to_path_buf(),
435 source,
436 })?;
437 for entry in entries.flatten() {
438 let p = entry.path();
439 if p.is_dir() {
440 let manifest = p.join("module.json");
441 if manifest.exists() {
442 let m = Module::from_path(&manifest)?;
443 m.validate()?;
444 modules.push((manifest, m));
445 }
446 }
447 }
448 // Detectar duplicados de id.
449 modules.sort_by(|a, b| a.1.id.cmp(&b.1.id));
450 let mut prev: Option<&(PathBuf, Module)> = None;
451 for cur in &modules {
452 if let Some(p) = prev {
453 if p.1.id == cur.1.id {
454 return Err(SchemaError::DuplicateModuleId {
455 id: cur.1.id.clone(),
456 first: p.0.clone(),
457 second: cur.0.clone(),
458 });
459 }
460 }
461 prev = Some(cur);
462 }
463 Ok(modules.into_iter().map(|(_, m)| m).collect())
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 fn sample_module() -> Module {
471 Module {
472 id: "customers".into(),
473 label: "Clientes".into(),
474 description: Some("Gestión de clientes".into()),
475 nakui_module_dir: None,
476 entities: vec![EntitySpec {
477 name: "customer".into(),
478 label: "Cliente".into(),
479 fields: vec![
480 FieldSpec {
481 name: "name".into(),
482 label: "Nombre".into(),
483 kind: FieldKind::Text,
484 default: None,
485 required: true,
486 help: None,
487 ref_entity: None,
488 options: Vec::new(),
489 },
490 FieldSpec {
491 name: "email".into(),
492 label: "Email".into(),
493 kind: FieldKind::Text,
494 default: None,
495 required: false,
496 help: Some("Opcional".into()),
497 ref_entity: None,
498 options: Vec::new(),
499 },
500 ],
501 }],
502 menu: vec![
503 MenuItem {
504 label: "Listar".into(),
505 view: "list".into(),
506 icon: None,
507 },
508 MenuItem {
509 label: "Nuevo".into(),
510 view: "form".into(),
511 icon: None,
512 },
513 ],
514 views: BTreeMap::from([
515 (
516 "list".into(),
517 View::List(ListView {
518 title: "Clientes".into(),
519 entity: "customer".into(),
520 columns: vec![
521 Column {
522 field: "name".into(),
523 label: "Nombre".into(),
524 weight: 2.0,
525 ref_entity: None,
526 format: ValueFormat::Plain,
527 },
528 Column {
529 field: "email".into(),
530 label: "Email".into(),
531 weight: 3.0,
532 ref_entity: None,
533 format: ValueFormat::Plain,
534 },
535 ],
536 actions: vec![Action::OpenView {
537 view: "form".into(),
538 label: Some("Nuevo".into()),
539 }],
540 search_in: vec!["name".into(), "email".into()],
541 }),
542 ),
543 (
544 "form".into(),
545 View::Form(FormView {
546 title: "Nuevo cliente".into(),
547 entity: "customer".into(),
548 fields: vec![FieldSpec {
549 name: "name".into(),
550 label: "Nombre".into(),
551 kind: FieldKind::Text,
552 default: None,
553 required: true,
554 help: None,
555 ref_entity: None,
556 options: Vec::new(),
557 }],
558 on_submit: Action::SeedEntity {
559 entity: "customer".into(),
560 next_view: Some("list".into()),
561 },
562 }),
563 ),
564 ]),
565 }
566 }
567
568 #[test]
569 fn module_serialize_deserialize_roundtrip() {
570 let m = sample_module();
571 let s = serde_json::to_string_pretty(&m).unwrap();
572 let m2: Module = serde_json::from_str(&s).unwrap();
573 assert_eq!(m.id, m2.id);
574 assert_eq!(m.menu.len(), m2.menu.len());
575 assert_eq!(m.views.len(), m2.views.len());
576 }
577
578 #[test]
579 fn validate_passes_for_well_formed_module() {
580 sample_module().validate().unwrap();
581 }
582
583 #[test]
584 fn validate_catches_dangling_menu_view() {
585 let mut m = sample_module();
586 m.menu.push(MenuItem {
587 label: "Roto".into(),
588 view: "no_existe".into(),
589 icon: None,
590 });
591 let err = m.validate().unwrap_err();
592 assert!(matches!(err, SchemaError::DanglingMenuView { .. }));
593 }
594
595 #[test]
596 fn from_path_loads_real_file() {
597 let tmp = tempfile::tempdir().unwrap();
598 let path = tmp.path().join("module.json");
599 std::fs::write(&path, serde_json::to_vec_pretty(&sample_module()).unwrap()).unwrap();
600 let m = Module::from_path(&path).unwrap();
601 assert_eq!(m.id, "customers");
602 }
603
604 #[test]
605 fn load_modules_from_dir_finds_subdirs() {
606 let tmp = tempfile::tempdir().unwrap();
607 let cust_dir = tmp.path().join("customers");
608 std::fs::create_dir(&cust_dir).unwrap();
609 let mut m1 = sample_module();
610 m1.id = "customers".into();
611 std::fs::write(
612 cust_dir.join("module.json"),
613 serde_json::to_vec_pretty(&m1).unwrap(),
614 )
615 .unwrap();
616 let prod_dir = tmp.path().join("products");
617 std::fs::create_dir(&prod_dir).unwrap();
618 let mut m2 = sample_module();
619 m2.id = "products".into();
620 std::fs::write(
621 prod_dir.join("module.json"),
622 serde_json::to_vec_pretty(&m2).unwrap(),
623 )
624 .unwrap();
625
626 let mods = load_modules_from_dir(tmp.path()).unwrap();
627 assert_eq!(mods.len(), 2);
628 assert_eq!(mods[0].id, "customers");
629 assert_eq!(mods[1].id, "products");
630 }
631
632 #[test]
633 fn validate_catches_entity_ref_without_target() {
634 let mut m = sample_module();
635 // Inyectamos un form con un campo EntityRef sin ref_entity.
636 m.views.insert(
637 "broken_form".into(),
638 View::Form(FormView {
639 title: "Roto".into(),
640 entity: "customer".into(),
641 fields: vec![FieldSpec {
642 name: "ref_to_nowhere".into(),
643 label: "Referencia".into(),
644 kind: FieldKind::EntityRef,
645 default: None,
646 required: true,
647 help: None,
648 ref_entity: None,
649 options: Vec::new(),
650 }],
651 on_submit: Action::SeedEntity {
652 entity: "customer".into(),
653 next_view: None,
654 },
655 }),
656 );
657 let err = m.validate().unwrap_err();
658 assert!(
659 matches!(err, SchemaError::EntityRefMissingTarget { .. }),
660 "got: {err:?}"
661 );
662 }
663
664 #[test]
665 fn entity_ref_with_target_validates_clean() {
666 let mut m = sample_module();
667 m.views.insert(
668 "ok_form".into(),
669 View::Form(FormView {
670 title: "OK".into(),
671 entity: "customer".into(),
672 fields: vec![FieldSpec {
673 name: "supplier".into(),
674 label: "Proveedor".into(),
675 kind: FieldKind::EntityRef,
676 default: None,
677 required: true,
678 help: None,
679 ref_entity: Some("supplier".into()),
680 options: Vec::new(),
681 }],
682 on_submit: Action::SeedEntity {
683 entity: "customer".into(),
684 next_view: None,
685 },
686 }),
687 );
688 m.menu.push(MenuItem {
689 label: "OK".into(),
690 view: "ok_form".into(),
691 icon: None,
692 });
693 m.validate().unwrap();
694 }
695
696 #[test]
697 fn select_without_options_is_rejected() {
698 let mut m = sample_module();
699 m.views.insert(
700 "sel_form".into(),
701 View::Form(FormView {
702 title: "Select roto".into(),
703 entity: "customer".into(),
704 fields: vec![FieldSpec {
705 name: "estado".into(),
706 label: "Estado".into(),
707 kind: FieldKind::Select,
708 default: None,
709 required: true,
710 help: None,
711 ref_entity: None,
712 options: Vec::new(),
713 }],
714 on_submit: Action::SeedEntity {
715 entity: "customer".into(),
716 next_view: None,
717 },
718 }),
719 );
720 let err = m.validate().unwrap_err();
721 assert!(
722 matches!(err, SchemaError::SelectMissingOptions { .. }),
723 "got: {err:?}"
724 );
725 }
726
727 #[test]
728 fn select_with_options_validates_clean() {
729 let mut m = sample_module();
730 m.views.insert(
731 "sel_form".into(),
732 View::Form(FormView {
733 title: "Select OK".into(),
734 entity: "customer".into(),
735 fields: vec![FieldSpec {
736 name: "estado".into(),
737 label: "Estado".into(),
738 kind: FieldKind::Select,
739 default: None,
740 required: true,
741 help: None,
742 ref_entity: None,
743 options: vec![
744 SelectOption {
745 value: "activo".into(),
746 label: Some("Activo".into()),
747 },
748 SelectOption {
749 value: "baja".into(),
750 label: None,
751 },
752 ],
753 }],
754 on_submit: Action::SeedEntity {
755 entity: "customer".into(),
756 next_view: None,
757 },
758 }),
759 );
760 m.menu.push(MenuItem {
761 label: "Sel".into(),
762 view: "sel_form".into(),
763 icon: None,
764 });
765 m.validate().unwrap();
766 }
767
768 #[test]
769 fn select_option_display_falls_back_to_value() {
770 let with_label = SelectOption {
771 value: "x".into(),
772 label: Some("Equis".into()),
773 };
774 let bare = SelectOption {
775 value: "y".into(),
776 label: None,
777 };
778 assert_eq!(with_label.display(), "Equis");
779 assert_eq!(bare.display(), "y");
780 }
781
782 #[test]
783 fn load_modules_detects_duplicate_id() {
784 let tmp = tempfile::tempdir().unwrap();
785 let a_dir = tmp.path().join("a");
786 let b_dir = tmp.path().join("b");
787 std::fs::create_dir_all(&a_dir).unwrap();
788 std::fs::create_dir_all(&b_dir).unwrap();
789 let m = sample_module(); // id = "customers"
790 std::fs::write(a_dir.join("module.json"), serde_json::to_vec(&m).unwrap()).unwrap();
791 std::fs::write(b_dir.join("module.json"), serde_json::to_vec(&m).unwrap()).unwrap();
792 let err = load_modules_from_dir(tmp.path()).unwrap_err();
793 assert!(matches!(err, SchemaError::DuplicateModuleId { .. }));
794 }
795}