lesavka/client/src/launcher/diagnostics.rs

178 lines
5.4 KiB
Rust
Raw Normal View History

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()),
keyboard: Some("/dev/input/event10".to_string()),
mouse: Some("/dev/input/event11".to_string()),
};
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"));
assert_eq!(
report.selected_microphone.as_deref(),
Some("alsa_input.usb")
);
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()]);
assert!(report.status.contains("mode=remote"));
}
#[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"));
}
}