Files
brahman/crates/modules/nahual/libs/meta-runtime/src/testing.rs
T
sergio 550c98f275 refactor(monorepo): reorganización lógica + renames + SDDs + split CHANGELOG
Reorganización física de crates/:
- core/ (mezclaba 6 propósitos) se divide en protocol/, init/, runtime/, compat/
- shared/ (3 crates) se redistribuye en protocol/ e init/
- lapaloma (sub-módulo de ui_engine) se promueve a modules/pineal/

Renames de proyectos:
- shipote → shuma (runtime de sandboxes)
- nouser → akasha (explorador de Mónadas)
- yahweh → nahual (motor GPUI, antes ui_engine/)
- lapaloma → pineal (data-viz agnóstica)

Fraccionamiento UI → core agnóstico:
- vista-core (DeckState + snap, 175 LOC, 5 tests verdes)
- barra-core (Task + render_html + sanitize, 90 LOC, 5 tests verdes)
- vista-web y barra-web ahora son thin DOM bindings

Documentación nueva:
- 16 SDDs por subdirectorio (≤80 LOC c/u): protocol/init/runtime/compat
  + 10 módulos + apps/
- docs/STATUS.md con cifras reales por proyecto
- docs/ROADMAP.md con plan a finalización (6 hitos, ~6-8 semanas)
- CHANGELOG.md particionado en docs/changelog/<proyecto>.md (7 buckets)

Automatización:
- scripts/reorg.py — script idempotente que: git mv directorios, renombra
  package names, recomputa path = refs, reescribe imports rust, actualiza
  workspace Cargo.toml. Soporta --dry-run.
- scripts/split-changelog.py — particiona CHANGELOG por componente.

Validación:
- cargo check --workspace pasa (124 crates + 2 nuevos cores).
- 10 tests adicionales (5 en vista-core + 5 en barra-core) verdes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 14:48:34 +00:00

340 lines
10 KiB
Rust

