feat(nakui): metainterfaz declarativa + 6 modulos ERP estandar
Salto cualitativo: Nakui pasa de "library + demos + read-only viewer
del event log" a plataforma ERP con UI dirigida por datos. Cada
modulo de negocio se declara como un module.json (sin codigo Rust
nuevo) y el runtime GPUI lo carga dinamicamente: sidebar de menus,
listas con columnas configurables, formularios de alta.
3 entregables:
1. Crate nakui-ui-schema (datos puros): Module, View::List/Form,
FieldSpec con FieldKind {Text|Multiline|Number|Boolean|Date},
Action {OpenView|SeedEntity|Morphism}. Module::from_path,
Module::validate, load_modules_from_dir(dir). 6 tests unit + 4
integration.
2. Crate nakui-ui (binario GPUI): carga modulos desde
NAKUI_MODULES_DIR. Sidebar + main panel. List view con tabla
weighted; form view con campos labeled + submit que ejecuta
SeedEntity contra MemoryStore in-process compartido. Toast +
error banner. 6 tests unit.
3. 6 modulos demo en examples/nakui-modules/:
- customers (nombre, email, telefono, credito, notas)
- products (SKU, nombre, categoria, precio, stock)
- suppliers (razon social, ID fiscal, contacto, terminos pago)
- inventory_movements (fecha, tipo, SKU, cantidad, costo, motivo)
- sales_orders (numero, cliente, fechas, estado, totales)
- invoices (numero, cliente, fechas, totales, pagado, moneda)
Filosofia: UI como datos. Persistencia universal (MemoryStore hoy,
SurrealStore manana, sin tocar module.json). Schema primero, semantica
despues.
Activacion:
NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
Limitaciones conocidas (proximos iters):
- Inputs sin teclado (GPUI no lo trae nativo; integrar
yahweh-widget-text-input).
- Click handlers no propagan mutacion al estado (refactor con
cx.listener pendiente).
- Action::Morphism queda como TODO hasta cargar Manifest junto al
Module.
- Sin persistencia entre runs (wire con EventLog/SurrealStore para
cuando el daemon Nakui exista).
Tests: 16 totales nuevos. Lo que esto desbloquea: cualquiera puede
escribir un module.json para su dominio (pacientes, alumnos,
reservaciones) y aparece en la UI sin recompilar.
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "nakui-ui-schema"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Nakui UI metainterface schema: módulos declaran menús, listas y formularios como datos (JSON); el runtime los carga y renderiza sin código compilado por módulo."
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
@@ -0,0 +1,499 @@
|
||||
//! Schema declarativo de la metainterfaz Nakui.
|
||||
//!
|
||||
//! Cada **módulo** Nakui (customers, products, sales, ...) declara
|
||||
//! aquí qué menús, vistas, listas y formularios expone, sin escribir
|
||||
//! código GPUI ni Rust. El runtime ([`nakui-ui` crate]) lee estos
|
||||
//! `module.json` desde un directorio y monta automáticamente la UI
|
||||
//! correspondiente.
|
||||
//!
|
||||
//! ## Filosofía
|
||||
//!
|
||||
//! - **UI como datos**: agregar un módulo = escribir un JSON. Ningún
|
||||
//! recompile, ningún acoplamiento con el binario del runtime.
|
||||
//! - **Persistencia universal**: el runtime conecta cada vista al
|
||||
//! `nakui_core::store::Store` actual; los formularios escriben
|
||||
//! ops por la pipeline normal de Nakui (executor + event log).
|
||||
//! - **Schema primero, semántica después**: este crate sólo define
|
||||
//! la *forma* de los manifests. Validación semántica
|
||||
//! (referencias rotas a entities, campos faltantes, etc.) vive en
|
||||
//! el runtime que lo carga, no acá.
|
||||
//!
|
||||
//! ## Anatomía de un módulo
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "id": "customers",
|
||||
//! "label": "Clientes",
|
||||
//! "entities": [
|
||||
//! { "name": "customer", "fields": [ ... ] }
|
||||
//! ],
|
||||
//! "menu": [
|
||||
//! { "label": "Listar", "view": "list" },
|
||||
//! { "label": "Nuevo", "view": "form" }
|
||||
//! ],
|
||||
//! "views": {
|
||||
//! "list": { "kind": "list", "entity": "customer", "columns": [...] },
|
||||
//! "form": { "kind": "form", "entity": "customer", "fields": [...] }
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(rust_2018_idioms)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Manifiesto de un módulo de la metainterfaz Nakui.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Module {
|
||||
/// Identificador estable. Único dentro del directorio cargado.
|
||||
pub id: String,
|
||||
|
||||
/// Nombre legible para mostrar en el sidebar.
|
||||
pub label: String,
|
||||
|
||||
/// Descripción corta opcional (tooltip / subtítulo).
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Entities que el módulo introduce o consume. El runtime las
|
||||
/// usa para validar columns/fields y para inicializar el store
|
||||
/// cuando son nuevas.
|
||||
#[serde(default)]
|
||||
pub entities: Vec<EntitySpec>,
|
||||
|
||||
/// Items del menú. Cada uno apunta a una key de `views`. Orden
|
||||
/// importa (es el orden en que se presentan en el sidebar).
|
||||
pub menu: Vec<MenuItem>,
|
||||
|
||||
/// Vistas indexadas por key. Las keys son referenciadas por
|
||||
/// `MenuItem.view` y por `Action::OpenView.view`.
|
||||
pub views: BTreeMap<String, View>,
|
||||
}
|
||||
|
||||
/// Item del menú lateral.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MenuItem {
|
||||
pub label: String,
|
||||
/// Key de la vista a abrir. Debe existir en `Module.views`.
|
||||
pub view: String,
|
||||
/// Icono opcional (texto unicode o emoji; el runtime decide
|
||||
/// renderización).
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
/// Una vista renderizada en el área principal cuando su menú es seleccionado.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum View {
|
||||
/// Tabla de instancias de una entity, columnas + acciones por fila.
|
||||
List(ListView),
|
||||
/// Formulario de creación / edición.
|
||||
Form(FormView),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ListView {
|
||||
pub title: String,
|
||||
/// Entity (del nakui store) cuyas instancias se listan.
|
||||
pub entity: String,
|
||||
/// Columnas a mostrar. Orden importa.
|
||||
pub columns: Vec<Column>,
|
||||
/// Acciones disponibles a nivel de la lista (ej. "Nuevo" → form).
|
||||
/// Renderizadas como botones en el header.
|
||||
#[serde(default)]
|
||||
pub actions: Vec<Action>,
|
||||
/// Cuando está set, se muestra una caja de búsqueda que filtra
|
||||
/// las filas por substring contra los valores de estas columnas.
|
||||
#[serde(default)]
|
||||
pub search_in: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Column {
|
||||
/// Path del campo dentro del record. Para tipos planos: nombre.
|
||||
/// Para nested: puntos (`address.city`). El runtime navega.
|
||||
pub field: String,
|
||||
/// Texto del header.
|
||||
pub label: String,
|
||||
/// Ancho relativo (peso flex). Default 1.
|
||||
#[serde(default = "default_weight")]
|
||||
pub weight: f32,
|
||||
}
|
||||
|
||||
fn default_weight() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FormView {
|
||||
pub title: String,
|
||||
/// Entity destino del seed/morphism al submit.
|
||||
pub entity: String,
|
||||
pub fields: Vec<FieldSpec>,
|
||||
/// Acción al submit. Típicamente `Action::SeedEntity` para alta
|
||||
/// directa o `Action::Morphism` cuando hay validación/cálculo.
|
||||
pub on_submit: Action,
|
||||
}
|
||||
|
||||
/// Especificación de un campo de formulario o columna implícita.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FieldSpec {
|
||||
/// Nombre del campo en el record (clave del JSON).
|
||||
pub name: String,
|
||||
/// Etiqueta legible.
|
||||
pub label: String,
|
||||
/// Tipo del valor — define el widget de input + parseo.
|
||||
pub kind: FieldKind,
|
||||
/// Valor por defecto al abrir el form (string raw; el parseo
|
||||
/// según `kind` lo hace el runtime).
|
||||
#[serde(default)]
|
||||
pub default: Option<String>,
|
||||
/// Si `true`, el form rechaza submit con campo vacío.
|
||||
#[serde(default)]
|
||||
pub required: bool,
|
||||
/// Texto de ayuda mostrado bajo el input.
|
||||
#[serde(default)]
|
||||
pub help: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FieldKind {
|
||||
/// Texto libre.
|
||||
Text,
|
||||
/// Texto multilínea.
|
||||
Multiline,
|
||||
/// Número (i64 o f64; runtime intenta parsear como i64 primero).
|
||||
Number,
|
||||
/// Booleano (renderizado como checkbox).
|
||||
Boolean,
|
||||
/// Fecha (formato ISO YYYY-MM-DD; almacenada como string).
|
||||
Date,
|
||||
}
|
||||
|
||||
/// Acciones disparables por menús, botones o submit de formularios.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Action {
|
||||
/// Cambia la vista activa a otra del mismo módulo.
|
||||
OpenView {
|
||||
view: String,
|
||||
/// Etiqueta del botón / item; default = nombre humano de la vista.
|
||||
#[serde(default)]
|
||||
label: Option<String>,
|
||||
},
|
||||
/// Crea un seed directo en la entity con los valores del form.
|
||||
/// Equivalente a `nakui_core::event_log::seed_and_log`.
|
||||
/// Sin pipeline de morphism — alta administrativa.
|
||||
SeedEntity {
|
||||
entity: String,
|
||||
/// Tras el submit exitoso, opcionalmente abrir esta vista
|
||||
/// (por convención: `"list"` para volver al listado).
|
||||
#[serde(default)]
|
||||
next_view: Option<String>,
|
||||
},
|
||||
/// Ejecuta un morphism declarado en el manifest del módulo
|
||||
/// nakui-core. Inputs y params se mapean desde los campos del form.
|
||||
Morphism {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
next_view: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EntitySpec {
|
||||
/// Nombre de la entity (clave en el store). Único dentro del módulo.
|
||||
pub name: String,
|
||||
/// Label legible (singular).
|
||||
pub label: String,
|
||||
/// Campos esperados en records de esta entity. Usados por el
|
||||
/// runtime para inferir columnas / validar formularios.
|
||||
#[serde(default)]
|
||||
pub fields: Vec<FieldSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SchemaError {
|
||||
#[error("io leyendo {path}: {source}")]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("parseo de {path}: {source}")]
|
||||
Parse {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("módulo {id}: la vista '{view}' referenciada por menu_item '{item}' no existe")]
|
||||
DanglingMenuView {
|
||||
id: String,
|
||||
item: String,
|
||||
view: String,
|
||||
},
|
||||
#[error("módulos con id duplicado: '{id}' aparece en {first} y {second}")]
|
||||
DuplicateModuleId {
|
||||
id: String,
|
||||
first: PathBuf,
|
||||
second: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
impl Module {
|
||||
/// Carga un module.json desde disco. Validación estructural
|
||||
/// posterior (vistas referenciadas existen, etc.) la ejecuta el
|
||||
/// runtime — acá sólo parseamos.
|
||||
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SchemaError> {
|
||||
let path = path.as_ref();
|
||||
let bytes = std::fs::read(path).map_err(|source| SchemaError::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
serde_json::from_slice(&bytes).map_err(|source| SchemaError::Parse {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// Validación post-parse: cada `MenuItem.view` debe existir en
|
||||
/// `views`. Retorna el primer error encontrado.
|
||||
pub fn validate(&self) -> Result<(), SchemaError> {
|
||||
for item in &self.menu {
|
||||
if !self.views.contains_key(&item.view) {
|
||||
return Err(SchemaError::DanglingMenuView {
|
||||
id: self.id.clone(),
|
||||
item: item.label.clone(),
|
||||
view: item.view.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Carga todos los `module.json` encontrados bajo `dir` (recursivo
|
||||
/// 1 nivel — espera `dir/<modulo>/module.json`). Devuelve la lista
|
||||
/// ordenada por id, validada.
|
||||
///
|
||||
/// Falla si: I/O, parseo, módulo inválido, o ids duplicados.
|
||||
pub fn load_modules_from_dir(dir: impl AsRef<Path>) -> Result<Vec<Module>, SchemaError> {
|
||||
let dir = dir.as_ref();
|
||||
let mut modules: Vec<(PathBuf, Module)> = Vec::new();
|
||||
let entries = std::fs::read_dir(dir).map_err(|source| SchemaError::Io {
|
||||
path: dir.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if p.is_dir() {
|
||||
let manifest = p.join("module.json");
|
||||
if manifest.exists() {
|
||||
let m = Module::from_path(&manifest)?;
|
||||
m.validate()?;
|
||||
modules.push((manifest, m));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Detectar duplicados de id.
|
||||
modules.sort_by(|a, b| a.1.id.cmp(&b.1.id));
|
||||
let mut prev: Option<&(PathBuf, Module)> = None;
|
||||
for cur in &modules {
|
||||
if let Some(p) = prev {
|
||||
if p.1.id == cur.1.id {
|
||||
return Err(SchemaError::DuplicateModuleId {
|
||||
id: cur.1.id.clone(),
|
||||
first: p.0.clone(),
|
||||
second: cur.0.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
prev = Some(cur);
|
||||
}
|
||||
Ok(modules.into_iter().map(|(_, m)| m).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_module() -> Module {
|
||||
Module {
|
||||
id: "customers".into(),
|
||||
label: "Clientes".into(),
|
||||
description: Some("Gestión de clientes".into()),
|
||||
entities: vec![EntitySpec {
|
||||
name: "customer".into(),
|
||||
label: "Cliente".into(),
|
||||
fields: vec![
|
||||
FieldSpec {
|
||||
name: "name".into(),
|
||||
label: "Nombre".into(),
|
||||
kind: FieldKind::Text,
|
||||
default: None,
|
||||
required: true,
|
||||
help: None,
|
||||
},
|
||||
FieldSpec {
|
||||
name: "email".into(),
|
||||
label: "Email".into(),
|
||||
kind: FieldKind::Text,
|
||||
default: None,
|
||||
required: false,
|
||||
help: Some("Opcional".into()),
|
||||
},
|
||||
],
|
||||
}],
|
||||
menu: vec![
|
||||
MenuItem {
|
||||
label: "Listar".into(),
|
||||
view: "list".into(),
|
||||
icon: None,
|
||||
},
|
||||
MenuItem {
|
||||
label: "Nuevo".into(),
|
||||
view: "form".into(),
|
||||
icon: None,
|
||||
},
|
||||
],
|
||||
views: BTreeMap::from([
|
||||
(
|
||||
"list".into(),
|
||||
View::List(ListView {
|
||||
title: "Clientes".into(),
|
||||
entity: "customer".into(),
|
||||
columns: vec![
|
||||
Column {
|
||||
field: "name".into(),
|
||||
label: "Nombre".into(),
|
||||
weight: 2.0,
|
||||
},
|
||||
Column {
|
||||
field: "email".into(),
|
||||
label: "Email".into(),
|
||||
weight: 3.0,
|
||||
},
|
||||
],
|
||||
actions: vec![Action::OpenView {
|
||||
view: "form".into(),
|
||||
label: Some("Nuevo".into()),
|
||||
}],
|
||||
search_in: vec!["name".into(), "email".into()],
|
||||
}),
|
||||
),
|
||||
(
|
||||
"form".into(),
|
||||
View::Form(FormView {
|
||||
title: "Nuevo cliente".into(),
|
||||
entity: "customer".into(),
|
||||
fields: vec![FieldSpec {
|
||||
name: "name".into(),
|
||||
label: "Nombre".into(),
|
||||
kind: FieldKind::Text,
|
||||
default: None,
|
||||
required: true,
|
||||
help: None,
|
||||
}],
|
||||
on_submit: Action::SeedEntity {
|
||||
entity: "customer".into(),
|
||||
next_view: Some("list".into()),
|
||||
},
|
||||
}),
|
||||
),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn module_serialize_deserialize_roundtrip() {
|
||||
let m = sample_module();
|
||||
let s = serde_json::to_string_pretty(&m).unwrap();
|
||||
let m2: Module = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(m.id, m2.id);
|
||||
assert_eq!(m.menu.len(), m2.menu.len());
|
||||
assert_eq!(m.views.len(), m2.views.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_passes_for_well_formed_module() {
|
||||
sample_module().validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_catches_dangling_menu_view() {
|
||||
let mut m = sample_module();
|
||||
m.menu.push(MenuItem {
|
||||
label: "Roto".into(),
|
||||
view: "no_existe".into(),
|
||||
icon: None,
|
||||
});
|
||||
let err = m.validate().unwrap_err();
|
||||
assert!(matches!(err, SchemaError::DanglingMenuView { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_path_loads_real_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("module.json");
|
||||
std::fs::write(&path, serde_json::to_vec_pretty(&sample_module()).unwrap()).unwrap();
|
||||
let m = Module::from_path(&path).unwrap();
|
||||
assert_eq!(m.id, "customers");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_modules_from_dir_finds_subdirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cust_dir = tmp.path().join("customers");
|
||||
std::fs::create_dir(&cust_dir).unwrap();
|
||||
let mut m1 = sample_module();
|
||||
m1.id = "customers".into();
|
||||
std::fs::write(
|
||||
cust_dir.join("module.json"),
|
||||
serde_json::to_vec_pretty(&m1).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let prod_dir = tmp.path().join("products");
|
||||
std::fs::create_dir(&prod_dir).unwrap();
|
||||
let mut m2 = sample_module();
|
||||
m2.id = "products".into();
|
||||
std::fs::write(
|
||||
prod_dir.join("module.json"),
|
||||
serde_json::to_vec_pretty(&m2).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mods = load_modules_from_dir(tmp.path()).unwrap();
|
||||
assert_eq!(mods.len(), 2);
|
||||
assert_eq!(mods[0].id, "customers");
|
||||
assert_eq!(mods[1].id, "products");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_modules_detects_duplicate_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let a_dir = tmp.path().join("a");
|
||||
let b_dir = tmp.path().join("b");
|
||||
std::fs::create_dir_all(&a_dir).unwrap();
|
||||
std::fs::create_dir_all(&b_dir).unwrap();
|
||||
let m = sample_module(); // id = "customers"
|
||||
std::fs::write(
|
||||
a_dir.join("module.json"),
|
||||
serde_json::to_vec(&m).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
b_dir.join("module.json"),
|
||||
serde_json::to_vec(&m).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let err = load_modules_from_dir(tmp.path()).unwrap_err();
|
||||
assert!(matches!(err, SchemaError::DuplicateModuleId { .. }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Validación de los 6 módulos demo en `examples/nakui-modules/`.
|
||||
//!
|
||||
//! Si esto verde, garantizamos que un usuario que clone el repo y
|
||||
//! corra `NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui`
|
||||
//! va a obtener los 6 módulos cargados sin tocar nada.
|
||||
|
||||
use nakui_ui_schema::{load_modules_from_dir, FieldKind, View};
|
||||
|
||||
fn examples_dir() -> std::path::PathBuf {
|
||||
// Tests corren desde el dir del crate; el repo root está dos
|
||||
// niveles arriba: crates/modules/nakui/ui-schema → repo.
|
||||
let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
here.join("../../../..").join("examples/nakui-modules")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_all_six_demo_modules() {
|
||||
let dir = examples_dir();
|
||||
let mods = load_modules_from_dir(&dir).unwrap_or_else(|e| {
|
||||
panic!("load failed for {}: {e}", dir.display());
|
||||
});
|
||||
let ids: Vec<&str> = mods.iter().map(|m| m.id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![
|
||||
"customers",
|
||||
"inventory_movements",
|
||||
"invoices",
|
||||
"products",
|
||||
"sales_orders",
|
||||
"suppliers",
|
||||
],
|
||||
"expected 6 modules in alphabetical order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_demo_module_has_list_and_form_views() {
|
||||
let mods = load_modules_from_dir(examples_dir()).unwrap();
|
||||
for m in &mods {
|
||||
let mut has_list = false;
|
||||
let mut has_form = false;
|
||||
for v in m.views.values() {
|
||||
match v {
|
||||
View::List(_) => has_list = true,
|
||||
View::Form(_) => has_form = true,
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
has_list && has_form,
|
||||
"module {} should expose at least one list + one form view",
|
||||
m.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_demo_form_field_kind_is_recognized() {
|
||||
// Sanity: ningún módulo demo usa un kind que no esté en el enum
|
||||
// (sería rechazado al parsear, pero check explícito no daña).
|
||||
let mods = load_modules_from_dir(examples_dir()).unwrap();
|
||||
for m in &mods {
|
||||
for v in m.views.values() {
|
||||
if let View::Form(form) = v {
|
||||
for f in &form.fields {
|
||||
let _ok = matches!(
|
||||
f.kind,
|
||||
FieldKind::Text
|
||||
| FieldKind::Multiline
|
||||
| FieldKind::Number
|
||||
| FieldKind::Boolean
|
||||
| FieldKind::Date
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_module_validates_clean() {
|
||||
// validate() chequea que cada MenuItem.view exista en views.
|
||||
// Un typo en cualquiera de los 6 módulos haría fallar este test.
|
||||
let mods = load_modules_from_dir(examples_dir()).unwrap();
|
||||
for m in &mods {
|
||||
m.validate()
|
||||
.unwrap_or_else(|e| panic!("module {} failed validate: {e}", m.id));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user