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: &str) -> 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: &str) -> 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    /// Negative control: single-word request fields still get their element name
540    /// derived by the macro, so adding the optional `request_xml_mapping:` block did
541    /// not regress the call sites that were already correct.
542    #[test]
543    fn test_set_relative_volume_payload_element_names() {
544        let request = SetRelativeVolumeOperationRequest {
545            instance_id: 0,
546            channel: "Master".to_string(),
547            adjustment: -5,
548        };
549        let payload = SetRelativeVolumeOperation::build_payload(&request).unwrap();
550        assert_eq!(
551            payload,
552            "<InstanceID>0</InstanceID><Channel>Master</Channel><Adjustment>-5</Adjustment>"
553        );
554    }
555
556    #[test]
557    fn test_service_level_subscription_helpers() {
558        // Test that subscription helper functions have correct signatures
559        let client = crate::SonosClient::new();
560
561        // Test subscribe function exists and has correct signature
562        let _subscribe_fn = || subscribe(&client, "192.168.1.100", "http://callback.url");
563
564        // Test subscribe_with_timeout function exists and has correct signature
565        let _subscribe_timeout_fn =
566            || subscribe_with_timeout(&client, "192.168.1.100", "http://callback.url", 3600);
567    }
568
569    #[test]
570    fn test_subscription_uses_correct_service() {
571        // Verify that our subscription helpers would use the correct service
572        let _client = crate::SonosClient::new();
573
574        // Verify SERVICE constant
575        assert_eq!(SERVICE, crate::Service::RenderingControl);
576
577        // The subscribe function should internally call client.subscribe(ip, SERVICE, callback_url)
578        // We can't test the actual call without mocking, but the function signature
579        // and SERVICE constant verification confirms correct integration
580    }
581
582    // =========================================================================
583    // Mute operation tests
584    // =========================================================================
585
586    #[test]
587    fn test_get_mute_builder() {
588        let op = get_mute_operation("Master".to_string()).build().unwrap();
589        assert_eq!(op.request().channel, "Master");
590        assert_eq!(op.request().instance_id, 0);
591    }
592
593    #[test]
594    fn test_get_mute_payload() {
595        let request = GetMuteOperationRequest {
596            instance_id: 0,
597            channel: "Master".to_string(),
598        };
599        let payload = GetMuteOperation::build_payload(&request).unwrap();
600        assert_eq!(
601            payload,
602            "<InstanceID>0</InstanceID><Channel>Master</Channel>"
603        );
604    }
605
606    #[test]
607    fn test_get_mute_parse_response_true() {
608        let xml_str = r#"<GetMuteResponse><CurrentMute>1</CurrentMute></GetMuteResponse>"#;
609        let response = GetMuteOperation::parse_response(xml_str).unwrap();
610        assert!(response.current_mute);
611    }
612
613    #[test]
614    fn test_get_mute_parse_response_false() {
615        let xml_str = r#"<GetMuteResponse><CurrentMute>0</CurrentMute></GetMuteResponse>"#;
616        let response = GetMuteOperation::parse_response(xml_str).unwrap();
617        assert!(!response.current_mute);
618    }
619
620    #[test]
621    fn test_get_mute_rejects_invalid_channel() {
622        let request = GetMuteOperationRequest {
623            instance_id: 0,
624            channel: "Invalid".to_string(),
625        };
626        assert!(request.validate_basic().is_err());
627    }
628
629    #[test]
630    fn test_set_mute_builder() {
631        let op = set_mute_operation("Master".to_string(), true)
632            .build()
633            .unwrap();
634        assert!(op.request().desired_mute);
635        assert_eq!(op.request().channel, "Master");
636    }
637
638    #[test]
639    fn test_set_mute_payload_true() {
640        let request = SetMuteOperationRequest {
641            instance_id: 0,
642            channel: "Master".to_string(),
643            desired_mute: true,
644        };
645        let payload = SetMuteOperation::build_payload(&request).unwrap();
646        assert!(payload.contains("<DesiredMute>1</DesiredMute>"));
647    }
648
649    #[test]
650    fn test_set_mute_payload_false() {
651        let request = SetMuteOperationRequest {
652            instance_id: 0,
653            channel: "Master".to_string(),
654            desired_mute: false,
655        };
656        let payload = SetMuteOperation::build_payload(&request).unwrap();
657        assert!(payload.contains("<DesiredMute>0</DesiredMute>"));
658    }
659
660    #[test]
661    fn test_set_mute_rejects_invalid_channel() {
662        let request = SetMuteOperationRequest {
663            instance_id: 0,
664            channel: "Invalid".to_string(),
665            desired_mute: true,
666        };
667        assert!(request.validate_basic().is_err());
668    }
669
670    // =========================================================================
671    // Bass operation tests
672    // =========================================================================
673
674    #[test]
675    fn test_get_bass_builder() {
676        let op = get_bass_operation().build().unwrap();
677        assert_eq!(op.request().instance_id, 0);
678    }
679
680    #[test]
681    fn test_get_bass_payload() {
682        let request = GetBassOperationRequest { instance_id: 0 };
683        let payload = GetBassOperation::build_payload(&request).unwrap();
684        assert_eq!(payload, "<InstanceID>0</InstanceID>");
685    }
686
687    #[test]
688    fn test_set_bass_builder() {
689        let op = set_bass_operation(5).build().unwrap();
690        assert_eq!(op.request().desired_bass, 5);
691    }
692
693    #[test]
694    fn test_set_bass_payload() {
695        let request = SetBassOperationRequest {
696            instance_id: 0,
697            desired_bass: -5,
698        };
699        let payload = SetBassOperation::build_payload(&request).unwrap();
700        assert!(payload.contains("<DesiredBass>-5</DesiredBass>"));
701    }
702
703    #[test]
704    fn test_set_bass_validation() {
705        // Valid range
706        let request = SetBassOperationRequest {
707            instance_id: 0,
708            desired_bass: -10,
709        };
710        assert!(request.validate_basic().is_ok());
711        let request = SetBassOperationRequest {
712            instance_id: 0,
713            desired_bass: 10,
714        };
715        assert!(request.validate_basic().is_ok());
716
717        // Out of range
718        let request = SetBassOperationRequest {
719            instance_id: 0,
720            desired_bass: -11,
721        };
722        assert!(request.validate_basic().is_err());
723        let request = SetBassOperationRequest {
724            instance_id: 0,
725            desired_bass: 11,
726        };
727        assert!(request.validate_basic().is_err());
728    }
729
730    // =========================================================================
731    // Treble operation tests
732    // =========================================================================
733
734    #[test]
735    fn test_get_treble_builder() {
736        let op = get_treble_operation().build().unwrap();
737        assert_eq!(op.request().instance_id, 0);
738    }
739
740    #[test]
741    fn test_get_treble_payload() {
742        let request = GetTrebleOperationRequest { instance_id: 0 };
743        let payload = GetTrebleOperation::build_payload(&request).unwrap();
744        assert_eq!(payload, "<InstanceID>0</InstanceID>");
745    }
746
747    #[test]
748    fn test_set_treble_builder() {
749        let op = set_treble_operation(-3).build().unwrap();
750        assert_eq!(op.request().desired_treble, -3);
751    }
752
753    #[test]
754    fn test_set_treble_payload() {
755        let request = SetTrebleOperationRequest {
756            instance_id: 0,
757            desired_treble: 7,
758        };
759        let payload = SetTrebleOperation::build_payload(&request).unwrap();
760        assert!(payload.contains("<DesiredTreble>7</DesiredTreble>"));
761    }
762
763    #[test]
764    fn test_set_treble_validation() {
765        let request = SetTrebleOperationRequest {
766            instance_id: 0,
767            desired_treble: -10,
768        };
769        assert!(request.validate_basic().is_ok());
770        let request = SetTrebleOperationRequest {
771            instance_id: 0,
772            desired_treble: 10,
773        };
774        assert!(request.validate_basic().is_ok());
775
776        let request = SetTrebleOperationRequest {
777            instance_id: 0,
778            desired_treble: -11,
779        };
780        assert!(request.validate_basic().is_err());
781        let request = SetTrebleOperationRequest {
782            instance_id: 0,
783            desired_treble: 11,
784        };
785        assert!(request.validate_basic().is_err());
786    }
787
788    // =========================================================================
789    // Loudness operation tests
790    // =========================================================================
791
792    #[test]
793    fn test_get_loudness_builder() {
794        let op = get_loudness_operation("Master".to_string())
795            .build()
796            .unwrap();
797        assert_eq!(op.request().channel, "Master");
798        assert_eq!(op.request().instance_id, 0);
799    }
800
801    #[test]
802    fn test_get_loudness_payload() {
803        let request = GetLoudnessOperationRequest {
804            instance_id: 0,
805            channel: "Master".to_string(),
806        };
807        let payload = GetLoudnessOperation::build_payload(&request).unwrap();
808        assert_eq!(
809            payload,
810            "<InstanceID>0</InstanceID><Channel>Master</Channel>"
811        );
812    }
813
814    #[test]
815    fn test_get_loudness_parse_response_true() {
816        let xml_str =
817            r#"<GetLoudnessResponse><CurrentLoudness>1</CurrentLoudness></GetLoudnessResponse>"#;
818        let response = GetLoudnessOperation::parse_response(xml_str).unwrap();
819        assert!(response.current_loudness);
820    }
821
822    #[test]
823    fn test_get_loudness_parse_response_false() {
824        let xml_str =
825            r#"<GetLoudnessResponse><CurrentLoudness>0</CurrentLoudness></GetLoudnessResponse>"#;
826        let response = GetLoudnessOperation::parse_response(xml_str).unwrap();
827        assert!(!response.current_loudness);
828    }
829
830    #[test]
831    fn test_get_loudness_rejects_invalid_channel() {
832        let request = GetLoudnessOperationRequest {
833            instance_id: 0,
834            channel: "Invalid".to_string(),
835        };
836        assert!(request.validate_basic().is_err());
837    }
838
839    #[test]
840    fn test_set_loudness_builder() {
841        let op = set_loudness_operation("Master".to_string(), true)
842            .build()
843            .unwrap();
844        assert!(op.request().desired_loudness);
845        assert_eq!(op.request().channel, "Master");
846    }
847
848    #[test]
849    fn test_set_loudness_payload_true() {
850        let request = SetLoudnessOperationRequest {
851            instance_id: 0,
852            channel: "Master".to_string(),
853            desired_loudness: true,
854        };
855        let payload = SetLoudnessOperation::build_payload(&request).unwrap();
856        assert!(payload.contains("<DesiredLoudness>1</DesiredLoudness>"));
857    }
858
859    #[test]
860    fn test_set_loudness_payload_false() {
861        let request = SetLoudnessOperationRequest {
862            instance_id: 0,
863            channel: "Master".to_string(),
864            desired_loudness: false,
865        };
866        let payload = SetLoudnessOperation::build_payload(&request).unwrap();
867        assert!(payload.contains("<DesiredLoudness>0</DesiredLoudness>"));
868    }
869
870    #[test]
871    fn test_set_loudness_rejects_invalid_channel() {
872        let request = SetLoudnessOperationRequest {
873            instance_id: 0,
874            channel: "Invalid".to_string(),
875            desired_loudness: true,
876        };
877        assert!(request.validate_basic().is_err());
878    }
879}