1use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::fmt;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct Scene {
22 pub prs_version: String,
24
25 pub metadata: SceneMetadata,
27
28 #[serde(default)]
30 pub resources: Resources,
31
32 pub layout: SceneLayout,
34
35 pub widgets: Vec<SceneWidget>,
37
38 #[serde(default)]
40 pub bindings: Vec<Binding>,
41
42 #[serde(default)]
44 pub theme: Option<SceneTheme>,
45
46 #[serde(default)]
48 pub permissions: Permissions,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct SceneMetadata {
54 pub name: String,
56
57 #[serde(default)]
59 pub title: Option<String>,
60
61 #[serde(default)]
63 pub description: Option<String>,
64
65 #[serde(default)]
67 pub author: Option<String>,
68
69 #[serde(default)]
71 pub created: Option<String>,
72
73 #[serde(default)]
75 pub license: Option<String>,
76
77 #[serde(default)]
79 pub tags: Vec<String>,
80}
81
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84pub struct Resources {
85 #[serde(default)]
87 pub models: HashMap<String, ModelResource>,
88
89 #[serde(default)]
91 pub datasets: HashMap<String, DatasetResource>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ModelResource {
97 #[serde(rename = "type")]
99 pub resource_type: ModelType,
100
101 pub source: ResourceSource,
103
104 #[serde(default)]
106 pub hash: Option<String>,
107
108 #[serde(default)]
110 pub size_bytes: Option<u64>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct DatasetResource {
116 #[serde(rename = "type")]
118 pub resource_type: DatasetType,
119
120 pub source: ResourceSource,
122
123 #[serde(default)]
125 pub hash: Option<String>,
126
127 #[serde(default)]
129 pub size_bytes: Option<u64>,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "lowercase")]
135pub enum ModelType {
136 Apr,
138 Gguf,
140 Safetensors,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "lowercase")]
147pub enum DatasetType {
148 Ald,
150 Parquet,
152 Csv,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
158#[serde(untagged)]
159pub enum ResourceSource {
160 Single(String),
162 Multiple(Vec<String>),
164}
165
166impl ResourceSource {
167 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct SceneLayout {
189 #[serde(rename = "type")]
191 pub layout_type: LayoutType,
192
193 #[serde(default)]
195 pub columns: Option<u32>,
196
197 #[serde(default)]
199 pub rows: Option<u32>,
200
201 #[serde(default = "default_gap")]
203 pub gap: u32,
204
205 #[serde(default)]
207 pub direction: Option<FlexDirection>,
208
209 #[serde(default)]
211 pub wrap: Option<bool>,
212
213 #[serde(default)]
215 pub width: Option<u32>,
216
217 #[serde(default)]
219 pub height: Option<u32>,
220}
221
222const fn default_gap() -> u32 {
223 16
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(rename_all = "lowercase")]
229pub enum LayoutType {
230 Grid,
232 Flex,
234 Absolute,
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "lowercase")]
241pub enum FlexDirection {
242 Row,
244 Column,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct SceneWidget {
251 pub id: String,
253
254 #[serde(rename = "type")]
256 pub widget_type: WidgetType,
257
258 #[serde(default)]
260 pub position: Option<GridPosition>,
261
262 #[serde(default)]
264 pub config: WidgetConfig,
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "snake_case")]
270pub enum WidgetType {
271 Textbox,
273 Slider,
275 Dropdown,
277 Button,
279 Image,
281 BarChart,
283 LineChart,
285 Gauge,
287 Table,
289 Markdown,
291 Inference,
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct GridPosition {
298 pub row: u32,
300 pub col: u32,
302 #[serde(default = "default_span")]
304 pub colspan: u32,
305 #[serde(default = "default_span")]
307 pub rowspan: u32,
308}
309
310const fn default_span() -> u32 {
311 1
312}
313
314#[derive(Debug, Clone, Default, Serialize, Deserialize)]
316pub struct WidgetConfig {
317 #[serde(default)]
320 pub label: Option<String>,
321 #[serde(default)]
323 pub title: Option<String>,
324
325 #[serde(default)]
328 pub placeholder: Option<String>,
329 #[serde(default)]
331 pub max_length: Option<u32>,
332
333 #[serde(default)]
336 pub min: Option<f64>,
337 #[serde(default)]
339 pub max: Option<f64>,
340 #[serde(default)]
342 pub step: Option<f64>,
343 #[serde(default)]
345 pub default: Option<f64>,
346
347 #[serde(default)]
350 pub options: Option<String>,
351 #[serde(default)]
353 pub multi_select: Option<bool>,
354
355 #[serde(default)]
358 pub action: Option<String>,
359
360 #[serde(default)]
363 pub source: Option<String>,
364 #[serde(default)]
366 pub alt: Option<String>,
367 #[serde(default)]
369 pub mode: Option<String>,
370 #[serde(default)]
372 pub accept: Option<Vec<String>>,
373
374 #[serde(default)]
377 pub data: Option<String>,
378 #[serde(default)]
380 pub x_axis: Option<String>,
381 #[serde(default)]
383 pub y_axis: Option<String>,
384
385 #[serde(default)]
388 pub value: Option<String>,
389 #[serde(default)]
391 pub thresholds: Option<Vec<Threshold>>,
392
393 #[serde(default)]
396 pub columns: Option<Vec<String>>,
397 #[serde(default)]
399 pub sortable: Option<bool>,
400
401 #[serde(default)]
404 pub content: Option<String>,
405
406 #[serde(default)]
409 pub model: Option<String>,
410 #[serde(default)]
412 pub input: Option<String>,
413 #[serde(default)]
415 pub output: Option<String>,
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct Threshold {
421 pub value: f64,
423 pub color: String,
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct Binding {
430 pub trigger: String,
432
433 #[serde(default)]
435 pub debounce_ms: Option<u32>,
436
437 pub actions: Vec<BindingAction>,
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct BindingAction {
444 pub target: String,
446
447 #[serde(default)]
449 pub action: Option<String>,
450
451 #[serde(default)]
453 pub input: Option<String>,
454}
455
456#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct SceneTheme {
459 #[serde(default)]
461 pub preset: Option<String>,
462
463 #[serde(default)]
465 pub custom: HashMap<String, String>,
466}
467
468#[derive(Debug, Clone, Default, Serialize, Deserialize)]
470pub struct Permissions {
471 #[serde(default)]
473 pub network: Vec<String>,
474
475 #[serde(default)]
477 pub filesystem: Vec<String>,
478
479 #[serde(default)]
481 pub clipboard: bool,
482
483 #[serde(default)]
485 pub camera: bool,
486}
487
488#[derive(Debug)]
490pub enum SceneError {
491 Yaml(serde_yaml::Error),
493
494 InvalidVersion(String),
496
497 DuplicateWidgetId(String),
499
500 InvalidBindingTarget {
502 trigger: String,
504 target: String,
506 },
507
508 InvalidHashFormat {
510 resource: String,
512 hash: String,
514 },
515
516 MissingRemoteHash {
518 resource: String,
520 },
521
522 InvalidExpression {
524 context: String,
526 expression: String,
528 message: String,
530 },
531
532 InvalidMetadataName(String),
534
535 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 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 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
605 serde_yaml::to_string(self)
606 }
607
608 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 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 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 if name.starts_with('-') || name.ends_with('-') {
656 return Err(SceneError::InvalidMetadataName(name.clone()));
657 }
658 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 if widget_ids.contains(target.as_str()) {
687 continue;
688 }
689
690 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 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 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 }
753 }
754 Ok(())
755 }
756
757 #[must_use]
759 pub fn widget_ids(&self) -> Vec<&str> {
760 self.widgets.iter().map(|w| w.id.as_str()).collect()
761 }
762
763 #[must_use]
765 pub fn get_widget(&self, id: &str) -> Option<&SceneWidget> {
766 self.widgets.iter().find(|w| w.id == id)
767 }
768
769 #[must_use]
771 pub fn get_model(&self, name: &str) -> Option<&ModelResource> {
772 self.resources.models.get(name)
773 }
774
775 #[must_use]
777 pub fn get_dataset(&self, name: &str) -> Option<&DatasetResource> {
778 self.resources.datasets.get(name)
779 }
780}
781
782fn is_remote_source(source: &ResourceSource) -> bool {
784 source.sources().iter().any(|s| s.starts_with("https://"))
785}
786
787fn validate_hash_format(resource: &str, hash: &str) -> Result<(), SceneError> {
789 if let Some(hex) = hash.strip_prefix("blake3:") {
790 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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); }
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); assert_eq!(pos.rowspan, 1); }
1720
1721 #[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 #[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 #[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 #[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 #[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}