From 89d8089bcc0052e55bedf643ea950bd84f4de037 Mon Sep 17 00:00:00 2001 From: gigas002 Date: Thu, 6 Aug 2026 11:13:08 +0900 Subject: [PATCH 1/7] feature: add testing CI pipeline --- .github/workflows/test-coverage.yml | 141 ++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 .github/workflows/test-coverage.yml diff --git a/.github/workflows/test-coverage.yml b/.github/workflows/test-coverage.yml new file mode 100644 index 0000000..1ed319c --- /dev/null +++ b/.github/workflows/test-coverage.yml @@ -0,0 +1,141 @@ +name: Test and coverage + +on: [push, pull_request] + +permissions: + contents: read + actions: write + +env: + CARGO_TERM_COLOR: always + +jobs: + test-coverage: + runs-on: ubuntu-latest + container: + image: archlinux:latest + options: --cap-add=SYS_NICE + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + + - name: Build Cache + uses: Swatinem/rust-cache@v2 + + - name: Install wayland dependencies + run: | + pacman -Syu --noconfirm git egl-wayland egl-gbm wayland base-devel mesa pango cairo sway + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@v2 + with: + tool: cargo-llvm-cov + + # Starts a real wlroots compositor using wlroots' headless backend, + # so tests that connect to a real Wayland server (registry/ + # xdg_output round trips, freeze/screenshot capture) can run + # instead of being skipped. + # This job's container has no GPU, so: + # - WLR_BACKENDS=headless: no DRM/KMS, no libseat/seatd session needed + # - WLR_RENDERER=pixman: pure CPU rendering, sidesteps EGL/GBM/Mesa + # software-GL fallback entirely instead of hoping llvmpipe works + # - WLR_LIBINPUT_NO_DEVICES=1: don't fail just because the container + # has no /dev/input/event* nodes + # - WLR_HEADLESS_OUTPUTS=1: create one virtual output so + # the tests have something to enumerate/capture + # sway picks its own server socket name; WAYLAND_DISPLAY is + # discovered afterwards from XDG_RUNTIME_DIR and exported for + # later steps rather than assumed in advance. + - name: Start headless Wayland compositor + run: | + export XDG_RUNTIME_DIR=/tmp/xdg-runtime + mkdir -p "$XDG_RUNTIME_DIR" + chmod 0700 "$XDG_RUNTIME_DIR" + echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" >> "$GITHUB_ENV" + + printf 'xwayland disable\n' > /tmp/ci-sway-config + + XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR" \ + WLR_BACKENDS=headless \ + WLR_RENDERER=pixman \ + WLR_LIBINPUT_NO_DEVICES=1 \ + WLR_HEADLESS_OUTPUTS=1 \ + sway -c /tmp/ci-sway-config > /tmp/sway.log 2>&1 & + echo $! > /tmp/sway.pid + + sock="" + for i in $(seq 1 60); do + sock=$(find "$XDG_RUNTIME_DIR" -maxdepth 1 -type s -name 'wayland-*' 2>/dev/null | head -n1) + if [ -n "$sock" ]; then + break + fi + if ! kill -0 "$(cat /tmp/sway.pid)" 2>/dev/null; then + echo "::error::sway process exited during startup" + cat /tmp/sway.log + exit 1 + fi + if [ $((i % 10)) -eq 0 ]; then + echo "still waiting for compositor socket (${i}s elapsed, sway still running)" + fi + sleep 1 + done + + if [ -z "$sock" ]; then + echo "::error::compositor failed to start within timeout" + echo "sway (pid $(cat /tmp/sway.pid)) is still running - looks like a genuine hang, not a crash" + echo "--- full sway.log ---" + cat /tmp/sway.log + echo "--- contents of XDG_RUNTIME_DIR ---" + ls -la "$XDG_RUNTIME_DIR" + exit 1 + fi + + display_name=$(basename "$sock") + echo "compositor socket is up at $sock" + echo "WAYLAND_DISPLAY=$display_name" >> "$GITHUB_ENV" + + - name: Run tests + run: cargo test --workspace --all-features --verbose + + - name: Generate coverage report (libwaysip) + run: | + cargo llvm-cov --all-features -p libwaysip --lcov --output-path lcov-libwaysip.info + + - name: Generate coverage report (waysip) + run: | + cargo llvm-cov --all-features -p waysip --lcov --output-path lcov-waysip.info + + - name: Stop headless Wayland compositor + if: always() + run: | + if [ -f /tmp/sway.pid ]; then + kill "$(cat /tmp/sway.pid)" 2>/dev/null || true + fi + cat /tmp/sway.log || true + + - name: Upload libwaysip coverage to Codecov + uses: codecov/codecov-action@v7 + with: + files: ./lcov-libwaysip.info + flags: libwaysip + name: libwaysip + fail_ci_if_error: false + verbose: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + - name: Upload waysip coverage to Codecov + uses: codecov/codecov-action@v7 + with: + files: ./lcov-waysip.info + flags: waysip + name: waysip + fail_ci_if_error: false + verbose: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} From 490c729c7eebd3daebb48553a07f0479950e24fa Mon Sep 17 00:00:00 2001 From: gigas002 Date: Thu, 6 Aug 2026 11:13:39 +0900 Subject: [PATCH 2/7] chore: make redraw_all public for testing --- libwaysip/src/state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libwaysip/src/state.rs b/libwaysip/src/state.rs index 2b31e6d..b82267c 100644 --- a/libwaysip/src/state.rs +++ b/libwaysip/src/state.rs @@ -226,7 +226,7 @@ pub struct WaysipState { pub(crate) effective_selection_type: Option, /// Time when mouse was pressed down pub(crate) mouse_press_time: Option, - redraw_all: bool, + pub(crate) redraw_all: bool, /// Whether the "edit selection before confirming" feature is enabled pub(crate) edit_enabled: bool, /// Keycode (evdev) that confirms an edited selection From 907929431a2d2d43db79dea161d7c2b32cf7a4a6 Mon Sep 17 00:00:00 2001 From: gigas002 Date: Thu, 6 Aug 2026 11:14:03 +0900 Subject: [PATCH 3/7] chore: add wayland_backend to dev deps for testing --- Cargo.lock | 2 ++ libwaysip/Cargo.toml | 3 +++ waysip/Cargo.toml | 4 ++++ 3 files changed, 9 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 2149c72..d61987a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -478,6 +478,7 @@ dependencies = [ "pangocairo", "tempfile", "thiserror", + "wayland-backend", "wayland-client", "wayland-cursor", "wayland-protocols", @@ -1031,6 +1032,7 @@ dependencies = [ "libwaysip", "tracing", "tracing-subscriber", + "wayland-backend", "wayland-client", ] diff --git a/libwaysip/Cargo.toml b/libwaysip/Cargo.toml index b90673f..3c60733 100644 --- a/libwaysip/Cargo.toml +++ b/libwaysip/Cargo.toml @@ -35,3 +35,6 @@ pangocairo = "0.22" memmap2 = "0.9" thiserror = "2.0" + +[dev-dependencies] +wayland-backend = "0.3" diff --git a/waysip/Cargo.toml b/waysip/Cargo.toml index a84cc37..1de06fd 100644 --- a/waysip/Cargo.toml +++ b/waysip/Cargo.toml @@ -35,3 +35,7 @@ libwayshot = { version = "0.9", optional = true, default-features = false } tracing.workspace = true tracing-subscriber = { version = "0.3", optional = true } wayland-client = { workspace = true, optional = true } + +[dev-dependencies] +wayland-backend = "0.3" +wayland-client.workspace = true From 201cfac41132ff9e7fbcfee4aa53734f7f481740 Mon Sep 17 00:00:00 2001 From: gigas002 Date: Thu, 6 Aug 2026 11:14:16 +0900 Subject: [PATCH 4/7] feature: add libwaysip tests --- libwaysip/src/lib.rs | 2 + libwaysip/src/tests/dispatch.rs | 979 +++++++++++++++++++++++++ libwaysip/src/tests/lib.rs | 94 +++ libwaysip/src/tests/live_connection.rs | 138 ++++ libwaysip/src/tests/mod.rs | 8 + libwaysip/src/tests/render.rs | 221 ++++++ libwaysip/src/tests/state.rs | 295 ++++++++ libwaysip/src/tests/utils.rs | 65 ++ 8 files changed, 1802 insertions(+) create mode 100644 libwaysip/src/tests/dispatch.rs create mode 100644 libwaysip/src/tests/lib.rs create mode 100644 libwaysip/src/tests/live_connection.rs create mode 100644 libwaysip/src/tests/mod.rs create mode 100644 libwaysip/src/tests/render.rs create mode 100644 libwaysip/src/tests/state.rs create mode 100644 libwaysip/src/tests/utils.rs diff --git a/libwaysip/src/lib.rs b/libwaysip/src/lib.rs index 23619e1..d3e992c 100644 --- a/libwaysip/src/lib.rs +++ b/libwaysip/src/lib.rs @@ -3,6 +3,8 @@ mod render; pub mod error; pub mod state; +#[cfg(test)] +mod tests; mod utils; pub use utils::*; diff --git a/libwaysip/src/tests/dispatch.rs b/libwaysip/src/tests/dispatch.rs new file mode 100644 index 0000000..8b2af68 --- /dev/null +++ b/libwaysip/src/tests/dispatch.rs @@ -0,0 +1,979 @@ +//! Unit tests for `WaysipState`'s `Dispatch` impls, using "inert" proxy +//! objects (`Proxy::inert`) backed by a locally paired `UnixStream` instead +//! of a real compositor. Nothing here needs a live Wayland server: the +//! backend is a genuine, working client backend, it just has nobody +//! listening on the other end of the socket, so one-way protocol requests +//! (bind, commit, attach, ...) succeed locally without ever needing a reply. +//! This mirrors the technique used by `libwayshot`'s own dispatch tests. +//! +//! Tests that need a real compositor round-trip (registry/xdg_output info +//! actually being filled in by a server) live in `live_connection.rs` +//! instead. + +use std::os::unix::net::UnixStream; + +use wayland_backend::client::Backend; +use wayland_client::protocol::{ + wl_buffer::{self, WlBuffer}, + wl_callback::{self, WlCallback}, + wl_keyboard, wl_output, wl_pointer, wl_registry, wl_seat, + wl_shm::WlShm, +}; +use wayland_client::{Connection, Dispatch, Proxy, WEnum}; +use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_manager_v1::WpCursorShapeManagerV1; +use wayland_protocols::xdg::shell::client::xdg_wm_base; +use wayland_protocols::xdg::xdg_output::zv1::client::zxdg_output_v1; +use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1; + +use crate::state::{self, Corner, DragTarget, SelectionType, WaysipState}; +use crate::{BoxInfo, Position, Size, Style}; + +fn dummy_conn() -> Connection { + let (client, server) = UnixStream::pair().expect("unix stream"); + Box::leak(Box::new(server)); + let backend = Backend::connect(client).expect("backend"); + Connection::from_backend(backend) +} + +fn inert(conn: &Connection) -> T { + T::inert(conn.backend().downgrade()) +} + +fn base_state() -> WaysipState { + WaysipState::new(SelectionType::Area) +} + +fn cairo_context() -> cairo::Context { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).unwrap(); + cairo::Context::new(&surface).unwrap() +} + +fn layer_surface_info(conn: &Connection) -> state::LayerSurfaceInfo { + state::LayerSurfaceInfo { + layer: inert(conn), + wl_surface: inert(conn), + cursor_surface: inert(conn), + buffer: inert(conn), + cursor_buffer: None, + cairo_t: cairo_context(), + stride: 16, + inited: false, + buffer_busy: false, + style: Style::default(), + pango_layout: std::cell::OnceCell::new(), + font_desc_bold: std::cell::OnceCell::new(), + font_desc_normal: std::cell::OnceCell::new(), + prev_selection: None, + margin: std::cell::OnceCell::new(), + frozen_bg: None, + } +} + +fn output_with_xdg_info( + conn: &Connection, + start: (i32, i32), + size: (i32, i32), +) -> state::WlOutputInfo { + let wl_output: wl_output::WlOutput = inert(conn); + let zxdg_output: zxdg_output_v1::ZxdgOutputV1 = inert(conn); + let info = state::WlOutputInfo::new(wl_output); + let mut xdg_info = state::ZXdgOutputInfo::new(zxdg_output); + xdg_info.start_position = Position { + x: start.0, + y: start.1, + }; + xdg_info.size = Size { + width: size.0, + height: size.1, + }; + info.xdg_output_info.set(xdg_info).unwrap(); + info +} + +// --- wl_keyboard --- + +#[test] +fn keyboard_escape_aborts_selection() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let keyboard: wl_keyboard::WlKeyboard = inert(&conn); + let mut state = base_state(); + state.start_pos = Some(Position { x: 1.0, y: 1.0 }); + state.end_pos = Some(Position { x: 2.0, y: 2.0 }); + + >::event( + &mut state, + &keyboard, + wl_keyboard::Event::Key { + serial: 0, + time: 0, + key: 1, + state: WEnum::Value(wl_keyboard::KeyState::Pressed), + }, + &(), + &conn, + &qh, + ); + + assert!(state.start_pos.is_none()); + assert!(state.end_pos.is_none()); + assert!(!state.running); +} + +#[test] +fn keyboard_escape_release_is_ignored() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let keyboard: wl_keyboard::WlKeyboard = inert(&conn); + let mut state = base_state(); + + >::event( + &mut state, + &keyboard, + wl_keyboard::Event::Key { + serial: 0, + time: 0, + key: 1, + state: WEnum::Value(wl_keyboard::KeyState::Released), + }, + &(), + &conn, + &qh, + ); + + assert!(state.running); +} + +#[test] +fn keyboard_confirm_key_while_editing_stops_running() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let keyboard: wl_keyboard::WlKeyboard = inert(&conn); + let mut state = base_state(); + state.editing = true; + state.confirm_key = 28; + + >::event( + &mut state, + &keyboard, + wl_keyboard::Event::Key { + serial: 0, + time: 0, + key: 28, + state: WEnum::Value(wl_keyboard::KeyState::Pressed), + }, + &(), + &conn, + &qh, + ); + + assert!(!state.running); +} + +#[test] +fn keyboard_confirm_key_while_not_editing_is_ignored() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let keyboard: wl_keyboard::WlKeyboard = inert(&conn); + let mut state = base_state(); + state.confirm_key = 28; + + >::event( + &mut state, + &keyboard, + wl_keyboard::Event::Key { + serial: 0, + time: 0, + key: 28, + state: WEnum::Value(wl_keyboard::KeyState::Pressed), + }, + &(), + &conn, + &qh, + ); + + assert!(state.running); +} + +#[test] +fn keyboard_unrelated_key_is_ignored() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let keyboard: wl_keyboard::WlKeyboard = inert(&conn); + let mut state = base_state(); + + >::event( + &mut state, + &keyboard, + wl_keyboard::Event::Key { + serial: 0, + time: 0, + key: 99, + state: WEnum::Value(wl_keyboard::KeyState::Pressed), + }, + &(), + &conn, + &qh, + ); + + assert!(state.running); +} + +// --- wl_registry --- + +#[test] +fn registry_global_wl_output_registers_output() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let registry: wl_registry::WlRegistry = inert(&conn); + let mut state = base_state(); + + >::event( + &mut state, + ®istry, + wl_registry::Event::Global { + name: 1, + interface: "wl_output".to_string(), + version: 4, + }, + &(), + &conn, + &qh, + ); + + assert_eq!(state.wloutput_infos.len(), 1); +} + +#[test] +fn registry_global_other_interface_is_ignored() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let registry: wl_registry::WlRegistry = inert(&conn); + let mut state = base_state(); + + >::event( + &mut state, + ®istry, + wl_registry::Event::Global { + name: 1, + interface: "wl_compositor".to_string(), + version: 4, + }, + &(), + &conn, + &qh, + ); + + assert!(state.wloutput_infos.is_empty()); +} + +#[test] +fn registry_global_remove_is_ignored() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let registry: wl_registry::WlRegistry = inert(&conn); + let mut state = base_state(); + + >::event( + &mut state, + ®istry, + wl_registry::Event::GlobalRemove { name: 1 }, + &(), + &conn, + &qh, + ); + + assert!(state.wloutput_infos.is_empty()); +} + +// --- wl_output --- + +#[test] +fn wl_output_name_event_sets_name() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let wl_output: wl_output::WlOutput = inert(&conn); + let mut state = base_state(); + state + .wloutput_infos + .push(state::WlOutputInfo::new(wl_output.clone())); + + >::event( + &mut state, + &wl_output, + wl_output::Event::Name { + name: "DP-1".to_string(), + }, + &(), + &conn, + &qh, + ); + + assert_eq!(state.wloutput_infos[0].name, "DP-1"); +} + +#[test] +fn wl_output_mode_event_sets_size() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let wl_output: wl_output::WlOutput = inert(&conn); + let mut state = base_state(); + state + .wloutput_infos + .push(state::WlOutputInfo::new(wl_output.clone())); + + >::event( + &mut state, + &wl_output, + wl_output::Event::Mode { + flags: WEnum::Value(wl_output::Mode::Current), + width: 1920, + height: 1080, + refresh: 60000, + }, + &(), + &conn, + &qh, + ); + + assert_eq!(state.wloutput_infos[0].size.width, 1920); + assert_eq!(state.wloutput_infos[0].size.height, 1080); +} + +// --- zxdg_output_v1 --- + +#[test] +fn zxdg_output_updates_matching_output() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let wl_output: wl_output::WlOutput = inert(&conn); + let zxdg_output: zxdg_output_v1::ZxdgOutputV1 = inert(&conn); + let output_info = state::WlOutputInfo::new(wl_output); + output_info + .xdg_output_info + .set(state::ZXdgOutputInfo::new(zxdg_output.clone())) + .unwrap(); + let mut state = base_state(); + state.wloutput_infos.push(output_info); + + >::event( + &mut state, + &zxdg_output, + zxdg_output_v1::Event::LogicalSize { + width: 1920, + height: 1080, + }, + &(), + &conn, + &qh, + ); + >::event( + &mut state, + &zxdg_output, + zxdg_output_v1::Event::LogicalPosition { x: 10, y: 20 }, + &(), + &conn, + &qh, + ); + >::event( + &mut state, + &zxdg_output, + zxdg_output_v1::Event::Name { + name: "DP-1".to_string(), + }, + &(), + &conn, + &qh, + ); + + let info = state.wloutput_infos[0].xdg_output_info(); + assert_eq!(info.size.width, 1920); + assert_eq!(info.size.height, 1080); + assert_eq!(info.start_position.x, 10); + assert_eq!(info.start_position.y, 20); + assert_eq!(info.name, "DP-1"); +} + +#[test] +fn zxdg_output_event_for_unmatched_proxy_is_ignored() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let zxdg_output: zxdg_output_v1::ZxdgOutputV1 = inert(&conn); + let mut state = base_state(); + + >::event( + &mut state, + &zxdg_output, + zxdg_output_v1::Event::LogicalSize { + width: 1920, + height: 1080, + }, + &(), + &conn, + &qh, + ); + + assert!(state.wloutput_infos.is_empty()); +} + +// --- xdg_wm_base --- + +#[test] +fn xdg_wm_base_ping_does_not_panic() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let wm_base: xdg_wm_base::XdgWmBase = inert(&conn); + let mut state = base_state(); + + >::event( + &mut state, + &wm_base, + xdg_wm_base::Event::Ping { serial: 7 }, + &(), + &conn, + &qh, + ); +} + +// --- wl_seat --- + +#[test] +fn seat_capabilities_pointer_and_keyboard_do_not_panic() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let seat: wl_seat::WlSeat = inert(&conn); + let mut state = base_state(); + + >::event( + &mut state, + &seat, + wl_seat::Event::Capabilities { + capabilities: WEnum::Value(wl_seat::Capability::Keyboard), + }, + &(), + &conn, + &qh, + ); + >::event( + &mut state, + &seat, + wl_seat::Event::Capabilities { + capabilities: WEnum::Value(wl_seat::Capability::Pointer), + }, + &(), + &conn, + &qh, + ); +} + +// --- wl_pointer: Button --- + +#[test] +fn pointer_button_press_sets_start_pos_for_area_selection() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let pointer: wl_pointer::WlPointer = inert(&conn); + let mut state = base_state(); + state.qh = Some(qh.clone()); + state.current_pos = Position { x: 12.0, y: 34.0 }; + + >::event( + &mut state, + &pointer, + wl_pointer::Event::Button { + serial: 5, + time: 0, + button: 272, + state: WEnum::Value(wl_pointer::ButtonState::Pressed), + }, + &(), + &conn, + &qh, + ); + + assert_eq!(state.start_pos.unwrap().x, 12.0); + assert_eq!(state.start_pos.unwrap().y, 34.0); + assert!(state.running); + assert_eq!(state.last_pointer_serial, Some(5)); +} + +#[test] +fn pointer_button_press_for_point_selection_finishes_immediately() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let pointer: wl_pointer::WlPointer = inert(&conn); + let mut state = WaysipState::new(SelectionType::Point); + state.qh = Some(qh.clone()); + state.current_pos = Position { x: 5.0, y: 6.0 }; + + >::event( + &mut state, + &pointer, + wl_pointer::Event::Button { + serial: 1, + time: 0, + button: 272, + state: WEnum::Value(wl_pointer::ButtonState::Pressed), + }, + &(), + &conn, + &qh, + ); + + assert_eq!(state.end_pos.unwrap().x, 5.0); + assert!(!state.running); +} + +#[test] +fn pointer_button_release_for_area_finishes_selection() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let pointer: wl_pointer::WlPointer = inert(&conn); + let mut state = base_state(); + state.qh = Some(qh.clone()); + state.start_pos = Some(Position { x: 1.0, y: 1.0 }); + state.current_pos = Position { x: 50.0, y: 60.0 }; + + >::event( + &mut state, + &pointer, + wl_pointer::Event::Button { + serial: 2, + time: 0, + button: 272, + state: WEnum::Value(wl_pointer::ButtonState::Released), + }, + &(), + &conn, + &qh, + ); + + assert_eq!(state.end_pos.unwrap().x, 50.0); + assert!(!state.running); +} + +#[test] +fn pointer_button_release_with_edit_enabled_starts_editing() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let pointer: wl_pointer::WlPointer = inert(&conn); + let mut state = base_state(); + state.qh = Some(qh.clone()); + state.edit_enabled = true; + state.start_pos = Some(Position { x: 1.0, y: 1.0 }); + state.current_pos = Position { x: 50.0, y: 60.0 }; + + >::event( + &mut state, + &pointer, + wl_pointer::Event::Button { + serial: 2, + time: 0, + button: 272, + state: WEnum::Value(wl_pointer::ButtonState::Released), + }, + &(), + &conn, + &qh, + ); + + assert!(state.editing); + assert!(state.running); +} + +#[test] +fn pointer_button_press_while_editing_selects_handle() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let pointer: wl_pointer::WlPointer = inert(&conn); + let mut state = base_state(); + state.qh = Some(qh.clone()); + state.editing = true; + state.start_pos = Some(Position { x: 0.0, y: 0.0 }); + state.end_pos = Some(Position { x: 100.0, y: 100.0 }); + state.current_pos = Position { x: 2.0, y: 2.0 }; + + >::event( + &mut state, + &pointer, + wl_pointer::Event::Button { + serial: 3, + time: 0, + button: 272, + state: WEnum::Value(wl_pointer::ButtonState::Pressed), + }, + &(), + &conn, + &qh, + ); + + assert_eq!(state.active_handle, Some(DragTarget::Corner(Corner::Start))); +} + +#[test] +fn pointer_button_release_while_editing_clears_active_handle() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let pointer: wl_pointer::WlPointer = inert(&conn); + let mut state = base_state(); + state.qh = Some(qh.clone()); + state.editing = true; + state.active_handle = Some(DragTarget::Body); + state.move_anchor = Some(state::MoveAnchor { + grab_pos: Position { x: 0.0, y: 0.0 }, + start_pos: Position { x: 0.0, y: 0.0 }, + end_pos: Position { x: 0.0, y: 0.0 }, + }); + + >::event( + &mut state, + &pointer, + wl_pointer::Event::Button { + serial: 4, + time: 0, + button: 272, + state: WEnum::Value(wl_pointer::ButtonState::Released), + }, + &(), + &conn, + &qh, + ); + + assert!(state.active_handle.is_none()); + assert!(state.move_anchor.is_none()); +} + +// --- wl_pointer: Enter --- + +#[test] +fn pointer_enter_sets_current_screen_and_pos() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let pointer: wl_pointer::WlPointer = inert(&conn); + let mut state = base_state(); + state.qh = Some(qh.clone()); + + let output_info = output_with_xdg_info(&conn, (100, 200), (1920, 1080)); + let surface_info = layer_surface_info(&conn); + let surface_handle = surface_info.wl_surface.clone(); + state.wloutput_infos.push(output_info); + state.wl_surfaces.push(surface_info); + + >::event( + &mut state, + &pointer, + wl_pointer::Event::Enter { + serial: 9, + surface: surface_handle, + surface_x: 5.0, + surface_y: 7.0, + }, + &(), + &conn, + &qh, + ); + + assert_eq!(state.current_screen, 0); + assert_eq!(state.current_pos.x, 105.0); + assert_eq!(state.current_pos.y, 207.0); + assert_eq!(state.last_pointer_serial, Some(9)); +} + +#[test] +fn pointer_enter_with_cursor_manager_requests_crosshair_shape() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let pointer: wl_pointer::WlPointer = inert(&conn); + let mut state = base_state(); + state.qh = Some(qh.clone()); + state.cursor_manager = Some(inert::(&conn)); + + let output_info = output_with_xdg_info(&conn, (0, 0), (1920, 1080)); + let surface_info = layer_surface_info(&conn); + let surface_handle = surface_info.wl_surface.clone(); + state.wloutput_infos.push(output_info); + state.wl_surfaces.push(surface_info); + + >::event( + &mut state, + &pointer, + wl_pointer::Event::Enter { + serial: 1, + surface: surface_handle, + surface_x: 0.0, + surface_y: 0.0, + }, + &(), + &conn, + &qh, + ); + + assert_eq!(state.cursor_is_crosshair, Some(true)); +} + +// --- wl_pointer: Motion --- + +#[test] +fn pointer_motion_updates_end_pos_for_area_selection() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let pointer: wl_pointer::WlPointer = inert(&conn); + let mut state = base_state(); + state.qh = Some(qh.clone()); + state + .wloutput_infos + .push(output_with_xdg_info(&conn, (0, 0), (1920, 1080))); + state.start_pos = Some(Position { x: 10.0, y: 10.0 }); + + >::event( + &mut state, + &pointer, + wl_pointer::Event::Motion { + time: 0, + surface_x: 40.0, + surface_y: 50.0, + }, + &(), + &conn, + &qh, + ); + + assert_eq!(state.current_pos.x, 40.0); + assert_eq!(state.end_pos.unwrap().x, 40.0); + assert_eq!(state.end_pos.unwrap().y, 50.0); +} + +#[test] +fn pointer_motion_respects_aspect_ratio_height_driven() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let pointer: wl_pointer::WlPointer = inert(&conn); + let mut state = base_state(); + state.qh = Some(qh.clone()); + state + .wloutput_infos + .push(output_with_xdg_info(&conn, (0, 0), (1920, 1080))); + state.aspect_ratio = Some((16.0, 9.0)); + state.start_pos = Some(Position { x: 0.0, y: 0.0 }); + + // width=50, height=200: too tall for 16:9, so height drives the box. + >::event( + &mut state, + &pointer, + wl_pointer::Event::Motion { + time: 0, + surface_x: 50.0, + surface_y: 200.0, + }, + &(), + &conn, + &qh, + ); + + let end = state.end_pos.unwrap(); + assert!((end.x - 200.0 * 16.0 / 9.0).abs() < 0.001); + assert_eq!(end.y, 200.0); +} + +#[test] +fn pointer_motion_respects_aspect_ratio_width_driven() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let pointer: wl_pointer::WlPointer = inert(&conn); + let mut state = base_state(); + state.qh = Some(qh.clone()); + state + .wloutput_infos + .push(output_with_xdg_info(&conn, (0, 0), (1920, 1080))); + state.aspect_ratio = Some((16.0, 9.0)); + state.start_pos = Some(Position { x: 0.0, y: 0.0 }); + + // width=200, height=50: too wide for 16:9, so width drives the box. + >::event( + &mut state, + &pointer, + wl_pointer::Event::Motion { + time: 0, + surface_x: 200.0, + surface_y: 50.0, + }, + &(), + &conn, + &qh, + ); + + let end = state.end_pos.unwrap(); + assert_eq!(end.x, 200.0); + assert!((end.y - 200.0 * 9.0 / 16.0).abs() < 0.001); +} + +#[test] +fn pointer_motion_over_predefined_box_snaps_selection() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let pointer: wl_pointer::WlPointer = inert(&conn); + let mut state = WaysipState::new(SelectionType::PredefinedBoxes); + state.qh = Some(qh.clone()); + state + .wloutput_infos + .push(output_with_xdg_info(&conn, (0, 0), (1920, 1080))); + state.predefined_boxes = Some(vec![BoxInfo { + start_x: 10.0, + start_y: 10.0, + end_x: 50.0, + end_y: 50.0, + }]); + + >::event( + &mut state, + &pointer, + wl_pointer::Event::Motion { + time: 0, + surface_x: 20.0, + surface_y: 20.0, + }, + &(), + &conn, + &qh, + ); + + assert_eq!(state.start_pos.unwrap().x, 10.0); + assert_eq!(state.end_pos.unwrap().x, 50.0); +} + +#[test] +fn pointer_motion_outside_any_predefined_box_leaves_selection_unset() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let pointer: wl_pointer::WlPointer = inert(&conn); + let mut state = WaysipState::new(SelectionType::PredefinedBoxes); + state.qh = Some(qh.clone()); + state + .wloutput_infos + .push(output_with_xdg_info(&conn, (0, 0), (1920, 1080))); + state.predefined_boxes = Some(vec![BoxInfo { + start_x: 10.0, + start_y: 10.0, + end_x: 50.0, + end_y: 50.0, + }]); + + >::event( + &mut state, + &pointer, + wl_pointer::Event::Motion { + time: 0, + surface_x: 500.0, + surface_y: 500.0, + }, + &(), + &conn, + &qh, + ); + + assert!(state.start_pos.is_none()); +} + +// --- WlCallback (frame done) --- + +#[test] +fn frame_callback_for_current_screen_triggers_redraw() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let callback: WlCallback = inert(&conn); + let mut state = base_state(); + state.redraw_all = true; + + >::event( + &mut state, + &callback, + wl_callback::Event::Done { callback_data: 123 }, + &0, + &conn, + &qh, + ); + + assert!(!state.redraw_all); +} + +#[test] +fn frame_callback_for_other_screen_is_ignored() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let callback: WlCallback = inert(&conn); + let mut state = base_state(); + state.current_screen = 0; + state.redraw_all = true; + + >::event( + &mut state, + &callback, + wl_callback::Event::Done { callback_data: 123 }, + &1, + &conn, + &qh, + ); + + assert!(state.redraw_all); +} + +// --- WlBuffer --- + +#[test] +fn buffer_release_clears_busy_flag() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let buffer: WlBuffer = inert(&conn); + let mut state = base_state(); + let mut surface_info = layer_surface_info(&conn); + surface_info.buffer = buffer.clone(); + surface_info.buffer_busy = true; + state.wl_surfaces.push(surface_info); + + >::event( + &mut state, + &buffer, + wl_buffer::Event::Release, + &(), + &conn, + &qh, + ); + + assert!(!state.wl_surfaces[0].buffer_busy); +} + +// --- zwlr_layer_surface_v1 --- + +#[test] +fn layer_surface_configure_creates_buffer_and_marks_inited() { + let conn = dummy_conn(); + let qh = conn.new_event_queue::().handle(); + let layer: zwlr_layer_surface_v1::ZwlrLayerSurfaceV1 = inert(&conn); + let shm: WlShm = inert(&conn); + + let mut state = base_state(); + state.shm = Some(shm); + state.qh = Some(qh.clone()); + + let mut surface_info = layer_surface_info(&conn); + surface_info.layer = layer.clone(); + surface_info.buffer_busy = false; + surface_info.inited = false; + state.wl_surfaces.push(surface_info); + + >::event( + &mut state, + &layer, + zwlr_layer_surface_v1::Event::Configure { + serial: 4, + width: 100, + height: 100, + }, + &(), + &conn, + &qh, + ); + + assert!(state.wl_surfaces[0].buffer_busy); + assert!(state.wl_surfaces[0].inited); +} diff --git a/libwaysip/src/tests/lib.rs b/libwaysip/src/tests/lib.rs new file mode 100644 index 0000000..2212926 --- /dev/null +++ b/libwaysip/src/tests/lib.rs @@ -0,0 +1,94 @@ +use crate::*; + +#[test] +fn builder_sets_selection_type_and_style() { + let sip = WaySip::new() + .with_selection_type(SelectionType::Point) + .with_background_color(Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }) + .with_foreground_color(Color { + r: 0.0, + g: 1.0, + b: 0.0, + a: 1.0, + }) + .with_border_text_color(Color { + r: 0.0, + g: 0.0, + b: 1.0, + a: 1.0, + }) + .with_box_color(Color { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + }) + .with_border_weight(2.5) + .with_font_size(20) + .with_font_name("Mono".to_string()); + + assert!(matches!(sip.selection_type, SelectionType::Point)); + assert_eq!(sip.style.background_color.r, 1.0); + assert_eq!(sip.style.foreground_color.g, 1.0); + assert_eq!(sip.style.border_text_color.b, 1.0); + assert_eq!(sip.style.box_color.r, 1.0); + assert_eq!(sip.style.border_weight, 2.5); + assert_eq!(sip.style.font_size, 20); + assert_eq!(sip.style.font_name, "Mono"); +} + +#[test] +fn builder_sets_predefined_boxes_and_aspect_ratio() { + let boxes = vec![state::BoxInfo { + start_x: 0.0, + start_y: 0.0, + end_x: 10.0, + end_y: 10.0, + }]; + let sip = WaySip::new() + .with_predefined_boxes(boxes) + .with_aspect_ratio(16.0, 9.0); + + assert_eq!(sip.predefined_boxes.as_ref().map(|b| b.len()), Some(1)); + assert_eq!(sip.aspect_ratio, Some((16.0, 9.0))); +} + +#[test] +fn builder_sets_edit_selection_and_confirm_key() { + let sip = WaySip::new().with_edit_selection().with_confirm_key(15); + assert!(sip.edit_selection); + assert_eq!(sip.confirm_key, Some(15)); +} + +#[test] +fn builder_default_has_no_edit_selection() { + let sip = WaySip::new(); + assert!(!sip.edit_selection); + assert!(sip.confirm_key.is_none()); +} + +#[test] +fn builder_sets_background_provider() { + let sip = WaySip::new().with_background_provider(|_output, _name| None); + assert!(sip.background_provider.is_some()); +} + +#[test] +fn debug_impl_does_not_panic() { + let sip = WaySip::new().with_edit_selection().with_confirm_key(5); + let debug_str = format!("{sip:?}"); + assert!(debug_str.contains("WaySip")); + assert!(debug_str.contains("edit_selection: true")); +} + +#[cfg(feature = "benchmark")] +#[test] +fn builder_sets_bench_flag() { + let sip = WaySip::new().with_bench(); + assert!(sip.bench); +} diff --git a/libwaysip/src/tests/live_connection.rs b/libwaysip/src/tests/live_connection.rs new file mode 100644 index 0000000..70c2f30 --- /dev/null +++ b/libwaysip/src/tests/live_connection.rs @@ -0,0 +1,138 @@ +//! Tests that connect to a *real* Wayland compositor over the actual +//! protocol, as opposed to the "inert object" tests in `dispatch.rs` that +//! fake a single dead proxy without a server behind it. +//! +//! There's no way to exercise a real registry/xdg_output round trip without +//! an actual compositor process answering on the other end of the socket. +//! CI starts one (wlroots' headless backend, see +//! `.github/workflows/test-coverage.yml`) and points `WAYLAND_DISPLAY` at +//! it before running tests. Locally, or in any other CI job, there's no +//! compositor, so these skip themselves at runtime instead of failing - +//! `cargo test` must stay green on a plain developer machine. +//! +//! `get_area_inner`'s own blocking event loop isn't exercised here: it +//! waits for real pointer/keyboard input, which the headless CI compositor +//! (no input devices, see `WLR_LIBINPUT_NO_DEVICES=1`) never sends. Instead +//! these replicate just its setup (registry + xdg_output round trip) to +//! prove that part works against a real server. + +use wayland_client::Connection; +use wayland_client::globals::registry_queue_init; +use wayland_client::protocol::{wl_compositor::WlCompositor, wl_seat::WlSeat, wl_shm::WlShm}; +use wayland_protocols::xdg::xdg_output::zv1::client::zxdg_output_manager_v1::ZxdgOutputManagerV1; +use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_shell_v1::ZwlrLayerShellV1; + +use crate::Position; +use crate::state::{self, SelectionType, WaysipState}; + +pub(super) fn skip_without_compositor() -> bool { + if std::env::var_os("WAYLAND_DISPLAY").is_none() { + eprintln!("skipping: no WAYLAND_DISPLAY set (requires a live compositor)"); + return true; + } + false +} + +/// Connects and does the registry + xdg_output round trip that +/// `get_area_inner` does, without its blocking event loop. +fn connected_state_with_outputs() -> (Connection, WaysipState) { + let connection = Connection::connect_to_env().expect("should connect to the CI compositor"); + let (globals, _) = registry_queue_init::(&connection) + .expect("registry init should succeed against a live compositor"); + let mut state = WaysipState::new(SelectionType::Area); + let mut event_queue = connection.new_event_queue::(); + let qh = event_queue.handle(); + + let _ = connection.display().get_registry(&qh, ()); + event_queue + .roundtrip(&mut state) + .expect("first roundtrip should populate outputs"); + + let xdg_output_manager = globals + .bind::(&qh, 1..=3, ()) + .expect("compositor should support xdg-output"); + for wloutput in state.wloutput_infos.iter_mut() { + let zwloutput = xdg_output_manager.get_xdg_output(wloutput.get_output(), &qh, ()); + wloutput + .xdg_output_info + .set(state::ZXdgOutputInfo::new(zwloutput)) + .expect("should be set only once"); + } + event_queue + .roundtrip(&mut state) + .expect("second roundtrip should populate xdg_output info"); + + (connection, state) +} + +#[test] +fn connects_and_registers_at_least_one_output() { + if skip_without_compositor() { + return; + } + let (_connection, state) = connected_state_with_outputs(); + assert!( + !state.wloutput_infos.is_empty(), + "expected at least one output (WLR_HEADLESS_OUTPUTS should create one)" + ); +} + +#[test] +fn xdg_output_info_is_populated_after_roundtrip() { + if skip_without_compositor() { + return; + } + let (_connection, state) = connected_state_with_outputs(); + let output = &state.wloutput_infos[0]; + let info = output.xdg_output_info(); + assert!(info.size.width > 0); + assert!(info.size.height > 0); +} + +#[test] +fn required_globals_are_advertised_by_the_compositor() { + if skip_without_compositor() { + return; + } + let connection = Connection::connect_to_env().expect("should connect to the CI compositor"); + let (globals, _) = registry_queue_init::(&connection) + .expect("registry init should succeed against a live compositor"); + let event_queue = connection.new_event_queue::(); + let qh = event_queue.handle(); + + assert!( + globals.bind::(&qh, 1..=5, ()).is_ok(), + "wl_compositor should be advertised" + ); + assert!( + globals.bind::(&qh, 1..=1, ()).is_ok(), + "wl_shm should be advertised" + ); + assert!( + globals.bind::(&qh, 1..=1, ()).is_ok(), + "wl_seat should be advertised" + ); + assert!( + globals + .bind::(&qh, 3..=4, ()) + .is_ok(), + "zwlr_layer_shell_v1 should be advertised" + ); +} + +#[test] +fn area_info_computes_correct_geometry_from_real_output() { + if skip_without_compositor() { + return; + } + let (_connection, mut state) = connected_state_with_outputs(); + state.start_pos = Some(Position { x: 10.0, y: 20.0 }); + state.end_pos = Some(Position { x: 110.0, y: 170.0 }); + + let area = state.area_info().expect("both positions are set"); + assert_eq!(area.width(), 100); + assert_eq!(area.height(), 150); + let top_left = area.left_top_point(); + assert_eq!(top_left.x, 10); + assert_eq!(top_left.y, 20); +} diff --git a/libwaysip/src/tests/mod.rs b/libwaysip/src/tests/mod.rs new file mode 100644 index 0000000..0b06d85 --- /dev/null +++ b/libwaysip/src/tests/mod.rs @@ -0,0 +1,8 @@ +//! Private test module. All unit tests for libwaysip live here to keep main source files focused. + +mod dispatch; +mod lib; +mod live_connection; +mod render; +mod state; +mod utils; diff --git a/libwaysip/src/tests/render.rs b/libwaysip/src/tests/render.rs new file mode 100644 index 0000000..ed2df7b --- /dev/null +++ b/libwaysip/src/tests/render.rs @@ -0,0 +1,221 @@ +use std::os::unix::net::UnixStream; + +use wayland_backend::client::Backend; +use wayland_client::{Connection, Proxy}; + +use crate::render::*; +use crate::state::{self, LayerSurfaceInfo}; +use crate::{BoxInfo, Color, Position, Size, Style}; + +fn sample_color() -> Color { + Color { + r: 0.1, + g: 0.2, + b: 0.3, + a: 0.4, + } +} + +fn dummy_conn() -> Connection { + let (client, server) = UnixStream::pair().expect("unix stream"); + Box::leak(Box::new(server)); + let backend = Backend::connect(client).expect("backend"); + Connection::from_backend(backend) +} + +fn inert(conn: &Connection) -> T { + T::inert(conn.backend().downgrade()) +} + +/// A `LayerSurfaceInfo` backed by an inert (no live compositor needed) set +/// of Wayland proxies and a real, reasonably-sized cairo surface, so the +/// drawing methods below have real pixels to paint into. +fn layer_surface_info(conn: &Connection) -> LayerSurfaceInfo { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 200, 200).unwrap(); + let cairo_t = cairo::Context::new(&surface).unwrap(); + state::LayerSurfaceInfo { + layer: inert(conn), + wl_surface: inert(conn), + cursor_surface: inert(conn), + buffer: inert(conn), + cursor_buffer: None, + cairo_t, + stride: 200 * 4, + inited: false, + buffer_busy: false, + style: Style::default(), + pango_layout: std::cell::OnceCell::new(), + font_desc_bold: std::cell::OnceCell::new(), + font_desc_normal: std::cell::OnceCell::new(), + prev_selection: None, + margin: std::cell::OnceCell::new(), + frozen_bg: None, + } +} + +#[test] +fn draw_ui_plain_background() { + let mut file = tempfile::tempfile().unwrap(); + let UiInit { stride, .. } = draw_ui(&mut file, (10, 10), sample_color(), None); + assert!(stride >= 10 * 4); +} + +#[test] +fn draw_ui_with_matching_frozen_background() { + let mut file = tempfile::tempfile().unwrap(); + let frozen = cairo::ImageSurface::create(cairo::Format::ARgb32, 10, 10).unwrap(); + let UiInit { stride, .. } = draw_ui(&mut file, (10, 10), sample_color(), Some(&frozen)); + assert!(stride >= 10 * 4); +} + +#[test] +fn draw_ui_with_mismatched_frozen_background_scales() { + let mut file = tempfile::tempfile().unwrap(); + let frozen = cairo::ImageSurface::create(cairo::Format::ARgb32, 5, 5).unwrap(); + let UiInit { stride, .. } = draw_ui(&mut file, (20, 20), sample_color(), Some(&frozen)); + assert!(stride >= 20 * 4); +} + +// --- LayerSurfaceInfo drawing methods --- +// +// None of these need a live compositor: `wl_surface.attach/damage/commit` +// are one-way protocol requests that succeed fine against an inert proxy +// (see `dispatch.rs` for the same technique), and the actual drawing is +// real cairo/pango work against a real (if disconnected-from-any-screen) +// surface. + +#[test] +fn init_commit_does_not_panic() { + let conn = dummy_conn(); + let info = layer_surface_info(&conn); + info.init_commit(); +} + +#[test] +fn redraw_select_screen_when_selected_draws_label() { + let conn = dummy_conn(); + let info = layer_surface_info(&conn); + info.redraw_select_screen( + true, + Size { + width: 200, + height: 200, + }, + Position { x: 0, y: 0 }, + "DP-1", + "Some Monitor", + ); +} + +#[test] +fn redraw_select_screen_when_not_selected_paints_background_only() { + let conn = dummy_conn(); + let info = layer_surface_info(&conn); + info.redraw_select_screen( + false, + Size { + width: 200, + height: 200, + }, + Position { x: 0, y: 0 }, + "DP-1", + "Some Monitor", + ); +} + +#[test] +fn redraw_minimal_does_not_panic() { + let conn = dummy_conn(); + let mut info = layer_surface_info(&conn); + info.redraw( + Position { x: 10.0, y: 10.0 }, + Position { x: 60.0, y: 60.0 }, + Position { x: 0, y: 0 }, + Size { + width: 200, + height: 200, + }, + false, + None, + true, + false, + ); +} + +#[test] +fn redraw_full_featured_does_not_panic() { + let conn = dummy_conn(); + let mut info = layer_surface_info(&conn); + let boxes = vec![BoxInfo { + start_x: 5.0, + start_y: 5.0, + end_x: 30.0, + end_y: 30.0, + }]; + info.redraw( + Position { x: 10.0, y: 10.0 }, + Position { x: 60.0, y: 60.0 }, + Position { x: 0, y: 0 }, + Size { + width: 200, + height: 200, + }, + true, + Some(&boxes), + false, + true, + ); +} + +#[test] +fn redraw_with_frozen_background_does_not_panic() { + let conn = dummy_conn(); + let mut info = layer_surface_info(&conn); + info.frozen_bg = Some(cairo::ImageSurface::create(cairo::Format::ARgb32, 200, 200).unwrap()); + info.redraw( + Position { x: 10.0, y: 10.0 }, + Position { x: 60.0, y: 60.0 }, + Position { x: 0, y: 0 }, + Size { + width: 200, + height: 200, + }, + true, + None, + true, + false, + ); +} + +#[test] +fn redraw_reuses_prev_selection_clip_on_second_call() { + let conn = dummy_conn(); + let mut info = layer_surface_info(&conn); + let size = Size { + width: 200, + height: 200, + }; + // First call has no prev_selection yet (the `else` branch of the + // clip-rect computation); the second exercises the `Some(prev)` branch. + info.redraw( + Position { x: 10.0, y: 10.0 }, + Position { x: 60.0, y: 60.0 }, + Position { x: 0, y: 0 }, + size, + false, + None, + false, + false, + ); + assert!(info.prev_selection.is_some()); + info.redraw( + Position { x: 15.0, y: 15.0 }, + Position { x: 70.0, y: 70.0 }, + Position { x: 0, y: 0 }, + size, + false, + None, + false, + false, + ); +} diff --git a/libwaysip/src/tests/state.rs b/libwaysip/src/tests/state.rs new file mode 100644 index 0000000..1aa47b0 --- /dev/null +++ b/libwaysip/src/tests/state.rs @@ -0,0 +1,295 @@ +use crate::Position; +use crate::error::BoxInfoError; +use crate::state::*; + +// --- BoxInfo --- + +#[test] +fn box_from_str_valid() { + let b = BoxInfo::get_box_from_str("10,20 100x50").unwrap(); + assert_eq!(b.start_x, 10.0); + assert_eq!(b.start_y, 20.0); + assert_eq!(b.end_x, 110.0); + assert_eq!(b.end_y, 70.0); +} + +#[test] +fn box_from_str_missing_space() { + let err = BoxInfo::get_box_from_str("10,20100x50").unwrap_err(); + assert!(matches!(err, BoxInfoError::InvalidBoxString(_))); +} + +#[test] +fn box_from_str_missing_comma() { + let err = BoxInfo::get_box_from_str("1020 100x50").unwrap_err(); + assert!(matches!(err, BoxInfoError::InvalidBoxCoordsString(_))); +} + +#[test] +fn box_from_str_missing_x_in_size() { + let err = BoxInfo::get_box_from_str("10,20 10050").unwrap_err(); + assert!(matches!(err, BoxInfoError::InvalidBoxSizeString(_))); +} + +#[test] +fn box_from_str_bad_number() { + let err = BoxInfo::get_box_from_str("a,20 100x50").unwrap_err(); + assert!(matches!(err, BoxInfoError::ParseFloatError(_))); +} + +// --- WaysipState selection-type predicates --- + +#[test] +fn selection_type_default_is_area() { + assert!(matches!(SelectionType::default(), SelectionType::Area)); +} + +#[test] +fn state_predicates() { + let state = WaysipState::new(SelectionType::Area); + assert!(state.is_area()); + assert!(!state.is_screen()); + assert!(!state.is_predefined_boxes()); + assert!(!state.is_dimensions_or_output()); + + let state = WaysipState::new(SelectionType::Screen); + assert!(state.is_screen()); + + let state = WaysipState::new(SelectionType::PredefinedBoxes); + assert!(state.is_predefined_boxes()); + + let state = WaysipState::new(SelectionType::DimensionsOrOutput); + assert!(state.is_dimensions_or_output()); +} + +#[test] +fn effective_selection_type_falls_back() { + let state = WaysipState::new(SelectionType::Screen); + assert!(matches!( + state.effective_selection_type(), + SelectionType::Screen + )); + assert!(state.is_effective_screen()); + assert!(!state.is_effective_area()); +} + +#[test] +fn effective_selection_type_overridden() { + let mut state = WaysipState::new(SelectionType::DimensionsOrOutput); + state.effective_selection_type = Some(SelectionType::Area); + assert!(state.is_effective_area()); + assert!(!state.is_effective_screen()); +} + +// --- corners / hit testing --- + +fn state_with_rect() -> WaysipState { + let mut state = WaysipState::new(SelectionType::Area); + state.start_pos = Some(Position { x: 0.0, y: 0.0 }); + state.end_pos = Some(Position { x: 100.0, y: 100.0 }); + state +} + +#[test] +fn corners_none_without_positions() { + let state = WaysipState::new(SelectionType::Area); + assert!(state.corners().is_none()); +} + +#[test] +fn corners_returns_four_points() { + let state = state_with_rect(); + let corners = state.corners().unwrap(); + assert_eq!(corners.len(), 4); + assert!( + corners + .iter() + .any(|(c, p)| *c == Corner::Start && p.x == 0.0 && p.y == 0.0) + ); + assert!( + corners + .iter() + .any(|(c, p)| *c == Corner::End && p.x == 100.0 && p.y == 100.0) + ); + assert!( + corners + .iter() + .any(|(c, p)| *c == Corner::EndXStartY && p.x == 100.0 && p.y == 0.0) + ); + assert!( + corners + .iter() + .any(|(c, p)| *c == Corner::StartXEndY && p.x == 0.0 && p.y == 100.0) + ); +} + +#[test] +fn hit_test_handle_near_corner() { + let state = state_with_rect(); + let hit = state.hit_test_handle(Position { x: 2.0, y: 2.0 }); + assert_eq!(hit, Some(Corner::Start)); +} + +#[test] +fn hit_test_handle_picks_closest() { + let state = state_with_rect(); + // closer to Start (0,0) than to any other corner + let hit = state.hit_test_handle(Position { x: 1.0, y: 5.0 }); + assert_eq!(hit, Some(Corner::Start)); +} + +#[test] +fn hit_test_handle_far_from_all_corners() { + let state = state_with_rect(); + assert_eq!(state.hit_test_handle(Position { x: 50.0, y: 50.0 }), None); +} + +#[test] +fn hit_test_body_when_inside() { + let state = state_with_rect(); + assert_eq!( + state.hit_test(Position { x: 50.0, y: 50.0 }), + Some(DragTarget::Body) + ); +} + +#[test] +fn hit_test_corner_takes_priority_over_body() { + let state = state_with_rect(); + assert_eq!( + state.hit_test(Position { x: 1.0, y: 1.0 }), + Some(DragTarget::Corner(Corner::Start)) + ); +} + +#[test] +fn hit_test_none_when_outside() { + let state = state_with_rect(); + assert_eq!(state.hit_test(Position { x: 200.0, y: 200.0 }), None); +} + +#[test] +fn hit_test_none_without_positions() { + let state = WaysipState::new(SelectionType::Area); + assert_eq!(state.hit_test(Position { x: 1.0, y: 1.0 }), None); +} + +// --- dragging --- + +#[test] +fn apply_handle_drag_start_corner() { + let mut state = state_with_rect(); + state.active_handle = Some(DragTarget::Corner(Corner::Start)); + state.current_pos = Position { x: 5.0, y: 5.0 }; + state.apply_handle_drag(); + assert_eq!(state.start_pos.unwrap().x, 5.0); + assert_eq!(state.start_pos.unwrap().y, 5.0); + assert_eq!(state.end_pos.unwrap().x, 100.0); +} + +#[test] +fn apply_handle_drag_end_corner() { + let mut state = state_with_rect(); + state.active_handle = Some(DragTarget::Corner(Corner::End)); + state.current_pos = Position { x: 150.0, y: 150.0 }; + state.apply_handle_drag(); + assert_eq!(state.end_pos.unwrap().x, 150.0); + assert_eq!(state.end_pos.unwrap().y, 150.0); +} + +#[test] +fn apply_handle_drag_end_x_start_y_corner() { + let mut state = state_with_rect(); + state.active_handle = Some(DragTarget::Corner(Corner::EndXStartY)); + state.current_pos = Position { x: 30.0, y: 40.0 }; + state.apply_handle_drag(); + assert_eq!(state.end_pos.unwrap().x, 30.0); + assert_eq!(state.start_pos.unwrap().y, 40.0); + assert_eq!(state.start_pos.unwrap().x, 0.0); + assert_eq!(state.end_pos.unwrap().y, 100.0); +} + +#[test] +fn apply_handle_drag_start_x_end_y_corner() { + let mut state = state_with_rect(); + state.active_handle = Some(DragTarget::Corner(Corner::StartXEndY)); + state.current_pos = Position { x: 30.0, y: 40.0 }; + state.apply_handle_drag(); + assert_eq!(state.start_pos.unwrap().x, 30.0); + assert_eq!(state.end_pos.unwrap().y, 40.0); + assert_eq!(state.start_pos.unwrap().y, 0.0); + assert_eq!(state.end_pos.unwrap().x, 100.0); +} + +#[test] +fn apply_handle_drag_body_moves_whole_rect() { + let mut state = state_with_rect(); + state.current_pos = Position { x: 10.0, y: 10.0 }; + state.begin_move_drag(); + state.active_handle = Some(DragTarget::Body); + state.current_pos = Position { x: 15.0, y: 25.0 }; + state.apply_handle_drag(); + assert_eq!(state.start_pos.unwrap().x, 5.0); + assert_eq!(state.start_pos.unwrap().y, 15.0); + assert_eq!(state.end_pos.unwrap().x, 105.0); + assert_eq!(state.end_pos.unwrap().y, 115.0); +} + +#[test] +fn apply_handle_drag_noop_without_active_handle() { + let mut state = state_with_rect(); + state.apply_handle_drag(); + assert_eq!(state.start_pos.unwrap().x, 0.0); + assert_eq!(state.end_pos.unwrap().x, 100.0); +} + +#[test] +fn begin_move_drag_noop_without_positions() { + let mut state = WaysipState::new(SelectionType::Area); + state.begin_move_drag(); + assert!(state.move_anchor.is_none()); +} + +// --- editing/confirm flow --- + +#[test] +fn finish_or_start_editing_disabled_stops_running() { + let mut state = WaysipState::new(SelectionType::Area); + state.finish_or_start_editing(); + assert!(!state.running); + assert!(!state.editing); +} + +#[test] +fn finish_or_start_editing_enabled_for_area_starts_editing() { + let mut state = WaysipState::new(SelectionType::Area); + state.edit_enabled = true; + state.finish_or_start_editing(); + assert!(state.editing); + assert!(state.running); +} + +#[test] +fn finish_or_start_editing_enabled_for_screen_stops_running() { + let mut state = WaysipState::new(SelectionType::Screen); + state.edit_enabled = true; + state.finish_or_start_editing(); + assert!(!state.editing); + assert!(!state.running); +} + +// --- start pos tracking --- + +#[test] +fn set_start_pos_marks_redraw_all_once() { + let mut state = WaysipState::new(SelectionType::Area); + assert!(!state.redraw_all); + state.set_start_pos(Position { x: 1.0, y: 2.0 }); + assert!(state.redraw_all); + assert_eq!(state.start_pos.unwrap().x, 1.0); + + state.redraw_all = false; + state.set_start_pos(Position { x: 3.0, y: 4.0 }); + assert!(!state.redraw_all); + assert_eq!(state.start_pos.unwrap().x, 3.0); +} diff --git a/libwaysip/src/tests/utils.rs b/libwaysip/src/tests/utils.rs new file mode 100644 index 0000000..39816f0 --- /dev/null +++ b/libwaysip/src/tests/utils.rs @@ -0,0 +1,65 @@ +use crate::error::ColorError; +use crate::utils::*; + +#[test] +fn size_from_tuple() { + let size: Size = (10, 20).into(); + assert_eq!(size.width, 10); + assert_eq!(size.height, 20); +} + +#[test] +fn color_default() { + let c = Color::default(); + assert_eq!((c.r, c.g, c.b, c.a), (0.0, 0.0, 0.0, 0.5)); +} + +#[test] +fn style_default() { + let s = Style::default(); + assert_eq!(s.font_size, 12); + assert_eq!(s.font_name, "Sans"); + assert_eq!(s.border_weight, 1.0); +} + +#[test] +fn hex_to_color_valid_with_hash() { + let c = Color::hex_to_color("#66666680".to_string()).unwrap(); + assert!((c.r - 0.4).abs() < 0.01); + assert!((c.g - 0.4).abs() < 0.01); + assert!((c.b - 0.4).abs() < 0.01); + assert!((c.a - 0.5).abs() < 0.01); +} + +#[test] +fn hex_to_color_valid_without_hash() { + let c = Color::hex_to_color("000000ff".to_string()).unwrap(); + assert_eq!((c.r, c.g, c.b, c.a), (0.0, 0.0, 0.0, 1.0)); +} + +#[test] +fn hex_to_color_white() { + let c = Color::hex_to_color("#ffffffff".to_string()).unwrap(); + assert_eq!((c.r, c.g, c.b, c.a), (1.0, 1.0, 1.0, 1.0)); +} + +#[test] +fn hex_to_color_wrong_length() { + let err = Color::hex_to_color("#fff".to_string()).unwrap_err(); + assert!(matches!(err, ColorError::InvalidColorFormat(_))); +} + +#[test] +fn hex_to_color_invalid_chars() { + let err = Color::hex_to_color("#zzzzzzzz".to_string()).unwrap_err(); + assert!(matches!(err, ColorError::InvalidColorFormat(_))); +} + +#[test] +fn hex_to_color_error_message() { + let err = Color::hex_to_color("#fff".to_string()).unwrap_err(); + assert_eq!( + err.to_string(), + "Invalid color format `#fff`, expected `#rrggbbaa/rrggbbaa`" + ); +} From 57358c2f0dcce36c523bbeba2d2e97483f0eae89 Mon Sep 17 00:00:00 2001 From: gigas002 Date: Thu, 6 Aug 2026 11:14:38 +0900 Subject: [PATCH 5/7] chore: make some waysip fns public for testing --- waysip/src/freeze.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/waysip/src/freeze.rs b/waysip/src/freeze.rs index de3b66c..2aff86d 100644 --- a/waysip/src/freeze.rs +++ b/waysip/src/freeze.rs @@ -52,7 +52,7 @@ pub fn capture_backgrounds() /// Converts an [`image::DynamicImage`] into a premultiplied-alpha /// `cairo::ImageSurface` (`ARgb32`) suitable for use as a paint source. -fn image_to_argb_surface(image: image::DynamicImage) -> Option { +pub(crate) fn image_to_argb_surface(image: image::DynamicImage) -> Option { let rgba = image.to_rgba8(); let width = rgba.width() as i32; let height = rgba.height() as i32; From 9c1e18a82c88cd5155e0fe126b84783a9ff713de Mon Sep 17 00:00:00 2001 From: gigas002 Date: Thu, 6 Aug 2026 11:14:52 +0900 Subject: [PATCH 6/7] feature: add waysip unit tests --- waysip/src/main.rs | 2 + waysip/src/tests/cli.rs | 98 +++++++++++++++++ waysip/src/tests/freeze.rs | 68 ++++++++++++ waysip/src/tests/mod.rs | 7 ++ waysip/src/tests/settings.rs | 119 +++++++++++++++++++++ waysip/src/tests/utils.rs | 197 +++++++++++++++++++++++++++++++++++ 6 files changed, 491 insertions(+) create mode 100644 waysip/src/tests/cli.rs create mode 100644 waysip/src/tests/freeze.rs create mode 100644 waysip/src/tests/mod.rs create mode 100644 waysip/src/tests/settings.rs create mode 100644 waysip/src/tests/utils.rs diff --git a/waysip/src/main.rs b/waysip/src/main.rs index 67ba965..a236af4 100644 --- a/waysip/src/main.rs +++ b/waysip/src/main.rs @@ -4,6 +4,8 @@ mod freeze; #[cfg(feature = "logger")] mod logger; mod settings; +#[cfg(test)] +mod tests; mod utils; use clap::Parser; diff --git a/waysip/src/tests/cli.rs b/waysip/src/tests/cli.rs new file mode 100644 index 0000000..cee1fa1 --- /dev/null +++ b/waysip/src/tests/cli.rs @@ -0,0 +1,98 @@ +use crate::cli::Cli; +#[cfg(feature = "completions")] +use crate::cli::Shell; +use clap::Parser; +#[cfg(feature = "logger")] +use tracing::Level; + +fn parse(args: &[&str]) -> Result { + Cli::try_parse_from(std::iter::once("waysip").chain(args.iter().copied())) +} + +#[test] +fn defaults_with_no_args() { + let cli = parse(&[]).unwrap(); + assert!(!cli.point); + assert!(!cli.dimensions); + assert!(!cli.screen); + assert!(!cli.output); + assert!(!cli.boxes); + assert!(!cli.edit_selection); + assert_eq!(cli.format, "%x,%y %wx%h\n"); + assert!(cli.background.is_none()); + assert!(cli.aspect_ratio.is_none()); +} + +#[test] +fn point_and_dimensions_conflict() { + assert!(parse(&["-p", "-d"]).is_err()); +} + +#[test] +fn screen_and_output_conflict() { + assert!(parse(&["-i", "-o"]).is_err()); +} + +#[test] +fn point_and_boxes_conflict() { + assert!(parse(&["-p", "-r"]).is_err()); +} + +#[test] +fn dimensions_and_output_are_compatible() { + // dimensions-or-output combined mode is intentionally allowed + let cli = parse(&["-d", "-o"]).unwrap(); + assert!(cli.dimensions); + assert!(cli.output); +} + +#[test] +fn custom_format_overrides_default() { + let cli = parse(&["-f", "%x"]).unwrap(); + assert_eq!(cli.format, "%x"); +} + +#[test] +fn aspect_ratio_value_is_captured() { + let cli = parse(&["-a", "16:9"]).unwrap(); + assert_eq!(cli.aspect_ratio.as_deref(), Some("16:9")); +} + +#[test] +fn edit_selection_key_requires_edit_selection() { + assert!(parse(&["--edit-selection-key", "15"]).is_err()); +} + +#[test] +fn edit_selection_with_key_parses() { + let cli = parse(&["-e", "--edit-selection-key", "15"]).unwrap(); + assert!(cli.edit_selection); + assert_eq!(cli.edit_selection_key, Some(15)); +} + +#[test] +fn color_flags_are_captured() { + let cli = parse(&["-b", "#000000ff", "-c", "#ffffffff"]).unwrap(); + assert_eq!(cli.background.as_deref(), Some("#000000ff")); + assert_eq!(cli.border_color.as_deref(), Some("#ffffffff")); +} + +#[cfg(feature = "logger")] +#[test] +fn log_level_parses() { + let cli = parse(&["--log-level", "debug"]).unwrap(); + assert_eq!(cli.log_level, Some(Level::DEBUG)); +} + +#[cfg(feature = "completions")] +#[test] +fn completions_flag_parses_shell() { + let cli = parse(&["--completions", "bash"]).unwrap(); + assert!(matches!(cli.completions, Some(Shell::Bash))); +} + +#[cfg(feature = "completions")] +#[test] +fn completions_is_exclusive() { + assert!(parse(&["--completions", "bash", "-p"]).is_err()); +} diff --git a/waysip/src/tests/freeze.rs b/waysip/src/tests/freeze.rs new file mode 100644 index 0000000..cad0d5c --- /dev/null +++ b/waysip/src/tests/freeze.rs @@ -0,0 +1,68 @@ +use image::{DynamicImage, Rgba, RgbaImage}; + +use crate::freeze::image_to_argb_surface; + +fn solid_image(width: u32, height: u32, pixel: [u8; 4]) -> DynamicImage { + let buf = RgbaImage::from_fn(width, height, |_, _| Rgba(pixel)); + DynamicImage::ImageRgba8(buf) +} + +#[test] +fn converts_opaque_image() { + let img = solid_image(2, 2, [10, 20, 30, 255]); + let surface = image_to_argb_surface(img).unwrap(); + assert_eq!(surface.width(), 2); + assert_eq!(surface.height(), 2); +} + +#[test] +fn zero_sized_image_returns_none() { + let img = solid_image(0, 0, [0, 0, 0, 0]); + assert!(image_to_argb_surface(img).is_none()); +} + +#[test] +fn premultiplies_alpha() { + let img = solid_image(1, 1, [200, 100, 50, 128]); + let mut surface = image_to_argb_surface(img).unwrap(); + let bytes = surface.data().unwrap(); + // cairo's ARgb32 stores native-endian premultiplied 0xAARRGGBB. + let pixel = u32::from_ne_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + let a = (pixel >> 24) & 0xff; + let r = (pixel >> 16) & 0xff; + let g = (pixel >> 8) & 0xff; + let b = pixel & 0xff; + assert_eq!(a, 128); + assert_eq!(r, 200u32 * 128 / 255); + assert_eq!(g, 100u32 * 128 / 255); + assert_eq!(b, 50u32 * 128 / 255); +} + +// --- live-compositor test --- +// +// `capture_backgrounds` needs a real `libwayshot::WayshotConnection` to talk +// wlr_screencopy to an actual compositor, so unlike everything else in this +// file, it can't be exercised with a fake/inert proxy. CI starts a headless +// wlroots compositor (see `.github/workflows/test-coverage.yml`) and points +// `WAYLAND_DISPLAY` at it before running tests; locally or in any other CI +// job there's no compositor, so this skips itself at runtime instead of +// failing. + +#[test] +fn capture_backgrounds_returns_image_for_a_real_output() { + if std::env::var_os("WAYLAND_DISPLAY").is_none() { + eprintln!("skipping: no WAYLAND_DISPLAY set (requires a live compositor)"); + return; + } + + let conn = libwayshot::WayshotConnection::new().expect("should connect to the CI compositor"); + let output = conn.get_all_outputs()[0].clone(); + + let provider = + crate::freeze::capture_backgrounds().expect("should init the screenshot backend"); + let surface = provider(&output.wl_output, &output.name) + .expect("should capture a background for a real output"); + + assert!(surface.width() > 0); + assert!(surface.height() > 0); +} diff --git a/waysip/src/tests/mod.rs b/waysip/src/tests/mod.rs new file mode 100644 index 0000000..0dc529b --- /dev/null +++ b/waysip/src/tests/mod.rs @@ -0,0 +1,7 @@ +//! Private test module. All unit tests for waysip live here to keep main source files focused. + +mod cli; +#[cfg(feature = "freeze")] +mod freeze; +mod settings; +mod utils; diff --git a/waysip/src/tests/settings.rs b/waysip/src/tests/settings.rs new file mode 100644 index 0000000..f366db3 --- /dev/null +++ b/waysip/src/tests/settings.rs @@ -0,0 +1,119 @@ +use clap::Parser; +use libwaysip::SelectionType; + +use crate::cli::Cli; +use crate::settings::*; + +fn cli(args: &[&str]) -> Cli { + Cli::try_parse_from(std::iter::once("waysip").chain(args.iter().copied())).unwrap() +} + +#[test] +fn dispatch_point() { + let args = cli(&["-p"]); + assert!(matches!( + SelectionDispatch::from_cli(&args), + Some(SelectionDispatch::Point) + )); +} + +#[test] +fn dispatch_dimensions_and_output() { + let args = cli(&["-d", "-o"]); + assert!(matches!( + SelectionDispatch::from_cli(&args), + Some(SelectionDispatch::DimensionsOrOutput) + )); +} + +#[test] +fn dispatch_dimensions_only() { + let args = cli(&["-d"]); + assert!(matches!( + SelectionDispatch::from_cli(&args), + Some(SelectionDispatch::Area) + )); +} + +#[test] +fn dispatch_output_only() { + let args = cli(&["-o"]); + assert!(matches!( + SelectionDispatch::from_cli(&args), + Some(SelectionDispatch::Screen) + )); +} + +#[test] +fn dispatch_screen_flag() { + let args = cli(&["-i"]); + assert!(matches!( + SelectionDispatch::from_cli(&args), + Some(SelectionDispatch::Screen) + )); +} + +#[test] +fn dispatch_none_by_default() { + let args = cli(&[]); + assert!(SelectionDispatch::from_cli(&args).is_none()); +} + +#[test] +fn dispatch_ignores_boxes_flag() { + let args = cli(&["-r"]); + assert!(SelectionDispatch::from_cli(&args).is_none()); +} + +#[test] +fn selection_type_mapping() { + assert!(matches!( + SelectionDispatch::Point.selection_type(), + SelectionType::Point + )); + assert!(matches!( + SelectionDispatch::DimensionsOrOutput.selection_type(), + SelectionType::DimensionsOrOutput + )); + assert!(matches!( + SelectionDispatch::Area.selection_type(), + SelectionType::Area + )); + assert!(matches!( + SelectionDispatch::Screen.selection_type(), + SelectionType::Screen + )); +} + +#[test] +fn resolve_output_format_uses_screen_template() { + let mut args = cli(&["-i", "-f", "custom"]); + let fmt = resolve_output_format(&mut args); + assert!(fmt.starts_with("Screen : %o %d")); +} + +#[test] +fn resolve_output_format_takes_custom_format_and_clears_it() { + let mut args = cli(&["-f", "custom"]); + let fmt = resolve_output_format(&mut args); + assert_eq!(fmt, "custom"); + assert_eq!(args.format, ""); +} + +#[test] +fn resolve_output_format_default_when_unset() { + let mut args = cli(&[]); + let fmt = resolve_output_format(&mut args); + assert_eq!(fmt, "%x,%y %wx%h\n"); +} + +#[test] +fn parse_hex_color_valid() { + let color = parse_hex_color("#000000ff".to_string()); + assert_eq!((color.r, color.g, color.b, color.a), (0.0, 0.0, 0.0, 1.0)); +} + +#[test] +fn parse_aspect_ratio_valid() { + assert_eq!(parse_aspect_ratio("16:9".to_string()), (16.0, 9.0)); +} diff --git a/waysip/src/tests/utils.rs b/waysip/src/tests/utils.rs new file mode 100644 index 0000000..9b50abe --- /dev/null +++ b/waysip/src/tests/utils.rs @@ -0,0 +1,197 @@ +//! `apply_format` is pure string formatting over an `AreaInfo`, but +//! `AreaInfo::screen_info` embeds a real `WlOutput` proxy. We don't need a +//! live compositor to get one though: an "inert" proxy backed by a locally +//! paired `UnixStream` (same technique as libwaysip's own dispatch tests) +//! is enough, since `apply_format` never actually sends it any protocol +//! requests. + +use std::os::unix::net::UnixStream; + +use libwaysip::state::ScreenInfo; +use libwaysip::{AreaInfo, BoxInfo, Position, Size}; +use wayland_backend::client::Backend; +use wayland_client::protocol::wl_output::WlOutput; +use wayland_client::{Connection, Proxy}; + +use crate::utils::apply_format; + +fn dummy_conn() -> Connection { + let (client, server) = UnixStream::pair().expect("unix stream"); + Box::leak(Box::new(server)); + let backend = Backend::connect(client).expect("backend"); + Connection::from_backend(backend) +} + +#[allow(clippy::too_many_arguments)] +fn area_info( + conn: &Connection, + box_info: BoxInfo, + screen_pos: (i32, i32), + screen_size: (i32, i32), + output_size: (i32, i32), + name: &str, + description: &str, +) -> AreaInfo { + AreaInfo { + box_info, + screen_info: ScreenInfo { + position: Position { + x: screen_pos.0, + y: screen_pos.1, + }, + screen_size: Size { + width: screen_size.0, + height: screen_size.1, + }, + wl_output: WlOutput::inert(conn.backend().downgrade()), + output_size: Size { + width: output_size.0, + height: output_size.1, + }, + name: name.to_string(), + description: description.to_string(), + }, + effective_selection_type: None, + #[cfg(feature = "benchmark")] + timestamps_total: Vec::new(), + } +} + +fn box_info(start_x: f64, start_y: f64, end_x: f64, end_y: f64) -> BoxInfo { + BoxInfo { + start_x, + start_y, + end_x, + end_y, + } +} + +#[test] +fn basic_placeholders_use_selection_bounds() { + let conn = dummy_conn(); + let info = area_info( + &conn, + box_info(10.0, 20.0, 110.0, 170.0), + (0, 0), + (1920, 1080), + (1920, 1080), + "DP-1", + "Some Monitor", + ); + let out = apply_format(&info, "%x,%y %wx%h", false); + assert_eq!(out, "10,20 100x150"); +} + +#[test] +fn relative_placeholders_are_clamped_to_screen_bounds() { + let conn = dummy_conn(); + // Screen starts at (100,100), sized 200x200; selection spills past the + // screen's right/bottom edge. + let info = area_info( + &conn, + box_info(250.0, 250.0, 400.0, 400.0), + (100, 100), + (200, 200), + (200, 200), + "DP-1", + "", + ); + let out = apply_format(&info, "%X,%Y %Wx%H", false); + assert_eq!(out, "150,150 50x50"); +} + +#[test] +fn output_name_and_description_placeholders() { + let conn = dummy_conn(); + let info = area_info( + &conn, + box_info(0.0, 0.0, 10.0, 10.0), + (0, 0), + (100, 100), + (100, 100), + "DP-1", + "My Monitor", + ); + let out = apply_format(&info, "%o|%l|%d", false); + assert_eq!(out, "DP-1|DP-1|My Monitor"); +} + +#[test] +fn wloutput_size_placeholders() { + let conn = dummy_conn(); + let info = area_info( + &conn, + box_info(0.0, 0.0, 10.0, 10.0), + (0, 0), + (1920, 1080), + (3840, 2160), + "DP-1", + "", + ); + let out = apply_format(&info, "%Lx%T", false); + assert_eq!(out, "3840x2160"); +} + +#[test] +fn screen_mode_uses_screen_bounds_instead_of_selection() { + let conn = dummy_conn(); + let info = area_info( + &conn, + box_info(999.0, 999.0, 1000.0, 1000.0), + (5, 5), + (800, 600), + (800, 600), + "DP-1", + "", + ); + let out = apply_format(&info, "%x,%y %wx%h", true); + assert_eq!(out, "5,5 800x600"); +} + +#[test] +fn literal_percent_and_escapes() { + let conn = dummy_conn(); + let info = area_info( + &conn, + box_info(0.0, 0.0, 1.0, 1.0), + (0, 0), + (10, 10), + (10, 10), + "DP-1", + "", + ); + let out = apply_format(&info, r"100%%\n\\end", false); + assert_eq!(out, "100%\n\\end"); +} + +#[test] +fn unknown_percent_specifier_passes_through() { + let conn = dummy_conn(); + let info = area_info( + &conn, + box_info(0.0, 0.0, 1.0, 1.0), + (0, 0), + (10, 10), + (10, 10), + "DP-1", + "", + ); + let out = apply_format(&info, "%z", false); + assert_eq!(out, "z"); +} + +#[test] +fn zero_size_selection_clamps_to_one_pixel() { + let conn = dummy_conn(); + let info = area_info( + &conn, + box_info(5.0, 5.0, 5.0, 5.0), + (0, 0), + (100, 100), + (100, 100), + "DP-1", + "", + ); + let out = apply_format(&info, "%wx%h", false); + assert_eq!(out, "1x1"); +} From c70d81047613da60a49fb371c4ee00426760da9a Mon Sep 17 00:00:00 2001 From: gigas002 Date: Thu, 6 Aug 2026 11:15:02 +0900 Subject: [PATCH 7/7] feature: add waysip integration tests --- waysip/tests/cli_integration.rs | 224 ++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 waysip/tests/cli_integration.rs diff --git a/waysip/tests/cli_integration.rs b/waysip/tests/cli_integration.rs new file mode 100644 index 0000000..baacf76 --- /dev/null +++ b/waysip/tests/cli_integration.rs @@ -0,0 +1,224 @@ +//! Integration tests that exercise the compiled `waysip` binary directly. +//! +//! These cover clap's `--help`/`--version`/`--completions` early-exit paths +//! (which never touch Wayland), the clap usage-error exit path for +//! conflicting flags, and the "no compositor available" error path -- +//! verifying the binary fails cleanly instead of panicking when it can't +//! connect to Wayland. None of this requires a running compositor, so it's +//! safe in headless CI. `WAYLAND_DISPLAY`/`WAYLAND_SOCKET` are stripped +//! explicitly so the tests behave the same on a machine that does have a +//! compositor running. + +use std::io::Write; +use std::process::{Command, Stdio}; + +fn waysip_cmd() -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_waysip")); + cmd.env_remove("WAYLAND_DISPLAY"); + cmd.env_remove("WAYLAND_SOCKET"); + cmd +} + +#[test] +fn help_flag_exits_successfully_without_a_compositor() { + let output = waysip_cmd() + .arg("--help") + .output() + .expect("failed to run waysip binary"); + assert!(output.status.success()); + assert!(!output.stdout.is_empty()); +} + +#[test] +fn version_flag_prints_version_and_exits_successfully() { + let output = waysip_cmd() + .arg("--version") + .output() + .expect("failed to run waysip binary"); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("waysip")); +} + +#[cfg(feature = "completions")] +#[test] +fn completions_flag_prints_a_script_without_touching_wayland() { + let output = waysip_cmd() + .args(["--completions", "bash"]) + .output() + .expect("failed to run waysip binary"); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("waysip")); +} + +#[test] +fn conflicting_flags_exit_with_a_usage_error_before_touching_wayland() { + let output = waysip_cmd() + .args(["-p", "-d"]) + .output() + .expect("failed to run waysip binary"); + assert!(!output.status.success()); + assert!(!output.stderr.is_empty()); +} + +#[test] +fn without_a_compositor_it_fails_cleanly_instead_of_panicking() { + let output = waysip_cmd() + .arg("-p") + .output() + .expect("failed to run waysip binary"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.is_empty()); + assert!(!stderr.contains("panicked at")); +} + +// ─── Bad CLI-value error paths ──────────────────────────────────────────── +// +// These fail during argument validation in `run_selection`, before +// `WaySip::get()` ever tries to connect to Wayland, so they're safe to run +// without a compositor even though they use a selection-mode flag. + +#[test] +fn invalid_background_color_fails_cleanly_before_touching_wayland() { + let output = waysip_cmd() + .args(["-p", "-b", "not-a-color"]) + .output() + .expect("failed to run waysip binary"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.is_empty()); + assert!(!stderr.contains("panicked at")); +} + +#[test] +fn invalid_border_weight_fails_cleanly_before_touching_wayland() { + let output = waysip_cmd() + .args(["-p", "-w", "not-a-number"]) + .output() + .expect("failed to run waysip binary"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.is_empty()); + assert!(!stderr.contains("panicked at")); +} + +#[test] +fn invalid_aspect_ratio_format_fails_cleanly_before_touching_wayland() { + // `-a` conflicts with `-p`, so this needs a mode compatible with it. + // Three ':'-separated parts hits the "wrong part count" branch. + let output = waysip_cmd() + .args(["-d", "-a", "not:a:ratio"]) + .output() + .expect("failed to run waysip binary"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.is_empty()); + assert!(!stderr.contains("panicked at")); +} + +#[test] +fn invalid_aspect_ratio_width_fails_cleanly_before_touching_wayland() { + // Two parts, but the width half isn't a number. + let output = waysip_cmd() + .args(["-d", "-a", "bad:9"]) + .output() + .expect("failed to run waysip binary"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.is_empty()); + assert!(!stderr.contains("panicked at")); +} + +#[test] +fn invalid_aspect_ratio_height_fails_cleanly_before_touching_wayland() { + // Two parts, but the height half isn't a number. + let output = waysip_cmd() + .args(["-d", "-a", "16:bad"]) + .output() + .expect("failed to run waysip binary"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.is_empty()); + assert!(!stderr.contains("panicked at")); +} + +// ─── Flags that reach further into `run_selection` before failing ──────── +// +// These pass argument validation cleanly and reach `edit_selection`/ +// `bench`/`freeze` handling (and, for `--freeze`, its own no-compositor +// fallback inside `capture_backgrounds`) before failing at the same +// `WaySip::get()` connection step as the plain no-compositor case. + +#[test] +fn edit_selection_flag_still_fails_cleanly_without_a_compositor() { + // `-e` conflicts with `-p`, so this needs a mode compatible with it. + let output = waysip_cmd() + .args(["-d", "-e", "--edit-selection-key", "5"]) + .output() + .expect("failed to run waysip binary"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.is_empty()); + assert!(!stderr.contains("panicked at")); +} + +#[cfg(feature = "benchmark")] +#[test] +fn bench_flag_still_fails_cleanly_without_a_compositor() { + let output = waysip_cmd() + .args(["-p", "--bench"]) + .output() + .expect("failed to run waysip binary"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.is_empty()); + assert!(!stderr.contains("panicked at")); +} + +#[cfg(feature = "freeze")] +#[test] +fn freeze_flag_still_fails_cleanly_without_a_compositor() { + let output = waysip_cmd() + .args(["-p", "--freeze"]) + .output() + .expect("failed to run waysip binary"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.is_empty()); + assert!(!stderr.contains("panicked at")); +} + +// ─── Predefined boxes (stdin) ────────────────────────────────────────────── + +#[test] +fn boxes_flag_reads_piped_stdin_then_fails_cleanly_without_a_compositor() { + let mut child = waysip_cmd() + .arg("-r") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn waysip binary"); + + child + .stdin + .take() + .expect("stdin should be piped") + .write_all(b"10,10 50x50\n") + .expect("failed to write to child stdin"); + + let output = child + .wait_with_output() + .expect("failed to wait on waysip binary"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.is_empty()); + assert!(!stderr.contains("panicked at")); + // Specifically: it got past stdin parsing (not the "no piped stdin" / + // "stdin is empty" early-exit messages) and failed at the Wayland + // connection step instead. + assert!(!stderr.contains("No piped stdin")); + assert!(!stderr.contains("Stdin is empty")); +}