gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definiciones

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

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

1//! Cálculo del delta entre el record actual y la propuesta del form.
2//!
3//! Sirve a un runtime de edición para emitir SOLO los Set/Clear que
4//! cambian algo: log + apply minimales, no-op edits = 0 entries.
5
6use serde_json::Value;
7
8/// Calcula el delta entre el record actual y los valores propuestos
9/// del form. Devuelve un Map con sólo los campos cuyo valor difiere.
10///
11/// Comparación: igualdad estructural sobre `serde_json::Value`. Un
12/// `current=Value::Null` (record no encontrado) hace que todos los
13/// campos del `proposed` sean considerados nuevos. Un campo del
14/// proposed que coincide con el del current se omite. Campos que
15/// están en current pero NO en proposed se preservan tal cual (el
16/// edit no los toca; ver [`compute_clear_fields`] para borrar
17/// explícito desde un input vacío).
18pub fn compute_field_delta(
19    current: &Value,
20    proposed: &serde_json::Map<String, Value>,
21) -> serde_json::Map<String, Value> {
22    proposed
23        .iter()
24        .filter(|(field, value)| current.get(field.as_str()) != Some(*value))
25        .map(|(k, v)| (k.clone(), v.clone()))
26        .collect()
27}
28
29/// Decide cuáles fields del `to_clear` candidate list ameritan
30/// realmente un `FieldOp::Clear`: sólo los que existen en el current
31/// con un valor non-null. Para fields ausentes o ya null, Clear es
32/// no-op semántico (el post-state es el mismo) y dropearlos
33/// preserva la propiedad "1 op = 1 cambio efectivo" del log.
34///
35/// Preserva el orden del input para que el log entry sea estable.
36pub fn compute_clear_fields(current: &Value, to_clear: &[String]) -> Vec<String> {
37    to_clear
38        .iter()
39        .filter(|f| match current.get(f.as_str()) {
40            None | Some(Value::Null) => false,
41            Some(_) => true,
42        })
43        .cloned()
44        .collect()
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50    use serde_json::json;
51
52    fn map(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
53        items
54            .iter()
55            .map(|(k, v)| (k.to_string(), v.clone()))
56            .collect()
57    }
58
59    #[test]
60    fn delta_empty_when_all_fields_match() {
61        let current = json!({"name": "Acme", "saldo": 100_i64, "currency": "USD"});
62        let proposed = map(&[
63            ("name", json!("Acme")),
64            ("saldo", json!(100_i64)),
65            ("currency", json!("USD")),
66        ]);
67        assert!(compute_field_delta(&current, &proposed).is_empty());
68    }
69
70    #[test]
71    fn delta_includes_only_changed_field() {
72        let current = json!({"name": "Acme", "saldo": 100_i64});
73        let proposed = map(&[("name", json!("Acme")), ("saldo", json!(200_i64))]);
74        let d = compute_field_delta(&current, &proposed);
75        assert_eq!(d.len(), 1);
76        assert_eq!(d.get("saldo"), Some(&json!(200_i64)));
77    }
78
79    #[test]
80    fn delta_treats_missing_record_as_all_new() {
81        let current = Value::Null;
82        let proposed = map(&[("name", json!("Acme")), ("saldo", json!(0_i64))]);
83        assert_eq!(compute_field_delta(&current, &proposed).len(), 2);
84    }
85
86    #[test]
87    fn delta_distinguishes_int_from_string_repr() {
88        let current = json!({"qty": 100_i64});
89        let proposed = map(&[("qty", json!(100_i64))]);
90        assert!(compute_field_delta(&current, &proposed).is_empty());
91
92        let current_str = json!({"qty": "100"});
93        let proposed_int = map(&[("qty", json!(100_i64))]);
94        assert_eq!(compute_field_delta(&current_str, &proposed_int).len(), 1);
95    }
96
97    #[test]
98    fn delta_skips_fields_absent_from_proposed() {
99        let current = json!({"name": "Acme", "saldo": 100_i64, "extra": "x"});
100        let proposed = map(&[("name", json!("Acme")), ("saldo", json!(150_i64))]);
101        let d = compute_field_delta(&current, &proposed);
102        assert_eq!(d.len(), 1);
103        assert!(!d.contains_key("extra"));
104    }
105
106    #[test]
107    fn clear_fields_skips_absent_and_null() {
108        let current = json!({"name": "Acme", "notes": "lorem", "tag": null});
109        let to_clear = vec![
110            "name".into(),
111            "notes".into(),
112            "tag".into(),
113            "missing".into(),
114        ];
115        assert_eq!(
116            compute_clear_fields(&current, &to_clear),
117            vec!["name".to_string(), "notes".to_string()]
118        );
119    }
120
121    #[test]
122    fn clear_fields_preserves_input_order() {
123        let current = json!({"a": 1, "b": 2, "c": 3});
124        let to_clear = vec!["c".into(), "a".into(), "b".into()];
125        assert_eq!(
126            compute_clear_fields(&current, &to_clear),
127            vec!["c", "a", "b"]
128        );
129    }
130
131    #[test]
132    fn clear_fields_empty_when_current_is_null() {
133        let current = Value::Null;
134        let to_clear = vec!["name".into()];
135        assert!(compute_clear_fields(&current, &to_clear).is_empty());
136    }
137}