Skip to main content

tauri_plugin_widgets/
snapshot.rs

1//! Canonical layout dump for core WidgetConfig snapshot tests.
2
3use crate::models::{WidgetConfig, WidgetElement, VStackElement, HStackElement, ZStackElement, GridElement, ContainerElement, TextElement, ImageElement, ProgressElement, ButtonElement, DividerElement, SpacerElement, LinkElement, ShapeElement};
4use serde_json::{json, Value};
5
6/// Core elements with a strict cross-platform snapshot contract.
7pub const CORE_ELEMENTS: &[&str] = &[
8    "vstack",
9    "hstack",
10    "zstack",
11    "container",
12    "grid",
13    "text",
14    "image",
15    "spacer",
16    "divider",
17    "progress",
18    "button",
19    "link",
20    "shape",
21];
22
23/// Extended / best-effort elements (platform-dependent).
24pub const EXTENDED_ELEMENTS: &[&str] = &[
25    "gauge", "toggle", "date", "chart", "list", "timer", "canvas", "label",
26];
27
28pub fn is_core_element(ty: &str) -> bool {
29    CORE_ELEMENTS.contains(&ty)
30}
31
32/// Dump a config to a stable JSON tree (nulls omitted, keys sorted via serde_json Map order
33/// of insertion — we insert in a fixed order).
34pub fn dump_config(config: &WidgetConfig) -> Value {
35    let mut obj = serde_json::Map::new();
36    obj.insert("version".into(), json!(config.version));
37    if let Some(el) = &config.small {
38        obj.insert("small".into(), dump_element(el, ParentAxis::Vertical));
39    }
40    if let Some(el) = &config.medium {
41        obj.insert("medium".into(), dump_element(el, ParentAxis::Vertical));
42    }
43    if let Some(el) = &config.large {
44        obj.insert("large".into(), dump_element(el, ParentAxis::Vertical));
45    }
46    Value::Object(obj)
47}
48
49#[derive(Clone, Copy)]
50enum ParentAxis {
51    Vertical,
52    Horizontal,
53    Overlay,
54}
55
56fn dump_element(el: &WidgetElement, parent: ParentAxis) -> Value {
57    let ty = el.type_name();
58    let mut obj = serde_json::Map::new();
59    obj.insert("type".into(), json!(ty));
60    obj.insert(
61        "tier".into(),
62        json!(if is_core_element(ty) {
63            "core"
64        } else {
65            "extended"
66        }),
67    );
68
69    match el {
70        WidgetElement::VStack(VStackElement {
71            children,
72            spacing,
73            alignment,
74            style,
75        }) => {
76            put_opt_f64(&mut obj, "spacing", *spacing);
77            if let Some(a) = alignment {
78                obj.insert("alignment".into(), json!(format!("{a:?}").to_lowercase()));
79            }
80            dump_style(&mut obj, style);
81            obj.insert(
82                "children".into(),
83                Value::Array(
84                    children
85                        .iter()
86                        .map(|c| dump_element(c, ParentAxis::Vertical))
87                        .collect(),
88                ),
89            );
90        }
91        WidgetElement::HStack(HStackElement {
92            children,
93            spacing,
94            alignment,
95            style,
96        }) => {
97            put_opt_f64(&mut obj, "spacing", *spacing);
98            if let Some(a) = alignment {
99                obj.insert("alignment".into(), json!(format!("{a:?}").to_lowercase()));
100            }
101            dump_style(&mut obj, style);
102            obj.insert(
103                "children".into(),
104                Value::Array(
105                    children
106                        .iter()
107                        .map(|c| dump_element(c, ParentAxis::Horizontal))
108                        .collect(),
109                ),
110            );
111        }
112        WidgetElement::ZStack(ZStackElement {
113            children,
114            alignment,
115            style,
116        }) => {
117            if let Some(a) = alignment {
118                obj.insert("alignment".into(), json!(a));
119            } else {
120                obj.insert("alignment".into(), json!("center"));
121            }
122            dump_style(&mut obj, style);
123            obj.insert(
124                "children".into(),
125                Value::Array(
126                    children
127                        .iter()
128                        .map(|c| dump_element(c, ParentAxis::Overlay))
129                        .collect(),
130                ),
131            );
132        }
133        WidgetElement::Grid(GridElement {
134            children,
135            columns,
136            spacing,
137            row_spacing,
138            style,
139        }) => {
140            obj.insert("columns".into(), json!(columns));
141            put_opt_f64(&mut obj, "spacing", *spacing);
142            put_opt_f64(&mut obj, "rowSpacing", *row_spacing);
143            dump_style(&mut obj, style);
144            obj.insert(
145                "children".into(),
146                Value::Array(
147                    children
148                        .iter()
149                        .map(|c| dump_element(c, ParentAxis::Vertical))
150                        .collect(),
151                ),
152            );
153        }
154        WidgetElement::Container(ContainerElement {
155            children,
156            content_alignment,
157            style,
158        }) => {
159            if let Some(a) = content_alignment {
160                obj.insert("contentAlignment".into(), json!(a));
161            }
162            dump_style(&mut obj, style);
163            obj.insert(
164                "children".into(),
165                Value::Array(
166                    children
167                        .iter()
168                        .map(|c| dump_element(c, ParentAxis::Overlay))
169                        .collect(),
170                ),
171            );
172        }
173        WidgetElement::Text(TextElement {
174            content,
175            font_size,
176            font_weight,
177            text_style,
178            color,
179            alignment,
180            line_limit,
181            style,
182            ..
183        }) => {
184            obj.insert("content".into(), json!(content));
185            put_opt_f64(&mut obj, "fontSize", *font_size);
186            if let Some(w) = font_weight {
187                obj.insert("fontWeight".into(), json!(format!("{w:?}").to_lowercase()));
188            }
189            if let Some(ts) = text_style {
190                obj.insert("textStyle".into(), json!(format!("{ts:?}")));
191            }
192            if let Some(c) = color {
193                obj.insert("color".into(), color_json(c));
194            }
195            if let Some(a) = alignment {
196                obj.insert("alignment".into(), json!(format!("{a:?}").to_lowercase()));
197            }
198            if let Some(n) = line_limit {
199                obj.insert("lineLimit".into(), json!(n));
200            }
201            dump_style(&mut obj, style);
202        }
203        WidgetElement::Image(ImageElement {
204            system_name,
205            url,
206            size,
207            color,
208            content_mode,
209            style,
210            ..
211        }) => {
212            if let Some(s) = system_name {
213                obj.insert("systemName".into(), json!(s));
214            }
215            if let Some(u) = url {
216                obj.insert("url".into(), json!(u));
217            }
218            put_opt_f64(&mut obj, "size", *size);
219            if let Some(c) = color {
220                obj.insert("color".into(), color_json(c));
221            }
222            if let Some(m) = content_mode {
223                obj.insert("contentMode".into(), json!(format!("{m:?}").to_lowercase()));
224            }
225            dump_style(&mut obj, style);
226        }
227        WidgetElement::Spacer(SpacerElement { min_length }) => {
228            put_opt_f64(&mut obj, "minLength", *min_length);
229            obj.insert("flex".into(), json!(true));
230        }
231        WidgetElement::Divider(DividerElement {
232            color,
233            thickness,
234            style,
235        }) => {
236            let axis = match parent {
237                ParentAxis::Horizontal => "vertical",
238                _ => "horizontal",
239            };
240            obj.insert("axis".into(), json!(axis));
241            put_opt_f64(&mut obj, "thickness", *thickness);
242            if let Some(c) = color {
243                obj.insert("color".into(), color_json(c));
244            }
245            dump_style(&mut obj, style);
246        }
247        WidgetElement::Progress(ProgressElement {
248            value,
249            total,
250            label,
251            tint,
252            bar_style,
253            style,
254            ..
255        }) => {
256            obj.insert("value".into(), json!(value));
257            obj.insert("total".into(), json!(total));
258            if let Some(l) = label {
259                obj.insert("label".into(), json!(l));
260            }
261            if let Some(t) = tint {
262                obj.insert("tint".into(), color_json(t));
263            }
264            if let Some(s) = bar_style {
265                obj.insert("barStyle".into(), json!(format!("{s:?}").to_lowercase()));
266            }
267            dump_style(&mut obj, style);
268        }
269        WidgetElement::Button(ButtonElement {
270            label,
271            url,
272            action,
273            color,
274            background_color,
275            font_size,
276            style,
277            ..
278        }) => {
279            obj.insert("label".into(), json!(label));
280            if let Some(u) = url {
281                obj.insert("url".into(), json!(u));
282            }
283            if let Some(a) = action {
284                obj.insert("action".into(), json!(a));
285            }
286            if let Some(c) = color {
287                obj.insert("color".into(), color_json(c));
288            }
289            if let Some(c) = background_color {
290                obj.insert("backgroundColor".into(), color_json(c));
291            }
292            put_opt_f64(&mut obj, "fontSize", *font_size);
293            dump_style(&mut obj, style);
294        }
295        WidgetElement::Link(LinkElement {
296            children,
297            url,
298            action,
299            style,
300        }) => {
301            if let Some(u) = url {
302                obj.insert("url".into(), json!(u));
303            }
304            if let Some(a) = action {
305                obj.insert("action".into(), json!(a));
306            }
307            dump_style(&mut obj, style);
308            obj.insert(
309                "children".into(),
310                Value::Array(children.iter().map(|c| dump_element(c, parent)).collect()),
311            );
312        }
313        WidgetElement::Shape(ShapeElement {
314            shape_type,
315            fill,
316            stroke,
317            stroke_width,
318            size,
319            style,
320        }) => {
321            obj.insert(
322                "shapeType".into(),
323                json!(format!("{shape_type:?}").to_lowercase()),
324            );
325            // Capsule contract: width = 2*size, height = size when size set.
326            if let Some(s) = size {
327                obj.insert("size".into(), json!(s));
328                if matches!(shape_type, crate::models::ShapeType::Capsule) {
329                    obj.insert("width".into(), json!(s * 2.0));
330                    obj.insert("height".into(), json!(s));
331                } else {
332                    obj.insert("width".into(), json!(s));
333                    obj.insert("height".into(), json!(s));
334                }
335            }
336            if let Some(f) = fill {
337                obj.insert("fill".into(), color_json(f));
338            }
339            if let Some(s) = stroke {
340                obj.insert("stroke".into(), color_json(s));
341            }
342            put_opt_f64(&mut obj, "strokeWidth", *stroke_width);
343            dump_style(&mut obj, style);
344        }
345        // Extended — minimal dump
346        other => {
347            obj.insert("note".into(), json!("extended best-effort"));
348            let _ = other;
349        }
350    }
351
352    Value::Object(obj)
353}
354
355fn put_opt_f64(obj: &mut serde_json::Map<String, Value>, key: &str, v: Option<f64>) {
356    if let Some(n) = v {
357        obj.insert(key.into(), json!(n));
358    }
359}
360
361fn color_json(c: &crate::models::ColorValue) -> Value {
362    match c {
363        crate::models::ColorValue::Solid(s) => json!(s),
364        crate::models::ColorValue::Adaptive { light, dark } => {
365            json!({ "light": light, "dark": dark })
366        }
367    }
368}
369
370fn dump_style(obj: &mut serde_json::Map<String, Value>, style: &crate::models::ElementStyle) {
371    if let Some(p) = &style.padding {
372        obj.insert(
373            "padding".into(),
374            serde_json::to_value(p).unwrap_or(Value::Null),
375        );
376    }
377    if let Some(b) = &style.background {
378        obj.insert(
379            "background".into(),
380            serde_json::to_value(b).unwrap_or(Value::Null),
381        );
382    }
383    put_opt_f64(obj, "cornerRadius", style.corner_radius);
384    put_opt_f64(obj, "opacity", style.opacity);
385    put_opt_f64(obj, "flex", style.flex);
386    if let Some(f) = &style.frame {
387        obj.insert(
388            "frame".into(),
389            serde_json::to_value(f).unwrap_or(Value::Null),
390        );
391    }
392    if let Some(b) = &style.border {
393        obj.insert(
394            "border".into(),
395            serde_json::to_value(b).unwrap_or(Value::Null),
396        );
397    }
398    if let Some(s) = &style.shadow {
399        obj.insert(
400            "shadow".into(),
401            serde_json::to_value(s).unwrap_or(Value::Null),
402        );
403    }
404    if let Some(c) = &style.clip_shape {
405        obj.insert(
406            "clipShape".into(),
407            serde_json::to_value(c).unwrap_or(Value::Null),
408        );
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use std::fs;
416    use std::path::PathBuf;
417
418    fn snap_dir() -> PathBuf {
419        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots/core")
420    }
421
422    fn fixture_dir() -> PathBuf {
423        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/core")
424    }
425
426    #[test]
427    fn core_and_extended_partition_element_types() {
428        use crate::capabilities::ELEMENT_TYPES;
429        let mut all: Vec<&str> = CORE_ELEMENTS
430            .iter()
431            .chain(EXTENDED_ELEMENTS.iter())
432            .copied()
433            .collect();
434        all.sort();
435        let mut expected: Vec<&str> = ELEMENT_TYPES.to_vec();
436        expected.sort();
437        assert_eq!(all, expected);
438    }
439
440    #[test]
441    fn core_layout_snapshot() {
442        let path = fixture_dir().join("layout.json");
443        if !path.exists() {
444            fs::create_dir_all(path.parent().unwrap()).unwrap();
445            let sample = r##"{
446  "version": 1,
447  "small": {
448    "type": "vstack",
449    "spacing": 8,
450    "padding": 12,
451    "background": "#1a1a2e",
452    "children": [
453      { "type": "text", "content": "Hello", "fontSize": 18, "fontWeight": "bold", "color": "#fff", "alignment": "center" },
454      { "type": "hstack", "spacing": 4, "children": [
455        { "type": "shape", "shapeType": "capsule", "size": 8, "fill": "#4CAF50" },
456        { "type": "divider" },
457        { "type": "spacer" },
458        { "type": "progress", "value": 0.5, "tint": "#4CAF50", "label": "OK" }
459      ]},
460      { "type": "button", "label": "Go", "action": "go", "backgroundColor": "#2196F3", "color": "#fff" }
461    ]
462  }
463}"##;
464            fs::write(&path, sample).unwrap();
465        }
466        let cfg: WidgetConfig = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
467        let dump = dump_config(&cfg);
468        let pretty = serde_json::to_string_pretty(&dump).unwrap() + "\n";
469
470        let snap = snap_dir().join("layout.snap.json");
471        fs::create_dir_all(snap.parent().unwrap()).unwrap();
472        if !snap.exists() {
473            fs::write(&snap, &pretty).unwrap();
474        }
475        let expected = fs::read_to_string(&snap).unwrap();
476        assert_eq!(pretty, expected, "core layout snapshot drifted");
477
478        // Divider inside hstack must be vertical axis in dump.
479        let hstack = &dump["small"]["children"][1];
480        assert_eq!(hstack["type"], "hstack");
481        assert_eq!(hstack["children"][1]["type"], "divider");
482        assert_eq!(hstack["children"][1]["axis"], "vertical");
483        // Capsule size contract
484        assert_eq!(hstack["children"][0]["width"], 16.0);
485        assert_eq!(hstack["children"][0]["height"], 8.0);
486    }
487}