gioser

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Definiciones

⎇ 86d06da020b21905887b8e689fe014b5c3cc6e8f crates/modules/nahual/libs/meta-schema/src/lib.rs Rust · 770 líneas · 26.4 KB Historia Quién tocó qué crudo

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