use sonos_api::Service;
use sonos_stream::events::{
AVTransportState, EnrichedEvent, EventData, GroupRenderingControlState, RenderingControlState,
ZoneGroupTopologyState,
};
use crate::model::{GroupId, SpeakerId};
use crate::property::{
Bass, CurrentTrack, GroupInfo, GroupMembership, GroupMute, GroupVolume, GroupVolumeChangeable,
Loudness, Mute, PlaybackState, Position, Treble, Volume,
};
use crate::state::StateStore;
#[derive(Debug)]
pub struct DecodedChanges {
pub speaker_id: SpeakerId,
pub changes: Vec<PropertyChange>,
}
#[derive(Debug)]
pub struct TopologyChanges {
pub groups: Vec<GroupInfo>,
pub memberships: Vec<(SpeakerId, GroupMembership)>,
pub boot_seqs: Vec<(SpeakerId, u32)>,
}
#[derive(Debug, Clone)]
pub enum PropertyChange {
Volume(Volume),
Mute(Mute),
Bass(Bass),
Treble(Treble),
Loudness(Loudness),
PlaybackState(PlaybackState),
Position(Position),
CurrentTrack(CurrentTrack),
GroupMembership(GroupMembership),
GroupVolume(GroupVolume),
GroupMute(GroupMute),
GroupVolumeChangeable(GroupVolumeChangeable),
}
impl PropertyChange {
pub fn apply(&self, store: &mut StateStore, speaker_id: &SpeakerId) -> bool {
match self {
PropertyChange::Volume(v) => store.set(speaker_id, v.clone()),
PropertyChange::Mute(v) => store.set(speaker_id, v.clone()),
PropertyChange::Bass(v) => store.set(speaker_id, v.clone()),
PropertyChange::Treble(v) => store.set(speaker_id, v.clone()),
PropertyChange::Loudness(v) => store.set(speaker_id, v.clone()),
PropertyChange::PlaybackState(v) => store.set(speaker_id, v.clone()),
PropertyChange::Position(v) => store.set(speaker_id, v.clone()),
PropertyChange::CurrentTrack(v) => store.set(speaker_id, v.clone()),
PropertyChange::GroupMembership(v) => store.set(speaker_id, v.clone()),
PropertyChange::GroupVolume(v) => {
if let Some(group_id) = store.speaker_to_group.get(speaker_id).cloned() {
store.set_group(&group_id, v.clone())
} else {
false
}
}
PropertyChange::GroupMute(v) => {
if let Some(group_id) = store.speaker_to_group.get(speaker_id).cloned() {
store.set_group(&group_id, v.clone())
} else {
false
}
}
PropertyChange::GroupVolumeChangeable(v) => {
if let Some(group_id) = store.speaker_to_group.get(speaker_id).cloned() {
store.set_group(&group_id, v.clone())
} else {
false
}
}
}
}
pub fn key(&self) -> &'static str {
use crate::property::Property;
match self {
PropertyChange::Volume(_) => Volume::KEY,
PropertyChange::Mute(_) => Mute::KEY,
PropertyChange::Bass(_) => Bass::KEY,
PropertyChange::Treble(_) => Treble::KEY,
PropertyChange::Loudness(_) => Loudness::KEY,
PropertyChange::PlaybackState(_) => PlaybackState::KEY,
PropertyChange::Position(_) => Position::KEY,
PropertyChange::CurrentTrack(_) => CurrentTrack::KEY,
PropertyChange::GroupMembership(_) => GroupMembership::KEY,
PropertyChange::GroupVolume(_) => GroupVolume::KEY,
PropertyChange::GroupMute(_) => GroupMute::KEY,
PropertyChange::GroupVolumeChangeable(_) => GroupVolumeChangeable::KEY,
}
}
pub fn service(&self) -> Service {
use crate::property::SonosProperty;
match self {
PropertyChange::Volume(_) => Volume::SERVICE,
PropertyChange::Mute(_) => Mute::SERVICE,
PropertyChange::Bass(_) => Bass::SERVICE,
PropertyChange::Treble(_) => Treble::SERVICE,
PropertyChange::Loudness(_) => Loudness::SERVICE,
PropertyChange::PlaybackState(_) => PlaybackState::SERVICE,
PropertyChange::Position(_) => Position::SERVICE,
PropertyChange::CurrentTrack(_) => CurrentTrack::SERVICE,
PropertyChange::GroupMembership(_) => GroupMembership::SERVICE,
PropertyChange::GroupVolume(_) => GroupVolume::SERVICE,
PropertyChange::GroupMute(_) => GroupMute::SERVICE,
PropertyChange::GroupVolumeChangeable(_) => GroupVolumeChangeable::SERVICE,
}
}
}
pub fn decode_event(event: &EnrichedEvent, speaker_id: SpeakerId) -> DecodedChanges {
let changes = match &event.event_data {
EventData::RenderingControl(rc) => decode_rendering_control(rc),
EventData::AVTransport(avt) => decode_av_transport(avt),
EventData::ZoneGroupTopology(zgt) => decode_topology(zgt),
EventData::DeviceProperties(_) => vec![],
EventData::GroupManagement(_) => vec![],
EventData::GroupRenderingControl(grc) => decode_group_rendering_control(grc),
};
DecodedChanges {
speaker_id,
changes,
}
}
fn decode_rendering_control(event: &RenderingControlState) -> Vec<PropertyChange> {
let mut changes = vec![];
if let Some(vol_str) = &event.master_volume {
if let Ok(vol) = vol_str.parse::<u8>() {
changes.push(PropertyChange::Volume(Volume(vol.min(100))));
}
}
if let Some(mute_str) = &event.master_mute {
let muted = mute_str == "1" || mute_str.eq_ignore_ascii_case("true");
changes.push(PropertyChange::Mute(Mute(muted)));
}
if let Some(bass_str) = &event.bass {
if let Ok(bass) = bass_str.parse::<i8>() {
changes.push(PropertyChange::Bass(Bass(bass.clamp(-10, 10))));
}
}
if let Some(treble_str) = &event.treble {
if let Ok(treble) = treble_str.parse::<i8>() {
changes.push(PropertyChange::Treble(Treble(treble.clamp(-10, 10))));
}
}
if let Some(loudness_str) = &event.loudness {
let loudness = loudness_str == "1" || loudness_str.eq_ignore_ascii_case("true");
changes.push(PropertyChange::Loudness(Loudness(loudness)));
}
changes
}
fn decode_av_transport(event: &AVTransportState) -> Vec<PropertyChange> {
let mut changes = vec![];
if let Some(state) = &event.transport_state {
let ps = match state.to_uppercase().as_str() {
"PLAYING" => PlaybackState::Playing,
"PAUSED_PLAYBACK" | "PAUSED" => PlaybackState::Paused,
"STOPPED" => PlaybackState::Stopped,
_ => PlaybackState::Transitioning,
};
changes.push(PropertyChange::PlaybackState(ps));
}
if event.rel_time.is_some() || event.track_duration.is_some() {
let position_ms = parse_duration_ms(event.rel_time.as_deref()).unwrap_or(0);
let duration_ms = parse_duration_ms(event.track_duration.as_deref()).unwrap_or(0);
let position = Position {
position_ms,
duration_ms,
};
changes.push(PropertyChange::Position(position));
}
if event.current_track_uri.is_some() || event.track_metadata.is_some() {
let (title, artist, album, album_art_uri) =
parse_track_metadata(event.track_metadata.as_deref());
let track = CurrentTrack {
title,
artist,
album,
album_art_uri,
uri: event.current_track_uri.clone(),
};
changes.push(PropertyChange::CurrentTrack(track));
}
changes
}
fn decode_topology(_event: &ZoneGroupTopologyState) -> Vec<PropertyChange> {
vec![]
}
fn decode_group_rendering_control(event: &GroupRenderingControlState) -> Vec<PropertyChange> {
let mut changes = vec![];
if let Some(vol) = event.group_volume {
changes.push(PropertyChange::GroupVolume(GroupVolume(vol.min(100))));
}
if let Some(muted) = event.group_mute {
changes.push(PropertyChange::GroupMute(GroupMute(muted)));
}
if let Some(changeable) = event.group_volume_changeable {
changes.push(PropertyChange::GroupVolumeChangeable(
GroupVolumeChangeable(changeable),
));
}
changes
}
pub fn decode_topology_event(event: &ZoneGroupTopologyState) -> TopologyChanges {
let mut groups = Vec::new();
let mut memberships = Vec::new();
let mut boot_seqs = Vec::new();
for zone_group in &event.zone_groups {
let group_id = GroupId::new(&zone_group.id);
let coordinator_id = SpeakerId::new(&zone_group.coordinator);
let member_ids: Vec<SpeakerId> = zone_group
.members
.iter()
.map(|m| SpeakerId::new(&m.uuid))
.collect();
let group_info =
GroupInfo::new(group_id.clone(), coordinator_id.clone(), member_ids.clone());
groups.push(group_info);
for member in &zone_group.members {
let speaker_id = SpeakerId::new(&member.uuid);
let is_coordinator = speaker_id == coordinator_id;
let membership = GroupMembership::new(group_id.clone(), is_coordinator);
memberships.push((speaker_id.clone(), membership));
boot_seqs.push((speaker_id, member.boot_seq));
}
}
TopologyChanges {
groups,
memberships,
boot_seqs,
}
}
fn parse_duration_ms(duration: Option<&str>) -> Option<u64> {
let d = duration?;
if d.is_empty() || d == "NOT_IMPLEMENTED" {
return None;
}
let parts: Vec<&str> = d.split(':').collect();
if parts.len() != 3 {
return None;
}
let hours: u64 = parts[0].parse().ok()?;
let minutes: u64 = parts[1].parse().ok()?;
let seconds_parts: Vec<&str> = parts[2].split('.').collect();
let seconds: u64 = seconds_parts[0].parse().ok()?;
let millis: u64 = seconds_parts
.get(1)
.and_then(|m| m.parse().ok())
.unwrap_or(0);
Some((hours * 3600 + minutes * 60 + seconds) * 1000 + millis)
}
pub fn parse_track_metadata(
metadata: Option<&str>,
) -> (
Option<String>,
Option<String>,
Option<String>,
Option<String>,
) {
let xml = match metadata {
Some(m) if !m.is_empty() && m != "NOT_IMPLEMENTED" => m,
_ => return (None, None, None, None),
};
let title = extract_xml_element(xml, "dc:title");
let artist = extract_xml_element(xml, "dc:creator")
.or_else(|| extract_xml_element(xml, "r:albumArtist"));
let album = extract_xml_element(xml, "upnp:album");
let album_art_uri = extract_xml_element(xml, "upnp:albumArtURI");
(title, artist, album, album_art_uri)
}
pub fn extract_xml_element(xml: &str, element: &str) -> Option<String> {
let start_tag = format!("<{element}>");
let end_tag = format!("</{element}>");
let start_idx = xml.find(&start_tag)? + start_tag.len();
let end_idx = xml[start_idx..].find(&end_tag)? + start_idx;
let content = &xml[start_idx..end_idx];
let unescaped = content
.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
.replace("'", "'")
.replace(""", "\"");
if unescaped.is_empty() {
None
} else {
Some(unescaped)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_duration_ms() {
assert_eq!(parse_duration_ms(Some("0:00:00")), Some(0));
assert_eq!(parse_duration_ms(Some("0:01:00")), Some(60_000));
assert_eq!(parse_duration_ms(Some("1:00:00")), Some(3_600_000));
assert_eq!(parse_duration_ms(Some("0:03:45")), Some(225_000));
assert_eq!(parse_duration_ms(Some("0:03:45.500")), Some(225_500));
assert_eq!(parse_duration_ms(Some("NOT_IMPLEMENTED")), None);
assert_eq!(parse_duration_ms(None), None);
assert_eq!(parse_duration_ms(Some("")), None);
}
#[test]
fn test_extract_xml_element() {
let xml = r#"<DIDL-Lite><item><dc:title>Test Song</dc:title><dc:creator>Artist Name</dc:creator></item></DIDL-Lite>"#;
assert_eq!(
extract_xml_element(xml, "dc:title"),
Some("Test Song".to_string())
);
assert_eq!(
extract_xml_element(xml, "dc:creator"),
Some("Artist Name".to_string())
);
assert_eq!(extract_xml_element(xml, "upnp:album"), None);
}
#[test]
fn test_decode_rendering_control() {
let event = RenderingControlState {
master_volume: Some("50".to_string()),
master_mute: Some("0".to_string()),
bass: Some("5".to_string()),
treble: Some("-3".to_string()),
loudness: Some("1".to_string()),
lf_volume: None,
rf_volume: None,
lf_mute: None,
rf_mute: None,
balance: None,
other_channels: std::collections::HashMap::new(),
};
let changes = decode_rendering_control(&event);
assert_eq!(changes.len(), 5);
if let PropertyChange::Volume(v) = &changes[0] {
assert_eq!(v.0, 50);
} else {
panic!("Expected Volume change");
}
if let PropertyChange::Mute(m) = &changes[1] {
assert!(!m.0);
} else {
panic!("Expected Mute change");
}
}
#[test]
fn test_decode_av_transport() {
let event = AVTransportState {
transport_state: Some("PLAYING".to_string()),
transport_status: None,
speed: None,
current_track_uri: Some("x-sonos-spotify:track123".to_string()),
track_duration: Some("0:03:45".to_string()),
rel_time: Some("0:01:30".to_string()),
abs_time: None,
rel_count: None,
abs_count: None,
play_mode: None,
track_metadata: None,
next_track_uri: None,
next_track_metadata: None,
queue_length: None,
};
let changes = decode_av_transport(&event);
assert!(changes.len() >= 2);
if let PropertyChange::PlaybackState(ps) = &changes[0] {
assert_eq!(*ps, PlaybackState::Playing);
} else {
panic!("Expected PlaybackState change");
}
}
#[test]
fn test_decode_group_rendering_control() {
let event = GroupRenderingControlState {
group_volume: Some(42),
group_mute: Some(false),
group_volume_changeable: Some(true),
};
let changes = decode_group_rendering_control(&event);
assert_eq!(changes.len(), 3);
if let PropertyChange::GroupVolume(v) = &changes[0] {
assert_eq!(v.0, 42);
} else {
panic!("Expected GroupVolume change");
}
if let PropertyChange::GroupMute(m) = &changes[1] {
assert!(!m.0);
} else {
panic!("Expected GroupMute change");
}
if let PropertyChange::GroupVolumeChangeable(c) = &changes[2] {
assert!(c.0);
} else {
panic!("Expected GroupVolumeChangeable change");
}
}
#[test]
fn test_decode_group_rendering_control_clamps_volume() {
let event = GroupRenderingControlState {
group_volume: Some(150),
group_mute: None,
group_volume_changeable: None,
};
let changes = decode_group_rendering_control(&event);
assert_eq!(changes.len(), 1);
if let PropertyChange::GroupVolume(v) = &changes[0] {
assert_eq!(v.0, 100);
} else {
panic!("Expected GroupVolume change");
}
}
#[test]
fn test_decode_group_rendering_control_no_volume() {
let event = GroupRenderingControlState {
group_volume: None,
group_mute: Some(true),
group_volume_changeable: None,
};
let changes = decode_group_rendering_control(&event);
assert_eq!(changes.len(), 1);
if let PropertyChange::GroupMute(m) = &changes[0] {
assert!(m.0);
} else {
panic!("Expected GroupMute change");
}
}
#[test]
fn test_property_change_key() {
use crate::property::Property;
let vol_change = PropertyChange::Volume(Volume(50));
assert_eq!(vol_change.key(), Volume::KEY);
let mute_change = PropertyChange::Mute(Mute(false));
assert_eq!(mute_change.key(), Mute::KEY);
let ps_change = PropertyChange::PlaybackState(PlaybackState::Playing);
assert_eq!(ps_change.key(), PlaybackState::KEY);
}
#[test]
fn test_property_change_service() {
use crate::model::GroupId;
use crate::property::SonosProperty;
let vol_change = PropertyChange::Volume(Volume(50));
assert_eq!(vol_change.service(), Volume::SERVICE);
let ps_change = PropertyChange::PlaybackState(PlaybackState::Playing);
assert_eq!(ps_change.service(), PlaybackState::SERVICE);
let gm_change = PropertyChange::GroupMembership(GroupMembership::new(
GroupId::new("RINCON_test:1"),
true,
));
assert_eq!(gm_change.service(), GroupMembership::SERVICE);
}
use sonos_stream::events::types::{NetworkInfo, ZoneGroupInfo, ZoneGroupMemberInfo};
fn make_member(uuid: &str, zone_name: &str) -> ZoneGroupMemberInfo {
make_member_with_boot_seq(uuid, zone_name, 0)
}
fn make_member_with_boot_seq(
uuid: &str,
zone_name: &str,
boot_seq: u32,
) -> ZoneGroupMemberInfo {
ZoneGroupMemberInfo {
uuid: uuid.to_string(),
location: "http://192.168.1.100:1400/xml/device_description.xml".to_string(),
zone_name: zone_name.to_string(),
software_version: "79.1-56030".to_string(),
boot_seq,
network_info: NetworkInfo {
wireless_mode: "0".to_string(),
wifi_enabled: "1".to_string(),
eth_link: "1".to_string(),
channel_freq: "2412".to_string(),
behind_wifi_extender: "0".to_string(),
},
satellites: vec![],
}
}
#[test]
fn test_decode_topology_single_group_one_speaker() {
let event = ZoneGroupTopologyState {
zone_groups: vec![ZoneGroupInfo {
coordinator: "RINCON_111111111111".to_string(),
id: "RINCON_111111111111:0".to_string(),
members: vec![make_member("RINCON_111111111111", "Living Room")],
}],
vanished_devices: vec![],
};
let result = decode_topology_event(&event);
assert_eq!(result.groups.len(), 1);
let group = &result.groups[0];
assert_eq!(group.id.as_str(), "RINCON_111111111111:0");
assert_eq!(group.coordinator_id.as_str(), "RINCON_111111111111");
assert_eq!(group.member_ids.len(), 1);
assert!(group.is_standalone());
assert_eq!(result.memberships.len(), 1);
let (speaker_id, membership) = &result.memberships[0];
assert_eq!(speaker_id.as_str(), "RINCON_111111111111");
assert_eq!(membership.group_id.as_str(), "RINCON_111111111111:0");
assert!(membership.is_coordinator);
}
#[test]
fn test_decode_topology_single_group_multiple_speakers() {
let event = ZoneGroupTopologyState {
zone_groups: vec![ZoneGroupInfo {
coordinator: "RINCON_111111111111".to_string(),
id: "RINCON_111111111111:0".to_string(),
members: vec![
make_member("RINCON_111111111111", "Living Room"),
make_member("RINCON_222222222222", "Kitchen"),
make_member("RINCON_333333333333", "Bedroom"),
],
}],
vanished_devices: vec![],
};
let result = decode_topology_event(&event);
assert_eq!(result.groups.len(), 1);
let group = &result.groups[0];
assert_eq!(group.member_ids.len(), 3);
assert!(!group.is_standalone());
assert_eq!(result.memberships.len(), 3);
let coordinator_membership = result
.memberships
.iter()
.find(|(sid, _)| sid.as_str() == "RINCON_111111111111")
.map(|(_, m)| m);
assert!(coordinator_membership.is_some());
assert!(coordinator_membership.unwrap().is_coordinator);
let kitchen_membership = result
.memberships
.iter()
.find(|(sid, _)| sid.as_str() == "RINCON_222222222222")
.map(|(_, m)| m);
assert!(kitchen_membership.is_some());
assert!(!kitchen_membership.unwrap().is_coordinator);
let bedroom_membership = result
.memberships
.iter()
.find(|(sid, _)| sid.as_str() == "RINCON_333333333333")
.map(|(_, m)| m);
assert!(bedroom_membership.is_some());
assert!(!bedroom_membership.unwrap().is_coordinator);
}
#[test]
fn test_decode_topology_multiple_groups() {
let event = ZoneGroupTopologyState {
zone_groups: vec![
ZoneGroupInfo {
coordinator: "RINCON_111111111111".to_string(),
id: "RINCON_111111111111:0".to_string(),
members: vec![
make_member("RINCON_111111111111", "Living Room"),
make_member("RINCON_222222222222", "Kitchen"),
],
},
ZoneGroupInfo {
coordinator: "RINCON_333333333333".to_string(),
id: "RINCON_333333333333:0".to_string(),
members: vec![make_member("RINCON_333333333333", "Bedroom")],
},
],
vanished_devices: vec![],
};
let result = decode_topology_event(&event);
assert_eq!(result.groups.len(), 2);
let group1 = &result.groups[0];
assert_eq!(group1.id.as_str(), "RINCON_111111111111:0");
assert_eq!(group1.member_ids.len(), 2);
let group2 = &result.groups[1];
assert_eq!(group2.id.as_str(), "RINCON_333333333333:0");
assert_eq!(group2.member_ids.len(), 1);
assert!(group2.is_standalone());
assert_eq!(result.memberships.len(), 3);
let living_room = result
.memberships
.iter()
.find(|(sid, _)| sid.as_str() == "RINCON_111111111111")
.map(|(_, m)| m)
.unwrap();
assert_eq!(living_room.group_id.as_str(), "RINCON_111111111111:0");
assert!(living_room.is_coordinator);
let kitchen = result
.memberships
.iter()
.find(|(sid, _)| sid.as_str() == "RINCON_222222222222")
.map(|(_, m)| m)
.unwrap();
assert_eq!(kitchen.group_id.as_str(), "RINCON_111111111111:0");
assert!(!kitchen.is_coordinator);
let bedroom = result
.memberships
.iter()
.find(|(sid, _)| sid.as_str() == "RINCON_333333333333")
.map(|(_, m)| m)
.unwrap();
assert_eq!(bedroom.group_id.as_str(), "RINCON_333333333333:0");
assert!(bedroom.is_coordinator);
}
#[test]
fn test_decode_topology_empty_event() {
let event = ZoneGroupTopologyState {
zone_groups: vec![],
vanished_devices: vec![],
};
let result = decode_topology_event(&event);
assert!(result.groups.is_empty());
assert!(result.memberships.is_empty());
}
#[test]
fn test_decode_topology_extracts_boot_seq_values() {
let event = ZoneGroupTopologyState {
zone_groups: vec![ZoneGroupInfo {
coordinator: "RINCON_111111111111".to_string(),
id: "RINCON_111111111111:0".to_string(),
members: vec![
make_member_with_boot_seq("RINCON_111111111111", "Living Room", 42),
make_member_with_boot_seq("RINCON_222222222222", "Kitchen", 17),
],
}],
vanished_devices: vec![],
};
let result = decode_topology_event(&event);
assert_eq!(result.boot_seqs.len(), 2);
let boot_seq_111 = result
.boot_seqs
.iter()
.find(|(id, _)| id.as_str() == "RINCON_111111111111")
.map(|(_, bs)| *bs);
assert_eq!(boot_seq_111, Some(42));
let boot_seq_222 = result
.boot_seqs
.iter()
.find(|(id, _)| id.as_str() == "RINCON_222222222222")
.map(|(_, bs)| *bs);
assert_eq!(boot_seq_222, Some(17));
}
#[test]
fn test_decode_topology_boot_seq_defaults_to_zero() {
let event = ZoneGroupTopologyState {
zone_groups: vec![ZoneGroupInfo {
coordinator: "RINCON_111111111111".to_string(),
id: "RINCON_111111111111:0".to_string(),
members: vec![make_member("RINCON_111111111111", "Living Room")],
}],
vanished_devices: vec![],
};
let result = decode_topology_event(&event);
assert_eq!(result.boot_seqs.len(), 1);
assert_eq!(result.boot_seqs[0].1, 0);
}
}
#[cfg(test)]
mod property_tests {
use super::*;
use proptest::prelude::*;
use sonos_stream::events::types::{NetworkInfo, ZoneGroupInfo, ZoneGroupMemberInfo};
fn speaker_uuid_strategy() -> impl Strategy<Value = String> {
"[A-F0-9]{12}".prop_map(|s| format!("RINCON_{s}"))
}
fn zone_group_member_strategy() -> impl Strategy<Value = ZoneGroupMemberInfo> {
(speaker_uuid_strategy(), "[A-Za-z ]{3,15}").prop_map(|(uuid, zone_name)| {
ZoneGroupMemberInfo {
uuid,
location: "http://192.168.1.100:1400/xml/device_description.xml".to_string(),
zone_name: zone_name.trim().to_string(),
software_version: "79.1-56030".to_string(),
boot_seq: 0,
network_info: NetworkInfo {
wireless_mode: "0".to_string(),
wifi_enabled: "1".to_string(),
eth_link: "1".to_string(),
channel_freq: "2412".to_string(),
behind_wifi_extender: "0".to_string(),
},
satellites: vec![],
}
})
}
fn zone_group_strategy() -> impl Strategy<Value = ZoneGroupInfo> {
proptest::collection::vec(zone_group_member_strategy(), 1..=5).prop_flat_map(|members| {
let coordinator = members[0].uuid.clone();
let group_id = format!("{coordinator}:0");
Just(ZoneGroupInfo {
coordinator,
id: group_id,
members,
})
})
}
fn topology_event_strategy() -> impl Strategy<Value = ZoneGroupTopologyState> {
proptest::collection::vec(zone_group_strategy(), 1..=3).prop_map(|zone_groups| {
ZoneGroupTopologyState {
zone_groups,
vanished_devices: vec![],
}
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_topology_event_processing_round_trip(event in topology_event_strategy()) {
let result = decode_topology_event(&event);
prop_assert_eq!(
result.groups.len(),
event.zone_groups.len(),
"Number of groups should match number of zone groups in event"
);
let total_members: usize = event.zone_groups.iter()
.map(|zg| zg.members.len())
.sum();
prop_assert_eq!(
result.memberships.len(),
total_members,
"Number of memberships should match total members across all groups"
);
for (group_info, zone_group) in result.groups.iter().zip(event.zone_groups.iter()) {
prop_assert_eq!(
group_info.id.as_str(),
&zone_group.id,
"GroupInfo ID should match zone group ID"
);
prop_assert_eq!(
group_info.coordinator_id.as_str(),
&zone_group.coordinator,
"GroupInfo coordinator should match zone group coordinator"
);
prop_assert_eq!(
group_info.member_ids.len(),
zone_group.members.len(),
"GroupInfo member count should match zone group member count"
);
}
for zone_group in &event.zone_groups {
for member in &zone_group.members {
let membership = result.memberships.iter()
.find(|(sid, _)| sid.as_str() == member.uuid)
.map(|(_, m)| m);
prop_assert!(
membership.is_some(),
"Each member should have a GroupMembership"
);
let membership = membership.unwrap();
prop_assert_eq!(
membership.group_id.as_str(),
&zone_group.id,
"GroupMembership.group_id should match zone group ID"
);
let is_coordinator = member.uuid == zone_group.coordinator;
prop_assert_eq!(
membership.is_coordinator,
is_coordinator,
"is_coordinator should be true only for the coordinator"
);
}
}
}
#[test]
fn prop_coordinator_always_in_members(event in topology_event_strategy()) {
let result = decode_topology_event(&event);
for group_info in &result.groups {
prop_assert!(
group_info.member_ids.contains(&group_info.coordinator_id),
"Coordinator should always be in member_ids"
);
}
}
#[test]
fn prop_exactly_one_coordinator_per_group(event in topology_event_strategy()) {
let result = decode_topology_event(&event);
for group_info in &result.groups {
let coordinator_count = result.memberships.iter()
.filter(|(sid, membership)| {
group_info.member_ids.contains(sid) && membership.is_coordinator
})
.count();
prop_assert_eq!(
coordinator_count,
1,
"Each group should have exactly one coordinator"
);
}
}
}
}