feat(nakui-ui): migrar consumer al brazo brahman_cards::load_cards_from_dir
Primera consumer migration del brazo. nakui-ui ya no llama a nakui_ui_schema::load_modules_from_dir directamente; pasa por brahman_cards::load_cards_from_dir y extrae UiModule del CardBody. Beneficios concretos: - Soporta .ncl además de .json (templates Nickel + merge funcionan en cualquier subdir de modules). - Cards de otros body kinds (Ente/Monad) se skipean limpio con toast informativo, no rompen la carga. Cambios en brahman-cards: - Nuevo load_cards_from_dir(dir) + variante con readers/filenames custom. DEFAULT_CARD_FILENAMES = [card.ncl, card.json, module.ncl, module.json] (orden de prioridad). - 4 tests nuevos del helper. Cambios en nakui-ui: - Nueva dep brahman-cards. - Helper load_ui_modules(dir) -> (Vec<Module>, Vec<String>) envuelve el brazo, filtra a UiModule, aplica Module::validate(), detecta duplicate ids. - MetaUi::new usa el helper, emite toast con cards skipped si las hay. - 3 tests e2e nuevos. 26/26 brahman-cards verdes (+4). 48/48 nakui-ui verdes (+3). Workspace build verde. nakui_ui_schema::load_modules_from_dir queda intacto (sus tests lo usan + otros consumers futuros pueden preferirlo). Migración opt-in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,74 @@ ratio/diff ver `git show <sha>`.
|
|||||||
|
|
||||||
## 2026-05-09
|
## 2026-05-09
|
||||||
|
|
||||||
|
### feat(nakui-ui): migrar consumer al brazo unificado `brahman_cards::load_cards_from_dir`
|
||||||
|
Primera consumer migration del brazo. `nakui-ui` ya no llama a
|
||||||
|
`nakui_ui_schema::load_modules_from_dir` directamente — pasa por
|
||||||
|
`brahman_cards::load_cards_from_dir` y extrae el variant `UiModule`
|
||||||
|
del `CardBody` de cada Card. Beneficios concretos:
|
||||||
|
|
||||||
|
- **Soporta `.ncl` además de `.json`**: el usuario puede dropear un
|
||||||
|
`card.ncl` (con templates Nickel + merge) en cualquier subdir y
|
||||||
|
el runtime lo levanta automáticamente. El layout legacy
|
||||||
|
`examples/nakui-modules/<id>/module.json` sigue funcionando vía
|
||||||
|
los filenames default `[card.ncl, card.json, module.ncl, module.json]`.
|
||||||
|
- **Cards de otros body kinds (Ente/Monad) se skipean limpio**:
|
||||||
|
si el dir contiene Cards no-UiModule, se reportan en un toast
|
||||||
|
informativo en lugar de fallar la carga.
|
||||||
|
|
||||||
|
Cambios en `brahman-cards`:
|
||||||
|
- **Nuevo `load_cards_from_dir(dir)`** + variante con readers/filenames
|
||||||
|
custom. Walkea subdirs (orden lexicográfico), busca el primero de
|
||||||
|
`DEFAULT_CARD_FILENAMES`, dispatcha al reader. Subdirs sin ningún
|
||||||
|
filename matching se skipean silenciosamente (permite assets/fixtures
|
||||||
|
sueltos al lado de los cards). Errores per-file se propagan loud
|
||||||
|
(sin ocultar corrupción).
|
||||||
|
- **`pub const DEFAULT_CARD_FILENAMES`**: lista canónica probada en
|
||||||
|
orden. `card.ncl` tiene prioridad sobre `card.json` y sobre los
|
||||||
|
legacy `module.*`.
|
||||||
|
- **4 tests nuevos del helper**: walk + skip de subdirs sin
|
||||||
|
card, prioridad ncl > json, propagación loud de errores per-file,
|
||||||
|
custom filenames.
|
||||||
|
|
||||||
|
Cambios en `nakui-ui`:
|
||||||
|
- **Nueva dep** `brahman-cards` en `Cargo.toml`.
|
||||||
|
- **Nuevo helper `load_ui_modules(dir) -> (Vec<Module>, Vec<String>)`**
|
||||||
|
que envuelve `brahman_cards::load_cards_from_dir`, filtra a
|
||||||
|
UiModule body, valida cada Module con su `validate()`, ordena
|
||||||
|
por id, y detecta duplicados. El callsite en `MetaUi::new` pasa
|
||||||
|
a usarlo y al ver Cards skipped emite un toast informativo.
|
||||||
|
- **3 tests nuevos**:
|
||||||
|
- `load_ui_modules_via_brahman_cards_returns_ui_modules_and_skips_others`
|
||||||
|
— verifica que un dir con UiModule + Ente carga el primero y
|
||||||
|
reporta el segundo en `skipped`.
|
||||||
|
- `load_ui_modules_via_brahman_cards_rejects_invalid_module` —
|
||||||
|
`Module::validate()` se sigue aplicando (menu apuntando a view
|
||||||
|
inexistente rebota).
|
||||||
|
- `load_ui_modules_detects_duplicate_id` — dos UiModule con
|
||||||
|
mismo id rebotan con mensaje claro.
|
||||||
|
|
||||||
|
Tests totales:
|
||||||
|
- `brahman-cards`: 22 → 26 (+4 helper directorio).
|
||||||
|
- `nakui-ui`: 45 → 48 (+3 e2e migración).
|
||||||
|
- Workspace build verde.
|
||||||
|
|
||||||
|
Lo que NO cambió:
|
||||||
|
- `nakui_ui_schema::load_modules_from_dir` se mantiene intacto (sus
|
||||||
|
propios tests lo siguen usando, y otros consumers futuros podrían
|
||||||
|
preferir su error-typing más específico). La migración es opt-in:
|
||||||
|
`nakui-ui` usa el brazo, ui-schema sigue siendo una API válida.
|
||||||
|
- Layout actual de `examples/nakui-modules/<id>/module.json` no
|
||||||
|
requiere cambio. Un usuario puede convertir cualquier módulo a
|
||||||
|
`card.ncl` sin tocar el dir layout.
|
||||||
|
|
||||||
|
**Pendientes para próximos commits** (orden):
|
||||||
|
1. **Yahweh refactor**: lift del MetaUi runtime a
|
||||||
|
`crates/modules/ui_engine/` para reuso. El brazo + canónico ya
|
||||||
|
estables, ahora puede extraerse el meta-form widget genérico.
|
||||||
|
2. **KCL → Nickel**: kcl_wrapper reemplazado por Nickel contracts;
|
||||||
|
los 3 schemas .k de nakui modules pasan a .ncl.
|
||||||
|
3. **card.k eliminado** (es REFERENCE ONLY documentado).
|
||||||
|
|
||||||
### feat(brahman-cards): Nickel reader + templates con merge nativo (V2)
|
### feat(brahman-cards): Nickel reader + templates con merge nativo (V2)
|
||||||
Sigue al V1 (readers JSON). Ahora el brazo acepta inputs `.ncl`:
|
Sigue al V1 (readers JSON). Ahora el brazo acepta inputs `.ncl`:
|
||||||
los evalúa via `nickel-lang` 2.0, exporta a JSON, y dispatcha por
|
los evalúa via `nickel-lang` 2.0, exporta a JSON, y dispatcha por
|
||||||
|
|||||||
Generated
+1
@@ -6393,6 +6393,7 @@ dependencies = [
|
|||||||
name = "nakui-ui"
|
name = "nakui-ui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"brahman-cards",
|
||||||
"gpui",
|
"gpui",
|
||||||
"nakui-core",
|
"nakui-core",
|
||||||
"nakui-ui-schema",
|
"nakui-ui-schema",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ description = "Nakui — runtime GPUI de la metainterfaz: carga module.json desd
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
nakui-core = { path = "../../modules/nakui/core" }
|
nakui-core = { path = "../../modules/nakui/core" }
|
||||||
nakui-ui-schema = { path = "../../modules/nakui/ui-schema" }
|
nakui-ui-schema = { path = "../../modules/nakui/ui-schema" }
|
||||||
|
brahman-cards = { path = "../../core/brahman-cards" }
|
||||||
yahweh-widget-text-input = { path = "../../modules/ui_engine/widgets/text_input" }
|
yahweh-widget-text-input = { path = "../../modules/ui_engine/widgets/text_input" }
|
||||||
yahweh-theme = { path = "../../modules/ui_engine/libs/theme" }
|
yahweh-theme = { path = "../../modules/ui_engine/libs/theme" }
|
||||||
gpui = { workspace = true }
|
gpui = { workspace = true }
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ use nakui_core::delta::{FieldOp, FieldPath};
|
|||||||
use nakui_core::event_log::{
|
use nakui_core::event_log::{
|
||||||
execute_and_log_with_recovery, replay_with_snapshot_into, EventLog, LogEntry, Snapshot,
|
execute_and_log_with_recovery, replay_with_snapshot_into, EventLog, LogEntry, Snapshot,
|
||||||
};
|
};
|
||||||
|
use brahman_cards::CardBody;
|
||||||
use nakui_core::executor::Executor;
|
use nakui_core::executor::Executor;
|
||||||
use nakui_core::store::{MemoryStore, Store};
|
use nakui_core::store::{MemoryStore, Store};
|
||||||
use nakui_ui_schema::{
|
use nakui_ui_schema::{
|
||||||
@@ -125,17 +126,36 @@ impl MetaUi {
|
|||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|| PathBuf::from("nakui-modules"));
|
.unwrap_or_else(|| PathBuf::from("nakui-modules"));
|
||||||
|
|
||||||
let (modules, mut load_error) =
|
// Carga via el brazo unificado: brahman_cards::load_cards_from_dir
|
||||||
match nakui_ui_schema::load_modules_from_dir(&modules_dir) {
|
// walkea subdirs buscando card.ncl/card.json/module.ncl/module.json,
|
||||||
Ok(m) => (m, None),
|
// dispatcha al reader apropiado, devuelve Vec<Card>. Acá filtramos
|
||||||
Err(e) => (
|
// a los UiModule body variants y aplicamos las validaciones
|
||||||
Vec::new(),
|
// específicas de la UI (validate de cada Module + dedup de id).
|
||||||
|
// Cards de otros kinds (Ente, Monad) que aparezcan en el dir se
|
||||||
|
// skipean con un msg al banner — no son fatales pero el usuario
|
||||||
|
// sabe que estaban ahí.
|
||||||
|
let (modules, mut load_error) = match load_ui_modules(&modules_dir) {
|
||||||
|
Ok((mods, skipped)) => {
|
||||||
|
let toast = if skipped.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
Some(SharedString::from(format!(
|
Some(SharedString::from(format!(
|
||||||
"no pude cargar módulos de {}: {e}",
|
"skipeé {} card(s) no-UiModule en {}: {:?}",
|
||||||
modules_dir.display()
|
skipped.len(),
|
||||||
))),
|
modules_dir.display(),
|
||||||
),
|
skipped
|
||||||
};
|
)))
|
||||||
|
};
|
||||||
|
(mods, toast)
|
||||||
|
}
|
||||||
|
Err(e) => (
|
||||||
|
Vec::new(),
|
||||||
|
Some(SharedString::from(format!(
|
||||||
|
"no pude cargar módulos de {}: {e}",
|
||||||
|
modules_dir.display()
|
||||||
|
))),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
// Persistencia: abrir/crear el event log + opcionalmente un
|
// Persistencia: abrir/crear el event log + opcionalmente un
|
||||||
// snapshot sibling para acortar el replay. Path del log por
|
// snapshot sibling para acortar el replay. Path del log por
|
||||||
@@ -895,6 +915,50 @@ impl CommitOutcome {
|
|||||||
/// Concatena un mensaje de compact opcional al toast del op original.
|
/// Concatena un mensaje de compact opcional al toast del op original.
|
||||||
/// Devuelve el toast resultante listo para ir a `SharedString`.
|
/// Devuelve el toast resultante listo para ir a `SharedString`.
|
||||||
/// Sin `compact_msg` devuelve `base` tal cual.
|
/// Sin `compact_msg` devuelve `base` tal cual.
|
||||||
|
/// Carga UiModules desde un directorio via el brazo unificado
|
||||||
|
/// `brahman_cards::load_cards_from_dir`. Aplica las reglas
|
||||||
|
/// específicas de la UI:
|
||||||
|
/// - Sólo `CardBody::UiModule` cuenta; otros body kinds
|
||||||
|
/// (Ente, Monad, ...) se reportan en el `skipped` para que el
|
||||||
|
/// runtime los muestre como banner informativo.
|
||||||
|
/// - Cada `Module` se valida via `Module::validate()`.
|
||||||
|
/// - Detecta `id` duplicados entre módulos UiModule (el runtime
|
||||||
|
/// los direcciona por id; duplicados serían ambiguos).
|
||||||
|
///
|
||||||
|
/// Devuelve `(modules, skipped_ids)` ordenados por id.
|
||||||
|
fn load_ui_modules(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
) -> Result<(Vec<Module>, Vec<String>), String> {
|
||||||
|
let cards = brahman_cards::load_cards_from_dir(dir)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut modules: Vec<Module> = Vec::new();
|
||||||
|
let mut skipped: Vec<String> = Vec::new();
|
||||||
|
for c in cards {
|
||||||
|
match c.body {
|
||||||
|
CardBody::UiModule(m) => modules.push(m),
|
||||||
|
other => skipped.push(format!("{}({})", c.id, other.kind_name())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for m in &modules {
|
||||||
|
m.validate()
|
||||||
|
.map_err(|e| format!("módulo '{}' inválido: {e}", m.id))?;
|
||||||
|
}
|
||||||
|
modules.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
let mut prev: Option<&Module> = None;
|
||||||
|
for cur in &modules {
|
||||||
|
if let Some(p) = prev {
|
||||||
|
if p.id == cur.id {
|
||||||
|
return Err(format!(
|
||||||
|
"id de módulo duplicado: '{}' aparece más de una vez",
|
||||||
|
cur.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev = Some(cur);
|
||||||
|
}
|
||||||
|
Ok((modules, skipped))
|
||||||
|
}
|
||||||
|
|
||||||
fn append_compact_msg(base: String, compact_msg: Option<String>) -> SharedString {
|
fn append_compact_msg(base: String, compact_msg: Option<String>) -> SharedString {
|
||||||
match compact_msg {
|
match compact_msg {
|
||||||
Some(m) => SharedString::from(format!("{base}; {m}")),
|
Some(m) => SharedString::from(format!("{base}; {m}")),
|
||||||
@@ -2175,6 +2239,102 @@ mod tests {
|
|||||||
assert!(validate_entity_refs(&store, &refs).is_err());
|
assert!(validate_entity_refs(&store, &refs).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// E2E del nuevo `load_ui_modules` que pasa por
|
||||||
|
/// `brahman_cards::load_cards_from_dir`. Verifica:
|
||||||
|
/// 1. UiModules cargados ordenados por id.
|
||||||
|
/// 2. Validación per-module se aplica (un module.json con
|
||||||
|
/// menu apuntando a una view inexistente debería fallar).
|
||||||
|
/// 3. Cards de otros body kinds (Ente fixture) se reportan
|
||||||
|
/// en el `skipped` sin romper la carga.
|
||||||
|
#[test]
|
||||||
|
fn load_ui_modules_via_brahman_cards_returns_ui_modules_and_skips_others() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
// Subdir A: UiModule válido.
|
||||||
|
let a = root.path().join("alpha");
|
||||||
|
std::fs::create_dir(&a).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
a.join("module.json"),
|
||||||
|
serde_json::to_vec(&json!({
|
||||||
|
"id": "alpha",
|
||||||
|
"label": "Alpha",
|
||||||
|
"entities": [],
|
||||||
|
"menu": [],
|
||||||
|
"views": {}
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Subdir B: Ente card (no UiModule). Debe skipearse,
|
||||||
|
// no romper la carga.
|
||||||
|
let b = root.path().join("bravo");
|
||||||
|
std::fs::create_dir(&b).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
b.join("card.json"),
|
||||||
|
serde_json::to_vec(&json!({
|
||||||
|
"schema_version": 1,
|
||||||
|
"id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||||
|
"label": "ente-bravo",
|
||||||
|
"payload": "Virtual",
|
||||||
|
"supervision": "OneShot"
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (modules, skipped) =
|
||||||
|
load_ui_modules(root.path()).expect("load ok");
|
||||||
|
assert_eq!(modules.len(), 1);
|
||||||
|
assert_eq!(modules[0].id, "alpha");
|
||||||
|
assert_eq!(skipped.len(), 1);
|
||||||
|
assert!(skipped[0].contains("ente"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_ui_modules_via_brahman_cards_rejects_invalid_module() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let sub = root.path().join("broken");
|
||||||
|
std::fs::create_dir(&sub).unwrap();
|
||||||
|
// menu apunta a una view que no existe en `views`.
|
||||||
|
std::fs::write(
|
||||||
|
sub.join("module.json"),
|
||||||
|
serde_json::to_vec(&json!({
|
||||||
|
"id": "broken",
|
||||||
|
"label": "Broken",
|
||||||
|
"entities": [],
|
||||||
|
"menu": [{ "label": "Phantom", "view": "ghost" }],
|
||||||
|
"views": {}
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let err = load_ui_modules(root.path()).unwrap_err();
|
||||||
|
assert!(err.contains("broken"), "msg debe nombrar el módulo: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_ui_modules_detects_duplicate_id() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
for name in ["dir_a", "dir_b"] {
|
||||||
|
let sub = root.path().join(name);
|
||||||
|
std::fs::create_dir(&sub).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
sub.join("module.json"),
|
||||||
|
serde_json::to_vec(&json!({
|
||||||
|
"id": "dup",
|
||||||
|
"label": "Dup",
|
||||||
|
"entities": [], "menu": [], "views": {}
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let err = load_ui_modules(root.path()).unwrap_err();
|
||||||
|
assert!(err.contains("duplicado"), "msg debe decir duplicado: {err}");
|
||||||
|
assert!(err.contains("dup"), "msg debe nombrar el id: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clear_fields_skips_absent_and_null() {
|
fn clear_fields_skips_absent_and_null() {
|
||||||
let current = json!({
|
let current = json!({
|
||||||
|
|||||||
@@ -256,3 +256,61 @@ fn dispatch_to_reader(
|
|||||||
}
|
}
|
||||||
Err(CardLoadError::NoMatchingReader)
|
Err(CardLoadError::NoMatchingReader)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Filenames convencionales que [`load_cards_from_dir`] busca dentro
|
||||||
|
/// de cada subdir, en orden de preferencia. Si `card.ncl` existe se
|
||||||
|
/// usa ese; sino `card.json`; sino los aliases legacy `module.*`. Los
|
||||||
|
/// últimos dos son por compat con el layout actual de
|
||||||
|
/// `examples/nakui-modules/<id>/module.json`.
|
||||||
|
pub const DEFAULT_CARD_FILENAMES: &[&str] =
|
||||||
|
&["card.ncl", "card.json", "module.ncl", "module.json"];
|
||||||
|
|
||||||
|
/// Carga todas las Cards encontradas como subdirs inmediatos de
|
||||||
|
/// `dir`. Por cada subdir, busca los filenames convencionales (ver
|
||||||
|
/// [`DEFAULT_CARD_FILENAMES`]) y carga el primero que existe. Subdirs
|
||||||
|
/// sin ningún filename matching se skipean silenciosamente — permite
|
||||||
|
/// que un dir contenga subdirs auxiliares (assets, fixtures, etc.).
|
||||||
|
///
|
||||||
|
/// Devuelve las Cards en orden lexicográfico por subdir name (estable
|
||||||
|
/// across runs). NO ordena por `Card.id` — el caller decide si quiere
|
||||||
|
/// re-ordenar y/o dedupar.
|
||||||
|
///
|
||||||
|
/// Errores: cualquier I/O al leer el `dir` mismo, o cualquier
|
||||||
|
/// `CardLoadError` de un archivo encontrado (NO continúa tras el
|
||||||
|
/// primer fallo — fallo loud > corrupción silenciosa).
|
||||||
|
pub fn load_cards_from_dir(dir: impl AsRef<Path>) -> Result<Vec<Card>, CardLoadError> {
|
||||||
|
load_cards_from_dir_with(dir, DEFAULT_CARD_FILENAMES, &default_readers())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Variante de [`load_cards_from_dir`] con filenames y readers
|
||||||
|
/// custom. Útil para apps que quieren restringir formatos o usar un
|
||||||
|
/// nombre canónico distinto.
|
||||||
|
pub fn load_cards_from_dir_with(
|
||||||
|
dir: impl AsRef<Path>,
|
||||||
|
filenames: &[&str],
|
||||||
|
readers: &[Box<dyn CardReader>],
|
||||||
|
) -> Result<Vec<Card>, CardLoadError> {
|
||||||
|
let dir = dir.as_ref();
|
||||||
|
let mut subdir_paths: Vec<std::path::PathBuf> = std::fs::read_dir(dir)?
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|e| {
|
||||||
|
let p = e.path();
|
||||||
|
if p.is_dir() { Some(p) } else { None }
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Orden estable por subdir name — el output del brazo no debería
|
||||||
|
// depender del orden de read_dir (que varía por filesystem).
|
||||||
|
subdir_paths.sort();
|
||||||
|
|
||||||
|
let mut out: Vec<Card> = Vec::new();
|
||||||
|
for sub in subdir_paths {
|
||||||
|
for fname in filenames {
|
||||||
|
let candidate = sub.join(fname);
|
||||||
|
if candidate.exists() {
|
||||||
|
out.push(load_card_with(&candidate, readers)?);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|||||||
@@ -268,6 +268,142 @@ fn extensions_field_starts_empty_in_v1() {
|
|||||||
assert_eq!(card.extensions, BTreeMap::new());
|
assert_eq!(card.extensions, BTreeMap::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// load_cards_from_dir (subdir walking)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_cards_from_dir_walks_subdirs_and_finds_module_json() {
|
||||||
|
let root = unique_dir("dir-walk");
|
||||||
|
// Subdir A: tiene module.json (UiModule).
|
||||||
|
let a = root.join("alpha");
|
||||||
|
std::fs::create_dir(&a).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
a.join("module.json"),
|
||||||
|
serde_json::to_vec_pretty(&json!({
|
||||||
|
"id": "alpha",
|
||||||
|
"label": "Alpha",
|
||||||
|
"entities": [],
|
||||||
|
"menu": [],
|
||||||
|
"views": {}
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Subdir B: tiene module.json (UiModule).
|
||||||
|
let b = root.join("bravo");
|
||||||
|
std::fs::create_dir(&b).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
b.join("module.json"),
|
||||||
|
serde_json::to_vec_pretty(&json!({
|
||||||
|
"id": "bravo",
|
||||||
|
"label": "Bravo",
|
||||||
|
"entities": [],
|
||||||
|
"menu": [],
|
||||||
|
"views": {}
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Subdir C: NO tiene ninguno de los filenames convencionales —
|
||||||
|
// se debe skipear sin error.
|
||||||
|
let c = root.join("charlie");
|
||||||
|
std::fs::create_dir(&c).unwrap();
|
||||||
|
std::fs::write(c.join("readme.txt"), b"sin card aca").unwrap();
|
||||||
|
|
||||||
|
let cards = brahman_cards::load_cards_from_dir(&root).expect("ok");
|
||||||
|
let ids: Vec<&str> = cards.iter().map(|c| c.id.as_str()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
ids,
|
||||||
|
vec!["alpha", "bravo"],
|
||||||
|
"orden lexicográfico por subdir name"
|
||||||
|
);
|
||||||
|
for c in &cards {
|
||||||
|
assert_eq!(c.body.kind_name(), "ui_module");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_cards_from_dir_prefers_ncl_over_json_when_both_present() {
|
||||||
|
let root = unique_dir("dir-prefer");
|
||||||
|
let sub = root.join("only");
|
||||||
|
std::fs::create_dir(&sub).unwrap();
|
||||||
|
// Ambos archivos existen; el .ncl debería ganar.
|
||||||
|
std::fs::write(
|
||||||
|
sub.join("card.ncl"),
|
||||||
|
r#"{ id = "from_ncl", label = "Ncl", entities = [], menu = [], views = {} }"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
sub.join("card.json"),
|
||||||
|
serde_json::to_vec(&json!({
|
||||||
|
"id": "from_json",
|
||||||
|
"label": "Json",
|
||||||
|
"entities": [], "menu": [], "views": {}
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let cards = brahman_cards::load_cards_from_dir(&root).expect("ok");
|
||||||
|
assert_eq!(cards.len(), 1);
|
||||||
|
assert_eq!(cards[0].id, "from_ncl", "card.ncl tiene prioridad");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_cards_from_dir_propagates_per_file_errors_loud() {
|
||||||
|
let root = unique_dir("dir-error-loud");
|
||||||
|
let sub = root.join("broken");
|
||||||
|
std::fs::create_dir(&sub).unwrap();
|
||||||
|
std::fs::write(sub.join("card.json"), b"{ this is not valid json").unwrap();
|
||||||
|
|
||||||
|
let err = brahman_cards::load_cards_from_dir(&root).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, CardLoadError::JsonParse(_)),
|
||||||
|
"el error de un file roto debe propagar fail-loud, got {err:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_cards_from_dir_with_custom_filenames() {
|
||||||
|
let root = unique_dir("dir-custom-fname");
|
||||||
|
let sub = root.join("only");
|
||||||
|
std::fs::create_dir(&sub).unwrap();
|
||||||
|
// Filename custom que NO está en el default.
|
||||||
|
std::fs::write(
|
||||||
|
sub.join("manifest.json"),
|
||||||
|
serde_json::to_vec(&json!({
|
||||||
|
"id": "x",
|
||||||
|
"label": "X",
|
||||||
|
"entities": [], "menu": [], "views": {}
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Default no encuentra nada (skipea):
|
||||||
|
let with_default = brahman_cards::load_cards_from_dir(&root).unwrap();
|
||||||
|
assert_eq!(with_default.len(), 0, "default filenames no incluye manifest.json");
|
||||||
|
|
||||||
|
// Custom filename encuentra:
|
||||||
|
let with_custom = brahman_cards::load_cards_from_dir_with(
|
||||||
|
&root,
|
||||||
|
&["manifest.json"],
|
||||||
|
&brahman_cards::default_readers(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(with_custom.len(), 1);
|
||||||
|
assert_eq!(with_custom[0].id, "x");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&root).ok();
|
||||||
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// Helpers de tests
|
// Helpers de tests
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
@@ -281,3 +417,18 @@ fn tempfile_path(name: &str) -> std::path::PathBuf {
|
|||||||
));
|
));
|
||||||
p
|
p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn unique_dir(name: &str) -> std::path::PathBuf {
|
||||||
|
let mut p = std::env::temp_dir();
|
||||||
|
p.push(format!(
|
||||||
|
"brahman-cards-test-{}-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0),
|
||||||
|
name
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&p).unwrap();
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user