Skip to main content

presentar_yaml/
scene.rs

1//! Presentar Scene Format (.prs) parser.
2//!
3//! This module implements the `.prs` format specification for shareable
4//! visualization manifests. The format is YAML-based and declarative,
5//! enabling WASM-native parsing without runtime interpreters.
6//!
7//! # Design Philosophy
8//!
9//! A `.prs` file is a *bill of materials* for a visualization—it declares
10//! **what** to display and **where** data lives, not **how** to fetch or render it.
11
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::fmt;
15
16/// Presentar Scene - top-level structure for `.prs` files.
17///
18/// A Scene is a declarative manifest that references external resources
19/// (models, datasets) and defines widget layout and interactions.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct Scene {
22    /// Format version (semver, e.g., "1.0")
23    pub prs_version: String,
24
25    /// Scene metadata
26    pub metadata: SceneMetadata,
27
28    /// External resources (models, datasets)
29    #[serde(default)]
30    pub resources: Resources,
31
32    /// Widget layout configuration
33    pub layout: SceneLayout,
34
35    /// Widget definitions
36    pub widgets: Vec<SceneWidget>,
37
38    /// Event → action bindings
39    #[serde(default)]
40    pub bindings: Vec<Binding>,
41
42    /// Theme configuration
43    #[serde(default)]
44    pub theme: Option<SceneTheme>,
45
46    /// Security permissions
47    #[serde(default)]
48    pub permissions: Permissions,
49}
50
51/// Scene metadata.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct SceneMetadata {
54    /// Unique scene identifier (kebab-case, e.g., "sentiment-analysis-demo")
55    pub name: String,
56
57    /// Human-readable title
58    #[serde(default)]
59    pub title: Option<String>,
60
61    /// Description
62    #[serde(default)]
63    pub description: Option<String>,
64
65    /// Author email or identifier
66    #[serde(default)]
67    pub author: Option<String>,
68
69    /// Creation timestamp (ISO 8601)
70    #[serde(default)]
71    pub created: Option<String>,
72
73    /// License identifier (e.g., "MIT", "Apache-2.0")
74    #[serde(default)]
75    pub license: Option<String>,
76
77    /// Tags for categorization
78    #[serde(default)]
79    pub tags: Vec<String>,
80}
81
82/// External resources container.
83#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84pub struct Resources {
85    /// Model resources
86    #[serde(default)]
87    pub models: HashMap<String, ModelResource>,
88
89    /// Dataset resources
90    #[serde(default)]
91    pub datasets: HashMap<String, DatasetResource>,
92}
93
94/// Model resource reference.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ModelResource {
97    /// Model format (apr, gguf, safetensors)
98    #[serde(rename = "type")]
99    pub resource_type: ModelType,
100
101    /// Source URL or path (can be array for fallback)
102    pub source: ResourceSource,
103
104    /// Content hash for verification (blake3:<hex>)
105    #[serde(default)]
106    pub hash: Option<String>,
107
108    /// File size in bytes (for progress indication)
109    #[serde(default)]
110    pub size_bytes: Option<u64>,
111}
112
113/// Dataset resource reference.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct DatasetResource {
116    /// Dataset format (ald, parquet, csv)
117    #[serde(rename = "type")]
118    pub resource_type: DatasetType,
119
120    /// Source URL or path (can be array for fallback)
121    pub source: ResourceSource,
122
123    /// Content hash for verification (blake3:<hex>)
124    #[serde(default)]
125    pub hash: Option<String>,
126
127    /// File size in bytes (for progress indication)
128    #[serde(default)]
129    pub size_bytes: Option<u64>,
130}
131
132/// Model format types.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "lowercase")]
135pub enum ModelType {
136    /// Aprender model format
137    Apr,
138    /// GGUF quantized format
139    Gguf,
140    /// SafeTensors format
141    Safetensors,
142}
143
144/// Dataset format types.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "lowercase")]
147pub enum DatasetType {
148    /// Alimentar dataset format
149    Ald,
150    /// Apache Parquet
151    Parquet,
152    /// Comma-separated values
153    Csv,
154}
155
156/// Resource source - single URL/path or array of fallbacks.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158#[serde(untagged)]
159pub enum ResourceSource {
160    /// Single source
161    Single(String),
162    /// Multiple sources (tried in order)
163    Multiple(Vec<String>),
164}
165
166impl ResourceSource {
167    /// Get all sources as a slice.
168    #[must_use]
169    pub fn sources(&self) -> Vec<&str> {
170        match self {
171            Self::Single(s) => vec![s.as_str()],
172            Self::Multiple(v) => v.iter().map(String::as_str).collect(),
173        }
174    }
175
176    /// Get primary source.
177    #[must_use]
178    pub fn primary(&self) -> &str {
179        match self {
180            Self::Single(s) => s.as_str(),
181            Self::Multiple(v) => v.first().map_or("", String::as_str),
182        }
183    }
184}
185
186/// Scene layout configuration.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct SceneLayout {
189    /// Layout type
190    #[serde(rename = "type")]
191    pub layout_type: LayoutType,
192
193    /// Number of columns (for grid layout)
194    #[serde(default)]
195    pub columns: Option<u32>,
196
197    /// Number of rows (for grid layout)
198    #[serde(default)]
199    pub rows: Option<u32>,
200
201    /// Gap between widgets in pixels
202    #[serde(default = "default_gap")]
203    pub gap: u32,
204
205    /// Flex direction (for flex layout)
206    #[serde(default)]
207    pub direction: Option<FlexDirection>,
208
209    /// Flex wrap (for flex layout)
210    #[serde(default)]
211    pub wrap: Option<bool>,
212
213    /// Canvas width (for absolute layout)
214    #[serde(default)]
215    pub width: Option<u32>,
216
217    /// Canvas height (for absolute layout)
218    #[serde(default)]
219    pub height: Option<u32>,
220}
221
222const fn default_gap() -> u32 {
223    16
224}
225
226/// Layout type enum.
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(rename_all = "lowercase")]
229pub enum LayoutType {
230    /// CSS Grid layout
231    Grid,
232    /// Flexbox layout
233    Flex,
234    /// Absolute positioning
235    Absolute,
236}
237
238/// Flex direction.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "lowercase")]
241pub enum FlexDirection {
242    /// Horizontal (left to right)
243    Row,
244    /// Vertical (top to bottom)
245    Column,
246}
247
248/// Scene widget definition.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct SceneWidget {
251    /// Unique widget identifier
252    pub id: String,
253
254    /// Widget type
255    #[serde(rename = "type")]
256    pub widget_type: WidgetType,
257
258    /// Grid position (for grid layout)
259    #[serde(default)]
260    pub position: Option<GridPosition>,
261
262    /// Widget-specific configuration
263    #[serde(default)]
264    pub config: WidgetConfig,
265}
266
267/// Widget types.
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "snake_case")]
270pub enum WidgetType {
271    /// Text input field
272    Textbox,
273    /// Numeric slider
274    Slider,
275    /// Selection dropdown
276    Dropdown,
277    /// Clickable button
278    Button,
279    /// Image display
280    Image,
281    /// Bar chart visualization
282    BarChart,
283    /// Line chart visualization
284    LineChart,
285    /// Single-value gauge
286    Gauge,
287    /// Data table
288    Table,
289    /// Markdown content
290    Markdown,
291    /// Model inference runner
292    Inference,
293}
294
295/// Grid position for widgets.
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct GridPosition {
298    /// Row index (0-based)
299    pub row: u32,
300    /// Column index (0-based)
301    pub col: u32,
302    /// Column span (defaults to 1)
303    #[serde(default = "default_span")]
304    pub colspan: u32,
305    /// Row span (defaults to 1)
306    #[serde(default = "default_span")]
307    pub rowspan: u32,
308}
309
310const fn default_span() -> u32 {
311    1
312}
313
314/// Widget configuration - varies by widget type.
315#[derive(Debug, Clone, Default, Serialize, Deserialize)]
316pub struct WidgetConfig {
317    // Common fields
318    /// Label text
319    #[serde(default)]
320    pub label: Option<String>,
321    /// Title text
322    #[serde(default)]
323    pub title: Option<String>,
324
325    // Textbox fields
326    /// Placeholder text
327    #[serde(default)]
328    pub placeholder: Option<String>,
329    /// Maximum input length
330    #[serde(default)]
331    pub max_length: Option<u32>,
332
333    // Slider fields
334    /// Minimum value
335    #[serde(default)]
336    pub min: Option<f64>,
337    /// Maximum value
338    #[serde(default)]
339    pub max: Option<f64>,
340    /// Step increment
341    #[serde(default)]
342    pub step: Option<f64>,
343    /// Default value
344    #[serde(default)]
345    pub default: Option<f64>,
346
347    // Dropdown fields
348    /// Selection options
349    #[serde(default)]
350    pub options: Option<String>,
351    /// Allow multiple selection
352    #[serde(default)]
353    pub multi_select: Option<bool>,
354
355    // Button fields
356    /// Button action
357    #[serde(default)]
358    pub action: Option<String>,
359
360    // Image fields
361    /// Image source URL/path
362    #[serde(default)]
363    pub source: Option<String>,
364    /// Alt text
365    #[serde(default)]
366    pub alt: Option<String>,
367    /// Upload mode
368    #[serde(default)]
369    pub mode: Option<String>,
370    /// Accepted MIME types
371    #[serde(default)]
372    pub accept: Option<Vec<String>>,
373
374    // Chart fields
375    /// Data source expression
376    #[serde(default)]
377    pub data: Option<String>,
378    /// X-axis field/expression
379    #[serde(default)]
380    pub x_axis: Option<String>,
381    /// Y-axis field/expression
382    #[serde(default)]
383    pub y_axis: Option<String>,
384
385    // Gauge fields
386    /// Gauge value expression
387    #[serde(default)]
388    pub value: Option<String>,
389    /// Gauge thresholds
390    #[serde(default)]
391    pub thresholds: Option<Vec<Threshold>>,
392
393    // Table fields
394    /// Column definitions
395    #[serde(default)]
396    pub columns: Option<Vec<String>>,
397    /// Sortable flag
398    #[serde(default)]
399    pub sortable: Option<bool>,
400
401    // Markdown fields
402    /// Markdown content
403    #[serde(default)]
404    pub content: Option<String>,
405
406    // Inference fields
407    /// Model reference
408    #[serde(default)]
409    pub model: Option<String>,
410    /// Input expression
411    #[serde(default)]
412    pub input: Option<String>,
413    /// Output field
414    #[serde(default)]
415    pub output: Option<String>,
416}
417
418/// Gauge threshold.
419#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct Threshold {
421    /// Threshold value
422    pub value: f64,
423    /// Color at/below threshold
424    pub color: String,
425}
426
427/// Event binding.
428#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct Binding {
430    /// Event trigger (e.g., "text_input.change")
431    pub trigger: String,
432
433    /// Debounce delay in milliseconds
434    #[serde(default)]
435    pub debounce_ms: Option<u32>,
436
437    /// Actions to execute
438    pub actions: Vec<BindingAction>,
439}
440
441/// Binding action.
442#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct BindingAction {
444    /// Target (widget ID or inference.model)
445    pub target: String,
446
447    /// Action type (refresh, set, etc.)
448    #[serde(default)]
449    pub action: Option<String>,
450
451    /// Input expression
452    #[serde(default)]
453    pub input: Option<String>,
454}
455
456/// Theme configuration.
457#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct SceneTheme {
459    /// Theme preset (light, dark)
460    #[serde(default)]
461    pub preset: Option<String>,
462
463    /// Custom theme values
464    #[serde(default)]
465    pub custom: HashMap<String, String>,
466}
467
468/// Security permissions.
469#[derive(Debug, Clone, Default, Serialize, Deserialize)]
470pub struct Permissions {
471    /// Allowed network URLs (glob patterns)
472    #[serde(default)]
473    pub network: Vec<String>,
474
475    /// Allowed filesystem paths (glob patterns)
476    #[serde(default)]
477    pub filesystem: Vec<String>,
478
479    /// Clipboard access
480    #[serde(default)]
481    pub clipboard: bool,
482
483    /// Camera access
484    #[serde(default)]
485    pub camera: bool,
486}
487
488/// Error type for scene parsing and validation.
489#[derive(Debug)]
490pub enum SceneError {
491    /// YAML parsing error
492    Yaml(serde_yaml::Error),
493
494    /// Invalid prs_version format
495    InvalidVersion(String),
496
497    /// Duplicate widget ID
498    DuplicateWidgetId(String),
499
500    /// Invalid binding target (references non-existent widget)
501    InvalidBindingTarget {
502        /// The binding trigger
503        trigger: String,
504        /// The invalid target
505        target: String,
506    },
507
508    /// Invalid hash format
509    InvalidHashFormat {
510        /// Resource name
511        resource: String,
512        /// The invalid hash
513        hash: String,
514    },
515
516    /// Missing required hash for remote resource
517    MissingRemoteHash {
518        /// Resource name
519        resource: String,
520    },
521
522    /// Invalid expression syntax
523    InvalidExpression {
524        /// Widget ID or context
525        context: String,
526        /// The invalid expression
527        expression: String,
528        /// Error message
529        message: String,
530    },
531
532    /// Invalid metadata name (must be kebab-case)
533    InvalidMetadataName(String),
534
535    /// Layout validation error
536    LayoutError(String),
537}
538
539impl fmt::Display for SceneError {
540    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
541        match self {
542            Self::Yaml(e) => write!(f, "YAML error: {e}"),
543            Self::InvalidVersion(v) => write!(f, "Invalid prs_version: {v}"),
544            Self::DuplicateWidgetId(id) => write!(f, "Duplicate widget id: {id}"),
545            Self::InvalidBindingTarget { trigger, target } => {
546                write!(
547                    f,
548                    "Invalid binding target '{target}' in trigger '{trigger}'"
549                )
550            }
551            Self::InvalidHashFormat { resource, hash } => {
552                write!(f, "Invalid hash format for '{resource}': {hash}")
553            }
554            Self::MissingRemoteHash { resource } => {
555                write!(f, "Missing hash for remote resource: {resource}")
556            }
557            Self::InvalidExpression {
558                context,
559                expression,
560                message,
561            } => {
562                write!(
563                    f,
564                    "Invalid expression in {context}: '{expression}' - {message}"
565                )
566            }
567            Self::InvalidMetadataName(name) => {
568                write!(f, "Invalid metadata name '{name}': must be kebab-case")
569            }
570            Self::LayoutError(msg) => write!(f, "Layout error: {msg}"),
571        }
572    }
573}
574
575impl std::error::Error for SceneError {
576    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
577        match self {
578            Self::Yaml(e) => Some(e),
579            _ => None,
580        }
581    }
582}
583
584impl From<serde_yaml::Error> for SceneError {
585    fn from(e: serde_yaml::Error) -> Self {
586        Self::Yaml(e)
587    }
588}
589
590impl Scene {
591    /// Parse a scene from YAML string.
592    ///
593    /// # Errors
594    ///
595    /// Returns an error if the YAML is invalid or fails validation.
596    pub fn from_yaml(yaml: &str) -> Result<Self, SceneError> {
597        let scene: Self = serde_yaml::from_str(yaml)?;
598        scene.validate()?;
599        Ok(scene)
600    }
601
602    /// Serialize scene to YAML string.
603    ///
604    /// # Errors
605    ///
606    /// Returns an error if serialization fails.
607    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
608        serde_yaml::to_string(self)
609    }
610
611    /// Validate the scene structure.
612    ///
613    /// Checks:
614    /// 1. prs_version format (semver)
615    /// 2. metadata.name is kebab-case
616    /// 3. Widget IDs are unique
617    /// 4. Binding targets reference valid widgets/resources
618    /// 5. Remote resources have hashes
619    /// 6. Hash formats are valid (blake3:<hex>)
620    ///
621    /// # Errors
622    ///
623    /// Returns the first validation error found.
624    pub fn validate(&self) -> Result<(), SceneError> {
625        self.validate_version()?;
626        self.validate_metadata_name()?;
627        self.validate_widget_ids()?;
628        self.validate_bindings()?;
629        self.validate_resource_hashes()?;
630        self.validate_layout()?;
631        Ok(())
632    }
633
634    fn validate_version(&self) -> Result<(), SceneError> {
635        // Version should be "X.Y" format
636        let parts: Vec<&str> = self.prs_version.split('.').collect();
637        if parts.len() != 2 {
638            return Err(SceneError::InvalidVersion(self.prs_version.clone()));
639        }
640        for part in parts {
641            if part.parse::<u32>().is_err() {
642                return Err(SceneError::InvalidVersion(self.prs_version.clone()));
643            }
644        }
645        Ok(())
646    }
647
648    fn validate_metadata_name(&self) -> Result<(), SceneError> {
649        let name = &self.metadata.name;
650        // Must be kebab-case: lowercase letters, numbers, hyphens
651        if !name
652            .chars()
653            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
654        {
655            return Err(SceneError::InvalidMetadataName(name.clone()));
656        }
657        // Cannot start or end with hyphen
658        if name.starts_with('-') || name.ends_with('-') {
659            return Err(SceneError::InvalidMetadataName(name.clone()));
660        }
661        // Cannot have consecutive hyphens
662        if name.contains("--") {
663            return Err(SceneError::InvalidMetadataName(name.clone()));
664        }
665        Ok(())
666    }
667
668    fn validate_widget_ids(&self) -> Result<(), SceneError> {
669        let mut seen = std::collections::HashSet::new();
670        for widget in &self.widgets {
671            if !seen.insert(&widget.id) {
672                return Err(SceneError::DuplicateWidgetId(widget.id.clone()));
673            }
674        }
675        Ok(())
676    }
677
678    fn validate_bindings(&self) -> Result<(), SceneError> {
679        let widget_ids: std::collections::HashSet<&str> =
680            self.widgets.iter().map(|w| w.id.as_str()).collect();
681        let model_ids: std::collections::HashSet<&str> =
682            self.resources.models.keys().map(String::as_str).collect();
683
684        for binding in &self.bindings {
685            for action in &binding.actions {
686                let target = &action.target;
687
688                // Check if target is a widget ID
689                if widget_ids.contains(target.as_str()) {
690                    continue;
691                }
692
693                // Check if target is inference.<model_name>
694                if let Some(model_name) = target.strip_prefix("inference.") {
695                    if model_ids.contains(model_name) {
696                        continue;
697                    }
698                }
699
700                return Err(SceneError::InvalidBindingTarget {
701                    trigger: binding.trigger.clone(),
702                    target: target.clone(),
703                });
704            }
705        }
706        Ok(())
707    }
708
709    fn validate_resource_hashes(&self) -> Result<(), SceneError> {
710        // Validate model hashes
711        for (name, resource) in &self.resources.models {
712            if is_remote_source(&resource.source) && resource.hash.is_none() {
713                return Err(SceneError::MissingRemoteHash {
714                    resource: name.clone(),
715                });
716            }
717            if let Some(hash) = &resource.hash {
718                validate_hash_format(name, hash)?;
719            }
720        }
721
722        // Validate dataset hashes
723        for (name, resource) in &self.resources.datasets {
724            if is_remote_source(&resource.source) && resource.hash.is_none() {
725                return Err(SceneError::MissingRemoteHash {
726                    resource: name.clone(),
727                });
728            }
729            if let Some(hash) = &resource.hash {
730                validate_hash_format(name, hash)?;
731            }
732        }
733
734        Ok(())
735    }
736
737    fn validate_layout(&self) -> Result<(), SceneError> {
738        match self.layout.layout_type {
739            LayoutType::Grid => {
740                if self.layout.columns.is_none() {
741                    return Err(SceneError::LayoutError(
742                        "Grid layout requires 'columns' field".to_string(),
743                    ));
744                }
745            }
746            LayoutType::Absolute => {
747                if self.layout.width.is_none() || self.layout.height.is_none() {
748                    return Err(SceneError::LayoutError(
749                        "Absolute layout requires 'width' and 'height' fields".to_string(),
750                    ));
751                }
752            }
753            LayoutType::Flex => {
754                // Flex layout has optional fields
755            }
756        }
757        Ok(())
758    }
759
760    /// Get all widget IDs.
761    #[must_use]
762    pub fn widget_ids(&self) -> Vec<&str> {
763        self.widgets.iter().map(|w| w.id.as_str()).collect()
764    }
765
766    /// Get a widget by ID.
767    #[must_use]
768    pub fn get_widget(&self, id: &str) -> Option<&SceneWidget> {
769        self.widgets.iter().find(|w| w.id == id)
770    }
771
772    /// Get a model resource by name.
773    #[must_use]
774    pub fn get_model(&self, name: &str) -> Option<&ModelResource> {
775        self.resources.models.get(name)
776    }
777
778    /// Get a dataset resource by name.
779    #[must_use]
780    pub fn get_dataset(&self, name: &str) -> Option<&DatasetResource> {
781        self.resources.datasets.get(name)
782    }
783}
784
785/// Check if a resource source is remote (https://).
786fn is_remote_source(source: &ResourceSource) -> bool {
787    source.sources().iter().any(|s| s.starts_with("https://"))
788}
789
790/// Validate hash format (blake3:<64-hex-chars>).
791fn validate_hash_format(resource: &str, hash: &str) -> Result<(), SceneError> {
792    if let Some(hex) = hash.strip_prefix("blake3:") {
793        // BLAKE3 produces 256-bit (32-byte) hashes = 64 hex characters
794        if hex.len() >= 12 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
795            return Ok(());
796        }
797    }
798    Err(SceneError::InvalidHashFormat {
799        resource: resource.to_string(),
800        hash: hash.to_string(),
801    })
802}
803
804#[cfg(test)]
805mod tests {
806    use super::*;
807    use std::error::Error;
808
809    // =========================================================================
810    // Basic Parsing Tests
811    // =========================================================================
812
813    const MINIMAL_SCENE: &str = r##"
814prs_version: "1.0"
815
816metadata:
817  name: "hello-world"
818
819layout:
820  type: flex
821  direction: column
822
823widgets:
824  - id: greeting
825    type: markdown
826    config:
827      content: "# Hello, Presentar!"
828"##;
829
830    #[test]
831    fn test_parse_minimal_scene() {
832        let scene = Scene::from_yaml(MINIMAL_SCENE).unwrap();
833        assert_eq!(scene.prs_version, "1.0");
834        assert_eq!(scene.metadata.name, "hello-world");
835        assert_eq!(scene.widgets.len(), 1);
836        assert_eq!(scene.widgets[0].id, "greeting");
837        assert_eq!(scene.widgets[0].widget_type, WidgetType::Markdown);
838    }
839
840    #[test]
841    fn test_parse_layout_flex() {
842        let scene = Scene::from_yaml(MINIMAL_SCENE).unwrap();
843        assert_eq!(scene.layout.layout_type, LayoutType::Flex);
844        assert_eq!(scene.layout.direction, Some(FlexDirection::Column));
845    }
846
847    #[test]
848    fn test_parse_widget_config() {
849        let scene = Scene::from_yaml(MINIMAL_SCENE).unwrap();
850        let widget = &scene.widgets[0];
851        assert_eq!(
852            widget.config.content.as_deref(),
853            Some("# Hello, Presentar!")
854        );
855    }
856
857    // =========================================================================
858    // Full Scene Parsing Tests
859    // =========================================================================
860
861    const FULL_SCENE: &str = r##"
862prs_version: "1.0"
863
864metadata:
865  name: "sentiment-analysis-demo"
866  title: "Real-time Sentiment Analysis"
867  description: "Interactive sentiment classifier with confidence visualization"
868  author: "alice@example.com"
869  created: "2025-12-06T10:00:00Z"
870  license: "MIT"
871  tags: ["nlp", "sentiment", "demo"]
872
873resources:
874  models:
875    sentiment_model:
876      type: apr
877      source: "https://registry.paiml.com/models/sentiment-bert-q4.apr"
878      hash: "blake3:a1b2c3d4e5f6789012345678901234567890123456789012345678901234"
879      size_bytes: 45000000
880
881  datasets:
882    examples:
883      type: ald
884      source: "./data/sentiment-examples.ald"
885
886layout:
887  type: grid
888  columns: 2
889  rows: 2
890  gap: 16
891
892widgets:
893  - id: text_input
894    type: textbox
895    position: { row: 0, col: 0, colspan: 2 }
896    config:
897      label: "Enter text to analyze"
898      placeholder: "Type a sentence..."
899      max_length: 512
900
901  - id: sentiment_chart
902    type: bar_chart
903    position: { row: 1, col: 0 }
904    config:
905      title: "Sentiment Scores"
906      data: "{{ inference.sentiment_model | select('scores') }}"
907      x_axis: "{{ ['Positive', 'Negative', 'Neutral'] }}"
908
909  - id: confidence_gauge
910    type: gauge
911    position: { row: 1, col: 1 }
912    config:
913      value: "{{ inference.sentiment_model | select('confidence') | percentage }}"
914      min: 0
915      max: 100
916      thresholds:
917        - { value: 50, color: "red" }
918        - { value: 75, color: "yellow" }
919        - { value: 100, color: "green" }
920
921bindings:
922  - trigger: "text_input.change"
923    debounce_ms: 300
924    actions:
925      - target: inference.sentiment_model
926        input: "{{ text_input.value }}"
927      - target: sentiment_chart
928        action: refresh
929      - target: confidence_gauge
930        action: refresh
931
932theme:
933  preset: "dark"
934  custom:
935    primary_color: "#4A90D9"
936    font_family: "Inter, sans-serif"
937
938permissions:
939  network:
940    - "https://registry.paiml.com/*"
941  filesystem: []
942  clipboard: false
943"##;
944
945    #[test]
946    fn test_parse_full_scene() {
947        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
948        assert_eq!(scene.prs_version, "1.0");
949        assert_eq!(scene.metadata.name, "sentiment-analysis-demo");
950        assert_eq!(
951            scene.metadata.title,
952            Some("Real-time Sentiment Analysis".to_string())
953        );
954        assert_eq!(scene.metadata.tags.len(), 3);
955    }
956
957    #[test]
958    fn test_parse_resources() {
959        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
960        assert_eq!(scene.resources.models.len(), 1);
961        assert_eq!(scene.resources.datasets.len(), 1);
962
963        let model = scene.get_model("sentiment_model").unwrap();
964        assert_eq!(model.resource_type, ModelType::Apr);
965        assert!(model.hash.is_some());
966        assert_eq!(model.size_bytes, Some(45_000_000));
967    }
968
969    #[test]
970    fn test_parse_grid_layout() {
971        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
972        assert_eq!(scene.layout.layout_type, LayoutType::Grid);
973        assert_eq!(scene.layout.columns, Some(2));
974        assert_eq!(scene.layout.rows, Some(2));
975        assert_eq!(scene.layout.gap, 16);
976    }
977
978    #[test]
979    fn test_parse_widget_positions() {
980        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
981
982        let text_input = scene.get_widget("text_input").unwrap();
983        let pos = text_input.position.as_ref().unwrap();
984        assert_eq!(pos.row, 0);
985        assert_eq!(pos.col, 0);
986        assert_eq!(pos.colspan, 2);
987
988        let chart = scene.get_widget("sentiment_chart").unwrap();
989        let pos = chart.position.as_ref().unwrap();
990        assert_eq!(pos.row, 1);
991        assert_eq!(pos.col, 0);
992    }
993
994    #[test]
995    fn test_parse_bindings() {
996        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
997        assert_eq!(scene.bindings.len(), 1);
998
999        let binding = &scene.bindings[0];
1000        assert_eq!(binding.trigger, "text_input.change");
1001        assert_eq!(binding.debounce_ms, Some(300));
1002        assert_eq!(binding.actions.len(), 3);
1003    }
1004
1005    #[test]
1006    fn test_parse_theme() {
1007        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
1008        let theme = scene.theme.as_ref().unwrap();
1009        assert_eq!(theme.preset, Some("dark".to_string()));
1010        assert_eq!(
1011            theme.custom.get("primary_color"),
1012            Some(&"#4A90D9".to_string())
1013        );
1014    }
1015
1016    #[test]
1017    fn test_parse_permissions() {
1018        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
1019        assert_eq!(scene.permissions.network.len(), 1);
1020        assert!(scene.permissions.filesystem.is_empty());
1021        assert!(!scene.permissions.clipboard);
1022    }
1023
1024    // =========================================================================
1025    // Widget Type Tests
1026    // =========================================================================
1027
1028    #[test]
1029    fn test_widget_types() {
1030        let yaml = r#"
1031prs_version: "1.0"
1032metadata:
1033  name: "widget-test"
1034layout:
1035  type: flex
1036widgets:
1037  - id: w1
1038    type: textbox
1039  - id: w2
1040    type: slider
1041  - id: w3
1042    type: dropdown
1043  - id: w4
1044    type: button
1045  - id: w5
1046    type: image
1047  - id: w6
1048    type: bar_chart
1049  - id: w7
1050    type: line_chart
1051  - id: w8
1052    type: gauge
1053  - id: w9
1054    type: table
1055  - id: w10
1056    type: markdown
1057  - id: w11
1058    type: inference
1059"#;
1060
1061        let scene = Scene::from_yaml(yaml).unwrap();
1062        assert_eq!(scene.widgets.len(), 11);
1063        assert_eq!(scene.widgets[0].widget_type, WidgetType::Textbox);
1064        assert_eq!(scene.widgets[1].widget_type, WidgetType::Slider);
1065        assert_eq!(scene.widgets[2].widget_type, WidgetType::Dropdown);
1066        assert_eq!(scene.widgets[3].widget_type, WidgetType::Button);
1067        assert_eq!(scene.widgets[4].widget_type, WidgetType::Image);
1068        assert_eq!(scene.widgets[5].widget_type, WidgetType::BarChart);
1069        assert_eq!(scene.widgets[6].widget_type, WidgetType::LineChart);
1070        assert_eq!(scene.widgets[7].widget_type, WidgetType::Gauge);
1071        assert_eq!(scene.widgets[8].widget_type, WidgetType::Table);
1072        assert_eq!(scene.widgets[9].widget_type, WidgetType::Markdown);
1073        assert_eq!(scene.widgets[10].widget_type, WidgetType::Inference);
1074    }
1075
1076    // =========================================================================
1077    // Resource Source Tests
1078    // =========================================================================
1079
1080    #[test]
1081    fn test_resource_source_single() {
1082        let yaml = r#"
1083prs_version: "1.0"
1084metadata:
1085  name: "test"
1086layout:
1087  type: flex
1088widgets: []
1089resources:
1090  models:
1091    model:
1092      type: apr
1093      source: "./local/model.apr"
1094"#;
1095
1096        let scene = Scene::from_yaml(yaml).unwrap();
1097        let model = scene.get_model("model").unwrap();
1098        assert_eq!(model.source.primary(), "./local/model.apr");
1099        assert_eq!(model.source.sources().len(), 1);
1100    }
1101
1102    #[test]
1103    fn test_resource_source_multiple() {
1104        let yaml = r#"
1105prs_version: "1.0"
1106metadata:
1107  name: "test"
1108layout:
1109  type: flex
1110widgets: []
1111resources:
1112  models:
1113    model:
1114      type: apr
1115      source:
1116        - "./local-cache/model.apr"
1117        - "https://cdn.example.com/model.apr"
1118      hash: "blake3:a1b2c3d4e5f6789012345678901234567890123456789012345678901234"
1119"#;
1120
1121        let scene = Scene::from_yaml(yaml).unwrap();
1122        let model = scene.get_model("model").unwrap();
1123        assert_eq!(model.source.primary(), "./local-cache/model.apr");
1124        assert_eq!(model.source.sources().len(), 2);
1125    }
1126
1127    // =========================================================================
1128    // Gauge Threshold Tests
1129    // =========================================================================
1130
1131    #[test]
1132    fn test_gauge_thresholds() {
1133        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
1134        let gauge = scene.get_widget("confidence_gauge").unwrap();
1135        let thresholds = gauge.config.thresholds.as_ref().unwrap();
1136
1137        assert_eq!(thresholds.len(), 3);
1138        assert!((thresholds[0].value - 50.0).abs() < f64::EPSILON);
1139        assert_eq!(thresholds[0].color, "red");
1140        assert!((thresholds[1].value - 75.0).abs() < f64::EPSILON);
1141        assert_eq!(thresholds[1].color, "yellow");
1142    }
1143
1144    // =========================================================================
1145    // Validation Tests
1146    // =========================================================================
1147
1148    #[test]
1149    fn test_validation_invalid_version() {
1150        let yaml = r#"
1151prs_version: "invalid"
1152metadata:
1153  name: "test"
1154layout:
1155  type: flex
1156widgets: []
1157"#;
1158
1159        let result = Scene::from_yaml(yaml);
1160        assert!(result.is_err());
1161        let err = result.unwrap_err();
1162        assert!(matches!(err, SceneError::InvalidVersion(_)));
1163    }
1164
1165    #[test]
1166    fn test_validation_invalid_version_format() {
1167        let yaml = r#"
1168prs_version: "1.0.0"
1169metadata:
1170  name: "test"
1171layout:
1172  type: flex
1173widgets: []
1174"#;
1175
1176        let result = Scene::from_yaml(yaml);
1177        assert!(result.is_err());
1178        assert!(matches!(result.unwrap_err(), SceneError::InvalidVersion(_)));
1179    }
1180
1181    #[test]
1182    fn test_validation_invalid_metadata_name_uppercase() {
1183        let yaml = r#"
1184prs_version: "1.0"
1185metadata:
1186  name: "Invalid-Name"
1187layout:
1188  type: flex
1189widgets: []
1190"#;
1191
1192        let result = Scene::from_yaml(yaml);
1193        assert!(result.is_err());
1194        assert!(matches!(
1195            result.unwrap_err(),
1196            SceneError::InvalidMetadataName(_)
1197        ));
1198    }
1199
1200    #[test]
1201    fn test_validation_invalid_metadata_name_leading_hyphen() {
1202        let yaml = r#"
1203prs_version: "1.0"
1204metadata:
1205  name: "-invalid"
1206layout:
1207  type: flex
1208widgets: []
1209"#;
1210
1211        let result = Scene::from_yaml(yaml);
1212        assert!(result.is_err());
1213    }
1214
1215    #[test]
1216    fn test_validation_duplicate_widget_ids() {
1217        let yaml = r#"
1218prs_version: "1.0"
1219metadata:
1220  name: "test"
1221layout:
1222  type: flex
1223widgets:
1224  - id: same_id
1225    type: textbox
1226  - id: same_id
1227    type: button
1228"#;
1229
1230        let result = Scene::from_yaml(yaml);
1231        assert!(result.is_err());
1232        assert!(matches!(
1233            result.unwrap_err(),
1234            SceneError::DuplicateWidgetId(_)
1235        ));
1236    }
1237
1238    #[test]
1239    fn test_validation_invalid_binding_target() {
1240        let yaml = r#"
1241prs_version: "1.0"
1242metadata:
1243  name: "test"
1244layout:
1245  type: flex
1246widgets:
1247  - id: input
1248    type: textbox
1249bindings:
1250  - trigger: "input.change"
1251    actions:
1252      - target: nonexistent_widget
1253        action: refresh
1254"#;
1255
1256        let result = Scene::from_yaml(yaml);
1257        assert!(result.is_err());
1258        assert!(matches!(
1259            result.unwrap_err(),
1260            SceneError::InvalidBindingTarget { .. }
1261        ));
1262    }
1263
1264    #[test]
1265    fn test_validation_valid_binding_to_widget() {
1266        let yaml = r#"
1267prs_version: "1.0"
1268metadata:
1269  name: "test"
1270layout:
1271  type: flex
1272widgets:
1273  - id: input
1274    type: textbox
1275  - id: output
1276    type: markdown
1277bindings:
1278  - trigger: "input.change"
1279    actions:
1280      - target: output
1281        action: refresh
1282"#;
1283
1284        let result = Scene::from_yaml(yaml);
1285        assert!(result.is_ok());
1286    }
1287
1288    #[test]
1289    fn test_validation_valid_binding_to_inference() {
1290        let yaml = r#"
1291prs_version: "1.0"
1292metadata:
1293  name: "test"
1294layout:
1295  type: flex
1296widgets:
1297  - id: input
1298    type: textbox
1299resources:
1300  models:
1301    my_model:
1302      type: apr
1303      source: "./model.apr"
1304bindings:
1305  - trigger: "input.change"
1306    actions:
1307      - target: inference.my_model
1308        input: "{{ input.value }}"
1309"#;
1310
1311        let result = Scene::from_yaml(yaml);
1312        assert!(result.is_ok());
1313    }
1314
1315    #[test]
1316    fn test_validation_missing_remote_hash() {
1317        let yaml = r#"
1318prs_version: "1.0"
1319metadata:
1320  name: "test"
1321layout:
1322  type: flex
1323widgets: []
1324resources:
1325  models:
1326    model:
1327      type: apr
1328      source: "https://example.com/model.apr"
1329"#;
1330
1331        let result = Scene::from_yaml(yaml);
1332        assert!(result.is_err());
1333        assert!(matches!(
1334            result.unwrap_err(),
1335            SceneError::MissingRemoteHash { .. }
1336        ));
1337    }
1338
1339    #[test]
1340    fn test_validation_local_resource_no_hash_ok() {
1341        let yaml = r#"
1342prs_version: "1.0"
1343metadata:
1344  name: "test"
1345layout:
1346  type: flex
1347widgets: []
1348resources:
1349  models:
1350    model:
1351      type: apr
1352      source: "./local/model.apr"
1353"#;
1354
1355        let result = Scene::from_yaml(yaml);
1356        assert!(result.is_ok());
1357    }
1358
1359    #[test]
1360    fn test_validation_invalid_hash_format() {
1361        let yaml = r#"
1362prs_version: "1.0"
1363metadata:
1364  name: "test"
1365layout:
1366  type: flex
1367widgets: []
1368resources:
1369  models:
1370    model:
1371      type: apr
1372      source: "./model.apr"
1373      hash: "sha256:invalid"
1374"#;
1375
1376        let result = Scene::from_yaml(yaml);
1377        assert!(result.is_err());
1378        assert!(matches!(
1379            result.unwrap_err(),
1380            SceneError::InvalidHashFormat { .. }
1381        ));
1382    }
1383
1384    #[test]
1385    fn test_validation_grid_layout_requires_columns() {
1386        let yaml = r#"
1387prs_version: "1.0"
1388metadata:
1389  name: "test"
1390layout:
1391  type: grid
1392widgets: []
1393"#;
1394
1395        let result = Scene::from_yaml(yaml);
1396        assert!(result.is_err());
1397        assert!(matches!(result.unwrap_err(), SceneError::LayoutError(_)));
1398    }
1399
1400    #[test]
1401    fn test_validation_absolute_layout_requires_dimensions() {
1402        let yaml = r#"
1403prs_version: "1.0"
1404metadata:
1405  name: "test"
1406layout:
1407  type: absolute
1408widgets: []
1409"#;
1410
1411        let result = Scene::from_yaml(yaml);
1412        assert!(result.is_err());
1413        assert!(matches!(result.unwrap_err(), SceneError::LayoutError(_)));
1414    }
1415
1416    // =========================================================================
1417    // Serialization Tests
1418    // =========================================================================
1419
1420    #[test]
1421    fn test_roundtrip() {
1422        let scene = Scene::from_yaml(MINIMAL_SCENE).unwrap();
1423        let yaml = scene.to_yaml().unwrap();
1424        let scene2 = Scene::from_yaml(&yaml).unwrap();
1425        assert_eq!(scene.prs_version, scene2.prs_version);
1426        assert_eq!(scene.metadata.name, scene2.metadata.name);
1427        assert_eq!(scene.widgets.len(), scene2.widgets.len());
1428    }
1429
1430    #[test]
1431    fn test_roundtrip_full() {
1432        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
1433        let yaml = scene.to_yaml().unwrap();
1434        let scene2 = Scene::from_yaml(&yaml).unwrap();
1435        assert_eq!(scene.prs_version, scene2.prs_version);
1436        assert_eq!(scene.metadata.name, scene2.metadata.name);
1437        assert_eq!(scene.resources.models.len(), scene2.resources.models.len());
1438        assert_eq!(scene.widgets.len(), scene2.widgets.len());
1439        assert_eq!(scene.bindings.len(), scene2.bindings.len());
1440    }
1441
1442    // =========================================================================
1443    // Helper Method Tests
1444    // =========================================================================
1445
1446    #[test]
1447    fn test_widget_ids() {
1448        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
1449        let ids = scene.widget_ids();
1450        assert_eq!(ids.len(), 3);
1451        assert!(ids.contains(&"text_input"));
1452        assert!(ids.contains(&"sentiment_chart"));
1453        assert!(ids.contains(&"confidence_gauge"));
1454    }
1455
1456    #[test]
1457    fn test_get_widget() {
1458        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
1459        let widget = scene.get_widget("text_input");
1460        assert!(widget.is_some());
1461        assert_eq!(widget.unwrap().widget_type, WidgetType::Textbox);
1462
1463        let missing = scene.get_widget("nonexistent");
1464        assert!(missing.is_none());
1465    }
1466
1467    #[test]
1468    fn test_get_model() {
1469        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
1470        let model = scene.get_model("sentiment_model");
1471        assert!(model.is_some());
1472        assert_eq!(model.unwrap().resource_type, ModelType::Apr);
1473    }
1474
1475    #[test]
1476    fn test_get_dataset() {
1477        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
1478        let dataset = scene.get_dataset("examples");
1479        assert!(dataset.is_some());
1480        assert_eq!(dataset.unwrap().resource_type, DatasetType::Ald);
1481    }
1482
1483    // =========================================================================
1484    // Error Display Tests
1485    // =========================================================================
1486
1487    #[test]
1488    fn test_error_display_yaml() {
1489        let yaml_err: serde_yaml::Error =
1490            serde_yaml::from_str::<serde_yaml::Value>("{{").unwrap_err();
1491        let err = SceneError::Yaml(yaml_err);
1492        assert!(err.to_string().contains("YAML error"));
1493    }
1494
1495    #[test]
1496    fn test_error_display_invalid_version() {
1497        let err = SceneError::InvalidVersion("bad".to_string());
1498        assert_eq!(err.to_string(), "Invalid prs_version: bad");
1499    }
1500
1501    #[test]
1502    fn test_error_display_duplicate_id() {
1503        let err = SceneError::DuplicateWidgetId("my_id".to_string());
1504        assert_eq!(err.to_string(), "Duplicate widget id: my_id");
1505    }
1506
1507    #[test]
1508    fn test_error_display_invalid_binding() {
1509        let err = SceneError::InvalidBindingTarget {
1510            trigger: "input.change".to_string(),
1511            target: "bad_target".to_string(),
1512        };
1513        assert!(err.to_string().contains("Invalid binding target"));
1514        assert!(err.to_string().contains("bad_target"));
1515    }
1516
1517    #[test]
1518    fn test_error_display_invalid_hash() {
1519        let err = SceneError::InvalidHashFormat {
1520            resource: "model".to_string(),
1521            hash: "bad".to_string(),
1522        };
1523        assert!(err.to_string().contains("Invalid hash format"));
1524    }
1525
1526    #[test]
1527    fn test_error_display_missing_hash() {
1528        let err = SceneError::MissingRemoteHash {
1529            resource: "model".to_string(),
1530        };
1531        assert!(err.to_string().contains("Missing hash for remote resource"));
1532    }
1533
1534    #[test]
1535    fn test_error_source() {
1536        let yaml_err: serde_yaml::Error =
1537            serde_yaml::from_str::<serde_yaml::Value>("{{").unwrap_err();
1538        let err = SceneError::Yaml(yaml_err);
1539        assert!(err.source().is_some());
1540
1541        let err2 = SceneError::InvalidVersion("x".to_string());
1542        assert!(err2.source().is_none());
1543    }
1544
1545    // =========================================================================
1546    // Model Type Tests
1547    // =========================================================================
1548
1549    #[test]
1550    fn test_model_types() {
1551        let yaml = r#"
1552prs_version: "1.0"
1553metadata:
1554  name: "test"
1555layout:
1556  type: flex
1557widgets: []
1558resources:
1559  models:
1560    apr_model:
1561      type: apr
1562      source: "./model.apr"
1563    gguf_model:
1564      type: gguf
1565      source: "./model.gguf"
1566    safetensors_model:
1567      type: safetensors
1568      source: "./model.safetensors"
1569"#;
1570
1571        let scene = Scene::from_yaml(yaml).unwrap();
1572        assert_eq!(
1573            scene.get_model("apr_model").unwrap().resource_type,
1574            ModelType::Apr
1575        );
1576        assert_eq!(
1577            scene.get_model("gguf_model").unwrap().resource_type,
1578            ModelType::Gguf
1579        );
1580        assert_eq!(
1581            scene.get_model("safetensors_model").unwrap().resource_type,
1582            ModelType::Safetensors
1583        );
1584    }
1585
1586    #[test]
1587    fn test_dataset_types() {
1588        let yaml = r#"
1589prs_version: "1.0"
1590metadata:
1591  name: "test"
1592layout:
1593  type: flex
1594widgets: []
1595resources:
1596  datasets:
1597    ald_data:
1598      type: ald
1599      source: "./data.ald"
1600    parquet_data:
1601      type: parquet
1602      source: "./data.parquet"
1603    csv_data:
1604      type: csv
1605      source: "./data.csv"
1606"#;
1607
1608        let scene = Scene::from_yaml(yaml).unwrap();
1609        assert_eq!(
1610            scene.get_dataset("ald_data").unwrap().resource_type,
1611            DatasetType::Ald
1612        );
1613        assert_eq!(
1614            scene.get_dataset("parquet_data").unwrap().resource_type,
1615            DatasetType::Parquet
1616        );
1617        assert_eq!(
1618            scene.get_dataset("csv_data").unwrap().resource_type,
1619            DatasetType::Csv
1620        );
1621    }
1622
1623    // =========================================================================
1624    // Layout Type Tests
1625    // =========================================================================
1626
1627    #[test]
1628    fn test_layout_type_grid() {
1629        let yaml = r#"
1630prs_version: "1.0"
1631metadata:
1632  name: "test"
1633layout:
1634  type: grid
1635  columns: 3
1636  rows: 2
1637  gap: 8
1638widgets: []
1639"#;
1640
1641        let scene = Scene::from_yaml(yaml).unwrap();
1642        assert_eq!(scene.layout.layout_type, LayoutType::Grid);
1643        assert_eq!(scene.layout.columns, Some(3));
1644        assert_eq!(scene.layout.rows, Some(2));
1645        assert_eq!(scene.layout.gap, 8);
1646    }
1647
1648    #[test]
1649    fn test_layout_type_flex() {
1650        let yaml = r#"
1651prs_version: "1.0"
1652metadata:
1653  name: "test"
1654layout:
1655  type: flex
1656  direction: row
1657  wrap: true
1658  gap: 4
1659widgets: []
1660"#;
1661
1662        let scene = Scene::from_yaml(yaml).unwrap();
1663        assert_eq!(scene.layout.layout_type, LayoutType::Flex);
1664        assert_eq!(scene.layout.direction, Some(FlexDirection::Row));
1665        assert_eq!(scene.layout.wrap, Some(true));
1666    }
1667
1668    #[test]
1669    fn test_layout_type_absolute() {
1670        let yaml = r#"
1671prs_version: "1.0"
1672metadata:
1673  name: "test"
1674layout:
1675  type: absolute
1676  width: 1200
1677  height: 800
1678widgets: []
1679"#;
1680
1681        let scene = Scene::from_yaml(yaml).unwrap();
1682        assert_eq!(scene.layout.layout_type, LayoutType::Absolute);
1683        assert_eq!(scene.layout.width, Some(1200));
1684        assert_eq!(scene.layout.height, Some(800));
1685    }
1686
1687    // =========================================================================
1688    // Default Value Tests
1689    // =========================================================================
1690
1691    #[test]
1692    fn test_default_gap() {
1693        let yaml = r#"
1694prs_version: "1.0"
1695metadata:
1696  name: "test"
1697layout:
1698  type: flex
1699widgets: []
1700"#;
1701
1702        let scene = Scene::from_yaml(yaml).unwrap();
1703        assert_eq!(scene.layout.gap, 16); // Default value
1704    }
1705
1706    #[test]
1707    fn test_default_span() {
1708        let yaml = r#"
1709prs_version: "1.0"
1710metadata:
1711  name: "test"
1712layout:
1713  type: grid
1714  columns: 2
1715widgets:
1716  - id: widget
1717    type: textbox
1718    position: { row: 0, col: 0 }
1719"#;
1720
1721        let scene = Scene::from_yaml(yaml).unwrap();
1722        let pos = scene.widgets[0].position.as_ref().unwrap();
1723        assert_eq!(pos.colspan, 1); // Default
1724        assert_eq!(pos.rowspan, 1); // Default
1725    }
1726
1727    // =========================================================================
1728    // Image Classifier Example (from spec)
1729    // =========================================================================
1730
1731    #[test]
1732    fn test_image_classifier_example() {
1733        let yaml = r#"
1734prs_version: "1.0"
1735metadata:
1736  name: "image-classifier"
1737  title: "CIFAR-10 Classifier"
1738
1739resources:
1740  models:
1741    classifier:
1742      type: apr
1743      source: "https://registry.paiml.com/models/cifar10-resnet.apr"
1744      hash: "blake3:abc123def456789012345678901234567890123456789012345678901234"
1745
1746layout:
1747  type: grid
1748  columns: 2
1749  rows: 1
1750
1751widgets:
1752  - id: image_upload
1753    type: image
1754    position: { row: 0, col: 0 }
1755    config:
1756      mode: upload
1757      accept: ["image/png", "image/jpeg"]
1758
1759  - id: predictions
1760    type: bar_chart
1761    position: { row: 0, col: 1 }
1762    config:
1763      title: "Predictions"
1764      data: "{{ inference.classifier | select('probabilities') }}"
1765      x_axis: "{{ ['airplane', 'automobile', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck'] }}"
1766
1767bindings:
1768  - trigger: image_upload.change
1769    actions:
1770      - target: inference.classifier
1771        input: "{{ image_upload.data }}"
1772"#;
1773
1774        let scene = Scene::from_yaml(yaml).unwrap();
1775        assert_eq!(scene.metadata.name, "image-classifier");
1776        assert_eq!(scene.widgets.len(), 2);
1777
1778        let upload = scene.get_widget("image_upload").unwrap();
1779        assert_eq!(upload.widget_type, WidgetType::Image);
1780        assert_eq!(upload.config.mode, Some("upload".to_string()));
1781        assert_eq!(
1782            upload.config.accept,
1783            Some(vec!["image/png".to_string(), "image/jpeg".to_string()])
1784        );
1785    }
1786
1787    // =========================================================================
1788    // Data Explorer Example (from spec)
1789    // =========================================================================
1790
1791    #[test]
1792    fn test_data_explorer_example() {
1793        let yaml = r#"
1794prs_version: "1.0"
1795metadata:
1796  name: "data-explorer"
1797
1798resources:
1799  datasets:
1800    sales:
1801      type: ald
1802      source: "./data/sales-2024.ald"
1803      hash: "blake3:789abc012345678901234567890123456789012345678901234567890123"
1804
1805layout:
1806  type: flex
1807  direction: column
1808
1809widgets:
1810  - id: filters
1811    type: dropdown
1812    config:
1813      label: "Region"
1814      options: "{{ dataset.sales | select('region') | unique }}"
1815
1816  - id: chart
1817    type: line_chart
1818    config:
1819      title: "Sales Over Time"
1820      data: "{{ dataset.sales | filter('region == filters.value') }}"
1821      x_axis: date
1822      y_axis: revenue
1823
1824  - id: table
1825    type: table
1826    config:
1827      data: "{{ dataset.sales | filter('region == filters.value') | limit(100) }}"
1828      columns: ["date", "region", "product", "revenue"]
1829      sortable: true
1830"#;
1831
1832        let scene = Scene::from_yaml(yaml).unwrap();
1833        assert_eq!(scene.metadata.name, "data-explorer");
1834        assert_eq!(scene.widgets.len(), 3);
1835
1836        let table = scene.get_widget("table").unwrap();
1837        assert_eq!(table.widget_type, WidgetType::Table);
1838        assert_eq!(table.config.sortable, Some(true));
1839        assert_eq!(
1840            table.config.columns,
1841            Some(vec![
1842                "date".to_string(),
1843                "region".to_string(),
1844                "product".to_string(),
1845                "revenue".to_string()
1846            ])
1847        );
1848    }
1849
1850    // =========================================================================
1851    // Slider Widget Tests
1852    // =========================================================================
1853
1854    #[test]
1855    fn test_slider_widget() {
1856        let yaml = r#"
1857prs_version: "1.0"
1858metadata:
1859  name: "test"
1860layout:
1861  type: flex
1862widgets:
1863  - id: temperature
1864    type: slider
1865    config:
1866      label: "Temperature"
1867      min: 0.0
1868      max: 2.0
1869      step: 0.1
1870      default: 0.7
1871"#;
1872
1873        let scene = Scene::from_yaml(yaml).unwrap();
1874        let slider = scene.get_widget("temperature").unwrap();
1875        assert_eq!(slider.widget_type, WidgetType::Slider);
1876        assert_eq!(slider.config.min, Some(0.0));
1877        assert_eq!(slider.config.max, Some(2.0));
1878        assert_eq!(slider.config.step, Some(0.1));
1879        assert_eq!(slider.config.default, Some(0.7));
1880    }
1881
1882    // =========================================================================
1883    // Multiple Binding Actions Tests
1884    // =========================================================================
1885
1886    #[test]
1887    fn test_multiple_binding_actions() {
1888        let yaml = r#"
1889prs_version: "1.0"
1890metadata:
1891  name: "test"
1892layout:
1893  type: flex
1894widgets:
1895  - id: input
1896    type: textbox
1897  - id: chart1
1898    type: bar_chart
1899  - id: chart2
1900    type: line_chart
1901  - id: label
1902    type: markdown
1903bindings:
1904  - trigger: input.submit
1905    actions:
1906      - target: chart1
1907        action: refresh
1908      - target: chart2
1909        action: refresh
1910      - target: label
1911        action: refresh
1912"#;
1913
1914        let scene = Scene::from_yaml(yaml).unwrap();
1915        assert_eq!(scene.bindings[0].actions.len(), 3);
1916    }
1917
1918    // =========================================================================
1919    // Empty Scene Tests
1920    // =========================================================================
1921
1922    #[test]
1923    fn test_empty_widgets() {
1924        let yaml = r#"
1925prs_version: "1.0"
1926metadata:
1927  name: "empty"
1928layout:
1929  type: flex
1930widgets: []
1931"#;
1932
1933        let scene = Scene::from_yaml(yaml).unwrap();
1934        assert!(scene.widgets.is_empty());
1935    }
1936
1937    #[test]
1938    fn test_empty_resources() {
1939        let yaml = r#"
1940prs_version: "1.0"
1941metadata:
1942  name: "test"
1943layout:
1944  type: flex
1945widgets: []
1946"#;
1947
1948        let scene = Scene::from_yaml(yaml).unwrap();
1949        assert!(scene.resources.models.is_empty());
1950        assert!(scene.resources.datasets.is_empty());
1951    }
1952
1953    #[test]
1954    fn test_empty_bindings() {
1955        let yaml = r#"
1956prs_version: "1.0"
1957metadata:
1958  name: "test"
1959layout:
1960  type: flex
1961widgets: []
1962"#;
1963
1964        let scene = Scene::from_yaml(yaml).unwrap();
1965        assert!(scene.bindings.is_empty());
1966    }
1967}