refactor(monorepo): reorganización lógica + renames + SDDs + split CHANGELOG

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>
This commit is contained in:
sergio
2026-05-19 14:48:34 +00:00
parent 86fb6ae20b
commit 550c98f275
375 changed files with 8512 additions and 7155 deletions
@@ -0,0 +1,434 @@
//! Integration tests del brazo brahman-cards.
//!
//! Cubre:
//! 1. Cada reader matchea sólo el shape correcto.
//! 2. El dispatcher (`load_card`/`dispatch`) elige el reader
//! correcto sin ambigüedad.
//! 3. Round-trip: cada formato JSON cargado produce el variant
//! esperado del Card canónico con los campos del wrapper bien
//! derivados.
//! 4. Rechazo gracioso de inputs no-matched + extensiones no
//! soportadas.
use std::collections::BTreeMap;
use brahman_cards::{
default_readers, load_card_with, Card, CardBody, CardLoadError, CardReader, EnteJsonReader,
MonadJsonReader, UiModuleJsonReader,
};
use serde_json::{json, Value};
/// Helper: dispatch in-process desde un Value, sin tocar disco.
/// Reproduce la lógica interna del dispatcher para no exigir I/O en
/// los tests.
fn dispatch(input: Value, readers: &[Box<dyn CardReader>]) -> Result<Card, CardLoadError> {
for r in readers {
if r.can_read(&input) {
return r.read(input);
}
}
Err(CardLoadError::NoMatchingReader)
}
// ===========================================================================
// Reader detection (can_read)
// ===========================================================================
#[test]
fn ui_module_reader_detects_only_ui_module_shape() {
let r = UiModuleJsonReader;
let ui = json!({"id": "x", "label": "X", "menu": [], "views": {}, "entities": []});
let ente = json!({"id": "x", "label": "X", "payload": "Virtual", "supervision": "OneShot"});
let monad = json!({"id": "x", "label": "X", "members": [], "cardinality": 0});
assert!(r.can_read(&ui), "UiModule reader debe matchear ui shape");
assert!(!r.can_read(&ente), "no debe matchear Ente");
assert!(!r.can_read(&monad), "no debe matchear Monad");
assert!(!r.can_read(&Value::Null), "no debe matchear non-object");
}
#[test]
fn ente_reader_detects_only_ente_shape() {
let r = EnteJsonReader;
let ente = json!({"payload": "Virtual", "supervision": "OneShot"});
let monad = json!({"members": [], "cardinality": 0});
let ui = json!({"menu": [], "views": {}, "entities": []});
assert!(r.can_read(&ente));
assert!(!r.can_read(&monad));
assert!(!r.can_read(&ui));
}
#[test]
fn monad_reader_detects_only_monad_shape() {
let r = MonadJsonReader;
let monad = json!({"members": [], "cardinality": 0});
let ente = json!({"payload": "Virtual", "supervision": "OneShot"});
let ui = json!({"menu": [], "views": {}, "entities": []});
assert!(r.can_read(&monad));
assert!(!r.can_read(&ente));
assert!(!r.can_read(&ui));
}
// ===========================================================================
// Dispatch + variant projection
// ===========================================================================
#[test]
fn loads_ui_module_to_card_ui_module_variant() {
let input = json!({
"id": "sales_engine",
"label": "Ventas",
"description": "Demo",
"entities": [],
"menu": [{"label": "Stock", "view": "stock_list"}],
"views": {
"stock_list": {
"kind": "list",
"title": "Stock",
"entity": "Stock",
"columns": []
}
}
});
let card = dispatch(input, &default_readers()).expect("dispatch ok");
assert_eq!(card.id, "sales_engine");
assert_eq!(card.label, "Ventas");
assert!(card.lineage.is_none(), "UiModule sin lineage");
assert_eq!(card.body.kind_name(), "ui_module");
match card.body {
CardBody::UiModule(m) => {
assert_eq!(m.id, "sales_engine");
assert_eq!(m.menu.len(), 1);
}
other => panic!("variant inesperado: {:?}", other.kind_name()),
}
}
#[test]
fn loads_ente_to_card_ente_variant() {
// Ulid mínimo: 26 chars Crockford. Usamos uno conocido.
let ulid = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let input = json!({
"schema_version": 1,
"id": ulid,
"label": "test-ente",
"payload": "Virtual",
"supervision": "OneShot"
});
let card = dispatch(input, &default_readers()).expect("dispatch ok");
assert_eq!(card.id, ulid);
assert_eq!(card.label, "test-ente");
assert_eq!(card.body.kind_name(), "ente");
match card.body {
CardBody::Ente(e) => {
assert_eq!(e.label, "test-ente");
assert_eq!(e.id.to_string(), ulid);
}
other => panic!("variant inesperado: {:?}", other.kind_name()),
}
}
#[test]
fn loads_monad_to_card_monad_variant() {
let ulid = "01ARZ3NDEKTSV4RRFFQ69G5FB1";
let input = json!({
"schema_version": 1,
"id": ulid,
"label": "test-monad",
"members": [],
"cardinality": 0,
"created_at_ms": 0,
"updated_at_ms": 0
});
let card = dispatch(input, &default_readers()).expect("dispatch ok");
assert_eq!(card.id, ulid);
assert_eq!(card.label, "test-monad");
assert_eq!(card.body.kind_name(), "monad");
match card.body {
CardBody::Monad(m) => {
assert_eq!(m.label, "test-monad");
assert_eq!(m.cardinality, 0);
}
other => panic!("variant inesperado: {:?}", other.kind_name()),
}
}
// ===========================================================================
// Negative cases
// ===========================================================================
#[test]
fn rejects_input_no_matching_reader() {
let input = json!({"random": "shape", "without": "fingerprint"});
let err = dispatch(input, &default_readers()).unwrap_err();
assert!(
matches!(err, CardLoadError::NoMatchingReader),
"expected NoMatchingReader, got {err:?}"
);
}
#[test]
fn rejects_non_object_input() {
let input = json!([1, 2, 3]);
let err = dispatch(input, &default_readers()).unwrap_err();
assert!(matches!(err, CardLoadError::NoMatchingReader));
}
#[test]
fn ui_module_takes_priority_when_shape_overlaps_partial() {
// Sanity del orden: si alguien armara un input híbrido con
// `menu`+`views`+`entities` Y también `payload`+`supervision`,
// el UiModuleReader (primero en orden) debería ganar. Esto no
// debería ocurrir con inputs reales pero defendemos el contrato
// de orden documentado.
let input = json!({
"id": "weird",
"label": "Weird",
"menu": [],
"views": {},
"entities": [],
"payload": "Virtual",
"supervision": "OneShot"
});
let card = dispatch(input, &default_readers()).expect("dispatch ok");
assert_eq!(
card.body.kind_name(),
"ui_module",
"el UiModuleReader debería ganar por orden"
);
}
// ===========================================================================
// load_card desde disco (e2e fino)
// ===========================================================================
#[test]
fn load_card_from_disk_round_trip_ui_module() {
let tmp = tempfile_path("ui_module.json");
let input = json!({
"id": "demo",
"label": "Demo",
"entities": [],
"menu": [],
"views": {}
});
std::fs::write(&tmp, serde_json::to_vec_pretty(&input).unwrap()).unwrap();
let card = load_card_with(&tmp, &default_readers()).expect("load ok");
assert_eq!(card.body.kind_name(), "ui_module");
assert_eq!(card.id, "demo");
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn load_card_rejects_unsupported_extension() {
let tmp = tempfile_path("foo.toml");
std::fs::write(&tmp, b"[anything]\nx = 1").unwrap();
let err = load_card_with(&tmp, &default_readers()).unwrap_err();
match err {
CardLoadError::UnsupportedExtension { ext, supported } => {
assert_eq!(ext, "toml");
assert!(supported.contains(&"json"));
}
other => panic!("expected UnsupportedExtension, got {other:?}"),
}
let _ = std::fs::remove_file(&tmp);
}
// ===========================================================================
// Custom reader sets
// ===========================================================================
#[test]
fn custom_reader_set_can_restrict_supported_formats() {
// Sólo Ente: un input Monad debería rechazarse.
let only_ente: Vec<Box<dyn CardReader>> = vec![Box::new(EnteJsonReader)];
let monad_input = json!({"members": [], "cardinality": 0});
let err = dispatch(monad_input, &only_ente).unwrap_err();
assert!(matches!(err, CardLoadError::NoMatchingReader));
}
// ===========================================================================
// Wrapper field invariants
// ===========================================================================
#[test]
fn extensions_field_starts_empty_in_v1() {
// Documented: V1 no mueve el "extras" del crate origen al
// wrapper.extensions. Si esto cambia, este test se rompe como
// signal para actualizar el doc de readers.rs.
let input = json!({
"id": "demo",
"label": "Demo",
"entities": [],
"menu": [],
"views": {}
});
let card = dispatch(input, &default_readers()).unwrap();
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
// ===========================================================================
fn tempfile_path(name: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"brahman-cards-test-{}-{}",
std::process::id(),
name
));
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
}
@@ -0,0 +1,371 @@
//! Nickel reader + templates.
//!
//! V2 del brazo: la dispatcher acepta archivos `.ncl`. La evaluación
//! produce JSON intermedio que va a los readers estándar, así que un
//! `.ncl` puede generar cualquier `CardBody` siempre que su shape sea
//! reconocida.
//!
//! Templates: Nickel `import` + `&` merge nativos. El brazo no
//! inventa nada — sólo agrega el parent dir + el env
//! `BRAHMAN_CARDS_TEMPLATES_DIR` al import path.
use std::fs;
use std::path::PathBuf;
use brahman_cards::{
eval_nickel_file, load_card, CardBody, CardLoadError, NickelEvalError,
BRAHMAN_CARDS_TEMPLATES_ENV,
};
use serde_json::json;
// ===========================================================================
// Helpers
// ===========================================================================
fn unique_dir(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"brahman-cards-nickel-{}-{}-{}",
std::process::id(),
nanos(),
name
));
fs::create_dir_all(&p).unwrap();
p
}
fn nanos() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
}
fn write_file(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
let p = dir.join(name);
fs::write(&p, content).unwrap();
p
}
// ===========================================================================
// 1. Evaluación directa: Nickel → Value
// ===========================================================================
#[test]
fn eval_nickel_file_returns_value_for_valid_input() {
let dir = unique_dir("eval-basic");
let p = write_file(
&dir,
"card.ncl",
r#"
{
id = "demo",
label = "Demo",
entities = [],
menu = [],
views = {},
}
"#,
);
let v = eval_nickel_file(&p).expect("eval ok");
assert_eq!(v.get("id"), Some(&json!("demo")));
assert_eq!(v.get("label"), Some(&json!("Demo")));
assert!(v.get("entities").is_some());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn eval_nickel_file_surfaces_evaluation_error() {
let dir = unique_dir("eval-err");
let p = write_file(
&dir,
"broken.ncl",
r#"
{
id = "x",
label = doesnotexist,
}
"#,
);
let err = eval_nickel_file(&p).unwrap_err();
match err {
NickelEvalError::Eval { path, message } => {
assert!(path.contains("broken.ncl"));
assert!(!message.is_empty(), "el msg debe traer info de Nickel");
}
other => panic!("expected Eval error, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
// ===========================================================================
// 2. load_card pipeline: .ncl → Card
// ===========================================================================
#[test]
fn load_card_dispatches_ncl_to_ui_module_variant() {
let dir = unique_dir("dispatch-ui");
let p = write_file(
&dir,
"module.ncl",
r#"
{
id = "demo",
label = "Demo",
entities = [],
menu = [{ label = "Stock", view = "stock_list" }],
views = {
stock_list = {
kind = "list",
title = "Stock",
entity = "Stock",
columns = [],
},
},
}
"#,
);
let card = load_card(&p).expect("load ok");
assert_eq!(card.body.kind_name(), "ui_module");
assert_eq!(card.id, "demo");
assert_eq!(card.label, "Demo");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn load_card_dispatches_ncl_to_ente_variant() {
let dir = unique_dir("dispatch-ente");
let p = write_file(
&dir,
"ente.ncl",
r#"
{
schema_version = 1,
id = "01ARZ3NDEKTSV4RRFFQ69G5FAV",
label = "test-ente",
payload = "Virtual",
supervision = "OneShot",
}
"#,
);
let card = load_card(&p).expect("load ok");
assert_eq!(card.body.kind_name(), "ente");
assert_eq!(card.id, "01ARZ3NDEKTSV4RRFFQ69G5FAV");
fs::remove_dir_all(&dir).ok();
}
// ===========================================================================
// 3. Templates: import + merge native de Nickel
// ===========================================================================
/// El caso de uso que el usuario describió: "un Card simple usa un
/// Card ya hecho cambiando sólo nombre y id". Template define la
/// shape full; el archivo concreto importa + override.
#[test]
fn template_merge_overrides_id_and_label_only() {
let dir = unique_dir("template-merge");
// Template con la shape full de un UiModule. Los campos
// sobrescribibles se marcan `| default` — Nickel sólo permite
// override en merge cuando hay diferencia de prioridad. Sin
// `| default` los strings no-iguales fallan con "non mergeable".
write_file(
&dir,
"ui_module_basic.ncl",
r#"
{
id | String | default = "TEMPLATE_ID",
label | String | default = "TEMPLATE_LABEL",
description = "stock + form básico",
entities = [
{ name = "Item", label = "Item", fields = [] },
],
menu = [
{ label = "Items", view = "items_list" },
{ label = "+ Item", view = "items_form" },
],
views = {
items_list = {
kind = "list",
title = "Items",
entity = "Item",
columns = [],
},
items_form = {
kind = "form",
title = "Nuevo item",
entity = "Item",
fields = [],
on_submit = {
kind = "seed_entity",
entity = "Item",
next_view = "items_list",
},
},
},
}
"#,
);
// Card concreto: import + merge override.
let p = write_file(
&dir,
"my_module.ncl",
r#"
let base = import "ui_module_basic.ncl" in
base & {
id = "my_module",
label = "Mi Módulo",
}
"#,
);
let card = load_card(&p).expect("template merge ok");
assert_eq!(card.id, "my_module", "el override del id se aplicó");
assert_eq!(card.label, "Mi Módulo", "el override del label se aplicó");
assert_eq!(card.body.kind_name(), "ui_module");
match card.body {
CardBody::UiModule(m) => {
// El resto viene del template intacto.
assert_eq!(m.menu.len(), 2);
assert_eq!(m.entities.len(), 1);
assert_eq!(m.entities[0].name, "Item");
}
other => panic!("variant inesperado: {:?}", other.kind_name()),
}
fs::remove_dir_all(&dir).ok();
}
/// El env `BRAHMAN_CARDS_TEMPLATES_DIR` permite tener un registry
/// global: el usuario importa por nombre desnudo desde cualquier
/// ubicación.
///
/// Este test setea/unset el env de forma local (no thread-safe en
/// tests paralelos contra el mismo env, pero usamos una key dedicada
/// y borramos después). Si se vuelve flaky, agregar mutex.
#[test]
fn template_resolves_via_env_registry() {
let registry = unique_dir("template-registry");
let inputs = unique_dir("template-input");
write_file(
&registry,
"ui_module_minimal.ncl",
r#"
{
id | String | default = "X",
label | String | default = "X",
entities = [],
menu = [],
views = {},
}
"#,
);
let p = write_file(
&inputs,
"from_registry.ncl",
r#"
let base = import "ui_module_minimal.ncl" in
base & { id = "registry_user", label = "Usado del Registry" }
"#,
);
// Set env, evaluar, restaurar.
let prev = std::env::var(BRAHMAN_CARDS_TEMPLATES_ENV).ok();
// SAFETY: nickel-lang tests modifican un env ad-hoc que no es
// referenciado por nada externo y se restaura al salir. Ningún
// otro test del crate lee este env.
unsafe {
std::env::set_var(BRAHMAN_CARDS_TEMPLATES_ENV, &registry);
}
let result = load_card(&p);
unsafe {
if let Some(v) = prev {
std::env::set_var(BRAHMAN_CARDS_TEMPLATES_ENV, v);
} else {
std::env::remove_var(BRAHMAN_CARDS_TEMPLATES_ENV);
}
}
let card = result.expect("template via registry ok");
assert_eq!(card.id, "registry_user");
assert_eq!(card.body.kind_name(), "ui_module");
fs::remove_dir_all(&registry).ok();
fs::remove_dir_all(&inputs).ok();
}
// ===========================================================================
// 4. Errores propagan limpios al CardLoadError
// ===========================================================================
#[test]
fn load_card_wraps_nickel_error_in_card_load_error() {
let dir = unique_dir("wrap-err");
let p = write_file(&dir, "bad.ncl", "let x = unknown in x");
let err = load_card(&p).unwrap_err();
match err {
CardLoadError::Nickel(NickelEvalError::Eval { .. }) => {} // expected
other => panic!("expected Nickel(Eval), got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
/// El value-add concreto de Nickel sobre JSON: un contract
/// violation se captura en evaluación, ANTES de que el reader
/// JSON tenga oportunidad de aceptar un shape mal-tipado. Acá un
/// `id | String` con un value que no es String falla en eval-time
/// con un mensaje legible. JSON puro lo aceptaría y rompería más
/// tarde aguas abajo.
#[test]
fn nickel_contract_violation_caught_at_eval_time() {
let dir = unique_dir("contract-violation");
let p = write_file(
&dir,
"bad_id.ncl",
r#"
{
id | String = 42,
label = "X",
entities = [],
menu = [],
views = {},
}
"#,
);
let err = load_card(&p).unwrap_err();
match err {
CardLoadError::Nickel(NickelEvalError::Eval { message, .. }) => {
// Mensaje de contract violation legible.
assert!(
message.contains("contract") || message.contains("String"),
"msg debe mencionar contract o String: {message}"
);
}
other => panic!("expected Nickel(Eval), got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
/// Sanity: un Nickel que evalúa a un shape NO-reconocible (no
/// matchea ningún reader) cae en `NoMatchingReader` — la cadena
/// Nickel + dispatcher se mantiene coherente.
#[test]
fn ncl_evaluating_to_unknown_shape_returns_no_matching_reader() {
let dir = unique_dir("unknown-shape");
let p = write_file(
&dir,
"weird.ncl",
r#"{ random = "shape", without = "fingerprint" }"#,
);
let err = load_card(&p).unwrap_err();
assert!(
matches!(err, CardLoadError::NoMatchingReader),
"expected NoMatchingReader, got {err:?}"
);
fs::remove_dir_all(&dir).ok();
}
@@ -0,0 +1,217 @@
//! Tests E2E de los templates canónicos shipped con el crate.
//!
//! Cada test escribe un Card user-side en un tempdir, importa el
//! template canónico, override id/label/etc., y verifica que el
//! brazo lo dispatcha al variant correcto del CardBody con los
//! valores merged.
//!
//! `BRAHMAN_CARDS_TEMPLATES_DIR` se setea localmente en cada test.
//! Como Nickel también busca relativo al input file, usamos el env
//! para que `import "ente_basic.ncl"` (sin path) resuelva desde
//! cualquier ubicación del input.
use std::fs;
use std::path::PathBuf;
use brahman_cards::{
canonical_templates_dir, load_card, CardBody, BRAHMAN_CARDS_TEMPLATES_ENV,
};
/// Helper: corre `f()` con `BRAHMAN_CARDS_TEMPLATES_ENV` set al
/// directorio de templates canónicos, restaurando el env al salir.
///
/// Tests no son thread-safe entre sí cuando comparten env. Por eso
/// quedan en serial via `nextest --test-threads=1` o `cargo test`
/// que paralelizara sólo entre `tests/*.rs` distintos. Como este
/// archivo encapsula todo el setup de env, aún en paralelo entre
/// archivos de tests no chocan (cada thread setea/restaura).
fn with_canonical_templates<F: FnOnce()>(f: F) {
let prev = std::env::var(BRAHMAN_CARDS_TEMPLATES_ENV).ok();
let dir = canonical_templates_dir();
// SAFETY: env mutation single-threaded en este test.
unsafe {
std::env::set_var(BRAHMAN_CARDS_TEMPLATES_ENV, &dir);
}
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
unsafe {
match prev {
Some(v) => std::env::set_var(BRAHMAN_CARDS_TEMPLATES_ENV, v),
None => std::env::remove_var(BRAHMAN_CARDS_TEMPLATES_ENV),
}
}
if let Err(panic) = result {
std::panic::resume_unwind(panic);
}
}
fn unique_dir(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"brahman-cards-templates-{}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0),
name
));
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn ente_basic_template_overridden_loads_as_ente_card() {
with_canonical_templates(|| {
let dir = unique_dir("ente");
let card_path = dir.join("my_ente.ncl");
fs::write(
&card_path,
r#"
let base = import "ente_basic.ncl" in
base & {
id = "01ARZ3NDEKTSV4RRFFQ69G5FAV",
label = "mi-ente",
}
"#,
)
.unwrap();
let card = load_card(&card_path).expect("load ente");
assert_eq!(card.id, "01ARZ3NDEKTSV4RRFFQ69G5FAV");
assert_eq!(card.label, "mi-ente");
assert_eq!(card.body.kind_name(), "ente");
match card.body {
CardBody::Ente(e) => {
assert_eq!(e.label, "mi-ente");
// Defaults del template intactos.
assert_eq!(e.schema_version, 1);
// Payload es el "Virtual" del template default.
assert!(
matches!(e.payload, brahman_card::Payload::Virtual),
"payload debería ser Virtual, got {:?}",
e.payload
);
}
other => panic!("variant inesperado: {:?}", other.kind_name()),
}
fs::remove_dir_all(&dir).ok();
});
}
#[test]
fn monad_basic_template_overridden_loads_as_monad_card() {
with_canonical_templates(|| {
let dir = unique_dir("monad");
let card_path = dir.join("my_monad.ncl");
fs::write(
&card_path,
r#"
let base = import "monad_basic.ncl" in
base & {
id = "01ARZ3NDEKTSV4RRFFQ69G5FAW",
label = "fotos-2026",
cardinality = 5,
}
"#,
)
.unwrap();
let card = load_card(&card_path).expect("load monad");
assert_eq!(card.id, "01ARZ3NDEKTSV4RRFFQ69G5FAW");
assert_eq!(card.label, "fotos-2026");
assert_eq!(card.body.kind_name(), "monad");
match card.body {
CardBody::Monad(m) => {
assert_eq!(m.label, "fotos-2026");
assert_eq!(m.cardinality, 5);
// Defaults del template intactos.
assert_eq!(m.schema_version, 1);
assert!(m.members.is_empty());
assert!(m.summary.is_empty());
}
other => panic!("variant inesperado: {:?}", other.kind_name()),
}
fs::remove_dir_all(&dir).ok();
});
}
#[test]
fn ui_module_basic_template_overridden_loads_as_ui_module_card() {
with_canonical_templates(|| {
let dir = unique_dir("ui");
let card_path = dir.join("my_module.ncl");
fs::write(
&card_path,
r#"
let base = import "ui_module_basic.ncl" in
base & {
id = "customers",
label = "Clientes",
menu = [{ label = "Lista", view = "list" }],
views = {
list = {
kind = "list",
title = "Customers",
entity = "Customer",
columns = [],
},
},
}
"#,
)
.unwrap();
let card = load_card(&card_path).expect("load ui_module");
assert_eq!(card.id, "customers");
assert_eq!(card.label, "Clientes");
assert_eq!(card.body.kind_name(), "ui_module");
match card.body {
CardBody::UiModule(m) => {
assert_eq!(m.id, "customers");
assert_eq!(m.menu.len(), 1);
assert!(m.views.contains_key("list"));
// Defaults del template: entities vacío.
assert!(m.entities.is_empty());
}
other => panic!("variant inesperado: {:?}", other.kind_name()),
}
fs::remove_dir_all(&dir).ok();
});
}
#[test]
fn template_default_id_and_label_pass_through_when_not_overridden() {
// Sanity: si el usuario importa el template SIN override de
// id/label, los defaults `"TEMPLATE_ID"` y `"TEMPLATE_LABEL"`
// pasan al wrapper Card.id/label. El brazo no falla — sólo
// los muestra como están. Validar este flow garantiza que un
// user "vacío" (importa y no override) carga sin error.
with_canonical_templates(|| {
let dir = unique_dir("defaults");
let card_path = dir.join("noop.ncl");
fs::write(&card_path, r#"import "ui_module_basic.ncl""#).unwrap();
let card = load_card(&card_path).expect("load defaults");
assert_eq!(card.id, "TEMPLATE_ID");
assert_eq!(card.label, "TEMPLATE_LABEL");
fs::remove_dir_all(&dir).ok();
});
}
#[test]
fn canonical_templates_dir_actually_exists() {
// Sanity: el path expuesto por canonical_templates_dir tiene
// que apuntar a un directorio que existe físicamente, sino los
// tests anteriores fallarían silenciosamente (Nickel reporta
// import-not-found pero el test ya estaría roto).
let d = canonical_templates_dir();
assert!(d.is_dir(), "templates dir no existe: {}", d.display());
for fname in ["ente_basic.ncl", "monad_basic.ncl", "ui_module_basic.ncl"] {
let p = d.join(fname);
assert!(p.is_file(), "template missing: {}", p.display());
}
}