use std::time::{Duration, Instant};
use super::common::unique_pipe_name;
use super::test_desktop::{
DaemonGuard, KillingChild, TestDesktop, TestWindow, query_windows, send_ipc_retry,
start_test_daemon, stop_test_daemon, unique_title,
};
use flow_wm::ipc::message::{SocketMessage, SocketResponse, WindowMode};
fn wait(ms: u64) {
std::thread::sleep(Duration::from_millis(ms));
}
fn wait_for_exit(child: &mut KillingChild, timeout: Duration) {
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(_)) => return,
Ok(None) if Instant::now() < deadline => wait(100),
_ => {
let _ = child.kill();
let _ = child.wait();
return;
}
}
}
}
fn col_of_hwnd(json: &serde_json::Value, hwnd: isize) -> Option<i64> {
json["windows"]
.as_array()?
.iter()
.find(|w| w["hwnd"].as_i64() == Some(hwnd as i64))
.and_then(|w| w["state"]["Tiling"]["Active"]["col"].as_i64())
}
fn is_floating(json: &serde_json::Value, hwnd: isize) -> bool {
json["windows"]
.as_array()
.map(|arr| {
arr.iter().any(|w| {
w["hwnd"].as_i64() == Some(hwnd as i64) && w["state"].get("Floating").is_some()
})
})
.unwrap_or(false)
}
fn window_present(json: &serde_json::Value, hwnd: isize) -> bool {
json["windows"]
.as_array()
.map(|arr| arr.iter().any(|w| w["hwnd"].as_i64() == Some(hwnd as i64)))
.unwrap_or(false)
}
fn query_windows_retry(pipe: &str) -> serde_json::Value {
for _ in 0..40 {
match query_windows(pipe) {
Ok(v) => return v,
Err(_) => wait(25),
}
}
panic!("query_windows failed after retries (daemon unreachable)");
}
fn wait_until_present(pipe: &str, hwnds: &[isize]) {
let deadline = Instant::now() + Duration::from_secs(15);
loop {
let json = query_windows_retry(pipe);
if hwnds.iter().all(|h| window_present(&json, *h)) {
return;
}
if Instant::now() >= deadline {
panic!("daemon did not register windows {hwnds:?} within 15s");
}
wait(100);
}
}
fn wait_until_n_tiled(pipe: &str, expected: usize) {
use super::test_desktop::{active_window_ids, query_layout_virtual};
let deadline = Instant::now() + Duration::from_secs(10);
let mut last = String::new();
loop {
if let Ok(json) = query_layout_virtual(pipe) {
if active_window_ids(&json).len() == expected {
return;
}
last = format!("{json:?}");
}
if Instant::now() >= deadline {
panic!("timed out waiting for {expected} windows to tile (last layout: {last})");
}
wait(100);
}
}
fn float_until_floating(pipe: &str, target_hwnd: isize) {
for _ in 0..20 {
let resp = send_ipc_retry(
pipe,
&SocketMessage::SetWindow {
mode: WindowMode::Float,
},
)
.expect("SetWindow Float IPC");
assert!(
matches!(resp, SocketResponse::Ok),
"SetWindow Float should succeed, got: {resp:?}"
);
wait(150);
if is_floating(&query_windows_retry(pipe), target_hwnd) {
return;
}
}
panic!("window hwnd {target_hwnd:#x} did not float after repeated SetWindow Float");
}
#[test]
fn loadout_roundtrip_restores_floating_window() {
let td = TestDesktop::create().expect("create test desktop");
let pipe = unique_pipe_name();
let mut child = start_test_daemon(&pipe, &td.name).expect("start first daemon");
wait(700);
let t1 = unique_title("RT-1");
let t2 = unique_title("RT-2");
let w1 = TestWindow::create(&t1).expect("create W1");
wait(300);
let w2 = TestWindow::create(&t2).expect("create W2");
let h1 = w1.hwnd.0 as isize;
let h2 = w2.hwnd.0 as isize;
wait_until_n_tiled(&pipe, 2);
float_until_floating(&pipe, h2);
wait(200);
let pre = query_windows_retry(&pipe);
assert!(
is_floating(&pre, h2),
"W2 should be floating before save (setup precondition)"
);
assert!(col_of_hwnd(&pre, h1).is_some(), "W1 should remain tiled");
let resp =
send_ipc_retry(&pipe, &SocketMessage::LoadoutSave { path: None }).expect("LoadoutSave IPC");
assert!(matches!(resp, SocketResponse::Ok), "save failed: {resp:?}");
stop_test_daemon(&pipe);
wait_for_exit(&mut child, Duration::from_secs(10));
drop(child);
let child2 = start_test_daemon(&pipe, &td.name).expect("restart daemon");
let _guard = DaemonGuard::new(&pipe);
wait_until_present(&pipe, &[h1, h2]);
wait(700);
let post = query_windows_retry(&pipe);
assert!(
col_of_hwnd(&post, h1).is_some(),
"W1 must be tiled after restore"
);
assert!(
is_floating(&post, h2),
"W2 must be restored to floating — init would have tiled it, so a float \
here proves the loadout was applied"
);
drop(w1);
drop(w2);
drop(td);
let _ = child2; }
#[test]
fn loadout_disambiguates_identical_windows_by_hwnd() {
let td = TestDesktop::create().expect("create test desktop");
let pipe = unique_pipe_name();
let mut child = start_test_daemon(&pipe, &td.name).expect("start first daemon");
wait(700);
let same = unique_title("Same-Term");
let w1 = TestWindow::create(&same).expect("create W1");
wait(350);
let w2 = TestWindow::create(&same).expect("create W2");
wait(350);
let w3 = TestWindow::create(&same).expect("create W3");
let h1 = w1.hwnd.0 as isize;
let h2 = w2.hwnd.0 as isize;
let h3 = w3.hwnd.0 as isize;
wait_until_n_tiled(&pipe, 3);
float_until_floating(&pipe, h3);
wait(200);
let pre = query_windows_retry(&pipe);
assert!(
is_floating(&pre, h3),
"W3 should float before save (precondition)"
);
let resp =
send_ipc_retry(&pipe, &SocketMessage::LoadoutSave { path: None }).expect("LoadoutSave IPC");
assert!(matches!(resp, SocketResponse::Ok), "save failed: {resp:?}");
stop_test_daemon(&pipe);
wait_for_exit(&mut child, Duration::from_secs(10));
drop(child);
let child2 = start_test_daemon(&pipe, &td.name).expect("restart daemon");
let _guard = DaemonGuard::new(&pipe);
wait_until_present(&pipe, &[h1, h2, h3]);
wait(800);
let post = query_windows_retry(&pipe);
assert!(is_floating(&post, h3), "W3 must be restored to floating");
assert_eq!(
col_of_hwnd(&post, h1),
Some(0),
"W1 must be restored to column 0 (by HWND)"
);
assert_eq!(
col_of_hwnd(&post, h2),
Some(1),
"W2 must be restored to column 1 (by HWND)"
);
drop(w1);
drop(w2);
drop(w3);
drop(td);
let _ = child2;
}
#[test]
fn loadout_aborts_when_a_saved_window_is_missing() {
let td = TestDesktop::create().expect("create test desktop");
let pipe = unique_pipe_name();
let child = start_test_daemon(&pipe, &td.name).expect("start daemon");
let _guard = DaemonGuard::new(&pipe);
wait(700);
let t1 = unique_title("Ab-A");
let t2 = unique_title("Ab-B");
let w1 = TestWindow::create(&t1).expect("create W1");
wait(300);
let w2 = TestWindow::create(&t2).expect("create W2");
let h1 = w1.hwnd.0 as isize;
let h2 = w2.hwnd.0 as isize;
wait_until_n_tiled(&pipe, 2);
let resp =
send_ipc_retry(&pipe, &SocketMessage::LoadoutSave { path: None }).expect("LoadoutSave IPC");
assert!(matches!(resp, SocketResponse::Ok), "save failed: {resp:?}");
drop(w2);
wait(700);
let pre = query_windows_retry(&pipe);
assert!(
!window_present(&pre, h2),
"W2 must be destroyed (absent from registry) before load"
);
let resp =
send_ipc_retry(&pipe, &SocketMessage::LoadoutLoad { path: None }).expect("LoadoutLoad IPC");
match resp {
SocketResponse::Error { message } => {
assert!(
message.contains("not currently open"),
"abort should diagnose the missing window, got: {message}"
);
}
other => panic!("expected Error (no-partial abort), got {other:?}"),
}
let post = query_windows_retry(&pipe);
assert!(
col_of_hwnd(&post, h1).is_some(),
"W1 must remain tiled after the aborted load"
);
drop(w1);
drop(td);
let _ = child;
}
#[test]
fn loadout_appends_unreferenced_window_as_column() {
let td = TestDesktop::create().expect("create test desktop");
let pipe = unique_pipe_name();
let mut child = start_test_daemon(&pipe, &td.name).expect("start first daemon");
wait(700);
let t1 = unique_title("Lf-A");
let t2 = unique_title("Lf-B");
let w1 = TestWindow::create(&t1).expect("create W1");
wait(300);
let w2 = TestWindow::create(&t2).expect("create W2");
let h1 = w1.hwnd.0 as isize;
let h2 = w2.hwnd.0 as isize;
wait_until_n_tiled(&pipe, 2);
let resp =
send_ipc_retry(&pipe, &SocketMessage::LoadoutSave { path: None }).expect("LoadoutSave IPC");
assert!(matches!(resp, SocketResponse::Ok), "save failed: {resp:?}");
stop_test_daemon(&pipe);
wait_for_exit(&mut child, Duration::from_secs(10));
drop(child);
let t3 = unique_title("Lf-C");
let w3 = TestWindow::create(&t3).expect("create W3");
let h3 = w3.hwnd.0 as isize;
let child2 = start_test_daemon(&pipe, &td.name).expect("restart daemon");
let _guard = DaemonGuard::new(&pipe);
wait_until_present(&pipe, &[h1, h2, h3]);
wait(800);
let post = query_windows_retry(&pipe);
assert!(col_of_hwnd(&post, h1).is_some(), "W1 must be tiled");
assert!(col_of_hwnd(&post, h2).is_some(), "W2 must be tiled");
assert!(
col_of_hwnd(&post, h3).is_some(),
"W3 (not in the loadout) must be appended as a column, not dropped"
);
drop(w1);
drop(w2);
drop(w3);
drop(td);
let _ = child2;
}