actl-uia 0.1.8

Windows UIA backend: the ONLY crate allowed to touch COM/unsafe
//! Real CLI and snapshot refs on owned buttons; posted selection is not desktop consent.
use super::*;
use serde_json::{Value, json};
use std::{
    process::{Command, Stdio},
    time::{Duration, Instant},
};

#[derive(Debug, Clone, Copy)]
enum Mode {
    Select,
    Dismiss,
    Expire,
    Move,
    Stop,
    Manual,
}

#[test]
#[ignore = "interactive desktop; checks only owned selection-window focus policy"]
fn choice_dialog_accepts_explicit_user_focus_without_activating_on_open() {
    let foreground = unsafe { GetForegroundWindow() };
    let window = Window::open_choice(
        [100, 200, 400, 250],
        "Choice focus test",
        "Select only",
        0,
        2,
    )
    .unwrap();
    assert_eq!(unsafe { GetForegroundWindow() }, foreground);
    assert_eq!(
        unsafe { GetWindowLongPtrW(window.notice, GWL_EXSTYLE) } as u32 & WS_EX_NOACTIVATE.0,
        0
    );
    assert_eq!(
        unsafe { SendMessageW(window.notice, WM_MOUSEACTIVATE, None, None) }.0,
        MA_ACTIVATE as isize
    );
}

