gioser

tawasuyu/brahman

Rust 570TOML 215Markdown 68JSON 198261 Definiciones

⎇ 86d06da020b21905887b8e689fe014b5c3cc6e8f crates/modules/nahual/libs/meta-runtime/src/testing.rs Rust · 312 líneas · 10.1 KB Historia Quién tocó qué crudo

feat(nakui): Fase 1 del ERP — FieldKind Select + AutoId, seed inyecta id 86d06da02 · sergio ·

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, &params)?;
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}