lesavka/client/src/launcher/tests/diagnostics.rs

499 lines
18 KiB
Rust

use super::*;
use crate::launcher::state::{
CaptureSizePreset, DeviceSelection, DisplaySurface, FeedSourcePreset, LauncherState,
};
use crate::uplink_telemetry::UpstreamStreamTelemetry;
fn sample(n: u64) -> PerformanceSample {
PerformanceSample {
rtt_ms: 20.0 + n as f32,
probe_spread_ms: 3.0 + n as f32,
input_latency_ms: 10.0 + n as f32,
probe_loss_pct: n as f32,
client_process_cpu_pct: 12.5 + n as f32,
server_process_cpu_pct: 22.5 + n as f32,
video_loss_pct: (n as f32) * 0.5,
left_receive_fps: 30.0,
left_present_fps: 29.0,
left_server_fps: 30.0,
left_stream_spread_ms: 4.0,
left_packet_gap_peak_ms: 55.0,
left_present_gap_peak_ms: 60.0,
left_queue_depth: n as u32,
left_queue_peak: n as u32,
left_server_source_gap_peak_ms: 42.0,
left_server_send_gap_peak_ms: 48.0,
left_server_queue_peak: n as u32 + 1,
left_server_encoder_label: "x264enc".to_string(),
left_decoder_label: "decodebin".to_string(),
left_stream_caps_label:
"video/x-h264, width=(int)1920, height=(int)1080, framerate=(fraction)60/1".to_string(),
left_decoded_caps_label:
"video/x-raw, format=(string)NV12, width=(int)1920, height=(int)1080".to_string(),
left_rendered_caps_label:
"video/x-raw, format=(string)RGBA, width=(int)1920, height=(int)1080".to_string(),
right_receive_fps: 30.0,
right_present_fps: 28.0,
right_server_fps: 30.0,
right_stream_spread_ms: 5.0,
right_packet_gap_peak_ms: 65.0,
right_present_gap_peak_ms: 75.0,
right_queue_depth: n as u32,
right_queue_peak: n as u32,
right_server_source_gap_peak_ms: 51.0,
right_server_send_gap_peak_ms: 58.0,
right_server_queue_peak: n as u32 + 1,
right_server_encoder_label: "source-pass-through".to_string(),
right_decoder_label: "decodebin".to_string(),
right_stream_caps_label:
"video/x-h264, width=(int)1920, height=(int)1080, framerate=(fraction)60/1".to_string(),
right_decoded_caps_label:
"video/x-raw, format=(string)NV12, width=(int)1920, height=(int)1080".to_string(),
right_rendered_caps_label:
"video/x-raw, format=(string)RGBA, width=(int)1920, height=(int)1080".to_string(),
upstream_camera: UpstreamStreamTelemetry {
enabled: true,
connected: true,
reconnect_count: 2,
queue_depth: 3,
queue_peak: 7,
latest_enqueue_age_ms: 14.0,
enqueue_age_peak_ms: 48.0,
enqueue_block_peak_ms: 5.0,
packets_enqueued: 120,
packets_streamed: 118,
dropped_packets: 0,
dropped_queue_full_packets: 0,
dropped_stale_packets: 0,
latest_delivery_age_ms: 22.0,
delivery_age_peak_ms: 61.0,
last_error: String::new(),
},
upstream_microphone: UpstreamStreamTelemetry {
enabled: true,
connected: true,
reconnect_count: 1,
queue_depth: 2,
queue_peak: 5,
latest_enqueue_age_ms: 11.0,
enqueue_age_peak_ms: 22.0,
enqueue_block_peak_ms: 3.0,
packets_enqueued: 220,
packets_streamed: 216,
dropped_packets: 1,
dropped_queue_full_packets: 1,
dropped_stale_packets: 0,
latest_delivery_age_ms: 19.0,
delivery_age_peak_ms: 37.0,
last_error: String::new(),
},
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.audio_gain_label, "200%");
assert_eq!(
report.selected_keyboard.as_deref(),
Some("/dev/input/event10")
);
assert_eq!(report.selected_mouse.as_deref(), Some("/dev/input/event11"));
assert_eq!(report.recent_samples.len(), 1);
assert_eq!(report.notes, vec!["first note".to_string()]);
assert!(report.status.contains("mode=remote"));
assert!(report.client_version.starts_with("0."));
assert_eq!(report.left_feed_source, "Left Eye");
assert!(
report
.left_capture_profile
.contains("observed 1920x1080 @ 60 fps")
);
assert_eq!(report.left_capture_transport, "device H.264 pass-through");
assert_eq!(report.left_decoder_label, "decodebin");
assert!(report.left_stream_caps_label.contains("video/x-h264"));
assert!(report.left_decoded_caps_label.contains("video/x-raw"));
assert!(report.left_rendered_caps_label.contains("video/x-raw"));
assert_eq!(report.upstream_camera.queue_peak, 7);
assert_eq!(report.upstream_microphone.reconnect_count, 1);
}
#[test]
fn snapshot_report_marks_empty_live_labels_pending() {
let mut log = DiagnosticsLog::new(1);
let mut sample = sample(0);
sample.left_decoder_label.clear();
sample.left_server_encoder_label.clear();
sample.left_stream_caps_label.clear();
sample.left_decoded_caps_label.clear();
sample.left_rendered_caps_label.clear();
sample.right_decoder_label.clear();
sample.right_server_encoder_label.clear();
sample.right_stream_caps_label.clear();
sample.right_decoded_caps_label.clear();
sample.right_rendered_caps_label.clear();
log.record(sample);
let mut state = LauncherState::new();
state.set_feed_source_preset(1, FeedSourcePreset::OtherEye);
let report = SnapshotReport::from_state(&state, &log, quality_probe_command().to_string());
assert_eq!(report.left_decoder_label, "pending");
assert_eq!(report.left_server_encoder_label, "pending");
assert_eq!(report.left_stream_caps_label, "pending");
assert_eq!(report.left_decoded_caps_label, "pending");
assert_eq!(report.left_rendered_caps_label, "pending");
assert_eq!(report.right_feed_source, "Left Eye (mirrored)");
assert_eq!(report.right_decoder_label, "pending");
assert_eq!(report.right_server_encoder_label, "pending");
assert_eq!(report.right_stream_caps_label, "pending");
assert_eq!(report.right_decoded_caps_label, "pending");
assert_eq!(report.right_rendered_caps_label, "pending");
}
#[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 snapshot_text_mentions_versions_profiles_and_recommendations() {
let report = SnapshotReport::from_state(
&LauncherState::new(),
&DiagnosticsLog::new(1),
quality_probe_command().to_string(),
);
let text = report.to_pretty_text();
assert!(text.contains("Lesavka Diagnostics"));
assert!(text.contains("client: v"));
assert!(text.contains("left eye"));
assert!(text.contains("source:"));
assert!(text.contains("transport:"));
assert!(text.contains("live: decoder="));
assert!(text.contains("stream caps:"));
assert!(text.contains("decoded caps:"));
assert!(text.contains("rendered caps:"));
assert!(text.contains("media staging"));
assert!(text.contains("uplink camera:"));
assert!(text.contains("uplink microphone:"));
assert!(text.contains("current UI state"));
assert!(text.contains("recommendations"));
}
#[test]
#[doc = "Verifies diagnostics text follows live media settings."]
fn snapshot_text_reflects_live_media_control_changes() {
let mut state = LauncherState::new();
state.select_camera(Some("/dev/video9".to_string()));
state.select_camera_quality(Some(crate::launcher::devices::CameraMode::new(
1920, 1080, 30,
)));
state.select_microphone(Some("alsa_input.usb".to_string()));
state.select_speaker(Some("alsa_output.usb".to_string()));
state.set_audio_gain_percent(250);
state.set_mic_gain_percent(125);
state.set_camera_channel_enabled(false);
state.set_microphone_channel_enabled(true);
let report = SnapshotReport::from_state(
&state,
&DiagnosticsLog::new(1),
quality_probe_command().to_string(),
);
let text = report.to_pretty_text();
assert!(text.contains("camera: /dev/video9 | quality=1080p@30 | enabled=false"));
assert!(text.contains("speaker: alsa_output.usb | volume=250% | enabled=true"));
assert!(text.contains("microphone: alsa_input.usb | gain=125% | enabled=true"));
}
#[test]
fn snapshot_text_renders_recent_samples_and_notes() {
let mut state = LauncherState::new();
state.set_server_available(true);
state.push_note("operator changed camera quality during the run");
let mut log = DiagnosticsLog::new(2);
log.record(sample(3));
let report = SnapshotReport::from_state(&state, &log, quality_probe_command().to_string());
let text = report.to_pretty_text();
assert!(text.contains("server: unknown (reachable)"));
assert!(text.contains("rtt=23.0ms"));
assert!(text.contains("server=lx264enc:42/48/4"));
assert!(text.contains("uplink: cam=live queue=3/7"));
assert!(text.contains("notes"));
assert!(text.contains("operator changed camera quality during the run"));
}
#[test]
fn recommendations_call_out_upstream_queue_age_and_reconnect_churn() {
let mut log = DiagnosticsLog::new(1);
let mut stressed = sample(9);
stressed.upstream_camera.latest_enqueue_age_ms = 180.0;
stressed.upstream_camera.enqueue_age_peak_ms = 320.0;
stressed.upstream_camera.enqueue_block_peak_ms = 55.0;
stressed.upstream_camera.delivery_age_peak_ms = 420.0;
stressed.upstream_camera.dropped_stale_packets = 4;
stressed.upstream_camera.reconnect_count = 4;
stressed.upstream_microphone.latest_enqueue_age_ms = 120.0;
stressed.upstream_microphone.enqueue_age_peak_ms = 260.0;
stressed.upstream_microphone.enqueue_block_peak_ms = 31.0;
stressed.upstream_microphone.delivery_age_peak_ms = 240.0;
stressed.upstream_microphone.dropped_queue_full_packets = 2;
log.record(stressed);
let report = SnapshotReport::from_state(
&LauncherState::new(),
&log,
quality_probe_command().to_string(),
);
assert!(
report
.recommendations
.iter()
.any(|item| { item.contains("webcam uplink queue is aging packets") })
);
assert!(
report
.recommendations
.iter()
.any(|item| { item.contains("microphone uplink queue is aging live audio") })
);
assert!(
report
.recommendations
.iter()
.any(|item| { item.contains("upstream media loops have already reconnected") })
);
assert!(
report
.recommendations
.iter()
.any(|item| { item.contains("webcam uplink is now choosing freshness") })
);
assert!(
report
.recommendations
.iter()
.any(|item| { item.contains("microphone uplink is dropping or aging live chunks") })
);
}
#[test]
fn snapshot_report_uses_effective_mirrored_capture_profile() {
let mut state = LauncherState::new();
state.set_feed_source_preset(0, FeedSourcePreset::OtherEye);
state.set_capture_size_preset(1, CaptureSizePreset::P720);
let report = SnapshotReport::from_state(
&state,
&DiagnosticsLog::new(1),
quality_probe_command().to_string(),
);
assert_eq!(report.left_feed_source, "Right Eye (mirrored)");
assert!(report.left_capture_profile.contains("720p"));
assert!(report.left_capture_profile.contains("1280x720"));
}
#[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"));
}
#[test]
fn source_capture_profile_prefers_observed_stream_caps_when_available() {
let capture = CaptureSizeChoice {
preset: CaptureSizePreset::P1080,
width: 1920,
height: 1080,
fps: 60,
max_bitrate_kbit: 18_000,
};
let label = capture_profile_label(
&capture,
"video/x-h264, width=(int)1920, height=(int)1080, framerate=(fraction)60/1",
);
assert_eq!(
label,
"1080p | observed 1920x1080 @ 60 fps | bitrate est ~18000 kbit"
);
}
#[test]
fn capture_profile_falls_back_when_stream_caps_are_incomplete() {
let capture = CaptureSizeChoice {
preset: CaptureSizePreset::P1080,
width: 1920,
height: 1080,
fps: 60,
max_bitrate_kbit: 18_000,
};
let label = capture_profile_label(&capture, "video/x-h264, width=(int)1920");
assert_eq!(
label,
"1080p | 1920x1080 | 60 fps | bitrate est ~18000 kbit"
);
}
#[test]
fn recommendations_do_not_suggest_hardware_decode_when_nvdec_is_active() {
let mut log = DiagnosticsLog::new(1);
let mut sample = sample(1);
sample.client_process_cpu_pct = 96.0;
sample.left_receive_fps = 40.0;
sample.left_present_fps = 30.0;
sample.left_decoder_label = "nvh264dec".to_string();
sample.right_decoder_label = "nvh264dec".to_string();
log.record(sample);
let items = recommendations_for(&LauncherState::new(), &log);
let joined = items.join("\n");
assert!(!joined.contains("hardware decoder before adding more bitrate"));
assert!(!joined.contains("lighter breakout sizes or hardware decode"));
assert!(joined.contains("cheaper source mode"));
}
#[test]
fn recommendations_cover_video_network_queue_cpu_and_decoder_pressure() {
let mut state = LauncherState::new();
state.set_server_available(true);
state.set_display_surface(0, DisplaySurface::Window);
state.set_display_surface(1, DisplaySurface::Window);
let mut sample = sample(12);
sample.probe_loss_pct = 4.0;
sample.probe_spread_ms = 22.0;
sample.video_loss_pct = 3.0;
sample.dropped_frames = 2;
sample.left_receive_fps = 58.0;
sample.left_present_fps = 42.0;
sample.right_receive_fps = 58.0;
sample.right_present_fps = 42.0;
sample.left_packet_gap_peak_ms = 180.0;
sample.right_packet_gap_peak_ms = 181.0;
sample.left_present_gap_peak_ms = 250.0;
sample.right_present_gap_peak_ms = 260.0;
sample.queue_depth = 9;
sample.left_queue_peak = 5;
sample.right_queue_peak = 5;
sample.left_server_send_gap_peak_ms = 40.0;
sample.right_server_send_gap_peak_ms = 40.0;
sample.left_server_source_gap_peak_ms = 130.0;
sample.right_server_source_gap_peak_ms = 131.0;
sample.left_server_queue_peak = 5;
sample.right_server_queue_peak = 5;
sample.client_process_cpu_pct = 90.0;
sample.server_process_cpu_pct = 88.0;
sample.left_decoder_label = "avdec_h264".to_string();
sample.right_decoder_label = "avdec_h264".to_string();
sample.left_server_encoder_label = "x264enc".to_string();
sample.right_server_encoder_label = "x264enc".to_string();
let mut log = DiagnosticsLog::new(1);
log.record(sample);
let joined = recommendations_for(&state, &log).join("\n");
for needle in [
"Control-plane probe spread or loss is elevated",
"Video packets are arriving with gaps",
"receiving more frames than it is presenting",
"Present-gap spikes are materially larger",
"preview queue is backing up",
"Queue depth is spiking",
"Client packet-gap spikes are much larger",
"large source-frame gaps",
"server-side stream queue is peaking",
"Client process CPU is high",
"Server process CPU is high",
"Device H.264 pass-through is active",
"At least one eye is falling back",
"At least one eye is still leaning on `x264enc`",
"Both eye feeds are broken out",
] {
assert!(joined.contains(needle), "{needle} missing from {joined}");
}
}
#[test]
fn recommendations_cover_low_receive_fps_and_bursty_gap_without_loss() {
let mut sample = sample(0);
sample.video_loss_pct = 0.0;
sample.dropped_frames = 0;
sample.left_server_fps = 60.0;
sample.left_receive_fps = 48.0;
sample.right_server_fps = 60.0;
sample.right_receive_fps = 48.0;
sample.left_packet_gap_peak_ms = 150.0;
sample.right_packet_gap_peak_ms = 151.0;
let mut log = DiagnosticsLog::new(1);
log.record(sample);
let joined = recommendations_for(&LauncherState::new(), &log).join("\n");
assert!(joined.contains("Receive fps is well below the target without packet loss"));
assert!(joined.contains("Packet-gap spikes are high without packet loss"));
}
#[test]
fn hardware_decoder_detection_recognizes_nvdec_labels() {
let mut sample = sample(1);
sample.left_decoder_label = "nvh264dec".to_string();
assert!(sample_uses_hardware_decode(&sample));
assert!(!sample_uses_software_decode(&sample));
}