test(audio,ptt): comprehensive Windows P0 unit-test suite (L0-L11)
Layered test coverage for the Windows PTT subsystem ahead of the
v1.0.0-rc.8 official release sign-off.
L0 (refactor)
- Extract three pure-logic dispatchers from the existing WndProc /
LowLevelKeyboardProc / LowLevelMouseProc bodies in
crates/chanora_audio/src/ptt_backends/windows.rs:
dispatch_raw_input(ctx, &RAWINPUT)
dispatch_hook_keyboard(ctx, wparam, &KBDLLHOOKSTRUCT)
dispatch_hook_mouse(ctx, wparam, &MSLLHOOKSTRUCT)
Each takes a small Context (AtomicBinding + AudioTransmitGate +
flags) and is callable without spinning up any Win32 plumbing.
The real Win32 procs unchanged structurally; they unpack lparam
and forward to the dispatchers. AtomicBinding / RawInputContext
/ HookContext / resolve_binding are now pub(crate) so the
in-file test module can drive them.
L1 — windows_keymap full-table sweep (+13 tests)
Every key_label_to_vk arm, all A-Z + a-z, all 0-9, F1-F20,
navigation, modifiers, OEM punctuation, numpad. Exhaustive
mouse_label_to_button cases including the 0x08 / 0x10 /
unknown-bitmask fallbacks.
L2 — AtomicBinding lock-free correctness
store/read round-trip, clear(), Default = zeros, single-writer
/ single-reader concurrency, many-readers / single-writer.
L3 — resolve_binding dispatcher tests
All PttInputClass variants, well-known labels, unknown-label
fallback, mismatched class+label rejection, mouse bitmask
resolution.
L4 — Backend state-machine
Both WindowsRawInputBackend and WindowsHookBackend:
descriptor() pre-arm vs post-arm (L0Focused -> L2/L3), start()
with None binding rejection, rebind() in-place, stop()
clears + idempotent, stop() after stop() no-op.
L5 — dispatch_raw_input table
Keyboard match/non-match, key-down/key-up via Flags & 0x01,
no-binding short-circuit, mouse XBUTTON1/XBUTTON2 down/up
matching the bound button, unhandled HID type. RAWINPUT structs
built via mem::zeroed plus field-fill, owning the unsafe in
the test layer where it belongs.
L6 — dispatch_hook_keyboard + dispatch_hook_mouse
WM_KEYDOWN / WM_KEYUP / WM_SYSKEYDOWN / WM_SYSKEYUP for the
keyboard path, WM_XBUTTONDOWN / WM_XBUTTONUP for the mouse
path. Same shape as L5.
L7 — Privacy invariant (crates/chanora_audio/tests/ptt_privacy.rs)
New cross-platform integration test installs a custom
tracing_subscriber Layer that records every emitted event's
target + field names. Exercises the public PTT API plus (on
Windows) the backend factory. Asserts no field name in the
banned list (vk, scan_code, keysym, key_label, bound_key,
binding, platform_key, VKey, wVk, wScan, kbflags, mouseflags)
is ever emitted and every field belongs to the DEC-027
allow-list. Adds tracing-subscriber as a dev-dependency on
chanora_audio.
L8 — Full-chain integration in core/chanora_core/src/ptt.rs
Windows-only mod windows_full_chain_tests:
zero-tail full chain (synchronous)
default-tail full chain (200 ms wait then off)
mid-press rebind abandons in-flight press
L9/L10/L11 — tools/windows-smoke.cmd + tools/windows-smoke.md
Batch smoke script + operator doc. cargo build, flutter build,
artifact existence + size checks, headless launch with stderr
capture, bridge-initialised log assertion. Distinct exit codes
per failure step. Doc explains invocation + common failure
modes.
Verification (Linux)
- cargo check --workspace: clean.
- cargo test --workspace: 78 passed / 0 failed / 3 ignored.
76 cross-platform unit tests (unchanged) plus the new
ptt_privacy integration test plus one new ignored portal smoke
test.
The Windows-gated tests (~49 new) compile and run on the Korean
Windows 11 host where they belong; cross-compile from Linux is
not configured locally. The smoke script is the production
acceptance gate for rc.8 on Windows.
Deviations from the original plan are minor (single ignored
real-runtime test rather than per-platform attribute, L7 uses
public API rather than pub(crate) dispatchers, dispatchers live
inside windows.rs rather than a sibling module) and documented
in the subagent report.
This commit is contained in:
Generated
+1
@@ -387,6 +387,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tsclientlib",
|
||||
"windows",
|
||||
"zbus",
|
||||
|
||||
@@ -420,3 +420,98 @@ mod tests {
|
||||
assert!(!real_gate.load(), "post-tail: pttActive must be false");
|
||||
}
|
||||
}
|
||||
|
||||
/// Full-chain Windows integration tests (L8 of the P0 release
|
||||
/// plan). Drives the same gate plumbing as production through the
|
||||
/// Windows backends' pure-logic dispatchers (no Win32 message
|
||||
/// pump). Gated `cfg(target_os = "windows")` because the backends
|
||||
/// themselves only exist there.
|
||||
#[cfg(all(test, target_os = "windows"))]
|
||||
mod windows_full_chain_tests {
|
||||
use super::*;
|
||||
use chanora_audio::ptt_backends::{PttBinding, PttInputClass};
|
||||
use chanora_audio::{TransmitMode, TransmitModeSelector};
|
||||
use std::time::Duration;
|
||||
|
||||
fn setup(
|
||||
tail_ms: u32,
|
||||
) -> (
|
||||
AudioTransmitGate,
|
||||
std::sync::Arc<TransmitModeSelector>,
|
||||
std::sync::Arc<ReleaseTailTimer>,
|
||||
) {
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let selector = std::sync::Arc::new(TransmitModeSelector::new(gate.clone()));
|
||||
selector.set_mode(TransmitMode::Ptt);
|
||||
selector.set_in_channel(true);
|
||||
let tail = std::sync::Arc::new(ReleaseTailTimer::new(selector.clone(), tail_ms));
|
||||
(gate, selector, tail)
|
||||
}
|
||||
|
||||
/// Full chain with zero tail: pressing the synthetic press-edge
|
||||
/// gate (the same call the Windows backend would make from its
|
||||
/// dispatcher) drives the real transmit gate true; releasing
|
||||
/// it drives it false. Mirrors what the dispatcher exercise in
|
||||
/// `windows.rs` would produce after `ctx.gate.set(true)`.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn full_chain_press_release_zero_tail() {
|
||||
let (real_gate, _sel, tail) = setup(0);
|
||||
let controller = PttController::new(tail);
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
assert!(!real_gate.load());
|
||||
|
||||
controller.press_gate().set(true);
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
assert!(real_gate.load(), "full chain must follow press");
|
||||
|
||||
controller.press_gate().set(false);
|
||||
tokio::time::sleep(Duration::from_millis(15)).await;
|
||||
assert!(!real_gate.load(), "full chain must follow release");
|
||||
}
|
||||
|
||||
/// Full chain with the SDD-096 default 200 ms tail.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn full_chain_press_release_default_tail() {
|
||||
let (real_gate, _sel, tail) = setup(200);
|
||||
let controller = PttController::new(tail);
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
controller.press_gate().set(true);
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
assert!(real_gate.load());
|
||||
|
||||
controller.press_gate().set(false);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert!(real_gate.load(), "tail window must keep gate true");
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(220)).await;
|
||||
assert!(!real_gate.load(), "post-tail must clear gate");
|
||||
}
|
||||
|
||||
/// Mid-press rebind: while the gate is held, a `set_binding`
|
||||
/// to a different key clears the in-flight press (Raw Input /
|
||||
/// Hook backends call `gate.set(false)` from `rebind`).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn mid_press_rebind_abandons_in_flight_press() {
|
||||
let (real_gate, _sel, tail) = setup(0);
|
||||
let controller = PttController::new(tail);
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
// Press.
|
||||
controller.press_gate().set(true);
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
assert!(real_gate.load());
|
||||
|
||||
// Rebind mid-press. The backend's `rebind` clears the gate.
|
||||
let new_binding = PttBinding {
|
||||
input_class: PttInputClass::Keyboard,
|
||||
platform_key: "F10".to_string(),
|
||||
};
|
||||
let _ = controller.set_binding(new_binding).await;
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
assert!(
|
||||
!real_gate.load(),
|
||||
"rebind during a held press must abandon the press"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ windows = { version = "0.54", features = [
|
||||
# `test-util` enables `start_paused` / virtual-clock tests used by
|
||||
# the missed-key-up watchdog unit tests.
|
||||
tokio = { version = "1", features = ["sync", "rt", "macros", "time", "test-util"] }
|
||||
# Cross-platform recording Layer for the SDD-090 / DEC-027 privacy
|
||||
# invariant integration test (`tests/ptt_privacy.rs`).
|
||||
tracing-subscriber = { version = "0.3", features = ["registry"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# GNOME-on-Wayland Global Push-to-Talk uses the freedesktop
|
||||
|
||||
@@ -77,18 +77,18 @@ pub fn try_select() -> Option<Box<dyn DesktopPttBackend>> {
|
||||
/// `vk` holds the resolved Windows VK_* code when class==Keyboard;
|
||||
/// `mouse_btn` holds 4 (XBUTTON1) or 5 (XBUTTON2) when class==MouseSideButton.
|
||||
#[derive(Default)]
|
||||
struct AtomicBinding {
|
||||
pub(crate) struct AtomicBinding {
|
||||
class: AtomicIsize,
|
||||
vk: AtomicIsize,
|
||||
mouse_btn: AtomicIsize,
|
||||
}
|
||||
|
||||
impl AtomicBinding {
|
||||
fn new() -> Self {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn store(&self, class: u8, vk: u16, mouse_btn: u8) {
|
||||
pub(crate) fn store(&self, class: u8, vk: u16, mouse_btn: u8) {
|
||||
// Order: write fields first, class last, so a concurrent
|
||||
// reader that sees class != 0 also sees a coherent
|
||||
// vk / mouse_btn pair. Acquire/release rather than seqcst
|
||||
@@ -98,19 +98,19 @@ impl AtomicBinding {
|
||||
self.class.store(class as isize, Ordering::Release);
|
||||
}
|
||||
|
||||
fn class(&self) -> u8 {
|
||||
pub(crate) fn class(&self) -> u8 {
|
||||
self.class.load(Ordering::Acquire) as u8
|
||||
}
|
||||
|
||||
fn vk(&self) -> u16 {
|
||||
pub(crate) fn vk(&self) -> u16 {
|
||||
self.vk.load(Ordering::Acquire) as u16
|
||||
}
|
||||
|
||||
fn mouse_btn(&self) -> u8 {
|
||||
pub(crate) fn mouse_btn(&self) -> u8 {
|
||||
self.mouse_btn.load(Ordering::Acquire) as u8
|
||||
}
|
||||
|
||||
fn clear(&self) {
|
||||
pub(crate) fn clear(&self) {
|
||||
self.class.store(0, Ordering::Release);
|
||||
self.vk.store(0, Ordering::Release);
|
||||
self.mouse_btn.store(0, Ordering::Release);
|
||||
@@ -120,7 +120,7 @@ impl AtomicBinding {
|
||||
/// Convert a `PttBinding` into the (class, vk, mouse_btn) triple.
|
||||
/// Returns `None` if the binding cannot be translated (caller
|
||||
/// surfaces `PttBackendError::InvalidBinding`).
|
||||
fn resolve_binding(binding: &PttBinding) -> Option<(u8, u16, u8)> {
|
||||
pub(crate) fn resolve_binding(binding: &PttBinding) -> Option<(u8, u16, u8)> {
|
||||
match binding.input_class {
|
||||
super::PttInputClass::None => Some((0, 0, 0)),
|
||||
super::PttInputClass::Keyboard => {
|
||||
@@ -140,9 +140,13 @@ fn resolve_binding(binding: &PttBinding) -> Option<(u8, u16, u8)> {
|
||||
/// the heap; the message window's `GWLP_USERDATA` is not used —
|
||||
/// the `WndProc` looks up its `RawInputContext` via a thread-local
|
||||
/// because Windows passes no user pointer on `WM_INPUT`.
|
||||
struct RawInputContext {
|
||||
gate: AudioTransmitGate,
|
||||
binding: Arc<AtomicBinding>,
|
||||
///
|
||||
/// Exposed to the test module so unit tests can call
|
||||
/// [`dispatch_raw_input`] directly without spinning up a Win32
|
||||
/// message pump (L0 refactor).
|
||||
pub(crate) struct RawInputContext {
|
||||
pub(crate) gate: AudioTransmitGate,
|
||||
pub(crate) binding: Arc<AtomicBinding>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
@@ -522,62 +526,77 @@ unsafe fn handle_wm_input(lparam: LPARAM) {
|
||||
|
||||
RAWINPUT_CTX.with(|cell| {
|
||||
let borrow = cell.borrow();
|
||||
let Some(ctx) = borrow.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let class = ctx.binding.class();
|
||||
if class == 0 {
|
||||
return;
|
||||
}
|
||||
match raw.header.dwType {
|
||||
t if t == RIM_TYPEKEYBOARD.0 && class == 1 => {
|
||||
let kb = &raw.data.keyboard;
|
||||
let vk = ctx.binding.vk();
|
||||
if kb.VKey == vk {
|
||||
// WM_KEY*DOWN messages have bit 0 of Flags
|
||||
// clear; WM_KEY*UP have bit 0 set.
|
||||
// (RI_KEY_BREAK = 1)
|
||||
if (kb.Flags & 0x01) == 0 {
|
||||
ctx.gate.set(true);
|
||||
} else {
|
||||
ctx.gate.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
t if t == RIM_TYPEMOUSE.0 && class == 2 => {
|
||||
let m = &raw.data.mouse;
|
||||
let want = ctx.binding.mouse_btn();
|
||||
// usButtonFlags is a bitmask of RI_MOUSE_BUTTON_*
|
||||
// constants; the X-button flags live in the
|
||||
// u32 anonymous union under
|
||||
// `m.Anonymous.Anonymous.usButtonFlags`.
|
||||
let flags = m.Anonymous.Anonymous.usButtonFlags;
|
||||
// Constants per Windows SDK
|
||||
// (RawInput RI_MOUSE_*):
|
||||
const RI_MOUSE_BUTTON_4_DOWN: u32 = 0x0040;
|
||||
const RI_MOUSE_BUTTON_4_UP: u32 = 0x0080;
|
||||
const RI_MOUSE_BUTTON_5_DOWN: u32 = 0x0100;
|
||||
const RI_MOUSE_BUTTON_5_UP: u32 = 0x0200;
|
||||
let flags = flags as u32;
|
||||
if want == 4 {
|
||||
if flags & RI_MOUSE_BUTTON_4_DOWN != 0 {
|
||||
ctx.gate.set(true);
|
||||
} else if flags & RI_MOUSE_BUTTON_4_UP != 0 {
|
||||
ctx.gate.set(false);
|
||||
}
|
||||
} else if want == 5 {
|
||||
if flags & RI_MOUSE_BUTTON_5_DOWN != 0 {
|
||||
ctx.gate.set(true);
|
||||
} else if flags & RI_MOUSE_BUTTON_5_UP != 0 {
|
||||
ctx.gate.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
if let Some(ctx) = borrow.as_ref() {
|
||||
dispatch_raw_input(ctx, raw);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Pure-logic dispatcher for a decoded `RAWINPUT` message (L0
|
||||
/// refactor; SDD-083 hot path).
|
||||
///
|
||||
/// The WndProc unpacks the lparam via `GetRawInputData` and then
|
||||
/// calls into this helper. Tests construct a synthetic `RAWINPUT`
|
||||
/// (via `mem::zeroed` + field fill) and call this directly without
|
||||
/// any Win32 plumbing.
|
||||
///
|
||||
/// Privacy: this function is the only place in the backend that
|
||||
/// touches `vk` / `usButtonFlags` integers; the comparison stays
|
||||
/// on the stack and the only side effect is `ctx.gate.set(bool)`.
|
||||
///
|
||||
/// # Safety
|
||||
/// Reads from the `raw.data` union; the caller (the WndProc or a
|
||||
/// test) is responsible for the union being valid for the
|
||||
/// `header.dwType` it set.
|
||||
pub(crate) unsafe fn dispatch_raw_input(ctx: &RawInputContext, raw: &RAWINPUT) {
|
||||
let class = ctx.binding.class();
|
||||
if class == 0 {
|
||||
return;
|
||||
}
|
||||
match raw.header.dwType {
|
||||
t if t == RIM_TYPEKEYBOARD.0 && class == 1 => {
|
||||
let kb = &raw.data.keyboard;
|
||||
let vk = ctx.binding.vk();
|
||||
if kb.VKey == vk {
|
||||
// WM_KEY*DOWN messages have bit 0 of Flags clear;
|
||||
// WM_KEY*UP have bit 0 set (RI_KEY_BREAK = 1).
|
||||
if (kb.Flags & 0x01) == 0 {
|
||||
ctx.gate.set(true);
|
||||
} else {
|
||||
ctx.gate.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
t if t == RIM_TYPEMOUSE.0 && class == 2 => {
|
||||
let m = &raw.data.mouse;
|
||||
let want = ctx.binding.mouse_btn();
|
||||
let flags = m.Anonymous.Anonymous.usButtonFlags as u32;
|
||||
// RI_MOUSE_* constants per the Windows SDK. Hard-coded
|
||||
// here rather than imported because the `windows`
|
||||
// crate version in use only re-exports the
|
||||
// RIM_TYPE_* type tags from the same module.
|
||||
const RI_MOUSE_BUTTON_4_DOWN: u32 = 0x0040;
|
||||
const RI_MOUSE_BUTTON_4_UP: u32 = 0x0080;
|
||||
const RI_MOUSE_BUTTON_5_DOWN: u32 = 0x0100;
|
||||
const RI_MOUSE_BUTTON_5_UP: u32 = 0x0200;
|
||||
if want == 4 {
|
||||
if flags & RI_MOUSE_BUTTON_4_DOWN != 0 {
|
||||
ctx.gate.set(true);
|
||||
} else if flags & RI_MOUSE_BUTTON_4_UP != 0 {
|
||||
ctx.gate.set(false);
|
||||
}
|
||||
} else if want == 5 {
|
||||
if flags & RI_MOUSE_BUTTON_5_DOWN != 0 {
|
||||
ctx.gate.set(true);
|
||||
} else if flags & RI_MOUSE_BUTTON_5_UP != 0 {
|
||||
ctx.gate.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Low-level hook backend ----------
|
||||
|
||||
thread_local! {
|
||||
@@ -587,9 +606,9 @@ thread_local! {
|
||||
const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct HookContext {
|
||||
gate: AudioTransmitGate,
|
||||
binding: Arc<AtomicBinding>,
|
||||
pub(crate) struct HookContext {
|
||||
pub(crate) gate: AudioTransmitGate,
|
||||
pub(crate) binding: Arc<AtomicBinding>,
|
||||
}
|
||||
|
||||
/// Low-level hook backend. Used only when Raw Input fails.
|
||||
@@ -821,51 +840,730 @@ unsafe extern "system" fn kbd_hook_proc(code: i32, wparam: WPARAM, lparam: LPARA
|
||||
HOOK_CTX.with(|cell| {
|
||||
let borrow = cell.borrow();
|
||||
if let Some(ctx) = borrow.as_ref() {
|
||||
if ctx.binding.class() == 1 && kb.vkCode as u16 == ctx.binding.vk() {
|
||||
let down = wparam.0 as u32 == WM_KEYDOWN
|
||||
|| wparam.0 as u32 == WM_SYSKEYDOWN;
|
||||
let up = wparam.0 as u32 == WM_KEYUP
|
||||
|| wparam.0 as u32 == WM_SYSKEYUP;
|
||||
if down {
|
||||
ctx.gate.set(true);
|
||||
} else if up {
|
||||
ctx.gate.set(false);
|
||||
}
|
||||
}
|
||||
dispatch_hook_keyboard(ctx, wparam, kb);
|
||||
}
|
||||
});
|
||||
}
|
||||
CallNextHookEx(HHOOK(0), code, wparam, lparam)
|
||||
}
|
||||
|
||||
/// Pure-logic dispatcher for a low-level keyboard hook event (L0
|
||||
/// refactor; SDD-084 hot path). The hook proc unpacks the
|
||||
/// `KBDLLHOOKSTRUCT` from `lparam` then calls into this helper so
|
||||
/// the tests can exercise the press-edge translation without
|
||||
/// installing a global hook.
|
||||
pub(crate) fn dispatch_hook_keyboard(
|
||||
ctx: &HookContext,
|
||||
wparam: WPARAM,
|
||||
kb: &KBDLLHOOKSTRUCT,
|
||||
) {
|
||||
if ctx.binding.class() != 1 {
|
||||
return;
|
||||
}
|
||||
if kb.vkCode as u16 != ctx.binding.vk() {
|
||||
return;
|
||||
}
|
||||
let w = wparam.0 as u32;
|
||||
let down = w == WM_KEYDOWN || w == WM_SYSKEYDOWN;
|
||||
let up = w == WM_KEYUP || w == WM_SYSKEYUP;
|
||||
if down {
|
||||
ctx.gate.set(true);
|
||||
} else if up {
|
||||
ctx.gate.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "system" fn mouse_hook_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
if code == HC_ACTION as i32 {
|
||||
let m = &*(lparam.0 as *const MSLLHOOKSTRUCT);
|
||||
HOOK_CTX.with(|cell| {
|
||||
let borrow = cell.borrow();
|
||||
if let Some(ctx) = borrow.as_ref() {
|
||||
if ctx.binding.class() == 2 {
|
||||
// For X-button messages mouseData high word
|
||||
// distinguishes XBUTTON1 (1) from XBUTTON2 (2).
|
||||
let xbutton = (m.mouseData >> 16) as u16;
|
||||
let want = ctx.binding.mouse_btn();
|
||||
let want_xbutton = if want == 4 {
|
||||
XBUTTON1
|
||||
} else if want == 5 {
|
||||
XBUTTON2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if xbutton == want_xbutton && want_xbutton != 0 {
|
||||
match wparam.0 as u32 {
|
||||
WM_XBUTTONDOWN => ctx.gate.set(true),
|
||||
WM_XBUTTONUP => ctx.gate.set(false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
dispatch_hook_mouse(ctx, wparam, m);
|
||||
}
|
||||
});
|
||||
}
|
||||
CallNextHookEx(HHOOK(0), code, wparam, lparam)
|
||||
}
|
||||
|
||||
/// Pure-logic dispatcher for a low-level mouse hook event (L0
|
||||
/// refactor; SDD-084 hot path). The mouse hook proc unpacks the
|
||||
/// `MSLLHOOKSTRUCT` from `lparam` then calls into this helper.
|
||||
pub(crate) fn dispatch_hook_mouse(ctx: &HookContext, wparam: WPARAM, m: &MSLLHOOKSTRUCT) {
|
||||
if ctx.binding.class() != 2 {
|
||||
return;
|
||||
}
|
||||
// For X-button messages mouseData high word distinguishes
|
||||
// XBUTTON1 (1) from XBUTTON2 (2).
|
||||
let xbutton = (m.mouseData >> 16) as u16;
|
||||
let want = ctx.binding.mouse_btn();
|
||||
let want_xbutton = if want == 4 {
|
||||
XBUTTON1
|
||||
} else if want == 5 {
|
||||
XBUTTON2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if xbutton != want_xbutton || want_xbutton == 0 {
|
||||
return;
|
||||
}
|
||||
match wparam.0 as u32 {
|
||||
WM_XBUTTONDOWN => ctx.gate.set(true),
|
||||
WM_XBUTTONUP => ctx.gate.set(false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Tests (Windows-only) ----------
|
||||
//
|
||||
// These unit tests exercise the pure-logic dispatchers refactored
|
||||
// out of the WndProc / hook procs (L0). The dispatchers can be
|
||||
// called directly with a constructed `RawInputContext` /
|
||||
// `HookContext` and a synthetic `RAWINPUT` / `KBDLLHOOKSTRUCT` /
|
||||
// `MSLLHOOKSTRUCT` — no Win32 message pump is involved, so the
|
||||
// tests are deterministic and fast.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ptt_backends::PttInputClass;
|
||||
|
||||
fn fresh_binding() -> Arc<AtomicBinding> {
|
||||
Arc::new(AtomicBinding::new())
|
||||
}
|
||||
|
||||
// ---- L2: AtomicBinding ----
|
||||
|
||||
#[test]
|
||||
fn atomic_binding_default_is_all_zero() {
|
||||
let b = AtomicBinding::new();
|
||||
assert_eq!(b.class(), 0);
|
||||
assert_eq!(b.vk(), 0);
|
||||
assert_eq!(b.mouse_btn(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_binding_store_round_trip() {
|
||||
let b = AtomicBinding::new();
|
||||
b.store(1, 0x42, 0);
|
||||
assert_eq!(b.class(), 1);
|
||||
assert_eq!(b.vk(), 0x42);
|
||||
assert_eq!(b.mouse_btn(), 0);
|
||||
|
||||
b.store(2, 0, 5);
|
||||
assert_eq!(b.class(), 2);
|
||||
assert_eq!(b.vk(), 0);
|
||||
assert_eq!(b.mouse_btn(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_binding_clear_resets_all_fields() {
|
||||
let b = AtomicBinding::new();
|
||||
b.store(1, 0x42, 7);
|
||||
b.clear();
|
||||
assert_eq!(b.class(), 0);
|
||||
assert_eq!(b.vk(), 0);
|
||||
assert_eq!(b.mouse_btn(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_binding_u8_and_u16_no_torn_reads() {
|
||||
// Atomic semantics on individual fields preclude torn
|
||||
// reads. Smoke-check by toggling a non-zero value.
|
||||
let b = AtomicBinding::new();
|
||||
for v in [0u16, 1, 0xFF, 0xFFFE, 0x7F00] {
|
||||
b.store(1, v, 0);
|
||||
assert_eq!(b.vk(), v);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_binding_concurrent_single_writer_single_reader() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::thread;
|
||||
let b = Arc::new(AtomicBinding::new());
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let bw = b.clone();
|
||||
let stopw = stop.clone();
|
||||
let writer = thread::spawn(move || {
|
||||
for i in 0..10_000u32 {
|
||||
if i % 2 == 0 {
|
||||
bw.store(1, 0x41, 0);
|
||||
} else {
|
||||
bw.store(2, 0, 5);
|
||||
}
|
||||
if stopw.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let br = b.clone();
|
||||
let stopr = stop.clone();
|
||||
let reader = thread::spawn(move || {
|
||||
for _ in 0..10_000 {
|
||||
let c = br.class();
|
||||
let vk = br.vk();
|
||||
let m = br.mouse_btn();
|
||||
// Coherence isn't guaranteed across the three
|
||||
// fields (each is independently atomic). Just
|
||||
// assert each is a sane u8/u16 value — the test
|
||||
// is for "no panic, no torn read".
|
||||
assert!(c <= 2);
|
||||
let _ = vk;
|
||||
let _ = m;
|
||||
if stopr.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
writer.join().unwrap();
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
reader.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_binding_many_readers_single_writer() {
|
||||
use std::thread;
|
||||
let b = Arc::new(AtomicBinding::new());
|
||||
b.store(1, 0x42, 0);
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..4 {
|
||||
let br = b.clone();
|
||||
handles.push(thread::spawn(move || {
|
||||
for _ in 0..5_000 {
|
||||
let c = br.class();
|
||||
assert!(c <= 2);
|
||||
}
|
||||
}));
|
||||
}
|
||||
let bw = b.clone();
|
||||
let writer = thread::spawn(move || {
|
||||
for i in 0..5_000u16 {
|
||||
bw.store(1, i, 0);
|
||||
}
|
||||
});
|
||||
writer.join().unwrap();
|
||||
for h in handles {
|
||||
h.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- L3: resolve_binding dispatcher ----
|
||||
|
||||
fn binding(class: PttInputClass, key: &str) -> PttBinding {
|
||||
PttBinding {
|
||||
input_class: class,
|
||||
platform_key: key.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_binding_none_returns_zero_triple() {
|
||||
// PttInputClass::None is the "no binding" path. The
|
||||
// dispatcher returns Some((0,0,0)) so callers can store
|
||||
// the cleared values atomically without a separate
|
||||
// "not bound" branch.
|
||||
let r = resolve_binding(&binding(PttInputClass::None, ""));
|
||||
assert_eq!(r, Some((0, 0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_binding_keyboard_space() {
|
||||
let r = resolve_binding(&binding(PttInputClass::Keyboard, "Space"));
|
||||
assert_eq!(r, Some((1, 0x20, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_binding_keyboard_letter_a() {
|
||||
let r = resolve_binding(&binding(PttInputClass::Keyboard, "A"));
|
||||
assert_eq!(r, Some((1, 0x41, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_binding_keyboard_f10() {
|
||||
let r = resolve_binding(&binding(PttInputClass::Keyboard, "F10"));
|
||||
assert_eq!(r, Some((1, 0x79, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_binding_keyboard_gibberish_returns_none() {
|
||||
let r = resolve_binding(&binding(PttInputClass::Keyboard, "GibberishKey"));
|
||||
assert_eq!(r, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_binding_keyboard_empty_returns_none() {
|
||||
let r = resolve_binding(&binding(PttInputClass::Keyboard, ""));
|
||||
assert_eq!(r, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_binding_mouse_back_button() {
|
||||
let r = resolve_binding(&binding(
|
||||
PttInputClass::MouseSideButton,
|
||||
"mouse-side-button:8",
|
||||
));
|
||||
assert_eq!(r, Some((2, 0, 4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_binding_mouse_forward_button() {
|
||||
let r = resolve_binding(&binding(
|
||||
PttInputClass::MouseSideButton,
|
||||
"mouse-side-button:16",
|
||||
));
|
||||
assert_eq!(r, Some((2, 0, 5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_binding_mouse_unknown_bitmask_returns_none() {
|
||||
let r = resolve_binding(&binding(
|
||||
PttInputClass::MouseSideButton,
|
||||
"mouse-side-button:99",
|
||||
));
|
||||
assert_eq!(r, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_binding_mouse_empty_returns_none() {
|
||||
let r = resolve_binding(&binding(PttInputClass::MouseSideButton, ""));
|
||||
assert_eq!(r, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_binding_mismatched_class_and_key_returns_none() {
|
||||
// Keyboard class + mouse-side-button key string: the
|
||||
// keymap parses the string as a key label and finds no
|
||||
// match → None.
|
||||
let r = resolve_binding(&binding(
|
||||
PttInputClass::Keyboard,
|
||||
"mouse-side-button:8",
|
||||
));
|
||||
assert_eq!(r, None);
|
||||
// MouseSideButton class + plain key label: mouse-side
|
||||
// parser rejects strings without the prefix → None.
|
||||
let r = resolve_binding(&binding(PttInputClass::MouseSideButton, "Space"));
|
||||
assert_eq!(r, None);
|
||||
}
|
||||
|
||||
// ---- L4: Backend state machine ----
|
||||
|
||||
#[test]
|
||||
fn raw_input_backend_descriptor_before_start_is_l0() {
|
||||
let b = WindowsRawInputBackend::try_new().expect("backend constructible");
|
||||
let d = b.descriptor();
|
||||
assert_eq!(d.level, PttCapabilityLevel::L0Focused);
|
||||
assert_eq!(d.backend_id, "raw-input");
|
||||
assert_eq!(d.bound_input_class, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_backend_descriptor_before_start_is_l0() {
|
||||
let b = WindowsHookBackend::try_new().expect("backend constructible");
|
||||
let d = b.descriptor();
|
||||
assert_eq!(d.level, PttCapabilityLevel::L0Focused);
|
||||
assert_eq!(d.backend_id, "low-level-hook");
|
||||
assert_eq!(d.bound_input_class, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_input_backend_descriptor_after_arm_keyboard_is_l2() {
|
||||
// Synthesise an "armed" state without actually starting
|
||||
// the message pump by writing the AtomicBool + binding
|
||||
// directly. The real `start()` path is exercised on the
|
||||
// Windows runtime under the #[ignore]'d test below.
|
||||
let b = WindowsRawInputBackend::try_new().unwrap();
|
||||
b.binding.store(1, 0x42, 0);
|
||||
b.is_mouse.store(false, Ordering::Release);
|
||||
b.armed.store(true, Ordering::Release);
|
||||
let d = b.descriptor();
|
||||
assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk);
|
||||
assert_eq!(d.bound_input_class, Some("keyboard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_input_backend_descriptor_after_arm_mouse_is_l3() {
|
||||
let b = WindowsRawInputBackend::try_new().unwrap();
|
||||
b.binding.store(2, 0, 4);
|
||||
b.is_mouse.store(true, Ordering::Release);
|
||||
b.armed.store(true, Ordering::Release);
|
||||
let d = b.descriptor();
|
||||
assert_eq!(d.level, PttCapabilityLevel::L3GlobalWithMouseButtons);
|
||||
assert_eq!(d.bound_input_class, Some("mouse-side-button"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_backend_descriptor_after_arm_keyboard_is_l2() {
|
||||
let b = WindowsHookBackend::try_new().unwrap();
|
||||
b.binding.store(1, 0x42, 0);
|
||||
b.is_mouse.store(false, Ordering::Release);
|
||||
b.armed.store(true, Ordering::Release);
|
||||
let d = b.descriptor();
|
||||
assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk);
|
||||
assert_eq!(d.bound_input_class, Some("keyboard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_backend_descriptor_after_arm_mouse_is_l3() {
|
||||
let b = WindowsHookBackend::try_new().unwrap();
|
||||
b.binding.store(2, 0, 5);
|
||||
b.is_mouse.store(true, Ordering::Release);
|
||||
b.armed.store(true, Ordering::Release);
|
||||
let d = b.descriptor();
|
||||
assert_eq!(d.level, PttCapabilityLevel::L3GlobalWithMouseButtons);
|
||||
assert_eq!(d.bound_input_class, Some("mouse-side-button"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_input_backend_start_rejects_invalid_binding() {
|
||||
let mut b = WindowsRawInputBackend::try_new().unwrap();
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let bad = binding(PttInputClass::Keyboard, "Banana");
|
||||
let r = b.start(gate, bad);
|
||||
match r {
|
||||
Err(PttBackendError::InvalidBinding(_)) => {}
|
||||
other => panic!("expected InvalidBinding, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_backend_start_rejects_invalid_binding() {
|
||||
let mut b = WindowsHookBackend::try_new().unwrap();
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let bad = binding(PttInputClass::Keyboard, "Banana");
|
||||
let r = b.start(gate, bad);
|
||||
match r {
|
||||
Err(PttBackendError::InvalidBinding(_)) => {}
|
||||
other => panic!("expected InvalidBinding, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_input_backend_rebind_to_none_leaves_class_zero() {
|
||||
let b = WindowsRawInputBackend::try_new().unwrap();
|
||||
b.binding.store(1, 0x42, 0);
|
||||
// Simulate rebind to None by storing the resolved triple
|
||||
// directly (resolve_binding(None) yields (0,0,0)).
|
||||
let resolved = resolve_binding(&PttBinding::none()).unwrap();
|
||||
b.binding.store(resolved.0, resolved.1, resolved.2);
|
||||
assert_eq!(b.binding.class(), 0);
|
||||
assert_eq!(b.binding.vk(), 0);
|
||||
assert_eq!(b.binding.mouse_btn(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_input_backend_stop_is_idempotent_when_never_started() {
|
||||
let mut b = WindowsRawInputBackend::try_new().unwrap();
|
||||
b.stop();
|
||||
b.stop();
|
||||
assert!(!b.armed.load(Ordering::Acquire));
|
||||
assert_eq!(b.binding.class(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hook_backend_stop_is_idempotent_when_never_started() {
|
||||
let mut b = WindowsHookBackend::try_new().unwrap();
|
||||
b.stop();
|
||||
b.stop();
|
||||
assert!(!b.armed.load(Ordering::Acquire));
|
||||
assert_eq!(b.binding.class(), 0);
|
||||
}
|
||||
|
||||
/// Real-runtime arm of the Raw Input backend. Requires a
|
||||
/// Windows message pump and Raw Input registration access, so
|
||||
/// the test is gated `#[ignore]` and only runs when explicitly
|
||||
/// requested with `cargo test -- --ignored` on the Korean
|
||||
/// host.
|
||||
#[test]
|
||||
#[ignore = "requires real Windows runtime; runs on the host smoke pass"]
|
||||
fn raw_input_backend_real_start_succeeds() {
|
||||
let mut b = WindowsRawInputBackend::try_new().unwrap();
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
b.start(gate, binding(PttInputClass::Keyboard, "Space"))
|
||||
.expect("real RegisterRawInputDevices should succeed on a Windows desktop");
|
||||
b.stop();
|
||||
}
|
||||
|
||||
// ---- L5: dispatch_raw_input ----
|
||||
|
||||
/// Construct a zero-filled RAWINPUT then fill the header and
|
||||
/// the keyboard / mouse union member as needed.
|
||||
unsafe fn zeroed_raw_input() -> RAWINPUT {
|
||||
std::mem::zeroed::<RAWINPUT>()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_raw_input_keyboard_match_press_then_release_drives_gate() {
|
||||
let bind = fresh_binding();
|
||||
bind.store(1, 0x41, 0);
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let ctx = RawInputContext {
|
||||
gate: gate.clone(),
|
||||
binding: bind,
|
||||
};
|
||||
unsafe {
|
||||
let mut raw = zeroed_raw_input();
|
||||
raw.header.dwType = RIM_TYPEKEYBOARD.0;
|
||||
raw.data.keyboard.VKey = 0x41;
|
||||
raw.data.keyboard.Flags = 0; // down
|
||||
dispatch_raw_input(&ctx, &raw);
|
||||
assert!(gate.load(), "down edge must set the gate");
|
||||
|
||||
raw.data.keyboard.Flags = 1; // up (RI_KEY_BREAK)
|
||||
dispatch_raw_input(&ctx, &raw);
|
||||
assert!(!gate.load(), "up edge must clear the gate");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_raw_input_keyboard_non_matching_vk_leaves_gate_unchanged() {
|
||||
let bind = fresh_binding();
|
||||
bind.store(1, 0x41, 0);
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let ctx = RawInputContext {
|
||||
gate: gate.clone(),
|
||||
binding: bind,
|
||||
};
|
||||
unsafe {
|
||||
let mut raw = zeroed_raw_input();
|
||||
raw.header.dwType = RIM_TYPEKEYBOARD.0;
|
||||
raw.data.keyboard.VKey = 0x42; // bound is A, this is B
|
||||
raw.data.keyboard.Flags = 0;
|
||||
dispatch_raw_input(&ctx, &raw);
|
||||
assert!(!gate.load(), "non-matching key must not fire the gate");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_raw_input_no_binding_class_ignores_all_events() {
|
||||
let bind = fresh_binding(); // class=0
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let ctx = RawInputContext {
|
||||
gate: gate.clone(),
|
||||
binding: bind,
|
||||
};
|
||||
unsafe {
|
||||
let mut raw = zeroed_raw_input();
|
||||
raw.header.dwType = RIM_TYPEKEYBOARD.0;
|
||||
raw.data.keyboard.VKey = 0x41;
|
||||
raw.data.keyboard.Flags = 0;
|
||||
dispatch_raw_input(&ctx, &raw);
|
||||
assert!(!gate.load());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_raw_input_mouse_button4_press_release_drives_gate() {
|
||||
let bind = fresh_binding();
|
||||
bind.store(2, 0, 4);
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let ctx = RawInputContext {
|
||||
gate: gate.clone(),
|
||||
binding: bind,
|
||||
};
|
||||
// RI_MOUSE_BUTTON_4_DOWN = 0x0040, _UP = 0x0080.
|
||||
unsafe {
|
||||
let mut raw = zeroed_raw_input();
|
||||
raw.header.dwType = RIM_TYPEMOUSE.0;
|
||||
raw.data.mouse.Anonymous.Anonymous.usButtonFlags = 0x0040;
|
||||
dispatch_raw_input(&ctx, &raw);
|
||||
assert!(gate.load());
|
||||
|
||||
raw.data.mouse.Anonymous.Anonymous.usButtonFlags = 0x0080;
|
||||
dispatch_raw_input(&ctx, &raw);
|
||||
assert!(!gate.load());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_raw_input_mouse_button5_press_release_drives_gate() {
|
||||
let bind = fresh_binding();
|
||||
bind.store(2, 0, 5);
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let ctx = RawInputContext {
|
||||
gate: gate.clone(),
|
||||
binding: bind,
|
||||
};
|
||||
// RI_MOUSE_BUTTON_5_DOWN = 0x0100, _UP = 0x0200.
|
||||
unsafe {
|
||||
let mut raw = zeroed_raw_input();
|
||||
raw.header.dwType = RIM_TYPEMOUSE.0;
|
||||
raw.data.mouse.Anonymous.Anonymous.usButtonFlags = 0x0100;
|
||||
dispatch_raw_input(&ctx, &raw);
|
||||
assert!(gate.load());
|
||||
|
||||
raw.data.mouse.Anonymous.Anonymous.usButtonFlags = 0x0200;
|
||||
dispatch_raw_input(&ctx, &raw);
|
||||
assert!(!gate.load());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_raw_input_mouse_button4_event_ignored_when_bound_to_button5() {
|
||||
let bind = fresh_binding();
|
||||
bind.store(2, 0, 5); // bound to XBUTTON2
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let ctx = RawInputContext {
|
||||
gate: gate.clone(),
|
||||
binding: bind,
|
||||
};
|
||||
unsafe {
|
||||
let mut raw = zeroed_raw_input();
|
||||
raw.header.dwType = RIM_TYPEMOUSE.0;
|
||||
raw.data.mouse.Anonymous.Anonymous.usButtonFlags = 0x0040; // BTN4 down
|
||||
dispatch_raw_input(&ctx, &raw);
|
||||
assert!(!gate.load());
|
||||
raw.data.mouse.Anonymous.Anonymous.usButtonFlags = 0x0080; // BTN4 up
|
||||
dispatch_raw_input(&ctx, &raw);
|
||||
assert!(!gate.load());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_raw_input_hid_type_is_ignored() {
|
||||
// dwType = RIM_TYPEHID = 2 in the Win32 SDK. Use the
|
||||
// integer directly since the constant is not re-exported
|
||||
// from the `windows` crate's UI::Input module.
|
||||
const RIM_TYPEHID: u32 = 2;
|
||||
let bind = fresh_binding();
|
||||
bind.store(1, 0x41, 0);
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let ctx = RawInputContext {
|
||||
gate: gate.clone(),
|
||||
binding: bind,
|
||||
};
|
||||
unsafe {
|
||||
let mut raw = zeroed_raw_input();
|
||||
raw.header.dwType = RIM_TYPEHID;
|
||||
dispatch_raw_input(&ctx, &raw);
|
||||
assert!(!gate.load());
|
||||
}
|
||||
}
|
||||
|
||||
// ---- L6: dispatch_hook_keyboard + dispatch_hook_mouse ----
|
||||
|
||||
fn zeroed_kbd() -> KBDLLHOOKSTRUCT {
|
||||
unsafe { std::mem::zeroed() }
|
||||
}
|
||||
|
||||
fn zeroed_mouse() -> MSLLHOOKSTRUCT {
|
||||
unsafe { std::mem::zeroed() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_hook_keyboard_match_keydown_then_keyup_drives_gate() {
|
||||
let bind = fresh_binding();
|
||||
bind.store(1, 0x41, 0);
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let ctx = HookContext {
|
||||
gate: gate.clone(),
|
||||
binding: bind,
|
||||
};
|
||||
let mut kb = zeroed_kbd();
|
||||
kb.vkCode = 0x41;
|
||||
dispatch_hook_keyboard(&ctx, WPARAM(WM_KEYDOWN as usize), &kb);
|
||||
assert!(gate.load());
|
||||
dispatch_hook_keyboard(&ctx, WPARAM(WM_KEYUP as usize), &kb);
|
||||
assert!(!gate.load());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_hook_keyboard_match_syskeydown_and_syskeyup_drives_gate() {
|
||||
let bind = fresh_binding();
|
||||
bind.store(1, 0x12, 0); // VK_MENU (Alt)
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let ctx = HookContext {
|
||||
gate: gate.clone(),
|
||||
binding: bind,
|
||||
};
|
||||
let mut kb = zeroed_kbd();
|
||||
kb.vkCode = 0x12;
|
||||
dispatch_hook_keyboard(&ctx, WPARAM(WM_SYSKEYDOWN as usize), &kb);
|
||||
assert!(gate.load());
|
||||
dispatch_hook_keyboard(&ctx, WPARAM(WM_SYSKEYUP as usize), &kb);
|
||||
assert!(!gate.load());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_hook_keyboard_non_matching_vk_leaves_gate_unchanged() {
|
||||
let bind = fresh_binding();
|
||||
bind.store(1, 0x41, 0);
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let ctx = HookContext {
|
||||
gate: gate.clone(),
|
||||
binding: bind,
|
||||
};
|
||||
let mut kb = zeroed_kbd();
|
||||
kb.vkCode = 0x42;
|
||||
dispatch_hook_keyboard(&ctx, WPARAM(WM_KEYDOWN as usize), &kb);
|
||||
assert!(!gate.load());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_hook_keyboard_ignored_when_class_is_not_keyboard() {
|
||||
let bind = fresh_binding();
|
||||
bind.store(2, 0, 4); // mouse class
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let ctx = HookContext {
|
||||
gate: gate.clone(),
|
||||
binding: bind,
|
||||
};
|
||||
let mut kb = zeroed_kbd();
|
||||
kb.vkCode = 0x41;
|
||||
dispatch_hook_keyboard(&ctx, WPARAM(WM_KEYDOWN as usize), &kb);
|
||||
assert!(!gate.load());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_hook_mouse_match_xbutton1_press_release_drives_gate() {
|
||||
let bind = fresh_binding();
|
||||
bind.store(2, 0, 4);
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let ctx = HookContext {
|
||||
gate: gate.clone(),
|
||||
binding: bind,
|
||||
};
|
||||
let mut m = zeroed_mouse();
|
||||
// XBUTTON1 = 0x0001 in the high word of mouseData.
|
||||
m.mouseData = (XBUTTON1 as u32) << 16;
|
||||
dispatch_hook_mouse(&ctx, WPARAM(WM_XBUTTONDOWN as usize), &m);
|
||||
assert!(gate.load());
|
||||
dispatch_hook_mouse(&ctx, WPARAM(WM_XBUTTONUP as usize), &m);
|
||||
assert!(!gate.load());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_hook_mouse_match_xbutton2_press_release_drives_gate() {
|
||||
let bind = fresh_binding();
|
||||
bind.store(2, 0, 5);
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let ctx = HookContext {
|
||||
gate: gate.clone(),
|
||||
binding: bind,
|
||||
};
|
||||
let mut m = zeroed_mouse();
|
||||
m.mouseData = (XBUTTON2 as u32) << 16;
|
||||
dispatch_hook_mouse(&ctx, WPARAM(WM_XBUTTONDOWN as usize), &m);
|
||||
assert!(gate.load());
|
||||
dispatch_hook_mouse(&ctx, WPARAM(WM_XBUTTONUP as usize), &m);
|
||||
assert!(!gate.load());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_hook_mouse_wrong_xbutton_is_ignored() {
|
||||
let bind = fresh_binding();
|
||||
bind.store(2, 0, 4); // bound to XBUTTON1
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let ctx = HookContext {
|
||||
gate: gate.clone(),
|
||||
binding: bind,
|
||||
};
|
||||
let mut m = zeroed_mouse();
|
||||
m.mouseData = (XBUTTON2 as u32) << 16; // wrong button
|
||||
dispatch_hook_mouse(&ctx, WPARAM(WM_XBUTTONDOWN as usize), &m);
|
||||
assert!(!gate.load());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ pub fn mouse_side_button_index(platform_key: &str) -> Option<u8> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse as kbd;
|
||||
|
||||
#[test]
|
||||
fn ascii_letters_map_to_vk() {
|
||||
@@ -189,4 +190,222 @@ mod tests {
|
||||
assert_eq!(mouse_side_button_index("keyboard"), None);
|
||||
assert_eq!(mouse_side_button_index("mouse-side-button:32"), None);
|
||||
}
|
||||
|
||||
/// Comprehensive table sweep over every entry in the
|
||||
/// `key_label_to_vk` match arms. The list is intentionally
|
||||
/// verbose so a regression that drops a single key surfaces
|
||||
/// here with a precise label.
|
||||
#[test]
|
||||
fn key_label_to_vk_full_table() {
|
||||
let cases: &[(&str, u16)] = &[
|
||||
// Whitespace
|
||||
("Space", kbd::VK_SPACE.0),
|
||||
(" ", kbd::VK_SPACE.0),
|
||||
("Enter", kbd::VK_RETURN.0),
|
||||
("Tab", kbd::VK_TAB.0),
|
||||
("Backspace", kbd::VK_BACK.0),
|
||||
("Escape", kbd::VK_ESCAPE.0),
|
||||
// Navigation
|
||||
("Arrow Up", kbd::VK_UP.0),
|
||||
("ArrowUp", kbd::VK_UP.0),
|
||||
("Arrow Down", kbd::VK_DOWN.0),
|
||||
("ArrowDown", kbd::VK_DOWN.0),
|
||||
("Arrow Left", kbd::VK_LEFT.0),
|
||||
("ArrowLeft", kbd::VK_LEFT.0),
|
||||
("Arrow Right", kbd::VK_RIGHT.0),
|
||||
("ArrowRight", kbd::VK_RIGHT.0),
|
||||
("Home", kbd::VK_HOME.0),
|
||||
("End", kbd::VK_END.0),
|
||||
("Page Up", kbd::VK_PRIOR.0),
|
||||
("PageUp", kbd::VK_PRIOR.0),
|
||||
("Page Down", kbd::VK_NEXT.0),
|
||||
("PageDown", kbd::VK_NEXT.0),
|
||||
("Insert", kbd::VK_INSERT.0),
|
||||
("Delete", kbd::VK_DELETE.0),
|
||||
// Modifiers
|
||||
("Shift", kbd::VK_LSHIFT.0),
|
||||
("Shift Left", kbd::VK_LSHIFT.0),
|
||||
("ShiftLeft", kbd::VK_LSHIFT.0),
|
||||
("Shift Right", kbd::VK_RSHIFT.0),
|
||||
("ShiftRight", kbd::VK_RSHIFT.0),
|
||||
("Control", kbd::VK_LCONTROL.0),
|
||||
("Control Left", kbd::VK_LCONTROL.0),
|
||||
("ControlLeft", kbd::VK_LCONTROL.0),
|
||||
("Control Right", kbd::VK_RCONTROL.0),
|
||||
("ControlRight", kbd::VK_RCONTROL.0),
|
||||
("Alt", kbd::VK_LMENU.0),
|
||||
("Alt Left", kbd::VK_LMENU.0),
|
||||
("AltLeft", kbd::VK_LMENU.0),
|
||||
("Alt Right", kbd::VK_RMENU.0),
|
||||
("AltRight", kbd::VK_RMENU.0),
|
||||
("Meta", kbd::VK_LWIN.0),
|
||||
("Meta Left", kbd::VK_LWIN.0),
|
||||
("MetaLeft", kbd::VK_LWIN.0),
|
||||
("Meta Right", kbd::VK_RWIN.0),
|
||||
("MetaRight", kbd::VK_RWIN.0),
|
||||
("Caps Lock", kbd::VK_CAPITAL.0),
|
||||
("CapsLock", kbd::VK_CAPITAL.0),
|
||||
// F1..F20
|
||||
("F1", kbd::VK_F1.0),
|
||||
("F2", kbd::VK_F2.0),
|
||||
("F3", kbd::VK_F3.0),
|
||||
("F4", kbd::VK_F4.0),
|
||||
("F5", kbd::VK_F5.0),
|
||||
("F6", kbd::VK_F6.0),
|
||||
("F7", kbd::VK_F7.0),
|
||||
("F8", kbd::VK_F8.0),
|
||||
("F9", kbd::VK_F9.0),
|
||||
("F10", kbd::VK_F10.0),
|
||||
("F11", kbd::VK_F11.0),
|
||||
("F12", kbd::VK_F12.0),
|
||||
("F13", kbd::VK_F13.0),
|
||||
("F14", kbd::VK_F14.0),
|
||||
("F15", kbd::VK_F15.0),
|
||||
("F16", kbd::VK_F16.0),
|
||||
("F17", kbd::VK_F17.0),
|
||||
("F18", kbd::VK_F18.0),
|
||||
("F19", kbd::VK_F19.0),
|
||||
("F20", kbd::VK_F20.0),
|
||||
// OEM punctuation
|
||||
(";", kbd::VK_OEM_1.0),
|
||||
("Semicolon", kbd::VK_OEM_1.0),
|
||||
("=", kbd::VK_OEM_PLUS.0),
|
||||
("Equal", kbd::VK_OEM_PLUS.0),
|
||||
(",", kbd::VK_OEM_COMMA.0),
|
||||
("Comma", kbd::VK_OEM_COMMA.0),
|
||||
("-", kbd::VK_OEM_MINUS.0),
|
||||
("Minus", kbd::VK_OEM_MINUS.0),
|
||||
(".", kbd::VK_OEM_PERIOD.0),
|
||||
("Period", kbd::VK_OEM_PERIOD.0),
|
||||
("/", kbd::VK_OEM_2.0),
|
||||
("Slash", kbd::VK_OEM_2.0),
|
||||
("`", kbd::VK_OEM_3.0),
|
||||
("Backquote", kbd::VK_OEM_3.0),
|
||||
("[", kbd::VK_OEM_4.0),
|
||||
("BracketLeft", kbd::VK_OEM_4.0),
|
||||
("\\", kbd::VK_OEM_5.0),
|
||||
("Backslash", kbd::VK_OEM_5.0),
|
||||
("]", kbd::VK_OEM_6.0),
|
||||
("BracketRight", kbd::VK_OEM_6.0),
|
||||
("'", kbd::VK_OEM_7.0),
|
||||
("Quote", kbd::VK_OEM_7.0),
|
||||
// Numpad
|
||||
("Numpad 0", kbd::VK_NUMPAD0.0),
|
||||
("Numpad0", kbd::VK_NUMPAD0.0),
|
||||
("Numpad 1", kbd::VK_NUMPAD1.0),
|
||||
("Numpad1", kbd::VK_NUMPAD1.0),
|
||||
("Numpad 2", kbd::VK_NUMPAD2.0),
|
||||
("Numpad2", kbd::VK_NUMPAD2.0),
|
||||
("Numpad 3", kbd::VK_NUMPAD3.0),
|
||||
("Numpad3", kbd::VK_NUMPAD3.0),
|
||||
("Numpad 4", kbd::VK_NUMPAD4.0),
|
||||
("Numpad4", kbd::VK_NUMPAD4.0),
|
||||
("Numpad 5", kbd::VK_NUMPAD5.0),
|
||||
("Numpad5", kbd::VK_NUMPAD5.0),
|
||||
("Numpad 6", kbd::VK_NUMPAD6.0),
|
||||
("Numpad6", kbd::VK_NUMPAD6.0),
|
||||
("Numpad 7", kbd::VK_NUMPAD7.0),
|
||||
("Numpad7", kbd::VK_NUMPAD7.0),
|
||||
("Numpad 8", kbd::VK_NUMPAD8.0),
|
||||
("Numpad8", kbd::VK_NUMPAD8.0),
|
||||
("Numpad 9", kbd::VK_NUMPAD9.0),
|
||||
("Numpad9", kbd::VK_NUMPAD9.0),
|
||||
("Numpad Enter", kbd::VK_RETURN.0),
|
||||
("NumpadEnter", kbd::VK_RETURN.0),
|
||||
("Numpad Multiply", kbd::VK_MULTIPLY.0),
|
||||
("NumpadMultiply", kbd::VK_MULTIPLY.0),
|
||||
("Numpad Add", kbd::VK_ADD.0),
|
||||
("NumpadAdd", kbd::VK_ADD.0),
|
||||
("Numpad Subtract", kbd::VK_SUBTRACT.0),
|
||||
("NumpadSubtract", kbd::VK_SUBTRACT.0),
|
||||
("Numpad Decimal", kbd::VK_DECIMAL.0),
|
||||
("NumpadDecimal", kbd::VK_DECIMAL.0),
|
||||
("Numpad Divide", kbd::VK_DIVIDE.0),
|
||||
("NumpadDivide", kbd::VK_DIVIDE.0),
|
||||
("Num Lock", kbd::VK_NUMLOCK.0),
|
||||
("NumLock", kbd::VK_NUMLOCK.0),
|
||||
];
|
||||
for (label, want) in cases {
|
||||
assert_eq!(
|
||||
key_label_to_vk(label),
|
||||
Some(*want),
|
||||
"key_label_to_vk({label:?}) mismatch"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// All uppercase ASCII letters A..Z map to their ASCII byte
|
||||
/// value, which by Win32 convention equals the VK_* code.
|
||||
#[test]
|
||||
fn all_ascii_uppercase_letters_round_trip() {
|
||||
for c in b'A'..=b'Z' {
|
||||
let s = (c as char).to_string();
|
||||
assert_eq!(key_label_to_vk(&s), Some(c as u16), "letter {s}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercase letters normalise to the uppercase VK_*.
|
||||
#[test]
|
||||
fn all_ascii_lowercase_letters_normalise() {
|
||||
for c in b'a'..=b'z' {
|
||||
let s = (c as char).to_string();
|
||||
let want = (c - b'a' + b'A') as u16;
|
||||
assert_eq!(key_label_to_vk(&s), Some(want), "letter {s}");
|
||||
}
|
||||
}
|
||||
|
||||
/// All decimal digits 0..9 map to their ASCII byte value.
|
||||
#[test]
|
||||
fn all_digits_round_trip() {
|
||||
for c in b'0'..=b'9' {
|
||||
let s = (c as char).to_string();
|
||||
assert_eq!(key_label_to_vk(&s), Some(c as u16), "digit {s}");
|
||||
}
|
||||
}
|
||||
|
||||
/// F1..F20 sweep, asserted as a contiguous range starting at
|
||||
/// VK_F1 = 0x70.
|
||||
#[test]
|
||||
fn function_keys_form_contiguous_range() {
|
||||
for n in 1u8..=20 {
|
||||
let label = format!("F{n}");
|
||||
let want = 0x70 + (n as u16 - 1);
|
||||
assert_eq!(key_label_to_vk(&label), Some(want), "function key {label}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Unknown / empty / whitespace labels return `None`. The
|
||||
/// caller surfaces this as `PttBackendError::InvalidBinding`.
|
||||
#[test]
|
||||
fn unknown_and_garbage_labels_return_none() {
|
||||
assert_eq!(key_label_to_vk("Banana"), None);
|
||||
assert_eq!(key_label_to_vk(""), None);
|
||||
assert_eq!(key_label_to_vk(" "), None);
|
||||
assert_eq!(key_label_to_vk("F25"), None);
|
||||
assert_eq!(key_label_to_vk("F0"), None);
|
||||
assert_eq!(key_label_to_vk("Scroll Lock"), None);
|
||||
assert_eq!(key_label_to_vk("Eject"), None);
|
||||
}
|
||||
|
||||
/// Comprehensive mouse-side-button sweep including malformed
|
||||
/// suffixes.
|
||||
#[test]
|
||||
fn mouse_side_button_index_full_table() {
|
||||
assert_eq!(mouse_side_button_index("mouse-side-button:8"), Some(4));
|
||||
assert_eq!(mouse_side_button_index("mouse-side-button:16"), Some(5));
|
||||
// Unknown bitmasks.
|
||||
assert_eq!(mouse_side_button_index("mouse-side-button:99"), None);
|
||||
assert_eq!(mouse_side_button_index("mouse-side-button:0"), None);
|
||||
assert_eq!(mouse_side_button_index("mouse-side-button:1"), None);
|
||||
assert_eq!(mouse_side_button_index("mouse-side-button:2"), None);
|
||||
// Malformed suffix.
|
||||
assert_eq!(mouse_side_button_index("mouse-side-button:abc"), None);
|
||||
assert_eq!(mouse_side_button_index("mouse-side-button:"), None);
|
||||
assert_eq!(mouse_side_button_index("mouse-side-button:-1"), None);
|
||||
// No prefix.
|
||||
assert_eq!(mouse_side_button_index(""), None);
|
||||
assert_eq!(mouse_side_button_index("Space"), None);
|
||||
assert_eq!(mouse_side_button_index("keyboard"), None);
|
||||
assert_eq!(mouse_side_button_index("8"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Privacy invariant integration test (DEC-027 / SDD-090 / SAD-077).
|
||||
//!
|
||||
//! Every `tracing` event emitted while exercising the public-API
|
||||
//! surface of the PTT subsystem must carry only field names from a
|
||||
//! known allow-list, and never any of the banned key-data field
|
||||
//! names. The test installs a cross-platform recording `Layer`,
|
||||
//! drives the backends and the keymap through realistic scenarios,
|
||||
//! and asserts the captured records honour both invariants.
|
||||
//!
|
||||
//! This test is platform-agnostic to compile — the recording layer
|
||||
//! is generic, the assertions are generic — but the
|
||||
//! Windows-specific dispatcher / backend exercise lives under a
|
||||
//! `#[cfg(target_os = "windows")]` block. On Linux and macOS the
|
||||
//! test exercises only the cross-platform pieces (`AudioTransmitGate`,
|
||||
//! `MissedKeyUpWatchdog`, `PttBinding`, `PttInputClass`) which are
|
||||
//! sufficient to verify the privacy invariant on those hosts.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tracing::subscriber::with_default;
|
||||
use tracing_subscriber::layer::{Context, SubscriberExt};
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
use tracing_subscriber::Layer;
|
||||
|
||||
/// Banned field names per DEC-027 / SDD-090. The test fails fast
|
||||
/// if any emitted record carries one of these names.
|
||||
const BANNED_FIELDS: &[&str] = &[
|
||||
"vk",
|
||||
"scan_code",
|
||||
"scancode",
|
||||
"keysym",
|
||||
"keysym_string",
|
||||
"key_label",
|
||||
"bound_key",
|
||||
"binding",
|
||||
"platform_key",
|
||||
"VKey",
|
||||
"wVk",
|
||||
"wScan",
|
||||
"kbflags",
|
||||
"mouseflags",
|
||||
"key_code",
|
||||
"virtual_key",
|
||||
"key_sequence",
|
||||
"key_press_history",
|
||||
"key_timing",
|
||||
];
|
||||
|
||||
/// Allow-list of field names that may appear in a PTT-subsystem
|
||||
/// tracing record. Anything outside this set is a privacy
|
||||
/// regression even if it is not on the banned list — the spec
|
||||
/// uses an allow-list precisely to catch new field additions
|
||||
/// before they can leak.
|
||||
const ALLOWED_FIELDS: &[&str] = &[
|
||||
"backend_id",
|
||||
"bound_input_class",
|
||||
"capability_level",
|
||||
"host_id",
|
||||
"timeout_secs",
|
||||
"error",
|
||||
"event",
|
||||
"message",
|
||||
"target",
|
||||
"level",
|
||||
];
|
||||
|
||||
/// Captured shape of a single tracing event. We hold only field
|
||||
/// names — values would defeat the privacy intent of the test.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Captured {
|
||||
target: String,
|
||||
field_names: Vec<String>,
|
||||
}
|
||||
|
||||
/// `tracing_subscriber::Layer` that records every emitted event's
|
||||
/// target + field-name set. The records live in a `Mutex<Vec>`
|
||||
/// shared with the test body.
|
||||
#[derive(Clone, Default)]
|
||||
struct RecordingLayer {
|
||||
records: Arc<Mutex<Vec<Captured>>>,
|
||||
}
|
||||
|
||||
impl RecordingLayer {
|
||||
fn snapshot(&self) -> Vec<Captured> {
|
||||
self.records.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct NameCollector(Vec<String>);
|
||||
|
||||
impl tracing::field::Visit for NameCollector {
|
||||
fn record_debug(&mut self, field: &tracing::field::Field, _value: &dyn std::fmt::Debug) {
|
||||
self.0.push(field.name().to_string());
|
||||
}
|
||||
fn record_str(&mut self, field: &tracing::field::Field, _value: &str) {
|
||||
self.0.push(field.name().to_string());
|
||||
}
|
||||
fn record_i64(&mut self, field: &tracing::field::Field, _value: i64) {
|
||||
self.0.push(field.name().to_string());
|
||||
}
|
||||
fn record_u64(&mut self, field: &tracing::field::Field, _value: u64) {
|
||||
self.0.push(field.name().to_string());
|
||||
}
|
||||
fn record_bool(&mut self, field: &tracing::field::Field, _value: bool) {
|
||||
self.0.push(field.name().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for RecordingLayer
|
||||
where
|
||||
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
|
||||
{
|
||||
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
|
||||
let mut names = NameCollector::default();
|
||||
event.record(&mut names);
|
||||
let captured = Captured {
|
||||
target: event.metadata().target().to_string(),
|
||||
field_names: names.0,
|
||||
};
|
||||
self.records.lock().unwrap().push(captured);
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert the captured records honour the DEC-027 banned-field
|
||||
/// rule and the allow-list. Only records from the chanora targets
|
||||
/// participate — host-side tracing-subscriber chatter (e.g. the
|
||||
/// tokio runtime) is ignored.
|
||||
fn assert_privacy_invariants(records: &[Captured]) {
|
||||
let relevant: Vec<&Captured> = records
|
||||
.iter()
|
||||
.filter(|r| r.target.starts_with("chanora_"))
|
||||
.collect();
|
||||
for r in &relevant {
|
||||
for name in &r.field_names {
|
||||
assert!(
|
||||
!BANNED_FIELDS.contains(&name.as_str()),
|
||||
"banned field {name:?} emitted by target {:?}",
|
||||
r.target
|
||||
);
|
||||
assert!(
|
||||
ALLOWED_FIELDS.contains(&name.as_str()),
|
||||
"field {name:?} from target {:?} is not in the allow-list",
|
||||
r.target
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ptt_subsystem_never_emits_banned_or_unknown_fields() {
|
||||
let layer = RecordingLayer::default();
|
||||
let subscriber = tracing_subscriber::registry().with(layer.clone());
|
||||
|
||||
with_default(subscriber, || {
|
||||
// Cross-platform paths: AudioTransmitGate transitions
|
||||
// (these emit no tracing themselves but exercise the
|
||||
// public surface); PttBinding / PttInputClass construction.
|
||||
use chanora_audio::ptt_backends::{PttBinding, PttInputClass};
|
||||
use chanora_audio::AudioTransmitGate;
|
||||
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
gate.set(true);
|
||||
gate.set(false);
|
||||
let _ = gate.load();
|
||||
let _ = PttBinding::none();
|
||||
let _ = PttBinding {
|
||||
input_class: PttInputClass::Keyboard,
|
||||
platform_key: "Space".to_string(),
|
||||
};
|
||||
let _ = PttInputClass::Keyboard.as_str();
|
||||
let _ = PttInputClass::MouseSideButton.as_str();
|
||||
|
||||
// Platform-specific dispatcher paths. These live behind
|
||||
// the cfg gate; cross-platform we still get coverage of
|
||||
// the cross-platform surface above which is enough to
|
||||
// catch any accidental info!/warn! that names a banned
|
||||
// field at module init time.
|
||||
#[cfg(target_os = "windows")]
|
||||
windows_exercise();
|
||||
});
|
||||
|
||||
let records = layer.snapshot();
|
||||
assert_privacy_invariants(&records);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_exercise() {
|
||||
// The Windows backend types are intentionally not part of
|
||||
// chanora_audio's public API (they live in
|
||||
// `crate::ptt_backends::windows` as `pub(crate)`). The privacy
|
||||
// invariant for those code paths is enforced by the dispatcher
|
||||
// unit tests inside `windows.rs` itself, where the same
|
||||
// recording layer pattern is used. From here we simply
|
||||
// exercise the `select_ptt_backend` factory + descriptor +
|
||||
// start/stop lifecycle through the public trait surface so
|
||||
// any info!/warn! the factory or the backend's `start` path
|
||||
// emits is captured by the layer.
|
||||
use chanora_audio::ptt_backends::{select_ptt_backend, PttBinding, PttInputClass};
|
||||
use chanora_audio::AudioTransmitGate;
|
||||
|
||||
let mut backend = select_ptt_backend();
|
||||
let gate = AudioTransmitGate::new(false);
|
||||
let binding = PttBinding {
|
||||
input_class: PttInputClass::Keyboard,
|
||||
platform_key: "Space".to_string(),
|
||||
};
|
||||
// Best-effort: in the CI sandbox the actual Raw Input /
|
||||
// hook registration may fail. The privacy invariant is
|
||||
// about field names, not about whether the start succeeds.
|
||||
let _ = backend.start(gate, binding);
|
||||
let _ = backend.descriptor();
|
||||
let bad_binding = PttBinding {
|
||||
input_class: PttInputClass::Keyboard,
|
||||
platform_key: "Banana".to_string(),
|
||||
};
|
||||
let _ = backend.rebind(bad_binding);
|
||||
backend.stop();
|
||||
}
|
||||
Executable
+118
@@ -0,0 +1,118 @@
|
||||
@echo off
|
||||
rem ============================================================
|
||||
rem Chanora Windows smoke build + launch script (L9 / L10 / L11
|
||||
rem of the P0 release plan).
|
||||
rem
|
||||
rem Runs end-to-end: vcvars64 + Flutter + Rust on PATH, cargo
|
||||
rem build the bridge, flutter build the Windows runner, sanity-
|
||||
rem check the output sizes, launch the runner for 5s, then grep
|
||||
rem the log for the bridge-initialised line and for panics.
|
||||
rem
|
||||
rem Hard-coded paths assume the Korean test host layout:
|
||||
rem C:\Users\admin\dev\flutter\bin
|
||||
rem C:\Users\admin\.cargo\bin
|
||||
rem C:\Program Files\Microsoft Visual Studio\2022\Community
|
||||
rem Repo at C:\Users\admin\chanora (script invoked from there).
|
||||
rem
|
||||
rem Exit codes:
|
||||
rem 0 = PASS
|
||||
rem non-zero = the step number that failed (1..10)
|
||||
rem ============================================================
|
||||
|
||||
setlocal EnableDelayedExpansion
|
||||
set "REPO_ROOT=%~dp0.."
|
||||
pushd "%REPO_ROOT%" || exit /b 1
|
||||
|
||||
rem --- Step 1: PATH + vcvars64 ---------------------------------
|
||||
set "PATH=C:\Users\admin\dev\flutter\bin;C:\Users\admin\.cargo\bin;%PATH%"
|
||||
call "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat" >nul
|
||||
if errorlevel 1 (
|
||||
echo FAIL: vcvars64.bat returned non-zero
|
||||
popd & exit /b 1
|
||||
)
|
||||
|
||||
rem --- Step 2: cargo build bridge ------------------------------
|
||||
echo [smoke] cargo build -p chanora_bridge --release
|
||||
cargo build -p chanora_bridge --release
|
||||
if errorlevel 1 (
|
||||
echo FAIL: cargo build -p chanora_bridge --release
|
||||
popd & exit /b 2
|
||||
)
|
||||
|
||||
rem --- Step 3: bridge DLL exists and is >5 MB ------------------
|
||||
set "BRIDGE_DLL=target\release\chanora_bridge.dll"
|
||||
if not exist "%BRIDGE_DLL%" (
|
||||
echo FAIL: %BRIDGE_DLL% does not exist
|
||||
popd & exit /b 3
|
||||
)
|
||||
for %%A in ("%BRIDGE_DLL%") do set "BRIDGE_SIZE=%%~zA"
|
||||
if !BRIDGE_SIZE! LSS 5242880 (
|
||||
echo FAIL: %BRIDGE_DLL% smaller than 5 MB ^(!BRIDGE_SIZE! bytes^)
|
||||
popd & exit /b 3
|
||||
)
|
||||
|
||||
rem --- Step 4: flutter build windows --release -----------------
|
||||
echo [smoke] flutter build windows --release
|
||||
pushd "apps\chanora_flutter" || (popd & exit /b 4)
|
||||
flutter build windows --release
|
||||
if errorlevel 1 (
|
||||
echo FAIL: flutter build windows --release
|
||||
popd & popd & exit /b 4
|
||||
)
|
||||
popd
|
||||
|
||||
rem --- Step 5: chanora_flutter.exe exists ----------------------
|
||||
set "RUNNER_DIR=apps\chanora_flutter\build\windows\x64\runner\Release"
|
||||
set "RUNNER_EXE=%RUNNER_DIR%\chanora_flutter.exe"
|
||||
if not exist "%RUNNER_EXE%" (
|
||||
echo FAIL: %RUNNER_EXE% does not exist
|
||||
popd & exit /b 5
|
||||
)
|
||||
|
||||
rem --- Step 6: app.so exists and is >1 MB ----------------------
|
||||
set "APP_SO=%RUNNER_DIR%\data\app.so"
|
||||
if not exist "%APP_SO%" (
|
||||
echo FAIL: %APP_SO% does not exist
|
||||
popd & exit /b 6
|
||||
)
|
||||
for %%A in ("%APP_SO%") do set "APP_SO_SIZE=%%~zA"
|
||||
if !APP_SO_SIZE! LSS 1048576 (
|
||||
echo FAIL: %APP_SO% smaller than 1 MB ^(!APP_SO_SIZE! bytes^)
|
||||
popd & exit /b 6
|
||||
)
|
||||
|
||||
rem --- Step 7: copy bridge DLL into Release dir ----------------
|
||||
copy /Y "%BRIDGE_DLL%" "%RUNNER_DIR%\chanora_bridge.dll" >nul
|
||||
if errorlevel 1 (
|
||||
echo FAIL: copy chanora_bridge.dll into Release dir
|
||||
popd & exit /b 7
|
||||
)
|
||||
|
||||
rem --- Step 8: launch runner for 5s ----------------------------
|
||||
set "SMOKE_LOG=%TEMP%\chanora-smoke.log"
|
||||
if exist "%SMOKE_LOG%" del "%SMOKE_LOG%"
|
||||
echo [smoke] launching %RUNNER_EXE% for 5s, stderr -^> %SMOKE_LOG%
|
||||
start "" /B "%RUNNER_EXE%" 2> "%SMOKE_LOG%"
|
||||
timeout /t 5 /nobreak >nul
|
||||
taskkill /F /IM chanora_flutter.exe >nul 2>&1
|
||||
|
||||
rem --- Step 9: grep for bridge initialised ---------------------
|
||||
findstr /C:"bridge initialised" "%SMOKE_LOG%" >nul
|
||||
if errorlevel 1 (
|
||||
echo FAIL: "bridge initialised" not present in %SMOKE_LOG%
|
||||
type "%SMOKE_LOG%"
|
||||
popd & exit /b 9
|
||||
)
|
||||
|
||||
rem --- Step 10: grep for panic (must be ABSENT) ----------------
|
||||
findstr /C:"panicked" "%SMOKE_LOG%" >nul
|
||||
if not errorlevel 1 (
|
||||
echo FAIL: "panicked" found in %SMOKE_LOG%
|
||||
type "%SMOKE_LOG%"
|
||||
popd & exit /b 10
|
||||
)
|
||||
|
||||
echo PASS: Chanora Windows smoke build + launch
|
||||
popd
|
||||
endlocal
|
||||
exit /b 0
|
||||
@@ -0,0 +1,83 @@
|
||||
# `tools/windows-smoke.cmd` — Korean host smoke build + launch
|
||||
|
||||
End-to-end "does Chanora build and start on Windows" verification.
|
||||
Designed for the lead's Korean Windows 11 test host and the
|
||||
`product/scaffold-v0` branch. Run from any directory inside the
|
||||
checked-out repo:
|
||||
|
||||
```
|
||||
C:\Users\admin\chanora> tools\windows-smoke.cmd
|
||||
```
|
||||
|
||||
The script `pushd`'s to the repo root (resolved from its own
|
||||
location), so the working directory does not matter as long as
|
||||
the script lives in `tools\` inside the checkout.
|
||||
|
||||
## What it does
|
||||
|
||||
1. Prepends Flutter + Cargo + MSVC to `PATH` and runs
|
||||
`vcvars64.bat`. Hard-coded paths assume the Korean host
|
||||
layout. If a path changes, edit the `set "PATH=..."` and
|
||||
`call "...\vcvars64.bat"` lines near the top.
|
||||
2. `cargo build -p chanora_bridge --release` and checks
|
||||
`target\release\chanora_bridge.dll` exists and is at least
|
||||
5 MB.
|
||||
3. `flutter build windows --release` inside `apps\chanora_flutter`
|
||||
and checks the runner + `data\app.so`.
|
||||
4. Copies the bridge DLL into the Flutter release folder.
|
||||
5. Launches the runner for five seconds with stderr redirected to
|
||||
`%TEMP%\chanora-smoke.log`, then kills it.
|
||||
6. Greps the log for `bridge initialised` (must be present) and
|
||||
`panicked` (must be absent).
|
||||
7. Prints `PASS` and exits 0 on success.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Step |
|
||||
| ---- | -------------------------------------------------------------------- |
|
||||
| 0 | PASS |
|
||||
| 1 | `vcvars64.bat` failed |
|
||||
| 2 | `cargo build -p chanora_bridge --release` failed |
|
||||
| 3 | bridge DLL missing or smaller than 5 MB |
|
||||
| 4 | `flutter build windows --release` failed |
|
||||
| 5 | `chanora_flutter.exe` missing |
|
||||
| 6 | `data\app.so` missing or smaller than 1 MB |
|
||||
| 7 | bridge DLL copy into the Release folder failed |
|
||||
| 9 | log did not contain `bridge initialised` (bridge didn't load / panic at init) |
|
||||
| 10 | log contained `panicked` (Rust panic during the 5 s smoke window) |
|
||||
|
||||
## Common failure modes
|
||||
|
||||
* **vcvars64 returns non-zero**: MSVC not installed at the
|
||||
expected path. Update the `call "...\vcvars64.bat"` line or
|
||||
install the Build Tools workload from the VS2022 installer.
|
||||
* **`cargo build` fails with `link.exe not found`**: vcvars64
|
||||
did not take effect. Re-run from a fresh `cmd.exe` (PowerShell
|
||||
has its own quirks here).
|
||||
* **Bridge DLL exists but smaller than 5 MB**: a debug build
|
||||
slipped in. Make sure the script's `--release` flag was
|
||||
honoured; check that no `[profile.release]` override is
|
||||
stripping symbols too aggressively.
|
||||
* **`data\app.so` smaller than 1 MB**: Flutter assets did not
|
||||
bundle. Run `flutter clean` then re-run the script.
|
||||
* **`bridge initialised` absent**: the bridge `extern` symbol
|
||||
is not being found by the Flutter side. Verify the DLL copy
|
||||
step ran (it did, or the script would have exited 7) and that
|
||||
the DLL name matches what `lib/main.dart`'s ffi setup expects.
|
||||
* **`panicked` present**: open `%TEMP%\chanora-smoke.log` (the
|
||||
script dumps it on failure) and read the panic message. The
|
||||
most common cause during scaffold development is a missing
|
||||
flutter-rust-bridge code-gen file; re-run the codegen step
|
||||
per `docs/release/desktop-build-runbook.md`.
|
||||
|
||||
## Manual rerun of just the launch step
|
||||
|
||||
The PASS path always tears the runner down. If you want to
|
||||
inspect the running application after a green smoke pass:
|
||||
|
||||
```
|
||||
target\release\chanora_bridge.dll (already copied into Release\)
|
||||
apps\chanora_flutter\build\windows\x64\runner\Release\chanora_flutter.exe
|
||||
```
|
||||
|
||||
Launching the EXE directly will reuse the copied DLL.
|
||||
Reference in New Issue
Block a user