lesavka/client/src/bin/tests/lesavka_relayctl.rs

396 lines
14 KiB
Rust

use super::{
CalibrationAction, CapturePowerCommand, CommandKind, Config, ParseOutcome,
calibration_request_for, capture_power_request, parse_args_from, parse_args_outcome_from,
};
use lesavka_common::lesavka::{CapturePowerState, UpstreamSyncState};
#[test]
/// Verifies safe recovery commands stay separate from explicit hard reset.
fn command_aliases_parse_to_stable_actions() {
assert_eq!(CommandKind::parse("status"), Some(CommandKind::Status));
assert_eq!(CommandKind::parse("get"), Some(CommandKind::Status));
assert_eq!(CommandKind::parse("version"), Some(CommandKind::Version));
assert_eq!(CommandKind::parse("versions"), Some(CommandKind::Version));
assert_eq!(
CommandKind::parse("calibration"),
Some(CommandKind::CalibrationStatus)
);
assert_eq!(
CommandKind::parse("calibrate"),
Some(CommandKind::CalibrationAdjust)
);
assert_eq!(
CommandKind::parse("calibration-restore-default"),
Some(CommandKind::CalibrationRestoreDefault)
);
assert_eq!(
CommandKind::parse("calibration-restore-factory"),
Some(CommandKind::CalibrationRestoreFactory)
);
assert_eq!(
CommandKind::parse("calibration-save-default"),
Some(CommandKind::CalibrationSaveDefault)
);
assert_eq!(
CommandKind::parse("upstream-sync"),
Some(CommandKind::UpstreamSync)
);
assert_eq!(CommandKind::parse("sync"), Some(CommandKind::UpstreamSync));
assert_eq!(
CommandKind::parse("output-delay-probe"),
Some(CommandKind::OutputDelayProbe)
);
assert_eq!(
CommandKind::parse("probe-output-delay"),
Some(CommandKind::OutputDelayProbe)
);
assert_eq!(CommandKind::parse("force-on"), Some(CommandKind::On));
assert_eq!(CommandKind::parse("force-off"), Some(CommandKind::Off));
assert_eq!(
CommandKind::parse("recover-usb"),
Some(CommandKind::RecoverUsb)
);
assert_eq!(
CommandKind::parse("recover-uac"),
Some(CommandKind::RecoverUac)
);
assert_eq!(CommandKind::parse("heal-av"), Some(CommandKind::RecoverUac));
assert_eq!(
CommandKind::parse("heal-upstream"),
Some(CommandKind::RecoverUac)
);
assert_eq!(
CommandKind::parse("recover-uvc"),
Some(CommandKind::RecoverUvc)
);
assert_eq!(
CommandKind::parse("hard-reset-usb"),
Some(CommandKind::ResetUsb)
);
assert_eq!(CommandKind::parse("wat"), None);
}
#[test]
fn parse_args_defaults_to_local_status() {
let config = parse_args_from(std::iter::empty::<&str>()).expect("default config");
assert_eq!(config.server, "http://127.0.0.1:50051");
assert_eq!(config.command, CommandKind::Status);
assert_eq!(config.audio_delta_us, 0);
assert_eq!(config.video_delta_us, 0);
assert!(config.note.is_empty());
}
#[test]
fn parse_args_accepts_server_and_command() {
let config =
parse_args_from(["--server", " http://lab:50051 ", "upstream-sync"]).expect("config");
assert_eq!(config.server, "http://lab:50051");
assert_eq!(config.command, CommandKind::UpstreamSync);
}
#[test]
/// Keeps `parse_args_accepts_output_delay_probe_config` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths.
/// Inputs are the typed parameters; output is the return value or side effect.
fn parse_args_accepts_output_delay_probe_config() {
let config = parse_args_from([
"--server",
"http://lab:50051",
"output-delay-probe",
"20",
"4",
"1000",
"120",
"1,2,3,4",
"0",
"157712",
])
.expect("probe config");
assert_eq!(config.command, CommandKind::OutputDelayProbe);
assert_eq!(config.probe_duration_seconds, 20);
assert_eq!(config.probe_warmup_seconds, 4);
assert_eq!(config.probe_pulse_period_ms, 1000);
assert_eq!(config.probe_pulse_width_ms, 120);
assert_eq!(config.probe_event_width_codes, "1,2,3,4");
assert_eq!(config.probe_audio_delay_us, 0);
assert_eq!(config.probe_video_delay_us, 157_712);
}
#[test]
/// Keeps `parse_args_accepts_calibration_adjustment` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths.
/// Inputs are the typed parameters; output is the return value or side effect.
fn parse_args_accepts_calibration_adjustment() {
let config = parse_args_from([
"--server",
"http://lab:50051",
"calibrate",
"0",
"71600",
"probe",
"median",
])
.expect("calibration config");
assert_eq!(config.command, CommandKind::CalibrationAdjust);
assert_eq!(config.audio_delta_us, 0);
assert_eq!(config.video_delta_us, 71_600);
assert_eq!(config.note, "probe median");
}
#[test]
/// Keeps `parse_args_rejects_bad_inputs` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths.
/// Inputs are the typed parameters; output is the return value or side effect.
fn parse_args_rejects_bad_inputs() {
assert!(parse_args_from(["--server"]).is_err());
assert!(parse_args_from(["nope"]).is_err());
assert!(parse_args_from(["status", "extra"]).is_err());
assert!(parse_args_from(["calibrate"]).is_err());
assert!(parse_args_from(["calibrate", "0", "not-int"]).is_err());
assert!(
parse_args_from([
"output-delay-probe",
"1",
"2",
"3",
"4",
"1",
"0",
"0",
"extra"
])
.is_err()
);
}
#[test]
fn parse_args_reports_help_without_exiting_test_process() {
assert_eq!(
parse_args_outcome_from(["--help"]).unwrap(),
ParseOutcome::Help
);
assert_eq!(parse_args_outcome_from(["-h"]).unwrap(), ParseOutcome::Help);
assert!(parse_args_from(["--help"]).is_err());
}
#[test]
fn parse_args_runtime_wrapper_is_non_panicking_under_tests() {
let _ = super::parse_args();
}
#[cfg(coverage)]
#[test]
fn coverage_main_references_runtime_parser() {
super::main();
}
#[cfg(coverage)]
#[tokio::test(flavor = "current_thread")]
async fn coverage_connect_uses_lazy_channel_after_endpoint_validation() {
let client = super::connect("http://127.0.0.1:1")
.await
.expect("coverage lazy channel");
drop(client);
}
#[test]
/// Keeps status/read commands from accidentally mutating capture power.
fn mutating_commands_map_to_capture_power_requests() {
let auto = capture_power_request(CommandKind::Auto).expect("auto request");
assert!(!auto.enabled);
assert_eq!(auto.command, CapturePowerCommand::Auto as i32);
let on = capture_power_request(CommandKind::On).expect("on request");
assert!(on.enabled);
assert_eq!(on.command, CapturePowerCommand::ForceOn as i32);
let off = capture_power_request(CommandKind::Off).expect("off request");
assert!(!off.enabled);
assert_eq!(off.command, CapturePowerCommand::ForceOff as i32);
assert!(capture_power_request(CommandKind::Status).is_none());
assert!(capture_power_request(CommandKind::Version).is_none());
assert!(capture_power_request(CommandKind::CalibrationStatus).is_none());
assert!(capture_power_request(CommandKind::CalibrationAdjust).is_none());
assert!(capture_power_request(CommandKind::CalibrationRestoreDefault).is_none());
assert!(capture_power_request(CommandKind::CalibrationRestoreFactory).is_none());
assert!(capture_power_request(CommandKind::CalibrationSaveDefault).is_none());
assert!(capture_power_request(CommandKind::RecoverUsb).is_none());
assert!(capture_power_request(CommandKind::RecoverUac).is_none());
assert!(capture_power_request(CommandKind::RecoverUvc).is_none());
assert!(capture_power_request(CommandKind::ResetUsb).is_none());
assert!(capture_power_request(CommandKind::UpstreamSync).is_none());
assert!(capture_power_request(CommandKind::OutputDelayProbe).is_none());
}
#[test]
fn print_state_accepts_full_capture_power_payload() {
super::print_state(CapturePowerState {
available: true,
enabled: false,
mode: "auto".to_string(),
detected_devices: 2,
active_leases: 1,
unit: "lesavka-capture-power.service".to_string(),
detail: "ready".to_string(),
});
}
#[test]
fn print_versions_accepts_unknown_and_reported_server_identity() {
super::print_versions(
"https://lab:50051",
&lesavka_common::lesavka::HandshakeSet {
camera_output: "uvc".to_string(),
camera_codec: "mjpeg".to_string(),
camera_width: 1280,
camera_height: 720,
camera_fps: 30,
bundled_webcam_media: true,
..Default::default()
},
);
super::print_versions(
"https://lab:50051",
&lesavka_common::lesavka::HandshakeSet {
server_version: "0.21.1".to_string(),
server_revision: "abc1234".to_string(),
camera_output: "uvc".to_string(),
camera_codec: "hevc".to_string(),
camera_width: 1920,
camera_height: 1080,
camera_fps: 30,
bundled_webcam_media: true,
..Default::default()
},
);
}
#[test]
/// Keeps `print_calibration_accepts_full_payload` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths.
/// Inputs are the typed parameters; output is the return value or side effect.
fn print_calibration_accepts_full_payload() {
super::print_calibration_state(lesavka_common::lesavka::CalibrationState {
profile: "mjpeg".to_string(),
factory_audio_offset_us: 0,
factory_video_offset_us: 1_090_000,
default_audio_offset_us: 0,
default_video_offset_us: 1_090_000,
active_audio_offset_us: 0,
active_video_offset_us: 1_161_600,
source: "manual".to_string(),
confidence: "manual".to_string(),
updated_at: "2026-05-02T00:00:00Z".to_string(),
detail: "probe nudge".to_string(),
});
}
#[test]
fn print_upstream_sync_accepts_complete_and_pending_payloads() {
super::print_upstream_sync(UpstreamSyncState {
session_id: 7,
phase: "locked".to_string(),
latest_camera_remote_pts_us: Some(1_000),
latest_microphone_remote_pts_us: Some(1_010),
last_video_presented_pts_us: Some(2_000),
last_audio_presented_pts_us: Some(2_010),
live_lag_ms: Some(44.5),
planner_skew_ms: Some(-3.25),
stale_audio_drops: 1,
stale_video_drops: 2,
skew_video_drops: 3,
freshness_reanchors: 4,
startup_timeouts: 5,
video_freezes: 6,
last_reason: "healthy".to_string(),
client_capture_skew_ms: Some(1.5),
client_send_skew_ms: Some(-2.5),
server_receive_skew_ms: Some(3.5),
camera_client_queue_age_ms: Some(4.5),
microphone_client_queue_age_ms: Some(5.5),
camera_server_receive_age_ms: Some(6.5),
microphone_server_receive_age_ms: Some(7.5),
client_capture_abs_skew_p95_ms: Some(8.5),
client_send_abs_skew_p95_ms: Some(9.5),
server_receive_abs_skew_p95_ms: Some(10.5),
camera_client_queue_age_p95_ms: Some(11.5),
microphone_client_queue_age_p95_ms: Some(12.5),
sink_handoff_skew_ms: Some(-13.5),
sink_handoff_abs_skew_p95_ms: Some(14.5),
camera_sink_late_ms: Some(15.5),
microphone_sink_late_ms: Some(-16.5),
camera_sink_late_p95_ms: Some(17.5),
microphone_sink_late_p95_ms: Some(18.5),
client_timing_window_samples: 19,
sink_handoff_window_samples: 20,
});
super::print_upstream_sync(UpstreamSyncState {
session_id: 0,
phase: "acquiring".to_string(),
last_reason: "waiting".to_string(),
..UpstreamSyncState::default()
});
}
#[test]
/// Keeps `calibration_requests_are_only_built_for_calibration_mutations` explicit because it sits on CLI orchestration, where operators need deterministic exits and artifact paths.
/// Inputs are the typed parameters; output is the return value or side effect.
fn calibration_requests_are_only_built_for_calibration_mutations() {
let config = Config {
server: "http://127.0.0.1:50051".to_string(),
command: CommandKind::CalibrationAdjust,
audio_delta_us: 0,
video_delta_us: 71_600,
note: "probe".to_string(),
probe_duration_seconds: 0,
probe_warmup_seconds: 0,
probe_pulse_period_ms: 0,
probe_pulse_width_ms: 0,
probe_event_width_codes: String::new(),
probe_audio_delay_us: 0,
probe_video_delay_us: 0,
};
let request = calibration_request_for(&config).expect("request");
assert_eq!(request.action, CalibrationAction::AdjustActive as i32);
assert_eq!(request.audio_delta_us, 0);
assert_eq!(request.video_delta_us, 71_600);
assert_eq!(request.note, "probe");
for (command, action) in [
(
CommandKind::CalibrationRestoreDefault,
CalibrationAction::RestoreDefault,
),
(
CommandKind::CalibrationRestoreFactory,
CalibrationAction::RestoreFactory,
),
(
CommandKind::CalibrationSaveDefault,
CalibrationAction::SaveActiveAsDefault,
),
] {
let mutation = Config {
server: config.server.clone(),
command,
audio_delta_us: config.audio_delta_us,
video_delta_us: config.video_delta_us,
note: config.note.clone(),
probe_duration_seconds: config.probe_duration_seconds,
probe_warmup_seconds: config.probe_warmup_seconds,
probe_pulse_period_ms: config.probe_pulse_period_ms,
probe_pulse_width_ms: config.probe_pulse_width_ms,
probe_event_width_codes: config.probe_event_width_codes.clone(),
probe_audio_delay_us: config.probe_audio_delay_us,
probe_video_delay_us: config.probe_video_delay_us,
};
let request = calibration_request_for(&mutation).expect("calibration mutation");
assert_eq!(request.action, action as i32);
}
let status = Config {
command: CommandKind::CalibrationStatus,
..config
};
assert!(calibration_request_for(&status).is_none());
}