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!(f, "Invalid binding target '{target}' in trigger '{trigger}'")
547            }
548            Self::InvalidHashFormat { resource, hash } => {
549                write!(f, "Invalid hash format for '{resource}': {hash}")
550            }
551            Self::MissingRemoteHash { resource } => {
552                write!(f, "Missing hash for remote resource: {resource}")
553            }
554            Self::InvalidExpression {
555                context,
556                expression,
557                message,
558            } => {
559                write!(
560                    f,
561                    "Invalid expression in {context}: '{expression}' - {message}"
562                )
563            }
564            Self::InvalidMetadataName(name) => {
565                write!(f, "Invalid metadata name '{name}': must be kebab-case")
566            }
567            Self::LayoutError(msg) => write!(f, "Layout error: {msg}"),
568        }
569    }
570}
571
572impl std::error::Error for SceneError {
573    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
574        match self {
575            Self::Yaml(e) => Some(e),
576            _ => None,
577        }
578    }
579}
580
581impl From<serde_yaml::Error> for SceneError {
582    fn from(e: serde_yaml::Error) -> Self {
583        Self::Yaml(e)
584    }
585}
586
587impl Scene {
588    /// Parse a scene from YAML string.
589    ///
590    /// # Errors
591    ///
592    /// Returns an error if the YAML is invalid or fails validation.
593    pub fn from_yaml(yaml: &str) -> Result<Self, SceneError> {
594        let scene: Self = serde_yaml::from_str(yaml)?;
595        scene.validate()?;
596        Ok(scene)
597    }
598
599    /// Serialize scene to YAML string.
600    ///
601    /// # Errors
602    ///
603    /// Returns an error if serialization fails.
604    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
605        serde_yaml::to_string(self)
606    }
607
608    /// Validate the scene structure.
609    ///
610    /// Checks:
611    /// 1. prs_version format (semver)
612    /// 2. metadata.name is kebab-case
613    /// 3. Widget IDs are unique
614    /// 4. Binding targets reference valid widgets/resources
615    /// 5. Remote resources have hashes
616    /// 6. Hash formats are valid (blake3:<hex>)
617    ///
618    /// # Errors
619    ///
620    /// Returns the first validation error found.
621    pub fn validate(&self) -> Result<(), SceneError> {
622        self.validate_version()?;
623        self.validate_metadata_name()?;
624        self.validate_widget_ids()?;
625        self.validate_bindings()?;
626        self.validate_resource_hashes()?;
627        self.validate_layout()?;
628        Ok(())
629    }
630
631    fn validate_version(&self) -> Result<(), SceneError> {
632        // Version should be "X.Y" format
633        let parts: Vec<&str> = self.prs_version.split('.').collect();
634        if parts.len() != 2 {
635            return Err(SceneError::InvalidVersion(self.prs_version.clone()));
636        }
637        for part in parts {
638            if part.parse::<u32>().is_err() {
639                return Err(SceneError::InvalidVersion(self.prs_version.clone()));
640            }
641        }
642        Ok(())
643    }
644
645    fn validate_metadata_name(&self) -> Result<(), SceneError> {
646        let name = &self.metadata.name;
647        // Must be kebab-case: lowercase letters, numbers, hyphens
648        if !name
649            .chars()
650            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
651        {
652            return Err(SceneError::InvalidMetadataName(name.clone()));
653        }
654        // Cannot start or end with hyphen
655        if name.starts_with('-') || name.ends_with('-') {
656            return Err(SceneError::InvalidMetadataName(name.clone()));
657        }
658        // Cannot have consecutive hyphens
659        if name.contains("--") {
660            return Err(SceneError::InvalidMetadataName(name.clone()));
661        }
662        Ok(())
663    }
664
665    fn validate_widget_ids(&self) -> Result<(), SceneError> {
666        let mut seen = std::collections::HashSet::new();
667        for widget in &self.widgets {
668            if !seen.insert(&widget.id) {
669                return Err(SceneError::DuplicateWidgetId(widget.id.clone()));
670            }
671        }
672        Ok(())
673    }
674
675    fn validate_bindings(&self) -> Result<(), SceneError> {
676        let widget_ids: std::collections::HashSet<&str> =
677            self.widgets.iter().map(|w| w.id.as_str()).collect();
678        let model_ids: std::collections::HashSet<&str> =
679            self.resources.models.keys().map(String::as_str).collect();
680
681        for binding in &self.bindings {
682            for action in &binding.actions {
683                let target = &action.target;
684
685                // Check if target is a widget ID
686                if widget_ids.contains(target.as_str()) {
687                    continue;
688                }
689
690                // Check if target is inference.<model_name>
691                if let Some(model_name) = target.strip_prefix("inference.") {
692                    if model_ids.contains(model_name) {
693                        continue;
694                    }
695                }
696
697                return Err(SceneError::InvalidBindingTarget {
698                    trigger: binding.trigger.clone(),
699                    target: target.clone(),
700                });
701            }
702        }
703        Ok(())
704    }
705
706    fn validate_resource_hashes(&self) -> Result<(), SceneError> {
707        // Validate model hashes
708        for (name, resource) in &self.resources.models {
709            if is_remote_source(&resource.source) && resource.hash.is_none() {
710                return Err(SceneError::MissingRemoteHash {
711                    resource: name.clone(),
712                });
713            }
714            if let Some(hash) = &resource.hash {
715                validate_hash_format(name, hash)?;
716            }
717        }
718
719        // Validate dataset hashes
720        for (name, resource) in &self.resources.datasets {
721            if is_remote_source(&resource.source) && resource.hash.is_none() {
722                return Err(SceneError::MissingRemoteHash {
723                    resource: name.clone(),
724                });
725            }
726            if let Some(hash) = &resource.hash {
727                validate_hash_format(name, hash)?;
728            }
729        }
730
731        Ok(())
732    }
733
734    fn validate_layout(&self) -> Result<(), SceneError> {
735        match self.layout.layout_type {
736            LayoutType::Grid => {
737                if self.layout.columns.is_none() {
738                    return Err(SceneError::LayoutError(
739                        "Grid layout requires 'columns' field".to_string(),
740                    ));
741                }
742            }
743            LayoutType::Absolute => {
744                if self.layout.width.is_none() || self.layout.height.is_none() {
745                    return Err(SceneError::LayoutError(
746                        "Absolute layout requires 'width' and 'height' fields".to_string(),
747                    ));
748                }
749            }
750            LayoutType::Flex => {
751                // Flex layout has optional fields
752            }
753        }
754        Ok(())
755    }
756
757    /// Get all widget IDs.
758    #[must_use]
759    pub fn widget_ids(&self) -> Vec<&str> {
760        self.widgets.iter().map(|w| w.id.as_str()).collect()
761    }
762
763    /// Get a widget by ID.
764    #[must_use]
765    pub fn get_widget(&self, id: &str) -> Option<&SceneWidget> {
766        self.widgets.iter().find(|w| w.id == id)
767    }
768
769    /// Get a model resource by name.
770    #[must_use]
771    pub fn get_model(&self, name: &str) -> Option<&ModelResource> {
772        self.resources.models.get(name)
773    }
774
775    /// Get a dataset resource by name.
776    #[must_use]
777    pub fn get_dataset(&self, name: &str) -> Option<&DatasetResource> {
778        self.resources.datasets.get(name)
779    }
780}
781
782/// Check if a resource source is remote (https://).
783fn is_remote_source(source: &ResourceSource) -> bool {
784    source.sources().iter().any(|s| s.starts_with("https://"))
785}
786
787/// Validate hash format (blake3:<64-hex-chars>).
788fn validate_hash_format(resource: &str, hash: &str) -> Result<(), SceneError> {
789    if let Some(hex) = hash.strip_prefix("blake3:") {
790        // BLAKE3 produces 256-bit (32-byte) hashes = 64 hex characters
791        if hex.len() >= 12 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
792            return Ok(());
793        }
794    }
795    Err(SceneError::InvalidHashFormat {
796        resource: resource.to_string(),
797        hash: hash.to_string(),
798    })
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804    use std::error::Error;
805
806    // =========================================================================
807    // Basic Parsing Tests
808    // =========================================================================
809
810    const MINIMAL_SCENE: &str = r##"
811prs_version: "1.0"
812
813metadata:
814  name: "hello-world"
815
816layout:
817  type: flex
818  direction: column
819
820widgets:
821  - id: greeting
822    type: markdown
823    config:
824      content: "# Hello, Presentar!"
825"##;
826
827    #[test]
828    fn test_parse_minimal_scene() {
829        let scene = Scene::from_yaml(MINIMAL_SCENE).unwrap();
830        assert_eq!(scene.prs_version, "1.0");
831        assert_eq!(scene.metadata.name, "hello-world");
832        assert_eq!(scene.widgets.len(), 1);
833        assert_eq!(scene.widgets[0].id, "greeting");
834        assert_eq!(scene.widgets[0].widget_type, WidgetType::Markdown);
835    }
836
837    #[test]
838    fn test_parse_layout_flex() {
839        let scene = Scene::from_yaml(MINIMAL_SCENE).unwrap();
840        assert_eq!(scene.layout.layout_type, LayoutType::Flex);
841        assert_eq!(scene.layout.direction, Some(FlexDirection::Column));
842    }
843
844    #[test]
845    fn test_parse_widget_config() {
846        let scene = Scene::from_yaml(MINIMAL_SCENE).unwrap();
847        let widget = &scene.widgets[0];
848        assert_eq!(widget.config.content.as_deref(), Some("# Hello, Presentar!"));
849    }
850
851    // =========================================================================
852    // Full Scene Parsing Tests
853    // =========================================================================
854
855    const FULL_SCENE: &str = r##"
856prs_version: "1.0"
857
858metadata:
859  name: "sentiment-analysis-demo"
860  title: "Real-time Sentiment Analysis"
861  description: "Interactive sentiment classifier with confidence visualization"
862  author: "alice@example.com"
863  created: "2025-12-06T10:00:00Z"
864  license: "MIT"
865  tags: ["nlp", "sentiment", "demo"]
866
867resources:
868  models:
869    sentiment_model:
870      type: apr
871      source: "https://registry.paiml.com/models/sentiment-bert-q4.apr"
872      hash: "blake3:a1b2c3d4e5f6789012345678901234567890123456789012345678901234"
873      size_bytes: 45000000
874
875  datasets:
876    examples:
877      type: ald
878      source: "./data/sentiment-examples.ald"
879
880layout:
881  type: grid
882  columns: 2
883  rows: 2
884  gap: 16
885
886widgets:
887  - id: text_input
888    type: textbox
889    position: { row: 0, col: 0, colspan: 2 }
890    config:
891      label: "Enter text to analyze"
892      placeholder: "Type a sentence..."
893      max_length: 512
894
895  - id: sentiment_chart
896    type: bar_chart
897    position: { row: 1, col: 0 }
898    config:
899      title: "Sentiment Scores"
900      data: "{{ inference.sentiment_model | select('scores') }}"
901      x_axis: "{{ ['Positive', 'Negative', 'Neutral'] }}"
902
903  - id: confidence_gauge
904    type: gauge
905    position: { row: 1, col: 1 }
906    config:
907      value: "{{ inference.sentiment_model | select('confidence') | percentage }}"
908      min: 0
909      max: 100
910      thresholds:
911        - { value: 50, color: "red" }
912        - { value: 75, color: "yellow" }
913        - { value: 100, color: "green" }
914
915bindings:
916  - trigger: "text_input.change"
917    debounce_ms: 300
918    actions:
919      - target: inference.sentiment_model
920        input: "{{ text_input.value }}"
921      - target: sentiment_chart
922        action: refresh
923      - target: confidence_gauge
924        action: refresh
925
926theme:
927  preset: "dark"
928  custom:
929    primary_color: "#4A90D9"
930    font_family: "Inter, sans-serif"
931
932permissions:
933  network:
934    - "https://registry.paiml.com/*"
935  filesystem: []
936  clipboard: false
937"##;
938
939    #[test]
940    fn test_parse_full_scene() {
941        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
942        assert_eq!(scene.prs_version, "1.0");
943        assert_eq!(scene.metadata.name, "sentiment-analysis-demo");
944        assert_eq!(
945            scene.metadata.title,
946            Some("Real-time Sentiment Analysis".to_string())
947        );
948        assert_eq!(scene.metadata.tags.len(), 3);
949    }
950
951    #[test]
952    fn test_parse_resources() {
953        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
954        assert_eq!(scene.resources.models.len(), 1);
955        assert_eq!(scene.resources.datasets.len(), 1);
956
957        let model = scene.get_model("sentiment_model").unwrap();
958        assert_eq!(model.resource_type, ModelType::Apr);
959        assert!(model.hash.is_some());
960        assert_eq!(model.size_bytes, Some(45_000_000));
961    }
962
963    #[test]
964    fn test_parse_grid_layout() {
965        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
966        assert_eq!(scene.layout.layout_type, LayoutType::Grid);
967        assert_eq!(scene.layout.columns, Some(2));
968        assert_eq!(scene.layout.rows, Some(2));
969        assert_eq!(scene.layout.gap, 16);
970    }
971
972    #[test]
973    fn test_parse_widget_positions() {
974        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
975
976        let text_input = scene.get_widget("text_input").unwrap();
977        let pos = text_input.position.as_ref().unwrap();
978        assert_eq!(pos.row, 0);
979        assert_eq!(pos.col, 0);
980        assert_eq!(pos.colspan, 2);
981
982        let chart = scene.get_widget("sentiment_chart").unwrap();
983        let pos = chart.position.as_ref().unwrap();
984        assert_eq!(pos.row, 1);
985        assert_eq!(pos.col, 0);
986    }
987
988    #[test]
989    fn test_parse_bindings() {
990        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
991        assert_eq!(scene.bindings.len(), 1);
992
993        let binding = &scene.bindings[0];
994        assert_eq!(binding.trigger, "text_input.change");
995        assert_eq!(binding.debounce_ms, Some(300));
996        assert_eq!(binding.actions.len(), 3);
997    }
998
999    #[test]
1000    fn test_parse_theme() {
1001        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
1002        let theme = scene.theme.as_ref().unwrap();
1003        assert_eq!(theme.preset, Some("dark".to_string()));
1004        assert_eq!(
1005            theme.custom.get("primary_color"),
1006            Some(&"#4A90D9".to_string())
1007        );
1008    }
1009
1010    #[test]
1011    fn test_parse_permissions() {
1012        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
1013        assert_eq!(scene.permissions.network.len(), 1);
1014        assert!(scene.permissions.filesystem.is_empty());
1015        assert!(!scene.permissions.clipboard);
1016    }
1017
1018    // =========================================================================
1019    // Widget Type Tests
1020    // =========================================================================
1021
1022    #[test]
1023    fn test_widget_types() {
1024        let yaml = r#"
1025prs_version: "1.0"
1026metadata:
1027  name: "widget-test"
1028layout:
1029  type: flex
1030widgets:
1031  - id: w1
1032    type: textbox
1033  - id: w2
1034    type: slider
1035  - id: w3
1036    type: dropdown
1037  - id: w4
1038    type: button
1039  - id: w5
1040    type: image
1041  - id: w6
1042    type: bar_chart
1043  - id: w7
1044    type: line_chart
1045  - id: w8
1046    type: gauge
1047  - id: w9
1048    type: table
1049  - id: w10
1050    type: markdown
1051  - id: w11
1052    type: inference
1053"#;
1054
1055        let scene = Scene::from_yaml(yaml).unwrap();
1056        assert_eq!(scene.widgets.len(), 11);
1057        assert_eq!(scene.widgets[0].widget_type, WidgetType::Textbox);
1058        assert_eq!(scene.widgets[1].widget_type, WidgetType::Slider);
1059        assert_eq!(scene.widgets[2].widget_type, WidgetType::Dropdown);
1060        assert_eq!(scene.widgets[3].widget_type, WidgetType::Button);
1061        assert_eq!(scene.widgets[4].widget_type, WidgetType::Image);
1062        assert_eq!(scene.widgets[5].widget_type, WidgetType::BarChart);
1063        assert_eq!(scene.widgets[6].widget_type, WidgetType::LineChart);
1064        assert_eq!(scene.widgets[7].widget_type, WidgetType::Gauge);
1065        assert_eq!(scene.widgets[8].widget_type, WidgetType::Table);
1066        assert_eq!(scene.widgets[9].widget_type, WidgetType::Markdown);
1067        assert_eq!(scene.widgets[10].widget_type, WidgetType::Inference);
1068    }
1069
1070    // =========================================================================
1071    // Resource Source Tests
1072    // =========================================================================
1073
1074    #[test]
1075    fn test_resource_source_single() {
1076        let yaml = r#"
1077prs_version: "1.0"
1078metadata:
1079  name: "test"
1080layout:
1081  type: flex
1082widgets: []
1083resources:
1084  models:
1085    model:
1086      type: apr
1087      source: "./local/model.apr"
1088"#;
1089
1090        let scene = Scene::from_yaml(yaml).unwrap();
1091        let model = scene.get_model("model").unwrap();
1092        assert_eq!(model.source.primary(), "./local/model.apr");
1093        assert_eq!(model.source.sources().len(), 1);
1094    }
1095
1096    #[test]
1097    fn test_resource_source_multiple() {
1098        let yaml = r#"
1099prs_version: "1.0"
1100metadata:
1101  name: "test"
1102layout:
1103  type: flex
1104widgets: []
1105resources:
1106  models:
1107    model:
1108      type: apr
1109      source:
1110        - "./local-cache/model.apr"
1111        - "https://cdn.example.com/model.apr"
1112      hash: "blake3:a1b2c3d4e5f6789012345678901234567890123456789012345678901234"
1113"#;
1114
1115        let scene = Scene::from_yaml(yaml).unwrap();
1116        let model = scene.get_model("model").unwrap();
1117        assert_eq!(model.source.primary(), "./local-cache/model.apr");
1118        assert_eq!(model.source.sources().len(), 2);
1119    }
1120
1121    // =========================================================================
1122    // Gauge Threshold Tests
1123    // =========================================================================
1124
1125    #[test]
1126    fn test_gauge_thresholds() {
1127        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
1128        let gauge = scene.get_widget("confidence_gauge").unwrap();
1129        let thresholds = gauge.config.thresholds.as_ref().unwrap();
1130
1131        assert_eq!(thresholds.len(), 3);
1132        assert!((thresholds[0].value - 50.0).abs() < f64::EPSILON);
1133        assert_eq!(thresholds[0].color, "red");
1134        assert!((thresholds[1].value - 75.0).abs() < f64::EPSILON);
1135        assert_eq!(thresholds[1].color, "yellow");
1136    }
1137
1138    // =========================================================================
1139    // Validation Tests
1140    // =========================================================================
1141
1142    #[test]
1143    fn test_validation_invalid_version() {
1144        let yaml = r#"
1145prs_version: "invalid"
1146metadata:
1147  name: "test"
1148layout:
1149  type: flex
1150widgets: []
1151"#;
1152
1153        let result = Scene::from_yaml(yaml);
1154        assert!(result.is_err());
1155        let err = result.unwrap_err();
1156        assert!(matches!(err, SceneError::InvalidVersion(_)));
1157    }
1158
1159    #[test]
1160    fn test_validation_invalid_version_format() {
1161        let yaml = r#"
1162prs_version: "1.0.0"
1163metadata:
1164  name: "test"
1165layout:
1166  type: flex
1167widgets: []
1168"#;
1169
1170        let result = Scene::from_yaml(yaml);
1171        assert!(result.is_err());
1172        assert!(matches!(result.unwrap_err(), SceneError::InvalidVersion(_)));
1173    }
1174
1175    #[test]
1176    fn test_validation_invalid_metadata_name_uppercase() {
1177        let yaml = r#"
1178prs_version: "1.0"
1179metadata:
1180  name: "Invalid-Name"
1181layout:
1182  type: flex
1183widgets: []
1184"#;
1185
1186        let result = Scene::from_yaml(yaml);
1187        assert!(result.is_err());
1188        assert!(matches!(
1189            result.unwrap_err(),
1190            SceneError::InvalidMetadataName(_)
1191        ));
1192    }
1193
1194    #[test]
1195    fn test_validation_invalid_metadata_name_leading_hyphen() {
1196        let yaml = r#"
1197prs_version: "1.0"
1198metadata:
1199  name: "-invalid"
1200layout:
1201  type: flex
1202widgets: []
1203"#;
1204
1205        let result = Scene::from_yaml(yaml);
1206        assert!(result.is_err());
1207    }
1208
1209    #[test]
1210    fn test_validation_duplicate_widget_ids() {
1211        let yaml = r#"
1212prs_version: "1.0"
1213metadata:
1214  name: "test"
1215layout:
1216  type: flex
1217widgets:
1218  - id: same_id
1219    type: textbox
1220  - id: same_id
1221    type: button
1222"#;
1223
1224        let result = Scene::from_yaml(yaml);
1225        assert!(result.is_err());
1226        assert!(matches!(
1227            result.unwrap_err(),
1228            SceneError::DuplicateWidgetId(_)
1229        ));
1230    }
1231
1232    #[test]
1233    fn test_validation_invalid_binding_target() {
1234        let yaml = r#"
1235prs_version: "1.0"
1236metadata:
1237  name: "test"
1238layout:
1239  type: flex
1240widgets:
1241  - id: input
1242    type: textbox
1243bindings:
1244  - trigger: "input.change"
1245    actions:
1246      - target: nonexistent_widget
1247        action: refresh
1248"#;
1249
1250        let result = Scene::from_yaml(yaml);
1251        assert!(result.is_err());
1252        assert!(matches!(
1253            result.unwrap_err(),
1254            SceneError::InvalidBindingTarget { .. }
1255        ));
1256    }
1257
1258    #[test]
1259    fn test_validation_valid_binding_to_widget() {
1260        let yaml = r#"
1261prs_version: "1.0"
1262metadata:
1263  name: "test"
1264layout:
1265  type: flex
1266widgets:
1267  - id: input
1268    type: textbox
1269  - id: output
1270    type: markdown
1271bindings:
1272  - trigger: "input.change"
1273    actions:
1274      - target: output
1275        action: refresh
1276"#;
1277
1278        let result = Scene::from_yaml(yaml);
1279        assert!(result.is_ok());
1280    }
1281
1282    #[test]
1283    fn test_validation_valid_binding_to_inference() {
1284        let yaml = r#"
1285prs_version: "1.0"
1286metadata:
1287  name: "test"
1288layout:
1289  type: flex
1290widgets:
1291  - id: input
1292    type: textbox
1293resources:
1294  models:
1295    my_model:
1296      type: apr
1297      source: "./model.apr"
1298bindings:
1299  - trigger: "input.change"
1300    actions:
1301      - target: inference.my_model
1302        input: "{{ input.value }}"
1303"#;
1304
1305        let result = Scene::from_yaml(yaml);
1306        assert!(result.is_ok());
1307    }
1308
1309    #[test]
1310    fn test_validation_missing_remote_hash() {
1311        let yaml = r#"
1312prs_version: "1.0"
1313metadata:
1314  name: "test"
1315layout:
1316  type: flex
1317widgets: []
1318resources:
1319  models:
1320    model:
1321      type: apr
1322      source: "https://example.com/model.apr"
1323"#;
1324
1325        let result = Scene::from_yaml(yaml);
1326        assert!(result.is_err());
1327        assert!(matches!(
1328            result.unwrap_err(),
1329            SceneError::MissingRemoteHash { .. }
1330        ));
1331    }
1332
1333    #[test]
1334    fn test_validation_local_resource_no_hash_ok() {
1335        let yaml = r#"
1336prs_version: "1.0"
1337metadata:
1338  name: "test"
1339layout:
1340  type: flex
1341widgets: []
1342resources:
1343  models:
1344    model:
1345      type: apr
1346      source: "./local/model.apr"
1347"#;
1348
1349        let result = Scene::from_yaml(yaml);
1350        assert!(result.is_ok());
1351    }
1352
1353    #[test]
1354    fn test_validation_invalid_hash_format() {
1355        let yaml = r#"
1356prs_version: "1.0"
1357metadata:
1358  name: "test"
1359layout:
1360  type: flex
1361widgets: []
1362resources:
1363  models:
1364    model:
1365      type: apr
1366      source: "./model.apr"
1367      hash: "sha256:invalid"
1368"#;
1369
1370        let result = Scene::from_yaml(yaml);
1371        assert!(result.is_err());
1372        assert!(matches!(
1373            result.unwrap_err(),
1374            SceneError::InvalidHashFormat { .. }
1375        ));
1376    }
1377
1378    #[test]
1379    fn test_validation_grid_layout_requires_columns() {
1380        let yaml = r#"
1381prs_version: "1.0"
1382metadata:
1383  name: "test"
1384layout:
1385  type: grid
1386widgets: []
1387"#;
1388
1389        let result = Scene::from_yaml(yaml);
1390        assert!(result.is_err());
1391        assert!(matches!(result.unwrap_err(), SceneError::LayoutError(_)));
1392    }
1393
1394    #[test]
1395    fn test_validation_absolute_layout_requires_dimensions() {
1396        let yaml = r#"
1397prs_version: "1.0"
1398metadata:
1399  name: "test"
1400layout:
1401  type: absolute
1402widgets: []
1403"#;
1404
1405        let result = Scene::from_yaml(yaml);
1406        assert!(result.is_err());
1407        assert!(matches!(result.unwrap_err(), SceneError::LayoutError(_)));
1408    }
1409
1410    // =========================================================================
1411    // Serialization Tests
1412    // =========================================================================
1413
1414    #[test]
1415    fn test_roundtrip() {
1416        let scene = Scene::from_yaml(MINIMAL_SCENE).unwrap();
1417        let yaml = scene.to_yaml().unwrap();
1418        let scene2 = Scene::from_yaml(&yaml).unwrap();
1419        assert_eq!(scene.prs_version, scene2.prs_version);
1420        assert_eq!(scene.metadata.name, scene2.metadata.name);
1421        assert_eq!(scene.widgets.len(), scene2.widgets.len());
1422    }
1423
1424    #[test]
1425    fn test_roundtrip_full() {
1426        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
1427        let yaml = scene.to_yaml().unwrap();
1428        let scene2 = Scene::from_yaml(&yaml).unwrap();
1429        assert_eq!(scene.prs_version, scene2.prs_version);
1430        assert_eq!(scene.metadata.name, scene2.metadata.name);
1431        assert_eq!(scene.resources.models.len(), scene2.resources.models.len());
1432        assert_eq!(scene.widgets.len(), scene2.widgets.len());
1433        assert_eq!(scene.bindings.len(), scene2.bindings.len());
1434    }
1435
1436    // =========================================================================
1437    // Helper Method Tests
1438    // =========================================================================
1439
1440    #[test]
1441    fn test_widget_ids() {
1442        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
1443        let ids = scene.widget_ids();
1444        assert_eq!(ids.len(), 3);
1445        assert!(ids.contains(&"text_input"));
1446        assert!(ids.contains(&"sentiment_chart"));
1447        assert!(ids.contains(&"confidence_gauge"));
1448    }
1449
1450    #[test]
1451    fn test_get_widget() {
1452        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
1453        let widget = scene.get_widget("text_input");
1454        assert!(widget.is_some());
1455        assert_eq!(widget.unwrap().widget_type, WidgetType::Textbox);
1456
1457        let missing = scene.get_widget("nonexistent");
1458        assert!(missing.is_none());
1459    }
1460
1461    #[test]
1462    fn test_get_model() {
1463        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
1464        let model = scene.get_model("sentiment_model");
1465        assert!(model.is_some());
1466        assert_eq!(model.unwrap().resource_type, ModelType::Apr);
1467    }
1468
1469    #[test]
1470    fn test_get_dataset() {
1471        let scene = Scene::from_yaml(FULL_SCENE).unwrap();
1472        let dataset = scene.get_dataset("examples");
1473        assert!(dataset.is_some());
1474        assert_eq!(dataset.unwrap().resource_type, DatasetType::Ald);
1475    }
1476
1477    // =========================================================================
1478    // Error Display Tests
1479    // =========================================================================
1480
1481    #[test]
1482    fn test_error_display_yaml() {
1483        let yaml_err: serde_yaml::Error =
1484            serde_yaml::from_str::<serde_yaml::Value>("{{").unwrap_err();
1485        let err = SceneError::Yaml(yaml_err);
1486        assert!(err.to_string().contains("YAML error"));
1487    }
1488
1489    #[test]
1490    fn test_error_display_invalid_version() {
1491        let err = SceneError::InvalidVersion("bad".to_string());
1492        assert_eq!(err.to_string(), "Invalid prs_version: bad");
1493    }
1494
1495    #[test]
1496    fn test_error_display_duplicate_id() {
1497        let err = SceneError::DuplicateWidgetId("my_id".to_string());
1498        assert_eq!(err.to_string(), "Duplicate widget id: my_id");
1499    }
1500
1501    #[test]
1502    fn test_error_display_invalid_binding() {
1503        let err = SceneError::InvalidBindingTarget {
1504            trigger: "input.change".to_string(),
1505            target: "bad_target".to_string(),
1506        };
1507        assert!(err.to_string().contains("Invalid binding target"));
1508        assert!(err.to_string().contains("bad_target"));
1509    }
1510
1511    #[test]
1512    fn test_error_display_invalid_hash() {
1513        let err = SceneError::InvalidHashFormat {
1514            resource: "model".to_string(),
1515            hash: "bad".to_string(),
1516        };
1517        assert!(err.to_string().contains("Invalid hash format"));
1518    }
1519
1520    #[test]
1521    fn test_error_display_missing_hash() {
1522        let err = SceneError::MissingRemoteHash {
1523            resource: "model".to_string(),
1524        };
1525        assert!(err.to_string().contains("Missing hash for remote resource"));
1526    }
1527
1528    #[test]
1529    fn test_error_source() {
1530        let yaml_err: serde_yaml::Error =
1531            serde_yaml::from_str::<serde_yaml::Value>("{{").unwrap_err();
1532        let err = SceneError::Yaml(yaml_err);
1533        assert!(err.source().is_some());
1534
1535        let err2 = SceneError::InvalidVersion("x".to_string());
1536        assert!(err2.source().is_none());
1537    }
1538
1539    // =========================================================================
1540    // Model Type Tests
1541    // =========================================================================
1542
1543    #[test]
1544    fn test_model_types() {
1545        let yaml = r#"
1546prs_version: "1.0"
1547metadata:
1548  name: "test"
1549layout:
1550  type: flex
1551widgets: []
1552resources:
1553  models:
1554    apr_model:
1555      type: apr
1556      source: "./model.apr"
1557    gguf_model:
1558      type: gguf
1559      source: "./model.gguf"
1560    safetensors_model:
1561      type: safetensors
1562      source: "./model.safetensors"
1563"#;
1564
1565        let scene = Scene::from_yaml(yaml).unwrap();
1566        assert_eq!(
1567            scene.get_model("apr_model").unwrap().resource_type,
1568            ModelType::Apr
1569        );
1570        assert_eq!(
1571            scene.get_model("gguf_model").unwrap().resource_type,
1572            ModelType::Gguf
1573        );
1574        assert_eq!(
1575            scene.get_model("safetensors_model").unwrap().resource_type,
1576            ModelType::Safetensors
1577        );
1578    }
1579
1580    #[test]
1581    fn test_dataset_types() {
1582        let yaml = r#"
1583prs_version: "1.0"
1584metadata:
1585  name: "test"
1586layout:
1587  type: flex
1588widgets: []
1589resources:
1590  datasets:
1591    ald_data:
1592      type: ald
1593      source: "./data.ald"
1594    parquet_data:
1595      type: parquet
1596      source: "./data.parquet"
1597    csv_data:
1598      type: csv
1599      source: "./data.csv"
1600"#;
1601
1602        let scene = Scene::from_yaml(yaml).unwrap();
1603        assert_eq!(
1604            scene.get_dataset("ald_data").unwrap().resource_type,
1605            DatasetType::Ald
1606        );
1607        assert_eq!(
1608            scene.get_dataset("parquet_data").unwrap().resource_type,
1609            DatasetType::Parquet
1610        );
1611        assert_eq!(
1612            scene.get_dataset("csv_data").unwrap().resource_type,
1613            DatasetType::Csv
1614        );
1615    }
1616
1617    // =========================================================================
1618    // Layout Type Tests
1619    // =========================================================================
1620
1621    #[test]
1622    fn test_layout_type_grid() {
1623        let yaml = r#"
1624prs_version: "1.0"
1625metadata:
1626  name: "test"
1627layout:
1628  type: grid
1629  columns: 3
1630  rows: 2
1631  gap: 8
1632widgets: []
1633"#;
1634
1635        let scene = Scene::from_yaml(yaml).unwrap();
1636        assert_eq!(scene.layout.layout_type, LayoutType::Grid);
1637        assert_eq!(scene.layout.columns, Some(3));
1638        assert_eq!(scene.layout.rows, Some(2));
1639        assert_eq!(scene.layout.gap, 8);
1640    }
1641
1642    #[test]
1643    fn test_layout_type_flex() {
1644        let yaml = r#"
1645prs_version: "1.0"
1646metadata:
1647  name: "test"
1648layout:
1649  type: flex
1650  direction: row
1651  wrap: true
1652  gap: 4
1653widgets: []
1654"#;
1655
1656        let scene = Scene::from_yaml(yaml).unwrap();
1657        assert_eq!(scene.layout.layout_type, LayoutType::Flex);
1658        assert_eq!(scene.layout.direction, Some(FlexDirection::Row));
1659        assert_eq!(scene.layout.wrap, Some(true));
1660    }
1661
1662    #[test]
1663    fn test_layout_type_absolute() {
1664        let yaml = r#"
1665prs_version: "1.0"
1666metadata:
1667  name: "test"
1668layout:
1669  type: absolute
1670  width: 1200
1671  height: 800
1672widgets: []
1673"#;
1674
1675        let scene = Scene::from_yaml(yaml).unwrap();
1676        assert_eq!(scene.layout.layout_type, LayoutType::Absolute);
1677        assert_eq!(scene.layout.width, Some(1200));
1678        assert_eq!(scene.layout.height, Some(800));
1679    }
1680
1681    // =========================================================================
1682    // Default Value Tests
1683    // =========================================================================
1684
1685    #[test]
1686    fn test_default_gap() {
1687        let yaml = r#"
1688prs_version: "1.0"
1689metadata:
1690  name: "test"
1691layout:
1692  type: flex
1693widgets: []
1694"#;
1695
1696        let scene = Scene::from_yaml(yaml).unwrap();
1697        assert_eq!(scene.layout.gap, 16); // Default value
1698    }
1699
1700    #[test]
1701    fn test_default_span() {
1702        let yaml = r#"
1703prs_version: "1.0"
1704metadata:
1705  name: "test"
1706layout:
1707  type: grid
1708  columns: 2
1709widgets:
1710  - id: widget
1711    type: textbox
1712    position: { row: 0, col: 0 }
1713"#;
1714
1715        let scene = Scene::from_yaml(yaml).unwrap();
1716        let pos = scene.widgets[0].position.as_ref().unwrap();
1717        assert_eq!(pos.colspan, 1); // Default
1718        assert_eq!(pos.rowspan, 1); // Default
1719    }
1720
1721    // =========================================================================
1722    // Image Classifier Example (from spec)
1723    // =========================================================================
1724
1725    #[test]
1726    fn test_image_classifier_example() {
1727        let yaml = r#"
1728prs_version: "1.0"
1729metadata:
1730  name: "image-classifier"
1731  title: "CIFAR-10 Classifier"
1732
1733resources:
1734  models:
1735    classifier:
1736      type: apr
1737      source: "https://registry.paiml.com/models/cifar10-resnet.apr"
1738      hash: "blake3:abc123def456789012345678901234567890123456789012345678901234"
1739
1740layout:
1741  type: grid
1742  columns: 2
1743  rows: 1
1744
1745widgets:
1746  - id: image_upload
1747    type: image
1748    position: { row: 0, col: 0 }
1749    config:
1750      mode: upload
1751      accept: ["image/png", "image/jpeg"]
1752
1753  - id: predictions
1754    type: bar_chart
1755    position: { row: 0, col: 1 }
1756    config:
1757      title: "Predictions"
1758      data: "{{ inference.classifier | select('probabilities') }}"
1759      x_axis: "{{ ['airplane', 'automobile', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck'] }}"
1760
1761bindings:
1762  - trigger: image_upload.change
1763    actions:
1764      - target: inference.classifier
1765        input: "{{ image_upload.data }}"
1766"#;
1767
1768        let scene = Scene::from_yaml(yaml).unwrap();
1769        assert_eq!(scene.metadata.name, "image-classifier");
1770        assert_eq!(scene.widgets.len(), 2);
1771
1772        let upload = scene.get_widget("image_upload").unwrap();
1773        assert_eq!(upload.widget_type, WidgetType::Image);
1774        assert_eq!(upload.config.mode, Some("upload".to_string()));
1775        assert_eq!(
1776            upload.config.accept,
1777            Some(vec!["image/png".to_string(), "image/jpeg".to_string()])
1778        );
1779    }
1780
1781    // =========================================================================
1782    // Data Explorer Example (from spec)
1783    // =========================================================================
1784
1785    #[test]
1786    fn test_data_explorer_example() {
1787        let yaml = r#"
1788prs_version: "1.0"
1789metadata:
1790  name: "data-explorer"
1791
1792resources:
1793  datasets:
1794    sales:
1795      type: ald
1796      source: "./data/sales-2024.ald"
1797      hash: "blake3:789abc012345678901234567890123456789012345678901234567890123"
1798
1799layout:
1800  type: flex
1801  direction: column
1802
1803widgets:
1804  - id: filters
1805    type: dropdown
1806    config:
1807      label: "Region"
1808      options: "{{ dataset.sales | select('region') | unique }}"
1809
1810  - id: chart
1811    type: line_chart
1812    config:
1813      title: "Sales Over Time"
1814      data: "{{ dataset.sales | filter('region == filters.value') }}"
1815      x_axis: date
1816      y_axis: revenue
1817
1818  - id: table
1819    type: table
1820    config:
1821      data: "{{ dataset.sales | filter('region == filters.value') | limit(100) }}"
1822      columns: ["date", "region", "product", "revenue"]
1823      sortable: true
1824"#;
1825
1826        let scene = Scene::from_yaml(yaml).unwrap();
1827        assert_eq!(scene.metadata.name, "data-explorer");
1828        assert_eq!(scene.widgets.len(), 3);
1829
1830        let table = scene.get_widget("table").unwrap();
1831        assert_eq!(table.widget_type, WidgetType::Table);
1832        assert_eq!(table.config.sortable, Some(true));
1833        assert_eq!(
1834            table.config.columns,
1835            Some(vec![
1836                "date".to_string(),
1837                "region".to_string(),
1838                "product".to_string(),
1839                "revenue".to_string()
1840            ])
1841        );
1842    }
1843
1844    // =========================================================================
1845    // Slider Widget Tests
1846    // =========================================================================
1847
1848    #[test]
1849    fn test_slider_widget() {
1850        let yaml = r#"
1851prs_version: "1.0"
1852metadata:
1853  name: "test"
1854layout:
1855  type: flex
1856widgets:
1857  - id: temperature
1858    type: slider
1859    config:
1860      label: "Temperature"
1861      min: 0.0
1862      max: 2.0
1863      step: 0.1
1864      default: 0.7
1865"#;
1866
1867        let scene = Scene::from_yaml(yaml).unwrap();
1868        let slider = scene.get_widget("temperature").unwrap();
1869        assert_eq!(slider.widget_type, WidgetType::Slider);
1870        assert_eq!(slider.config.min, Some(0.0));
1871        assert_eq!(slider.config.max, Some(2.0));
1872        assert_eq!(slider.config.step, Some(0.1));
1873        assert_eq!(slider.config.default, Some(0.7));
1874    }
1875
1876    // =========================================================================
1877    // Multiple Binding Actions Tests
1878    // =========================================================================
1879
1880    #[test]
1881    fn test_multiple_binding_actions() {
1882        let yaml = r#"
1883prs_version: "1.0"
1884metadata:
1885  name: "test"
1886layout:
1887  type: flex
1888widgets:
1889  - id: input
1890    type: textbox
1891  - id: chart1
1892    type: bar_chart
1893  - id: chart2
1894    type: line_chart
1895  - id: label
1896    type: markdown
1897bindings:
1898  - trigger: input.submit
1899    actions:
1900      - target: chart1
1901        action: refresh
1902      - target: chart2
1903        action: refresh
1904      - target: label
1905        action: refresh
1906"#;
1907
1908        let scene = Scene::from_yaml(yaml).unwrap();
1909        assert_eq!(scene.bindings[0].actions.len(), 3);
1910    }
1911
1912    // =========================================================================
1913    // Empty Scene Tests
1914    // =========================================================================
1915
1916    #[test]
1917    fn test_empty_widgets() {
1918        let yaml = r#"
1919prs_version: "1.0"
1920metadata:
1921  name: "empty"
1922layout:
1923  type: flex
1924widgets: []
1925"#;
1926
1927        let scene = Scene::from_yaml(yaml).unwrap();
1928        assert!(scene.widgets.is_empty());
1929    }
1930
1931    #[test]
1932    fn test_empty_resources() {
1933        let yaml = r#"
1934prs_version: "1.0"
1935metadata:
1936  name: "test"
1937layout:
1938  type: flex
1939widgets: []
1940"#;
1941
1942        let scene = Scene::from_yaml(yaml).unwrap();
1943        assert!(scene.resources.models.is_empty());
1944        assert!(scene.resources.datasets.is_empty());
1945    }
1946
1947    #[test]
1948    fn test_empty_bindings() {
1949        let yaml = r#"
1950prs_version: "1.0"
1951metadata:
1952  name: "test"
1953layout:
1954  type: flex
1955widgets: []
1956"#;
1957
1958        let scene = Scene::from_yaml(yaml).unwrap();
1959        assert!(scene.bindings.is_empty());
1960    }
1961}