gioser

tawasuyu/brahman

Rust 570TOML 215Markdown 68JSON 198261 Definiciones

⎇ eba629a806c7f861dd00a37c20b0f9e9d1ef3a52 crates/modules/nahual/libs/meta-schema/src/lib.rs Rust · 795 líneas · 27.4 KB Historia Quién tocó qué crudo

feat(nakui): Fase 2 del ERP — relaciones legibles + formato eba629a80 · sergio ·

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}