1use std::ffi::CStr;
13
14use crate::{
15 capabilities::{
16 FrameRate, GroupType, ListFormat, ListItem, ListType, ListValue, PropertyKind,
17 PropertyMetadata,
18 },
19 data::{ImmutableObsData, ObsData, ObsDataGetters, ObsDataPointers, ObsDataSetters},
20 run_with_obs,
21 utils::{ObsError, ObsString},
22};
23
24#[derive(Clone, Debug, PartialEq)]
26pub enum PropertyValue {
27 Boolean(bool),
28 Integer(i64),
29 Float(f64),
30 String(String),
31 FrameRate(FrameRateSetting),
32 EditableList(Vec<EditableListEntry>),
33 Font(FontSetting),
34}
35
36impl PropertyValue {
37 pub fn kind_name(&self) -> &'static str {
38 match self {
39 Self::Boolean(_) => "boolean",
40 Self::Integer(_) => "integer",
41 Self::Float(_) => "float",
42 Self::String(_) => "string",
43 Self::FrameRate(_) => "frame-rate",
44 Self::EditableList(_) => "editable-list",
45 Self::Font(_) => "font",
46 }
47 }
48}
49
50#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct FrameRateSetting {
52 pub frame_rate: FrameRate,
53 pub option: Option<String>,
54}
55
56impl From<FrameRate> for PropertyValue {
57 fn from(frame_rate: FrameRate) -> Self {
58 Self::FrameRate(FrameRateSetting {
59 frame_rate,
60 option: None,
61 })
62 }
63}
64
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct EditableListEntry {
67 pub value: String,
68 pub uuid: Option<String>,
69 pub selected: bool,
70 pub hidden: bool,
71}
72
73impl EditableListEntry {
74 pub fn new(value: impl Into<String>) -> Self {
75 Self {
76 value: value.into(),
77 uuid: None,
78 selected: false,
79 hidden: false,
80 }
81 }
82}
83
84#[derive(Clone, Debug, PartialEq, Eq)]
85pub struct FontSetting {
86 pub face: String,
87 pub style: String,
88 pub size: i64,
89 pub flags: u32,
90}
91
92fn list_value_to_property_value(value: &ListValue) -> Option<PropertyValue> {
93 match value {
94 ListValue::String(value) => Some(PropertyValue::String(value.clone())),
95 ListValue::Int(value) => Some(PropertyValue::Integer(*value)),
96 ListValue::Float(value) => Some(PropertyValue::Float(*value)),
97 ListValue::Bool(value) => Some(PropertyValue::Boolean(*value)),
98 ListValue::UnknownFormat(_) => None,
99 }
100}
101
102#[derive(Clone, Debug, PartialEq)]
105pub struct PropertyState {
106 pub metadata: PropertyMetadata,
107 pub current_value: Option<PropertyValue>,
108 pub default_value: Option<PropertyValue>,
109}
110
111#[derive(Clone, Debug, PartialEq)]
113pub struct SettingsSnapshot {
114 schema: SettingsSchema,
115 states: Vec<PropertyState>,
116}
117
118impl SettingsSnapshot {
119 pub fn schema(&self) -> &SettingsSchema {
120 &self.schema
121 }
122
123 pub fn states(&self) -> &[PropertyState] {
124 &self.states
125 }
126
127 pub fn state(&self, name: &str) -> Option<&PropertyState> {
128 self.states.iter().find(|state| state.metadata.name == name)
129 }
130}
131
132#[derive(Clone, Debug, Default, PartialEq)]
134pub struct SettingsSchema {
135 properties: Vec<PropertyMetadata>,
136}
137
138impl SettingsSchema {
139 pub fn new(properties: Vec<PropertyMetadata>) -> Self {
140 Self { properties }
141 }
142
143 pub fn properties(&self) -> &[PropertyMetadata] {
144 &self.properties
145 }
146
147 pub fn property(&self, name: &str) -> Option<&PropertyMetadata> {
149 find_property(&self.properties, name)
150 }
151
152 pub fn validate(&self, name: &str, value: &PropertyValue) -> Result<(), ObsError> {
154 let property = self
155 .property(name)
156 .ok_or_else(|| ObsError::PropertyNotFound {
157 name: name.to_owned(),
158 })?;
159 validate_property_value(property, value)
160 }
161
162 pub fn set(
164 &self,
165 settings: &mut ObsData,
166 name: &str,
167 value: PropertyValue,
168 ) -> Result<(), ObsError> {
169 self.validate(name, &value)?;
170 match value {
171 PropertyValue::Boolean(value) => {
172 settings.set_bool(name, value)?;
173 }
174 PropertyValue::Integer(value) => {
175 settings.set_int(name, value)?;
176 }
177 PropertyValue::Float(value) => {
178 settings.set_double(name, value)?;
179 }
180 PropertyValue::String(value) => {
181 settings.set_string(name, value)?;
182 }
183 PropertyValue::FrameRate(value) => {
184 set_frame_rate(settings, name, value)?;
185 }
186 PropertyValue::EditableList(entries) => {
187 set_editable_list(settings, name, entries)?;
188 }
189 PropertyValue::Font(font) => {
190 set_font(settings, name, font)?;
191 }
192 }
193 Ok(())
194 }
195
196 pub fn value(&self, settings: &ObsData, name: &str) -> Result<Option<PropertyValue>, ObsError> {
198 let property = self
199 .property(name)
200 .ok_or_else(|| ObsError::PropertyNotFound {
201 name: name.to_owned(),
202 })?;
203 read_property_value(settings, property)
204 }
205
206 pub fn snapshot(
208 &self,
209 current: &ObsData,
210 defaults: Option<&ImmutableObsData>,
211 ) -> Result<SettingsSnapshot, ObsError> {
212 if let Some(defaults) = defaults {
213 current.runtime().ensure_same_runtime(defaults.runtime())?;
214 }
215 let mut states = Vec::new();
216 collect_property_states(&self.properties, current, defaults, &mut states)?;
217 Ok(SettingsSnapshot {
218 schema: self.clone(),
219 states,
220 })
221 }
222}
223
224impl PropertyMetadata {
225 pub fn enabled_list_values(&self) -> Vec<PropertyValue> {
227 match &self.kind {
228 PropertyKind::List { items, .. } => items
229 .iter()
230 .filter(|item| !item.disabled)
231 .filter_map(|item| list_value_to_property_value(&item.value))
232 .collect(),
233 _ => Vec::new(),
234 }
235 }
236}
237
238fn collect_property_states(
239 properties: &[PropertyMetadata],
240 current: &ObsData,
241 defaults: Option<&ImmutableObsData>,
242 states: &mut Vec<PropertyState>,
243) -> Result<(), ObsError> {
244 for property in properties {
245 states.push(PropertyState {
246 metadata: property.clone(),
247 current_value: read_property_value(current, property)?,
248 default_value: match defaults {
249 Some(defaults) => read_property_value(defaults, property)?,
250 None => None,
251 },
252 });
253 if let PropertyKind::Group { properties, .. } = &property.kind {
254 collect_property_states(properties, current, defaults, states)?;
255 }
256 }
257 Ok(())
258}
259
260fn find_property<'a>(
261 properties: &'a [PropertyMetadata],
262 name: &str,
263) -> Option<&'a PropertyMetadata> {
264 for property in properties {
265 if property.name == name {
266 return Some(property);
267 }
268 if let PropertyKind::Group { properties, .. } = &property.kind {
269 if let Some(found) = find_property(properties, name) {
270 return Some(found);
271 }
272 }
273 }
274 None
275}
276
277fn expected_kind_name(kind: &PropertyKind) -> &'static str {
278 match kind {
279 PropertyKind::Bool => "boolean",
280 PropertyKind::Integer { .. } | PropertyKind::Color | PropertyKind::ColorAlpha => "integer",
281 PropertyKind::Float { .. } => "float",
282 PropertyKind::Text { .. } | PropertyKind::Path { .. } => "string",
283 PropertyKind::List { format, .. } => match format {
284 ListFormat::String => "string",
285 ListFormat::Int => "integer",
286 ListFormat::Float => "float",
287 ListFormat::Bool => "boolean",
288 _ => "list value",
289 },
290 PropertyKind::FrameRate { .. } => "frame-rate",
291 PropertyKind::EditableList { .. } => "editable-list",
292 PropertyKind::Font => "font",
293 PropertyKind::Group {
294 group_type: GroupType::Checkable,
295 ..
296 } => "boolean",
297 PropertyKind::Group { .. } => "non-value/structural",
298 PropertyKind::Invalid | PropertyKind::Button { .. } | PropertyKind::Unknown(_) => {
299 "non-value/action"
300 }
301 }
302}
303
304fn type_mismatch(property: &PropertyMetadata, value: &PropertyValue) -> ObsError {
305 ObsError::PropertyValueTypeMismatch {
306 name: property.name.clone(),
307 expected: expected_kind_name(&property.kind).to_owned(),
308 actual: value.kind_name().to_owned(),
309 }
310}
311
312fn validate_property_value(
313 property: &PropertyMetadata,
314 value: &PropertyValue,
315) -> Result<(), ObsError> {
316 match (&property.kind, value) {
317 (PropertyKind::Bool, PropertyValue::Boolean(_))
318 | (
319 PropertyKind::Group {
320 group_type: GroupType::Checkable,
321 ..
322 },
323 PropertyValue::Boolean(_),
324 )
325 | (PropertyKind::Text { .. }, PropertyValue::String(_))
326 | (PropertyKind::Path { .. }, PropertyValue::String(_))
327 | (PropertyKind::Color, PropertyValue::Integer(_))
328 | (PropertyKind::ColorAlpha, PropertyValue::Integer(_)) => Ok(()),
329 (PropertyKind::Integer { min, max, step, .. }, PropertyValue::Integer(value)) => {
330 let min = i64::from(*min);
331 let max = i64::from(*max);
332 if *value < min || *value > max {
333 return Err(ObsError::PropertyValueOutOfRange {
334 name: property.name.clone(),
335 value: value.to_string(),
336 min: min.to_string(),
337 max: max.to_string(),
338 });
339 }
340 if *step > 0 && (*value - min) % i64::from(*step) != 0 {
341 return Err(ObsError::PropertyValueNotAllowed {
342 name: property.name.clone(),
343 value: value.to_string(),
344 });
345 }
346 Ok(())
347 }
348 (PropertyKind::Float { min, max, .. }, PropertyValue::Float(value)) => {
349 if !value.is_finite() || *value < *min || *value > *max {
350 return Err(ObsError::PropertyValueOutOfRange {
351 name: property.name.clone(),
352 value: value.to_string(),
353 min: min.to_string(),
354 max: max.to_string(),
355 });
356 }
357 Ok(())
358 }
359 (
360 PropertyKind::List {
361 list_type,
362 format,
363 items,
364 },
365 value,
366 ) => validate_list_value(property, *list_type, *format, items, value),
367 (PropertyKind::FrameRate { options, ranges }, PropertyValue::FrameRate(value)) => {
368 if let Some(option) = value.option.as_deref() {
369 if options
370 .iter()
371 .any(|candidate| candidate.name.as_deref() == Some(option))
372 {
373 return Ok(());
374 }
375 return Err(ObsError::PropertyValueNotAllowed {
376 name: property.name.clone(),
377 value: option.to_owned(),
378 });
379 }
380
381 let fps = value.frame_rate;
382 if fps.denominator == 0 {
383 return Err(ObsError::PropertyValueNotAllowed {
384 name: property.name.clone(),
385 value: format!("{}/{}", fps.numerator, fps.denominator),
386 });
387 }
388 if ranges.is_empty()
389 || ranges
390 .iter()
391 .any(|range| frame_rate_in_range(fps, range.min, range.max))
392 {
393 Ok(())
394 } else {
395 Err(ObsError::PropertyValueNotAllowed {
396 name: property.name.clone(),
397 value: format!("{}/{}", fps.numerator, fps.denominator),
398 })
399 }
400 }
401 (PropertyKind::EditableList { .. }, PropertyValue::EditableList(_))
402 | (PropertyKind::Font, PropertyValue::Font(_)) => Ok(()),
403 (
404 PropertyKind::Invalid
405 | PropertyKind::Button { .. }
406 | PropertyKind::Group { .. }
407 | PropertyKind::Unknown(_),
408 _,
409 ) => Err(ObsError::PropertyValueUnsupported {
410 name: property.name.clone(),
411 property_type: format!("{:?}", property.kind),
412 }),
413 _ => Err(type_mismatch(property, value)),
414 }
415}
416
417fn validate_list_value(
418 property: &PropertyMetadata,
419 list_type: ListType,
420 format: ListFormat,
421 items: &[ListItem],
422 value: &PropertyValue,
423) -> Result<(), ObsError> {
424 let type_matches = matches!(
425 (format, value),
426 (ListFormat::String, PropertyValue::String(_))
427 | (ListFormat::Int, PropertyValue::Integer(_))
428 | (ListFormat::Float, PropertyValue::Float(_))
429 | (ListFormat::Bool, PropertyValue::Boolean(_))
430 );
431 if !type_matches {
432 return Err(type_mismatch(property, value));
433 }
434
435 if matches!(list_type, ListType::Editable) {
436 return Ok(());
437 }
438 if items
439 .iter()
440 .filter(|item| !item.disabled)
441 .filter_map(|item| list_value_to_property_value(&item.value))
442 .any(|item| item == *value)
443 {
444 Ok(())
445 } else {
446 Err(ObsError::PropertyValueNotAllowed {
447 name: property.name.clone(),
448 value: format!("{value:?}"),
449 })
450 }
451}
452
453fn frame_rate_in_range(value: FrameRate, min: FrameRate, max: FrameRate) -> bool {
454 let value = value.numerator as f64 / value.denominator as f64;
455 let min = min.numerator as f64 / min.denominator.max(1) as f64;
456 let max = max.numerator as f64 / max.denominator.max(1) as f64;
457 value >= min && value <= max
458}
459
460fn set_frame_rate(settings: &ObsData, name: &str, value: FrameRateSetting) -> Result<(), ObsError> {
461 let name = ObsString::new(name);
462 let data = settings.as_ptr();
463 run_with_obs!(settings.runtime(), (name, data, value), move || unsafe {
464 let fps = libobs::media_frames_per_second {
467 numerator: value.frame_rate.numerator,
468 denominator: value.frame_rate.denominator,
469 };
470 let option = value.option.as_deref().map(ObsString::new);
471 libobs::obs_data_set_frames_per_second(
472 data.get_ptr(),
473 name.as_ptr().0,
474 fps,
475 option
476 .as_ref()
477 .map_or(std::ptr::null(), |option| option.as_ptr().0),
478 );
479 })
480}
481
482fn set_editable_list(
483 settings: &ObsData,
484 name: &str,
485 entries: Vec<EditableListEntry>,
486) -> Result<(), ObsError> {
487 let name = ObsString::new(name);
488 let data = settings.as_ptr();
489 run_with_obs!(settings.runtime(), (name, data, entries), move || unsafe {
490 let array = libobs::obs_data_array_create();
493 if array.is_null() {
494 return Err(ObsError::NullPointer(Some(
495 "creating editable-list data array".into(),
496 )));
497 }
498 let value_key = ObsString::new("value");
499 let uuid_key = ObsString::new("uuid");
500 let selected_key = ObsString::new("selected");
501 let hidden_key = ObsString::new("hidden");
502 for entry in entries {
503 let item = libobs::obs_data_create();
504 if item.is_null() {
505 libobs::obs_data_array_release(array);
506 return Err(ObsError::NullPointer(Some(
507 "creating editable-list item".into(),
508 )));
509 }
510 let value = ObsString::new(entry.value);
511 libobs::obs_data_set_string(item, value_key.as_ptr().0, value.as_ptr().0);
512 if let Some(uuid) = entry.uuid {
513 let uuid = ObsString::new(uuid);
514 libobs::obs_data_set_string(item, uuid_key.as_ptr().0, uuid.as_ptr().0);
515 }
516 libobs::obs_data_set_bool(item, selected_key.as_ptr().0, entry.selected);
517 libobs::obs_data_set_bool(item, hidden_key.as_ptr().0, entry.hidden);
518 libobs::obs_data_array_push_back(array, item);
519 libobs::obs_data_release(item);
520 }
521 libobs::obs_data_set_array(data.get_ptr(), name.as_ptr().0, array);
522 libobs::obs_data_array_release(array);
523 Ok(())
524 })?
525}
526
527fn set_font(settings: &ObsData, name: &str, font: FontSetting) -> Result<(), ObsError> {
528 let name = ObsString::new(name);
529 let data = settings.as_ptr();
530 run_with_obs!(settings.runtime(), (name, data, font), move || unsafe {
531 let object = libobs::obs_data_create();
533 if object.is_null() {
534 return Err(ObsError::NullPointer(Some(
535 "creating font settings object".into(),
536 )));
537 }
538 let face_key = ObsString::new("face");
539 let style_key = ObsString::new("style");
540 let size_key = ObsString::new("size");
541 let flags_key = ObsString::new("flags");
542 let face = ObsString::new(font.face);
543 let style = ObsString::new(font.style);
544 libobs::obs_data_set_string(object, face_key.as_ptr().0, face.as_ptr().0);
545 libobs::obs_data_set_string(object, style_key.as_ptr().0, style.as_ptr().0);
546 libobs::obs_data_set_int(object, size_key.as_ptr().0, font.size);
547 libobs::obs_data_set_int(object, flags_key.as_ptr().0, i64::from(font.flags));
548 libobs::obs_data_set_obj(data.get_ptr(), name.as_ptr().0, object);
549 libobs::obs_data_release(object);
550 Ok(())
551 })?
552}
553
554fn read_frame_rate<T>(settings: &T, name: &str) -> Result<Option<PropertyValue>, ObsError>
555where
556 T: ObsDataPointers,
557{
558 let name = ObsString::new(name);
559 let data = settings.as_ptr();
560 run_with_obs!(settings.runtime(), (name, data), move || unsafe {
561 let mut fps = libobs::media_frames_per_second {
563 numerator: 0,
564 denominator: 0,
565 };
566 let mut option = std::ptr::null();
567 if !libobs::obs_data_get_frames_per_second(
568 data.get_ptr(),
569 name.as_ptr().0,
570 &mut fps,
571 &mut option,
572 ) {
573 return None;
574 }
575 let option = if option.is_null() {
576 None
577 } else {
578 Some(CStr::from_ptr(option).to_string_lossy().into_owned())
579 };
580 Some(PropertyValue::FrameRate(FrameRateSetting {
581 frame_rate: FrameRate {
582 numerator: fps.numerator,
583 denominator: fps.denominator,
584 },
585 option,
586 }))
587 })
588}
589
590fn read_editable_list<T>(settings: &T, name: &str) -> Result<Option<PropertyValue>, ObsError>
591where
592 T: ObsDataPointers,
593{
594 let name = ObsString::new(name);
595 let data = settings.as_ptr();
596 run_with_obs!(settings.runtime(), (name, data), move || unsafe {
597 let array = libobs::obs_data_get_array(data.get_ptr(), name.as_ptr().0);
600 if array.is_null() {
601 return None;
602 }
603 let value_key = ObsString::new("value");
604 let uuid_key = ObsString::new("uuid");
605 let selected_key = ObsString::new("selected");
606 let hidden_key = ObsString::new("hidden");
607 let mut entries = Vec::with_capacity(libobs::obs_data_array_count(array));
608 for index in 0..libobs::obs_data_array_count(array) {
609 let item = libobs::obs_data_array_item(array, index);
610 if item.is_null() {
611 continue;
612 }
613 let value_ptr = libobs::obs_data_get_string(item, value_key.as_ptr().0);
614 let uuid_ptr = libobs::obs_data_get_string(item, uuid_key.as_ptr().0);
615 let value = if value_ptr.is_null() {
616 String::new()
617 } else {
618 CStr::from_ptr(value_ptr).to_string_lossy().into_owned()
619 };
620 let uuid = if uuid_ptr.is_null() {
621 None
622 } else {
623 let uuid = CStr::from_ptr(uuid_ptr).to_string_lossy().into_owned();
624 (!uuid.is_empty()).then_some(uuid)
625 };
626 entries.push(EditableListEntry {
627 value,
628 uuid,
629 selected: libobs::obs_data_get_bool(item, selected_key.as_ptr().0),
630 hidden: libobs::obs_data_get_bool(item, hidden_key.as_ptr().0),
631 });
632 libobs::obs_data_release(item);
633 }
634 libobs::obs_data_array_release(array);
635 Some(PropertyValue::EditableList(entries))
636 })
637}
638
639fn read_font<T>(settings: &T, name: &str) -> Result<Option<PropertyValue>, ObsError>
640where
641 T: ObsDataPointers,
642{
643 let name = ObsString::new(name);
644 let data = settings.as_ptr();
645 run_with_obs!(settings.runtime(), (name, data), move || unsafe {
646 let object = libobs::obs_data_get_obj(data.get_ptr(), name.as_ptr().0);
648 if object.is_null() {
649 return None;
650 }
651 let face_key = ObsString::new("face");
652 let style_key = ObsString::new("style");
653 let size_key = ObsString::new("size");
654 let flags_key = ObsString::new("flags");
655 let face_ptr = libobs::obs_data_get_string(object, face_key.as_ptr().0);
656 let style_ptr = libobs::obs_data_get_string(object, style_key.as_ptr().0);
657 let face = if face_ptr.is_null() {
658 String::new()
659 } else {
660 CStr::from_ptr(face_ptr).to_string_lossy().into_owned()
661 };
662 let style = if style_ptr.is_null() {
663 String::new()
664 } else {
665 CStr::from_ptr(style_ptr).to_string_lossy().into_owned()
666 };
667 let result = PropertyValue::Font(FontSetting {
668 face,
669 style,
670 size: libobs::obs_data_get_int(object, size_key.as_ptr().0),
671 flags: libobs::obs_data_get_int(object, flags_key.as_ptr().0) as u32,
672 });
673 libobs::obs_data_release(object);
674 Some(result)
675 })
676}
677
678fn read_property_value<T>(
679 settings: &T,
680 property: &PropertyMetadata,
681) -> Result<Option<PropertyValue>, ObsError>
682where
683 T: ObsDataGetters + ObsDataPointers,
684{
685 match &property.kind {
686 PropertyKind::Bool
687 | PropertyKind::Group {
688 group_type: GroupType::Checkable,
689 ..
690 } => settings
691 .get_bool(property.name.as_str())
692 .map(|value| value.map(PropertyValue::Boolean)),
693 PropertyKind::Integer { .. } | PropertyKind::Color | PropertyKind::ColorAlpha => settings
694 .get_int(property.name.as_str())
695 .map(|value| value.map(PropertyValue::Integer)),
696 PropertyKind::Float { .. } => settings
697 .get_double(property.name.as_str())
698 .map(|value| value.map(PropertyValue::Float)),
699 PropertyKind::Text { .. } | PropertyKind::Path { .. } => settings
700 .get_string(property.name.as_str())
701 .map(|value| value.map(PropertyValue::String)),
702 PropertyKind::List { format, .. } => match format {
703 ListFormat::String => settings
704 .get_string(property.name.as_str())
705 .map(|value| value.map(PropertyValue::String)),
706 ListFormat::Int => settings
707 .get_int(property.name.as_str())
708 .map(|value| value.map(PropertyValue::Integer)),
709 ListFormat::Float => settings
710 .get_double(property.name.as_str())
711 .map(|value| value.map(PropertyValue::Float)),
712 ListFormat::Bool => settings
713 .get_bool(property.name.as_str())
714 .map(|value| value.map(PropertyValue::Boolean)),
715 _ => Ok(None),
716 },
717 PropertyKind::FrameRate { .. } => read_frame_rate(settings, property.name.as_str()),
718 PropertyKind::EditableList { .. } => read_editable_list(settings, property.name.as_str()),
719 PropertyKind::Font => read_font(settings, property.name.as_str()),
720 _ => Ok(None),
721 }
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727 use crate::capabilities::NumberControl;
728
729 fn integer_property() -> PropertyMetadata {
730 PropertyMetadata {
731 name: "bitrate".into(),
732 description: None,
733 long_description: None,
734 enabled: true,
735 visible: true,
736 kind: PropertyKind::Integer {
737 min: 100,
738 max: 10_000,
739 step: 50,
740 suffix: None,
741 control: NumberControl::Scroller,
742 },
743 }
744 }
745
746 #[test]
747 fn integer_validation_enforces_range_and_step() {
748 let schema = SettingsSchema::new(vec![integer_property()]);
749 assert!(schema
750 .validate("bitrate", &PropertyValue::Integer(6_000))
751 .is_ok());
752 assert!(matches!(
753 schema.validate("bitrate", &PropertyValue::Integer(6_025)),
754 Err(ObsError::PropertyValueNotAllowed { .. })
755 ));
756 assert!(matches!(
757 schema.validate("bitrate", &PropertyValue::Integer(20_000)),
758 Err(ObsError::PropertyValueOutOfRange { .. })
759 ));
760 }
761
762 #[test]
763 fn only_checkable_groups_have_boolean_values() {
764 let structural = PropertyMetadata {
765 name: "advanced".into(),
766 description: None,
767 long_description: None,
768 enabled: true,
769 visible: true,
770 kind: PropertyKind::Group {
771 group_type: GroupType::Normal,
772 properties: Vec::new(),
773 },
774 };
775 let checkable = PropertyMetadata {
776 name: "enabled_group".into(),
777 description: None,
778 long_description: None,
779 enabled: true,
780 visible: true,
781 kind: PropertyKind::Group {
782 group_type: GroupType::Checkable,
783 properties: Vec::new(),
784 },
785 };
786 let schema = SettingsSchema::new(vec![structural, checkable]);
787 assert!(matches!(
788 schema.validate("advanced", &PropertyValue::Boolean(true)),
789 Err(ObsError::PropertyValueUnsupported { .. })
790 ));
791 assert!(schema
792 .validate("enabled_group", &PropertyValue::Boolean(true))
793 .is_ok());
794 }
795
796 #[test]
797 fn frame_rate_named_options_must_be_advertised_by_obs() {
798 let schema = SettingsSchema::new(vec![PropertyMetadata {
799 name: "fps".into(),
800 description: None,
801 long_description: None,
802 enabled: true,
803 visible: true,
804 kind: PropertyKind::FrameRate {
805 options: vec![crate::capabilities::FrameRateOption {
806 name: Some("match-output".into()),
807 description: None,
808 }],
809 ranges: Vec::new(),
810 },
811 }]);
812 let named = PropertyValue::FrameRate(FrameRateSetting {
813 frame_rate: FrameRate {
814 numerator: 0,
815 denominator: 0,
816 },
817 option: Some("match-output".into()),
818 });
819 assert!(schema.validate("fps", &named).is_ok());
820
821 let invalid = PropertyValue::FrameRate(FrameRateSetting {
822 frame_rate: FrameRate {
823 numerator: 0,
824 denominator: 0,
825 },
826 option: Some("not-advertised".into()),
827 });
828 assert!(matches!(
829 schema.validate("fps", &invalid),
830 Err(ObsError::PropertyValueNotAllowed { .. })
831 ));
832 }
833}