feat(brahman-cards): brazo unificado V1 — readers JSON + estructura canónica
Pivote arquitectónico: Brahman maneja varios formatos legítimos de
"Card" (cada uno en su crate origen, shape preservado), y un único
brazo los lee y proyecta a UNA estructura interna canónica que
consumen UI runtime / storage / DHT / wire. Agregar formato nuevo
= agregar reader, sin tocar consumers.
Crate nuevo `crates/core/brahman-cards/`:
- Card { id, schema_version, lineage, label, extensions, body }:
wrapper común con identidad legible. PartialEq omitido porque
MonadManifest y nakui_ui_schema::Module no lo implementan.
- CardBody enum tagged: Ente(brahman_card::Card), Monad(MonadManifest),
UiModule(nakui_ui_schema::Module). Convención: agregar variant +
reader; consumers hacen `match { Ente(..) => ..., _ => skip }`.
- trait CardReader { name, can_read(&Value), read(Value) }.
- 3 readers: EnteJsonReader (payload+supervision), MonadJsonReader
(members+cardinality), UiModuleJsonReader (entities+views+menu).
- Entry points load_card / load_card_with. Errores tipados.
13 tests integration: detection x3, dispatch+projection x3,
negative cases x2, sanity de orden, e2e desde disco, unsupported
extension, custom reader set, documented invariant.
13/13 verdes. Workspace build verde.
V1 NO hace (explícito): Nickel reader, templates, migración de
consumers, yahweh refactor, KCL→Nickel — todos en commits siguientes
para mantener este aislado.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
//! 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());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user