fn run(mode: Mode) {
    assert!(!actl_core::state::SignalPaths::default().stop_requested());
    let host = OwnedHost::with_choices(true);
    let foreground = unsafe { GetForegroundWindow() };
    let binary = std::env::var("ACTL_PREVIEW_CLI").expect("ACTL_PREVIEW_CLI");
    let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../../target/preview-choice")
        .join(format!(
            "{}-{mode:?}",
            actl_core::snapshot::new_snapshot_id()
        ));
    std::fs::create_dir_all(&root).unwrap();
    let app = format!("hwnd:{}", host.hwnd);
    let out = Command::new(&binary)
        .env("LOCALAPPDATA", &root)
        .args(["snapshot", "--app", &app])
        .output()
        .unwrap();
    std::fs::write(root.join("snapshot.json"), &out.stdout).unwrap();
    let snapshot: Value = serde_json::from_slice(&out.stdout).unwrap();
    assert_eq!(snapshot["ok"], true, "{snapshot}");
    let elements = snapshot["data"]["elements"].as_array().unwrap();
    let reference = |name: &str| {
        elements.iter().find(|e| e["name"] == name).unwrap()["ref"]
            .as_str()
            .unwrap()
            .to_owned()
    };
    let first = reference("Option A");
    let second = reference("Option B");
    let snapshot_id = snapshot["snapshot_id"].as_str().unwrap();
    let duration = if matches!(mode, Mode::Manual) {
        "30000"
    } else {
        "4000"
    };
    let mut child = Command::new(&binary)
        .env("LOCALAPPDATA", &root)
        .env_remove("ACTL_TASK_ID")
        .args([
            "preview",
            &first,
            "--candidate",
            &second,
            "--snapshot",
            snapshot_id,
            "--app",
            &app,
            "--message",
            "Select Option B; test only",
            "--duration",
            duration,
        ])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .unwrap();
    let pid = child.id();
    let until = Instant::now() + Duration::from_secs(8);
    let mut saw_first = false;
    let mut saw_second = false;
    loop {
        let windows = super::lifecycle::previews(pid);
        if child.try_wait().unwrap().is_some() || Instant::now() >= until {
            break;
        }
        for notice in windows {
            unsafe {
                let mut title = [0; 512];
                let len = GetWindowTextW(notice, &mut title);
                let title = String::from_utf16_lossy(&title[..len.max(0) as usize]);
                if title.contains("1/2") && !saw_first {
                    saw_first = true;
                    match mode {
                        Mode::Select | Mode::Move => {
                            PostMessageW(Some(notice), WM_COMMAND, WPARAM(2), LPARAM(0)).unwrap();
                        }
                        Mode::Dismiss => {
                            PostMessageW(Some(notice), WM_CLOSE, WPARAM(0), LPARAM(0)).unwrap();
                        }
                        Mode::Stop => {
                            actl_core::state::SignalPaths::at(root.join("actl"))
                                .request_stop()
                                .unwrap();
                        }
                        _ => {}
                    }
                }
                if title.contains("2/2") && !saw_second {
                    saw_second = true;
                    if matches!(mode, Mode::Move) {
                        let button = GetDlgItem(Some(HWND(host.hwnd as *mut _)), 11).unwrap();
                        SetWindowPos(
                            button,
                            None,
                            220,
                            40,
                            0,
                            0,
                            SWP_NOACTIVATE | SWP_NOSIZE | SWP_NOZORDER,
                        )
                        .unwrap();
                    }
                    if matches!(mode, Mode::Select | Mode::Move) {
                        PostMessageW(Some(notice), WM_COMMAND, WPARAM(3), LPARAM(0)).unwrap();
                    }
                }
            }
        }
        if matches!(mode, Mode::Manual) && saw_first {
            break;
        }
        std::thread::sleep(Duration::from_millis(10));
    }
    let out = child.wait_with_output().unwrap();
    std::fs::write(root.join("result.json"), &out.stdout).unwrap();
    std::fs::write(root.join("stderr.txt"), &out.stderr).unwrap();
    let result: Value = serde_json::from_slice(&out.stdout).unwrap();
    let windows = super::lifecycle::previews(pid);
    let unchanged_focus = unsafe { GetForegroundWindow() } == foreground;
    let host_handle = HWND(host.hwnd as *mut _);
    drop(host);
    let cleaned = !unsafe { IsWindow(Some(host_handle)).as_bool() };
    std::fs::write(root.join("evidence.json"), serde_json::to_vec_pretty(&json!({
        "binary":binary,"pid":pid,"host":host_handle.0 as usize,"mode":format!("{mode:?}"),
        "saw_first":saw_first,"saw_second":saw_second,"foreground_preserved":unchanged_focus,
        "preview_cleanup":windows.is_empty(),"host_cleanup":cleaned,"exit_code":out.status.code()
    })).unwrap()).unwrap();
    assert!(windows.is_empty() && cleaned);
    assert!(saw_first, "{result}");
    if !matches!(mode, Mode::Manual) {
        assert!(unchanged_focus);
    }
    match mode {
        Mode::Select | Mode::Manual => {
            assert_eq!(result["data"]["exit_reason"], "selected", "{result}");
            assert_eq!(result["data"]["selection"]["target"], second);
            assert_eq!(result["data"]["selection"]["identity"]["name"], "Option B");
            assert_eq!(result["data"]["executed"], false);
            assert_eq!(result["data"]["snapshot_id"], snapshot_id);
        }
        Mode::Move => {
            assert_eq!(result["error"]["code"], "STALE_REF", "{result}");
        }
        Mode::Stop => {
            assert_eq!(result["error"]["code"], "ABORTED", "{result}");
        }
        Mode::Expire | Mode::Dismiss => {
            assert_eq!(result["data"]["selected"], false, "{result}");
            assert!(result["data"]["selection"].is_null());
        }
    }
}

#[test]
#[ignore = "interactive desktop, real CLI, owned buttons only"]
fn candidate_switch_and_select() {
    run(Mode::Select);
}
#[test]
#[ignore = "interactive desktop, real CLI, owned buttons only"]
fn candidate_close_returns_no_selection() {
    run(Mode::Dismiss);
}
#[test]
#[ignore = "interactive desktop, real CLI, owned buttons only"]
fn candidate_timeout_returns_no_selection() {
    run(Mode::Expire);
}
#[test]
#[ignore = "interactive desktop, real CLI, owned buttons only"]
fn candidate_movement_rejects_confirmation() {
    run(Mode::Move);
}
#[test]
#[ignore = "interactive desktop, real CLI, isolated stop state"]
fn candidate_stop_cancels_selection() {
    run(Mode::Stop);
}
#[test]
#[ignore = "manual or computer-use click of Next and Select within 30s; not consent"]
fn mouse_selection_acceptance() {
    run(Mode::Manual);
}