Skip to main content

canvas_core/
a2ui.rs

1//! A2UI (Agent-to-User Interface) component tree parser.
2//!
3//! This module implements parsing for A2UI format, which is Google's specification
4//! for AI agent visual output. It provides a standardized way for AI models to
5//! describe UI components that get rendered to users.
6//!
7//! ## A2UI Component Types
8//!
9//! | Component   | Saorsa Element       | Description                     |
10//! |-------------|----------------------|---------------------------------|
11//! | Container   | Group                | Layout container for children   |
12//! | Text        | Text                 | Text label or paragraph         |
13//! | Image       | Image                | Static image                    |
14//! | Button      | Text (interactive)   | Clickable button with action    |
15//! | Chart       | Chart                | Data visualization              |
16//! | `VideoFeed` | Video                | Live video stream               |
17//!
18//! ## Example A2UI JSON
19//!
20//! ```json
21//! {
22//!   "root": {
23//!     "component": "container",
24//!     "layout": "vertical",
25//!     "children": [
26//!       { "component": "text", "content": "Hello World" },
27//!       { "component": "chart", "chart_type": "bar", "data": {"values": [1,2,3]} }
28//!     ]
29//!   },
30//!   "data_model": {}
31//! }
32//! ```
33
34use serde::{Deserialize, Serialize};
35
36use crate::{Element, ElementKind, ImageFormat, Transform};
37
38/// A2UI component tree from AI agent output.
39///
40/// This represents the full tree that an AI agent sends to describe
41/// what should be rendered to the user.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub struct A2UITree {
44    /// Root node of the component tree.
45    pub root: A2UINode,
46    /// Optional data model for data binding.
47    #[serde(default)]
48    pub data_model: serde_json::Value,
49}
50
51/// Style properties for A2UI nodes.
52#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
53pub struct A2UIStyle {
54    /// Font size in pixels.
55    #[serde(default)]
56    pub font_size: Option<f32>,
57    /// Text/foreground color as hex string.
58    #[serde(default)]
59    pub color: Option<String>,
60    /// Background color as hex string.
61    #[serde(default)]
62    pub background: Option<String>,
63    /// Width in pixels.
64    #[serde(default)]
65    pub width: Option<f32>,
66    /// Height in pixels.
67    #[serde(default)]
68    pub height: Option<f32>,
69    /// Padding in pixels.
70    #[serde(default)]
71    pub padding: Option<f32>,
72    /// Margin in pixels.
73    #[serde(default)]
74    pub margin: Option<f32>,
75}
76
77/// A2UI component node.
78///
79/// Each variant represents a different UI component type from the A2UI spec.
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
81#[serde(tag = "component", rename_all = "snake_case")]
82pub enum A2UINode {
83    /// A layout container for grouping children.
84    Container {
85        /// Child nodes.
86        children: Vec<A2UINode>,
87        /// Layout direction: "horizontal", "vertical", or "grid".
88        #[serde(default = "default_layout")]
89        layout: String,
90        /// Optional styling.
91        #[serde(default)]
92        style: Option<A2UIStyle>,
93    },
94
95    /// A text label or paragraph.
96    Text {
97        /// Text content to display.
98        content: String,
99        /// Optional styling.
100        #[serde(default)]
101        style: Option<A2UIStyle>,
102    },
103
104    /// A static image.
105    Image {
106        /// Image source URL or base64 data URI.
107        src: String,
108        /// Alt text for accessibility.
109        #[serde(default)]
110        alt: Option<String>,
111        /// Optional styling.
112        #[serde(default)]
113        style: Option<A2UIStyle>,
114    },
115
116    /// A clickable button.
117    Button {
118        /// Button label text.
119        label: String,
120        /// Action identifier sent back on click.
121        action: String,
122        /// Optional styling.
123        #[serde(default)]
124        style: Option<A2UIStyle>,
125    },
126
127    /// A data visualization chart.
128    Chart {
129        /// Chart type: "bar", "line", "pie", "scatter".
130        chart_type: String,
131        /// Chart data as JSON.
132        data: serde_json::Value,
133        /// Optional styling.
134        #[serde(default)]
135        style: Option<A2UIStyle>,
136    },
137
138    /// A live video feed (Saorsa Canvas extension).
139    VideoFeed {
140        /// Stream identifier.
141        stream_id: String,
142        /// Whether to mirror the video.
143        #[serde(default)]
144        mirror: bool,
145        /// Optional styling.
146        #[serde(default)]
147        style: Option<A2UIStyle>,
148    },
149}
150
151fn default_layout() -> String {
152    "vertical".to_string()
153}
154
155/// Layout direction for containers.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
157pub enum Layout {
158    /// Vertical layout (children stacked top-to-bottom).
159    #[default]
160    Column,
161    /// Horizontal layout (children arranged left-to-right).
162    Row,
163    /// Grid layout with specified number of columns.
164    Grid {
165        /// Number of columns in the grid.
166        columns: u32,
167    },
168    /// Stack layout (children overlaid on top of each other).
169    Stack,
170}
171
172impl Layout {
173    /// Parse a layout string into a Layout enum.
174    ///
175    /// Supported formats:
176    /// - "vertical" or "column" -> Column
177    /// - "horizontal" or "row" -> Row
178    /// - "grid:3" or "grid-3" -> Grid { columns: 3 }
179    /// - "stack" or "overlay" -> Stack
180    #[must_use]
181    pub fn parse(s: &str) -> Self {
182        let lower = s.to_lowercase();
183        if lower == "horizontal" || lower == "row" {
184            Self::Row
185        } else if lower.starts_with("grid") {
186            // Parse "grid:3" or "grid-3" format
187            let columns = lower
188                .trim_start_matches("grid")
189                .trim_start_matches(':')
190                .trim_start_matches('-')
191                .parse()
192                .unwrap_or(2);
193            Self::Grid { columns }
194        } else if lower == "stack" || lower == "overlay" {
195            Self::Stack
196        } else {
197            // Default to column/vertical
198            Self::Column
199        }
200    }
201}
202
203/// Result of converting an A2UI tree to canvas elements.
204#[derive(Debug, Clone)]
205pub struct ConversionResult {
206    /// The converted elements.
207    pub elements: Vec<Element>,
208    /// Any warnings during conversion.
209    pub warnings: Vec<String>,
210}
211
212impl A2UITree {
213    /// Parse an A2UI tree from JSON string.
214    ///
215    /// # Errors
216    ///
217    /// Returns an error if the JSON is invalid or doesn't match the A2UI schema.
218    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
219        serde_json::from_str(json)
220    }
221
222    /// Convert this A2UI tree to Saorsa Canvas elements.
223    ///
224    /// The conversion applies automatic layout based on container layout types
225    /// and respects style properties where applicable.
226    #[must_use]
227    pub fn to_elements(&self) -> ConversionResult {
228        let mut converter = A2UIConverter::new();
229        let elements = converter.convert_node(&self.root, 0.0, 0.0);
230        ConversionResult {
231            elements,
232            warnings: converter.warnings,
233        }
234    }
235}
236
237/// Internal converter state.
238struct A2UIConverter {
239    /// Accumulated warnings.
240    warnings: Vec<String>,
241    /// Current z-index counter for layering.
242    z_index: i32,
243}
244
245impl A2UIConverter {
246    fn new() -> Self {
247        Self {
248            warnings: Vec::new(),
249            z_index: 0,
250        }
251    }
252
253    fn next_z_index(&mut self) -> i32 {
254        let z = self.z_index;
255        self.z_index += 1;
256        z
257    }
258
259    fn convert_node(&mut self, node: &A2UINode, x: f32, y: f32) -> Vec<Element> {
260        match node {
261            A2UINode::Container {
262                children,
263                layout,
264                style,
265            } => self.convert_container(children, layout, style.as_ref(), x, y),
266
267            A2UINode::Text { content, style } => {
268                vec![self.convert_text(content, style.as_ref(), x, y)]
269            }
270
271            A2UINode::Image { src, style, .. } => {
272                vec![self.convert_image(src, style.as_ref(), x, y)]
273            }
274
275            A2UINode::Button { label, style, .. } => {
276                // Buttons are rendered as interactive text
277                let mut element = self.convert_text(label, style.as_ref(), x, y);
278                element.interactive = true;
279                vec![element]
280            }
281
282            A2UINode::Chart {
283                chart_type,
284                data,
285                style,
286            } => {
287                vec![self.convert_chart(chart_type, data, style.as_ref(), x, y)]
288            }
289
290            A2UINode::VideoFeed {
291                stream_id,
292                mirror,
293                style,
294            } => {
295                vec![self.convert_video(stream_id, *mirror, style.as_ref(), x, y)]
296            }
297        }
298    }
299
300    fn convert_container(
301        &mut self,
302        children: &[A2UINode],
303        layout_str: &str,
304        style: Option<&A2UIStyle>,
305        start_x: f32,
306        start_y: f32,
307    ) -> Vec<Element> {
308        let layout = Layout::parse(layout_str);
309        let padding = style.and_then(|s| s.padding).unwrap_or(10.0);
310        let margin = style.and_then(|s| s.margin).unwrap_or(0.0);
311        let spacing = 10.0; // Default spacing between children
312
313        let base_x = start_x + padding + margin;
314        let base_y = start_y + padding + margin;
315
316        match layout {
317            Layout::Column => self.layout_column(children, base_x, base_y, spacing),
318            Layout::Row => self.layout_row(children, base_x, base_y, spacing),
319            Layout::Grid { columns } => {
320                self.layout_grid(children, base_x, base_y, spacing, columns)
321            }
322            Layout::Stack => self.layout_stack(children, base_x, base_y),
323        }
324    }
325
326    /// Layout children in a vertical column (top to bottom).
327    fn layout_column(
328        &mut self,
329        children: &[A2UINode],
330        start_x: f32,
331        start_y: f32,
332        spacing: f32,
333    ) -> Vec<Element> {
334        let mut elements = Vec::new();
335        let mut current_y = start_y;
336
337        for child in children {
338            let child_elements = self.convert_node(child, start_x, current_y);
339            let (_, child_height) = Self::calculate_bounds(&child_elements);
340            elements.extend(child_elements);
341            current_y += child_height + spacing;
342        }
343
344        elements
345    }
346
347    /// Layout children in a horizontal row (left to right).
348    fn layout_row(
349        &mut self,
350        children: &[A2UINode],
351        start_x: f32,
352        start_y: f32,
353        spacing: f32,
354    ) -> Vec<Element> {
355        let mut elements = Vec::new();
356        let mut current_x = start_x;
357
358        for child in children {
359            let child_elements = self.convert_node(child, current_x, start_y);
360            let (child_width, _) = Self::calculate_bounds(&child_elements);
361            elements.extend(child_elements);
362            current_x += child_width + spacing;
363        }
364
365        elements
366    }
367
368    /// Layout children in a grid with specified number of columns.
369    fn layout_grid(
370        &mut self,
371        children: &[A2UINode],
372        start_x: f32,
373        start_y: f32,
374        spacing: f32,
375        columns: u32,
376    ) -> Vec<Element> {
377        let mut elements = Vec::new();
378        let mut current_x = start_x;
379        let mut current_y = start_y;
380        let mut row_height: f32 = 0.0;
381        let columns = columns.max(1) as usize; // Ensure at least 1 column
382
383        for (i, child) in children.iter().enumerate() {
384            // Start new row if needed
385            if i > 0 && i % columns == 0 {
386                current_x = start_x;
387                current_y += row_height + spacing;
388                row_height = 0.0;
389            }
390
391            let child_elements = self.convert_node(child, current_x, current_y);
392            let (child_width, child_height) = Self::calculate_bounds(&child_elements);
393            row_height = row_height.max(child_height);
394            elements.extend(child_elements);
395            current_x += child_width + spacing;
396        }
397
398        elements
399    }
400
401    /// Layout children stacked on top of each other (same position, different z-index).
402    fn layout_stack(&mut self, children: &[A2UINode], start_x: f32, start_y: f32) -> Vec<Element> {
403        let mut elements = Vec::new();
404
405        for child in children {
406            let child_elements = self.convert_node(child, start_x, start_y);
407            elements.extend(child_elements);
408        }
409
410        elements
411    }
412
413    fn detect_image_format(src: &str) -> ImageFormat {
414        use std::path::Path;
415        let ext = Path::new(src)
416            .extension()
417            .and_then(|e| e.to_str())
418            .map(str::to_lowercase);
419
420        match ext.as_deref() {
421            Some("jpg" | "jpeg") => ImageFormat::Jpeg,
422            Some("svg") => ImageFormat::Svg,
423            Some("webp") => ImageFormat::WebP,
424            _ => ImageFormat::Png,
425        }
426    }
427
428    fn calculate_bounds(elements: &[Element]) -> (f32, f32) {
429        elements.iter().fold((0.0, 0.0), |(w, h), el| {
430            (w.max(el.transform.width), h.max(el.transform.height))
431        })
432    }
433
434    fn convert_text(
435        &mut self,
436        content: &str,
437        style: Option<&A2UIStyle>,
438        x: f32,
439        y: f32,
440    ) -> Element {
441        let font_size = style.and_then(|s| s.font_size).unwrap_or(16.0);
442        let color = style
443            .and_then(|s| s.color.clone())
444            .unwrap_or_else(|| "#000000".to_string());
445        let width = style.and_then(|s| s.width).unwrap_or(200.0);
446        let height = style.and_then(|s| s.height).unwrap_or(font_size * 1.5);
447
448        Element::new(ElementKind::Text {
449            content: content.to_string(),
450            font_size,
451            color,
452        })
453        .with_transform(Transform {
454            x,
455            y,
456            width,
457            height,
458            rotation: 0.0,
459            z_index: self.next_z_index(),
460        })
461    }
462
463    fn convert_image(&mut self, src: &str, style: Option<&A2UIStyle>, x: f32, y: f32) -> Element {
464        let width = style.and_then(|s| s.width).unwrap_or(200.0);
465        let height = style.and_then(|s| s.height).unwrap_or(200.0);
466
467        // Detect format from extension or default to PNG (case-insensitive)
468        let format = Self::detect_image_format(src);
469
470        Element::new(ElementKind::Image {
471            src: src.to_string(),
472            format,
473        })
474        .with_transform(Transform {
475            x,
476            y,
477            width,
478            height,
479            rotation: 0.0,
480            z_index: self.next_z_index(),
481        })
482    }
483
484    fn convert_chart(
485        &mut self,
486        chart_type: &str,
487        data: &serde_json::Value,
488        style: Option<&A2UIStyle>,
489        x: f32,
490        y: f32,
491    ) -> Element {
492        let width = style.and_then(|s| s.width).unwrap_or(400.0);
493        let height = style.and_then(|s| s.height).unwrap_or(300.0);
494
495        Element::new(ElementKind::Chart {
496            chart_type: chart_type.to_string(),
497            data: data.clone(),
498        })
499        .with_transform(Transform {
500            x,
501            y,
502            width,
503            height,
504            rotation: 0.0,
505            z_index: self.next_z_index(),
506        })
507    }
508
509    fn convert_video(
510        &mut self,
511        stream_id: &str,
512        mirror: bool,
513        style: Option<&A2UIStyle>,
514        x: f32,
515        y: f32,
516    ) -> Element {
517        let width = style.and_then(|s| s.width).unwrap_or(640.0);
518        let height = style.and_then(|s| s.height).unwrap_or(480.0);
519
520        Element::new(ElementKind::Video {
521            stream_id: stream_id.to_string(),
522            is_live: true,
523            mirror,
524            crop: None,
525            media_config: None,
526        })
527        .with_transform(Transform {
528            x,
529            y,
530            width,
531            height,
532            rotation: 0.0,
533            z_index: self.next_z_index(),
534        })
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    // ===========================================
543    // TDD: Parsing Tests
544    // ===========================================
545
546    #[test]
547    fn test_parse_simple_text_node() {
548        let json = r#"{
549            "root": {
550                "component": "text",
551                "content": "Hello World"
552            }
553        }"#;
554
555        let tree = A2UITree::from_json(json).expect("should parse");
556
557        match &tree.root {
558            A2UINode::Text { content, style } => {
559                assert_eq!(content, "Hello World");
560                assert!(style.is_none());
561            }
562            _ => panic!("Expected Text node"),
563        }
564    }
565
566    #[test]
567    fn test_parse_text_with_style() {
568        let json = r##"{
569            "root": {
570                "component": "text",
571                "content": "Styled Text",
572                "style": {
573                    "font_size": 24.0,
574                    "color": "#FF0000"
575                }
576            }
577        }"##;
578
579        let tree = A2UITree::from_json(json).expect("should parse");
580
581        match &tree.root {
582            A2UINode::Text { content, style } => {
583                assert_eq!(content, "Styled Text");
584                let s = style.as_ref().expect("should have style");
585                assert_eq!(s.font_size, Some(24.0));
586                assert_eq!(s.color.as_deref(), Some("#FF0000"));
587            }
588            _ => panic!("Expected Text node"),
589        }
590    }
591
592    #[test]
593    fn test_parse_container_with_children() {
594        let json = r#"{
595            "root": {
596                "component": "container",
597                "layout": "vertical",
598                "children": [
599                    { "component": "text", "content": "First" },
600                    { "component": "text", "content": "Second" }
601                ]
602            }
603        }"#;
604
605        let tree = A2UITree::from_json(json).expect("should parse");
606
607        match &tree.root {
608            A2UINode::Container {
609                children, layout, ..
610            } => {
611                assert_eq!(layout, "vertical");
612                assert_eq!(children.len(), 2);
613            }
614            _ => panic!("Expected Container node"),
615        }
616    }
617
618    #[test]
619    fn test_parse_horizontal_container() {
620        let json = r#"{
621            "root": {
622                "component": "container",
623                "layout": "horizontal",
624                "children": []
625            }
626        }"#;
627
628        let tree = A2UITree::from_json(json).expect("should parse");
629
630        match &tree.root {
631            A2UINode::Container { layout, .. } => {
632                assert_eq!(layout, "horizontal");
633            }
634            _ => panic!("Expected Container node"),
635        }
636    }
637
638    #[test]
639    fn test_parse_image() {
640        let json = r#"{
641            "root": {
642                "component": "image",
643                "src": "https://example.com/image.png",
644                "alt": "Example image"
645            }
646        }"#;
647
648        let tree = A2UITree::from_json(json).expect("should parse");
649
650        match &tree.root {
651            A2UINode::Image { src, alt, .. } => {
652                assert_eq!(src, "https://example.com/image.png");
653                assert_eq!(alt.as_deref(), Some("Example image"));
654            }
655            _ => panic!("Expected Image node"),
656        }
657    }
658
659    #[test]
660    fn test_parse_button() {
661        let json = r#"{
662            "root": {
663                "component": "button",
664                "label": "Click Me",
665                "action": "submit_form"
666            }
667        }"#;
668
669        let tree = A2UITree::from_json(json).expect("should parse");
670
671        match &tree.root {
672            A2UINode::Button { label, action, .. } => {
673                assert_eq!(label, "Click Me");
674                assert_eq!(action, "submit_form");
675            }
676            _ => panic!("Expected Button node"),
677        }
678    }
679
680    #[test]
681    fn test_parse_chart() {
682        let json = r#"{
683            "root": {
684                "component": "chart",
685                "chart_type": "bar",
686                "data": {
687                    "labels": ["A", "B", "C"],
688                    "values": [10, 20, 15]
689                }
690            }
691        }"#;
692
693        let tree = A2UITree::from_json(json).expect("should parse");
694
695        match &tree.root {
696            A2UINode::Chart {
697                chart_type, data, ..
698            } => {
699                assert_eq!(chart_type, "bar");
700                assert!(data.get("labels").is_some());
701                assert!(data.get("values").is_some());
702            }
703            _ => panic!("Expected Chart node"),
704        }
705    }
706
707    #[test]
708    fn test_parse_video_feed() {
709        let json = r#"{
710            "root": {
711                "component": "video_feed",
712                "stream_id": "local",
713                "mirror": true
714            }
715        }"#;
716
717        let tree = A2UITree::from_json(json).expect("should parse");
718
719        match &tree.root {
720            A2UINode::VideoFeed {
721                stream_id, mirror, ..
722            } => {
723                assert_eq!(stream_id, "local");
724                assert!(*mirror);
725            }
726            _ => panic!("Expected VideoFeed node"),
727        }
728    }
729
730    #[test]
731    fn test_parse_with_data_model() {
732        let json = r#"{
733            "root": {
734                "component": "text",
735                "content": "Data bound"
736            },
737            "data_model": {
738                "user": "Alice",
739                "count": 42
740            }
741        }"#;
742
743        let tree = A2UITree::from_json(json).expect("should parse");
744
745        assert_eq!(tree.data_model["user"], "Alice");
746        assert_eq!(tree.data_model["count"], 42);
747    }
748
749    #[test]
750    fn test_parse_invalid_json() {
751        let json = "{ invalid json }";
752        let result = A2UITree::from_json(json);
753        assert!(result.is_err());
754    }
755
756    #[test]
757    fn test_parse_missing_component_tag() {
758        let json = r#"{
759            "root": {
760                "content": "No component tag"
761            }
762        }"#;
763
764        let result = A2UITree::from_json(json);
765        assert!(result.is_err());
766    }
767
768    // ===========================================
769    // TDD: Conversion Tests
770    // ===========================================
771
772    #[test]
773    fn test_convert_text_to_element() {
774        let json = r#"{
775            "root": {
776                "component": "text",
777                "content": "Hello World"
778            }
779        }"#;
780
781        let tree = A2UITree::from_json(json).expect("should parse");
782        let result = tree.to_elements();
783
784        assert_eq!(result.elements.len(), 1);
785        assert!(result.warnings.is_empty());
786
787        match &result.elements[0].kind {
788            ElementKind::Text {
789                content, font_size, ..
790            } => {
791                assert_eq!(content, "Hello World");
792                assert!((font_size - 16.0).abs() < f32::EPSILON); // Default font size
793            }
794            _ => panic!("Expected Text element"),
795        }
796    }
797
798    #[test]
799    fn test_convert_text_with_style() {
800        let json = r##"{
801            "root": {
802                "component": "text",
803                "content": "Styled",
804                "style": {
805                    "font_size": 32.0,
806                    "color": "#00FF00"
807                }
808            }
809        }"##;
810
811        let tree = A2UITree::from_json(json).expect("should parse");
812        let result = tree.to_elements();
813
814        match &result.elements[0].kind {
815            ElementKind::Text {
816                font_size, color, ..
817            } => {
818                assert!((font_size - 32.0).abs() < f32::EPSILON);
819                assert_eq!(color, "#00FF00");
820            }
821            _ => panic!("Expected Text element"),
822        }
823    }
824
825    #[test]
826    fn test_convert_container_vertical_layout() {
827        let json = r#"{
828            "root": {
829                "component": "container",
830                "layout": "vertical",
831                "children": [
832                    { "component": "text", "content": "First" },
833                    { "component": "text", "content": "Second" }
834                ]
835            }
836        }"#;
837
838        let tree = A2UITree::from_json(json).expect("should parse");
839        let result = tree.to_elements();
840
841        assert_eq!(result.elements.len(), 2);
842
843        // In vertical layout, Y positions should increase
844        let y1 = result.elements[0].transform.y;
845        let y2 = result.elements[1].transform.y;
846        assert!(y2 > y1, "Second element should be below first");
847    }
848
849    #[test]
850    fn test_convert_container_horizontal_layout() {
851        let json = r#"{
852            "root": {
853                "component": "container",
854                "layout": "horizontal",
855                "children": [
856                    { "component": "text", "content": "Left" },
857                    { "component": "text", "content": "Right" }
858                ]
859            }
860        }"#;
861
862        let tree = A2UITree::from_json(json).expect("should parse");
863        let result = tree.to_elements();
864
865        assert_eq!(result.elements.len(), 2);
866
867        // In horizontal layout, X positions should increase
868        let x1 = result.elements[0].transform.x;
869        let x2 = result.elements[1].transform.x;
870        assert!(x2 > x1, "Second element should be right of first");
871    }
872
873    #[test]
874    fn test_convert_image() {
875        let json = r#"{
876            "root": {
877                "component": "image",
878                "src": "test.png"
879            }
880        }"#;
881
882        let tree = A2UITree::from_json(json).expect("should parse");
883        let result = tree.to_elements();
884
885        assert_eq!(result.elements.len(), 1);
886
887        match &result.elements[0].kind {
888            ElementKind::Image { src, format } => {
889                assert_eq!(src, "test.png");
890                assert_eq!(*format, ImageFormat::Png);
891            }
892            _ => panic!("Expected Image element"),
893        }
894    }
895
896    #[test]
897    fn test_convert_image_jpeg() {
898        let json = r#"{
899            "root": {
900                "component": "image",
901                "src": "photo.jpg"
902            }
903        }"#;
904
905        let tree = A2UITree::from_json(json).expect("should parse");
906        let result = tree.to_elements();
907
908        match &result.elements[0].kind {
909            ElementKind::Image { format, .. } => {
910                assert_eq!(*format, ImageFormat::Jpeg);
911            }
912            _ => panic!("Expected Image element"),
913        }
914    }
915
916    #[test]
917    fn test_convert_button_is_interactive() {
918        let json = r#"{
919            "root": {
920                "component": "button",
921                "label": "Click Me",
922                "action": "do_something"
923            }
924        }"#;
925
926        let tree = A2UITree::from_json(json).expect("should parse");
927        let result = tree.to_elements();
928
929        assert_eq!(result.elements.len(), 1);
930        assert!(
931            result.elements[0].interactive,
932            "Button should be interactive"
933        );
934    }
935
936    #[test]
937    fn test_convert_chart() {
938        let json = r#"{
939            "root": {
940                "component": "chart",
941                "chart_type": "pie",
942                "data": { "values": [25, 50, 25] }
943            }
944        }"#;
945
946        let tree = A2UITree::from_json(json).expect("should parse");
947        let result = tree.to_elements();
948
949        assert_eq!(result.elements.len(), 1);
950
951        match &result.elements[0].kind {
952            ElementKind::Chart {
953                chart_type, data, ..
954            } => {
955                assert_eq!(chart_type, "pie");
956                assert!(data.get("values").is_some());
957            }
958            _ => panic!("Expected Chart element"),
959        }
960    }
961
962    #[test]
963    fn test_convert_video_feed() {
964        let json = r#"{
965            "root": {
966                "component": "video_feed",
967                "stream_id": "camera_1",
968                "mirror": true
969            }
970        }"#;
971
972        let tree = A2UITree::from_json(json).expect("should parse");
973        let result = tree.to_elements();
974
975        assert_eq!(result.elements.len(), 1);
976
977        match &result.elements[0].kind {
978            ElementKind::Video {
979                stream_id,
980                is_live,
981                mirror,
982                ..
983            } => {
984                assert_eq!(stream_id, "camera_1");
985                assert!(*is_live);
986                assert!(*mirror);
987            }
988            _ => panic!("Expected Video element"),
989        }
990    }
991
992    #[test]
993    fn test_convert_nested_containers() {
994        let json = r#"{
995            "root": {
996                "component": "container",
997                "layout": "vertical",
998                "children": [
999                    {
1000                        "component": "container",
1001                        "layout": "horizontal",
1002                        "children": [
1003                            { "component": "text", "content": "A" },
1004                            { "component": "text", "content": "B" }
1005                        ]
1006                    },
1007                    { "component": "text", "content": "C" }
1008                ]
1009            }
1010        }"#;
1011
1012        let tree = A2UITree::from_json(json).expect("should parse");
1013        let result = tree.to_elements();
1014
1015        // Should have 3 text elements: A, B, C
1016        assert_eq!(result.elements.len(), 3);
1017    }
1018
1019    #[test]
1020    fn test_convert_z_index_ordering() {
1021        let json = r#"{
1022            "root": {
1023                "component": "container",
1024                "layout": "vertical",
1025                "children": [
1026                    { "component": "text", "content": "First" },
1027                    { "component": "text", "content": "Second" },
1028                    { "component": "text", "content": "Third" }
1029                ]
1030            }
1031        }"#;
1032
1033        let tree = A2UITree::from_json(json).expect("should parse");
1034        let result = tree.to_elements();
1035
1036        // Z-indices should be in order
1037        let z0 = result.elements[0].transform.z_index;
1038        let z1 = result.elements[1].transform.z_index;
1039        let z2 = result.elements[2].transform.z_index;
1040
1041        assert!(z1 > z0, "Second should be above first");
1042        assert!(z2 > z1, "Third should be above second");
1043    }
1044
1045    #[test]
1046    fn test_convert_style_dimensions() {
1047        let json = r#"{
1048            "root": {
1049                "component": "image",
1050                "src": "test.png",
1051                "style": {
1052                    "width": 300.0,
1053                    "height": 150.0
1054                }
1055            }
1056        }"#;
1057
1058        let tree = A2UITree::from_json(json).expect("should parse");
1059        let result = tree.to_elements();
1060
1061        let transform = &result.elements[0].transform;
1062        assert!((transform.width - 300.0).abs() < f32::EPSILON);
1063        assert!((transform.height - 150.0).abs() < f32::EPSILON);
1064    }
1065
1066    // ===========================================
1067    // TDD: Layout Tests
1068    // ===========================================
1069
1070    #[test]
1071    fn test_layout_parse() {
1072        assert_eq!(Layout::parse("vertical"), Layout::Column);
1073        assert_eq!(Layout::parse("column"), Layout::Column);
1074        assert_eq!(Layout::parse("horizontal"), Layout::Row);
1075        assert_eq!(Layout::parse("row"), Layout::Row);
1076        assert_eq!(Layout::parse("grid:3"), Layout::Grid { columns: 3 });
1077        assert_eq!(Layout::parse("grid-2"), Layout::Grid { columns: 2 });
1078        assert_eq!(Layout::parse("stack"), Layout::Stack);
1079        assert_eq!(Layout::parse("overlay"), Layout::Stack);
1080        assert_eq!(Layout::parse("unknown"), Layout::Column); // Default
1081    }
1082
1083    #[test]
1084    fn test_convert_grid_layout() {
1085        let json = r#"{
1086            "root": {
1087                "component": "container",
1088                "layout": "grid:2",
1089                "children": [
1090                    { "component": "text", "content": "A" },
1091                    { "component": "text", "content": "B" },
1092                    { "component": "text", "content": "C" },
1093                    { "component": "text", "content": "D" }
1094                ]
1095            }
1096        }"#;
1097
1098        let tree = A2UITree::from_json(json).expect("should parse");
1099        let result = tree.to_elements();
1100
1101        assert_eq!(result.elements.len(), 4);
1102
1103        // In a 2-column grid:
1104        // Row 1: A (x=10, y=10), B (x > 10, y=10)
1105        // Row 2: C (x=10, y > 10), D (x > 10, y > 10)
1106        let a = &result.elements[0].transform;
1107        let b = &result.elements[1].transform;
1108        let c = &result.elements[2].transform;
1109        let d = &result.elements[3].transform;
1110
1111        // A and B same row (same y)
1112        assert!(
1113            (a.y - b.y).abs() < f32::EPSILON,
1114            "A and B should be on same row"
1115        );
1116        // B is to the right of A
1117        assert!(b.x > a.x, "B should be right of A");
1118        // C is below A (new row)
1119        assert!(c.y > a.y, "C should be below A (new row)");
1120        // C and D same row
1121        assert!(
1122            (c.y - d.y).abs() < f32::EPSILON,
1123            "C and D should be on same row"
1124        );
1125    }
1126
1127    #[test]
1128    fn test_convert_stack_layout() {
1129        let json = r#"{
1130            "root": {
1131                "component": "container",
1132                "layout": "stack",
1133                "children": [
1134                    { "component": "text", "content": "Background" },
1135                    { "component": "text", "content": "Foreground" }
1136                ]
1137            }
1138        }"#;
1139
1140        let tree = A2UITree::from_json(json).expect("should parse");
1141        let result = tree.to_elements();
1142
1143        assert_eq!(result.elements.len(), 2);
1144
1145        let bg = &result.elements[0].transform;
1146        let fg = &result.elements[1].transform;
1147
1148        // Stack layout: same position, different z-index
1149        assert!((bg.x - fg.x).abs() < f32::EPSILON, "X should be same");
1150        assert!((bg.y - fg.y).abs() < f32::EPSILON, "Y should be same");
1151        assert!(
1152            fg.z_index > bg.z_index,
1153            "Foreground should have higher z-index"
1154        );
1155    }
1156
1157    #[test]
1158    fn test_layout_with_margin() {
1159        let json = r#"{
1160            "root": {
1161                "component": "container",
1162                "layout": "vertical",
1163                "style": {
1164                    "margin": 20.0,
1165                    "padding": 5.0
1166                },
1167                "children": [
1168                    { "component": "text", "content": "Test" }
1169                ]
1170            }
1171        }"#;
1172
1173        let tree = A2UITree::from_json(json).expect("should parse");
1174        let result = tree.to_elements();
1175
1176        // Position should account for margin + padding
1177        let text = &result.elements[0].transform;
1178        assert!(text.x >= 25.0, "X should include margin + padding");
1179        assert!(text.y >= 25.0, "Y should include margin + padding");
1180    }
1181
1182    #[test]
1183    fn test_grid_with_varying_sizes() {
1184        let json = r#"{
1185            "root": {
1186                "component": "container",
1187                "layout": "grid:2",
1188                "children": [
1189                    { "component": "text", "content": "Small", "style": { "height": 20.0 } },
1190                    { "component": "text", "content": "Tall", "style": { "height": 50.0 } },
1191                    { "component": "text", "content": "Below" }
1192                ]
1193            }
1194        }"#;
1195
1196        let tree = A2UITree::from_json(json).expect("should parse");
1197        let result = tree.to_elements();
1198
1199        let small = &result.elements[0].transform;
1200        // Element 1 is the tallest in row 1 (height 50) - used to determine row height
1201        let below = &result.elements[2].transform;
1202
1203        // Row height determined by tallest element (50)
1204        // "Below" should be positioned after the tallest element in the previous row
1205        assert!(
1206            below.y > small.y + 50.0 - 1.0,
1207            "Third element should be below the tallest in row 1"
1208        );
1209    }
1210
1211    #[test]
1212    fn test_roundtrip_serialize_deserialize() {
1213        let original = A2UITree {
1214            root: A2UINode::Container {
1215                children: vec![
1216                    A2UINode::Text {
1217                        content: "Hello".to_string(),
1218                        style: None,
1219                    },
1220                    A2UINode::Button {
1221                        label: "Click".to_string(),
1222                        action: "submit".to_string(),
1223                        style: None,
1224                    },
1225                ],
1226                layout: "vertical".to_string(),
1227                style: None,
1228            },
1229            data_model: serde_json::json!({"key": "value"}),
1230        };
1231
1232        let json = serde_json::to_string(&original).expect("should serialize");
1233        let parsed: A2UITree = serde_json::from_str(&json).expect("should deserialize");
1234
1235        assert_eq!(original, parsed);
1236    }
1237
1238    // ===========================================
1239    // Edge Case Tests
1240    // ===========================================
1241
1242    #[test]
1243    fn test_empty_container() {
1244        let json = r#"{
1245            "root": {
1246                "component": "container",
1247                "layout": "vertical",
1248                "children": []
1249            }
1250        }"#;
1251
1252        let tree = A2UITree::from_json(json).expect("should parse empty container");
1253
1254        match &tree.root {
1255            A2UINode::Container { children, .. } => {
1256                assert!(children.is_empty());
1257            }
1258            _ => panic!("Expected Container node"),
1259        }
1260
1261        // Should produce no elements when converted
1262        let result = tree.to_elements();
1263        assert!(result.elements.is_empty());
1264    }
1265
1266    #[test]
1267    fn test_deeply_nested_containers() {
1268        // Create a 10-level deep nesting
1269        let json = r#"{
1270            "root": {
1271                "component": "container",
1272                "layout": "vertical",
1273                "children": [{
1274                    "component": "container",
1275                    "layout": "horizontal",
1276                    "children": [{
1277                        "component": "container",
1278                        "layout": "vertical",
1279                        "children": [{
1280                            "component": "container",
1281                            "layout": "horizontal",
1282                            "children": [{
1283                                "component": "container",
1284                                "layout": "vertical",
1285                                "children": [{
1286                                    "component": "text",
1287                                    "content": "Deep"
1288                                }]
1289                            }]
1290                        }]
1291                    }]
1292                }]
1293            }
1294        }"#;
1295
1296        let tree = A2UITree::from_json(json).expect("should parse deep nesting");
1297
1298        // Verify we can traverse to the deepest text using iterative depth count
1299        let depth = {
1300            let mut max_depth = 0;
1301            let mut stack: Vec<(&A2UINode, usize)> = vec![(&tree.root, 1)];
1302            while let Some((node, d)) = stack.pop() {
1303                max_depth = max_depth.max(d);
1304                if let A2UINode::Container { children, .. } = node {
1305                    for child in children {
1306                        stack.push((child, d + 1));
1307                    }
1308                }
1309            }
1310            max_depth
1311        };
1312        assert_eq!(depth, 6); // 5 containers + 1 text
1313
1314        // Should convert successfully
1315        let result = tree.to_elements();
1316        assert_eq!(result.elements.len(), 1); // Just the text element
1317    }
1318
1319    #[test]
1320    fn test_container_with_mixed_children() {
1321        let json = r#"{
1322            "root": {
1323                "component": "container",
1324                "layout": "vertical",
1325                "children": [
1326                    { "component": "text", "content": "Title" },
1327                    { "component": "button", "label": "Action", "action": "click" },
1328                    { "component": "container", "layout": "horizontal", "children": [
1329                        { "component": "text", "content": "Nested" }
1330                    ]},
1331                    { "component": "image", "src": "test.png", "alt": "Test" }
1332                ]
1333            }
1334        }"#;
1335
1336        let tree = A2UITree::from_json(json).expect("should parse mixed children");
1337
1338        match &tree.root {
1339            A2UINode::Container { children, .. } => {
1340                assert_eq!(children.len(), 4);
1341            }
1342            _ => panic!("Expected Container node"),
1343        }
1344
1345        let result = tree.to_elements();
1346        // Text, Button as Text, nested Text, Image = 4 elements
1347        assert_eq!(result.elements.len(), 4);
1348    }
1349
1350    #[test]
1351    fn test_invalid_json_returns_error() {
1352        let invalid_json = r"{ not valid json }";
1353        let result = A2UITree::from_json(invalid_json);
1354        assert!(result.is_err());
1355    }
1356
1357    #[test]
1358    fn test_missing_required_fields_returns_error() {
1359        // Text without content
1360        let json = r#"{
1361            "root": {
1362                "component": "text"
1363            }
1364        }"#;
1365
1366        let result = A2UITree::from_json(json);
1367        assert!(result.is_err());
1368    }
1369
1370    #[test]
1371    fn test_unknown_component_type() {
1372        // Unknown component type should fail parsing
1373        let json = r#"{
1374            "root": {
1375                "component": "unknown_widget",
1376                "value": "test"
1377            }
1378        }"#;
1379
1380        let result = A2UITree::from_json(json);
1381        assert!(result.is_err());
1382    }
1383
1384    #[test]
1385    fn test_text_with_empty_content() {
1386        let json = r#"{
1387            "root": {
1388                "component": "text",
1389                "content": ""
1390            }
1391        }"#;
1392
1393        let tree = A2UITree::from_json(json).expect("should parse empty text");
1394
1395        match &tree.root {
1396            A2UINode::Text { content, .. } => {
1397                assert!(content.is_empty());
1398            }
1399            _ => panic!("Expected Text node"),
1400        }
1401    }
1402
1403    #[test]
1404    fn test_container_single_child() {
1405        let json = r#"{
1406            "root": {
1407                "component": "container",
1408                "layout": "vertical",
1409                "children": [
1410                    { "component": "text", "content": "Only child" }
1411                ]
1412            }
1413        }"#;
1414
1415        let tree = A2UITree::from_json(json).expect("should parse");
1416
1417        match &tree.root {
1418            A2UINode::Container { children, .. } => {
1419                assert_eq!(children.len(), 1);
1420            }
1421            _ => panic!("Expected Container node"),
1422        }
1423
1424        let result = tree.to_elements();
1425        assert_eq!(result.elements.len(), 1);
1426    }
1427
1428    #[test]
1429    fn test_data_model_preserved() {
1430        let json = r#"{
1431            "root": {
1432                "component": "text",
1433                "content": "Hello"
1434            },
1435            "data_model": {
1436                "user": {
1437                    "name": "Alice",
1438                    "age": 30
1439                },
1440                "settings": ["a", "b", "c"]
1441            }
1442        }"#;
1443
1444        let tree = A2UITree::from_json(json).expect("should parse");
1445
1446        // Verify data_model is preserved
1447        assert_eq!(tree.data_model["user"]["name"], "Alice");
1448        assert_eq!(tree.data_model["user"]["age"], 30);
1449        assert_eq!(tree.data_model["settings"].as_array().unwrap().len(), 3);
1450    }
1451
1452    #[test]
1453    fn test_all_style_properties() {
1454        let json = r##"{
1455            "root": {
1456                "component": "text",
1457                "content": "Styled",
1458                "style": {
1459                    "font_size": 18.5,
1460                    "color": "#123ABC",
1461                    "background": "#FFFFFF",
1462                    "padding": 10.0,
1463                    "margin": 5.0,
1464                    "width": 200.0,
1465                    "height": 50.0
1466                }
1467            }
1468        }"##;
1469
1470        let tree = A2UITree::from_json(json).expect("should parse");
1471
1472        match &tree.root {
1473            A2UINode::Text { style, .. } => {
1474                let s = style.as_ref().expect("should have style");
1475                assert!((s.font_size.unwrap() - 18.5).abs() < 0.001);
1476                assert_eq!(s.color.as_deref(), Some("#123ABC"));
1477                assert_eq!(s.background.as_deref(), Some("#FFFFFF"));
1478                assert!((s.padding.unwrap() - 10.0).abs() < 0.001);
1479                assert!((s.margin.unwrap() - 5.0).abs() < 0.001);
1480                assert!((s.width.unwrap() - 200.0).abs() < 0.001);
1481                assert!((s.height.unwrap() - 50.0).abs() < 0.001);
1482            }
1483            _ => panic!("Expected Text node"),
1484        }
1485    }
1486}