⎇ 86d06da020b21905887b8e689fe014b5c3cc6e8f
crates/modules/nahual/libs/meta-runtime/src/testing.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(nakui): Fase 1 del ERP — FieldKind Select + AutoId, seed inyecta id
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 [`nahual_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
208 .iter()
209 .map(|(k, v)| (k.to_string(), v.clone()))
210 .collect()
211 }
212
213 #[test]
214 fn with_records_populates_state() {
215 let id = Uuid::new_v4();
216 let b = MockBackend::with_records([("Customer".into(), id, json!({"name": "Acme"}))]);
217 assert_eq!(b.total_records(), 1);
218 assert_eq!(b.load_record("Customer", id), Some(json!({"name": "Acme"})));
219 }
220
221 #[test]
222 fn seed_then_load_round_trip_via_trait() {
223 let mut b = MockBackend::new();
224 let out = b.seed("X", map_of(&[("k", json!(1))])).unwrap();
225 let id = out.id.unwrap();
226 assert_eq!(out.changed, 1);
227 assert_eq!(b.load_record("X", id), Some(json!({"k": 1})));
228 }
229
230 #[test]
231 fn update_no_op_returns_no_change() {
232 let id = Uuid::new_v4();
233 let mut b = MockBackend::with_records([("X".into(), id, json!({"k": 1}))]);
234 let out = b.update("X", id, serde_json::Map::new(), vec![]).unwrap();
235 assert_eq!(out, WriteOutcome::no_change(id));
236 }
237
238 #[test]
239 fn update_set_and_clear_aplica_ambos() {
240 let id = Uuid::new_v4();
241 let mut b = MockBackend::with_records([("X".into(), id, json!({"a": 1, "b": 2}))]);
242 let out = b
243 .update("X", id, map_of(&[("a", json!(10))]), vec!["b".into()])
244 .unwrap();
245 assert_eq!(out.changed, 2);
246 let rec = b.load_record("X", id).unwrap();
247 assert_eq!(rec.get("a"), Some(&json!(10)));
248 assert!(rec.get("b").is_none());
249 }
250
251 #[test]
252 fn delete_then_load_returns_none() {
253 let id = Uuid::new_v4();
254 let mut b = MockBackend::with_records([("X".into(), id, json!({"k": 1}))]);
255 b.delete("X", id).unwrap();
256 assert!(b.load_record("X", id).is_none());
257 }
258
259 #[test]
260 fn morphism_without_handler_errors_clearly() {
261 let mut b = MockBackend::new();
262 let err = b
263 .morphism("mod", "foo", BTreeMap::new(), json!({}))
264 .unwrap_err();
265 assert!(err.contains("foo"));
266 }
267
268 #[test]
269 fn with_morphism_lets_caller_simulate_logic() {
270 let mut b = MockBackend::new().with_morphism("double_qty", |inputs, params| {
271 assert!(inputs.is_empty());
272 let qty = params.get("qty").and_then(|v| v.as_i64()).unwrap_or(0);
273 if qty <= 0 {
274 return Err("qty must be positive".into());
275 }
276 Ok(qty as usize)
277 });
278 let out = b
279 .morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 7}))
280 .unwrap();
281 assert_eq!(out.changed, 7);
282 assert!(out.id.is_none(), "morphism no devuelve id por convención");
283
284 let err = b
285 .morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 0}))
286 .unwrap_err();
287 assert!(err.contains("positive"));
288 }
289
290 #[test]
291 fn list_records_orders_lexicographically() {
292 let id_a = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
293 let id_b = Uuid::parse_str("ffffffff-0000-0000-0000-000000000000").unwrap();
294 let b = MockBackend::with_records([
295 ("X".into(), id_b, json!({"n": 2})),
296 ("X".into(), id_a, json!({"n": 1})),
297 ]);
298 let rows = b.list_records("X");
299 assert_eq!(rows.len(), 2);
300 assert_eq!(rows[0].0, id_a, "menor uuid primero (orden lex)");
301 }
302
303 #[test]
304 fn records_for_returns_borrowed_view() {
305 let id = Uuid::new_v4();
306 let b = MockBackend::with_records([("X".into(), id, json!({"k": 1}))]);
307 let view = b.records_for("X");
308 assert_eq!(view.len(), 1);
309 assert_eq!(view[0].0, id);
310 assert_eq!(view[0].1.get("k"), Some(&json!(1)));
311 }
312}