Skip to content
File

Blob: firmware/crates/radio-core/tests/contracts.rs

rust306 lines
1use radio_core::{
2 music::{Catalog, Error as MusicError, MAX_OPUS_BYTES},
3 playback::Playback,
4 protocol::{self, Action, Command},
5 signaling::{self, Channels, Started},
6};
7use std::num::NonZeroU32;
8 
9fn parse_music(mut bytes: &[u8]) -> Result<Catalog, MusicError> {
10 Catalog::parse(&mut bytes)
11}
12 
13fn packet(catalog: &Catalog, mut bytes: &[u8], index: u32) -> Result<Vec<u8>, MusicError> {
14 let mut out = [0; MAX_OPUS_BYTES];
15 let length = catalog.read_frame(&mut bytes, 0, index, &mut out)?;
16 Ok(out[..length].to_vec())
17}
18 
19// Independent fixture: Python struct.pack('<8sIIIIII32x', ...) and zlib.crc32
20// over one 3-byte Opus silence packet with spectrum bytes 0..31. No paid media.
21fn fixture() -> Vec<u8> {
22 include_bytes!("fixtures/silence.pack").to_vec()
23}
24 
25#[test]
26fn pack_reads_known_crc_and_ignores_erased_partition_tail() {
27 let mut bytes = fixture();
28 bytes.extend([255; 100]);
29 let pack = parse_music(&bytes).unwrap();
30 assert!(pack.songs()[0].metadata.as_ref().is_none());
31 assert_eq!(
32 packet(&pack, bytes.as_ref(), 0).unwrap(),
33 [0xf8, 0xff, 0xfe]
34 );
35 assert!(packet(&pack, bytes.as_ref(), 1).is_err());
36}
37 
38#[test]
39fn every_truncated_fixture_is_rejected_without_panicking() {
40 let bytes = fixture();
41 for end in 0..bytes.len() {
42 assert!(
43 parse_music(&bytes[..end]).is_err(),
44 "accepted truncated length {end}"
45 );
46 }
47}
48 
49#[test]
50fn v2_pack_needs_no_spectrum_bytes() {
51 let bytes = include_bytes!("fixtures/silence-v2.pack");
52 let pack = parse_music(bytes).unwrap();
53 assert!(pack.songs()[0].metadata.as_ref().is_none());
54 assert_eq!(
55 packet(&pack, bytes.as_ref(), 0).unwrap(),
56 [0xf8, 0xff, 0xfe]
57 );
58 for end in 0..bytes.len() {
59 assert!(parse_music(&bytes[..end]).is_err());
60 }
61 let mut corrupt = bytes.to_vec();
62 *corrupt.last_mut().unwrap() ^= 1;
63 assert_eq!(parse_music(&corrupt).unwrap_err(), MusicError::Checksum);
64}
65 
66#[test]
67fn metadata_pack_keeps_unicode_tags_audio_and_duration_together() {
68 let mut bytes = include_bytes!("fixtures/silence-v3.pack").to_vec();
69 bytes.extend([255; 100]);
70 let pack = parse_music(&bytes).unwrap();
71 let track = pack.songs()[0].metadata.as_ref().unwrap();
72 assert_eq!(track.title, "Café 音");
73 assert_eq!(track.artist, "Test artist");
74 assert_eq!(track.duration_ms, 20);
75 assert_eq!(
76 packet(&pack, bytes.as_ref(), 0).unwrap(),
77 [0xf8, 0xff, 0xfe]
78 );
79 assert_eq!(
80 serde_json::to_value(track).unwrap(),
81 serde_json::json!({
82 "title": "Café 音", "artist": "Test artist", "durationMs": 20
83 })
84 );
85}
86 
87#[test]
88fn metadata_lengths_encoding_and_integrity_are_validated() {
89 let bytes = include_bytes!("fixtures/silence-v3.pack");
90 for end in 0..bytes.len() {
91 assert!(parse_music(&bytes[..end]).is_err());
92 }
93 for length in [0u16, 257, u16::MAX] {
94 let mut invalid = bytes.to_vec();
95 invalid[64..66].copy_from_slice(&length.to_le_bytes());
96 assert!(parse_music(&invalid).is_err());
97 }
98 let mut corrupt = bytes.to_vec();
99 corrupt[68] = b'D';
100 assert_eq!(parse_music(&corrupt).unwrap_err(), MusicError::Checksum);
101 for bad_byte in [0u8, 255] {
102 let mut invalid = bytes.to_vec();
103 invalid[68] = bad_byte;
104 let crc = crc32fast::hash(&invalid[64..]);
105 invalid[28..32].copy_from_slice(&crc.to_le_bytes());
106 assert_eq!(parse_music(&invalid).unwrap_err(), MusicError::Metadata);
107 }
108}
109 
110#[test]
111fn metadata_accepts_a_maximum_utf8_title_and_an_unknown_artist() {
112 let bytes = include_bytes!("fixtures/silence-v3-max-tag.pack");
113 let pack = parse_music(bytes).unwrap();
114 let track = pack.songs()[0].metadata.as_ref().unwrap();
115 assert_eq!(track.title.len(), 256);
116 assert_eq!(track.title.chars().count(), 128);
117 assert!(track.artist.is_empty());
118 assert_eq!(
119 packet(&pack, bytes.as_ref(), 0).unwrap(),
120 [0xf8, 0xff, 0xfe]
121 );
122}
123 
124#[test]
125fn corrupted_payload_and_hostile_record_lengths_are_rejected() {
126 let mut bytes = fixture();
127 bytes[100] ^= 1;
128 assert_eq!(parse_music(&bytes).unwrap_err(), MusicError::Checksum);
129 for size in [0u16, 1276, u16::MAX] {
130 let mut bytes = fixture();
131 bytes[64..66].copy_from_slice(&size.to_le_bytes());
132 assert_eq!(parse_music(&bytes).unwrap_err(), MusicError::PacketSize);
133 }
134 for count in [0u32, 30_001, u32::MAX] {
135 let mut bytes = fixture();
136 bytes[12..16].copy_from_slice(&count.to_le_bytes());
137 assert_eq!(parse_music(&bytes).unwrap_err(), MusicError::FrameCount);
138 }
139}
140 
141#[test]
142fn arbitrary_small_inputs_do_not_panic() {
143 let mut random = 123u32;
144 for length in 0..1024 {
145 let bytes: Vec<u8> = (0..length)
146 .map(|_| {
147 random ^= random << 13;
148 random ^= random >> 17;
149 random ^= random << 5;
150 random as u8
151 })
152 .collect();
153 let _ = parse_music(&bytes);
154 let _ = Command::parse(&bytes);
155 }
156}
157 
158fn player() -> Playback {
159 Playback::new(NonZeroU32::new(5).unwrap(), 1_000_000)
160}
161 
162#[test]
163fn stall_skips_audio_without_bursting_and_wraps_track() {
164 let mut player = player();
165 assert_eq!(player.due(1_000_000).unwrap().index, 0);
166 let due = player.due(1_141_000).unwrap();
167 assert_eq!((due.index, due.pts_ms), (2, 140));
168 assert_eq!(player.skipped_frames(), 6);
169 assert!(player.due(1_141_001).is_none());
170 assert_eq!(player.due(1_160_000).unwrap().index, 3);
171}
172 
173#[test]
174fn pause_holds_position_while_transport_clock_advances() {
175 let mut player = player();
176 player.due(1_000_000);
177 player.apply(Action::Pause);
178 let a = player.due(1_020_000).unwrap();
179 let b = player.due(1_080_000).unwrap();
180 assert_eq!((a.index, b.index, b.pts_ms), (1, 1, 80));
181 assert!(a.paused && b.paused);
182 player.apply(Action::Play);
183 let c = player.due(1_100_000).unwrap();
184 assert_eq!((c.index, c.pts_ms, c.paused), (1, 100, false));
185}
186 
187#[test]
188fn restart_resets_song_but_never_resets_transport_clock() {
189 let mut player = player();
190 player.due(1_000_000);
191 player.due(1_020_000);
192 player.apply(Action::Pause);
193 player.apply(Action::Restart);
194 let due = player.due(1_040_000).unwrap();
195 assert_eq!((due.index, due.pts_ms, due.paused), (0, 40, false));
196}
197 
198#[test]
199fn spectrum_has_independent_little_endian_wire_oracle() {
200 let due = radio_core::playback::Due {
201 epoch: 0,
202 track_index: 0,
203 revision: 0,
204 index: 17,
205 pts_ms: 0x12345678,
206 paused: false,
207 spectrum: true,
208 };
209 let bytes = protocol::spectrum(due, &[42; 32]);
210 assert_eq!(
211 &bytes[..12],
212 &[2, 0, 0, 0, 0x78, 0x56, 0x34, 0x12, 0x54, 1, 0, 0]
213 );
214 assert!(bytes[12..44].iter().all(|&v| v == 42));
215 let paused = protocol::spectrum(
216 radio_core::playback::Due {
217 paused: true,
218 ..due
219 },
220 &[42; 32],
221 );
222 assert_eq!(paused[1], 1);
223 assert!(paused[12..44].iter().all(|&v| v == 0));
224}
225 
226#[test]
227fn commands_enforce_color_and_keep_maintenance_out_of_network_protocol() {
228 assert!(Command::parse(br#"{"led":[0,255,17],"command_id":"abc"}"#).is_ok());
229 for bytes in [
230 br#"{"led":[-1,0,0]}"#.as_slice(),
231 br#"{"led":[256,0,0]}"#,
232 br#"{"led":[1.5,0,0]}"#,
233 br#"{"led":[0,0]}"#,
234 br#"{"cmd":"restart_device","led":[0,0,0]}"#,
235 br#"{"action":"erase"}"#,
236 br#"{"action":"pause","action":"play"}"#,
237 br#"{"led":[0,0,0]} trailing"#,
238 ] {
239 assert!(Command::parse(bytes).is_err());
240 }
241 assert!(Command::parse(&[b' '; 513]).is_err());
242 let oversized_id = format!(r#"{{"action":"pause","command_id":"{}"}}"#, "x".repeat(65));
243 assert!(Command::parse(oversized_id.as_bytes()).is_err());
244}
245 
246#[test]
247fn publisher_validation_redacts_sdp_and_rejects_wrong_response_types() {
248 let raw = r#"{"generation":"g1","sessionDescription":{"type":"answer","sdp":"v=0\r\na=ice-pwd:secret"}}"#;
249 let started: Started = serde_json::from_str(raw).unwrap();
250 assert!(!format!("{started:?}").contains("secret"));
251 assert!(started.validate().is_ok());
252 let wrong: Started = serde_json::from_str(&raw.replace("answer", "offer")).unwrap();
253 assert!(wrong.validate().is_err());
254}
255 
256#[test]
257fn channel_allocation_is_checked_by_name_not_response_order() {
258 let reversed = r#"{"channels":[{"dataChannelName":"spectrum","id":4},{"dataChannelName":"robot","id":2}]}"#;
259 assert!(
260 serde_json::from_str::<Channels>(reversed)
261 .unwrap()
262 .validate()
263 .is_ok()
264 );
265 let reassigned = reversed
266 .replace("\"id\":2", "\"id\":8")
267 .replace("\"id\":4", "\"id\":12");
268 let ids = serde_json::from_str::<Channels>(&reassigned)
269 .unwrap()
270 .validate()
271 .unwrap();
272 assert_eq!((ids.robot(), ids.spectrum()), (8, 12));
273 assert!(
274 serde_json::from_str::<Channels>(&reversed.replace("\"id\":2", "\"id\":4"))
275 .unwrap()
276 .validate()
277 .is_err()
278 );
279 assert!(
280 serde_json::from_str::<Channels>(&reversed.replace("spectrum", "robot"))
281 .unwrap()
282 .validate()
283 .is_err()
284 );
285}
286 
287#[test]
288fn channel_ids_reject_collisions_and_reserved_streams() {
289 use radio_core::signaling::ChannelIds;
290 for (robot, spectrum) in [(0, 2), (2, 0), (4, 4), (65535, 6), (6, 65535)] {
291 assert!(ChannelIds::new(robot, spectrum).is_err());
292 }
293 assert!(ChannelIds::new(42, 65534).is_ok());
294}
295 
296#[test]
297fn recovery_distinguishes_transient_failure_and_revoked_generation() {
298 assert!(signaling::retryable(-1));
299 assert!(signaling::retryable(429));
300 assert!(signaling::retryable(503));
301 assert!(!signaling::retryable(401));
302 assert!(!signaling::needs_recovery(503, 5_000_000));
303 assert!(signaling::needs_recovery(503, 56_000_000));
304 assert!(signaling::needs_recovery(409, 1));
305}