tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definitions

⎇ f6361bbdcab3209adc2d90f004c6a7746e204f67 crates/core/brahman-cards/tests/integration.rs Rust · 434 lines · 13.8 KB History Who touched what raw

feat(nakui-ui): migrar consumer al brazo brahman_cards::load_cards_from_dir f6361bbdc · Sergio ·

1//! Integration tests del brazo brahman-cards.
2//!
3//! Cubre:
4//! 1. Cada reader matchea sólo el shape correcto.
5//! 2. El dispatcher (`load_card`/`dispatch`) elige el reader
6//!    correcto sin ambigüedad.
7//! 3. Round-trip: cada formato JSON cargado produce el variant
8//!    esperado del Card canónico con los campos del wrapper bien
9//!    derivados.
10//! 4. Rechazo gracioso de inputs no-matched + extensiones no
11//!    soportadas.
12
13use std::collections::BTreeMap;
14
15use brahman_cards::{
16    default_readers, load_card_with, Card, CardBody, CardLoadError, CardReader, EnteJsonReader,
17    MonadJsonReader, UiModuleJsonReader,
18};
19use serde_json::{json, Value};
20
21/// Helper: dispatch in-process desde un Value, sin tocar disco.
22/// Reproduce la lógica interna del dispatcher para no exigir I/O en
23/// los tests.
24fn dispatch(input: Value, readers: &[Box<dyn CardReader>]) -> Result<Card, CardLoadError> {
25    for r in readers {
26        if r.can_read(&input) {
27            return r.read(input);
28        }
29    }
30    Err(CardLoadError::NoMatchingReader)
31}
32
33// ===========================================================================
34// Reader detection (can_read)
35// ===========================================================================
36
37#[test]
38fn ui_module_reader_detects_only_ui_module_shape() {
39    let r = UiModuleJsonReader;
40    let ui = json!({"id": "x", "label": "X", "menu": [], "views": {}, "entities": []});
41    let ente = json!({"id": "x", "label": "X", "payload": "Virtual", "supervision": "OneShot"});
42    let monad = json!({"id": "x", "label": "X", "members": [], "cardinality": 0});
43    assert!(r.can_read(&ui), "UiModule reader debe matchear ui shape");
44    assert!(!r.can_read(&ente), "no debe matchear Ente");
45    assert!(!r.can_read(&monad), "no debe matchear Monad");
46    assert!(!r.can_read(&Value::Null), "no debe matchear non-object");
47}
48
49#[test]
50fn ente_reader_detects_only_ente_shape() {
51    let r = EnteJsonReader;
52    let ente = json!({"payload": "Virtual", "supervision": "OneShot"});
53    let monad = json!({"members": [], "cardinality": 0});
54    let ui = json!({"menu": [], "views": {}, "entities": []});
55    assert!(r.can_read(&ente));
56    assert!(!r.can_read(&monad));
57    assert!(!r.can_read(&ui));
58}
59
60#[test]
61fn monad_reader_detects_only_monad_shape() {
62    let r = MonadJsonReader;
63    let monad = json!({"members": [], "cardinality": 0});
64    let ente = json!({"payload": "Virtual", "supervision": "OneShot"});
65    let ui = json!({"menu": [], "views": {}, "entities": []});
66    assert!(r.can_read(&monad));
67    assert!(!r.can_read(&ente));
68    assert!(!r.can_read(&ui));
69}
70
71// ===========================================================================
72// Dispatch + variant projection
73// ===========================================================================
74
75#[test]
76fn loads_ui_module_to_card_ui_module_variant() {
77    let input = json!({
78        "id": "sales_engine",
79        "label": "Ventas",
80        "description": "Demo",
81        "entities": [],
82        "menu": [{"label": "Stock", "view": "stock_list"}],
83        "views": {
84            "stock_list": {
85                "kind": "list",
86                "title": "Stock",
87                "entity": "Stock",
88                "columns": []
89            }
90        }
91    });
92    let card = dispatch(input, &default_readers()).expect("dispatch ok");
93    assert_eq!(card.id, "sales_engine");
94    assert_eq!(card.label, "Ventas");
95    assert!(card.lineage.is_none(), "UiModule sin lineage");
96    assert_eq!(card.body.kind_name(), "ui_module");
97    match card.body {
98        CardBody::UiModule(m) => {
99            assert_eq!(m.id, "sales_engine");
100            assert_eq!(m.menu.len(), 1);
101        }
102        other => panic!("variant inesperado: {:?}", other.kind_name()),
103    }
104}
105
106#[test]
107fn loads_ente_to_card_ente_variant() {
108    // Ulid mínimo: 26 chars Crockford. Usamos uno conocido.
109    let ulid = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
110    let input = json!({
111        "schema_version": 1,
112        "id": ulid,
113        "label": "test-ente",
114        "payload": "Virtual",
115        "supervision": "OneShot"
116    });
117    let card = dispatch(input, &default_readers()).expect("dispatch ok");
118    assert_eq!(card.id, ulid);
119    assert_eq!(card.label, "test-ente");
120    assert_eq!(card.body.kind_name(), "ente");
121    match card.body {
122        CardBody::Ente(e) => {
123            assert_eq!(e.label, "test-ente");
124            assert_eq!(e.id.to_string(), ulid);
125        }
126        other => panic!("variant inesperado: {:?}", other.kind_name()),
127    }
128}
129
130#[test]
131fn loads_monad_to_card_monad_variant() {
132    let ulid = "01ARZ3NDEKTSV4RRFFQ69G5FB1";
133    let input = json!({
134        "schema_version": 1,
135        "id": ulid,
136        "label": "test-monad",
137        "members": [],
138        "cardinality": 0,
139        "created_at_ms": 0,
140        "updated_at_ms": 0
141    });
142    let card = dispatch(input, &default_readers()).expect("dispatch ok");
143    assert_eq!(card.id, ulid);
144    assert_eq!(card.label, "test-monad");
145    assert_eq!(card.body.kind_name(), "monad");
146    match card.body {
147        CardBody::Monad(m) => {
148            assert_eq!(m.label, "test-monad");
149            assert_eq!(m.cardinality, 0);
150        }
151        other => panic!("variant inesperado: {:?}", other.kind_name()),
152    }
153}
154
155// ===========================================================================
156// Negative cases
157// ===========================================================================
158
159#[test]
160fn rejects_input_no_matching_reader() {
161    let input = json!({"random": "shape", "without": "fingerprint"});
162    let err = dispatch(input, &default_readers()).unwrap_err();
163    assert!(
164        matches!(err, CardLoadError::NoMatchingReader),
165        "expected NoMatchingReader, got {err:?}"
166    );
167}
168
169#[test]
170fn rejects_non_object_input() {
171    let input = json!([1, 2, 3]);
172    let err = dispatch(input, &default_readers()).unwrap_err();
173    assert!(matches!(err, CardLoadError::NoMatchingReader));
174}
175
176#[test]
177fn ui_module_takes_priority_when_shape_overlaps_partial() {
178    // Sanity del orden: si alguien armara un input híbrido con
179    // `menu`+`views`+`entities` Y también `payload`+`supervision`,
180    // el UiModuleReader (primero en orden) debería ganar. Esto no
181    // debería ocurrir con inputs reales pero defendemos el contrato
182    // de orden documentado.
183    let input = json!({
184        "id": "weird",
185        "label": "Weird",
186        "menu": [],
187        "views": {},
188        "entities": [],
189        "payload": "Virtual",
190        "supervision": "OneShot"
191    });
192    let card = dispatch(input, &default_readers()).expect("dispatch ok");
193    assert_eq!(
194        card.body.kind_name(),
195        "ui_module",
196        "el UiModuleReader debería ganar por orden"
197    );
198}
199
200// ===========================================================================
201// load_card desde disco (e2e fino)
202// ===========================================================================
203
204#[test]
205fn load_card_from_disk_round_trip_ui_module() {
206    let tmp = tempfile_path("ui_module.json");
207    let input = json!({
208        "id": "demo",
209        "label": "Demo",
210        "entities": [],
211        "menu": [],
212        "views": {}
213    });
214    std::fs::write(&tmp, serde_json::to_vec_pretty(&input).unwrap()).unwrap();
215
216    let card = load_card_with(&tmp, &default_readers()).expect("load ok");
217    assert_eq!(card.body.kind_name(), "ui_module");
218    assert_eq!(card.id, "demo");
219
220    let _ = std::fs::remove_file(&tmp);
221}
222
223#[test]
224fn load_card_rejects_unsupported_extension() {
225    let tmp = tempfile_path("foo.toml");
226    std::fs::write(&tmp, b"[anything]\nx = 1").unwrap();
227    let err = load_card_with(&tmp, &default_readers()).unwrap_err();
228    match err {
229        CardLoadError::UnsupportedExtension { ext, supported } => {
230            assert_eq!(ext, "toml");
231            assert!(supported.contains(&"json"));
232        }
233        other => panic!("expected UnsupportedExtension, got {other:?}"),
234    }
235    let _ = std::fs::remove_file(&tmp);
236}
237
238// ===========================================================================
239// Custom reader sets
240// ===========================================================================
241
242#[test]
243fn custom_reader_set_can_restrict_supported_formats() {
244    // Sólo Ente: un input Monad debería rechazarse.
245    let only_ente: Vec<Box<dyn CardReader>> = vec![Box::new(EnteJsonReader)];
246    let monad_input = json!({"members": [], "cardinality": 0});
247    let err = dispatch(monad_input, &only_ente).unwrap_err();
248    assert!(matches!(err, CardLoadError::NoMatchingReader));
249}
250
251// ===========================================================================
252// Wrapper field invariants
253// ===========================================================================
254
255#[test]
256fn extensions_field_starts_empty_in_v1() {
257    // Documented: V1 no mueve el "extras" del crate origen al
258    // wrapper.extensions. Si esto cambia, este test se rompe como
259    // signal para actualizar el doc de readers.rs.
260    let input = json!({
261        "id": "demo",
262        "label": "Demo",
263        "entities": [],
264        "menu": [],
265        "views": {}
266    });
267    let card = dispatch(input, &default_readers()).unwrap();
268    assert_eq!(card.extensions, BTreeMap::new());
269}
270
271// ===========================================================================
272// load_cards_from_dir (subdir walking)
273// ===========================================================================
274
275#[test]
276fn load_cards_from_dir_walks_subdirs_and_finds_module_json() {
277    let root = unique_dir("dir-walk");
278    // Subdir A: tiene module.json (UiModule).
279    let a = root.join("alpha");
280    std::fs::create_dir(&a).unwrap();
281    std::fs::write(
282        a.join("module.json"),
283        serde_json::to_vec_pretty(&json!({
284            "id": "alpha",
285            "label": "Alpha",
286            "entities": [],
287            "menu": [],
288            "views": {}
289        }))
290        .unwrap(),
291    )
292    .unwrap();
293    // Subdir B: tiene module.json (UiModule).
294    let b = root.join("bravo");
295    std::fs::create_dir(&b).unwrap();
296    std::fs::write(
297        b.join("module.json"),
298        serde_json::to_vec_pretty(&json!({
299            "id": "bravo",
300            "label": "Bravo",
301            "entities": [],
302            "menu": [],
303            "views": {}
304        }))
305        .unwrap(),
306    )
307    .unwrap();
308    // Subdir C: NO tiene ninguno de los filenames convencionales —
309    // se debe skipear sin error.
310    let c = root.join("charlie");
311    std::fs::create_dir(&c).unwrap();
312    std::fs::write(c.join("readme.txt"), b"sin card aca").unwrap();
313
314    let cards = brahman_cards::load_cards_from_dir(&root).expect("ok");
315    let ids: Vec<&str> = cards.iter().map(|c| c.id.as_str()).collect();
316    assert_eq!(
317        ids,
318        vec!["alpha", "bravo"],
319        "orden lexicográfico por subdir name"
320    );
321    for c in &cards {
322        assert_eq!(c.body.kind_name(), "ui_module");
323    }
324
325    std::fs::remove_dir_all(&root).ok();
326}
327
328#[test]
329fn load_cards_from_dir_prefers_ncl_over_json_when_both_present() {
330    let root = unique_dir("dir-prefer");
331    let sub = root.join("only");
332    std::fs::create_dir(&sub).unwrap();
333    // Ambos archivos existen; el .ncl debería ganar.
334    std::fs::write(
335        sub.join("card.ncl"),
336        r#"{ id = "from_ncl", label = "Ncl", entities = [], menu = [], views = {} }"#,
337    )
338    .unwrap();
339    std::fs::write(
340        sub.join("card.json"),
341        serde_json::to_vec(&json!({
342            "id": "from_json",
343            "label": "Json",
344            "entities": [], "menu": [], "views": {}
345        }))
346        .unwrap(),
347    )
348    .unwrap();
349
350    let cards = brahman_cards::load_cards_from_dir(&root).expect("ok");
351    assert_eq!(cards.len(), 1);
352    assert_eq!(cards[0].id, "from_ncl", "card.ncl tiene prioridad");
353
354    std::fs::remove_dir_all(&root).ok();
355}
356
357#[test]
358fn load_cards_from_dir_propagates_per_file_errors_loud() {
359    let root = unique_dir("dir-error-loud");
360    let sub = root.join("broken");
361    std::fs::create_dir(&sub).unwrap();
362    std::fs::write(sub.join("card.json"), b"{ this is not valid json").unwrap();
363
364    let err = brahman_cards::load_cards_from_dir(&root).unwrap_err();
365    assert!(
366        matches!(err, CardLoadError::JsonParse(_)),
367        "el error de un file roto debe propagar fail-loud, got {err:?}"
368    );
369
370    std::fs::remove_dir_all(&root).ok();
371}
372
373#[test]
374fn load_cards_from_dir_with_custom_filenames() {
375    let root = unique_dir("dir-custom-fname");
376    let sub = root.join("only");
377    std::fs::create_dir(&sub).unwrap();
378    // Filename custom que NO está en el default.
379    std::fs::write(
380        sub.join("manifest.json"),
381        serde_json::to_vec(&json!({
382            "id": "x",
383            "label": "X",
384            "entities": [], "menu": [], "views": {}
385        }))
386        .unwrap(),
387    )
388    .unwrap();
389
390    // Default no encuentra nada (skipea):
391    let with_default = brahman_cards::load_cards_from_dir(&root).unwrap();
392    assert_eq!(with_default.len(), 0, "default filenames no incluye manifest.json");
393
394    // Custom filename encuentra:
395    let with_custom = brahman_cards::load_cards_from_dir_with(
396        &root,
397        &["manifest.json"],
398        &brahman_cards::default_readers(),
399    )
400    .unwrap();
401    assert_eq!(with_custom.len(), 1);
402    assert_eq!(with_custom[0].id, "x");
403
404    std::fs::remove_dir_all(&root).ok();
405}
406
407// ===========================================================================
408// Helpers de tests
409// ===========================================================================
410
411fn tempfile_path(name: &str) -> std::path::PathBuf {
412    let mut p = std::env::temp_dir();
413    p.push(format!(
414        "brahman-cards-test-{}-{}",
415        std::process::id(),
416        name
417    ));
418    p
419}
420
421fn unique_dir(name: &str) -> std::path::PathBuf {
422    let mut p = std::env::temp_dir();
423    p.push(format!(
424        "brahman-cards-test-{}-{}-{}",
425        std::process::id(),
426        std::time::SystemTime::now()
427            .duration_since(std::time::UNIX_EPOCH)
428            .map(|d| d.as_nanos())
429            .unwrap_or(0),
430        name
431    ));
432    std::fs::create_dir_all(&p).unwrap();
433    p
434}