1use serde_json::Value;
12
13use crate::{Clause, Severity};
14
15fn floats(v: &Value, key: &str, n: usize) -> Option<Vec<f64>> {
17 let arr = v.get(key)?.as_array()?;
18 if arr.len() != n {
19 return None;
20 }
21 let out: Vec<f64> = arr
22 .iter()
23 .filter_map(Value::as_f64)
24 .filter(|f| f.is_finite())
25 .collect();
26 (out.len() == n).then_some(out)
27}
28
29pub fn check_observer_welcome(msg: &Value) -> Vec<Clause> {
35 let mut clauses = check_welcome(msg);
36 clauses.push(Clause {
37 name: "observe welcome is marked observer",
38 severity: Severity::Error,
39 pass: msg.get("observer").and_then(Value::as_bool) == Some(true),
40 notes: match msg.get("observer") {
41 Some(v) => vec![format!("observer = {v}")],
42 None => vec!["no `observer` field — a watcher cannot tell it is one".into()],
43 },
44 });
45 clauses.push(Clause {
46 name: "observer is not an occupant (id 0)",
47 severity: Severity::Error,
48 pass: msg.get("id").and_then(Value::as_u64) == Some(0),
49 notes: vec![],
50 });
51 clauses
52}
53
54pub fn check_welcome(msg: &Value) -> Vec<Clause> {
58 let is_welcome = msg.get("t").and_then(Value::as_str) == Some("welcome");
59 vec![
60 Clause {
61 name: "welcome tagged t=welcome",
62 severity: Severity::Error,
63 pass: is_welcome,
64 notes: if is_welcome {
65 vec![]
66 } else {
67 vec![format!("t = {:?}", msg.get("t"))]
68 },
69 },
70 Clause {
71 name: "welcome assigns an occupant id",
72 severity: Severity::Error,
73 pass: msg.get("id").and_then(Value::as_u64).is_some(),
74 notes: vec![],
75 },
76 Clause {
77 name: "welcome lists occupants",
78 severity: Severity::Error,
79 pass: msg.get("occupants").map(Value::is_array).unwrap_or(false),
80 notes: vec![],
81 },
82 Clause {
83 name: "welcome declares tick_hz",
84 severity: Severity::Warn,
85 pass: msg
86 .get("tick_hz")
87 .and_then(Value::as_u64)
88 .is_some_and(|h| h > 0),
89 notes: vec![],
90 },
91 ]
92}
93
94pub fn check_pose(msg: &Value) -> Vec<Clause> {
98 let is_pose = msg.get("t").and_then(Value::as_str) == Some("pose");
99 let quat = floats(msg, "r", 4);
100 let has_orientation = quat.is_some() || msg.get("y").and_then(Value::as_f64).is_some();
101 let unit_quat = match &quat {
103 Some(r) => {
104 let len2 = r.iter().map(|x| x * x).sum::<f64>();
105 (len2 - 1.0).abs() < 0.05
106 }
107 None => true, };
109
110 vec![
111 Clause {
112 name: "pose tagged t=pose",
113 severity: Severity::Error,
114 pass: is_pose,
115 notes: if is_pose {
116 vec![]
117 } else {
118 vec![format!("t = {:?}", msg.get("t"))]
119 },
120 },
121 Clause {
122 name: "pose carries an occupant id",
123 severity: Severity::Error,
124 pass: msg.get("id").and_then(Value::as_u64).is_some(),
125 notes: vec![],
126 },
127 Clause {
128 name: "pose is server-stamped (ts)",
129 severity: Severity::Error,
130 pass: msg.get("ts").and_then(Value::as_u64).is_some(),
131 notes: vec!["ts is mandatory: the single clock source for interpolation".into()],
132 },
133 Clause {
134 name: "pose has a position [f32;3]",
135 severity: Severity::Error,
136 pass: floats(msg, "p", 3).is_some(),
137 notes: vec![],
138 },
139 Clause {
140 name: "pose has a velocity [f32;3]",
141 severity: Severity::Error,
142 pass: floats(msg, "v", 3).is_some(),
143 notes: vec!["v is mandatory: enables gap extrapolation".into()],
144 },
145 Clause {
146 name: "pose has an orientation (r xyzw or yaw y)",
147 severity: Severity::Error,
148 pass: has_orientation,
149 notes: vec![],
150 },
151 Clause {
152 name: "orientation is a unit quaternion",
153 severity: Severity::Warn,
154 pass: unit_quat,
155 notes: vec![],
156 },
157 Clause {
158 name: "pose carries an animation state (a)",
159 severity: Severity::Warn,
160 pass: msg.get("a").and_then(Value::as_u64).is_some(),
161 notes: vec![],
162 },
163 ]
164}
165
166pub struct RelayOutcome {
168 pub connected: bool,
170 pub clauses: Vec<Clause>,
171 pub notes: Vec<String>,
173}
174
175pub async fn probe(url: &str, passport: &str, timeout_ms: u64) -> RelayOutcome {
180 use futures_util::{SinkExt, StreamExt};
181 use tokio_tungstenite::tungstenite::Message;
182
183 let mut clauses = Vec::new();
184 let mut notes = Vec::new();
185
186 let ws = match tokio_tungstenite::connect_async(url).await {
187 Ok((ws, _)) => ws,
188 Err(e) => {
189 clauses.push(Clause {
190 name: "relay reachable (WebSocket)",
191 severity: Severity::Error,
192 pass: false,
193 notes: vec![e.to_string()],
194 });
195 return RelayOutcome {
196 connected: false,
197 clauses,
198 notes,
199 };
200 }
201 };
202 clauses.push(Clause {
203 name: "relay reachable (WebSocket)",
204 severity: Severity::Error,
205 pass: true,
206 notes: vec![],
207 });
208
209 let (mut write, mut read) = ws.split();
210 let join = serde_json::json!({ "t": "join", "passport": passport, "spawn": null });
211 let _ = write.send(Message::Text(join.to_string())).await;
212
213 let pose = serde_json::json!({
215 "t": "pose", "id": 0, "ts": 0, "p": [0.0, 0.0, 0.0], "r": [0.0, 0.0, 0.0, 1.0], "v": [0.0, 0.0, 0.0], "a": 0
216 });
217 let _ = write.send(Message::Text(pose.to_string())).await;
218
219 let deadline = tokio::time::Duration::from_millis(timeout_ms);
220 let mut saw_welcome = false;
221 let mut saw_pose = false;
222
223 let _ = tokio::time::timeout(deadline, async {
224 while let Some(Ok(msg)) = read.next().await {
225 let Ok(text) = msg.into_text() else { continue };
226 let Ok(v) = serde_json::from_str::<Value>(&text) else {
227 continue;
228 };
229 match v.get("t").and_then(Value::as_str) {
230 Some("welcome") if !saw_welcome => {
231 saw_welcome = true;
232 clauses.extend(check_welcome(&v));
233 if let Some(occ) = v.get("occupants").and_then(Value::as_array) {
234 notes.push(format!("relay reports {} occupant(s)", occ.len()));
235 }
236 }
237 Some("pose") if !saw_pose => {
238 saw_pose = true;
239 clauses.extend(check_pose(&v));
240 }
241 _ => {}
242 }
243 if saw_welcome && saw_pose {
244 break;
245 }
246 }
247 })
248 .await;
249
250 if !saw_welcome {
251 clauses.push(Clause {
252 name: "relay replies welcome on join",
253 severity: Severity::Error,
254 pass: false,
255 notes: vec!["no welcome within timeout (Passport rejected? wrong path?)".into()],
256 });
257 }
258 if !saw_pose {
259 notes.push("no pose fan-out observed (expected with a lone probe client)".into());
260 }
261
262 match tokio_tungstenite::connect_async(url).await {
267 Ok((ws2, _)) => {
268 let (mut w2, mut r2) = ws2.split();
269 let _ = w2
270 .send(Message::Text(serde_json::json!({ "t": "observe" }).to_string()))
271 .await;
272 let mut welcomed = false;
273 let mut poses_leaked = 0usize;
274 let _ = tokio::time::timeout(deadline, async {
275 while let Some(Ok(msg)) = r2.next().await {
276 let Ok(text) = msg.into_text() else { continue };
277 let Ok(v) = serde_json::from_str::<Value>(&text) else { continue };
278 match v.get("t").and_then(Value::as_str) {
279 Some("welcome") if !welcomed => {
280 welcomed = true;
281 clauses.extend(check_observer_welcome(&v));
282 }
283 Some("pose") => poses_leaked += 1,
284 _ => {}
285 }
286 }
287 })
288 .await;
289 if welcomed {
290 clauses.push(Clause {
291 name: "observers are sent no poses",
292 severity: Severity::Error,
293 pass: poses_leaked == 0,
294 notes: if poses_leaked == 0 {
295 vec![]
296 } else {
297 vec![format!("{poses_leaked} pose frame(s) reached a watcher")]
298 },
299 });
300 } else {
301 notes.push(
302 "relay did not answer `observe` — watching without entering is unsupported \
303 (presence-wire §2)"
304 .into(),
305 );
306 }
307 }
308 Err(e) => notes.push(format!("could not open a second socket to watch: {e}")),
309 }
310
311 RelayOutcome {
312 connected: true,
313 clauses,
314 notes,
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use crate::clauses_pass;
322 use serde_json::json;
323
324#[test]
330 fn a_spec_observer_welcome_passes_and_a_joiners_does_not() {
331 let w = json!({
332 "t": "welcome", "id": 0, "observer": true,
333 "occupants": [{ "id": 1 }], "tick_hz": 15
334 });
335 let clauses = check_observer_welcome(&w);
336 assert!(clauses_pass(&clauses), "{clauses:?}");
337 assert_eq!(
338 w["occupants"].as_array().unwrap().len(),
339 1,
340 "the roster stands as it is, not as it will be"
341 );
342
343 let joiner = json!({ "t": "welcome", "id": 3, "occupants": [], "tick_hz": 15 });
347 let clauses = check_observer_welcome(&joiner);
348 assert!(!clauses_pass(&clauses));
349 assert!(clauses.iter().any(|c| c.name == "observe welcome is marked observer" && !c.pass));
350 assert!(clauses.iter().any(|c| c.name == "observer is not an occupant (id 0)" && !c.pass));
351 }
352
353 #[test]
354 fn a_spec_welcome_passes() {
355 let w = json!({ "t": "welcome", "id": 5, "occupants": [{ "id": 7 }, { "id": 9 }], "tick_hz": 15 });
356 let clauses = check_welcome(&w);
357 assert!(clauses_pass(&clauses), "{clauses:?}");
358 assert!(clauses.iter().all(|c| c.pass));
359 }
360
361 #[test]
362 fn a_welcome_without_an_id_fails() {
363 let w = json!({ "t": "welcome", "occupants": [] });
364 let clauses = check_welcome(&w);
365 assert!(!clauses_pass(&clauses));
366 let id = clauses
367 .iter()
368 .find(|c| c.name == "welcome assigns an occupant id")
369 .unwrap();
370 assert!(!id.pass);
371 assert_eq!(id.severity, Severity::Error);
372 }
373
374 #[test]
375 fn a_full_spec_pose_passes() {
376 let p = json!({ "t": "pose", "id": 7, "ts": 123456, "p": [3.0, 0.0, -1.0], "r": [0.0, 0.0, 0.0, 1.0], "v": [1.0, 0.0, 0.0], "a": 2 });
377 let clauses = check_pose(&p);
378 assert!(clauses_pass(&clauses));
379 assert!(clauses.iter().all(|c| c.pass), "{clauses:?}");
380 }
381
382 #[test]
383 fn a_pose_missing_ts_or_velocity_is_an_error() {
384 let no_ts = json!({ "t": "pose", "id": 7, "p": [0.0, 0.0, 0.0], "r": [0.0, 0.0, 0.0, 1.0], "v": [0.0, 0.0, 0.0], "a": 0 });
385 assert!(!clauses_pass(&check_pose(&no_ts)), "ts is mandatory");
386
387 let no_v = json!({ "t": "pose", "id": 7, "ts": 1, "p": [0.0, 0.0, 0.0], "r": [0.0, 0.0, 0.0, 1.0], "a": 0 });
388 assert!(!clauses_pass(&check_pose(&no_v)), "v is mandatory");
389 }
390
391 #[test]
392 fn yaw_only_orientation_is_accepted() {
393 let p = json!({ "t": "pose", "id": 7, "ts": 1, "p": [0.0, 0.0, 0.0], "y": 1.57, "v": [0.0, 0.0, 0.0], "a": 1 });
395 let clauses = check_pose(&p);
396 assert!(clauses_pass(&clauses), "{clauses:?}");
397 let orient = clauses
398 .iter()
399 .find(|c| c.name.contains("orientation is a unit"))
400 .unwrap();
401 assert!(
402 orient.pass,
403 "unit-quat check must not penalize a yaw-only pose"
404 );
405 }
406
407 #[test]
408 fn a_non_unit_quaternion_warns_but_does_not_fail() {
409 let p = json!({ "t": "pose", "id": 7, "ts": 1, "p": [0.0, 0.0, 0.0], "r": [1.0, 1.0, 0.0, 0.0], "v": [0.0, 0.0, 0.0], "a": 0 });
410 let clauses = check_pose(&p);
411 assert!(
412 clauses_pass(&clauses),
413 "non-unit quat is a warning, not a failure"
414 );
415 let unit = clauses
416 .iter()
417 .find(|c| c.name.contains("unit quaternion"))
418 .unwrap();
419 assert!(!unit.pass);
420 assert_eq!(unit.severity, Severity::Warn);
421 }
422}