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