forgewright 0.2.0

Standalone UI automation — CDP for browsers, UIA for Windows desktop apps
Documentation
// macro_compiler.rs — Skill Macro Compiler
//
// Compiles parameterized SkillMacro definitions into per-tick AgentInputFrame
// sequences. Each MacroStep expands into `step.ticks` copies of the
// corresponding AgentInputFrame with stick/trigger values clamped to valid
// hardware ranges.

use serde::{Deserialize, Serialize};

use crate::spooler::AgentInputFrame;

// ─── Types ───────────────────────────────────────────────────────────────────

/// A parameterized sequence of input steps (e.g., dash-jump, dodge-roll).
/// Serializable to/from JSON for storage and transport.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SkillMacro {
    pub name: String,
    pub steps: Vec<MacroStep>,
}

/// One step in a skill macro: hold these inputs for `ticks` logical frames.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MacroStep {
    pub ticks: u32,
    pub buttons: u16,
    pub left_stick_x: f32,
    pub left_stick_y: f32,
    pub right_stick_x: f32,
    pub right_stick_y: f32,
    pub left_trigger: f32,
    pub right_trigger: f32,
}

// ─── Compiler ────────────────────────────────────────────────────────────────

/// Expand a SkillMacro into a flat sequence of AgentInputFrames.
///
/// Each MacroStep produces `step.ticks` identical frames. Stick values are
/// clamped to [-1.0, 1.0] and trigger values to [0.0, 1.0].
///
/// The total output length equals the sum of all `step.ticks`.
pub fn compile_macro(skill: &SkillMacro) -> Vec<AgentInputFrame> {
    skill
        .steps
        .iter()
        .flat_map(|step| {
            let frame = AgentInputFrame {
                buttons: step.buttons,
                _pad: 0,
                left_stick_x: step.left_stick_x.clamp(-1.0, 1.0),
                left_stick_y: step.left_stick_y.clamp(-1.0, 1.0),
                right_stick_x: step.right_stick_x.clamp(-1.0, 1.0),
                right_stick_y: step.right_stick_y.clamp(-1.0, 1.0),
                left_trigger: step.left_trigger.clamp(0.0, 1.0),
                right_trigger: step.right_trigger.clamp(0.0, 1.0),
                _pad_end: 0,
            };
            std::iter::repeat(frame).take(step.ticks as usize)
        })
        .collect()
}

