⎇ 72758e75ce515f355f27615671c95ecaf8c2a8bc
crates/modules/ui_engine/libs/meta-runtime/src/delta.rs
Historia
Quién tocó qué
crudo
refactor(yahweh): Fase 2 — extraer helpers puros a yahweh-meta-runtime
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.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
54 }
55
56 #[test]
57 fn delta_empty_when_all_fields_match() {
58 let current = json!({"name": "Acme", "saldo": 100_i64, "currency": "USD"});
59 let proposed = map(&[
60 ("name", json!("Acme")),
61 ("saldo", json!(100_i64)),
62 ("currency", json!("USD")),
63 ]);
64 assert!(compute_field_delta(¤t, &proposed).is_empty());
65 }
66
67 #[test]
68 fn delta_includes_only_changed_field() {
69 let current = json!({"name": "Acme", "saldo": 100_i64});
70 let proposed = map(&[("name", json!("Acme")), ("saldo", json!(200_i64))]);
71 let d = compute_field_delta(¤t, &proposed);
72 assert_eq!(d.len(), 1);
73 assert_eq!(d.get("saldo"), Some(&json!(200_i64)));
74 }
75
76 #[test]
77 fn delta_treats_missing_record_as_all_new() {
78 let current = Value::Null;
79 let proposed = map(&[("name", json!("Acme")), ("saldo", json!(0_i64))]);
80 assert_eq!(compute_field_delta(¤t, &proposed).len(), 2);
81 }
82
83 #[test]
84 fn delta_distinguishes_int_from_string_repr() {
85 let current = json!({"qty": 100_i64});
86 let proposed = map(&[("qty", json!(100_i64))]);
87 assert!(compute_field_delta(¤t, &proposed).is_empty());
88
89 let current_str = json!({"qty": "100"});
90 let proposed_int = map(&[("qty", json!(100_i64))]);
91 assert_eq!(compute_field_delta(¤t_str, &proposed_int).len(), 1);
92 }
93
94 #[test]
95 fn delta_skips_fields_absent_from_proposed() {
96 let current = json!({"name": "Acme", "saldo": 100_i64, "extra": "x"});
97 let proposed = map(&[("name", json!("Acme")), ("saldo", json!(150_i64))]);
98 let d = compute_field_delta(¤t, &proposed);
99 assert_eq!(d.len(), 1);
100 assert!(!d.contains_key("extra"));
101 }
102
103 #[test]
104 fn clear_fields_skips_absent_and_null() {
105 let current = json!({"name": "Acme", "notes": "lorem", "tag": null});
106 let to_clear = vec![
107 "name".into(),
108 "notes".into(),
109 "tag".into(),
110 "missing".into(),
111 ];
112 assert_eq!(
113 compute_clear_fields(¤t, &to_clear),
114 vec!["name".to_string(), "notes".to_string()]
115 );
116 }
117
118 #[test]
119 fn clear_fields_preserves_input_order() {
120 let current = json!({"a": 1, "b": 2, "c": 3});
121 let to_clear = vec!["c".into(), "a".into(), "b".into()];
122 assert_eq!(
123 compute_clear_fields(¤t, &to_clear),
124 vec!["c", "a", "b"]
125 );
126 }
127
128 #[test]
129 fn clear_fields_empty_when_current_is_null() {
130 let current = Value::Null;
131 let to_clear = vec!["name".into()];
132 assert!(compute_clear_fields(¤t, &to_clear).is_empty());
133 }
134}