2026-04-13 23:11:35 -03:00
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use std::collections::VecDeque;
|
|
|
|
|
|
|
|
|
|
use super::state::{InputRouting, LauncherState, ViewMode};
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
|
|
|
pub struct PerformanceSample {
|
|
|
|
|
pub rtt_ms: f32,
|
|
|
|
|
pub input_latency_ms: f32,
|
|
|
|
|
pub left_fps: f32,
|
|
|
|
|
pub right_fps: f32,
|
|
|
|
|
pub dropped_frames: u64,
|
|
|
|
|
pub queue_depth: u32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct DiagnosticsLog {
|
|
|
|
|
capacity: usize,
|
|
|
|
|
history: VecDeque<PerformanceSample>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl DiagnosticsLog {
|
|
|
|
|
pub fn new(capacity: usize) -> Self {
|
|
|
|
|
let capacity = capacity.max(1);
|
|
|
|
|
Self {
|
|
|
|
|
capacity,
|
|
|
|
|
history: VecDeque::with_capacity(capacity),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn record(&mut self, sample: PerformanceSample) {
|
|
|
|
|
if self.history.len() == self.capacity {
|
|
|
|
|
let _ = self.history.pop_front();
|
|
|
|
|
}
|
|
|
|
|
self.history.push_back(sample);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn latest(&self) -> Option<&PerformanceSample> {
|
|
|
|
|
self.history.back()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn len(&self) -> usize {
|
|
|
|
|
self.history.len()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
|
|
|
self.history.is_empty()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn iter(&self) -> impl Iterator<Item = &PerformanceSample> {
|
|
|
|
|
self.history.iter()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct SnapshotReport {
|
|
|
|
|
pub routing: InputRouting,
|
|
|
|
|
pub view_mode: ViewMode,
|
|
|
|
|
pub remote_active: bool,
|
|
|
|
|
pub selected_camera: Option<String>,
|
|
|
|
|
pub selected_microphone: Option<String>,
|
|
|
|
|
pub selected_speaker: Option<String>,
|
|
|
|
|
pub status: String,
|
|
|
|
|
pub recent_samples: Vec<PerformanceSample>,
|
|
|
|
|
pub notes: Vec<String>,
|
|
|
|
|
pub probe_command: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl SnapshotReport {
|
|
|
|
|
pub fn from_state(state: &LauncherState, log: &DiagnosticsLog, probe_command: String) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
routing: state.routing,
|
|
|
|
|
view_mode: state.view_mode,
|
|
|
|
|
remote_active: state.remote_active,
|
|
|
|
|
selected_camera: state.devices.camera.clone(),
|
|
|
|
|
selected_microphone: state.devices.microphone.clone(),
|
|
|
|
|
selected_speaker: state.devices.speaker.clone(),
|
|
|
|
|
status: state.status_line(),
|
|
|
|
|
recent_samples: log.iter().cloned().collect(),
|
|
|
|
|
notes: state.notes.clone(),
|
|
|
|
|
probe_command,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn to_pretty_json(&self) -> Result<String, serde_json::Error> {
|
|
|
|
|
serde_json::to_string_pretty(self)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn quality_probe_command() -> &'static str {
|
|
|
|
|
"scripts/ci/hygiene_gate.sh && scripts/ci/quality_gate.sh"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use crate::launcher::state::{DeviceSelection, LauncherState};
|
|
|
|
|
|
|
|
|
|
fn sample(n: u64) -> PerformanceSample {
|
|
|
|
|
PerformanceSample {
|
|
|
|
|
rtt_ms: 20.0 + n as f32,
|
|
|
|
|
input_latency_ms: 10.0 + n as f32,
|
|
|
|
|
left_fps: 30.0,
|
|
|
|
|
right_fps: 30.0,
|
|
|
|
|
dropped_frames: n,
|
|
|
|
|
queue_depth: n as u32,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn diagnostics_log_keeps_only_latest_samples_with_capacity() {
|
|
|
|
|
let mut log = DiagnosticsLog::new(2);
|
|
|
|
|
log.record(sample(1));
|
|
|
|
|
log.record(sample(2));
|
|
|
|
|
log.record(sample(3));
|
|
|
|
|
|
|
|
|
|
let kept: Vec<u64> = log.iter().map(|item| item.dropped_frames).collect();
|
|
|
|
|
assert_eq!(kept, vec![2, 3]);
|
|
|
|
|
assert_eq!(log.latest().map(|s| s.dropped_frames), Some(3));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn diagnostics_log_enforces_minimum_capacity() {
|
|
|
|
|
let mut log = DiagnosticsLog::new(0);
|
|
|
|
|
log.record(sample(1));
|
|
|
|
|
log.record(sample(2));
|
|
|
|
|
assert_eq!(log.len(), 1);
|
|
|
|
|
assert_eq!(log.latest().map(|s| s.dropped_frames), Some(2));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn snapshot_report_contains_state_fields_and_samples() {
|
|
|
|
|
let mut state = LauncherState::new();
|
|
|
|
|
state.devices = DeviceSelection {
|
|
|
|
|
camera: Some("/dev/video0".to_string()),
|
|
|
|
|
microphone: Some("alsa_input.usb".to_string()),
|
|
|
|
|
speaker: Some("alsa_output.usb".to_string()),
|
2026-04-16 12:58:05 -03:00
|
|
|
keyboard: Some("/dev/input/event10".to_string()),
|
|
|
|
|
mouse: Some("/dev/input/event11".to_string()),
|
2026-04-13 23:11:35 -03:00
|
|
|
};
|
|
|
|
|
state.push_note("first note");
|
|
|
|
|
|
|
|
|
|
let mut log = DiagnosticsLog::new(4);
|
|
|
|
|
log.record(sample(7));
|
|
|
|
|
|
|
|
|
|
let report = SnapshotReport::from_state(&state, &log, quality_probe_command().to_string());
|
|
|
|
|
assert_eq!(report.selected_camera.as_deref(), Some("/dev/video0"));
|
2026-04-14 23:03:18 -03:00
|
|
|
assert_eq!(
|
|
|
|
|
report.selected_microphone.as_deref(),
|
|
|
|
|
Some("alsa_input.usb")
|
|
|
|
|
);
|
2026-04-13 23:11:35 -03:00
|
|
|
assert_eq!(report.selected_speaker.as_deref(), Some("alsa_output.usb"));
|
|
|
|
|
assert_eq!(report.recent_samples.len(), 1);
|
|
|
|
|
assert_eq!(report.notes, vec!["first note".to_string()]);
|
2026-04-14 18:44:40 -03:00
|
|
|
assert!(report.status.contains("mode=remote"));
|
2026-04-13 23:11:35 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn snapshot_json_is_serializable_and_mentions_probe_command() {
|
|
|
|
|
let report = SnapshotReport::from_state(
|
|
|
|
|
&LauncherState::new(),
|
|
|
|
|
&DiagnosticsLog::new(1),
|
|
|
|
|
quality_probe_command().to_string(),
|
|
|
|
|
);
|
|
|
|
|
let json = report.to_pretty_json().expect("serialize");
|
|
|
|
|
assert!(json.contains("quality_gate.sh"));
|
|
|
|
|
assert!(json.contains("routing"));
|
|
|
|
|
assert!(json.contains("view_mode"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn quality_probe_command_mentions_both_gates() {
|
|
|
|
|
let cmd = quality_probe_command();
|
|
|
|
|
assert!(cmd.contains("hygiene_gate.sh"));
|
|
|
|
|
assert!(cmd.contains("quality_gate.sh"));
|
|
|
|
|
}
|
|
|
|
|
}
|