1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
5pub struct SnapshotElement {
6 #[serde(rename = "ref")]
7 pub ref_id: String,
8 pub role: String,
9 pub depth: u64,
10 #[serde(skip_serializing_if = "Option::is_none")]
11 pub name: Option<String>,
12 #[serde(skip_serializing_if = "Option::is_none")]
13 pub value: Option<String>,
14 #[serde(skip_serializing_if = "Option::is_none")]
15 pub checked: Option<bool>,
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub disabled: Option<bool>,
18}
19
20#[derive(Debug, Clone, Serialize)]
21pub struct ChangedEntry {
22 pub old: SnapshotElement,
23 pub new: SnapshotElement,
24 pub changes: Vec<String>,
25}
26
27#[derive(Debug, Clone, Serialize)]
28pub struct DiffResult {
29 pub added: Vec<SnapshotElement>,
30 pub removed: Vec<SnapshotElement>,
31 pub changed: Vec<ChangedEntry>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Hash)]
37struct ElementKey {
38 role: String,
39 name: Option<String>,
40 depth: u64,
41}
42
43impl ElementKey {
44 fn from(el: &SnapshotElement) -> Self {
45 Self { role: el.role.clone(), name: el.name.clone(), depth: el.depth }
46 }
47}
48
49#[must_use]
54pub fn compute_diff(old: &[SnapshotElement], new: &[SnapshotElement]) -> DiffResult {
55 let mut old_groups: HashMap<ElementKey, Vec<usize>> = HashMap::new();
56 for (i, el) in old.iter().enumerate() {
57 old_groups.entry(ElementKey::from(el)).or_default().push(i);
58 }
59
60 let mut new_groups: HashMap<ElementKey, Vec<usize>> = HashMap::new();
61 for (i, el) in new.iter().enumerate() {
62 new_groups.entry(ElementKey::from(el)).or_default().push(i);
63 }
64
65 let mut matched_old: Vec<bool> = vec![false; old.len()];
66 let mut matched_new: Vec<bool> = vec![false; new.len()];
67 let mut changed: Vec<ChangedEntry> = Vec::new();
68
69 for (key, old_indices) in &old_groups {
71 if let Some(new_indices) = new_groups.get(key) {
72 let pair_count = old_indices.len().min(new_indices.len());
73 for i in 0..pair_count {
74 let old_idx = old_indices[i];
75 let new_idx = new_indices[i];
76 matched_old[old_idx] = true;
77 matched_new[new_idx] = true;
78
79 let old_el = &old[old_idx];
80 let new_el = &new[new_idx];
81 let mut field_changes: Vec<String> = Vec::new();
82
83 if old_el.value != new_el.value {
84 field_changes.push("value".to_owned());
85 }
86 if old_el.checked != new_el.checked {
87 field_changes.push("checked".to_owned());
88 }
89 if old_el.disabled != new_el.disabled {
90 field_changes.push("disabled".to_owned());
91 }
92
93 if !field_changes.is_empty() {
94 changed.push(ChangedEntry { old: old_el.clone(), new: new_el.clone(), changes: field_changes });
95 }
96 }
97 }
98 }
99
100 let mut removed: Vec<SnapshotElement> =
101 old.iter().enumerate().filter(|(i, _)| !matched_old[*i]).map(|(_, el)| el.clone()).collect();
102
103 let mut added: Vec<SnapshotElement> =
104 new.iter().enumerate().filter(|(i, _)| !matched_new[*i]).map(|(_, el)| el.clone()).collect();
105
106 let sort_key = |a: &SnapshotElement, b: &SnapshotElement| {
108 a.depth.cmp(&b.depth).then_with(|| a.role.cmp(&b.role)).then_with(|| a.name.cmp(&b.name))
109 };
110 added.sort_by(sort_key);
111 removed.sort_by(sort_key);
112 changed.sort_by(|a, b| sort_key(&a.new, &b.new));
113
114 DiffResult { added, removed, changed }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 fn el(ref_id: &str, role: &str, depth: u64) -> SnapshotElement {
122 SnapshotElement {
123 ref_id: ref_id.to_owned(),
124 role: role.to_owned(),
125 depth,
126 name: None,
127 value: None,
128 checked: None,
129 disabled: None,
130 }
131 }
132
133 fn el_named(ref_id: &str, role: &str, depth: u64, name: &str) -> SnapshotElement {
134 SnapshotElement {
135 ref_id: ref_id.to_owned(),
136 role: role.to_owned(),
137 depth,
138 name: Some(name.to_owned()),
139 value: None,
140 checked: None,
141 disabled: None,
142 }
143 }
144
145 #[test]
146 fn test_snapshot_element_deserializes_string_value() {
147 let json = r#"{"ref":"e31","role":"listitem","depth":3,"name":"single hyphen value","value":"0"}"#;
150 let el: SnapshotElement = serde_json::from_str(json).expect("string value should deserialize");
151 assert_eq!(el.value, Some("0".to_owned()));
152 }
153
154 #[test]
155 fn test_diff_identical_snapshots() {
156 let snapshot = vec![el("e1", "button", 1), el("e2", "input", 2)];
157 let result = compute_diff(&snapshot, &snapshot);
158 assert!(result.added.is_empty());
159 assert!(result.removed.is_empty());
160 assert!(result.changed.is_empty());
161 }
162
163 #[test]
164 fn test_diff_added_elements() {
165 let old = vec![el("e1", "button", 1)];
166 let new = vec![el("e1", "button", 1), el("e2", "input", 2)];
167 let result = compute_diff(&old, &new);
168 assert_eq!(result.added.len(), 1);
169 assert_eq!(result.added[0].role, "input");
170 assert!(result.removed.is_empty());
171 assert!(result.changed.is_empty());
172 }
173
174 #[test]
175 fn test_diff_removed_elements() {
176 let old = vec![el("e1", "button", 1), el("e2", "input", 2)];
177 let new = vec![el("e1", "button", 1)];
178 let result = compute_diff(&old, &new);
179 assert!(result.added.is_empty());
180 assert_eq!(result.removed.len(), 1);
181 assert_eq!(result.removed[0].role, "input");
182 assert!(result.changed.is_empty());
183 }
184
185 #[test]
186 fn test_diff_changed_value() {
187 let old = vec![SnapshotElement {
188 ref_id: "e1".to_owned(),
189 role: "input".to_owned(),
190 depth: 1,
191 name: Some("username".to_owned()),
192 value: Some("old".to_owned()),
193 checked: None,
194 disabled: None,
195 }];
196 let new = vec![SnapshotElement {
197 ref_id: "e2".to_owned(),
198 role: "input".to_owned(),
199 depth: 1,
200 name: Some("username".to_owned()),
201 value: Some("new".to_owned()),
202 checked: None,
203 disabled: None,
204 }];
205 let result = compute_diff(&old, &new);
206 assert!(result.added.is_empty());
207 assert!(result.removed.is_empty());
208 assert_eq!(result.changed.len(), 1);
209 assert_eq!(result.changed[0].changes, vec!["value"]);
210 }
211
212 #[test]
213 fn test_diff_changed_multiple_fields() {
214 let old = vec![SnapshotElement {
215 ref_id: "e1".to_owned(),
216 role: "checkbox".to_owned(),
217 depth: 2,
218 name: Some("agree".to_owned()),
219 value: Some("on".to_owned()),
220 checked: Some(false),
221 disabled: Some(false),
222 }];
223 let new = vec![SnapshotElement {
224 ref_id: "e2".to_owned(),
225 role: "checkbox".to_owned(),
226 depth: 2,
227 name: Some("agree".to_owned()),
228 value: Some("off".to_owned()),
229 checked: Some(true),
230 disabled: Some(true),
231 }];
232 let result = compute_diff(&old, &new);
233 assert!(result.added.is_empty());
234 assert!(result.removed.is_empty());
235 assert_eq!(result.changed.len(), 1);
236 assert!(result.changed[0].changes.contains(&"value".to_owned()));
237 assert!(result.changed[0].changes.contains(&"checked".to_owned()));
238 assert!(result.changed[0].changes.contains(&"disabled".to_owned()));
239 }
240
241 #[test]
242 fn test_diff_mixed_changes() {
243 let old = vec![
244 el_named("e1", "button", 1, "submit"),
245 SnapshotElement {
246 ref_id: "e2".to_owned(),
247 role: "input".to_owned(),
248 depth: 2,
249 name: Some("email".to_owned()),
250 value: Some("old@example.com".to_owned()),
251 checked: None,
252 disabled: None,
253 },
254 el_named("e3", "link", 3, "home"),
255 ];
256 let new = vec![
257 el_named("e1", "button", 1, "submit"),
258 SnapshotElement {
259 ref_id: "e4".to_owned(),
260 role: "input".to_owned(),
261 depth: 2,
262 name: Some("email".to_owned()),
263 value: Some("new@example.com".to_owned()),
264 checked: None,
265 disabled: None,
266 },
267 el_named("e5", "paragraph", 4, "info"),
268 ];
269 let result = compute_diff(&old, &new);
270 assert_eq!(result.added.len(), 1);
271 assert_eq!(result.added[0].role, "paragraph");
272 assert_eq!(result.removed.len(), 1);
273 assert_eq!(result.removed[0].role, "link");
274 assert_eq!(result.changed.len(), 1);
275 assert_eq!(result.changed[0].changes, vec!["value"]);
276 }
277
278 #[test]
279 fn test_diff_empty_old() {
280 let old: Vec<SnapshotElement> = vec![];
281 let new = vec![el("e1", "button", 1), el("e2", "input", 2)];
282 let result = compute_diff(&old, &new);
283 assert_eq!(result.added.len(), 2);
284 assert!(result.removed.is_empty());
285 assert!(result.changed.is_empty());
286 }
287
288 #[test]
289 fn test_diff_duplicate_roles() {
290 let old = vec![
292 SnapshotElement {
293 ref_id: "e1".to_owned(),
294 role: "button".to_owned(),
295 depth: 1,
296 name: None,
297 value: Some("save".to_owned()),
298 checked: None,
299 disabled: None,
300 },
301 SnapshotElement {
302 ref_id: "e2".to_owned(),
303 role: "button".to_owned(),
304 depth: 1,
305 name: None,
306 value: Some("cancel".to_owned()),
307 checked: None,
308 disabled: None,
309 },
310 ];
311 let new = vec![
312 SnapshotElement {
313 ref_id: "e3".to_owned(),
314 role: "button".to_owned(),
315 depth: 1,
316 name: None,
317 value: Some("save".to_owned()),
318 checked: None,
319 disabled: None,
320 },
321 SnapshotElement {
322 ref_id: "e4".to_owned(),
323 role: "button".to_owned(),
324 depth: 1,
325 name: None,
326 value: Some("cancel".to_owned()),
327 checked: None,
328 disabled: None,
329 },
330 ];
331 let result = compute_diff(&old, &new);
332 assert!(result.added.is_empty());
333 assert!(result.removed.is_empty());
334 assert!(result.changed.is_empty());
335 }
336}