550c98f275
Reorganización física de crates/: - core/ (mezclaba 6 propósitos) se divide en protocol/, init/, runtime/, compat/ - shared/ (3 crates) se redistribuye en protocol/ e init/ - lapaloma (sub-módulo de ui_engine) se promueve a modules/pineal/ Renames de proyectos: - shipote → shuma (runtime de sandboxes) - nouser → akasha (explorador de Mónadas) - yahweh → nahual (motor GPUI, antes ui_engine/) - lapaloma → pineal (data-viz agnóstica) Fraccionamiento UI → core agnóstico: - vista-core (DeckState + snap, 175 LOC, 5 tests verdes) - barra-core (Task + render_html + sanitize, 90 LOC, 5 tests verdes) - vista-web y barra-web ahora son thin DOM bindings Documentación nueva: - 16 SDDs por subdirectorio (≤80 LOC c/u): protocol/init/runtime/compat + 10 módulos + apps/ - docs/STATUS.md con cifras reales por proyecto - docs/ROADMAP.md con plan a finalización (6 hitos, ~6-8 semanas) - CHANGELOG.md particionado en docs/changelog/<proyecto>.md (7 buckets) Automatización: - scripts/reorg.py — script idempotente que: git mv directorios, renombra package names, recomputa path = refs, reescribe imports rust, actualiza workspace Cargo.toml. Soporta --dry-run. - scripts/split-changelog.py — particiona CHANGELOG por componente. Validación: - cargo check --workspace pasa (124 crates + 2 nuevos cores). - 10 tests adicionales (5 en vista-core + 5 en barra-core) verdes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
643 lines
22 KiB
Rust
643 lines
22 KiB
Rust
//! Schema declarativo de la metainterfaz (nahual meta-schema).
|
|
//!
|
|
//! Cada **módulo** declara aquí qué menús, vistas, listas y
|
|
//! formularios expone, sin escribir código GPUI ni Rust. Cualquier
|
|
//! runtime de UI dirigida por datos (Nakui hoy, otros mañana) lo
|
|
//! carga y monta la UI correspondiente.
|
|
//!
|
|
//! ## Filosofía
|
|
//!
|
|
//! - **UI como datos**: agregar un módulo = escribir un JSON o un
|
|
//! `.ncl`. Ningún recompile, ningún acoplamiento con el binario
|
|
//! del runtime.
|
|
//! - **Backend-agnostic**: este crate sólo describe la *forma* de
|
|
//! la UI. La conexión a un store/log/executor concretos vive en
|
|
//! el runtime que lo consume (ej: el meta-runtime de Nakui que
|
|
//! wirea esto a `nakui_core` + KCL post-checks).
|
|
//! - **Schema primero, semántica después**: 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 declarativo de UI.
|
|
#[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>,
|
|
|
|
/// Path opaco al backend que va a manejar este módulo. Lo
|
|
/// interpreta el runtime concreto (no este schema).
|
|
///
|
|
/// Convención actual de Nakui: directorio con `nsmc.json` +
|
|
/// schemas KCL + scripts Rhai. Cuando está set, el runtime
|
|
/// Nakui carga un `Executor` para ese path y permite que las
|
|
/// acciones `Morphism { name }` despachen al pipeline real
|
|
/// (compute → log → apply). Otro backend puede ignorar este
|
|
/// campo o darle un significado distinto.
|
|
///
|
|
/// Path resuelto relativo al directorio del `module.json`
|
|
/// o absoluto.
|
|
///
|
|
/// Si es `None`, los backends que requieren manifest deberían
|
|
/// degradar (toast informativo, deshabilitar morphisms, etc.);
|
|
/// los `SeedEntity` siguen funcionando — son altas
|
|
/// administrativas que no necesitan validación de manifest.
|
|
///
|
|
/// Nombre conservado por compat con módulos ya escritos.
|
|
/// Renombrar a `backend_module_dir` o similar si emerge un
|
|
/// segundo backend que también lo use.
|
|
#[serde(default, alias = "backend_module_dir")]
|
|
pub nakui_module_dir: Option<String>,
|
|
|
|
/// 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>,
|
|
/// Si `kind == EntityRef`, indica qué entity referencia. Sin
|
|
/// esto, el runtime no sabe qué records ofrecer en el selector
|
|
/// y la validación `Module::validate` rechaza el manifest.
|
|
/// Para los demás kinds, este campo se ignora.
|
|
#[serde(default)]
|
|
pub ref_entity: 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,
|
|
/// Referencia a otro record. El runtime renderiza un selector
|
|
/// clickable de records existentes de la entity declarada en
|
|
/// `FieldSpec.ref_entity`; el value almacenado es el UUID del
|
|
/// seleccionado, parseable como cualquier text/UUID al submit.
|
|
EntityRef,
|
|
}
|
|
|
|
/// 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 (cuyo path vive en `Module.nakui_module_dir`).
|
|
/// Inputs (records existentes) y params (valores escalares) se
|
|
/// mapean desde los campos del form.
|
|
Morphism {
|
|
/// Nombre del morphism declarado en `nsmc.json` del manifest
|
|
/// nakui apuntado por el módulo.
|
|
name: String,
|
|
/// Mapeo `role → field_name`: por cada input declarado en
|
|
/// el `MorphismSpec.inputs`, indica qué field del form
|
|
/// contiene el UUID del record. El runtime parsea el value
|
|
/// como `Uuid` y lo pasa como input al `execute_and_log`.
|
|
///
|
|
/// Ej: `{ "stock": "stock_id", "caja": "caja_id" }` para un
|
|
/// morphism `vender` que toma roles `stock` y `caja`.
|
|
#[serde(default)]
|
|
inputs: BTreeMap<String, String>,
|
|
/// Lista de fields del form cuyos values van al `params`
|
|
/// JSON object pasado al morphism. Si está vacío, todos los
|
|
/// fields que no estén en `inputs` van a params.
|
|
#[serde(default)]
|
|
params: Vec<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,
|
|
},
|
|
#[error(
|
|
"módulo {id} vista '{view}': field '{field}' tiene kind=entity_ref \
|
|
pero no declaró ref_entity"
|
|
)]
|
|
EntityRefMissingTarget {
|
|
id: String,
|
|
view: String,
|
|
field: String,
|
|
},
|
|
}
|
|
|
|
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`.
|
|
/// - Cada `FieldSpec` con `kind=EntityRef` debe declarar
|
|
/// `ref_entity`.
|
|
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(),
|
|
});
|
|
}
|
|
}
|
|
for (view_key, view) in &self.views {
|
|
if let View::Form(form) = view {
|
|
for f in &form.fields {
|
|
if f.kind == FieldKind::EntityRef && f.ref_entity.is_none() {
|
|
return Err(SchemaError::EntityRefMissingTarget {
|
|
id: self.id.clone(),
|
|
view: view_key.clone(),
|
|
field: f.name.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()),
|
|
nakui_module_dir: None,
|
|
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,
|
|
ref_entity: None,
|
|
},
|
|
FieldSpec {
|
|
name: "email".into(),
|
|
label: "Email".into(),
|
|
kind: FieldKind::Text,
|
|
default: None,
|
|
required: false,
|
|
help: Some("Opcional".into()),
|
|
ref_entity: None,
|
|
},
|
|
],
|
|
}],
|
|
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,
|
|
ref_entity: 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 validate_catches_entity_ref_without_target() {
|
|
let mut m = sample_module();
|
|
// Inyectamos un form con un campo EntityRef sin ref_entity.
|
|
m.views.insert(
|
|
"broken_form".into(),
|
|
View::Form(FormView {
|
|
title: "Roto".into(),
|
|
entity: "customer".into(),
|
|
fields: vec![FieldSpec {
|
|
name: "ref_to_nowhere".into(),
|
|
label: "Referencia".into(),
|
|
kind: FieldKind::EntityRef,
|
|
default: None,
|
|
required: true,
|
|
help: None,
|
|
ref_entity: None,
|
|
}],
|
|
on_submit: Action::SeedEntity {
|
|
entity: "customer".into(),
|
|
next_view: None,
|
|
},
|
|
}),
|
|
);
|
|
let err = m.validate().unwrap_err();
|
|
assert!(
|
|
matches!(err, SchemaError::EntityRefMissingTarget { .. }),
|
|
"got: {err:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn entity_ref_with_target_validates_clean() {
|
|
let mut m = sample_module();
|
|
m.views.insert(
|
|
"ok_form".into(),
|
|
View::Form(FormView {
|
|
title: "OK".into(),
|
|
entity: "customer".into(),
|
|
fields: vec![FieldSpec {
|
|
name: "supplier".into(),
|
|
label: "Proveedor".into(),
|
|
kind: FieldKind::EntityRef,
|
|
default: None,
|
|
required: true,
|
|
help: None,
|
|
ref_entity: Some("supplier".into()),
|
|
}],
|
|
on_submit: Action::SeedEntity {
|
|
entity: "customer".into(),
|
|
next_view: None,
|
|
},
|
|
}),
|
|
);
|
|
m.menu.push(MenuItem {
|
|
label: "OK".into(),
|
|
view: "ok_form".into(),
|
|
icon: None,
|
|
});
|
|
m.validate().unwrap();
|
|
}
|
|
|
|
#[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 { .. }));
|
|
}
|
|
}
|