/
/
/
1"""Tests for the Bose SoundTouch API client parsing helpers."""
2
3from __future__ import annotations
4
5from defusedxml import ElementTree as DefusedET
6
7from music_assistant.providers.bose_soundtouch.client import (
8 _build_notification_xml,
9 _build_zone_xml,
10 extract_preset_id,
11 parse_info,
12 parse_now_playing,
13 parse_sources,
14 parse_volume,
15 parse_zone,
16 play_status_is_paused,
17 play_status_is_playing,
18)
19
20INFO_XML = """
21<info deviceID="ABC123">
22 <name>Living Room</name>
23 <type>SoundTouch 20</type>
24 <networkInfo type="SCM">
25 <macAddress>001122334455</macAddress>
26 <ipAddress>192.168.1.50</ipAddress>
27 </networkInfo>
28 <components>
29 <component>
30 <componentCategory>SCM</componentCategory>
31 <softwareVersion>27.0.6.46330 epdbuild</softwareVersion>
32 </component>
33 </components>
34</info>
35"""
36
37NOW_PLAYING_XML = """
38<nowPlaying deviceID="ABC123" source="SPOTIFY" sourceAccount="user1">
39 <ContentItem source="SPOTIFY"><itemName>My Playlist</itemName></ContentItem>
40 <track>Song Title</track>
41 <artist>The Artist</artist>
42 <album>The Album</album>
43 <art artImageStatus="IMAGE_PRESENT">http://192.168.1.50/art.jpg</art>
44 <time total="240">42</time>
45 <playStatus>PLAY_STATE</playStatus>
46</nowPlaying>
47"""
48
49
50def test_parse_info() -> None:
51 """Device info is parsed including identifiers and firmware version."""
52 info = parse_info(DefusedET.fromstring(INFO_XML), fallback_ip="10.0.0.1")
53 assert info.device_id == "ABC123"
54 assert info.name == "Living Room"
55 assert info.model == "SoundTouch 20"
56 assert info.mac_address == "001122334455"
57 assert info.ip_address == "192.168.1.50"
58 assert info.software_version == "27.0.6.46330"
59
60
61def test_parse_info_falls_back_to_connection_ip() -> None:
62 """When the device reports no network IP, the connection IP is used."""
63 info = parse_info(
64 DefusedET.fromstring('<info deviceID="X"><name>N</name></info>'), fallback_ip="10.0.0.9"
65 )
66 assert info.ip_address == "10.0.0.9"
67
68
69def test_parse_now_playing() -> None:
70 """A playing now_playing snapshot maps to the expected fields."""
71 now = parse_now_playing(DefusedET.fromstring(NOW_PLAYING_XML))
72 assert now.source == "SPOTIFY"
73 assert now.source_account == "user1"
74 assert now.title == "Song Title"
75 assert now.artist == "The Artist"
76 assert now.album == "The Album"
77 assert now.image_url == "http://192.168.1.50/art.jpg"
78 assert now.duration == 240
79 assert now.position == 42
80 assert play_status_is_playing(now.play_status)
81 assert not play_status_is_paused(now.play_status)
82
83
84def test_parse_now_playing_standby() -> None:
85 """A standby snapshot reports the STANDBY source."""
86 now = parse_now_playing(
87 DefusedET.fromstring('<nowPlaying deviceID="A" source="STANDBY"></nowPlaying>')
88 )
89 assert now.source == "STANDBY"
90 assert now.title is None
91
92
93def test_parse_volume() -> None:
94 """Volume level and mute state are parsed."""
95 volume = parse_volume(
96 DefusedET.fromstring(
97 "<volume><targetvolume>30</targetvolume>"
98 "<actualvolume>28</actualvolume><muteenabled>true</muteenabled></volume>"
99 )
100 )
101 assert volume.level == 28
102 assert volume.muted is True
103
104
105def test_parse_sources() -> None:
106 """Sources are parsed with their ready state."""
107 sources = parse_sources(
108 DefusedET.fromstring(
109 '<sources deviceID="A">'
110 '<sourceItem source="AUX" sourceAccount="AUX" status="READY">AUX IN</sourceItem>'
111 '<sourceItem source="BLUETOOTH" status="UNAVAILABLE">Bluetooth</sourceItem>'
112 "</sources>"
113 )
114 )
115 assert len(sources) == 2
116 assert sources[0].source == "AUX"
117 assert sources[0].source_account == "AUX"
118 assert sources[0].name == "AUX IN"
119 assert sources[0].ready is True
120 assert sources[1].ready is False
121
122
123def test_parse_zone_master() -> None:
124 """A zone master reports its members."""
125 zone = parse_zone(
126 DefusedET.fromstring(
127 '<zone master="ABC"><member ipaddress="192.168.1.51">DEF</member></zone>'
128 )
129 )
130 assert zone.master_id == "ABC"
131 assert zone.member_ids == ["DEF"]
132
133
134def test_parse_zone_empty() -> None:
135 """An empty zone response yields no master and no members."""
136 zone = parse_zone(DefusedET.fromstring("<zone />"))
137 assert zone.master_id is None
138 assert zone.member_ids == []
139
140
141def test_build_zone_xml() -> None:
142 """Zone request bodies are built with master and member entries."""
143 xml = _build_zone_xml("MASTER", [("MASTER", "1.2.3.4"), ("SLAVE", "1.2.3.5")])
144 assert xml == (
145 '<zone master="MASTER">'
146 '<member ipaddress="1.2.3.4">MASTER</member>'
147 '<member ipaddress="1.2.3.5">SLAVE</member>'
148 "</zone>"
149 )
150
151
152def test_build_notification_xml() -> None:
153 """The notification body includes the app key, url and volume, escaping the url."""
154 xml = _build_notification_xml("KEY", "http://host/stream?a=1&b=2", volume=25)
155 assert "<app_key>KEY</app_key>" in xml
156 assert "<url>http://host/stream?a=1&b=2</url>" in xml
157 assert "<volume>25</volume>" in xml
158
159
160def test_build_notification_xml_without_volume() -> None:
161 """The notification body omits the volume element when no volume is given."""
162 xml = _build_notification_xml("KEY", "http://host/stream")
163 assert "<volume>" not in xml
164
165
166def test_extract_preset_id() -> None:
167 """A preset button press is detected from a websocket message."""
168 assert extract_preset_id('<updates deviceID="A"><preset id="3"/></updates>') == 3
169
170
171def test_extract_preset_id_no_preset() -> None:
172 """A message without a preset element returns None."""
173 assert extract_preset_id('<updates deviceID="A"><nowPlayingUpdated/></updates>') is None
174
175
176def test_extract_preset_id_invalid_xml() -> None:
177 """A non-XML message returns None instead of raising."""
178 assert extract_preset_id("not xml at all") is None
179
180
181def test_extract_preset_id_non_numeric() -> None:
182 """A non-numeric preset id returns None instead of raising."""
183 assert extract_preset_id('<preset id="abc"/>') is None
184
185
186def test_play_status_helpers() -> None:
187 """Play status helpers classify the SoundTouch playStatus values."""
188 assert play_status_is_playing("PLAY_STATE")
189 assert play_status_is_playing("BUFFERING_STATE")
190 assert not play_status_is_playing("STOP_STATE")
191 assert not play_status_is_playing(None)
192 assert play_status_is_paused("PAUSE_STATE")
193 assert not play_status_is_paused("PLAY_STATE")
194