//! Utilidades de testing para code que consume [`MetaBackend`].
//!
//! Provee [`MockBackend`]: implementación in-memory minimalista
//! del trait, sin acoplamiento a stores reales (event log,
//! SurrealDB, etc.). Útil para:
//!
//! - Tests del widget [`nahual_widget_meta_form::MetaApp`] que
//! necesitan un backend funcional sin levantar nakui-core.
//! - Tests de cualquier consumer que tome `B: MetaBackend` y quiera
//! asserts sobre lecturas/escrituras sin tocar disco.
//! - Fixtures pre-pobladas para demos/screenshots/CI.
//!
//! Está bajo `pub mod testing` (no `#[cfg(test)]`) deliberadamente
//! para que crates downstream puedan importarlo en sus dev/integ
//! tests. No tiene overhead en producción si no se usa.
use std::collections::{BTreeMap, HashMap};
use serde_json::Value;
use uuid::Uuid;
use crate::backend::{MetaBackend, WriteOutcome};
/// Backend in-memory para tests. Implementa el contrato completo
/// del [`MetaBackend`] con semantica simple:
///
/// - `seed`: genera Uuid v4, inserta record. `changed = 1`.
/// - `update`: aplica `set` (overrides) y `clear` (key removal).
/// Si ambos vacíos → `changed = 0`. Falla si record no existe.
/// - `delete`: remueve record. Falla si no existe.
/// - `morphism`: por default rebota con error
/// `"MockBackend no soporta morphism '<name>'"`. Si querés
/// simular morphisms, registrá callbacks via
/// [`MockBackend::with_morphism`].
/// - `list_records`: orden lexicográfico por id (estable).
/// - Sin `post_status`: el mock no tiene tick/compact.
///
/// Métodos de inspección públicos ([`total_records`],
/// [`records_for`], etc.) facilitan asserts en tests sin necesidad
/// de re-leer el state via las APIs del trait.
pub struct MockBackend {
records: HashMap<(String, Uuid), Value>,
morphisms: HashMap<String, MorphismHandler>,
}
type MorphismHandler =
Box<dyn Fn(&BTreeMap<String, Uuid>, &Value) -> Result<usize, String> + Send + Sync>;
impl Default for MockBackend {
fn default() -> Self {
Self::new()
}
}
impl MockBackend {
/// Backend vacío.
pub fn new() -> Self {
Self {
records: HashMap::new(),
morphisms: HashMap::new(),
}
}
/// Pre-popula el backend con records `(entity, uuid, data)`.
/// Útil para fixtures: asserts sobre lecturas sin tener que
/// armar seeds via `seed()`.
pub fn with_records<I>(records: I) -> Self
where
I: IntoIterator<Item = (String, Uuid, Value)>,
{
let mut b = Self::new();
for (entity, id, data) in records {
b.records.insert((entity, id), data);
}
b
}
/// Registra un handler para un morphism de nombre `name`.
/// El handler recibe inputs + params y devuelve `changed` o
/// `Err` para simular fallo del morphism. Sobrescribe cualquier
/// handler previo del mismo nombre.
pub fn with_morphism<F>(mut self, name: impl Into<String>, handler: F) -> Self
where
F: Fn(&BTreeMap<String, Uuid>, &Value) -> Result<usize, String> + Send + Sync + 'static,
{
self.morphisms.insert(name.into(), Box::new(handler));
self
}
/// Cantidad total de records en el backend (todas las entities).
pub fn total_records(&self) -> usize {
self.records.len()
}
/// Records de una entity como `Vec<(Uuid, &Value)>` sin clones
/// (más liviano que `list_records` cuando el caller sólo quiere
/// inspeccionar).
pub fn records_for<'a>(&'a self, entity: &str) -> Vec<(Uuid, &'a Value)> {
self.records
.iter()
.filter(|((e, _), _)| e == entity)
.map(|((_, id), v)| (*id, v))
.collect()
}
}
impl MetaBackend for MockBackend {
fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)> {
let mut out: Vec<(Uuid, Value)> = self
.records
.iter()
.filter(|((e, _), _)| e == entity)
.map(|((_, id), v)| (*id, v.clone()))
.collect();
out.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
out
}
fn load_record(&self, entity: &str, id: Uuid) -> Option<Value> {
self.records.get(&(entity.to_string(), id)).cloned()
}
fn seed(
&mut self,
entity: &str,
data: serde_json::Map<String, Value>,
) -> Result<WriteOutcome, String> {
let id = Uuid::new_v4();
self.records
.insert((entity.to_string(), id), Value::Object(data));
Ok(WriteOutcome {
id: Some(id),
changed: 1,
post_status: None,
})
}
fn update(
&mut self,
entity: &str,
id: Uuid,
set: serde_json::Map<String, Value>,
clear: Vec<String>,
) -> Result<WriteOutcome, String> {
if set.is_empty() && clear.is_empty() {
return Ok(WriteOutcome::no_change(id));
}
let rec = self
.records
.get_mut(&(entity.to_string(), id))
.ok_or_else(|| format!("not found: {entity}/{id}"))?;
let map = rec
.as_object_mut()
.ok_or_else(|| format!("not an object: {entity}/{id}"))?;
let changed = set.len() + clear.len();
for (k, v) in set {
map.insert(k, v);
}
for k in clear {
map.remove(&k);
}
Ok(WriteOutcome {
id: Some(id),
changed,
post_status: None,
})
}
fn delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String> {
self.records
.remove(&(entity.to_string(), id))
.ok_or_else(|| format!("not found: {entity}/{id}"))?;
Ok(WriteOutcome {
id: Some(id),
changed: 1,
post_status: None,
})
}
fn morphism(
&mut self,
_module_id: &str,
name: &str,
inputs: BTreeMap<String, Uuid>,
params: Value,
) -> Result<WriteOutcome, String> {
match self.morphisms.get(name) {
Some(handler) => {
let changed = handler(&inputs, &params)?;
Ok(WriteOutcome {
id: None,
changed,
post_status: None,
})
}
None => Err(format!("MockBackend no soporta morphism '{name}'")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
}
#[test]
fn with_records_populates_state() {
let id = Uuid::new_v4();
let b = MockBackend::with_records([(
"Customer".into(),
id,
json!({"name": "Acme"}),
)]);
assert_eq!(b.total_records(), 1);
assert_eq!(
b.load_record("Customer", id),
Some(json!({"name": "Acme"}))
);
}
#[test]
fn seed_then_load_round_trip_via_trait() {
let mut b = MockBackend::new();
let out = b
.seed("X", map_of(&[("k", json!(1))]))
.unwrap();
let id = out.id.unwrap();
assert_eq!(out.changed, 1);
assert_eq!(b.load_record("X", id), Some(json!({"k": 1})));
}
#[test]
fn update_no_op_returns_no_change() {
let id = Uuid::new_v4();
let mut b = MockBackend::with_records([(
"X".into(),
id,
json!({"k": 1}),
)]);
let out = b
.update("X", id, serde_json::Map::new(), vec![])
.unwrap();
assert_eq!(out, WriteOutcome::no_change(id));
}
#[test]
fn update_set_and_clear_aplica_ambos() {
let id = Uuid::new_v4();
let mut b = MockBackend::with_records([(
"X".into(),
id,
json!({"a": 1, "b": 2}),
)]);
let out = b
.update("X", id, map_of(&[("a", json!(10))]), vec!["b".into()])
.unwrap();
assert_eq!(out.changed, 2);
let rec = b.load_record("X", id).unwrap();
assert_eq!(rec.get("a"), Some(&json!(10)));
assert!(rec.get("b").is_none());
}
#[test]
fn delete_then_load_returns_none() {
let id = Uuid::new_v4();
let mut b = MockBackend::with_records([(
"X".into(),
id,
json!({"k": 1}),
)]);
b.delete("X", id).unwrap();
assert!(b.load_record("X", id).is_none());
}
#[test]
fn morphism_without_handler_errors_clearly() {
let mut b = MockBackend::new();
let err = b
.morphism("mod", "foo", BTreeMap::new(), json!({}))
.unwrap_err();
assert!(err.contains("foo"));
}
#[test]
fn with_morphism_lets_caller_simulate_logic() {
let mut b = MockBackend::new().with_morphism(
"double_qty",
|inputs, params| {
assert!(inputs.is_empty());
let qty = params.get("qty").and_then(|v| v.as_i64()).unwrap_or(0);
if qty <= 0 {
return Err("qty must be positive".into());
}
Ok(qty as usize)
},
);
let out = b
.morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 7}))
.unwrap();
assert_eq!(out.changed, 7);
assert!(out.id.is_none(), "morphism no devuelve id por convención");
let err = b
.morphism("mod", "double_qty", BTreeMap::new(), json!({"qty": 0}))
.unwrap_err();
assert!(err.contains("positive"));
}
#[test]
fn list_records_orders_lexicographically() {
let id_a = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
let id_b = Uuid::parse_str("ffffffff-0000-0000-0000-000000000000").unwrap();
let b = MockBackend::with_records([
("X".into(), id_b, json!({"n": 2})),
("X".into(), id_a, json!({"n": 1})),
]);
let rows = b.list_records("X");
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].0, id_a, "menor uuid primero (orden lex)");
}
#[test]
fn records_for_returns_borrowed_view() {
let id = Uuid::new_v4();
let b = MockBackend::with_records([(
"X".into(),
id,
json!({"k": 1}),
)]);
let view = b.records_for("X");
assert_eq!(view.len(), 1);
assert_eq!(view[0].0, id);
assert_eq!(view[0].1.get("k"), Some(&json!(1)));
}
}