//! Cálculo del delta entre el record actual y la propuesta del form. //! //! Sirve a un runtime de edición para emitir SOLO los Set/Clear que //! cambian algo: log + apply minimales, no-op edits = 0 entries. use serde_json::Value; /// Calcula el delta entre el record actual y los valores propuestos /// del form. Devuelve un Map con sólo los campos cuyo valor difiere. /// /// Comparación: igualdad estructural sobre `serde_json::Value`. Un /// `current=Value::Null` (record no encontrado) hace que todos los /// campos del `proposed` sean considerados nuevos. Un campo del /// proposed que coincide con el del current se omite. Campos que /// están en current pero NO en proposed se preservan tal cual (el /// edit no los toca; ver [`compute_clear_fields`] para borrar /// explícito desde un input vacío). pub fn compute_field_delta( current: &Value, proposed: &serde_json::Map, ) -> serde_json::Map { proposed .iter() .filter(|(field, value)| current.get(field.as_str()) != Some(*value)) .map(|(k, v)| (k.clone(), v.clone())) .collect() } /// Decide cuáles fields del `to_clear` candidate list ameritan /// realmente un `FieldOp::Clear`: sólo los que existen en el current /// con un valor non-null. Para fields ausentes o ya null, Clear es /// no-op semántico (el post-state es el mismo) y dropearlos /// preserva la propiedad "1 op = 1 cambio efectivo" del log. /// /// Preserva el orden del input para que el log entry sea estable. pub fn compute_clear_fields(current: &Value, to_clear: &[String]) -> Vec { to_clear .iter() .filter(|f| match current.get(f.as_str()) { None | Some(Value::Null) => false, Some(_) => true, }) .cloned() .collect() } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn map(items: &[(&str, Value)]) -> serde_json::Map { items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect() } #[test] fn delta_empty_when_all_fields_match() { let current = json!({"name": "Acme", "saldo": 100_i64, "currency": "USD"}); let proposed = map(&[ ("name", json!("Acme")), ("saldo", json!(100_i64)), ("currency", json!("USD")), ]); assert!(compute_field_delta(¤t, &proposed).is_empty()); } #[test] fn delta_includes_only_changed_field() { let current = json!({"name": "Acme", "saldo": 100_i64}); let proposed = map(&[("name", json!("Acme")), ("saldo", json!(200_i64))]); let d = compute_field_delta(¤t, &proposed); assert_eq!(d.len(), 1); assert_eq!(d.get("saldo"), Some(&json!(200_i64))); } #[test] fn delta_treats_missing_record_as_all_new() { let current = Value::Null; let proposed = map(&[("name", json!("Acme")), ("saldo", json!(0_i64))]); assert_eq!(compute_field_delta(¤t, &proposed).len(), 2); } #[test] fn delta_distinguishes_int_from_string_repr() { let current = json!({"qty": 100_i64}); let proposed = map(&[("qty", json!(100_i64))]); assert!(compute_field_delta(¤t, &proposed).is_empty()); let current_str = json!({"qty": "100"}); let proposed_int = map(&[("qty", json!(100_i64))]); assert_eq!(compute_field_delta(¤t_str, &proposed_int).len(), 1); } #[test] fn delta_skips_fields_absent_from_proposed() { let current = json!({"name": "Acme", "saldo": 100_i64, "extra": "x"}); let proposed = map(&[("name", json!("Acme")), ("saldo", json!(150_i64))]); let d = compute_field_delta(¤t, &proposed); assert_eq!(d.len(), 1); assert!(!d.contains_key("extra")); } #[test] fn clear_fields_skips_absent_and_null() { let current = json!({"name": "Acme", "notes": "lorem", "tag": null}); let to_clear = vec![ "name".into(), "notes".into(), "tag".into(), "missing".into(), ]; assert_eq!( compute_clear_fields(¤t, &to_clear), vec!["name".to_string(), "notes".to_string()] ); } #[test] fn clear_fields_preserves_input_order() { let current = json!({"a": 1, "b": 2, "c": 3}); let to_clear = vec!["c".into(), "a".into(), "b".into()]; assert_eq!( compute_clear_fields(¤t, &to_clear), vec!["c", "a", "b"] ); } #[test] fn clear_fields_empty_when_current_is_null() { let current = Value::Null; let to_clear = vec!["name".into()]; assert!(compute_clear_fields(¤t, &to_clear).is_empty()); } }