⎇ 72758e75ce515f355f27615671c95ecaf8c2a8bc
crates/modules/ui_engine/libs/meta-runtime/src/testing.rs
Historia
Quién tocó qué
crudo
feat(yahweh): MockBackend público + tests E2E del widget con TestAppContext
1//! Utilidades de testing para code que consume [`MetaBackend`].
2//!
3//! Provee [`MockBackend`]: implementación in-memory minimalista
4//! del trait, sin acoplamiento a stores reales (event log,
5//! SurrealDB, etc.). Útil para:
6//!
7//! - Tests del widget [`yahweh_widget_meta_form::MetaApp`] que
8//! necesitan un backend funcional sin levantar nakui-core.
9//! - Tests de cualquier consumer que tome `B: MetaBackend` y quiera
10//! asserts sobre lecturas/escrituras sin tocar disco.
11//! - Fixtures pre-pobladas para demos/screenshots/CI.
12//!
13//! Está bajo `pub mod testing` (no `#[cfg(test)]`) deliberadamente
14//! para que crates downstream puedan importarlo en sus dev/integ
15//! tests. No tiene overhead en producción si no se usa.
16
17use std::collections::{BTreeMap, HashMap};
18
19use serde_json::Value;
20use uuid::Uuid;
21
22use crate::backend::{MetaBackend, WriteOutcome};
23
24/// Backend in-memory para tests. Implementa el contrato completo
25/// del [`MetaBackend`] con semantica simple:
26///
27/// - `seed`: genera Uuid v4, inserta record. `changed = 1`.
28/// - `update`: aplica `set` (overrides) y `clear` (key removal).
29/// Si ambos vacíos → `changed = 0`. Falla si record no existe.
30/// - `delete`: remueve record. Falla si no existe.
31/// - `morphism`: por default rebota con error
32/// `"MockBackend no soporta morphism '<name>'"`. Si querés
33/// simular morphisms, registrá callbacks via
34/// [`MockBackend::with_morphism`].
35/// - `list_records`: orden lexicográfico por id (estable).
36/// - Sin `post_status`: el mock no tiene tick/compact.
37///
38/// Métodos de inspección públicos ([`total_records`],
39/// [`records_for`], etc.) facilitan asserts en tests sin necesidad
40/// de re-leer el state via las APIs del trait.
41pub struct MockBackend {
42 records: HashMap<(String, Uuid), Value>,
43 morphisms: HashMap<String, MorphismHandler>,
44}
45
46type MorphismHandler =
47 Box<dyn Fn(&BTreeMap<String, Uuid>, &Value) -> Result<usize, String> + Send + Sync>;
48
49impl Default for MockBackend {
50 fn default() -> Self {
51 Self::new()
52 }
53}
54
55impl MockBackend {
56 /// Backend vacío.
57 pub fn new() -> Self {
58 Self {
59 records: HashMap::new(),
60 morphisms: HashMap::new(),
61 }
62 }
63
64 /// Pre-popula el backend con records `(entity, uuid, data)`.
65 /// Útil para fixtures: asserts sobre lecturas sin tener que
66 /// armar seeds via `seed()`.
67 pub fn with_records<I>(records: I) -> Self
68 where
69 I: IntoIterator<Item = (String, Uuid, Value)>,
70 {
71 let mut b = Self::new();
72 for (entity, id, data) in records {
73 b.records.insert((entity, id), data);
74 }
75 b
76 }
77
78 /// Registra un handler para un morphism de nombre `name`.
79 /// El handler recibe inputs + params y devuelve `changed` o
80 /// `Err` para simular fallo del morphism. Sobrescribe cualquier
81 /// handler previo del mismo nombre.
82 pub fn with_morphism<F>(mut self, name: impl Into<String>, handler: F) -> Self
83 where
84 F: Fn(&BTreeMap<String, Uuid>, &Value) -> Result<usize, String> + Send + Sync + 'static,
85 {
86 self.morphisms.insert(name.into(), Box::new(handler));
87 self
88 }
89
90 /// Cantidad total de records en el backend (todas las entities).
91 pub fn total_records(&self) -> usize {
92 self.records.len()
93 }
94
95 /// Records de una entity como `Vec<(Uuid, &Value)>` sin clones
96 /// (más liviano que `list_records` cuando el caller sólo quiere
97 /// inspeccionar).
98 pub fn records_for<'a>(&'a self, entity: &str) -> Vec<(Uuid, &'a Value)> {
99 self.records
100 .iter()
101 .filter(|((e, _), _)| e == entity)
102 .map(|((_, id), v)| (*id, v))
103 .collect()
104 }
105}
106
107impl MetaBackend for MockBackend {
108 fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)> {
109 let mut out: Vec<(Uuid, Value)> = self
110 .records
111 .iter()
112 .filter(|((e, _), _)| e == entity)
113 .map(|((_, id), v)| (*id, v.clone()))
114 .collect();
115 out.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
116 out
117 }
118
119 fn load_record(&self, entity: &str, id: Uuid) -> Option<Value> {
120 self.records.get(&(entity.to_string(), id)).cloned()
121 }
122
123 fn seed(
124 &mut self,
125 entity: &str,
126 data: serde_json::Map<String, Value>,
127 ) -> Result<WriteOutcome, String> {
128 let id = Uuid::new_v4();
129 self.records
130 .insert((entity.to_string(), id), Value::Object(data));
131 Ok(WriteOutcome {
132 id: Some(id),
133 changed: 1,
134 post_status: None,
135 })
136 }
137
138 fn update(
139 &mut self,
140 entity: &str,
141 id: Uuid,
142 set: serde_json::Map<String, Value>,
143 clear: Vec<String>,
144 ) -> Result<WriteOutcome, String> {
145 if set.is_empty() && clear.is_empty() {
146 return Ok(WriteOutcome::no_change(id));
147 }
148 let rec = self
149 .records
150 .get_mut(&(entity.to_string(), id))
151 .ok_or_else(|| format!("not found: {entity}/{id}"))?;
152 let map = rec
153 .as_object_mut()
154 .ok_or_else(|| format!("not an object: {entity}/{id}"))?;
155 let changed = set.len() + clear.len();
156 for (k, v) in set {
157 map.insert(k, v);
158 }
159 for k in clear {
160 map.remove(&k);
161 }
162 Ok(WriteOutcome {
163 id: Some(id),
164 changed,
165 post_status: None,
166 })
167 }
168
169 fn delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String> {
170 self.records
171 .remove(&(entity.to_string(), id))
172 .ok_or_else(|| format!("not found: {entity}/{id}"))?;
173 Ok(WriteOutcome {
174 id: Some(id),
175 changed: 1,
176 post_status: None,
177 })
178 }
179
180 fn morphism(
181 &mut self,
182 _module_id: &str,
183 name: &str,
184 inputs: BTreeMap<String, Uuid>,
185 params: Value,
186 ) -> Result<WriteOutcome, String> {
187 match self.morphisms.get(name) {
188 Some(handler) => {
189 let changed = handler(&inputs, ¶ms)?;
190 Ok(WriteOutcome {
191 id: None,
192 changed,
193 post_status: None,
194 })
195 }
196 None => Err(format!("MockBackend no soporta morphism '{name}'")),
197 }
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use serde_json::json;
205
206 fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
207 items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
208 }
209
210 #[test]
211 fn with_records_populates_state() {
212 let id = Uuid::new_v4();
213 let b = MockBackend::with_records([(
214 "Customer".into(),
215 id,
216 json!({"name": "Acme"}),
217 )]);
218 assert_eq!(b.total_records(), 1);
219 assert_eq!(
220 b.load_record("Customer", id),
221 Some(json!({"name": "Acme"}))
222 );
223 }
224
225 #[test]
226 fn seed_then_load_round_trip_via_trait() {
227 let mut b = MockBackend::new();
228 let out = b
229 .seed("X", map_of(&[("k", json!(1))]))
230 .unwrap();
231 let id = out.id.unwrap();
232 assert_eq!(out.changed, 1);
233 assert_eq!(b.load_record("X", id), Some(json!({"k": 1})));
234 }
235
236 #[test]
237 fn update_no_op_returns_no_change() {
238 let id = Uuid::new_v4();
239 let mut b = MockBackend::with_records([(
240 "X".into(),
241 id,
242 json!({"k": 1}),
243 )]);
244 let out = b
245 .update("X", id, serde_json::Map::new(), vec![])
246 .unwrap();
247 assert_eq!(out, WriteOutcome::no_change(id));
248 }
249
250 #[test]
251 fn update_set_and_clear_aplica_ambos() {
252 let id = Uuid::new_v4();
253 let mut b = MockBackend::with_records([(
254 "X".into(),
255 id,
256 json!({"a": 1, "b": 2}),
257 )]);
258 let out = b
259 .update("X", id, map_of(&[("a", json!(10))]), vec!["b".into()])
260 .unwrap();
261 assert_eq!(out.changed, 2);
262 let rec = b.load_record("X", id).unwrap();
263 assert_eq!(rec.get("a"), Some(&json!(10)));
264 assert!(rec.get("b").is_none());
265 }
266
267 #[test]
268 fn delete_then_load_returns_none() {
269 let id = Uuid::new_v4();
270 let mut b = MockBackend::with_records([(
271 "X".into(),
272 id,
273 json!({"k": 1}),
274 )]);
275 b.delete("X", id).unwrap();
276 assert!(b.load_record("X", id).is_none());
277 }
278
279 #[test]
280 fn morphism_without_handler_errors_clearly() {
281 let mut b = MockBackend::new();
282 let err = b
283 .morphism("mod", "foo", BTreeMap::new(), json!({}))
284 .unwrap_err();
285 assert!(err.contains("foo"));
286 }
287
288 #[test]
289 fn with_morphism_lets_caller_simulate_logic() {
290 let mut b = MockBackend::new().with_morphism(
291 "double_qty",
292 |inputs, params| {
293 assert!(inputs.is_empty());
294 let qty = params.get("qty").and_then(|v| v.as_i64()).unwrap_or(0);
295 if qty <= 0 {
296 return Err("qty must be positive".into());
297 }
298 Ok(qty as usize)
299 },
300 );
301 let out = b
302 .morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 7}))
303 .unwrap();
304 assert_eq!(out.changed, 7);
305 assert!(out.id.is_none(), "morphism no devuelve id por convención");
306
307 let err = b
308 .morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 0}))
309 .unwrap_err();
310 assert!(err.contains("positive"));
311 }
312
313 #[test]
314 fn list_records_orders_lexicographically() {
315 let id_a = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
316 let id_b = Uuid::parse_str("ffffffff-0000-0000-0000-000000000000").unwrap();
317 let b = MockBackend::with_records([
318 ("X".into(), id_b, json!({"n": 2})),
319 ("X".into(), id_a, json!({"n": 1})),
320 ]);
321 let rows = b.list_records("X");
322 assert_eq!(rows.len(), 2);
323 assert_eq!(rows[0].0, id_a, "menor uuid primero (orden lex)");
324 }
325
326 #[test]
327 fn records_for_returns_borrowed_view() {
328 let id = Uuid::new_v4();
329 let b = MockBackend::with_records([(
330 "X".into(),
331 id,
332 json!({"k": 1}),
333 )]);
334 let view = b.records_for("X");
335 assert_eq!(view.len(), 1);
336 assert_eq!(view[0].0, id);
337 assert_eq!(view[0].1.get("k"), Some(&json!(1)));
338 }
339}