diff --git a/crates/dynamonix-ipc/Cargo.toml b/crates/dynamonix-ipc/Cargo.toml index 6c68df6..f2e7c68 100644 --- a/crates/dynamonix-ipc/Cargo.toml +++ b/crates/dynamonix-ipc/Cargo.toml @@ -17,6 +17,7 @@ thiserror.workspace = true tokio = { workspace = true, features = ["rt", "net", "io-util", "sync", "time", "macros"] } [dev-dependencies] +dynamonix-geom = { path = "../dynamonix-geom" } tokio = { workspace = true, features = ["rt-multi-thread", "test-util"] } [lints] diff --git a/crates/dynamonix-ipc/src/fake.rs b/crates/dynamonix-ipc/src/fake.rs new file mode 100644 index 0000000..3ae3f41 --- /dev/null +++ b/crates/dynamonix-ipc/src/fake.rs @@ -0,0 +1,342 @@ +//! A compositor that answers like niri, for the tests and for a show. +//! +//! A nested niri instance gives only one output. That is not sufficient to test +//! an arrangement of many monitors. This module gives a server that speaks the +//! same protocol on a socket of its own. The server holds a group of the +//! outputs in its memory and changes them with the same rules as the +//! compositor. +//! +//! The server also gives the program a mode that shows the interface with no +//! monitor and no compositor. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; + +use niri_ipc::{ + LogicalOutput, Mode, ModeToSet, Output, OutputAction, OutputConfigChanged, PositionToSet, + Reply, Request, Response, ScaleToSet, Transform, +}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{UnixListener, UnixStream}; +use tokio::task::JoinHandle; + +use dynamonix_model::{Scale, logical_size, mode}; + +static COUNTER: AtomicU32 = AtomicU32::new(0); + +type State = Arc>>; + +/// A compositor that answers like niri. +#[derive(Debug)] +pub struct FakeCompositor { + path: PathBuf, + handle: JoinHandle<()>, +} + +impl FakeCompositor { + /// Start a server that holds the outputs. + /// + /// The server makes a socket in the directory for the temporary files. + /// + /// # Errors + /// + /// The function gives an error if it cannot make the socket. + pub fn start(outputs: Vec) -> std::io::Result { + let count = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "dynamonix-fake-{}-{count}.sock", + std::process::id() + )); + Self::start_at(path, outputs) + } + + /// Start a server that makes its socket at the path. + /// + /// # Errors + /// + /// The function gives an error if it cannot make the socket. + pub fn start_at(path: PathBuf, outputs: Vec) -> std::io::Result { + let _ = std::fs::remove_file(&path); + let listener = UnixListener::bind(&path)?; + let state: State = Arc::new(Mutex::new( + outputs + .into_iter() + .map(|output| (output.name.clone(), output)) + .collect(), + )); + let handle = tokio::spawn(accept(listener, state)); + Ok(Self { path, handle }) + } + + /// Give the path of the socket of the server. + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for FakeCompositor { + fn drop(&mut self) { + self.handle.abort(); + let _ = std::fs::remove_file(&self.path); + } +} + +async fn accept(listener: UnixListener, state: State) { + while let Ok((stream, _)) = listener.accept().await { + tokio::spawn(serve(stream, state.clone())); + } +} + +async fn serve(stream: UnixStream, state: State) { + let (read, mut writer) = stream.into_split(); + let mut reader = BufReader::new(read); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) | Err(_) => return, + Ok(_) => {} + } + let Ok(request) = serde_json::from_str::(&line) else { + return; + }; + let streaming = matches!(request, Request::EventStream); + let reply = answer(&request, &state); + let Ok(mut text) = serde_json::to_string(&reply) else { + return; + }; + text.push('\n'); + if writer.write_all(text.as_bytes()).await.is_err() { + return; + } + if writer.flush().await.is_err() { + return; + } + if streaming { + hold(reader).await; + return; + } + } +} + +async fn hold(mut reader: BufReader) { + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) | Err(_) => return, + Ok(_) => {} + } + } +} + +fn answer(request: &Request, state: &State) -> Reply { + let mut outputs = state.lock().expect("the state is not poisoned"); + match request { + Request::Version => Ok(Response::Version("26.04 (dynamonix fake)".to_owned())), + Request::Outputs => Ok(Response::Outputs( + outputs + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + )), + Request::EventStream => Ok(Response::Handled), + Request::Output { output, action } => { + let Some(target) = outputs.get_mut(output) else { + return Ok(Response::OutputConfigChanged( + OutputConfigChanged::OutputWasMissing, + )); + }; + apply(target, action); + Ok(Response::OutputConfigChanged(OutputConfigChanged::Applied)) + } + _ => Err("the fake compositor does not answer this request".to_owned()), + } +} + +fn apply(output: &mut Output, action: &OutputAction) { + match action { + OutputAction::Off => output.logical = None, + OutputAction::On => { + if output.logical.is_none() { + let index = output + .modes + .iter() + .position(|item| item.is_preferred) + .unwrap_or(0); + output.current_mode = Some(index); + output.logical = Some(LogicalOutput { + x: 0, + y: 0, + width: 0, + height: 0, + scale: 1.0, + transform: Transform::Normal, + }); + resize(output); + } + } + OutputAction::Mode { mode: requested } => { + let found = match requested { + ModeToSet::Automatic => mode::preferred(&output.modes), + ModeToSet::Specific(value) => mode::matching(&output.modes, *value), + }; + if let Some(found) = found + && let Some(index) = output.modes.iter().position(|item| *item == found) + { + output.current_mode = Some(index); + resize(output); + } + } + OutputAction::Scale { scale } => { + if let Some(logical) = output.logical.as_mut() { + logical.scale = match scale { + ScaleToSet::Automatic => 1.0, + ScaleToSet::Specific(value) => Scale::clamped(*value).get(), + }; + resize(output); + } + } + OutputAction::Transform { transform } => { + if let Some(logical) = output.logical.as_mut() { + logical.transform = *transform; + resize(output); + } + } + OutputAction::Position { position } => { + if let Some(logical) = output.logical.as_mut() + && let PositionToSet::Specific(value) = position + { + logical.x = value.x; + logical.y = value.y; + } + } + OutputAction::Vrr { vrr } => output.vrr_enabled = vrr.vrr && output.vrr_supported, + OutputAction::CustomMode { .. } | OutputAction::Modeline { .. } => {} + } +} + +fn resize(output: &mut Output) { + let Some(current) = output + .current_mode + .and_then(|index| output.modes.get(index)) + else { + return; + }; + let current = *current; + if let Some(logical) = output.logical.as_mut() { + let size = logical_size(current, Scale::clamped(logical.scale), logical.transform); + logical.width = u32::try_from(size.width).unwrap_or(0); + logical.height = u32::try_from(size.height).unwrap_or(0); + } +} + +/// Make a monitor for a test or for a show. +#[must_use] +pub fn monitor(name: &str, make: &str, model: &str, sizes: &[(u16, u16, u32)]) -> Output { + let available: Vec = sizes + .iter() + .enumerate() + .map(|(index, (width, height, refresh_rate))| Mode { + width: *width, + height: *height, + refresh_rate: *refresh_rate, + is_preferred: index == 0, + }) + .collect(); + let mut output = Output { + name: name.to_owned(), + make: make.to_owned(), + model: model.to_owned(), + serial: Some(format!("{name}-0001")), + physical_size: Some((600, 340)), + modes: available, + current_mode: Some(0), + is_custom_mode: false, + vrr_supported: true, + vrr_enabled: false, + logical: Some(LogicalOutput { + x: 0, + y: 0, + width: 0, + height: 0, + scale: 1.0, + transform: Transform::Normal, + }), + }; + resize(&mut output); + output +} + +/// Make a group of the monitors that a user has frequently. +/// +/// The group has a laptop screen, a large monitor and a monitor that a user can +/// turn. The positions start at the origin and make one row. +#[must_use] +pub fn sample_desk() -> Vec { + let mut laptop = monitor( + "eDP-1", + "Lenovo", + "MNE007JA1-3", + &[ + (1920, 1200, 60_001), + (1920, 1080, 60_001), + (1280, 720, 60_001), + ], + ); + let mut wide = monitor( + "DP-2", + "Dell", + "U3423WE", + &[ + (3440, 1440, 143_999), + (3440, 1440, 59_997), + (2560, 1080, 59_997), + ], + ); + let mut side = monitor( + "HDMI-A-1", + "Dell", + "U2720Q", + &[ + (3840, 2160, 59_997), + (2560, 1440, 59_997), + (1920, 1080, 60_000), + ], + ); + place(&mut wide, 0, 0); + place(&mut laptop, 3440, 360); + place(&mut side, 5360, 0); + vec![laptop, wide, side] +} + +fn place(output: &mut Output, x: i32, y: i32) { + if let Some(logical) = output.logical.as_mut() { + logical.x = x; + logical.y = y; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_sample_monitor_has_a_logical_size() { + let output = monitor("DP-1", "Make", "Model", &[(1920, 1080, 60_000)]); + let logical = output.logical.expect("the monitor is on"); + assert_eq!((logical.width, logical.height), (1920, 1080)); + } + + #[test] + fn the_sample_group_has_three_monitors_in_one_row() { + let outputs = sample_desk(); + assert_eq!(outputs.len(), 3); + for output in &outputs { + assert!(output.logical.is_some()); + } + } +} diff --git a/crates/dynamonix-ipc/src/lib.rs b/crates/dynamonix-ipc/src/lib.rs index 3e7066a..c4b92d4 100644 --- a/crates/dynamonix-ipc/src/lib.rs +++ b/crates/dynamonix-ipc/src/lib.rs @@ -3,11 +3,12 @@ //! The program speaks to the compositor only through its socket. The program //! does not read a configuration file and does not write a configuration file. //! -//! The crate has three parts: +//! The crate has four parts: //! //! - A [`Client`] sends the requests and applies a plan. //! - An [`EventStream`] receives the events of the compositor. //! - A [`Watcher`] reports a change of the outputs. +//! - A [`FakeCompositor`] answers like niri, for the tests and for a show. //! //! The compositor has no event for a change of the outputs. The [`Watcher`] //! therefore asks the compositor again at a regular time. See the `watch` @@ -16,9 +17,11 @@ pub mod client; pub mod error; pub mod events; +pub mod fake; pub mod watch; pub use client::{Client, Outcome, Report, StepResult, socket_path}; pub use error::{Error, Result}; pub use events::EventStream; +pub use fake::FakeCompositor; pub use watch::{Update, WatchConfig, Watcher}; diff --git a/crates/dynamonix-ipc/tests/roundtrip.rs b/crates/dynamonix-ipc/tests/roundtrip.rs new file mode 100644 index 0000000..c75381f --- /dev/null +++ b/crates/dynamonix-ipc/tests/roundtrip.rs @@ -0,0 +1,166 @@ +//! The examination of the full path from a layout to the compositor. +//! +//! The test uses the fake compositor. A nested niri instance gives only one +//! output, and that is not sufficient to test an arrangement of many monitors. + +use std::time::Duration; + +use dynamonix_geom::Point; +use dynamonix_ipc::fake::{FakeCompositor, sample_desk}; +use dynamonix_ipc::{Client, Update, WatchConfig, Watcher}; +use dynamonix_model::{Scale, TransformExt}; +use dynamonix_plan::{check, diff, is_safe}; +use niri_ipc::Transform; + +async fn started() -> (FakeCompositor, Client) { + let server = FakeCompositor::start(sample_desk()).expect("the server starts"); + let client = Client::connect_to(server.path()) + .await + .expect("the client connects"); + (server, client) +} + +#[tokio::test] +async fn the_client_reads_the_outputs_of_the_compositor() { + let (_server, mut client) = started().await; + let snapshot = client.snapshot().await.expect("the compositor answers"); + assert_eq!(snapshot.len(), 3); + assert_eq!(snapshot.enabled_count(), 3); + assert_eq!(snapshot.names(), vec!["DP-2", "HDMI-A-1", "eDP-1"]); +} + +#[tokio::test] +async fn a_layout_that_matches_the_compositor_needs_no_step() { + let (_server, mut client) = started().await; + let snapshot = client.snapshot().await.expect("the compositor answers"); + let plan = diff(&snapshot, &snapshot.to_layout()); + assert!(plan.is_empty(), "the plan has {} steps", plan.len()); +} + +#[tokio::test] +async fn the_compositor_reaches_the_wanted_layout() { + let (_server, mut client) = started().await; + let snapshot = client.snapshot().await.expect("the compositor answers"); + + let mut wanted = snapshot.to_layout(); + wanted.update("eDP-1", |config| { + config + .with_origin(Point::new(0, 1440)) + .with_scale(Scale::new(1.5).expect("the factor is valid")) + }); + wanted.update("HDMI-A-1", |config| config.with_transform(Transform::_90)); + + let problems = check(&wanted, &snapshot); + assert!(is_safe(&problems), "the layout is not safe: {problems:?}"); + + let plan = diff(&snapshot, &wanted); + assert!(!plan.is_empty()); + let report = client.apply(&plan).await.expect("the compositor answers"); + assert!(report.is_success(), "the compositor refused: {report:?}"); + + let after = client.snapshot().await.expect("the compositor answers"); + let remaining = diff(&after, &wanted); + assert!( + remaining.is_empty(), + "the layout did not converge: {:?}", + remaining.steps() + ); +} + +#[tokio::test] +async fn the_compositor_applies_a_scale_factor_and_a_turn() { + let (_server, mut client) = started().await; + let snapshot = client.snapshot().await.expect("the compositor answers"); + + let mut wanted = snapshot.to_layout(); + wanted.update("HDMI-A-1", |config| { + config + .with_transform(Transform::_270) + .with_scale(Scale::new(2.0).expect("the factor is valid")) + }); + let plan = diff(&snapshot, &wanted); + client.apply(&plan).await.expect("the compositor answers"); + + let after = client.snapshot().await.expect("the compositor answers"); + let output = after.get("HDMI-A-1").expect("the output exists"); + let logical = output.logical.expect("the output is on"); + assert!(logical.transform.swaps_axes()); + assert_eq!((logical.width, logical.height), (1080, 1920)); +} + +#[tokio::test] +async fn the_program_can_turn_an_output_off_and_on() { + let (_server, mut client) = started().await; + let snapshot = client.snapshot().await.expect("the compositor answers"); + + let mut wanted = snapshot.to_layout(); + wanted.update("eDP-1", |config| config.with_enabled(false)); + let plan = diff(&snapshot, &wanted); + client.apply(&plan).await.expect("the compositor answers"); + + let after = client.snapshot().await.expect("the compositor answers"); + assert_eq!(after.enabled_count(), 2); + + let back = after.to_layout(); + let mut wanted = back.clone(); + wanted.update("eDP-1", |config| config.with_enabled(true)); + let plan = diff(&after, &wanted); + client.apply(&plan).await.expect("the compositor answers"); + + let final_state = client.snapshot().await.expect("the compositor answers"); + assert_eq!(final_state.enabled_count(), 3); +} + +#[tokio::test] +async fn a_change_for_an_output_that_is_not_connected_waits() { + let (_server, mut client) = started().await; + let snapshot = client.snapshot().await.expect("the compositor answers"); + + let mut wanted = snapshot.to_layout(); + wanted.insert( + "DP-9", + dynamonix_model::OutputConfig::default().with_origin(Point::new(9000, 0)), + ); + let plan = diff(&snapshot, &wanted); + let report = client.apply(&plan).await.expect("the compositor answers"); + assert!(report.is_success()); + assert!(report.staged_count() > 0); +} + +#[tokio::test] +async fn the_watcher_reports_the_outputs_and_then_a_change() { + let server = FakeCompositor::start(sample_desk()).expect("the server starts"); + let mut watcher = Watcher::start( + server.path().to_path_buf(), + WatchConfig { + interval: Duration::from_millis(50), + retry: Duration::from_millis(50), + }, + ); + + let first = watcher + .next_update() + .await + .expect("the task sends a message"); + let Update::Outputs(snapshot) = first else { + panic!("the first message must hold the outputs"); + }; + assert_eq!(snapshot.enabled_count(), 3); + + let mut client = Client::connect_to(server.path()) + .await + .expect("the client connects"); + let mut wanted = snapshot.to_layout(); + wanted.update("eDP-1", |config| config.with_enabled(false)); + let plan = diff(&snapshot, &wanted); + client.apply(&plan).await.expect("the compositor answers"); + + let next = tokio::time::timeout(Duration::from_secs(5), watcher.next_update()) + .await + .expect("the task reports the change in time") + .expect("the task sends a message"); + let Update::Outputs(after) = next else { + panic!("the message must hold the outputs"); + }; + assert_eq!(after.enabled_count(), 2); +}