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>
435 lines
14 KiB
Rust
435 lines
14 KiB
Rust
//! 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
|
|
}
|