396 lines
14 KiB
Rust
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());
|
|
}
|