Skip to main content

sonos_api/services/rendering_control/
operations.rs

1//! RenderingControl service operations
2//!
3//! This module provides operations for controlling audio rendering settings on individual
4//! Sonos speakers. All operations use the `UPnPOperation` trait pattern.
5//!
6//! # Operations
7//! - `get_volume` / `set_volume` - Get/set volume level (0-100)
8//! - `set_relative_volume` - Adjust volume relatively (-100 to +100)
9//! - `get_mute` / `set_mute` - Get/set mute state
10//! - `get_bass` / `set_bass` - Get/set bass level (-10 to +10)
11//! - `get_treble` / `set_treble` - Get/set treble level (-10 to +10)
12//! - `get_loudness` / `set_loudness` - Get/set loudness compensation
13
14use crate::operation::{parse_sonos_bool, validate_channel};
15use crate::{define_operation_with_response, define_upnp_operation, Validate};
16use paste::paste;
17
18// Operation with complex response parsing and channel validation
19define_operation_with_response! {
20    operation: GetVolumeOperation,
21    action: "GetVolume",
22    service: RenderingControl,
23    request: {
24        channel: String,
25    },
26    response: GetVolumeResponse {
27        current_volume: u8,
28    },
29    xml_mapping: {
30        current_volume: "CurrentVolume",
31    },
32}
33
34// Custom validation implementation for GetVolumeOperation (channel validation)
35impl Validate for GetVolumeOperationRequest {
36    fn validate_basic(&self) -> Result<(), crate::operation::ValidationError> {
37        validate_channel(&self.channel)
38    }
39}
40
41// Operation with volume range validation and channel validation
42define_upnp_operation! {
43    operation: SetVolumeOperation,
44    action: "SetVolume",
45    service: RenderingControl,
46    request: {
47        channel: String,
48        desired_volume: u8,
49    },
50    response: (),
51    payload: |req| {
52        format!(
53            "<InstanceID>{}</InstanceID><Channel>{}</Channel><DesiredVolume>{}</DesiredVolume>",
54            req.instance_id, req.channel, req.desired_volume
55        )
56    },
57    parse: |_xml| Ok(()),
58}
59
60// Custom validation implementation for SetVolumeOperation (range + channel validation)
61impl Validate for SetVolumeOperationRequest {
62    fn validate_basic(&self) -> Result<(), crate::operation::ValidationError> {
63        if self.desired_volume > 100 {
64            return Err(crate::operation::ValidationError::range_error(
65                "desired_volume",
66                0,
67                100,
68                self.desired_volume,
69            ));
70        }
71        validate_channel(&self.channel)
72    }
73}
74
75// Operation with adjustment range validation, channel validation, and response parsing
76define_operation_with_response! {
77    operation: SetRelativeVolumeOperation,
78    action: "SetRelativeVolume",
79    service: RenderingControl,
80    request: {
81        channel: String,
82        adjustment: i8,
83    },
84    response: SetRelativeVolumeResponse {
85        new_volume: u8,
86    },
87    xml_mapping: {
88        new_volume: "NewVolume",
89    },
90}
91
92// Custom validation implementation for SetRelativeVolumeOperation (range + channel validation)
93impl Validate for SetRelativeVolumeOperationRequest {
94    fn validate_basic(&self) -> Result<(), crate::operation::ValidationError> {
95        // i8 range is already enforced by type, but we can check reasonable bounds
96        if self.adjustment < -100 || self.adjustment > 100 {
97            return Err(crate::operation::ValidationError::range_error(
98                "adjustment",
99                -100,
100                100,
101                self.adjustment,
102            ));
103        }
104        validate_channel(&self.channel)
105    }
106}
107
108// =============================================================================
109// GET MUTE
110// =============================================================================
111
112// Manual implementation because Sonos returns "0"/"1" for bools, not "true"/"false",
113// and the define_operation_with_response! macro's .parse::<bool>() only handles "true"/"false".
114#[derive(serde::Serialize, Clone, Debug, PartialEq)]
115pub struct GetMuteOperationRequest {
116    pub channel: String,
117    pub instance_id: u32,
118}
119
120#[derive(serde::Deserialize, Debug, Clone, PartialEq)]
121pub struct GetMuteResponse {
122    pub current_mute: bool,
123}
124
125pub struct GetMuteOperation;
126
127impl crate::operation::UPnPOperation for GetMuteOperation {
128    type Request = GetMuteOperationRequest;
129    type Response = GetMuteResponse;
130
131    const SERVICE: crate::service::Service = crate::service::Service::RenderingControl;
132    const ACTION: &'static str = "GetMute";
133
134    fn build_payload(request: &Self::Request) -> Result<String, crate::operation::ValidationError> {
135        request.validate(crate::operation::ValidationLevel::Basic)?;
136        Ok(format!(
137            "<InstanceID>{}</InstanceID><Channel>{}</Channel>",
138            request.instance_id, request.channel
139        ))
140    }
141
142    fn parse_response(xml: &xmltree::Element) -> Result<Self::Response, crate::error::ApiError> {
143        Ok(GetMuteResponse {
144            current_mute: parse_sonos_bool(xml, "CurrentMute"),
145        })
146    }
147}
148
149pub fn get_mute_operation(channel: String) -> crate::operation::OperationBuilder<GetMuteOperation> {
150    let request = GetMuteOperationRequest {
151        channel,
152        instance_id: 0,
153    };
154    crate::operation::OperationBuilder::new(request)
155}
156
157impl Validate for GetMuteOperationRequest {
158    fn validate_basic(&self) -> Result<(), crate::operation::ValidationError> {
159        validate_channel(&self.channel)
160    }
161}
162
163pub use get_mute_operation as get_mute;
164
165// =============================================================================
166// SET MUTE
167// =============================================================================
168
169define_upnp_operation! {
170    operation: SetMuteOperation,
171    action: "SetMute",
172    service: RenderingControl,
173    request: {
174        channel: String,
175        desired_mute: bool,
176    },
177    response: (),
178    payload: |req| {
179        format!(
180            "<InstanceID>{}</InstanceID><Channel>{}</Channel><DesiredMute>{}</DesiredMute>",
181            req.instance_id, req.channel, if req.desired_mute { "1" } else { "0" }
182        )
183    },
184    parse: |_xml| Ok(()),
185}
186
187impl Validate for SetMuteOperationRequest {
188    fn validate_basic(&self) -> Result<(), crate::operation::ValidationError> {
189        validate_channel(&self.channel)
190    }
191}
192
193pub use set_mute_operation as set_mute;
194
195// =============================================================================
196// GET BASS
197// =============================================================================
198
199define_operation_with_response! {
200    operation: GetBassOperation,
201    action: "GetBass",
202    service: RenderingControl,
203    request: {},
204    response: GetBassResponse {
205        current_bass: i8,
206    },
207    xml_mapping: {
208        current_bass: "CurrentBass",
209    },
210}
211
212impl Validate for GetBassOperationRequest {}
213
214pub use get_bass_operation as get_bass;
215
216// =============================================================================
217// SET BASS
218// =============================================================================
219
220define_upnp_operation! {
221    operation: SetBassOperation,
222    action: "SetBass",
223    service: RenderingControl,
224    request: {
225        desired_bass: i8,
226    },
227    response: (),
228    payload: |req| {
229        format!(
230            "<InstanceID>{}</InstanceID><DesiredBass>{}</DesiredBass>",
231            req.instance_id, req.desired_bass
232        )
233    },
234    parse: |_xml| Ok(()),
235}
236
237impl Validate for SetBassOperationRequest {
238    fn validate_basic(&self) -> Result<(), crate::operation::ValidationError> {
239        if self.desired_bass < -10 || self.desired_bass > 10 {
240            return Err(crate::operation::ValidationError::range_error(
241                "desired_bass",
242                -10,
243                10,
244                self.desired_bass,
245            ));
246        }
247        Ok(())
248    }
249}
250
251pub use set_bass_operation as set_bass;
252
253// =============================================================================
254// GET TREBLE
255// =============================================================================
256
257define_operation_with_response! {
258    operation: GetTrebleOperation,
259    action: "GetTreble",
260    service: RenderingControl,
261    request: {},
262    response: GetTrebleResponse {
263        current_treble: i8,
264    },
265    xml_mapping: {
266        current_treble: "CurrentTreble",
267    },
268}
269
270impl Validate for GetTrebleOperationRequest {}
271
272pub use get_treble_operation as get_treble;
273
274// =============================================================================
275// SET TREBLE
276// =============================================================================
277
278define_upnp_operation! {
279    operation: SetTrebleOperation,
280    action: "SetTreble",
281    service: RenderingControl,
282    request: {
283        desired_treble: i8,
284    },
285    response: (),
286    payload: |req| {
287        format!(
288            "<InstanceID>{}</InstanceID><DesiredTreble>{}</DesiredTreble>",
289            req.instance_id, req.desired_treble
290        )
291    },
292    parse: |_xml| Ok(()),
293}
294
295impl Validate for SetTrebleOperationRequest {
296    fn validate_basic(&self) -> Result<(), crate::operation::ValidationError> {
297        if self.desired_treble < -10 || self.desired_treble > 10 {
298            return Err(crate::operation::ValidationError::range_error(
299                "desired_treble",
300                -10,
301                10,
302                self.desired_treble,
303            ));
304        }
305        Ok(())
306    }
307}
308
309pub use set_treble_operation as set_treble;
310
311// =============================================================================
312// GET LOUDNESS
313// =============================================================================
314
315// Manual implementation for bool "0"/"1" parsing (same reason as GetMute).
316#[derive(serde::Serialize, Clone, Debug, PartialEq)]
317pub struct GetLoudnessOperationRequest {
318    pub channel: String,
319    pub instance_id: u32,
320}
321
322#[derive(serde::Deserialize, Debug, Clone, PartialEq)]
323pub struct GetLoudnessResponse {
324    pub current_loudness: bool,
325}
326
327pub struct GetLoudnessOperation;
328
329impl crate::operation::UPnPOperation for GetLoudnessOperation {
330    type Request = GetLoudnessOperationRequest;
331    type Response = GetLoudnessResponse;
332
333    const SERVICE: crate::service::Service = crate::service::Service::RenderingControl;
334    const ACTION: &'static str = "GetLoudness";
335
336    fn build_payload(request: &Self::Request) -> Result<String, crate::operation::ValidationError> {
337        request.validate(crate::operation::ValidationLevel::Basic)?;
338        Ok(format!(
339            "<InstanceID>{}</InstanceID><Channel>{}</Channel>",
340            request.instance_id, request.channel
341        ))
342    }
343
344    fn parse_response(xml: &xmltree::Element) -> Result<Self::Response, crate::error::ApiError> {
345        Ok(GetLoudnessResponse {
346            current_loudness: parse_sonos_bool(xml, "CurrentLoudness"),
347        })
348    }
349}
350
351pub fn get_loudness_operation(
352    channel: String,
353) -> crate::operation::OperationBuilder<GetLoudnessOperation> {
354    let request = GetLoudnessOperationRequest {
355        channel,
356        instance_id: 0,
357    };
358    crate::operation::OperationBuilder::new(request)
359}
360
361impl Validate for GetLoudnessOperationRequest {
362    fn validate_basic(&self) -> Result<(), crate::operation::ValidationError> {
363        validate_channel(&self.channel)
364    }
365}
366
367pub use get_loudness_operation as get_loudness;
368
369// =============================================================================
370// SET LOUDNESS
371// =============================================================================
372
373define_upnp_operation! {
374    operation: SetLoudnessOperation,
375    action: "SetLoudness",
376    service: RenderingControl,
377    request: {
378        channel: String,
379        desired_loudness: bool,
380    },
381    response: (),
382    payload: |req| {
383        format!(
384            "<InstanceID>{}</InstanceID><Channel>{}</Channel><DesiredLoudness>{}</DesiredLoudness>",
385            req.instance_id, req.channel, if req.desired_loudness { "1" } else { "0" }
386        )
387    },
388    parse: |_xml| Ok(()),
389}
390
391impl Validate for SetLoudnessOperationRequest {
392    fn validate_basic(&self) -> Result<(), crate::operation::ValidationError> {
393        validate_channel(&self.channel)
394    }
395}
396
397pub use set_loudness_operation as set_loudness;
398
399// Legacy convenience functions for backward compatibility
400pub use get_volume_operation as get_volume;
401pub use set_relative_volume_operation as set_relative_volume;
402pub use set_volume_operation as set_volume;
403
404/// Service identifier for RenderingControl
405pub const SERVICE: crate::Service = crate::Service::RenderingControl;
406
407/// Subscribe to RenderingControl events
408///
409/// This is a convenience function that subscribes to RenderingControl service events.
410/// Events include volume changes, mute state changes, etc.
411///
412/// # Arguments
413/// * `client` - The SonosClient to use for the subscription
414/// * `ip` - The IP address of the Sonos device
415/// * `callback_url` - URL where the device will send event notifications
416///
417/// # Returns
418/// A managed subscription for RenderingControl events
419///
420/// # Example
421/// ```rust,ignore
422/// use sonos_api::{SonosClient, services::rendering_control};
423///
424/// let client = SonosClient::new();
425/// let subscription = rendering_control::subscribe(
426///     &client,
427///     "192.168.1.100",
428///     "http://192.168.1.50:8080/callback"
429/// )?;
430///
431/// // Now RenderingControl events will be sent to your callback URL
432/// // Execute control operations separately:
433/// let vol_op = rendering_control::set_volume("Master".to_string(), 50).build()?;
434/// client.execute("192.168.1.100", vol_op)?;
435/// ```
436pub fn subscribe(
437    client: &crate::SonosClient,
438    ip: &str,
439    callback_url: &str,
440) -> crate::Result<crate::ManagedSubscription> {
441    client.subscribe(ip, SERVICE, callback_url)
442}
443
444/// Subscribe to RenderingControl events with custom timeout
445///
446/// Same as `subscribe()` but allows specifying a custom timeout.
447///
448/// # Arguments
449/// * `client` - The SonosClient to use for the subscription
450/// * `ip` - The IP address of the Sonos device
451/// * `callback_url` - URL where the device will send event notifications
452/// * `timeout_seconds` - How long the subscription should last
453///
454/// # Returns
455/// A managed subscription for RenderingControl events
456pub fn subscribe_with_timeout(
457    client: &crate::SonosClient,
458    ip: &str,
459    callback_url: &str,
460    timeout_seconds: u32,
461) -> crate::Result<crate::ManagedSubscription> {
462    client.subscribe_with_timeout(ip, SERVICE, callback_url, timeout_seconds)
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use crate::operation::UPnPOperation;
469
470    #[test]
471    fn test_volume_operations() {
472        let get_vol = get_volume_operation("Master".to_string()).build().unwrap();
473        assert_eq!(get_vol.request().channel, "Master");
474
475        let set_vol = set_volume_operation("Master".to_string(), 75)
476            .build()
477            .unwrap();
478        assert_eq!(set_vol.request().desired_volume, 75);
479    }
480
481    #[test]
482    fn test_volume_validation() {
483        let request = SetVolumeOperationRequest {
484            instance_id: 0,
485            channel: "Master".to_string(),
486            desired_volume: 150, // Invalid volume
487        };
488        assert!(request.validate_basic().is_err());
489
490        let request = SetVolumeOperationRequest {
491            instance_id: 0,
492            channel: "Invalid".to_string(), // Invalid channel
493            desired_volume: 50,
494        };
495        assert!(request.validate_basic().is_err());
496    }
497
498    #[test]
499    fn test_relative_volume_validation() {
500        let request = SetRelativeVolumeOperationRequest {
501            instance_id: 0,
502            channel: "Master".to_string(),
503            adjustment: 100, // Maximum valid adjustment within validation range
504        };
505        assert!(request.validate_basic().is_ok());
506
507        // Test with invalid channel
508        let request = SetRelativeVolumeOperationRequest {
509            instance_id: 0,
510            channel: "Invalid".to_string(),
511            adjustment: 10,
512        };
513        assert!(request.validate_basic().is_err());
514    }
515
516    #[test]
517    fn test_service_constant() {
518        // Verify that SERVICE constant is correctly set
519        assert_eq!(SERVICE, crate::Service::RenderingControl);
520    }
521
522    #[test]
523    fn test_get_volume_payload() {
524        let request = GetVolumeOperationRequest {
525            instance_id: 0,
526            channel: "Master".to_string(),
527        };
528        let payload = GetVolumeOperation::build_payload(&request).unwrap();
529
530        // Verify correct Sonos XML format with capitalized element names
531        assert!(payload.contains("<InstanceID>0</InstanceID>"));
532        assert!(payload.contains("<Channel>Master</Channel>"));
533        assert_eq!(
534            payload,
535            "<InstanceID>0</InstanceID><Channel>Master</Channel>"
536        );
537    }
538
539    #[test]
540    fn test_service_level_subscription_helpers() {
541        // Test that subscription helper functions have correct signatures
542        let client = crate::SonosClient::new();
543
544        // Test subscribe function exists and has correct signature
545        let _subscribe_fn = || subscribe(&client, "192.168.1.100", "http://callback.url");
546
547        // Test subscribe_with_timeout function exists and has correct signature
548        let _subscribe_timeout_fn =
549            || subscribe_with_timeout(&client, "192.168.1.100", "http://callback.url", 3600);
550    }
551
552    #[test]
553    fn test_subscription_uses_correct_service() {
554        // Verify that our subscription helpers would use the correct service
555        let _client = crate::SonosClient::new();
556
557        // Verify SERVICE constant
558        assert_eq!(SERVICE, crate::Service::RenderingControl);
559
560        // The subscribe function should internally call client.subscribe(ip, SERVICE, callback_url)
561        // We can't test the actual call without mocking, but the function signature
562        // and SERVICE constant verification confirms correct integration
563    }
564
565    // =========================================================================
566    // Mute operation tests
567    // =========================================================================
568
569    #[test]
570    fn test_get_mute_builder() {
571        let op = get_mute_operation("Master".to_string()).build().unwrap();
572        assert_eq!(op.request().channel, "Master");
573        assert_eq!(op.request().instance_id, 0);
574    }
575
576    #[test]
577    fn test_get_mute_payload() {
578        let request = GetMuteOperationRequest {
579            instance_id: 0,
580            channel: "Master".to_string(),
581        };
582        let payload = GetMuteOperation::build_payload(&request).unwrap();
583        assert_eq!(
584            payload,
585            "<InstanceID>0</InstanceID><Channel>Master</Channel>"
586        );
587    }
588
589    #[test]
590    fn test_get_mute_parse_response_true() {
591        let xml_str = r#"<GetMuteResponse><CurrentMute>1</CurrentMute></GetMuteResponse>"#;
592        let xml = xmltree::Element::parse(xml_str.as_bytes()).unwrap();
593        let response = GetMuteOperation::parse_response(&xml).unwrap();
594        assert!(response.current_mute);
595    }
596
597    #[test]
598    fn test_get_mute_parse_response_false() {
599        let xml_str = r#"<GetMuteResponse><CurrentMute>0</CurrentMute></GetMuteResponse>"#;
600        let xml = xmltree::Element::parse(xml_str.as_bytes()).unwrap();
601        let response = GetMuteOperation::parse_response(&xml).unwrap();
602        assert!(!response.current_mute);
603    }
604
605    #[test]
606    fn test_get_mute_rejects_invalid_channel() {
607        let request = GetMuteOperationRequest {
608            instance_id: 0,
609            channel: "Invalid".to_string(),
610        };
611        assert!(request.validate_basic().is_err());
612    }
613
614    #[test]
615    fn test_set_mute_builder() {
616        let op = set_mute_operation("Master".to_string(), true)
617            .build()
618            .unwrap();
619        assert!(op.request().desired_mute);
620        assert_eq!(op.request().channel, "Master");
621    }
622
623    #[test]
624    fn test_set_mute_payload_true() {
625        let request = SetMuteOperationRequest {
626            instance_id: 0,
627            channel: "Master".to_string(),
628            desired_mute: true,
629        };
630        let payload = SetMuteOperation::build_payload(&request).unwrap();
631        assert!(payload.contains("<DesiredMute>1</DesiredMute>"));
632    }
633
634    #[test]
635    fn test_set_mute_payload_false() {
636        let request = SetMuteOperationRequest {
637            instance_id: 0,
638            channel: "Master".to_string(),
639            desired_mute: false,
640        };
641        let payload = SetMuteOperation::build_payload(&request).unwrap();
642        assert!(payload.contains("<DesiredMute>0</DesiredMute>"));
643    }
644
645    #[test]
646    fn test_set_mute_rejects_invalid_channel() {
647        let request = SetMuteOperationRequest {
648            instance_id: 0,
649            channel: "Invalid".to_string(),
650            desired_mute: true,
651        };
652        assert!(request.validate_basic().is_err());
653    }
654
655    // =========================================================================
656    // Bass operation tests
657    // =========================================================================
658
659    #[test]
660    fn test_get_bass_builder() {
661        let op = get_bass_operation().build().unwrap();
662        assert_eq!(op.request().instance_id, 0);
663    }
664
665    #[test]
666    fn test_get_bass_payload() {
667        let request = GetBassOperationRequest { instance_id: 0 };
668        let payload = GetBassOperation::build_payload(&request).unwrap();
669        assert_eq!(payload, "<InstanceID>0</InstanceID>");
670    }
671
672    #[test]
673    fn test_set_bass_builder() {
674        let op = set_bass_operation(5).build().unwrap();
675        assert_eq!(op.request().desired_bass, 5);
676    }
677
678    #[test]
679    fn test_set_bass_payload() {
680        let request = SetBassOperationRequest {
681            instance_id: 0,
682            desired_bass: -5,
683        };
684        let payload = SetBassOperation::build_payload(&request).unwrap();
685        assert!(payload.contains("<DesiredBass>-5</DesiredBass>"));
686    }
687
688    #[test]
689    fn test_set_bass_validation() {
690        // Valid range
691        let request = SetBassOperationRequest {
692            instance_id: 0,
693            desired_bass: -10,
694        };
695        assert!(request.validate_basic().is_ok());
696        let request = SetBassOperationRequest {
697            instance_id: 0,
698            desired_bass: 10,
699        };
700        assert!(request.validate_basic().is_ok());
701
702        // Out of range
703        let request = SetBassOperationRequest {
704            instance_id: 0,
705            desired_bass: -11,
706        };
707        assert!(request.validate_basic().is_err());
708        let request = SetBassOperationRequest {
709            instance_id: 0,
710            desired_bass: 11,
711        };
712        assert!(request.validate_basic().is_err());
713    }
714
715    // =========================================================================
716    // Treble operation tests
717    // =========================================================================
718
719    #[test]
720    fn test_get_treble_builder() {
721        let op = get_treble_operation().build().unwrap();
722        assert_eq!(op.request().instance_id, 0);
723    }
724
725    #[test]
726    fn test_get_treble_payload() {
727        let request = GetTrebleOperationRequest { instance_id: 0 };
728        let payload = GetTrebleOperation::build_payload(&request).unwrap();
729        assert_eq!(payload, "<InstanceID>0</InstanceID>");
730    }
731
732    #[test]
733    fn test_set_treble_builder() {
734        let op = set_treble_operation(-3).build().unwrap();
735        assert_eq!(op.request().desired_treble, -3);
736    }
737
738    #[test]
739    fn test_set_treble_payload() {
740        let request = SetTrebleOperationRequest {
741            instance_id: 0,
742            desired_treble: 7,
743        };
744        let payload = SetTrebleOperation::build_payload(&request).unwrap();
745        assert!(payload.contains("<DesiredTreble>7</DesiredTreble>"));
746    }
747
748    #[test]
749    fn test_set_treble_validation() {
750        let request = SetTrebleOperationRequest {
751            instance_id: 0,
752            desired_treble: -10,
753        };
754        assert!(request.validate_basic().is_ok());
755        let request = SetTrebleOperationRequest {
756            instance_id: 0,
757            desired_treble: 10,
758        };
759        assert!(request.validate_basic().is_ok());
760
761        let request = SetTrebleOperationRequest {
762            instance_id: 0,
763            desired_treble: -11,
764        };
765        assert!(request.validate_basic().is_err());
766        let request = SetTrebleOperationRequest {
767            instance_id: 0,
768            desired_treble: 11,
769        };
770        assert!(request.validate_basic().is_err());
771    }
772
773    // =========================================================================
774    // Loudness operation tests
775    // =========================================================================
776
777    #[test]
778    fn test_get_loudness_builder() {
779        let op = get_loudness_operation("Master".to_string())
780            .build()
781            .unwrap();
782        assert_eq!(op.request().channel, "Master");
783        assert_eq!(op.request().instance_id, 0);
784    }
785
786    #[test]
787    fn test_get_loudness_payload() {
788        let request = GetLoudnessOperationRequest {
789            instance_id: 0,
790            channel: "Master".to_string(),
791        };
792        let payload = GetLoudnessOperation::build_payload(&request).unwrap();
793        assert_eq!(
794            payload,
795            "<InstanceID>0</InstanceID><Channel>Master</Channel>"
796        );
797    }
798
799    #[test]
800    fn test_get_loudness_parse_response_true() {
801        let xml_str =
802            r#"<GetLoudnessResponse><CurrentLoudness>1</CurrentLoudness></GetLoudnessResponse>"#;
803        let xml = xmltree::Element::parse(xml_str.as_bytes()).unwrap();
804        let response = GetLoudnessOperation::parse_response(&xml).unwrap();
805        assert!(response.current_loudness);
806    }
807
808    #[test]
809    fn test_get_loudness_parse_response_false() {
810        let xml_str =
811            r#"<GetLoudnessResponse><CurrentLoudness>0</CurrentLoudness></GetLoudnessResponse>"#;
812        let xml = xmltree::Element::parse(xml_str.as_bytes()).unwrap();
813        let response = GetLoudnessOperation::parse_response(&xml).unwrap();
814        assert!(!response.current_loudness);
815    }
816
817    #[test]
818    fn test_get_loudness_rejects_invalid_channel() {
819        let request = GetLoudnessOperationRequest {
820            instance_id: 0,
821            channel: "Invalid".to_string(),
822        };
823        assert!(request.validate_basic().is_err());
824    }
825
826    #[test]
827    fn test_set_loudness_builder() {
828        let op = set_loudness_operation("Master".to_string(), true)
829            .build()
830            .unwrap();
831        assert!(op.request().desired_loudness);
832        assert_eq!(op.request().channel, "Master");
833    }
834
835    #[test]
836    fn test_set_loudness_payload_true() {
837        let request = SetLoudnessOperationRequest {
838            instance_id: 0,
839            channel: "Master".to_string(),
840            desired_loudness: true,
841        };
842        let payload = SetLoudnessOperation::build_payload(&request).unwrap();
843        assert!(payload.contains("<DesiredLoudness>1</DesiredLoudness>"));
844    }
845
846    #[test]
847    fn test_set_loudness_payload_false() {
848        let request = SetLoudnessOperationRequest {
849            instance_id: 0,
850            channel: "Master".to_string(),
851            desired_loudness: false,
852        };
853        let payload = SetLoudnessOperation::build_payload(&request).unwrap();
854        assert!(payload.contains("<DesiredLoudness>0</DesiredLoudness>"));
855    }
856
857    #[test]
858    fn test_set_loudness_rejects_invalid_channel() {
859        let request = SetLoudnessOperationRequest {
860            instance_id: 0,
861            channel: "Invalid".to_string(),
862            desired_loudness: true,
863        };
864        assert!(request.validate_basic().is_err());
865    }
866}