⎇ f6361bbdcab3209adc2d90f004c6a7746e204f67
crates/core/brahman-cards/tests/integration.rs
History
Who touched what
raw
feat(nakui-ui): migrar consumer al brazo brahman_cards::load_cards_from_dir
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}