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

583 lines
19 KiB
Rust
Raw Normal View History

use super::*;
use crate::launcher::{
devices::{CameraMode, DeviceCatalog},
preview::PreviewBinding,
state::{BreakoutSizePreset, LauncherState, PreviewSourceSize},
ui_components::build_launcher_view,
};
use gtk::prelude::*;
use serial_test::serial;
use std::{
cell::RefCell,
collections::BTreeMap,
rc::Rc,
time::{Duration, Instant},
};
fn present_and_settle(window: &gtk::ApplicationWindow) {
window.set_default_size(1280, 780);
window.present();
let deadline = Instant::now() + Duration::from_millis(450);
while Instant::now() < deadline {
while glib::MainContext::default().iteration(false) {}
std::thread::sleep(Duration::from_millis(15));
}
}
#[test]
fn local_test_detail_mentions_idle_and_running_modes() {
assert!(local_test_detail(false, false, false, false).contains("idle"));
let running = local_test_detail(true, true, false, false);
assert!(running.contains("camera preview"));
assert!(running.contains("mic monitor"));
}
#[test]
fn gpio_power_label_tracks_detected_devices() {
let mut power = CapturePowerStatus::default();
assert_eq!(gpio_power_label(&power), "Unavailable");
power.available = true;
assert_eq!(gpio_power_label(&power), "Power Off");
power.enabled = true;
assert_eq!(gpio_power_label(&power), "No Eyes");
power.detected_devices = 1;
assert_eq!(gpio_power_label(&power), "1 Eye");
power.detected_devices = 2;
assert_eq!(gpio_power_label(&power), "2 Eyes");
}
#[gtk::test]
#[serial]
fn launcher_shell_measures_inside_a_1080p_desktop_budget() {
if gtk::gdk::Display::default().is_none() {
return;
}
let app = gtk::Application::builder()
.application_id("dev.lesavka.test-layout-budget")
.build();
let _ = app.register(None::<&gtk::gio::Cancellable>);
let state = LauncherState::new();
let view = build_launcher_view(
&app,
"http://127.0.0.1:50051",
&DeviceCatalog::default(),
&state,
);
present_and_settle(&view.window);
let (min_width, natural_width, _, _) = view.window.measure(gtk::Orientation::Horizontal, -1);
let (min_height, natural_height, _, _) = view.window.measure(gtk::Orientation::Vertical, 1920);
assert!(
min_width <= 1280 && view.window.width() <= 1280,
"launcher width budget regressed: min={min_width}, natural={natural_width}"
);
assert!(
min_height <= 900 && view.window.height() <= 900 && natural_height <= 1080,
"launcher height budget regressed: min={min_height}, natural={natural_height}"
);
}
#[gtk::test]
#[serial]
fn populated_launcher_shell_measures_inside_a_1080p_desktop_budget() {
if gtk::gdk::Display::default().is_none() {
return;
}
let app = gtk::Application::builder()
.application_id("dev.lesavka.test-layout-budget-populated")
.build();
let _ = app.register(None::<&gtk::gio::Cancellable>);
let catalog = realistic_device_catalog();
let mut state = LauncherState::new();
state.apply_catalog_defaults(&catalog);
let view = build_launcher_view(&app, "http://127.0.0.1:50051", &catalog, &state);
present_and_settle(&view.window);
let (min_width, natural_width, _, _) = view.window.measure(gtk::Orientation::Horizontal, -1);
let (min_height, natural_height, _, _) = view.window.measure(gtk::Orientation::Vertical, 1920);
assert!(
min_width <= 1280 && view.window.width() <= 1280,
"populated launcher width budget regressed: min={min_width}, natural={natural_width}"
);
assert!(
min_height <= 900 && view.window.height() <= 900 && natural_height <= 1080,
"populated launcher height budget regressed: min={min_height}, natural={natural_height}"
);
}
#[gtk::test]
#[serial]
fn populated_launcher_runtime_widgets_stay_compact() {
if gtk::gdk::Display::default().is_none() {
return;
}
let app = gtk::Application::builder()
.application_id("dev.lesavka.test-layout-widget-budget")
.build();
let _ = app.register(None::<&gtk::gio::Cancellable>);
let catalog = realistic_device_catalog();
let mut state = LauncherState::new();
state.apply_catalog_defaults(&catalog);
let view = build_launcher_view(&app, "http://127.0.0.1:50051", &catalog, &state);
present_and_settle(&view.window);
let (camera_min_w, camera_nat_w, _, _) = view
.device_stage
.camera_preview
.measure(gtk::Orientation::Horizontal, -1);
let (camera_min_h, camera_nat_h, _, _) = view
.device_stage
.camera_preview
.measure(gtk::Orientation::Vertical, 160);
let (testing_panel_min_h, testing_panel_nat_h, _, _) = view
.device_stage
.preview_panel
.measure(gtk::Orientation::Vertical, 320);
let (left_min_w, left_nat_w, _, _) = view.widgets.display_panes[0]
.root
.measure(gtk::Orientation::Horizontal, -1);
let (left_min_h, left_nat_h, _, _) = view.widgets.display_panes[0]
.root
.measure(gtk::Orientation::Vertical, 640);
let (server_min_w, server_nat_w, _, _) =
view.server_entry.measure(gtk::Orientation::Horizontal, -1);
assert!(
camera_min_w <= 160 && camera_nat_w <= 160,
"camera preview width regressed: min={camera_min_w}, natural={camera_nat_w}"
);
assert!(
camera_min_h <= 90 && camera_nat_h <= 90,
"camera preview height regressed: min={camera_min_h}, natural={camera_nat_h}"
);
assert!(
testing_panel_min_h <= 260 && testing_panel_nat_h <= 260,
"device testing panel height regressed: min={testing_panel_min_h}, natural={testing_panel_nat_h}"
);
assert!(
left_min_w <= 445 && left_nat_w <= 470,
"eye pane width regressed: min={left_min_w}, natural={left_nat_w}"
);
assert!(
left_min_h <= 520 && left_nat_h <= 520,
"eye pane height regressed: min={left_min_h}, natural={left_nat_h}"
);
assert!(
server_min_w <= 168 && server_nat_w <= 180,
"server entry width regressed: min={server_min_w}, natural={server_nat_w}"
);
assert!(
(view.device_stage.devices_panel.height() - view.device_stage.preview_panel.height()).abs()
<= 2,
"device staging and upstream media heights diverged: staging={}, upstream={}",
view.device_stage.devices_panel.height(),
view.device_stage.preview_panel.height()
);
assert!(
view.widgets.display_panes[0].preview_frame.height()
>= view.device_stage.camera_preview_frame.height(),
"eye preview should stay at least as tall as the webcam preview: eye={}, webcam={}",
view.widgets.display_panes[0].preview_frame.height(),
view.device_stage.camera_preview_frame.height()
);
}
#[gtk::test]
#[serial]
fn breakout_size_changes_resize_the_open_popout_window() {
if gtk::gdk::Display::default().is_none() {
return;
}
let app = gtk::Application::builder()
.application_id("dev.lesavka.test-breakout-resize")
.build();
let _ = app.register(None::<&gtk::gio::Cancellable>);
let window = gtk::ApplicationWindow::builder()
.application(&app)
.default_width(540)
.default_height(304)
.build();
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
let frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false);
let picture = gtk::Picture::new();
frame.set_child(Some(&picture));
root.append(&frame);
window.set_child(Some(&root));
let handle = PopoutWindowHandle {
window,
root,
frame: frame.clone(),
picture: picture.clone(),
status_label: gtk::Label::new(None),
binding: PreviewBinding::test_stub(),
};
apply_popout_window_size(
&handle,
BreakoutSizeChoice {
preset: BreakoutSizePreset::P720,
width: 1280,
height: 720,
},
PreviewSourceSize {
width: 1920,
height: 1080,
fps: 60,
},
);
assert_eq!(handle.root.width_request(), 1280);
assert_eq!(handle.root.height_request(), 720);
assert_eq!(handle.frame.width_request(), 1280);
assert_eq!(handle.frame.height_request(), 720);
assert_eq!(handle.picture.width_request(), 1280);
assert_eq!(handle.picture.height_request(), 720);
}
#[test]
fn server_chip_state_tracks_connection_not_just_reachability() {
let mut state = LauncherState::new();
assert_eq!(server_light_state(&state, false), StatusLightState::Idle);
assert_eq!(server_version_label(&state), "-");
state.set_server_available(true);
state.set_server_version(Some("0.12.3".to_string()));
assert_eq!(server_light_state(&state, false), StatusLightState::Live);
assert_eq!(server_version_label(&state), "v0.12.3");
assert_eq!(
server_light_state(&state, true),
StatusLightState::Connected
);
state.set_server_version(Some("v0.12.4".to_string()));
assert_eq!(server_light_state(&state, false), StatusLightState::Warning);
assert_eq!(server_light_state(&state, true), StatusLightState::Caution);
assert_eq!(server_version_label(&state), "v0.12.4");
state.set_server_version(Some(" ".to_string()));
assert_eq!(server_light_state(&state, false), StatusLightState::Idle);
assert_eq!(server_version_label(&state), "-");
}
#[test]
fn capture_power_detail_mentions_detected_eyes_when_powered() {
let power = CapturePowerStatus {
available: true,
enabled: true,
detail: "active/running".to_string(),
detected_devices: 1,
..Default::default()
};
assert!(capture_power_detail(&power).contains("1 eye detected"));
}
#[test]
fn compact_device_name_prefers_basename_when_available() {
assert_eq!(compact_device_name("/dev/video0"), "video0");
assert_eq!(compact_device_name("alsa_input.usb"), "alsa_input.usb");
}
#[test]
fn strip_ansi_sequences_removes_terminal_codes() {
let raw = "\u{1b}[32mINFO\u{1b}[0m hello";
assert_eq!(strip_ansi_sequences(raw), "INFO hello");
}
#[test]
fn classify_log_tags_assigns_prefix_and_severity_colors() {
let tags = classify_log_tags("[relay] WARN pipeline failed");
assert!(tags.contains(&"log-relay"));
assert!(tags.contains(&"log-error") || tags.contains(&"log-warn"));
}
#[test]
#[doc = "Verifies the default console filter hides relay INFO noise."]
fn session_log_filter_hides_noisy_info_by_default_but_keeps_errors() {
assert!(!should_show_session_log_line(
"[relay] 2026-04-22T23:20:17Z INFO ThreadId(01) audio packet received packet=3000",
ConsoleLogLevel::Warn
));
assert!(!should_show_session_log_line(
"[relay] 2026-04-22T23:20:17Z INFO ThreadId(04) decoded audio level rms=-32",
ConsoleLogLevel::Warn
));
assert!(should_show_session_log_line(
"[relay] 2026-04-22T23:20:17Z WARN pipeline is recovering",
ConsoleLogLevel::Warn
));
assert!(should_show_session_log_line(
"[relay] 2026-04-22T23:20:17Z INFO ❌ connect failed",
ConsoleLogLevel::Error
));
assert!(should_show_session_log_line(
"[launcher] Relay connected with inputs routed to remote.",
ConsoleLogLevel::Error
));
assert!(should_show_session_log_line(
"[relay] 2026-04-22T23:20:17Z INFO audio packet received",
ConsoleLogLevel::Info
));
}
#[test]
fn write_audio_gain_request_formats_live_control_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("gain.control");
write_audio_gain_request(&path, 425).expect("write gain");
let raw = std::fs::read_to_string(path).expect("read gain");
assert!(raw.starts_with("4.250 "), "{raw}");
}
#[test]
fn write_mic_gain_request_formats_live_control_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("mic-gain.control");
write_mic_gain_request(&path, 325).expect("write gain");
let raw = std::fs::read_to_string(path).expect("read gain");
assert!(raw.starts_with("3.250 "), "{raw}");
}
#[gtk::test]
#[serial]
fn dock_all_displays_to_preview_closes_popouts_and_resets_surfaces() {
if gtk::gdk::Display::default().is_none() {
return;
}
let app = gtk::Application::builder()
.application_id("dev.lesavka.test-dock")
.build();
let _ = app.register(None::<&gtk::gio::Cancellable>);
let state = Rc::new(RefCell::new(LauncherState::new()));
state
.borrow_mut()
.set_display_surface(0, DisplaySurface::Window);
state
.borrow_mut()
.set_display_surface(1, DisplaySurface::Window);
let state_snapshot = state.borrow().clone();
let view = build_launcher_view(
&app,
"http://127.0.0.1:50051",
&DeviceCatalog::default(),
&state_snapshot,
);
let child_proc = Rc::new(RefCell::new(None::<RelayChild>));
let left_binding = PreviewBinding::test_stub();
let right_binding = PreviewBinding::test_stub();
{
let mut popouts = view.popouts.borrow_mut();
popouts[0] = Some(PopoutWindowHandle {
window: gtk::ApplicationWindow::builder()
.application(&app)
.title("Left")
.build(),
root: gtk::Box::new(gtk::Orientation::Vertical, 0),
frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false),
picture: gtk::Picture::new(),
status_label: gtk::Label::new(None),
binding: left_binding,
});
popouts[1] = Some(PopoutWindowHandle {
window: gtk::ApplicationWindow::builder()
.application(&app)
.title("Right")
.build(),
root: gtk::Box::new(gtk::Orientation::Vertical, 0),
frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false),
picture: gtk::Picture::new(),
status_label: gtk::Label::new(None),
binding: right_binding,
});
}
dock_all_displays_to_preview(&state, &child_proc, &view.popouts, &view.widgets);
assert!(view.popouts.borrow().iter().all(|handle| handle.is_none()));
assert_eq!(state.borrow().display_surface(0), DisplaySurface::Preview);
assert_eq!(state.borrow().display_surface(1), DisplaySurface::Preview);
}
#[gtk::test]
#[serial]
fn dock_all_displays_to_preview_handles_reentrant_close_callbacks() {
if gtk::gdk::Display::default().is_none() {
return;
}
let app = gtk::Application::builder()
.application_id("dev.lesavka.test-reentrant-dock")
.build();
let _ = app.register(None::<&gtk::gio::Cancellable>);
let state = Rc::new(RefCell::new(LauncherState::new()));
state
.borrow_mut()
.set_display_surface(0, DisplaySurface::Window);
let state_snapshot = state.borrow().clone();
let view = build_launcher_view(
&app,
"http://127.0.0.1:50051",
&DeviceCatalog::default(),
&state_snapshot,
);
let child_proc = Rc::new(RefCell::new(None::<RelayChild>));
let popouts = Rc::clone(&view.popouts);
let window = gtk::ApplicationWindow::builder()
.application(&app)
.title("Reentrant")
.build();
{
let popouts = Rc::clone(&popouts);
window.connect_close_request(move |_| {
let _ = popouts.borrow_mut()[0].take();
glib::Propagation::Proceed
});
}
{
let mut slot = popouts.borrow_mut();
slot[0] = Some(PopoutWindowHandle {
window,
root: gtk::Box::new(gtk::Orientation::Vertical, 0),
frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false),
picture: gtk::Picture::new(),
status_label: gtk::Label::new(None),
binding: PreviewBinding::test_stub(),
});
}
dock_all_displays_to_preview(&state, &child_proc, &popouts, &view.widgets);
assert!(popouts.borrow().iter().all(|handle| handle.is_none()));
assert_eq!(state.borrow().display_surface(0), DisplaySurface::Preview);
}
fn realistic_device_catalog() -> DeviceCatalog {
DeviceCatalog {
cameras: vec!["usb-046d_Logitech_BRIO_5F6EB379-video-index0".to_string()],
camera_modes: [(
"usb-046d_Logitech_BRIO_5F6EB379-video-index0".to_string(),
vec![
CameraMode::new(1920, 1080, 30),
CameraMode::new(1280, 720, 30),
],
)]
.into_iter()
.collect::<BTreeMap<_, _>>(),
microphones: vec![
"alsa_input.usb-Focusrite_Scarlett_2i2_USB_Y7ABC12345-00.analog-stereo".to_string(),
],
speakers: vec![
"alsa_output.pci-0000_00_1f.3.analog-stereo".to_string(),
"bluez_output.80_C3_BA_76_26_AB.1".to_string(),
],
keyboards: vec!["usb-Corsair_K70_RGB_PRO_Mechanical_Gaming_Keyboard-event-kbd".to_string()],
mice: vec!["usb-Logitech_G502_X_LIGHTSPEED_Gaming_Mouse-event-mouse".to_string()],
}
}
#[gtk::test]
#[serial]
fn shutdown_launcher_runtime_closes_preview_bindings_and_popouts() {
if gtk::gdk::Display::default().is_none() {
return;
}
let app = gtk::Application::builder()
.application_id("dev.lesavka.test-shutdown")
.build();
let _ = app.register(None::<&gtk::gio::Cancellable>);
let state = Rc::new(RefCell::new(LauncherState::new()));
let state_snapshot = state.borrow().clone();
let view = build_launcher_view(
&app,
"http://127.0.0.1:50051",
&DeviceCatalog::default(),
&state_snapshot,
);
let child_proc = Rc::new(RefCell::new(None::<RelayChild>));
let tests = Rc::new(RefCell::new(DeviceTestController::new()));
let left_binding = PreviewBinding::test_stub();
let right_binding = PreviewBinding::test_stub();
*view.widgets.display_panes[0].preview_binding.borrow_mut() = Some(left_binding.clone());
*view.widgets.display_panes[1].preview_binding.borrow_mut() = Some(right_binding.clone());
{
let mut popouts = view.popouts.borrow_mut();
popouts[0] = Some(PopoutWindowHandle {
window: gtk::ApplicationWindow::builder()
.application(&app)
.title("Left")
.build(),
root: gtk::Box::new(gtk::Orientation::Vertical, 0),
frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false),
picture: gtk::Picture::new(),
status_label: gtk::Label::new(None),
binding: PreviewBinding::test_stub(),
});
}
*view.diagnostics_popout.borrow_mut() = Some(
gtk::ApplicationWindow::builder()
.application(&app)
.title("Diagnostics")
.build(),
);
*view.log_popout.borrow_mut() = Some(
gtk::ApplicationWindow::builder()
.application(&app)
.title("Log")
.build(),
);
shutdown_launcher_runtime(
&child_proc,
&tests,
None,
&view.widgets,
&view.popouts,
&view.diagnostics_popout,
&view.log_popout,
);
assert!(view.popouts.borrow().iter().all(|handle| handle.is_none()));
assert!(
view.widgets.display_panes[0]
.preview_binding
.borrow()
.is_none()
);
assert!(
view.widgets.display_panes[1]
.preview_binding
.borrow()
.is_none()
);
assert!(view.diagnostics_popout.borrow().is_none());
assert!(view.log_popout.borrow().is_none());
}