Skip to main content

sonos_state/
decoder.rs

1//! Event decoder - converts EnrichedEvent to typed property changes
2//!
3//! This module decodes raw events from sonos-stream into typed property
4//! changes that can be applied to the StateStore.
5
6use serde::Deserialize;
7use sonos_api::Service;
8use sonos_stream::events::{
9    AVTransportState, EnrichedEvent, EventData, GroupRenderingControlState, RenderingControlState,
10    ZoneGroupTopologyState,
11};
12use url::{Host, Url};
13
14use std::net::IpAddr;
15
16use crate::model::{GroupId, SpeakerId};
17use crate::property::{
18    Bass, CurrentTrack, GroupInfo, GroupMembership, GroupMute, GroupVolume, GroupVolumeChangeable,
19    Loudness, Mute, PlaybackState, Position, Treble, Volume,
20};
21use crate::state::{StateStore, WriteOutcome, WriteStamp};
22
23/// Decoded changes from a single event
24#[derive(Debug)]
25pub struct DecodedChanges {
26    /// Speaker ID the changes apply to
27    pub speaker_id: SpeakerId,
28    /// List of property changes
29    pub changes: Vec<PropertyChange>,
30}
31
32/// Changes extracted from a ZoneGroupTopology event
33///
34/// This struct contains the complete topology update including:
35/// - All groups with their coordinator and members
36/// - GroupMembership for each speaker in the topology
37#[derive(Debug)]
38pub struct TopologyChanges {
39    /// Updated group information
40    pub groups: Vec<GroupInfo>,
41    /// Updated speaker memberships: (speaker_id, membership)
42    pub memberships: Vec<(SpeakerId, GroupMembership)>,
43    /// Boot sequence numbers per speaker (for GroupManagement AddMember)
44    pub boot_seqs: Vec<(SpeakerId, u32)>,
45    /// Current IPs extracted from topology location URLs
46    pub speaker_ips: Vec<(SpeakerId, IpAddr)>,
47    /// Speakers marked Invisible="1" (satellites: surrounds, subs)
48    pub satellite_ids: Vec<SpeakerId>,
49}
50
51/// A single property change
52///
53/// `#[non_exhaustive]` because this enum is public API (re-exported from
54/// `sonos-sdk`) and gains a variant every time a property is added. Without it,
55/// every consumer `match` would be exhaustive-by-default and adding a property
56/// would be a breaking change; consumers must include a `_ =>` arm.
57#[derive(Debug, Clone)]
58#[non_exhaustive]
59pub enum PropertyChange {
60    Volume(Volume),
61    Mute(Mute),
62    Bass(Bass),
63    Treble(Treble),
64    Loudness(Loudness),
65    PlaybackState(PlaybackState),
66    Position(Position),
67    CurrentTrack(CurrentTrack),
68    GroupMembership(GroupMembership),
69    GroupVolume(GroupVolume),
70    GroupMute(GroupMute),
71    GroupVolumeChangeable(GroupVolumeChangeable),
72}
73
74impl PropertyChange {
75    /// Apply this change to the store using scope-based routing
76    ///
77    /// Speaker-scoped properties are stored in `speaker_props`,
78    /// group-scoped properties resolve speaker→group and store in `group_props`.
79    ///
80    /// Returns the [`WriteOutcome`] — `Changed` only if the value actually
81    /// changed *and* `stamp` was not older than the stored observation.
82    pub fn apply(
83        &self,
84        store: &mut StateStore,
85        speaker_id: &SpeakerId,
86        stamp: WriteStamp,
87    ) -> WriteOutcome {
88        match self {
89            // Speaker-scoped properties
90            PropertyChange::Volume(v) => store.set(speaker_id, v.clone(), stamp),
91            PropertyChange::Mute(v) => store.set(speaker_id, v.clone(), stamp),
92            PropertyChange::Bass(v) => store.set(speaker_id, v.clone(), stamp),
93            PropertyChange::Treble(v) => store.set(speaker_id, v.clone(), stamp),
94            PropertyChange::Loudness(v) => store.set(speaker_id, v.clone(), stamp),
95            PropertyChange::PlaybackState(v) => store.set(speaker_id, v.clone(), stamp),
96            PropertyChange::Position(v) => store.set(speaker_id, v.clone(), stamp),
97            PropertyChange::CurrentTrack(v) => store.set(speaker_id, v.clone(), stamp),
98            PropertyChange::GroupMembership(v) => store.set(speaker_id, v.clone(), stamp),
99            // Group-scoped properties: resolve speaker→group, store in group_props
100            PropertyChange::GroupVolume(v) => {
101                match store.speaker_to_group.get(speaker_id).cloned() {
102                    Some(group_id) => store.set_group(&group_id, v.clone(), stamp),
103                    None => self.log_unmapped_group_change(speaker_id),
104                }
105            }
106            PropertyChange::GroupMute(v) => match store.speaker_to_group.get(speaker_id).cloned() {
107                Some(group_id) => store.set_group(&group_id, v.clone(), stamp),
108                None => self.log_unmapped_group_change(speaker_id),
109            },
110            PropertyChange::GroupVolumeChangeable(v) => {
111                match store.speaker_to_group.get(speaker_id).cloned() {
112                    Some(group_id) => store.set_group(&group_id, v.clone(), stamp),
113                    None => self.log_unmapped_group_change(speaker_id),
114                }
115            }
116        }
117    }
118
119    /// Report a group-scoped change that could not be stored because the speaker
120    /// has no `speaker_to_group` mapping yet.
121    ///
122    /// This used to be a bare `else { false }`. It is a legitimate state — a
123    /// GroupRenderingControl event can arrive before the first topology snapshot
124    /// — but it is also what a wiped `speaker_to_group` map looks like, so it
125    /// must be observable rather than silent. Always reports `Unchanged`
126    /// (nothing was stored, so nothing changed) rather than `Stale` — the write
127    /// was not rejected for being out of order, it had nowhere to go.
128    fn log_unmapped_group_change(&self, speaker_id: &SpeakerId) -> WriteOutcome {
129        tracing::warn!(
130            "Dropping group-scoped {} change for {}: speaker has no group mapping \
131             (no topology snapshot applied yet, or group state was cleared)",
132            self.key(),
133            speaker_id.as_str()
134        );
135        WriteOutcome::Unchanged
136    }
137
138    /// Get the property key for this change
139    pub fn key(&self) -> &'static str {
140        use crate::property::Property;
141        match self {
142            PropertyChange::Volume(_) => Volume::KEY,
143            PropertyChange::Mute(_) => Mute::KEY,
144            PropertyChange::Bass(_) => Bass::KEY,
145            PropertyChange::Treble(_) => Treble::KEY,
146            PropertyChange::Loudness(_) => Loudness::KEY,
147            PropertyChange::PlaybackState(_) => PlaybackState::KEY,
148            PropertyChange::Position(_) => Position::KEY,
149            PropertyChange::CurrentTrack(_) => CurrentTrack::KEY,
150            PropertyChange::GroupMembership(_) => GroupMembership::KEY,
151            PropertyChange::GroupVolume(_) => GroupVolume::KEY,
152            PropertyChange::GroupMute(_) => GroupMute::KEY,
153            PropertyChange::GroupVolumeChangeable(_) => GroupVolumeChangeable::KEY,
154        }
155    }
156
157    /// Get the scope of this property
158    pub fn scope(&self) -> crate::property::Scope {
159        use crate::property::SonosProperty;
160        match self {
161            PropertyChange::Volume(_) => Volume::SCOPE,
162            PropertyChange::Mute(_) => Mute::SCOPE,
163            PropertyChange::Bass(_) => Bass::SCOPE,
164            PropertyChange::Treble(_) => Treble::SCOPE,
165            PropertyChange::Loudness(_) => Loudness::SCOPE,
166            PropertyChange::PlaybackState(_) => PlaybackState::SCOPE,
167            PropertyChange::Position(_) => Position::SCOPE,
168            PropertyChange::CurrentTrack(_) => CurrentTrack::SCOPE,
169            PropertyChange::GroupMembership(_) => GroupMembership::SCOPE,
170            PropertyChange::GroupVolume(_) => GroupVolume::SCOPE,
171            PropertyChange::GroupMute(_) => GroupMute::SCOPE,
172            PropertyChange::GroupVolumeChangeable(_) => GroupVolumeChangeable::SCOPE,
173        }
174    }
175
176    /// Get the service this property belongs to
177    pub fn service(&self) -> Service {
178        use crate::property::SonosProperty;
179        match self {
180            PropertyChange::Volume(_) => Volume::SERVICE,
181            PropertyChange::Mute(_) => Mute::SERVICE,
182            PropertyChange::Bass(_) => Bass::SERVICE,
183            PropertyChange::Treble(_) => Treble::SERVICE,
184            PropertyChange::Loudness(_) => Loudness::SERVICE,
185            PropertyChange::PlaybackState(_) => PlaybackState::SERVICE,
186            PropertyChange::Position(_) => Position::SERVICE,
187            PropertyChange::CurrentTrack(_) => CurrentTrack::SERVICE,
188            PropertyChange::GroupMembership(_) => GroupMembership::SERVICE,
189            PropertyChange::GroupVolume(_) => GroupVolume::SERVICE,
190            PropertyChange::GroupMute(_) => GroupMute::SERVICE,
191            PropertyChange::GroupVolumeChangeable(_) => GroupVolumeChangeable::SERVICE,
192        }
193    }
194}
195
196/// Decode an enriched event into typed property changes
197pub fn decode_event(event: &EnrichedEvent, speaker_id: SpeakerId) -> DecodedChanges {
198    let changes = match &event.event_data {
199        EventData::RenderingControl(rc) => decode_rendering_control(rc),
200        EventData::AVTransport(avt) => decode_av_transport(avt),
201        EventData::ZoneGroupTopology(zgt) => decode_topology(zgt),
202        EventData::DeviceProperties(_) => vec![],
203        // GroupManagement is action-only; group changes surface via ZoneGroupTopology events.
204        // No user-facing properties to decode.
205        EventData::GroupManagement(_) => vec![],
206        EventData::GroupRenderingControl(grc) => decode_group_rendering_control(grc),
207    };
208
209    DecodedChanges {
210        speaker_id,
211        changes,
212    }
213}
214
215/// Decode RenderingControl event data
216fn decode_rendering_control(event: &RenderingControlState) -> Vec<PropertyChange> {
217    let mut changes = vec![];
218
219    // Volume
220    if let Some(vol_str) = &event.master_volume {
221        if let Ok(vol) = vol_str.parse::<u8>() {
222            changes.push(PropertyChange::Volume(Volume(vol.min(100))));
223        }
224    }
225
226    // Mute
227    if let Some(mute_str) = &event.master_mute {
228        let muted = mute_str == "1" || mute_str.eq_ignore_ascii_case("true");
229        changes.push(PropertyChange::Mute(Mute(muted)));
230    }
231
232    // Bass
233    if let Some(bass_str) = &event.bass {
234        if let Ok(bass) = bass_str.parse::<i8>() {
235            changes.push(PropertyChange::Bass(Bass(bass.clamp(-10, 10))));
236        }
237    }
238
239    // Treble
240    if let Some(treble_str) = &event.treble {
241        if let Ok(treble) = treble_str.parse::<i8>() {
242            changes.push(PropertyChange::Treble(Treble(treble.clamp(-10, 10))));
243        }
244    }
245
246    // Loudness
247    if let Some(loudness_str) = &event.loudness {
248        let loudness = loudness_str == "1" || loudness_str.eq_ignore_ascii_case("true");
249        changes.push(PropertyChange::Loudness(Loudness(loudness)));
250    }
251
252    changes
253}
254
255/// Decode AVTransport event data
256fn decode_av_transport(event: &AVTransportState) -> Vec<PropertyChange> {
257    let mut changes = vec![];
258
259    // Playback state
260    if let Some(state) = &event.transport_state {
261        let ps = match state.to_uppercase().as_str() {
262            "PLAYING" => PlaybackState::Playing,
263            "PAUSED_PLAYBACK" | "PAUSED" => PlaybackState::Paused,
264            "STOPPED" => PlaybackState::Stopped,
265            _ => PlaybackState::Transitioning,
266        };
267        changes.push(PropertyChange::PlaybackState(ps));
268    }
269
270    // Position
271    //
272    // Only emit when `rel_time` actually parses. The previous `unwrap_or(0)`
273    // turned an unparseable or missing position into 0:00, which a consumer
274    // cannot tell apart from a track that genuinely just started — so a garbage
275    // value would visibly rewind the playhead. `track_duration` keeps its
276    // `unwrap_or(0)` because "unknown duration" is a real, common state for
277    // live streams and 0 is how the SDK already represents it.
278    if event.rel_time.is_some() || event.track_duration.is_some() {
279        match parse_duration_ms(event.rel_time.as_deref()) {
280            Some(position_ms) => {
281                let duration_ms = parse_duration_ms(event.track_duration.as_deref()).unwrap_or(0);
282                changes.push(PropertyChange::Position(Position {
283                    position_ms,
284                    duration_ms,
285                }));
286            }
287            None => {
288                tracing::debug!(
289                    "Skipping Position update: RelTime {:?} is missing or unparseable",
290                    event.rel_time
291                );
292            }
293        }
294    }
295
296    // CurrentTrack
297    if event.current_track_uri.is_some() || event.track_metadata.is_some() {
298        // Parse metadata if available (track_metadata is raw XML, need to parse it)
299        let (title, artist, album, album_art_uri) =
300            parse_track_metadata(event.track_metadata.as_deref());
301
302        let track = CurrentTrack {
303            title,
304            artist,
305            album,
306            album_art_uri,
307            uri: event.current_track_uri.clone(),
308        };
309        changes.push(PropertyChange::CurrentTrack(track));
310    }
311
312    changes
313}
314
315/// Decode ZoneGroupTopology event data into property changes
316///
317/// Note: This returns an empty Vec because topology changes are handled
318/// specially via `decode_topology_event()` which returns `TopologyChanges`.
319fn decode_topology(_event: &ZoneGroupTopologyState) -> Vec<PropertyChange> {
320    // Topology events are handled specially via decode_topology_event()
321    // which returns TopologyChanges instead of PropertyChange
322    vec![]
323}
324
325/// Decode GroupRenderingControl event data
326fn decode_group_rendering_control(event: &GroupRenderingControlState) -> Vec<PropertyChange> {
327    let mut changes = vec![];
328
329    if let Some(vol) = event.group_volume {
330        changes.push(PropertyChange::GroupVolume(GroupVolume(vol.min(100))));
331    }
332
333    if let Some(muted) = event.group_mute {
334        changes.push(PropertyChange::GroupMute(GroupMute(muted)));
335    }
336
337    if let Some(changeable) = event.group_volume_changeable {
338        changes.push(PropertyChange::GroupVolumeChangeable(
339            GroupVolumeChangeable(changeable),
340        ));
341    }
342
343    changes
344}
345
346/// Decode a ZoneGroupTopology event into TopologyChanges
347///
348/// This extracts group information and speaker memberships from the topology event.
349/// Each zone group becomes a GroupInfo, and each member gets a GroupMembership.
350///
351/// # Arguments
352/// * `event` - The ZoneGroupTopology event to decode
353///
354/// # Returns
355/// TopologyChanges containing all groups and speaker memberships
356pub fn decode_topology_event(event: &ZoneGroupTopologyState) -> TopologyChanges {
357    let mut groups = Vec::new();
358    let mut memberships = Vec::new();
359    let mut boot_seqs = Vec::new();
360    let mut speaker_ips = Vec::new();
361    let mut satellite_ids = Vec::new();
362
363    for zone_group in &event.zone_groups {
364        let group_id = GroupId::new(&zone_group.id);
365        let coordinator_id = SpeakerId::new(&zone_group.coordinator);
366
367        // Collect all member IDs
368        let member_ids: Vec<SpeakerId> = zone_group
369            .members
370            .iter()
371            .map(|m| SpeakerId::new(&m.uuid))
372            .collect();
373
374        // Create GroupInfo for this zone group
375        let group_info =
376            GroupInfo::new(group_id.clone(), coordinator_id.clone(), member_ids.clone());
377        groups.push(group_info);
378
379        // Create GroupMembership and extract boot_seq for each member
380        for member in &zone_group.members {
381            let speaker_id = SpeakerId::new(&member.uuid);
382            let is_coordinator = speaker_id == coordinator_id;
383            let membership = GroupMembership::new(group_id.clone(), is_coordinator);
384            memberships.push((speaker_id.clone(), membership));
385            boot_seqs.push((speaker_id.clone(), member.boot_seq));
386
387            if let Some(ip) = extract_ip_from_location(&member.location) {
388                speaker_ips.push((speaker_id, ip));
389            }
390
391            for sat in &member.satellites {
392                if sat.invisible == "1" {
393                    let sat_id = SpeakerId::new(&sat.uuid);
394                    satellite_ids.push(sat_id.clone());
395                    if let Some(ip) = extract_ip_from_location(&sat.location) {
396                        speaker_ips.push((sat_id, ip));
397                    }
398                }
399            }
400        }
401    }
402
403    TopologyChanges {
404        groups,
405        memberships,
406        boot_seqs,
407        speaker_ips,
408        satellite_ids,
409    }
410}
411
412/// Extract the device IP from a topology `location` URL.
413///
414/// Topology members are addressed as `http://<ip>:1400/xml/device_description.xml`.
415/// Parsing with `url::Url` rather than splitting on `"http://"`, `'/'` and `':'`
416/// keeps two cases correct that hand-splitting got wrong: an IPv6 literal is
417/// bracketed (`http://[fe80::1]:1400/...`), so splitting on `':'` truncated it to
418/// `"[fe80"` and yielded `None`; and userinfo or a port-less host shifted the
419/// segment the naive split picked. `Url::parse` also rejects a scheme-less string
420/// as a relative URL, preserving the previous `strip_prefix` behaviour of
421/// returning `None` for `"192.168.1.1:1400/xml"`. A hostname that is not a
422/// literal address still returns `None`: this is a cache key, not a resolver.
423fn extract_ip_from_location(location: &str) -> Option<IpAddr> {
424    match Url::parse(location).ok()?.host()? {
425        Host::Ipv4(ip) => Some(IpAddr::V4(ip)),
426        Host::Ipv6(ip) => Some(IpAddr::V6(ip)),
427        Host::Domain(_) => None,
428    }
429}
430
431/// Parse duration string (HH:MM:SS or H:MM:SS) to milliseconds
432fn parse_duration_ms(duration: Option<&str>) -> Option<u64> {
433    let d = duration?;
434
435    // Handle NOT_IMPLEMENTED or empty strings
436    if d.is_empty() || d == "NOT_IMPLEMENTED" {
437        return None;
438    }
439
440    let parts: Vec<&str> = d.split(':').collect();
441    if parts.len() != 3 {
442        return None;
443    }
444
445    let hours: u64 = parts[0].parse().ok()?;
446    let minutes: u64 = parts[1].parse().ok()?;
447
448    // Handle potential milliseconds in seconds part (HH:MM:SS.mmm)
449    let seconds_parts: Vec<&str> = parts[2].split('.').collect();
450    let seconds: u64 = seconds_parts[0].parse().ok()?;
451    let millis: u64 = seconds_parts
452        .get(1)
453        .and_then(|m| m.parse().ok())
454        .unwrap_or(0);
455
456    // Checked arithmetic throughout: the components come straight off the wire,
457    // so a device (or a forged event) can supply values near `u64::MAX` that
458    // overflow the multiply. Unchecked math panics in debug and silently wraps
459    // to a nonsense position in release; `None` correctly means "unparseable".
460    hours
461        .checked_mul(3600)?
462        .checked_add(minutes.checked_mul(60)?)?
463        .checked_add(seconds)?
464        .checked_mul(1000)?
465        .checked_add(millis)
466}
467
468/// The one DIDL-Lite `<item>` a track's metadata carries.
469///
470/// Field names are the element **local** names. UPnP sends `dc:title`,
471/// `upnp:album`, `upnp:albumArtURI` and `r:albumArtist`, but quick-xml's serde
472/// deserializer resolves prefixes for us and matches on the local name only, so
473/// `rename = "dc:title"` could never match. Unknown siblings (`res`,
474/// `upnp:class`, `r:streamContent`, ...) are ignored by serde.
475///
476/// This duplicates most of `sonos_api::events::DidlItem`; that type lacks
477/// `albumArtist`, which the artist fallback below needs. Consolidating the two
478/// DIDL models is worthwhile follow-up work.
479#[derive(Debug, Deserialize)]
480struct DidlItem {
481    #[serde(default)]
482    title: Option<String>,
483    #[serde(default)]
484    creator: Option<String>,
485    #[serde(rename = "albumArtist", default)]
486    album_artist: Option<String>,
487    #[serde(default)]
488    album: Option<String>,
489    #[serde(rename = "albumArtURI", default)]
490    album_art_uri: Option<String>,
491}
492
493#[derive(Debug, Deserialize)]
494struct DidlLite {
495    #[serde(rename = "item", default)]
496    items: Vec<DidlItem>,
497}
498
499/// Parse DIDL-Lite track metadata into `(title, artist, album, album_art_uri)`.
500///
501/// Infallible by contract: `sonos-sdk` destructures the tuple directly
502/// (`sonos-sdk/src/property/handles.rs`), and a track whose metadata will not
503/// parse must read as "unknown track", never as an error or a panic. Absent,
504/// empty and `NOT_IMPLEMENTED` input all yield all-`None`, as does any parse
505/// failure.
506pub fn parse_track_metadata(
507    metadata: Option<&str>,
508) -> (
509    Option<String>,
510    Option<String>,
511    Option<String>,
512    Option<String>,
513) {
514    let xml = match metadata {
515        Some(m) if !m.is_empty() && m != "NOT_IMPLEMENTED" => m,
516        _ => return (None, None, None, None),
517    };
518
519    // Strict parse first: valid DIDL is the common case and costs no allocation.
520    // quick-xml unescapes text as it deserializes, and `unescape` rejects any
521    // entity it does not know (`UnrecognizedSymbol`). Real DIDL from streaming
522    // services carries bare `&` in titles and occasional HTML entities, which
523    // the previous `.replace()` chain tolerated by simply leaving them alone. A
524    // strict-only parse would therefore blank out tracks that render fine today,
525    // so a failed parse retries against a copy whose stray ampersands have been
526    // escaped -- turning `&` into `&amp;` and an unknown `&nbsp;` into the
527    // literal text `&nbsp;`, exactly the old lenient outcome. Only if that also
528    // fails (malformed markup, not just a bad entity) is the whole item dropped.
529    let didl = match quick_xml::de::from_str::<DidlLite>(xml) {
530        Ok(didl) => didl,
531        Err(strict_err) => {
532            let repaired = escape_stray_ampersands(xml);
533            match quick_xml::de::from_str::<DidlLite>(&repaired) {
534                Ok(didl) => didl,
535                Err(err) => {
536                    tracing::debug!(
537                        "Failed to parse DIDL-Lite track metadata: {strict_err}; \
538                         retry with escaped ampersands also failed: {err}"
539                    );
540                    return (None, None, None, None);
541                }
542            }
543        }
544    };
545
546    let Some(item) = didl.items.into_iter().next() else {
547        return (None, None, None, None);
548    };
549
550    // `dc:creator` is authoritative; `r:albumArtist` is the fallback Sonos
551    // supplies for library tracks that carry no creator.
552    let artist = nonempty(item.creator).or_else(|| nonempty(item.album_artist));
553
554    (
555        nonempty(item.title),
556        artist,
557        nonempty(item.album),
558        nonempty(item.album_art_uri),
559    )
560}
561
562/// An element present but empty reads as absent, matching the previous behavior.
563fn nonempty(value: Option<String>) -> Option<String> {
564    value.filter(|s| !s.is_empty())
565}
566
567/// Escape every `&` that does not already begin a well-formed character
568/// reference, so a single bad entity cannot fail the whole document.
569fn escape_stray_ampersands(xml: &str) -> String {
570    let mut out = String::with_capacity(xml.len() + 16);
571    let mut rest = xml;
572
573    while let Some(pos) = rest.find('&') {
574        out.push_str(&rest[..pos]);
575        let tail = &rest[pos..];
576        match character_reference_len(tail) {
577            Some(len) => {
578                out.push_str(&tail[..len]);
579                rest = &tail[len..];
580            }
581            None => {
582                out.push_str("&amp;");
583                rest = &tail[1..];
584            }
585        }
586    }
587
588    out.push_str(rest);
589    out
590}
591
592/// Length of the character reference at the start of `tail`, if it is one
593/// quick-xml will accept: a predefined entity or a numeric reference naming a
594/// real code point. Everything else is a stray `&`.
595fn character_reference_len(tail: &str) -> Option<usize> {
596    for named in ["&amp;", "&lt;", "&gt;", "&quot;", "&apos;"] {
597        if tail.starts_with(named) {
598            return Some(named.len());
599        }
600    }
601
602    let body = tail.strip_prefix("&#")?;
603    let (digits, radix) = match body.strip_prefix(['x', 'X']) {
604        Some(hex) => (hex, 16),
605        None => (body, 10),
606    };
607    let end = digits.find(';')?;
608    let number = &digits[..end];
609    if number.is_empty() {
610        return None;
611    }
612    let code = u32::from_str_radix(number, radix).ok()?;
613    char::from_u32(code)?;
614
615    // "&#" or "&#x", then the digits and the ';'.
616    Some(tail.len() - digits.len() + end + 1)
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    #[test]
624    fn test_parse_duration_ms() {
625        assert_eq!(parse_duration_ms(Some("0:00:00")), Some(0));
626        assert_eq!(parse_duration_ms(Some("0:01:00")), Some(60_000));
627        assert_eq!(parse_duration_ms(Some("1:00:00")), Some(3_600_000));
628        assert_eq!(parse_duration_ms(Some("0:03:45")), Some(225_000));
629        assert_eq!(parse_duration_ms(Some("0:03:45.500")), Some(225_500));
630        assert_eq!(parse_duration_ms(Some("NOT_IMPLEMENTED")), None);
631        assert_eq!(parse_duration_ms(None), None);
632        assert_eq!(parse_duration_ms(Some("")), None);
633    }
634
635    #[test]
636    fn test_parse_duration_ms_overflow_returns_none() {
637        // Components near u64::MAX overflow the seconds→millis multiply.
638        // Unchecked arithmetic would panic in debug and wrap in release.
639        assert_eq!(parse_duration_ms(Some("18446744073709551615:00:00")), None);
640        assert_eq!(parse_duration_ms(Some("0:0:18446744073709551615")), None);
641    }
642
643    #[test]
644    fn test_decode_av_transport_skips_position_when_rel_time_garbage() {
645        // Garbage RelTime must not silently become position 0:00, which is
646        // indistinguishable from a track that just started.
647        let event = AVTransportState {
648            transport_state: None,
649            transport_status: None,
650            speed: None,
651            current_track_uri: None,
652            track_duration: Some("0:03:45".to_string()),
653            rel_time: Some("garbage".to_string()),
654            abs_time: None,
655            rel_count: None,
656            abs_count: None,
657            play_mode: None,
658            track_metadata: None,
659            next_track_uri: None,
660            next_track_metadata: None,
661            queue_length: None,
662        };
663
664        let changes = decode_av_transport(&event);
665
666        assert!(
667            !changes
668                .iter()
669                .any(|c| matches!(c, PropertyChange::Position(_))),
670            "expected no Position change, got {changes:?}"
671        );
672    }
673
674    #[test]
675    fn test_parse_track_metadata_tolerates_bad_entities() {
676        // A bare `&` and an unknown entity are both rejected by a strict XML
677        // parse, but real streaming-service DIDL contains them. They must not
678        // blank out the rest of an otherwise-good item, or panic.
679        let xml = r#"<DIDL-Lite><item><dc:title>Rock & Roll&nbsp;Star</dc:title><dc:creator>Oasis</dc:creator></item></DIDL-Lite>"#;
680
681        let (title, artist, album, art) = parse_track_metadata(Some(xml));
682
683        assert_eq!(title, Some("Rock & Roll&nbsp;Star".to_string()));
684        assert_eq!(artist, Some("Oasis".to_string()));
685        assert_eq!((album, art), (None, None));
686
687        // Markup that is broken rather than merely badly escaped degrades to
688        // all-None instead of propagating an error.
689        assert_eq!(
690            parse_track_metadata(Some("<DIDL-Lite><item><dc:title>Unclosed")),
691            (None, None, None, None)
692        );
693    }
694
695    #[test]
696    fn test_parse_track_metadata_decodes_double_escaped_entity() {
697        // `&amp;apos;` is an escaped `&` followed by the text `apos;`, so it
698        // decodes to the literal `&apos;`. The old `.replace()` chain ran
699        // `&amp;` before `&apos;` and collapsed this to `'`.
700        let xml =
701            r#"<DIDL-Lite><item><dc:title>&amp;apos;Round Midnight</dc:title></item></DIDL-Lite>"#;
702
703        let (title, ..) = parse_track_metadata(Some(xml));
704
705        assert_eq!(title, Some("&apos;Round Midnight".to_string()));
706    }
707
708    #[test]
709    fn test_extract_ip_from_location_valid() {
710        let ip = extract_ip_from_location("http://192.168.4.200:1400/xml/device_description.xml");
711        assert_eq!(ip, Some("192.168.4.200".parse().unwrap()));
712    }
713
714    #[test]
715    fn test_extract_ip_from_location_no_path() {
716        let ip = extract_ip_from_location("http://10.0.0.1:1400");
717        assert_eq!(ip, Some("10.0.0.1".parse().unwrap()));
718    }
719
720    #[test]
721    fn test_extract_ip_from_location_missing_prefix() {
722        assert_eq!(extract_ip_from_location("192.168.1.1:1400/xml"), None);
723    }
724
725    #[test]
726    fn test_extract_ip_from_location_empty() {
727        assert_eq!(extract_ip_from_location(""), None);
728    }
729
730    #[test]
731    fn test_extract_ip_from_location_malformed() {
732        assert_eq!(extract_ip_from_location("http://not-an-ip:1400/xml"), None);
733    }
734
735    #[test]
736    fn test_decode_topology_extracts_ips_and_satellites() {
737        use sonos_stream::events::{
738            NetworkInfo, SatelliteInfo, ZoneGroupInfo, ZoneGroupMemberInfo, ZoneGroupTopologyState,
739        };
740
741        let event = ZoneGroupTopologyState {
742            zone_groups: vec![ZoneGroupInfo {
743                coordinator: "RINCON_MAIN".to_string(),
744                id: "RINCON_MAIN:1".to_string(),
745                members: vec![ZoneGroupMemberInfo {
746                    uuid: "RINCON_MAIN".to_string(),
747                    location: "http://192.168.4.100:1400/xml/device_description.xml".to_string(),
748                    zone_name: "Living Room".to_string(),
749                    software_version: "56.0".to_string(),
750                    boot_seq: 42,
751                    network_info: NetworkInfo::default(),
752                    satellites: vec![SatelliteInfo {
753                        uuid: "RINCON_SAT".to_string(),
754                        location: "http://192.168.4.101:1400/xml/device_description.xml"
755                            .to_string(),
756                        zone_name: "Living Room".to_string(),
757                        ht_sat_chan_map_set: "".to_string(),
758                        invisible: "1".to_string(),
759                    }],
760                }],
761            }],
762            vanished_devices: vec![],
763        };
764
765        let changes = decode_topology_event(&event);
766
767        assert_eq!(changes.speaker_ips.len(), 2);
768        assert_eq!(
769            changes.speaker_ips[0],
770            (
771                SpeakerId::new("RINCON_MAIN"),
772                "192.168.4.100".parse().unwrap()
773            )
774        );
775        assert_eq!(
776            changes.speaker_ips[1],
777            (
778                SpeakerId::new("RINCON_SAT"),
779                "192.168.4.101".parse().unwrap()
780            )
781        );
782
783        assert_eq!(changes.satellite_ids.len(), 1);
784        assert_eq!(changes.satellite_ids[0], SpeakerId::new("RINCON_SAT"));
785    }
786
787    #[test]
788    fn test_decode_rendering_control() {
789        let event = RenderingControlState {
790            master_volume: Some("50".to_string()),
791            master_mute: Some("0".to_string()),
792            bass: Some("5".to_string()),
793            treble: Some("-3".to_string()),
794            loudness: Some("1".to_string()),
795            lf_volume: None,
796            rf_volume: None,
797            lf_mute: None,
798            rf_mute: None,
799            balance: None,
800            other_channels: std::collections::HashMap::new(),
801        };
802
803        let changes = decode_rendering_control(&event);
804
805        assert_eq!(changes.len(), 5);
806
807        // Check volume
808        if let PropertyChange::Volume(v) = &changes[0] {
809            assert_eq!(v.0, 50);
810        } else {
811            panic!("Expected Volume change");
812        }
813
814        // Check mute
815        if let PropertyChange::Mute(m) = &changes[1] {
816            assert!(!m.0);
817        } else {
818            panic!("Expected Mute change");
819        }
820    }
821
822    #[test]
823    fn test_decode_av_transport() {
824        let event = AVTransportState {
825            transport_state: Some("PLAYING".to_string()),
826            transport_status: None,
827            speed: None,
828            current_track_uri: Some("x-sonos-spotify:track123".to_string()),
829            track_duration: Some("0:03:45".to_string()),
830            rel_time: Some("0:01:30".to_string()),
831            abs_time: None,
832            rel_count: None,
833            abs_count: None,
834            play_mode: None,
835            track_metadata: None,
836            next_track_uri: None,
837            next_track_metadata: None,
838            queue_length: None,
839        };
840
841        let changes = decode_av_transport(&event);
842
843        assert!(changes.len() >= 2);
844
845        // Check playback state
846        if let PropertyChange::PlaybackState(ps) = &changes[0] {
847            assert_eq!(*ps, PlaybackState::Playing);
848        } else {
849            panic!("Expected PlaybackState change");
850        }
851    }
852
853    #[test]
854    fn test_decode_group_rendering_control() {
855        let event = GroupRenderingControlState {
856            group_volume: Some(42),
857            group_mute: Some(false),
858            group_volume_changeable: Some(true),
859        };
860
861        let changes = decode_group_rendering_control(&event);
862
863        assert_eq!(changes.len(), 3);
864
865        if let PropertyChange::GroupVolume(v) = &changes[0] {
866            assert_eq!(v.0, 42);
867        } else {
868            panic!("Expected GroupVolume change");
869        }
870
871        if let PropertyChange::GroupMute(m) = &changes[1] {
872            assert!(!m.0);
873        } else {
874            panic!("Expected GroupMute change");
875        }
876
877        if let PropertyChange::GroupVolumeChangeable(c) = &changes[2] {
878            assert!(c.0);
879        } else {
880            panic!("Expected GroupVolumeChangeable change");
881        }
882    }
883
884    #[test]
885    fn test_decode_group_rendering_control_clamps_volume() {
886        let event = GroupRenderingControlState {
887            group_volume: Some(150),
888            group_mute: None,
889            group_volume_changeable: None,
890        };
891
892        let changes = decode_group_rendering_control(&event);
893
894        assert_eq!(changes.len(), 1);
895
896        if let PropertyChange::GroupVolume(v) = &changes[0] {
897            assert_eq!(v.0, 100);
898        } else {
899            panic!("Expected GroupVolume change");
900        }
901    }
902
903    #[test]
904    fn test_decode_group_rendering_control_no_volume() {
905        let event = GroupRenderingControlState {
906            group_volume: None,
907            group_mute: Some(true),
908            group_volume_changeable: None,
909        };
910
911        let changes = decode_group_rendering_control(&event);
912        assert_eq!(changes.len(), 1);
913
914        if let PropertyChange::GroupMute(m) = &changes[0] {
915            assert!(m.0);
916        } else {
917            panic!("Expected GroupMute change");
918        }
919    }
920
921    #[test]
922    fn test_property_change_key() {
923        use crate::property::Property;
924
925        let vol_change = PropertyChange::Volume(Volume(50));
926        assert_eq!(vol_change.key(), Volume::KEY);
927
928        let mute_change = PropertyChange::Mute(Mute(false));
929        assert_eq!(mute_change.key(), Mute::KEY);
930
931        let ps_change = PropertyChange::PlaybackState(PlaybackState::Playing);
932        assert_eq!(ps_change.key(), PlaybackState::KEY);
933    }
934
935    #[test]
936    fn test_property_change_service() {
937        use crate::model::GroupId;
938        use crate::property::SonosProperty;
939
940        let vol_change = PropertyChange::Volume(Volume(50));
941        assert_eq!(vol_change.service(), Volume::SERVICE);
942
943        let ps_change = PropertyChange::PlaybackState(PlaybackState::Playing);
944        assert_eq!(ps_change.service(), PlaybackState::SERVICE);
945
946        let gm_change = PropertyChange::GroupMembership(GroupMembership::new(
947            GroupId::new("RINCON_test:1"),
948            true,
949        ));
950        assert_eq!(gm_change.service(), GroupMembership::SERVICE);
951    }
952
953    #[test]
954    fn test_property_change_scope() {
955        use crate::property::Scope;
956
957        // Speaker-scoped properties
958        let vol_change = PropertyChange::Volume(Volume(50));
959        assert_eq!(vol_change.scope(), Scope::Speaker);
960
961        let ps_change = PropertyChange::PlaybackState(PlaybackState::Playing);
962        assert_eq!(ps_change.scope(), Scope::Speaker);
963
964        // Group-scoped properties
965        let gv_change = PropertyChange::GroupVolume(GroupVolume(50));
966        assert_eq!(gv_change.scope(), Scope::Group);
967
968        let gm_change = PropertyChange::GroupMute(GroupMute(false));
969        assert_eq!(gm_change.scope(), Scope::Group);
970    }
971
972    // ========================================================================
973    // Unit Tests for decode_topology_event
974    // ========================================================================
975
976    use sonos_stream::events::types::{NetworkInfo, ZoneGroupInfo, ZoneGroupMemberInfo};
977
978    /// Helper to create a ZoneGroupMemberInfo for testing
979    fn make_member(uuid: &str, zone_name: &str) -> ZoneGroupMemberInfo {
980        make_member_with_boot_seq(uuid, zone_name, 0)
981    }
982
983    fn make_member_with_boot_seq(
984        uuid: &str,
985        zone_name: &str,
986        boot_seq: u32,
987    ) -> ZoneGroupMemberInfo {
988        ZoneGroupMemberInfo {
989            uuid: uuid.to_string(),
990            location: "http://192.168.1.100:1400/xml/device_description.xml".to_string(),
991            zone_name: zone_name.to_string(),
992            software_version: "79.1-56030".to_string(),
993            boot_seq,
994            network_info: NetworkInfo {
995                wireless_mode: "0".to_string(),
996                wifi_enabled: "1".to_string(),
997                eth_link: "1".to_string(),
998                channel_freq: "2412".to_string(),
999                behind_wifi_extender: "0".to_string(),
1000            },
1001            satellites: vec![],
1002        }
1003    }
1004
1005    #[test]
1006    fn test_decode_topology_single_group_one_speaker() {
1007        // Single speaker in a standalone group
1008        let event = ZoneGroupTopologyState {
1009            zone_groups: vec![ZoneGroupInfo {
1010                coordinator: "RINCON_111111111111".to_string(),
1011                id: "RINCON_111111111111:0".to_string(),
1012                members: vec![make_member("RINCON_111111111111", "Living Room")],
1013            }],
1014            vanished_devices: vec![],
1015        };
1016
1017        let result = decode_topology_event(&event);
1018
1019        // Should have 1 group
1020        assert_eq!(result.groups.len(), 1);
1021        let group = &result.groups[0];
1022        assert_eq!(group.id.as_str(), "RINCON_111111111111:0");
1023        assert_eq!(group.coordinator_id.as_str(), "RINCON_111111111111");
1024        assert_eq!(group.member_ids.len(), 1);
1025        assert!(group.is_standalone());
1026
1027        // Should have 1 membership
1028        assert_eq!(result.memberships.len(), 1);
1029        let (speaker_id, membership) = &result.memberships[0];
1030        assert_eq!(speaker_id.as_str(), "RINCON_111111111111");
1031        assert_eq!(membership.group_id.as_str(), "RINCON_111111111111:0");
1032        assert!(membership.is_coordinator);
1033    }
1034
1035    #[test]
1036    fn test_decode_topology_single_group_multiple_speakers() {
1037        // Group with 3 speakers: coordinator + 2 members
1038        let event = ZoneGroupTopologyState {
1039            zone_groups: vec![ZoneGroupInfo {
1040                coordinator: "RINCON_111111111111".to_string(),
1041                id: "RINCON_111111111111:0".to_string(),
1042                members: vec![
1043                    make_member("RINCON_111111111111", "Living Room"),
1044                    make_member("RINCON_222222222222", "Kitchen"),
1045                    make_member("RINCON_333333333333", "Bedroom"),
1046                ],
1047            }],
1048            vanished_devices: vec![],
1049        };
1050
1051        let result = decode_topology_event(&event);
1052
1053        // Should have 1 group with 3 members
1054        assert_eq!(result.groups.len(), 1);
1055        let group = &result.groups[0];
1056        assert_eq!(group.member_ids.len(), 3);
1057        assert!(!group.is_standalone());
1058
1059        // Should have 3 memberships
1060        assert_eq!(result.memberships.len(), 3);
1061
1062        // Check coordinator membership
1063        let coordinator_membership = result
1064            .memberships
1065            .iter()
1066            .find(|(sid, _)| sid.as_str() == "RINCON_111111111111")
1067            .map(|(_, m)| m);
1068        assert!(coordinator_membership.is_some());
1069        assert!(coordinator_membership.unwrap().is_coordinator);
1070
1071        // Check non-coordinator memberships
1072        let kitchen_membership = result
1073            .memberships
1074            .iter()
1075            .find(|(sid, _)| sid.as_str() == "RINCON_222222222222")
1076            .map(|(_, m)| m);
1077        assert!(kitchen_membership.is_some());
1078        assert!(!kitchen_membership.unwrap().is_coordinator);
1079
1080        let bedroom_membership = result
1081            .memberships
1082            .iter()
1083            .find(|(sid, _)| sid.as_str() == "RINCON_333333333333")
1084            .map(|(_, m)| m);
1085        assert!(bedroom_membership.is_some());
1086        assert!(!bedroom_membership.unwrap().is_coordinator);
1087    }
1088
1089    #[test]
1090    fn test_decode_topology_multiple_groups() {
1091        // Two separate groups
1092        let event = ZoneGroupTopologyState {
1093            zone_groups: vec![
1094                ZoneGroupInfo {
1095                    coordinator: "RINCON_111111111111".to_string(),
1096                    id: "RINCON_111111111111:0".to_string(),
1097                    members: vec![
1098                        make_member("RINCON_111111111111", "Living Room"),
1099                        make_member("RINCON_222222222222", "Kitchen"),
1100                    ],
1101                },
1102                ZoneGroupInfo {
1103                    coordinator: "RINCON_333333333333".to_string(),
1104                    id: "RINCON_333333333333:0".to_string(),
1105                    members: vec![make_member("RINCON_333333333333", "Bedroom")],
1106                },
1107            ],
1108            vanished_devices: vec![],
1109        };
1110
1111        let result = decode_topology_event(&event);
1112
1113        // Should have 2 groups
1114        assert_eq!(result.groups.len(), 2);
1115
1116        // First group: 2 members
1117        let group1 = &result.groups[0];
1118        assert_eq!(group1.id.as_str(), "RINCON_111111111111:0");
1119        assert_eq!(group1.member_ids.len(), 2);
1120
1121        // Second group: 1 member (standalone)
1122        let group2 = &result.groups[1];
1123        assert_eq!(group2.id.as_str(), "RINCON_333333333333:0");
1124        assert_eq!(group2.member_ids.len(), 1);
1125        assert!(group2.is_standalone());
1126
1127        // Should have 3 total memberships
1128        assert_eq!(result.memberships.len(), 3);
1129
1130        // Verify each speaker has correct group_id
1131        let living_room = result
1132            .memberships
1133            .iter()
1134            .find(|(sid, _)| sid.as_str() == "RINCON_111111111111")
1135            .map(|(_, m)| m)
1136            .unwrap();
1137        assert_eq!(living_room.group_id.as_str(), "RINCON_111111111111:0");
1138        assert!(living_room.is_coordinator);
1139
1140        let kitchen = result
1141            .memberships
1142            .iter()
1143            .find(|(sid, _)| sid.as_str() == "RINCON_222222222222")
1144            .map(|(_, m)| m)
1145            .unwrap();
1146        assert_eq!(kitchen.group_id.as_str(), "RINCON_111111111111:0");
1147        assert!(!kitchen.is_coordinator);
1148
1149        let bedroom = result
1150            .memberships
1151            .iter()
1152            .find(|(sid, _)| sid.as_str() == "RINCON_333333333333")
1153            .map(|(_, m)| m)
1154            .unwrap();
1155        assert_eq!(bedroom.group_id.as_str(), "RINCON_333333333333:0");
1156        assert!(bedroom.is_coordinator);
1157    }
1158
1159    #[test]
1160    fn test_decode_topology_empty_event() {
1161        // Empty topology (no groups)
1162        let event = ZoneGroupTopologyState {
1163            zone_groups: vec![],
1164            vanished_devices: vec![],
1165        };
1166
1167        let result = decode_topology_event(&event);
1168
1169        assert!(result.groups.is_empty());
1170        assert!(result.memberships.is_empty());
1171    }
1172
1173    #[test]
1174    fn test_decode_topology_extracts_boot_seq_values() {
1175        let event = ZoneGroupTopologyState {
1176            zone_groups: vec![ZoneGroupInfo {
1177                coordinator: "RINCON_111111111111".to_string(),
1178                id: "RINCON_111111111111:0".to_string(),
1179                members: vec![
1180                    make_member_with_boot_seq("RINCON_111111111111", "Living Room", 42),
1181                    make_member_with_boot_seq("RINCON_222222222222", "Kitchen", 17),
1182                ],
1183            }],
1184            vanished_devices: vec![],
1185        };
1186
1187        let result = decode_topology_event(&event);
1188
1189        assert_eq!(result.boot_seqs.len(), 2);
1190
1191        let boot_seq_111 = result
1192            .boot_seqs
1193            .iter()
1194            .find(|(id, _)| id.as_str() == "RINCON_111111111111")
1195            .map(|(_, bs)| *bs);
1196        assert_eq!(boot_seq_111, Some(42));
1197
1198        let boot_seq_222 = result
1199            .boot_seqs
1200            .iter()
1201            .find(|(id, _)| id.as_str() == "RINCON_222222222222")
1202            .map(|(_, bs)| *bs);
1203        assert_eq!(boot_seq_222, Some(17));
1204    }
1205
1206    #[test]
1207    fn test_decode_topology_boot_seq_defaults_to_zero() {
1208        // make_member() uses boot_seq: 0
1209        let event = ZoneGroupTopologyState {
1210            zone_groups: vec![ZoneGroupInfo {
1211                coordinator: "RINCON_111111111111".to_string(),
1212                id: "RINCON_111111111111:0".to_string(),
1213                members: vec![make_member("RINCON_111111111111", "Living Room")],
1214            }],
1215            vanished_devices: vec![],
1216        };
1217
1218        let result = decode_topology_event(&event);
1219
1220        assert_eq!(result.boot_seqs.len(), 1);
1221        assert_eq!(result.boot_seqs[0].1, 0);
1222    }
1223}
1224
1225// ============================================================================
1226// Property-Based Tests for Topology Decoding
1227// ============================================================================
1228
1229#[cfg(test)]
1230mod property_tests {
1231    use super::*;
1232    use proptest::prelude::*;
1233    use sonos_stream::events::types::{NetworkInfo, ZoneGroupInfo, ZoneGroupMemberInfo};
1234
1235    /// Strategy for generating valid RINCON-style speaker UUIDs
1236    fn speaker_uuid_strategy() -> impl Strategy<Value = String> {
1237        "[A-F0-9]{12}".prop_map(|s| format!("RINCON_{s}"))
1238    }
1239
1240    /// Strategy for generating a zone group member
1241    fn zone_group_member_strategy() -> impl Strategy<Value = ZoneGroupMemberInfo> {
1242        (speaker_uuid_strategy(), "[A-Za-z ]{3,15}").prop_map(|(uuid, zone_name)| {
1243            ZoneGroupMemberInfo {
1244                uuid,
1245                location: "http://192.168.1.100:1400/xml/device_description.xml".to_string(),
1246                zone_name: zone_name.trim().to_string(),
1247                software_version: "79.1-56030".to_string(),
1248                boot_seq: 0,
1249                network_info: NetworkInfo {
1250                    wireless_mode: "0".to_string(),
1251                    wifi_enabled: "1".to_string(),
1252                    eth_link: "1".to_string(),
1253                    channel_freq: "2412".to_string(),
1254                    behind_wifi_extender: "0".to_string(),
1255                },
1256                satellites: vec![],
1257            }
1258        })
1259    }
1260
1261    /// Strategy for generating a zone group with 1-5 members
1262    fn zone_group_strategy() -> impl Strategy<Value = ZoneGroupInfo> {
1263        proptest::collection::vec(zone_group_member_strategy(), 1..=5).prop_flat_map(|members| {
1264            // First member is always the coordinator
1265            let coordinator = members[0].uuid.clone();
1266            let group_id = format!("{coordinator}:0");
1267            Just(ZoneGroupInfo {
1268                coordinator,
1269                id: group_id,
1270                members,
1271            })
1272        })
1273    }
1274
1275    /// Strategy for generating a topology event with 1-3 groups
1276    fn topology_event_strategy() -> impl Strategy<Value = ZoneGroupTopologyState> {
1277        proptest::collection::vec(zone_group_strategy(), 1..=3).prop_map(|zone_groups| {
1278            ZoneGroupTopologyState {
1279                zone_groups,
1280                vanished_devices: vec![],
1281            }
1282        })
1283    }
1284
1285    proptest! {
1286        #![proptest_config(ProptestConfig::with_cases(100))]
1287
1288        /// *For any* valid ZoneGroupTopology event containing zone groups, after processing:
1289        /// - Each zone group in the event corresponds to a GroupInfo in the result
1290        /// - Each member in each zone group has a GroupMembership in the result
1291        /// - The GroupMembership.group_id matches the zone group's ID
1292        /// - The GroupMembership.is_coordinator is true only for the coordinator
1293        #[test]
1294        fn prop_topology_event_processing_round_trip(event in topology_event_strategy()) {
1295            let result = decode_topology_event(&event);
1296
1297            // Property: Each zone group in the event corresponds to a GroupInfo
1298            prop_assert_eq!(
1299                result.groups.len(),
1300                event.zone_groups.len(),
1301                "Number of groups should match number of zone groups in event"
1302            );
1303
1304            // Property: Each member has a GroupMembership
1305            let total_members: usize = event.zone_groups.iter()
1306                .map(|zg| zg.members.len())
1307                .sum();
1308            prop_assert_eq!(
1309                result.memberships.len(),
1310                total_members,
1311                "Number of memberships should match total members across all groups"
1312            );
1313
1314            // Property: GroupInfo matches zone group data
1315            for (group_info, zone_group) in result.groups.iter().zip(event.zone_groups.iter()) {
1316                prop_assert_eq!(
1317                    group_info.id.as_str(),
1318                    &zone_group.id,
1319                    "GroupInfo ID should match zone group ID"
1320                );
1321                prop_assert_eq!(
1322                    group_info.coordinator_id.as_str(),
1323                    &zone_group.coordinator,
1324                    "GroupInfo coordinator should match zone group coordinator"
1325                );
1326                prop_assert_eq!(
1327                    group_info.member_ids.len(),
1328                    zone_group.members.len(),
1329                    "GroupInfo member count should match zone group member count"
1330                );
1331            }
1332
1333            // Property: GroupMembership.group_id matches and is_coordinator is correct
1334            for zone_group in &event.zone_groups {
1335                for member in &zone_group.members {
1336                    let membership = result.memberships.iter()
1337                        .find(|(sid, _)| sid.as_str() == member.uuid)
1338                        .map(|(_, m)| m);
1339
1340                    prop_assert!(
1341                        membership.is_some(),
1342                        "Each member should have a GroupMembership"
1343                    );
1344
1345                    let membership = membership.unwrap();
1346                    prop_assert_eq!(
1347                        membership.group_id.as_str(),
1348                        &zone_group.id,
1349                        "GroupMembership.group_id should match zone group ID"
1350                    );
1351
1352                    let is_coordinator = member.uuid == zone_group.coordinator;
1353                    prop_assert_eq!(
1354                        membership.is_coordinator,
1355                        is_coordinator,
1356                        "is_coordinator should be true only for the coordinator"
1357                    );
1358                }
1359            }
1360        }
1361
1362        /// *For any* decoded topology, the coordinator_id should always be present
1363        /// in the member_ids list.
1364        #[test]
1365        fn prop_coordinator_always_in_members(event in topology_event_strategy()) {
1366            let result = decode_topology_event(&event);
1367
1368            for group_info in &result.groups {
1369                prop_assert!(
1370                    group_info.member_ids.contains(&group_info.coordinator_id),
1371                    "Coordinator should always be in member_ids"
1372                );
1373            }
1374        }
1375
1376        /// *For any* decoded topology, each group should have exactly one member
1377        /// marked as coordinator in the memberships.
1378        #[test]
1379        fn prop_exactly_one_coordinator_per_group(event in topology_event_strategy()) {
1380            let result = decode_topology_event(&event);
1381
1382            for group_info in &result.groups {
1383                let coordinator_count = result.memberships.iter()
1384                    .filter(|(sid, membership)| {
1385                        group_info.member_ids.contains(sid) && membership.is_coordinator
1386                    })
1387                    .count();
1388
1389                prop_assert_eq!(
1390                    coordinator_count,
1391                    1,
1392                    "Each group should have exactly one coordinator"
1393                );
1394            }
1395        }
1396    }
1397}