diff --git a/configurator/src/app/entry.rs b/configurator/src/app/entry.rs index 280402a2..ef750490 100644 --- a/configurator/src/app/entry.rs +++ b/configurator/src/app/entry.rs @@ -30,3 +30,57 @@ fn should_force_tiny_skia() -> bool { let combined = combined.to_ascii_lowercase(); combined.contains("gnome") || combined.contains("ubuntu") } + +#[cfg(test)] +mod tests { + use std::sync::{Mutex, OnceLock}; + + use super::*; + + fn env_mutex() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + #[test] + fn should_force_tiny_skia_requires_wayland_and_gnome_like_desktop() { + let _guard = env_mutex().lock().unwrap(); + let original_wayland = std::env::var_os("WAYLAND_DISPLAY"); + let original_current = std::env::var_os("XDG_CURRENT_DESKTOP"); + let original_session = std::env::var_os("XDG_SESSION_DESKTOP"); + + // SAFETY: serialized by env mutex in this test module. + unsafe { + std::env::set_var("WAYLAND_DISPLAY", "wayland-0"); + std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME"); + std::env::set_var("XDG_SESSION_DESKTOP", ""); + } + assert!(should_force_tiny_skia()); + + // SAFETY: serialized by env mutex in this test module. + unsafe { + std::env::set_var("XDG_CURRENT_DESKTOP", "KDE"); + std::env::set_var("XDG_SESSION_DESKTOP", "plasma"); + } + assert!(!should_force_tiny_skia()); + + // SAFETY: serialized by env mutex in this test module. + unsafe { + std::env::remove_var("WAYLAND_DISPLAY"); + } + assert!(!should_force_tiny_skia()); + + match original_wayland { + Some(value) => unsafe { std::env::set_var("WAYLAND_DISPLAY", value) }, + None => unsafe { std::env::remove_var("WAYLAND_DISPLAY") }, + } + match original_current { + Some(value) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", value) }, + None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") }, + } + match original_session { + Some(value) => unsafe { std::env::set_var("XDG_SESSION_DESKTOP", value) }, + None => unsafe { std::env::remove_var("XDG_SESSION_DESKTOP") }, + } + } +} diff --git a/configurator/src/app/state.rs b/configurator/src/app/state.rs index 8304efd6..7f5716da 100644 --- a/configurator/src/app/state.rs +++ b/configurator/src/app/state.rs @@ -122,3 +122,54 @@ impl ConfiguratorApp { self.is_dirty = self.draft != self.baseline; } } + +#[cfg(test)] +mod tests { + use std::time::SystemTime; + + use super::*; + + fn temp_config_path(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "wayscriber-configurator-state-{}-{name}.toml", + std::process::id() + )) + } + + #[test] + fn refresh_dirty_flag_tracks_draft_vs_baseline() { + let (mut app, _cmd) = ConfiguratorApp::new_app(); + app.refresh_dirty_flag(); + assert!(!app.is_dirty); + + app.draft.capture_enabled = !app.draft.capture_enabled; + app.refresh_dirty_flag(); + assert!(app.is_dirty); + } + + #[test] + fn config_changed_on_disk_detects_newer_file() { + let (mut app, _cmd) = ConfiguratorApp::new_app(); + let path = temp_config_path("mtime"); + std::fs::write(&path, "test").expect("write config"); + + app.config_path = Some(path.clone()); + app.config_mtime = Some(SystemTime::UNIX_EPOCH); + + assert!(app.config_changed_on_disk()); + + let _ = std::fs::remove_file(path); + } + + #[test] + fn config_changed_on_disk_returns_false_without_path_or_mtime() { + let (mut app, _cmd) = ConfiguratorApp::new_app(); + app.config_path = None; + app.config_mtime = Some(SystemTime::UNIX_EPOCH); + assert!(!app.config_changed_on_disk()); + + app.config_path = Some(temp_config_path("missing")); + app.config_mtime = None; + assert!(!app.config_changed_on_disk()); + } +} diff --git a/configurator/src/app/update/boards.rs b/configurator/src/app/update/boards.rs index c5dadf4c..12893d0a 100644 --- a/configurator/src/app/update/boards.rs +++ b/configurator/src/app/update/boards.rs @@ -221,3 +221,52 @@ impl ConfiguratorApp { self.sync_board_color_picker_hex(); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::ColorPickerId; + + #[test] + fn add_item_updates_boards_and_collapsed_state() { + let (mut app, _cmd) = ConfiguratorApp::new_app(); + let before = app.draft.boards.items.len(); + + let _ = app.handle_boards_add_item(); + + assert_eq!(app.draft.boards.items.len(), before + 1); + assert_eq!(app.boards_collapsed.len(), app.draft.boards.items.len()); + assert!(app.is_dirty); + } + + #[test] + fn duplicate_item_inserts_copy_with_new_id() { + let (mut app, _cmd) = ConfiguratorApp::new_app(); + let before = app.draft.boards.items.len(); + let original_id = app + .draft + .boards + .items + .first() + .map(|item| item.id.clone()) + .unwrap_or_default(); + + let _ = app.handle_boards_duplicate_item(0); + + assert_eq!(app.draft.boards.items.len(), before + 1); + assert_eq!(app.boards_collapsed.len(), app.draft.boards.items.len()); + assert_ne!(app.draft.boards.items[1].id, original_id); + } + + #[test] + fn remove_item_clears_board_color_picker_when_targeting_board_entry() { + let (mut app, _cmd) = ConfiguratorApp::new_app(); + let _ = app.handle_boards_add_item(); + app.color_picker_open = Some(ColorPickerId::BoardBackground(0)); + + let _ = app.handle_boards_remove_item(0); + + assert!(app.color_picker_open.is_none()); + assert_eq!(app.boards_collapsed.len(), app.draft.boards.items.len()); + } +} diff --git a/configurator/src/app/update/config.rs b/configurator/src/app/update/config.rs index 0029d208..29d90df0 100644 --- a/configurator/src/app/update/config.rs +++ b/configurator/src/app/update/config.rs @@ -131,3 +131,110 @@ impl ConfiguratorApp { Command::none() } } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + use std::time::SystemTime; + + use super::*; + use crate::models::ColorPickerId; + + fn status_contains(status: &StatusMessage, needle: &str) -> bool { + match status { + StatusMessage::Info(text) + | StatusMessage::Success(text) + | StatusMessage::Error(text) => text.contains(needle), + StatusMessage::Idle => false, + } + } + + fn temp_config_path(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "wayscriber-configurator-update-config-{}-{name}.toml", + std::process::id() + )) + } + + #[test] + fn handle_config_loaded_success_resets_loading_and_dirty_state() { + let (mut app, _cmd) = ConfiguratorApp::new_app(); + app.color_picker_open = Some(ColorPickerId::StatusBarBg); + app.is_dirty = true; + + let _ = app.handle_config_loaded(Ok(Arc::new(Config::default()))); + + assert!(!app.is_loading); + assert!(!app.is_dirty); + assert!(app.color_picker_open.is_none()); + assert_eq!(app.boards_collapsed.len(), app.draft.boards.items.len()); + assert!(status_contains( + &app.status, + "Configuration loaded from disk." + )); + } + + #[test] + fn handle_config_loaded_error_updates_status() { + let (mut app, _cmd) = ConfiguratorApp::new_app(); + let _ = app.handle_config_loaded(Err("broken".to_string())); + + assert!(!app.is_loading); + assert!(status_contains( + &app.status, + "Failed to load config from disk: broken" + )); + } + + #[test] + fn handle_save_requested_blocks_when_config_changed_on_disk() { + let (mut app, _cmd) = ConfiguratorApp::new_app(); + let path = temp_config_path("mtime"); + std::fs::write(&path, "x").expect("write config"); + app.config_path = Some(path.clone()); + app.config_mtime = Some(SystemTime::UNIX_EPOCH); + + let _ = app.handle_save_requested(); + + assert!(!app.is_saving); + assert!(status_contains( + &app.status, + "Configuration changed on disk. Reload before saving." + )); + let _ = std::fs::remove_file(path); + } + + #[test] + fn handle_save_requested_sets_saving_for_valid_draft() { + let (mut app, _cmd) = ConfiguratorApp::new_app(); + app.is_saving = false; + app.config_path = None; + app.config_mtime = None; + + let _ = app.handle_save_requested(); + + assert!(app.is_saving); + assert!(status_contains(&app.status, "Saving configuration...")); + } + + #[test] + fn handle_config_saved_success_clears_dirty_and_records_backup() { + let (mut app, _cmd) = ConfiguratorApp::new_app(); + app.is_saving = true; + app.is_dirty = true; + app.draft.capture_enabled = !app.draft.capture_enabled; + let backup = PathBuf::from("/tmp/wayscriber-config.bak"); + + let _ = app.handle_config_saved(Ok((Some(backup.clone()), Arc::new(Config::default())))); + + assert!(!app.is_saving); + assert!(!app.is_dirty); + assert_eq!(app.last_backup_path, Some(backup)); + assert_eq!(app.draft, app.baseline); + assert!(status_contains( + &app.status, + "Configuration saved successfully." + )); + } +} diff --git a/configurator/src/app/view/widgets/validation.rs b/configurator/src/app/view/widgets/validation.rs index 9c82cad3..286e8532 100644 --- a/configurator/src/app/view/widgets/validation.rs +++ b/configurator/src/app/view/widgets/validation.rs @@ -115,3 +115,57 @@ pub(in crate::app::view) fn validate_usize_min(value: &str, min: usize) -> Optio Err(_) => Some("Expected a whole number".to_string()), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_f64_range_accepts_bounds_and_rejects_outside() { + assert_eq!(validate_f64_range("0.5", 0.5, 2.0), None); + assert_eq!(validate_f64_range("2.0", 0.5, 2.0), None); + assert_eq!( + validate_f64_range("2.1", 0.5, 2.0), + Some("Range: 0.5-2".to_string()) + ); + assert_eq!( + validate_f64_range("", 0.5, 2.0), + Some("Expected a numeric value".to_string()) + ); + } + + #[test] + fn validate_u32_range_reports_expected_errors() { + assert_eq!(validate_u32_range("10", 1, 100), None); + assert_eq!( + validate_u32_range("0", 1, 100), + Some("Range: 1-100".to_string()) + ); + assert_eq!( + validate_u32_range("abc", 1, 100), + Some("Expected a whole number".to_string()) + ); + } + + #[test] + fn validate_u64_helpers_enforce_range_and_minimum() { + assert_eq!(validate_u64_range("5", 1, 10), None); + assert_eq!( + validate_u64_range("11", 1, 10), + Some("Range: 1-10".to_string()) + ); + assert_eq!(validate_u64_min("10", 10), None); + assert_eq!(validate_u64_min("9", 10), Some("Minimum: 10".to_string())); + } + + #[test] + fn validate_usize_helpers_enforce_range_and_minimum() { + assert_eq!(validate_usize_range("3", 1, 5), None); + assert_eq!( + validate_usize_range("6", 1, 5), + Some("Range: 1-5".to_string()) + ); + assert_eq!(validate_usize_min("3", 3), None); + assert_eq!(validate_usize_min("2", 3), Some("Minimum: 3".to_string())); + } +} diff --git a/src/about_window/clipboard.rs b/src/about_window/clipboard.rs index 9e5507c9..faf2c6c3 100644 --- a/src/about_window/clipboard.rs +++ b/src/about_window/clipboard.rs @@ -29,17 +29,31 @@ pub(super) fn copy_text_to_clipboard(text: &str) { if text.is_empty() { return; } + let text = text.to_string(); std::thread::spawn(move || { - if copy_text_via_command(&text).is_ok() { - return; - } - if let Err(err) = copy_text_via_library(&text) { + if let Err(err) = + copy_text_with_backends(&text, copy_text_via_command, copy_text_via_library) + { warn!("Failed to copy commit id to clipboard: {}", err); } }); } +fn copy_text_with_backends(text: &str, command_copy: C, library_copy: L) -> Result<()> +where + C: Fn(&str) -> Result<()>, + L: Fn(&str) -> Result<()>, +{ + if text.is_empty() { + return Ok(()); + } + if command_copy(text).is_ok() { + return Ok(()); + } + library_copy(text) +} + fn copy_text_via_library(text: &str) -> Result<()> { use wl_clipboard_rs::copy::{MimeType, Options, ServeRequests, Source}; @@ -78,3 +92,88 @@ fn copy_text_via_command(text: &str) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + #[test] + fn copy_text_with_backends_short_circuits_for_empty_text() { + let command_calls = AtomicUsize::new(0); + let library_calls = AtomicUsize::new(0); + + copy_text_with_backends( + "", + |_| { + command_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + |_| { + library_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(command_calls.load(Ordering::SeqCst), 0); + assert_eq!(library_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn copy_text_with_backends_uses_command_when_available() { + let command_calls = AtomicUsize::new(0); + let library_calls = AtomicUsize::new(0); + + copy_text_with_backends( + "abc123", + |_| { + command_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + |_| { + library_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(command_calls.load(Ordering::SeqCst), 1); + assert_eq!(library_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn copy_text_with_backends_falls_back_to_library() { + let command_calls = AtomicUsize::new(0); + let library_calls = AtomicUsize::new(0); + + copy_text_with_backends( + "abc123", + |_| { + command_calls.fetch_add(1, Ordering::SeqCst); + Err(anyhow::anyhow!("command failed")) + }, + |_| { + library_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .unwrap(); + + assert_eq!(command_calls.load(Ordering::SeqCst), 1); + assert_eq!(library_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn copy_text_with_backends_returns_library_error_when_both_fail() { + let err = copy_text_with_backends( + "abc123", + |_| Err(anyhow::anyhow!("command failed")), + |_| Err(anyhow::anyhow!("library failed")), + ) + .unwrap_err(); + + assert!(err.to_string().contains("library failed")); + } +} diff --git a/src/about_window/state.rs b/src/about_window/state.rs index 4f97d4e6..4eb8ac90 100644 --- a/src/about_window/state.rs +++ b/src/about_window/state.rs @@ -4,6 +4,19 @@ use wayland_client::Connection; use super::{ABOUT_HEIGHT, ABOUT_WIDTH, AboutWindowState}; +fn link_index_at_impl(links: &[super::LinkRegion], pos: (f64, f64)) -> Option { + links.iter().position(|link| link.contains(pos)) +} + +fn update_hover_index( + links: &[super::LinkRegion], + current: Option, + pos: (f64, f64), +) -> (Option, bool) { + let next = link_index_at_impl(links, pos); + (next, next != current) +} + impl AboutWindowState { pub(super) fn new( registry_state: super::RegistryState, @@ -36,12 +49,12 @@ impl AboutWindowState { } pub(super) fn link_index_at(&self, pos: (f64, f64)) -> Option { - self.link_regions.iter().position(|link| link.contains(pos)) + link_index_at_impl(&self.link_regions, pos) } pub(super) fn update_hover(&mut self, pos: (f64, f64)) { - let next = self.link_index_at(pos); - if next != self.hover_index { + let (next, changed) = update_hover_index(&self.link_regions, self.hover_index, pos); + if changed { self.hover_index = next; self.needs_redraw = true; } @@ -60,3 +73,51 @@ impl AboutWindowState { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_links() -> Vec { + vec![ + super::super::LinkRegion { + rect: (10.0, 10.0, 40.0, 20.0), + action: super::super::LinkAction::Close, + }, + super::super::LinkRegion { + rect: (70.0, 12.0, 30.0, 30.0), + action: super::super::LinkAction::OpenUrl("https://example.com".to_string()), + }, + ] + } + + #[test] + fn link_index_at_finds_matching_region() { + let links = sample_links(); + + assert_eq!(link_index_at_impl(&links, (15.0, 15.0)), Some(0)); + assert_eq!(link_index_at_impl(&links, (90.0, 30.0)), Some(1)); + assert_eq!(link_index_at_impl(&links, (0.0, 0.0)), None); + } + + #[test] + fn update_hover_index_reports_when_hover_changed() { + let links = sample_links(); + + let (next, changed) = update_hover_index(&links, None, (15.0, 15.0)); + assert_eq!(next, Some(0)); + assert!(changed); + + let (next, changed) = update_hover_index(&links, Some(0), (16.0, 16.0)); + assert_eq!(next, Some(0)); + assert!(!changed); + + let (next, changed) = update_hover_index(&links, Some(0), (90.0, 30.0)); + assert_eq!(next, Some(1)); + assert!(changed); + + let (next, changed) = update_hover_index(&links, Some(1), (1.0, 1.0)); + assert_eq!(next, None); + assert!(changed); + } +} diff --git a/src/backend/wayland/backend/event_loop/dispatch.rs b/src/backend/wayland/backend/event_loop/dispatch.rs index b5ab7099..944d87e2 100644 --- a/src/backend/wayland/backend/event_loop/dispatch.rs +++ b/src/backend/wayland/backend/event_loop/dispatch.rs @@ -1,40 +1,195 @@ +use std::time::Duration; + use wayland_client::{EventQueue, backend::WaylandError}; use super::super::super::state::WaylandState; use super::super::helpers::dispatch_with_timeout; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CaptureReadOutcome { + Readable, + WouldBlock, +} + +trait CaptureDispatchOps { + fn dispatch_pending(&mut self) -> Result<(), anyhow::Error>; + fn flush(&mut self) -> Result<(), anyhow::Error>; + fn prepare_read(&mut self) -> Result, anyhow::Error>; +} + +struct RealCaptureDispatchOps<'a> { + event_queue: &'a mut EventQueue, + state: &'a mut WaylandState, +} + +impl CaptureDispatchOps for RealCaptureDispatchOps<'_> { + fn dispatch_pending(&mut self) -> Result<(), anyhow::Error> { + self.event_queue + .dispatch_pending(self.state) + .map(|_| ()) + .map_err(|e| anyhow::anyhow!("Wayland event queue error: {}", e)) + } + + fn flush(&mut self) -> Result<(), anyhow::Error> { + self.event_queue + .flush() + .map_err(|e| anyhow::anyhow!("Wayland flush error: {}", e)) + } + + fn prepare_read(&mut self) -> Result, anyhow::Error> { + let Some(guard) = self.event_queue.prepare_read() else { + return Ok(None); + }; + + match guard.read() { + Ok(_) => Ok(Some(CaptureReadOutcome::Readable)), + Err(WaylandError::Io(err)) if err.kind() == std::io::ErrorKind::WouldBlock => { + Ok(Some(CaptureReadOutcome::WouldBlock)) + } + Err(err) => Err(anyhow::anyhow!("Wayland read error: {}", err)), + } + } +} + +fn dispatch_capture_active(ops: &mut impl CaptureDispatchOps) -> Result<(), anyhow::Error> { + ops.dispatch_pending()?; + ops.flush()?; + + if matches!(ops.prepare_read()?, Some(CaptureReadOutcome::Readable)) { + ops.dispatch_pending()?; + } + + Ok(()) +} + pub(super) fn dispatch_events( event_queue: &mut EventQueue, state: &mut WaylandState, capture_active: bool, - animation_timeout: Option, + animation_timeout: Option, ) -> Result<(), anyhow::Error> { if capture_active { - if let Err(e) = event_queue.dispatch_pending(state) { - return Err(anyhow::anyhow!("Wayland event queue error: {}", e)); + let mut ops = RealCaptureDispatchOps { event_queue, state }; + dispatch_capture_active(&mut ops) + } else { + dispatch_with_timeout(event_queue, state, animation_timeout) + .map_err(|e| anyhow::anyhow!("Wayland event queue error: {}", e)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct FakeCaptureDispatchOps { + dispatch_calls: usize, + flush_calls: usize, + prepare_calls: usize, + dispatch_error_on_call: Option, + flush_error: Option, + prepare_result: Result, anyhow::Error>, + } + + impl FakeCaptureDispatchOps { + fn new(prepare_result: Result, anyhow::Error>) -> Self { + Self { + dispatch_calls: 0, + flush_calls: 0, + prepare_calls: 0, + dispatch_error_on_call: None, + flush_error: None, + prepare_result, + } } + } - if let Err(e) = event_queue.flush() { - return Err(anyhow::anyhow!("Wayland flush error: {}", e)); + impl CaptureDispatchOps for FakeCaptureDispatchOps { + fn dispatch_pending(&mut self) -> Result<(), anyhow::Error> { + self.dispatch_calls += 1; + if self.dispatch_error_on_call == Some(self.dispatch_calls) { + return Err(anyhow::anyhow!("dispatch failed")); + } + Ok(()) } - if let Some(guard) = event_queue.prepare_read() { - match guard.read() { - Ok(_) => { - if let Err(e) = event_queue.dispatch_pending(state) { - return Err(anyhow::anyhow!("Wayland event queue error: {}", e)); - } - } - Err(WaylandError::Io(err)) if err.kind() == std::io::ErrorKind::WouldBlock => {} - Err(err) => { - return Err(anyhow::anyhow!("Wayland read error: {}", err)); - } + fn flush(&mut self) -> Result<(), anyhow::Error> { + self.flush_calls += 1; + if let Some(err) = self.flush_error.take() { + return Err(err); } + Ok(()) } - Ok(()) - } else { - dispatch_with_timeout(event_queue, state, animation_timeout) - .map_err(|e| anyhow::anyhow!("Wayland event queue error: {}", e)) + fn prepare_read(&mut self) -> Result, anyhow::Error> { + self.prepare_calls += 1; + match &self.prepare_result { + Ok(value) => Ok(*value), + Err(err) => Err(anyhow::anyhow!(err.to_string())), + } + } + } + + #[test] + fn capture_dispatch_reads_and_dispatches_again() { + let mut ops = FakeCaptureDispatchOps::new(Ok(Some(CaptureReadOutcome::Readable))); + dispatch_capture_active(&mut ops).unwrap(); + + assert_eq!(ops.dispatch_calls, 2); + assert_eq!(ops.flush_calls, 1); + assert_eq!(ops.prepare_calls, 1); + } + + #[test] + fn capture_dispatch_would_block_skips_second_dispatch() { + let mut ops = FakeCaptureDispatchOps::new(Ok(Some(CaptureReadOutcome::WouldBlock))); + dispatch_capture_active(&mut ops).unwrap(); + + assert_eq!(ops.dispatch_calls, 1); + assert_eq!(ops.flush_calls, 1); + assert_eq!(ops.prepare_calls, 1); + } + + #[test] + fn capture_dispatch_without_prepared_read_dispatches_once() { + let mut ops = FakeCaptureDispatchOps::new(Ok(None)); + dispatch_capture_active(&mut ops).unwrap(); + + assert_eq!(ops.dispatch_calls, 1); + assert_eq!(ops.flush_calls, 1); + assert_eq!(ops.prepare_calls, 1); + } + + #[test] + fn capture_dispatch_propagates_flush_error() { + let mut ops = FakeCaptureDispatchOps::new(Ok(None)); + ops.flush_error = Some(anyhow::anyhow!("flush failed")); + + let err = dispatch_capture_active(&mut ops).unwrap_err(); + assert!(err.to_string().contains("flush failed")); + assert_eq!(ops.dispatch_calls, 1); + assert_eq!(ops.prepare_calls, 0); + } + + #[test] + fn capture_dispatch_propagates_read_error() { + let mut ops = FakeCaptureDispatchOps::new(Err(anyhow::anyhow!("read failed"))); + + let err = dispatch_capture_active(&mut ops).unwrap_err(); + assert!(err.to_string().contains("read failed")); + assert_eq!(ops.dispatch_calls, 1); + assert_eq!(ops.flush_calls, 1); + assert_eq!(ops.prepare_calls, 1); + } + + #[test] + fn capture_dispatch_propagates_second_dispatch_error() { + let mut ops = FakeCaptureDispatchOps::new(Ok(Some(CaptureReadOutcome::Readable))); + ops.dispatch_error_on_call = Some(2); + + let err = dispatch_capture_active(&mut ops).unwrap_err(); + assert!(err.to_string().contains("dispatch failed")); + assert_eq!(ops.dispatch_calls, 2); + assert_eq!(ops.flush_calls, 1); + assert_eq!(ops.prepare_calls, 1); } } diff --git a/src/backend/wayland/backend/event_loop/render.rs b/src/backend/wayland/backend/event_loop/render.rs index ada09098..e1d6372a 100644 --- a/src/backend/wayland/backend/event_loop/render.rs +++ b/src/backend/wayland/backend/event_loop/render.rs @@ -31,6 +31,30 @@ pub(super) fn frame_rate_cap_timeout( } } +fn handle_render_failure( + consecutive_render_failures: &mut u32, + needs_redraw: &mut bool, + err: &anyhow::Error, +) -> Option { + *consecutive_render_failures += 1; + warn!( + "Rendering error (attempt {}/{}): {}", + *consecutive_render_failures, MAX_RENDER_FAILURES, err + ); + + if *consecutive_render_failures >= MAX_RENDER_FAILURES { + return Some(anyhow::anyhow!( + "Too many consecutive render failures ({}), exiting: {}", + *consecutive_render_failures, + err + )); + } + + // Clear redraw flag to avoid infinite error loop. + *needs_redraw = false; + None +} + pub(super) fn maybe_render( state: &mut WaylandState, qh: &wayland_client::QueueHandle, @@ -86,22 +110,13 @@ pub(super) fn maybe_render( } } Err(e) => { - *consecutive_render_failures += 1; - warn!( - "Rendering error (attempt {}/{}): {}", - *consecutive_render_failures, MAX_RENDER_FAILURES, e - ); - - if *consecutive_render_failures >= MAX_RENDER_FAILURES { - return Some(anyhow::anyhow!( - "Too many consecutive render failures ({}), exiting: {}", - *consecutive_render_failures, - e - )); + if let Some(err) = handle_render_failure( + consecutive_render_failures, + &mut state.input_state.needs_redraw, + &e, + ) { + return Some(err); } - - // Clear redraw flag to avoid infinite error loop. - state.input_state.needs_redraw = false; } } } else { @@ -120,3 +135,58 @@ pub(super) fn maybe_render( None } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_rate_cap_timeout_returns_none_when_unlimited_or_missing_last_frame() { + assert_eq!(frame_rate_cap_timeout(0, None), None); + assert_eq!(frame_rate_cap_timeout(60, None), None); + } + + #[test] + fn frame_rate_cap_timeout_returns_remaining_budget_when_called_too_soon() { + let timeout = frame_rate_cap_timeout(60, Some(Instant::now())).expect("timeout"); + assert!(timeout > Duration::ZERO); + assert!(timeout <= Duration::from_millis(17)); + } + + #[test] + fn frame_rate_cap_timeout_returns_none_when_budget_elapsed() { + let last = Instant::now() - Duration::from_millis(20); + assert_eq!(frame_rate_cap_timeout(60, Some(last)), None); + } + + #[test] + fn handle_render_failure_increments_counter_and_clears_redraw() { + let mut failures = 0; + let mut needs_redraw = true; + let err = anyhow::anyhow!("render failed"); + + let fatal = handle_render_failure(&mut failures, &mut needs_redraw, &err); + + assert!(fatal.is_none()); + assert_eq!(failures, 1); + assert!(!needs_redraw); + } + + #[test] + fn handle_render_failure_returns_fatal_error_at_limit() { + let mut failures = MAX_RENDER_FAILURES - 1; + let mut needs_redraw = true; + let err = anyhow::anyhow!("render failed"); + + let fatal = handle_render_failure(&mut failures, &mut needs_redraw, &err) + .expect("should fail at limit"); + + assert_eq!(failures, MAX_RENDER_FAILURES); + assert!( + fatal + .to_string() + .contains("Too many consecutive render failures") + ); + assert!(needs_redraw); + } +} diff --git a/src/backend/wayland/backend/event_loop/session_save.rs b/src/backend/wayland/backend/event_loop/session_save.rs index 8bb13456..4b58a57f 100644 --- a/src/backend/wayland/backend/event_loop/session_save.rs +++ b/src/backend/wayland/backend/event_loop/session_save.rs @@ -34,16 +34,9 @@ pub(super) fn autosave_if_due(state: &mut WaylandState, now: Instant) -> Result< &options, session::snapshot_from_input(&state.input_state, &options), ) { - Ok(saved) => { - if saved { - state.session.mark_saved(now); - } - } + Ok(saved) => record_autosave_success(&mut state.session, now, saved), Err(err) => { - if state - .session - .mark_autosave_failure(now, options.autosave_failure_backoff) - { + if record_autosave_failure(&mut state.session, now, &options) { notify_session_failure(state, &err); } return Err(err); @@ -79,6 +72,24 @@ fn persistence_enabled(options: &session::SessionOptions) -> bool { options.any_enabled() || options.restore_tool_state || options.persist_history } +fn record_autosave_success( + session_state: &mut crate::backend::wayland::session::SessionState, + now: Instant, + saved: bool, +) { + if saved { + session_state.mark_saved(now); + } +} + +fn record_autosave_failure( + session_state: &mut crate::backend::wayland::session::SessionState, + now: Instant, + options: &session::SessionOptions, +) -> bool { + session_state.mark_autosave_failure(now, options.autosave_failure_backoff) +} + pub(super) fn notify_session_failure(state: &WaylandState, err: &anyhow::Error) { notification::send_notification_async( &state.tokio_handle, @@ -87,3 +98,82 @@ pub(super) fn notify_session_failure(state: &WaylandState, err: &anyhow::Error) Some("dialog-error".to_string()), ); } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn persistence_enabled_respects_any_enabled_boards() { + let mut options = session::SessionOptions::new(PathBuf::from("/tmp"), "display-1"); + options.persist_transparent = true; + options.persist_history = false; + options.restore_tool_state = false; + + assert!(persistence_enabled(&options)); + } + + #[test] + fn persistence_enabled_respects_restore_tool_state() { + let mut options = session::SessionOptions::new(PathBuf::from("/tmp"), "display-1"); + options.persist_transparent = false; + options.persist_whiteboard = false; + options.persist_blackboard = false; + options.persist_history = false; + options.restore_tool_state = true; + + assert!(persistence_enabled(&options)); + } + + #[test] + fn persistence_enabled_is_false_when_nothing_is_enabled() { + let mut options = session::SessionOptions::new(PathBuf::from("/tmp"), "display-1"); + options.persist_transparent = false; + options.persist_whiteboard = false; + options.persist_blackboard = false; + options.persist_history = false; + options.restore_tool_state = false; + + assert!(!persistence_enabled(&options)); + } + + #[test] + fn record_autosave_failure_notifies_only_once_until_saved() { + let mut options = session::SessionOptions::new(PathBuf::from("/tmp"), "display-1"); + options.persist_transparent = true; + options.autosave_enabled = true; + options.autosave_idle = Duration::from_millis(1); + options.autosave_interval = Duration::from_millis(1); + options.autosave_failure_backoff = Duration::from_millis(50); + + let mut state = crate::backend::wayland::session::SessionState::new(Some(options.clone())); + let now = Instant::now(); + state.record_input_dirty(now, true); + + assert!(record_autosave_failure(&mut state, now, &options)); + assert!(!record_autosave_failure(&mut state, now, &options)); + assert!(!state.autosave_due(now, &options)); + + record_autosave_success(&mut state, now, true); + state.record_input_dirty(now, true); + assert!(record_autosave_failure(&mut state, now, &options)); + } + + #[test] + fn record_autosave_success_clears_dirty_state_when_saved() { + let mut options = session::SessionOptions::new(PathBuf::from("/tmp"), "display-1"); + options.persist_transparent = true; + options.autosave_enabled = true; + options.autosave_idle = Duration::from_millis(1); + options.autosave_interval = Duration::from_millis(1); + + let mut state = crate::backend::wayland::session::SessionState::new(Some(options.clone())); + let now = Instant::now(); + state.record_input_dirty(now, true); + assert!(state.autosave_due(now + Duration::from_millis(2), &options)); + + record_autosave_success(&mut state, now + Duration::from_millis(2), true); + assert!(!state.autosave_due(now + Duration::from_millis(2), &options)); + } +} diff --git a/src/backend/wayland/backend/helpers.rs b/src/backend/wayland/backend/helpers.rs index c13f6c8f..814c48fc 100644 --- a/src/backend/wayland/backend/helpers.rs +++ b/src/backend/wayland/backend/helpers.rs @@ -2,6 +2,7 @@ use anyhow::Result; use log::warn; use std::env; use std::os::fd::AsRawFd; +use std::time::Duration; use wayland_client::{EventQueue, backend::ReadEventsGuard, backend::WaylandError}; use super::super::state::WaylandState; @@ -42,20 +43,34 @@ fn is_missing_tool(lower: &str, tool: &str) -> bool { || lower.contains("failed to spawn")) } +fn timeout_to_poll_ms(timeout: Option) -> i32 { + timeout + .map(|dur| dur.as_millis().min(i32::MAX as u128) as i32) + .unwrap_or(-1) +} + +fn normalize_read_result(result: Result) -> Result { + match result { + Ok(n) => Ok(n), + Err(WaylandError::Io(err)) if err.kind() == std::io::ErrorKind::WouldBlock => Ok(0), + Err(e) => Err(e), + } +} + pub(super) fn read_events_with_timeout( guard: ReadEventsGuard, - timeout: Option, + timeout: Option, ) -> Result { let mut pollfd = libc::pollfd { fd: guard.connection_fd().as_raw_fd(), events: libc::POLLIN, revents: 0, }; - let timeout_ms = timeout - .map(|dur| dur.as_millis().min(i32::MAX as u128) as i32) - .unwrap_or(-1); + let timeout_ms = timeout_to_poll_ms(timeout); loop { + // SAFETY: pollfd points to valid memory and the file descriptor belongs + // to the prepared Wayland read guard for the duration of this call. let ready = unsafe { libc::poll(&mut pollfd, 1, timeout_ms) }; if ready == 0 { // Dropping the guard cancels the prepared read. @@ -71,17 +86,13 @@ pub(super) fn read_events_with_timeout( break; } - match guard.read() { - Ok(n) => Ok(n), - Err(WaylandError::Io(err)) if err.kind() == std::io::ErrorKind::WouldBlock => Ok(0), - Err(e) => Err(e), - } + normalize_read_result(guard.read()) } pub(super) fn dispatch_with_timeout( event_queue: &mut EventQueue, state: &mut WaylandState, - timeout: Option, + timeout: Option, ) -> Result<(), anyhow::Error> { let dispatched = event_queue .dispatch_pending(state) @@ -127,3 +138,127 @@ pub(super) fn resume_override_from_env() -> Option { Err(_) => None, } } + +#[cfg(test)] +mod tests { + use std::sync::{Mutex, OnceLock}; + + use super::*; + use crate::set_runtime_session_override; + + fn env_mutex() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + #[test] + fn timeout_to_poll_ms_supports_none_and_caps_large_values() { + assert_eq!(timeout_to_poll_ms(None), -1); + assert_eq!(timeout_to_poll_ms(Some(Duration::from_millis(15))), 15); + + let huge = Duration::from_millis(i32::MAX as u64 + 1000); + assert_eq!(timeout_to_poll_ms(Some(huge)), i32::MAX); + } + + #[test] + fn normalize_read_result_maps_would_block_to_zero() { + let err = WaylandError::Io(std::io::Error::from(std::io::ErrorKind::WouldBlock)); + assert_eq!(normalize_read_result(Err(err)).unwrap(), 0); + } + + #[test] + fn normalize_read_result_preserves_other_errors() { + let err = WaylandError::Io(std::io::Error::from(std::io::ErrorKind::BrokenPipe)); + let actual = normalize_read_result(Err(err)).unwrap_err(); + match actual { + WaylandError::Io(io_err) => { + assert_eq!(io_err.kind(), std::io::ErrorKind::BrokenPipe); + } + other => panic!("expected io error, got {other}"), + } + } + + #[test] + fn friendly_capture_error_covers_known_classes() { + assert_eq!( + friendly_capture_error("failed to spawn slurp: No such file"), + "Missing screenshot tool: slurp. Install slurp + grim and try again." + ); + assert_eq!( + friendly_capture_error("grim not found"), + "Missing screenshot tool: grim. Install grim and try again." + ); + assert_eq!( + friendly_capture_error("wl-copy failed to run"), + "Missing clipboard tool: wl-clipboard (wl-copy). Install it and try again." + ); + assert_eq!( + friendly_capture_error("RequestCancelled by user"), + "Screen capture cancelled by user" + ); + assert_eq!( + friendly_capture_error("permission denied"), + "Permission denied. Enable screen sharing in system settings." + ); + assert_eq!( + friendly_capture_error("portal returned error code 2"), + "Portal screenshot failed. If you use wlroots/Hyprland/Niri, install grim + slurp. Otherwise check xdg-desktop-portal." + ); + assert_eq!( + friendly_capture_error("resource busy"), + "Screen capture in progress. Try again in a moment." + ); + assert_eq!( + friendly_capture_error("something unexpected"), + "Screen capture failed. Please try again." + ); + } + + #[test] + fn resume_override_from_env_prefers_runtime_override() { + let _guard = env_mutex().lock().unwrap(); + + // SAFETY: test serialized by env mutex. + unsafe { + std::env::set_var(RESUME_SESSION_ENV, "off"); + } + set_runtime_session_override(Some(true)); + + assert_eq!(resume_override_from_env(), Some(true)); + + set_runtime_session_override(None); + // SAFETY: test serialized by env mutex. + unsafe { + std::env::remove_var(RESUME_SESSION_ENV); + } + } + + #[test] + fn resume_override_from_env_parses_expected_values() { + let _guard = env_mutex().lock().unwrap(); + set_runtime_session_override(None); + + // SAFETY: test serialized by env mutex. + unsafe { + std::env::set_var(RESUME_SESSION_ENV, "enabled"); + } + assert_eq!(resume_override_from_env(), Some(true)); + + // SAFETY: test serialized by env mutex. + unsafe { + std::env::set_var(RESUME_SESSION_ENV, "0"); + } + assert_eq!(resume_override_from_env(), Some(false)); + + // SAFETY: test serialized by env mutex. + unsafe { + std::env::set_var(RESUME_SESSION_ENV, "maybe"); + } + assert_eq!(resume_override_from_env(), None); + + // SAFETY: test serialized by env mutex. + unsafe { + std::env::remove_var(RESUME_SESSION_ENV); + } + } +} diff --git a/src/backend/wayland/backend/state_init/config.rs b/src/backend/wayland/backend/state_init/config.rs index 48704edb..2b077190 100644 --- a/src/backend/wayland/backend/state_init/config.rs +++ b/src/backend/wayland/backend/state_init/config.rs @@ -60,3 +60,48 @@ fn log_config(config: &Config) { #[cfg(not(tablet))] info!("Tablet feature: compiled=no"); } + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + use crate::config::test_helpers::with_temp_config_home; + + #[test] + fn load_applies_capture_exit_after_capture_to_auto_mode() { + with_temp_config_home(|_| { + let path = Config::get_config_path().expect("config path"); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create config dir"); + } + fs::write(path, "[capture]\nexit_after_capture = true\n").expect("write config"); + + let loaded = load(ExitAfterCaptureMode::Auto); + assert!(matches!(loaded.source, ConfigSource::Primary)); + assert!(matches!( + loaded.exit_after_capture_mode, + ExitAfterCaptureMode::Always + )); + }); + } + + #[test] + fn load_falls_back_to_defaults_when_config_is_invalid() { + with_temp_config_home(|_| { + let path = Config::get_config_path().expect("config path"); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create config dir"); + } + fs::write(path, "not = [valid").expect("write invalid config"); + + let loaded = load(ExitAfterCaptureMode::Auto); + assert!(matches!(loaded.source, ConfigSource::Default)); + assert!(matches!( + loaded.exit_after_capture_mode, + ExitAfterCaptureMode::Auto + )); + assert!(!loaded.config.capture.exit_after_capture); + }); + } +} diff --git a/src/backend/wayland/backend/state_init/input_state.rs b/src/backend/wayland/backend/state_init/input_state.rs index 87eb1c98..b83324f8 100644 --- a/src/backend/wayland/backend/state_init/input_state.rs +++ b/src/backend/wayland/backend/state_init/input_state.rs @@ -131,3 +131,56 @@ fn build_action_bindings(config: &Config) -> HashMap> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_action_map_falls_back_when_keybindings_invalid() { + let mut config = Config::default(); + config.keybindings.core.undo = vec!["Ctrl+Z".to_string()]; + config.keybindings.core.redo = vec!["Ctrl+Z".to_string()]; + + let map = build_action_map(&config); + let default_map = KeybindingsConfig::default() + .build_action_map() + .expect("default keybindings should build"); + + assert_eq!(map, default_map); + } + + #[test] + fn build_action_bindings_fall_back_when_keybindings_invalid() { + let mut config = Config::default(); + config.keybindings.core.undo = vec!["Ctrl+Z".to_string()]; + config.keybindings.core.redo = vec!["Ctrl+Z".to_string()]; + + let bindings = build_action_bindings(&config); + let default_bindings = KeybindingsConfig::default() + .build_action_bindings() + .expect("default keybindings should build"); + + assert_eq!(bindings, default_bindings); + } + + #[test] + fn build_input_state_applies_selected_ui_flags() { + let mut config = Config::default(); + config.ui.context_menu.enabled = false; + config.ui.show_status_board_badge = false; + config.ui.show_status_page_badge = false; + config.ui.show_floating_badge_always = true; + config.ui.active_output_badge = true; + config.ui.command_palette_toast_duration_ms = 1234; + + let input = build_input_state(&config); + + assert!(!input.context_menu_enabled()); + assert!(!input.show_status_board_badge); + assert!(!input.show_status_page_badge); + assert!(input.show_floating_badge_always); + assert!(input.show_active_output_badge); + assert_eq!(input.command_palette_toast_duration_ms, 1234); + } +} diff --git a/src/daemon/core.rs b/src/daemon/core.rs index 425192c8..2350b8bb 100644 --- a/src/daemon/core.rs +++ b/src/daemon/core.rs @@ -12,7 +12,7 @@ use std::thread; use std::thread::JoinHandle; use std::time::Duration; -#[cfg(all(test, feature = "tray"))] +#[cfg(test)] use crate::SESSION_OVERRIDE_FOLLOW_CONFIG; use crate::paths::daemon_lock_file; use crate::session::try_lock_exclusive; @@ -71,7 +71,7 @@ impl Daemon { } } - #[cfg(all(test, feature = "tray"))] + #[cfg(test)] fn with_backend_runner_internal( initial_mode: Option, backend_runner: Arc, @@ -97,7 +97,7 @@ impl Daemon { } } - #[cfg(all(test, feature = "tray"))] + #[cfg(test)] pub fn with_backend_runner( initial_mode: Option, backend_runner: Arc, @@ -265,7 +265,7 @@ impl Daemon { } } -#[cfg(all(test, feature = "tray"))] +#[cfg(test)] impl Daemon { pub fn test_state(&self) -> OverlayState { self.overlay_state diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index c1925542..43944934 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -6,7 +6,7 @@ mod overlay; mod tray; mod types; -#[cfg(all(test, feature = "tray"))] +#[cfg(test)] mod tests; pub use core::Daemon; diff --git a/src/daemon/overlay/spawn.rs b/src/daemon/overlay/spawn.rs index 14f256ee..4312a025 100644 --- a/src/daemon/overlay/spawn.rs +++ b/src/daemon/overlay/spawn.rs @@ -181,3 +181,73 @@ impl Daemon { )) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backoff_duration_grows_and_caps() { + let mut daemon = Daemon::new(None, false, None); + + daemon.overlay_spawn_failures = 1; + assert_eq!( + daemon.overlay_spawn_backoff_duration(), + Duration::from_secs(1) + ); + + daemon.overlay_spawn_failures = 2; + assert_eq!( + daemon.overlay_spawn_backoff_duration(), + Duration::from_secs(2) + ); + + daemon.overlay_spawn_failures = 5; + assert_eq!( + daemon.overlay_spawn_backoff_duration(), + Duration::from_secs(16) + ); + + daemon.overlay_spawn_failures = 6; + assert_eq!( + daemon.overlay_spawn_backoff_duration(), + Duration::from_secs(30) + ); + } + + #[test] + fn overlay_spawn_allowed_honors_retry_window() { + let mut daemon = Daemon::new(None, false, None); + daemon.overlay_spawn_next_retry = Some(Instant::now() + Duration::from_secs(2)); + daemon.overlay_spawn_backoff_logged = false; + + assert!(!daemon.overlay_spawn_allowed()); + assert!(daemon.overlay_spawn_backoff_logged); + + daemon.overlay_spawn_next_retry = Some(Instant::now() - Duration::from_secs(1)); + assert!(daemon.overlay_spawn_allowed()); + assert!(!daemon.overlay_spawn_backoff_logged); + } + + #[test] + fn push_spawn_candidate_deduplicates_programs() { + let mut candidates = Vec::new(); + let mut seen = HashSet::::new(); + + Daemon::push_spawn_candidate( + &mut candidates, + &mut seen, + OsString::from("wayscriber"), + "PATH", + ); + Daemon::push_spawn_candidate( + &mut candidates, + &mut seen, + OsString::from("wayscriber"), + "argv0", + ); + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].source, "PATH"); + } +} diff --git a/src/daemon/tests.rs b/src/daemon/tests.rs index b9cbf71a..7439d4ba 100644 --- a/src/daemon/tests.rs +++ b/src/daemon/tests.rs @@ -2,13 +2,39 @@ use super::core::Daemon; #[cfg(feature = "tray")] use super::tray::WayscriberTray; use super::types::{BackendRunner, OverlayState}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; #[cfg(feature = "tray")] use ksni::{Tray, menu::MenuItem}; #[cfg(feature = "tray")] -use std::sync::Arc; -#[cfg(feature = "tray")] -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering}; +use std::sync::atomic::AtomicBool; + +#[test] +fn daemon_session_resume_override_reflects_constructor_value() { + let daemon_true = Daemon::new(None, false, Some(true)); + let daemon_false = Daemon::new(None, false, Some(false)); + let daemon_none = Daemon::new(None, false, None); + + assert_eq!(daemon_true.session_resume_override(), Some(true)); + assert_eq!(daemon_false.session_resume_override(), Some(false)); + assert_eq!(daemon_none.session_resume_override(), None); +} + +#[test] +fn toggle_overlay_with_backend_runner_works_without_external_process() { + let called = Arc::new(AtomicUsize::new(0)); + let called_clone = Arc::clone(&called); + let runner: Arc = Arc::new(move |_| { + called_clone.fetch_add(1, AtomicOrdering::SeqCst); + Ok(()) + }); + let mut daemon = Daemon::with_backend_runner(None, runner); + + daemon.toggle_overlay().unwrap(); + assert_eq!(called.load(AtomicOrdering::SeqCst), 1); + assert_eq!(daemon.test_state(), OverlayState::Hidden); +} #[cfg(feature = "tray")] fn runner_counter(count: Arc) -> Arc {