// ─── Tests ───────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    use proptest::prelude::*;

    // ── Strategies ───────────────────────────────────────────────────────

    /// Generate an arbitrary MacroStep with reasonable tick counts.
    fn macro_step_strategy() -> impl Strategy<Value = MacroStep> {
        (
            0u32..=50,          // ticks (keep small to avoid huge vecs)
            any::<u16>(),       // buttons
            any::<f32>(),       // left_stick_x
            any::<f32>(),       // left_stick_y
            any::<f32>(),       // right_stick_x
            any::<f32>(),       // right_stick_y
            any::<f32>(),       // left_trigger
            any::<f32>(),       // right_trigger
        )
            .prop_map(
                |(ticks, buttons, lsx, lsy, rsx, rsy, lt, rt)| MacroStep {
                    ticks,
                    buttons,
                    left_stick_x: lsx,
                    left_stick_y: lsy,
                    right_stick_x: rsx,
                    right_stick_y: rsy,
                    left_trigger: lt,
                    right_trigger: rt,
                },
            )
    }

    /// Generate a finite MacroStep that avoids NaN/Inf for JSON round-trip.
    fn finite_macro_step_strategy() -> impl Strategy<Value = MacroStep> {
        (
            0u32..=50,
            any::<u16>(),
            -100.0f32..100.0f32,
            -100.0f32..100.0f32,
            -100.0f32..100.0f32,
            -100.0f32..100.0f32,
            -100.0f32..100.0f32,
            -100.0f32..100.0f32,
        )
            .prop_map(
                |(ticks, buttons, lsx, lsy, rsx, rsy, lt, rt)| MacroStep {
                    ticks,
                    buttons,
                    left_stick_x: lsx,
                    left_stick_y: lsy,
                    right_stick_x: rsx,
                    right_stick_y: rsy,
                    left_trigger: lt,
                    right_trigger: rt,
                },
            )
    }

    /// Generate an arbitrary SkillMacro with 0..10 steps.
    fn skill_macro_strategy() -> impl Strategy<Value = SkillMacro> {
        (
            "[a-zA-Z0-9_]{1,20}",
            proptest::collection::vec(macro_step_strategy(), 0..10),
        )
            .prop_map(|(name, steps)| SkillMacro { name, steps })
    }

    /// Generate a SkillMacro with finite f32 values (JSON-safe).
    fn finite_skill_macro_strategy() -> impl Strategy<Value = SkillMacro> {
        (
            "[a-zA-Z0-9_]{1,20}",
            proptest::collection::vec(finite_macro_step_strategy(), 0..10),
        )
            .prop_map(|(name, steps)| SkillMacro { name, steps })
    }

    // ── Property 24: Macro expansion length ─────────────────────────────
    // **Validates: Requirements 9.1, 9.2**

    proptest! {
        #![proptest_config(ProptestConfig::with_cases(200))]

        /// Property 24 — Macro expansion length.
        ///
        /// For any SkillMacro, `compile_macro` output length equals the sum
        /// of all `step.ticks`.
        ///
        /// **Validates: Requirements 9.1, 9.2**
        #[test]
        fn prop_macro_expansion_length(skill in skill_macro_strategy()) {
            let frames = compile_macro(&skill);
            let expected: u32 = skill.steps.iter().map(|s| s.ticks).sum();
            prop_assert_eq!(frames.len(), expected as usize);
        }
    }

    // ── Property 25: Macro value clamping ───────────────────────────────
    // **Validates: Requirement 9.3**

    proptest! {
        #![proptest_config(ProptestConfig::with_cases(200))]

        /// Property 25 — Macro value clamping.
        ///
        /// For any MacroStep with arbitrary f32 values, compiled
        /// AgentInputFrame has sticks in [-1.0, 1.0] and triggers in
        /// [0.0, 1.0].
        ///
        /// **Validates: Requirement 9.3**
        #[test]
        fn prop_macro_value_clamping(step in macro_step_strategy()) {
            // Skip NaN inputs — NaN propagates through clamp and is not
            // a valid hardware value. The property holds for all finite f32.
            prop_assume!(!step.left_stick_x.is_nan());
            prop_assume!(!step.left_stick_y.is_nan());
            prop_assume!(!step.right_stick_x.is_nan());
            prop_assume!(!step.right_stick_y.is_nan());
            prop_assume!(!step.left_trigger.is_nan());
            prop_assume!(!step.right_trigger.is_nan());

            let skill = SkillMacro {
                name: "test".to_string(),
                steps: vec![MacroStep { ticks: 1, ..step }],
            };
            let frames = compile_macro(&skill);

            for frame in &frames {
                prop_assert!(frame.left_stick_x >= -1.0 && frame.left_stick_x <= 1.0,
                    "left_stick_x out of range: {}", frame.left_stick_x);
                prop_assert!(frame.left_stick_y >= -1.0 && frame.left_stick_y <= 1.0,
                    "left_stick_y out of range: {}", frame.left_stick_y);
                prop_assert!(frame.right_stick_x >= -1.0 && frame.right_stick_x <= 1.0,
                    "right_stick_x out of range: {}", frame.right_stick_x);
                prop_assert!(frame.right_stick_y >= -1.0 && frame.right_stick_y <= 1.0,
                    "right_stick_y out of range: {}", frame.right_stick_y);
                prop_assert!(frame.left_trigger >= 0.0 && frame.left_trigger <= 1.0,
                    "left_trigger out of range: {}", frame.left_trigger);
                prop_assert!(frame.right_trigger >= 0.0 && frame.right_trigger <= 1.0,
                    "right_trigger out of range: {}", frame.right_trigger);
            }
        }
    }

    // ── Property 26: SkillMacro JSON round-trip ─────────────────────────
    // **Validates: Requirement 9.4**

    proptest! {
        #![proptest_config(ProptestConfig::with_cases(200))]

        /// Property 26 — SkillMacro JSON round-trip.
        ///
        /// Serialize any valid SkillMacro to JSON, deserialize, verify
        /// equivalence.
        ///
        /// **Validates: Requirement 9.4**
        #[test]
        fn prop_skill_macro_json_roundtrip(skill in finite_skill_macro_strategy()) {
            let json = serde_json::to_string(&skill).expect("serialize");
            let back: SkillMacro = serde_json::from_str(&json).expect("deserialize");
            prop_assert_eq!(&skill, &back);
        }
    }
}