Skip to main content

dotzuki_renderer/layout_engine/
deserialize.rs

1//! JSON deserialization for screen layout definitions.
2//!
3//! Provides [`load_layout`] (from file) and [`parse_layout`] (from string)
4//! entry points that deserialise JSON into [`ScreenLayout`] and run basic
5//! validation.
6
7use crate::layout_engine::types::{RenderError, ScreenLayout};
8use std::path::Path;
9
10// ── Public API ──────────────────────────────────────────────────────────────
11
12/// Load a layout by name from the game's data directory.
13///
14/// Tries to load from `data/ui_layouts/{name}.json` relative to the current
15/// working directory (a generic fallback; callers that know their own data
16/// root pass a full path instead).
17///
18/// # Errors
19///
20/// Returns [`RenderError::InvalidLayout`] if the file cannot be read or
21/// the JSON is malformed.
22pub fn load_layout(name: &str) -> Result<ScreenLayout, RenderError> {
23    let candidate_paths = [format!("data/ui_layouts/{}.json", name)];
24
25    for path in &candidate_paths {
26        if Path::new(path).exists() {
27            let json = std::fs::read_to_string(path).map_err(|e| {
28                log::warn!("Failed to read layout file '{}': {}", path, e);
29                RenderError::InvalidLayout
30            })?;
31            return parse_layout(&json);
32        }
33    }
34
35    log::warn!(
36        "Layout file not found for '{}' (tried: {:?})",
37        name,
38        candidate_paths
39    );
40    Err(RenderError::InvalidLayout)
41}
42
43/// Parse a layout from a JSON string.
44///
45/// This is the primary entry point for testing and for programmatic layout
46/// creation. After deserialisation the layout is validated for common
47/// issues (zero-sized elements, unknown types, etc.).
48///
49/// # Errors
50///
51/// Returns [`RenderError::InvalidLayout`] if the JSON cannot be parsed
52/// into a valid [`ScreenLayout`].
53pub fn parse_layout(json: &str) -> Result<ScreenLayout, RenderError> {
54    let layout: ScreenLayout = serde_json::from_str(json).map_err(|e| {
55        log::warn!("Failed to parse layout JSON: {}", e);
56        RenderError::InvalidLayout
57    })?;
58
59    validate_layout(&layout);
60    Ok(layout)
61}
62
63// ── Validation ─────────────────────────────────────────────────────────────
64
65/// Run checks on a deserialised layout and log warnings for recoverable
66/// issues.
67fn validate_layout(layout: &ScreenLayout) {
68    for element in &layout.elements {
69        let id = if element.id.is_empty() {
70            format!("<type:{}>", element.element_type)
71        } else {
72            element.id.clone()
73        };
74
75        // Warn on zero-sized elements (tw=0 or th=0 means nothing renders)
76        if element.rect.tw == Some(0) {
77            log::warn!(
78                "Element '{}' has tw=0 — nothing will be drawn horizontally",
79                id
80            );
81        }
82        if element.rect.th == Some(0) {
83            log::warn!(
84                "Element '{}' has th=0 — nothing will be drawn vertically",
85                id
86            );
87        }
88
89        // Warn on unknown element types so the user knows the renderer
90        // will skip them.
91        match element.element_type.as_str() {
92            "group" | "border" | "text" | "tile" | "divider" | "image"
93            | "list" | "flex_list" => {}
94            t if t.starts_with("custom:") => {}
95            _ => {
96                log::warn!(
97                    "Unknown element type '{}' in element '{}' — will be skipped at render time",
98                    element.element_type,
99                    id,
100                );
101            }
102        }
103    }
104}
105
106// ── Tests ──────────────────────────────────────────────────────────────────
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    // ── test_parse_basic_layout ───────────────────────────────────────
113
114    #[test]
115    fn test_parse_basic_layout() {
116        let json = r##"{
117            "schema_version": 1,
118            "screen": "test",
119            "theme": { "bg_color": "#FFFFFF", "default_font": "default" },
120            "elements": [
121                {
122                    "type": "text",
123                    "rect": { "tx": 0, "ty": 0, "tw": 10, "th": 2 },
124                    "value": "Hello World",
125                    "color": "black"
126                },
127                {
128                    "type": "border",
129                    "rect": { "tx": 0, "ty": 0, "tw": 10, "th": 5 },
130                    "style": "Single"
131                },
132                {
133                    "type": "tile",
134                    "rect": { "tx": 5, "ty": 5, "tw": 1, "th": 1 },
135                    "tile_id": 42
136                }
137            ]
138        }"##;
139
140        let layout = parse_layout(json).expect("should parse");
141        assert_eq!(layout.schema_version, 1);
142        assert_eq!(layout.screen, "test");
143        assert_eq!(layout.elements.len(), 3);
144
145        // First element — text
146        let e0 = &layout.elements[0];
147        assert_eq!(e0.element_type, "text");
148        assert_eq!(e0.rect.tx.as_literal(), Some(0));
149        assert_eq!(e0.rect.ty.as_literal(), Some(0));
150
151        // Second element — border
152        let e1 = &layout.elements[1];
153        assert_eq!(e1.element_type, "border");
154        assert_eq!(e1.rect.tw, Some(10));
155
156        // Third element — tile
157        let e2 = &layout.elements[2];
158        assert_eq!(e2.element_type, "tile");
159        assert_eq!(e2.rect.tx.as_literal(), Some(5));
160    }
161
162    // ── test_parse_empty_elements ─────────────────────────────────────
163
164    #[test]
165    fn test_parse_empty_elements() {
166        let json = r##"{
167            "schema_version": 1,
168            "screen": "empty",
169            "theme": { "bg_color": "#000000", "default_font": "default" },
170            "elements": []
171        }"##;
172
173        let layout = parse_layout(json).expect("should parse even with zero elements");
174        assert_eq!(layout.screen, "empty");
175        assert!(layout.elements.is_empty());
176    }
177
178    // ── test_parse_invalid_json ───────────────────────────────────────
179
180    #[test]
181    fn test_parse_invalid_json() {
182        // Missing closing brace
183        let result = parse_layout(r##"{"schema_version": 1, "screen": "bad""##);
184        assert!(result.is_err(), "invalid JSON should return error");
185        match result {
186            Err(RenderError::InvalidLayout) => {} // expected
187            other => panic!("expected InvalidLayout, got {:?}", other),
188        }
189    }
190
191    #[test]
192    fn test_parse_missing_required_field() {
193        // Missing required "screen" field — serde should fail
194        let json = r##"{
195            "schema_version": 1,
196            "theme": { "bg_color": "#FFFFFF", "default_font": "default" },
197            "elements": []
198        }"##;
199        let result = parse_layout(json);
200        assert!(result.is_err(), "missing required field should error");
201    }
202
203    #[test]
204    fn test_parse_wrong_types() {
205        // elements should be an array, not an object
206        let json = r##"{
207            "schema_version": 1,
208            "screen": "test",
209            "elements": { "not": "an array" }
210        }"##;
211        let result = parse_layout(json);
212        assert!(result.is_err(), "wrong type should error");
213    }
214
215    // ── test_parse_unknown_type ───────────────────────────────────────
216
217    #[test]
218    fn test_parse_unknown_type() {
219        // Unknown element types should parse successfully (no error)
220        // but a warning should be logged by validate_layout.
221        let json = r##"{
222            "schema_version": 1,
223            "screen": "test",
224            "theme": { "bg_color": "#FFFFFF", "default_font": "default" },
225            "elements": [
226                {
227                    "type": "foobar_unknown",
228                    "rect": { "tx": 0, "ty": 0, "tw": 5, "th": 5 },
229                    "some_param": "hello"
230                }
231            ]
232        }"##;
233
234        let layout = parse_layout(json).expect("should parse unknown types");
235        assert_eq!(layout.elements.len(), 1);
236        assert_eq!(layout.elements[0].element_type, "foobar_unknown");
237        // Validation logs a warning but does not error — the element is kept.
238    }
239
240    // ── test_parse_dex_layout ─────────────────────────────────────────
241
242    #[test]
243    fn test_parse_dex_layout() {
244        let json = r##"{
245            "schema_version": 1,
246            "screen": "dex",
247            "theme": { "bg_color": "#FFFFFF", "default_font": "default" },
248            "elements": [
249                {
250                    "id": "dex_border",
251                    "type": "border",
252                    "rect": { "tx": 0, "ty": 0, "tw": 20, "th": 18 },
253                    "style": "Single"
254                },
255                {
256                    "id": "dex_title",
257                    "type": "text",
258                    "rect": { "tx": 1, "ty": 1, "tw": 18, "th": 2 },
259                    "value": "DEX",
260                    "color": "black",
261                    "align": "Center"
262                },
263                {
264                    "id": "separator",
265                    "type": "divider",
266                    "rect": { "tx": 1, "ty": 2, "tw": 18, "th": 1 },
267                    "tiles": [122],
268                    "repeat": 18
269                },
270                {
271                    "id": "dex_image",
272                    "type": "image",
273                    "rect": { "tx": 1, "ty": 3, "tw": 7, "th": 7 },
274                    "source": "{monster_sprite}"
275                },
276                {
277                    "id": "dex_num",
278                    "type": "text",
279                    "rect": { "tx": 9, "ty": 3, "tw": 10, "th": 1 },
280                    "value": "\u2116\u2022{dex_num:03}",
281                    "color": "black"
282                },
283                {
284                    "id": "dex_name",
285                    "type": "text",
286                    "rect": { "tx": 9, "ty": 4, "tw": 10, "th": 1 },
287                    "value": "{name}",
288                    "color": "black"
289                },
290                {
291                    "id": "dex_height",
292                    "type": "text",
293                    "rect": { "tx": 9, "ty": 5, "tw": 10, "th": 1 },
294                    "value": "HT {feet}\u2032{inches:02}\u2033",
295                    "color": "black"
296                },
297                {
298                    "id": "dex_weight",
299                    "type": "text",
300                    "rect": { "tx": 9, "ty": 6, "tw": 10, "th": 1 },
301                    "value": "WT {weight%10}.{weight%10} lb",
302                    "color": "black"
303                },
304                {
305                    "id": "dex_desc",
306                    "type": "text",
307                    "rect": { "tx": 1, "ty": 11, "tw": 18, "th": 6 },
308                    "value": "{description}",
309                    "color": "black",
310                    "wrap": true
311                }
312            ]
313        }"##;
314
315        let layout = parse_layout(json).expect("dex layout should parse");
316        assert_eq!(layout.screen, "dex");
317        assert_eq!(layout.elements.len(), 9);
318
319        // Verify key elements by id
320        let ids: Vec<&str> = layout.elements.iter().map(|e| e.id.as_str()).collect();
321        assert!(ids.contains(&"dex_border"));
322        assert!(ids.contains(&"dex_title"));
323        assert!(ids.contains(&"dex_image"));
324        assert!(ids.contains(&"dex_name"));
325        assert!(ids.contains(&"dex_desc"));
326    }
327
328    // ── test_load_layout_not_found ────────────────────────────────────
329
330    #[test]
331    fn test_load_layout_not_found() {
332        let result = load_layout("__nonexistent_layout_xyzzy__");
333        assert!(result.is_err(), "missing file should error");
334        match result {
335            Err(RenderError::InvalidLayout) => {} // expected
336            other => panic!("expected InvalidLayout, got {:?}", other),
337        }
338    }
339
340    // ── test_parse_minimal_layout ─────────────────────────────────────
341
342    #[test]
343    fn test_parse_minimal_layout() {
344        // Schema with all defaults — theme defaults, elements empty, no ids
345        let json = r##"{
346            "schema_version": 1,
347            "screen": "minimal",
348            "elements": []
349        }"##;
350
351        let layout = parse_layout(json).expect("minimal should parse");
352        assert_eq!(layout.screen, "minimal");
353        assert_eq!(layout.elements.len(), 0);
354    }
355
356    // ── test_parse_group_layout ───────────────────────────────────────
357
358    #[test]
359    fn test_parse_group_layout() {
360        let json = r##"{
361            "schema_version": 1,
362            "screen": "grouped",
363            "theme": { "bg_color": "#FFFFFF", "default_font": "default" },
364            "elements": [
365                {
366                    "id": "container",
367                    "type": "group",
368                    "rect": { "tx": 2, "ty": 2, "tw": 16, "th": 14 },
369                    "layout": {
370                        "direction": "Vertical",
371                        "gap": 2,
372                        "padding": { "top": 1, "bottom": 1, "left": 1, "right": 1 }
373                    },
374                    "clip": true,
375                    "children": [
376                        {
377                            "type": "text",
378                            "rect": { "tx": 0, "ty": 0, "tw": 14, "th": 2 },
379                            "value": "ITEM",
380                            "color": "black"
381                        },
382                        {
383                            "type": "text",
384                            "rect": { "tx": 0, "ty": 2, "tw": 14, "th": 2 },
385                            "value": "DESCRIPTION",
386                            "color": "darkgray",
387                            "wrap": true
388                        }
389                    ]
390                }
391            ]
392        }"##;
393
394        let layout = parse_layout(json).expect("group layout should parse");
395        assert_eq!(layout.elements.len(), 1);
396        assert_eq!(layout.elements[0].element_type, "group");
397        assert_eq!(layout.elements[0].id, "container");
398
399        // Verify group params
400        if let crate::layout_engine::types::ElementParams::Group(ref gp) =
401            layout.elements[0].params
402        {
403            assert_eq!(gp.children.len(), 2);
404            assert!(gp.clip);
405            assert!(matches!(
406                gp.layout.direction,
407                Some(crate::layout_engine::types::Direction::Vertical)
408            ));
409            assert_eq!(gp.layout.gap, 2);
410        } else {
411            panic!("expected Group params");
412        }
413    }
414
415    // ── test_parse_custom_element ─────────────────────────────────────
416
417    #[test]
418    fn test_parse_custom_element() {
419        let json = r##"{
420            "schema_version": 1,
421            "screen": "custom",
422            "elements": [
423                {
424                    "type": "custom:monster_sprite",
425                    "rect": { "tx": 0, "ty": 0, "tw": 7, "th": 7 },
426                    "sprite_id": 25,
427                    "palette": "default"
428                }
429            ]
430        }"##;
431
432        let layout = parse_layout(json).expect("custom element should parse");
433        assert_eq!(layout.elements.len(), 1);
434        assert_eq!(layout.elements[0].element_type, "custom:monster_sprite");
435    }
436
437    // ── test_parse_z_index ────────────────────────────────────────────
438
439    #[test]
440    fn test_parse_z_index_default() {
441        let json = r##"{
442            "schema_version": 1,
443            "screen": "z_test",
444            "elements": [
445                { "type": "text", "rect": { "tx": 0, "ty": 0 }, "value": "bg" },
446                { "type": "text", "rect": { "tx": 0, "ty": 0 }, "value": "fg", "z_index": 10 }
447            ]
448        }"##;
449
450        let layout = parse_layout(json).expect("should parse");
451        assert_eq!(layout.elements[0].z_index, 0); // default
452        assert_eq!(layout.elements[1].z_index, 10);
453    }
454
455    // ── test_parse_visible ────────────────────────────────────────────
456
457    #[test]
458    fn test_parse_visible_default_and_explicit() {
459        let json = r##"{
460            "schema_version": 1,
461            "screen": "vis_test",
462            "elements": [
463                { "type": "text", "rect": { "tx": 0, "ty": 0 }, "value": "visible" },
464                { "type": "text", "rect": { "tx": 0, "ty": 0 }, "value": "hidden", "visible": false }
465            ]
466        }"##;
467
468        let layout = parse_layout(json).expect("should parse");
469        let ctx = crate::layout_engine::types::DataContext::new();
470        assert!(layout.elements[0].visible.eval(&ctx)); // default true
471        assert!(!layout.elements[1].visible.eval(&ctx)); // explicit false
472    }
473
474    // ── test_parse_flex_list_layout ───────────────────────────────────
475
476    #[test]
477    fn test_parse_flex_list_layout() {
478        let json = r##"{
479            "schema_version": 1,
480            "screen": "mart",
481            "elements": [
482                {
483                    "type": "flex_list",
484                    "rect": { "tx": 1, "ty": 3, "tw": 18, "th": 12 },
485                    "items": "{inventory}",
486                    "cursor": 0,
487                    "gap": 0,
488                    "padding": { "top": 0, "bottom": 0, "left": 1, "right": 0 },
489                    "item_layout": [
490                        { "field": "name", "width": 12, "align": "Left" },
491                        { "field": "price", "width": 5, "align": "Right", "prefix": "$" }
492                    ]
493                }
494            ]
495        }"##;
496
497        let layout = parse_layout(json).expect("flex_list should parse");
498        assert_eq!(layout.elements.len(), 1);
499        assert_eq!(layout.elements[0].element_type, "flex_list");
500    }
501}