⎇ 86d06da020b21905887b8e689fe014b5c3cc6e8f
crates/modules/nahual/libs/meta-schema/src/lib.rs
Historia
Quién tocó qué
crudo
feat(nakui): Fase 1 del ERP — FieldKind Select + AutoId, seed inyecta id
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}