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    /// 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 xml = xmltree::Element::parse(xml_str.as_bytes()).unwrap();
610        let response = GetMuteOperation::parse_response(&xml).unwrap();
611        assert!(response.current_mute);
612    }
613
614    #[test]
615    fn test_get_mute_parse_response_false() {
616        let xml_str = r#"<GetMuteResponse><CurrentMute>0</CurrentMute></GetMuteResponse>"#;
617        let xml = xmltree::Element::parse(xml_str.as_bytes()).unwrap();
618        let response = GetMuteOperation::parse_response(&xml).unwrap();
619        assert!(!response.current_mute);
620    }
621
622    #[test]
623    fn test_get_mute_rejects_invalid_channel() {
624        let request = GetMuteOperationRequest {
625            instance_id: 0,
626            channel: "Invalid".to_string(),
627        };
628        assert!(request.validate_basic().is_err());
629    }
630
631    #[test]
632    fn test_set_mute_builder() {
633        let op = set_mute_operation("Master".to_string(), true)
634            .build()
635            .unwrap();
636        assert!(op.request().desired_mute);
637        assert_eq!(op.request().channel, "Master");
638    }
639
640    #[test]
641    fn test_set_mute_payload_true() {
642        let request = SetMuteOperationRequest {
643            instance_id: 0,
644            channel: "Master".to_string(),
645            desired_mute: true,
646        };
647        let payload = SetMuteOperation::build_payload(&request).unwrap();
648        assert!(payload.contains("<DesiredMute>1</DesiredMute>"));
649    }
650
651    #[test]
652    fn test_set_mute_payload_false() {
653        let request = SetMuteOperationRequest {
654            instance_id: 0,
655            channel: "Master".to_string(),
656            desired_mute: false,
657        };
658        let payload = SetMuteOperation::build_payload(&request).unwrap();
659        assert!(payload.contains("<DesiredMute>0</DesiredMute>"));
660    }
661
662    #[test]
663    fn test_set_mute_rejects_invalid_channel() {
664        let request = SetMuteOperationRequest {
665            instance_id: 0,
666            channel: "Invalid".to_string(),
667            desired_mute: true,
668        };
669        assert!(request.validate_basic().is_err());
670    }
671
672    // =========================================================================
673    // Bass operation tests
674    // =========================================================================
675
676    #[test]
677    fn test_get_bass_builder() {
678        let op = get_bass_operation().build().unwrap();
679        assert_eq!(op.request().instance_id, 0);
680    }
681
682    #[test]
683    fn test_get_bass_payload() {
684        let request = GetBassOperationRequest { instance_id: 0 };
685        let payload = GetBassOperation::build_payload(&request).unwrap();
686        assert_eq!(payload, "<InstanceID>0</InstanceID>");
687    }
688
689    #[test]
690    fn test_set_bass_builder() {
691        let op = set_bass_operation(5).build().unwrap();
692        assert_eq!(op.request().desired_bass, 5);
693    }
694
695    #[test]
696    fn test_set_bass_payload() {
697        let request = SetBassOperationRequest {
698            instance_id: 0,
699            desired_bass: -5,
700        };
701        let payload = SetBassOperation::build_payload(&request).unwrap();
702        assert!(payload.contains("<DesiredBass>-5</DesiredBass>"));
703    }
704
705    #[test]
706    fn test_set_bass_validation() {
707        // Valid range
708        let request = SetBassOperationRequest {
709            instance_id: 0,
710            desired_bass: -10,
711        };
712        assert!(request.validate_basic().is_ok());
713        let request = SetBassOperationRequest {
714            instance_id: 0,
715            desired_bass: 10,
716        };
717        assert!(request.validate_basic().is_ok());
718
719        // Out of range
720        let request = SetBassOperationRequest {
721            instance_id: 0,
722            desired_bass: -11,
723        };
724        assert!(request.validate_basic().is_err());
725        let request = SetBassOperationRequest {
726            instance_id: 0,
727            desired_bass: 11,
728        };
729        assert!(request.validate_basic().is_err());
730    }
731
732    // =========================================================================
733    // Treble operation tests
734    // =========================================================================
735
736    #[test]
737    fn test_get_treble_builder() {
738        let op = get_treble_operation().build().unwrap();
739        assert_eq!(op.request().instance_id, 0);
740    }
741
742    #[test]
743    fn test_get_treble_payload() {
744        let request = GetTrebleOperationRequest { instance_id: 0 };
745        let payload = GetTrebleOperation::build_payload(&request).unwrap();
746        assert_eq!(payload, "<InstanceID>0</InstanceID>");
747    }
748
749    #[test]
750    fn test_set_treble_builder() {
751        let op = set_treble_operation(-3).build().unwrap();
752        assert_eq!(op.request().desired_treble, -3);
753    }
754
755    #[test]
756    fn test_set_treble_payload() {
757        let request = SetTrebleOperationRequest {
758            instance_id: 0,
759            desired_treble: 7,
760        };
761        let payload = SetTrebleOperation::build_payload(&request).unwrap();
762        assert!(payload.contains("<DesiredTreble>7</DesiredTreble>"));
763    }
764
765    #[test]
766    fn test_set_treble_validation() {
767        let request = SetTrebleOperationRequest {
768            instance_id: 0,
769            desired_treble: -10,
770        };
771        assert!(request.validate_basic().is_ok());
772        let request = SetTrebleOperationRequest {
773            instance_id: 0,
774            desired_treble: 10,
775        };
776        assert!(request.validate_basic().is_ok());
777
778        let request = SetTrebleOperationRequest {
779            instance_id: 0,
780            desired_treble: -11,
781        };
782        assert!(request.validate_basic().is_err());
783        let request = SetTrebleOperationRequest {
784            instance_id: 0,
785            desired_treble: 11,
786        };
787        assert!(request.validate_basic().is_err());
788    }
789
790    // =========================================================================
791    // Loudness operation tests
792    // =========================================================================
793
794    #[test]
795    fn test_get_loudness_builder() {
796        let op = get_loudness_operation("Master".to_string())
797            .build()
798            .unwrap();
799        assert_eq!(op.request().channel, "Master");
800        assert_eq!(op.request().instance_id, 0);
801    }
802
803    #[test]
804    fn test_get_loudness_payload() {
805        let request = GetLoudnessOperationRequest {
806            instance_id: 0,
807            channel: "Master".to_string(),
808        };
809        let payload = GetLoudnessOperation::build_payload(&request).unwrap();
810        assert_eq!(
811            payload,
812            "<InstanceID>0</InstanceID><Channel>Master</Channel>"
813        );
814    }
815
816    #[test]
817    fn test_get_loudness_parse_response_true() {
818        let xml_str =
819            r#"<GetLoudnessResponse><CurrentLoudness>1</CurrentLoudness></GetLoudnessResponse>"#;
820        let xml = xmltree::Element::parse(xml_str.as_bytes()).unwrap();
821        let response = GetLoudnessOperation::parse_response(&xml).unwrap();
822        assert!(response.current_loudness);
823    }
824
825    #[test]
826    fn test_get_loudness_parse_response_false() {
827        let xml_str =
828            r#"<GetLoudnessResponse><CurrentLoudness>0</CurrentLoudness></GetLoudnessResponse>"#;
829        let xml = xmltree::Element::parse(xml_str.as_bytes()).unwrap();
830        let response = GetLoudnessOperation::parse_response(&xml).unwrap();
831        assert!(!response.current_loudness);
832    }
833
834    #[test]
835    fn test_get_loudness_rejects_invalid_channel() {
836        let request = GetLoudnessOperationRequest {
837            instance_id: 0,
838            channel: "Invalid".to_string(),
839        };
840        assert!(request.validate_basic().is_err());
841    }
842
843    #[test]
844    fn test_set_loudness_builder() {
845        let op = set_loudness_operation("Master".to_string(), true)
846            .build()
847            .unwrap();
848        assert!(op.request().desired_loudness);
849        assert_eq!(op.request().channel, "Master");
850    }
851
852    #[test]
853    fn test_set_loudness_payload_true() {
854        let request = SetLoudnessOperationRequest {
855            instance_id: 0,
856            channel: "Master".to_string(),
857            desired_loudness: true,
858        };
859        let payload = SetLoudnessOperation::build_payload(&request).unwrap();
860        assert!(payload.contains("<DesiredLoudness>1</DesiredLoudness>"));
861    }
862
863    #[test]
864    fn test_set_loudness_payload_false() {
865        let request = SetLoudnessOperationRequest {
866            instance_id: 0,
867            channel: "Master".to_string(),
868            desired_loudness: false,
869        };
870        let payload = SetLoudnessOperation::build_payload(&request).unwrap();
871        assert!(payload.contains("<DesiredLoudness>0</DesiredLoudness>"));
872    }
873
874    #[test]
875    fn test_set_loudness_rejects_invalid_channel() {
876        let request = SetLoudnessOperationRequest {
877            instance_id: 0,
878            channel: "Invalid".to_string(),
879            desired_loudness: true,
880        };
881        assert!(request.validate_basic().is_err());
882    }
883}