⎇ 86d06da020b21905887b8e689fe014b5c3cc6e8f
crates/modules/nahual/libs/meta-runtime/src/delta.rs
Historia
Quién tocó qué
crudo
feat(nakui): Fase 1 del ERP — FieldKind Select + AutoId, seed inyecta id
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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤t_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(¤t, &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(¤t, &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(¤t, &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(¤t, &to_clear).is_empty());
136 }
137}