bind: "interact" now resolves to game_binds.interact (Alt+J) only — a single keystroke. The full ISBoxer-style chain (CTM on -> Alt+J -> sleep walk_delay_ms -> CTM off) lives at bind: "smart_interact". Fixes the doubling bug Grok flagged: bind: "interact" previously expanded into the full chain unconditionally, so the loot_manual and interact_hold example maps fired ctm_on/ctm_off twice and produced unwanted sleep delays. - engine.rs: rename shortcut trigger (was: "interact") - examples/profile.yaml: loot now uses smart_interact; loot_manual and interact_hold keep "interact" as a single Alt+J send - docs/MACROS.md: heading + shortcut comment - docs/NOTES.md: trigger name - Add new test: interact_simple_sends_only_alt_j (1-line single-send) - Existing smart shortcut tests renamed to bind: smart_interact - CHANGELOG.md: trigger split note 93/93 cargo test pass; clippy clean.
833 lines
29 KiB
Rust
833 lines
29 KiB
Rust
use crate::hotkey::passthrough_id;
|
|
use crate::profile::{Map, Mode, Profile, Step};
|
|
use anyhow::{bail, Result};
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::time::Duration;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Engine {
|
|
pub profile: Profile,
|
|
pub mode: Mode,
|
|
pub leader_slot: u32,
|
|
passthrough: HashSet<String>,
|
|
/// Per-map cursor for `target: round_robin` / `rr`. The map name is
|
|
/// the key; the value is the next slot index to fire (1..=slots).
|
|
/// Reset on a fresh Engine; not persisted across reload.
|
|
round_robin: HashMap<String, usize>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Action {
|
|
Send {
|
|
key: String,
|
|
slots: Vec<u32>,
|
|
hold: Hold,
|
|
},
|
|
Sleep(Duration),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Hold {
|
|
Tap,
|
|
Down,
|
|
Up,
|
|
}
|
|
|
|
impl Engine {
|
|
pub fn new(profile: Profile) -> Result<Self> {
|
|
let mode = profile.mode_default;
|
|
let passthrough = profile.passthrough_set()?;
|
|
Ok(Self {
|
|
leader_slot: 1,
|
|
mode,
|
|
passthrough,
|
|
profile,
|
|
round_robin: HashMap::new(),
|
|
})
|
|
}
|
|
|
|
pub fn set_leader(&mut self, slot: u32) {
|
|
if slot >= 1 && slot <= self.profile.slots {
|
|
self.leader_slot = slot;
|
|
}
|
|
}
|
|
|
|
pub fn cycle_mode(&mut self) -> Mode {
|
|
self.mode = self.mode.cycle();
|
|
self.mode
|
|
}
|
|
|
|
pub fn should_intercept(&self, hotkey: &str) -> bool {
|
|
if self.mode == Mode::Off {
|
|
return false;
|
|
}
|
|
if let Ok(id) = passthrough_id(hotkey) {
|
|
if self.passthrough.contains(&id) {
|
|
return false;
|
|
}
|
|
}
|
|
self.profile.map_by_hotkey(hotkey).is_some()
|
|
|| (self.mode == Mode::Mirror && self.is_mirror_key(hotkey))
|
|
}
|
|
|
|
fn is_mirror_key(&self, hotkey: &str) -> bool {
|
|
if self.profile.repeater.keys.is_empty() {
|
|
return true;
|
|
}
|
|
let want = passthrough_id(hotkey).unwrap_or_else(|_| hotkey.to_string());
|
|
self.profile.repeater.keys.iter().any(|k| {
|
|
k.eq_ignore_ascii_case(hotkey) || passthrough_id(k).ok().is_some_and(|id| id == want)
|
|
})
|
|
}
|
|
|
|
/// Resolve a `target:` field on a step into the slot ids that should
|
|
/// receive the action. Side effect: when `target` is `round_robin` /
|
|
/// `rr`, advances the per-map cursor for `map_name` (if provided).
|
|
/// The cursor lives on the engine so two `Engine` instances each
|
|
/// carry their own round-robin state — two profiles do not share it.
|
|
pub fn resolve_targets(&mut self, target: &str, map_name: &str) -> Result<Vec<u32>> {
|
|
let all: Vec<u32> = (1..=self.profile.slots).collect();
|
|
let leader = self.leader_slot;
|
|
match target {
|
|
"current" => Ok(vec![leader]),
|
|
"others" | "all_except_current" => {
|
|
Ok(all.into_iter().filter(|s| *s != leader).collect())
|
|
}
|
|
"all" => Ok(all),
|
|
"round_robin" | "rr" => {
|
|
if self.profile.slots == 0 {
|
|
bail!("round_robin needs at least 1 slot");
|
|
}
|
|
let n = self.profile.slots as usize;
|
|
let current = self
|
|
.round_robin
|
|
.entry(map_name.to_string())
|
|
.or_insert(1);
|
|
let pick = *current;
|
|
*current = (pick % n) + 1;
|
|
Ok(vec![pick as u32])
|
|
}
|
|
other if other.starts_with("group:") => {
|
|
let name = &other[6..];
|
|
let g = self
|
|
.profile
|
|
.groups
|
|
.get(name)
|
|
.ok_or_else(|| anyhow::anyhow!("unknown group {name}"))?;
|
|
Ok(g.slots.clone())
|
|
}
|
|
other if other.starts_with("slots:") => {
|
|
let mut out = Vec::new();
|
|
for p in other[6..].split(',') {
|
|
let n: u32 = p.trim().parse()?;
|
|
out.push(n);
|
|
}
|
|
Ok(out)
|
|
}
|
|
other if other.starts_with("slot:") => Ok(vec![other[5..].parse()?]),
|
|
other => bail!("bad target {other:?}"),
|
|
}
|
|
}
|
|
|
|
pub fn fire(&mut self, hotkey: &str, edge: Hold) -> Result<Vec<Action>> {
|
|
if !self.should_intercept(hotkey) {
|
|
return Ok(vec![]);
|
|
}
|
|
if let Some(map) = self.profile.map_by_hotkey(hotkey).cloned() {
|
|
let edge = if map.hold && matches!(edge, Hold::Tap) {
|
|
Hold::Down
|
|
} else {
|
|
edge
|
|
};
|
|
return self.fire_map(&map, edge);
|
|
}
|
|
if self.mode == Mode::Mirror && matches!(edge, Hold::Tap | Hold::Down) {
|
|
let slots = self.resolve_targets("others", "")?;
|
|
return Ok(vec![Action::Send {
|
|
key: hotkey.to_string(),
|
|
slots,
|
|
hold: if matches!(edge, Hold::Down) {
|
|
Hold::Down
|
|
} else {
|
|
Hold::Tap
|
|
},
|
|
}]);
|
|
}
|
|
Ok(vec![])
|
|
}
|
|
|
|
fn fire_map(&mut self, map: &Map, edge: Hold) -> Result<Vec<Action>> {
|
|
let steps = match edge {
|
|
Hold::Up => &map.release_steps,
|
|
_ => &map.steps,
|
|
};
|
|
let hold = if map.hold && matches!(edge, Hold::Down) {
|
|
Hold::Down
|
|
} else if map.hold && matches!(edge, Hold::Up) {
|
|
Hold::Up
|
|
} else {
|
|
Hold::Tap
|
|
};
|
|
self.compile_steps(map, steps, hold)
|
|
}
|
|
|
|
fn resolve_party_bind(&self, name: &str) -> Result<String> {
|
|
if name == "assist" || name == "follow" {
|
|
if let Some(ch) = self
|
|
.profile
|
|
.characters
|
|
.iter()
|
|
.find(|c| c.slot == self.leader_slot)
|
|
{
|
|
let k = if name == "assist" {
|
|
&ch.assist_key
|
|
} else {
|
|
&ch.follow_key
|
|
};
|
|
if !k.is_empty() {
|
|
return Ok(k.clone());
|
|
}
|
|
}
|
|
}
|
|
self.profile.resolve_bind(name)
|
|
}
|
|
|
|
fn compile_steps(
|
|
&mut self,
|
|
map: &Map,
|
|
steps: &[Step],
|
|
hold: Hold,
|
|
) -> Result<Vec<Action>> {
|
|
let map_name = map.name.clone();
|
|
let mut out = Vec::new();
|
|
for step in steps {
|
|
if let Some(ms) = step.delay_ms {
|
|
out.push(Action::Sleep(Duration::from_millis(ms)));
|
|
continue;
|
|
}
|
|
// ISBoxer-style Mapped Key shortcut: `bind: "smart_interact"` fires
|
|
// the full CTM-toggle + Interact-with-Target + (optional) wait +
|
|
// CTM-off chain driven by profile.interact (style + walk_delay_ms).
|
|
// The user only pressed the map hotkey; the daemon composes the chain.
|
|
// For per-step control, use `bind: "interact"` to send a single
|
|
// Interact-with-Target keystroke (game_binds.interact = Alt+J) and
|
|
// compose ctm_on / ctm_off / walk_delay_ms yourself.
|
|
if step.bind.as_deref() == Some("smart_interact") {
|
|
let slots = self.resolve_targets(&step.target, &map_name)?;
|
|
let interact_key = self
|
|
.profile
|
|
.game_binds
|
|
.get("interact")
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
let ctm_on = self
|
|
.profile
|
|
.game_binds
|
|
.get("ctm_on")
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
let ctm_off = self
|
|
.profile
|
|
.game_binds
|
|
.get("ctm_off")
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
|
|
// 1. CTM on (sends the macro key that toggles autoInteract on).
|
|
if !ctm_on.is_empty() {
|
|
out.push(Action::Send {
|
|
key: ctm_on,
|
|
slots: slots.clone(),
|
|
hold,
|
|
});
|
|
}
|
|
// 2. Interact with Target keybind.
|
|
if !interact_key.is_empty() {
|
|
out.push(Action::Send {
|
|
key: interact_key,
|
|
slots: slots.clone(),
|
|
hold,
|
|
});
|
|
}
|
|
// 3. Style-driven tail.
|
|
match self.profile.interact.style {
|
|
crate::profile::InteractStyle::Standard => {
|
|
out.push(Action::Sleep(Duration::from_millis(
|
|
self.profile.interact.walk_delay_ms,
|
|
)));
|
|
if !ctm_off.is_empty() {
|
|
out.push(Action::Send {
|
|
key: ctm_off,
|
|
slots,
|
|
hold,
|
|
});
|
|
}
|
|
}
|
|
crate::profile::InteractStyle::Auto => {
|
|
// CTM stays on after the press. The user toggles it off
|
|
// (via game_binds.ctm_off) when they explicitly want to.
|
|
}
|
|
crate::profile::InteractStyle::Hold => {
|
|
// CTM is bound on the press; ctm_off goes on release_steps.
|
|
// No tail here.
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
let key = if let Some(k) = &step.key {
|
|
k.clone()
|
|
} else if let Some(b) = &step.bind {
|
|
self.resolve_party_bind(b)?
|
|
} else {
|
|
continue;
|
|
};
|
|
let slots = self.resolve_targets(&step.target, &map_name)?;
|
|
out.push(Action::Send { key, slots, hold });
|
|
}
|
|
Ok(out)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::hotkey::Hotkey;
|
|
use crate::profile::{Group, Interact, InteractStyle, Map, Profile, Step};
|
|
use pretty_assertions::assert_eq;
|
|
use std::collections::BTreeMap;
|
|
|
|
fn sample() -> Engine {
|
|
let mut game_binds = BTreeMap::new();
|
|
game_binds.insert("interact".into(), "g".into());
|
|
game_binds.insert("ctm_on".into(), "Shift+F3".into());
|
|
game_binds.insert("ctm_off".into(), "Shift+F4".into());
|
|
game_binds.insert("assist".into(), "Shift+F2".into());
|
|
let mut groups = BTreeMap::new();
|
|
groups.insert("alts".into(), Group { slots: vec![2, 3] });
|
|
let profile = Profile {
|
|
name: "t".into(),
|
|
client: "wow-retail".into(),
|
|
slots: 3,
|
|
window_match: Default::default(),
|
|
passthrough: vec!["e".into(), "s".into(), "d".into(), "f".into()],
|
|
mode_default: Mode::Maps,
|
|
repeater: Default::default(),
|
|
game_binds,
|
|
interact: Interact {
|
|
style: InteractStyle::Standard,
|
|
walk_delay_ms: 2500,
|
|
},
|
|
session_hotkeys: BTreeMap::new(),
|
|
characters: vec![],
|
|
groups,
|
|
maps: vec![
|
|
Map {
|
|
name: "bar1".into(),
|
|
hotkey: Hotkey("1".into()),
|
|
hold: false,
|
|
steps: vec![
|
|
Step {
|
|
key: None,
|
|
bind: Some("assist".into()),
|
|
delay_ms: None,
|
|
target: "others".into(),
|
|
},
|
|
Step {
|
|
key: Some("1".into()),
|
|
bind: None,
|
|
delay_ms: None,
|
|
target: "all".into(),
|
|
},
|
|
],
|
|
release_steps: vec![],
|
|
},
|
|
Map {
|
|
// Smart shortcut: one user keypress fires the entire interact
|
|
// chain (CTM on -> Alt+J -> sleep walk_delay_ms -> CTM off).
|
|
name: "loot".into(),
|
|
hotkey: Hotkey("Alt+G".into()),
|
|
hold: false,
|
|
steps: vec![
|
|
Step {
|
|
bind: Some("assist".into()),
|
|
key: None,
|
|
delay_ms: None,
|
|
target: "others".into(),
|
|
},
|
|
Step {
|
|
bind: Some("smart_interact".into()),
|
|
key: None,
|
|
delay_ms: None,
|
|
target: "others".into(),
|
|
},
|
|
],
|
|
release_steps: vec![],
|
|
},
|
|
// Manual form: same chain composed by hand. Use this when you
|
|
// need per-step control (different key per step, custom delay
|
|
// between, conditional branches, etc.).
|
|
Map {
|
|
name: "loot_manual".into(),
|
|
hotkey: Hotkey("Ctrl+Alt+G".into()),
|
|
hold: false,
|
|
steps: vec![
|
|
Step {
|
|
bind: Some("assist".into()),
|
|
key: None,
|
|
delay_ms: None,
|
|
target: "others".into(),
|
|
},
|
|
Step {
|
|
bind: Some("ctm_on".into()),
|
|
key: None,
|
|
delay_ms: None,
|
|
target: "others".into(),
|
|
},
|
|
Step {
|
|
bind: Some("interact".into()),
|
|
key: None,
|
|
delay_ms: None,
|
|
target: "others".into(),
|
|
},
|
|
Step {
|
|
delay_ms: Some(5000),
|
|
key: None,
|
|
bind: None,
|
|
target: "others".into(),
|
|
},
|
|
Step {
|
|
bind: Some("ctm_off".into()),
|
|
key: None,
|
|
delay_ms: None,
|
|
target: "others".into(),
|
|
},
|
|
],
|
|
release_steps: vec![],
|
|
},
|
|
],
|
|
video_fx: vec![],
|
|
layout: Default::default(),
|
|
};
|
|
Engine::new(profile).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn example_profile_loads() {
|
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/profile.yaml");
|
|
crate::profile::Profile::load(&path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn esdf_is_not_intercepted() {
|
|
let e = sample();
|
|
assert!(!e.should_intercept("e"));
|
|
assert!(!e.should_intercept("s"));
|
|
assert!(e.should_intercept("1"));
|
|
assert!(e.should_intercept("Alt+G"));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_passthrough_allows_mapping_esdf() {
|
|
let mut e = sample();
|
|
e.passthrough.clear();
|
|
e.profile.passthrough.clear();
|
|
// still no map for e, so not intercepted
|
|
assert!(!e.should_intercept("e"));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_passthrough_can_map_e() {
|
|
let profile = Profile {
|
|
name: "t".into(),
|
|
client: "wow-retail".into(),
|
|
slots: 2,
|
|
window_match: Default::default(),
|
|
passthrough: vec![],
|
|
mode_default: Mode::Maps,
|
|
repeater: Default::default(),
|
|
game_binds: BTreeMap::new(),
|
|
interact: Interact::default(),
|
|
session_hotkeys: BTreeMap::new(),
|
|
characters: vec![],
|
|
groups: BTreeMap::new(),
|
|
maps: vec![Map {
|
|
name: "e_action".into(),
|
|
hotkey: Hotkey("e".into()),
|
|
hold: false,
|
|
steps: vec![Step {
|
|
key: Some("e".into()),
|
|
bind: None,
|
|
delay_ms: None,
|
|
target: "current".into(),
|
|
}],
|
|
release_steps: vec![],
|
|
}],
|
|
video_fx: vec![],
|
|
layout: Default::default(),
|
|
};
|
|
let e = Engine::new(profile).unwrap();
|
|
assert!(e.should_intercept("e"));
|
|
}
|
|
|
|
#[test]
|
|
fn others_skips_leader() {
|
|
let mut e = sample();
|
|
e.set_leader(2);
|
|
assert_eq!(e.resolve_targets("others", "").unwrap(), vec![1, 3]);
|
|
assert_eq!(e.resolve_targets("current", "").unwrap(), vec![2]);
|
|
assert_eq!(e.resolve_targets("group:alts", "").unwrap(), vec![2, 3]);
|
|
}
|
|
|
|
#[test]
|
|
fn loot_is_single_iwt_with_delay() {
|
|
let mut e = sample();
|
|
let actions = e.fire("Alt+G", Hold::Tap).unwrap();
|
|
match &actions[..] {
|
|
[Action::Send {
|
|
key: a, slots: s1, ..
|
|
}, Action::Send {
|
|
key: b, slots: s2, ..
|
|
}, Action::Send {
|
|
key: c, slots: s3, ..
|
|
}, Action::Sleep(d), Action::Send {
|
|
key: ekey,
|
|
slots: s4,
|
|
..
|
|
}] => {
|
|
assert_eq!(a, "Shift+F2");
|
|
assert_eq!(b, "Shift+F3");
|
|
assert_eq!(c, "g");
|
|
assert_eq!(*d, Duration::from_millis(2500));
|
|
assert_eq!(ekey, "Shift+F4");
|
|
assert_eq!(s1, &vec![2, 3]);
|
|
assert_eq!(s2, &vec![2, 3]);
|
|
assert_eq!(s3, &vec![2, 3]);
|
|
assert_eq!(s4, &vec![2, 3]);
|
|
}
|
|
other => panic!("unexpected {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn interact_simple_sends_only_alt_j() {
|
|
// After the smart_interact split, `bind: "interact"` is a single
|
|
// Alt+J send (game_binds.interact = "g"). No CTM toggle, no sleep,
|
|
// no second send. Use this from manual chains or release_steps.
|
|
let mut e = sample();
|
|
let map = crate::profile::Map {
|
|
name: "interact_simple".into(),
|
|
hotkey: Hotkey("Alt+U".into()),
|
|
hold: false,
|
|
steps: vec![crate::profile::Step {
|
|
key: None,
|
|
bind: Some("interact".into()),
|
|
delay_ms: None,
|
|
target: "others".into(),
|
|
}],
|
|
release_steps: vec![],
|
|
};
|
|
e.profile.maps.push(map);
|
|
let acts = e.fire("Alt+U", Hold::Tap).unwrap();
|
|
assert_eq!(acts.len(), 1, "exactly one action: {acts:?}");
|
|
match &acts[0] {
|
|
Action::Send { key, slots, hold } => {
|
|
assert_eq!(key, "g");
|
|
assert_eq!(*hold, Hold::Tap);
|
|
assert_eq!(slots, &vec![2, 3]);
|
|
}
|
|
other => panic!("expected Send, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn interact_smart_shortcut_standard_emits_full_sequence() {
|
|
// bind: "interact" should fire CTM-on, Interact with Target,
|
|
// sleep walk_delay_ms, CTM-off (ISBoxer Mapped Key analog).
|
|
let mut e = sample();
|
|
e.profile.interact.style = crate::profile::InteractStyle::Standard;
|
|
e.profile.interact.walk_delay_ms = 2500;
|
|
let map = crate::profile::Map {
|
|
name: "interact_short".into(),
|
|
hotkey: Hotkey("Alt+I".into()),
|
|
hold: false,
|
|
steps: vec![crate::profile::Step {
|
|
key: None,
|
|
bind: Some("smart_interact".into()),
|
|
delay_ms: None,
|
|
target: "others".into(),
|
|
}],
|
|
release_steps: vec![],
|
|
};
|
|
e.profile.maps.push(map);
|
|
let acts = e.fire("Alt+I", Hold::Tap).unwrap();
|
|
match &acts[..] {
|
|
[Action::Send { key: k1, slots: s1, .. },
|
|
Action::Send { key: k2, slots: s2, .. },
|
|
Action::Sleep(d),
|
|
Action::Send { key: k3, slots: s3, .. }] => {
|
|
assert_eq!(k1, "Shift+F3");
|
|
assert_eq!(k2, "g");
|
|
assert_eq!(*d, Duration::from_millis(2500));
|
|
assert_eq!(k3, "Shift+F4");
|
|
assert_eq!(s1, &vec![2, 3]);
|
|
assert_eq!(s2, &vec![2, 3]);
|
|
assert_eq!(s3, &vec![2, 3]);
|
|
}
|
|
other => panic!("expected [ctm_on, interact, sleep, ctm_off], got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn interact_smart_shortcut_auto_emits_two_no_tail() {
|
|
// style = auto: CTM stays on. No sleep, no ctm_off in the press.
|
|
let mut e = sample();
|
|
e.profile.interact.style = crate::profile::InteractStyle::Auto;
|
|
e.profile.interact.walk_delay_ms = 9999;
|
|
let map = crate::profile::Map {
|
|
name: "interact_auto".into(),
|
|
hotkey: Hotkey("Alt+I".into()),
|
|
hold: false,
|
|
steps: vec![crate::profile::Step {
|
|
key: None,
|
|
bind: Some("smart_interact".into()),
|
|
delay_ms: None,
|
|
target: "others".into(),
|
|
}],
|
|
release_steps: vec![],
|
|
};
|
|
e.profile.maps.push(map);
|
|
let acts = e.fire("Alt+I", Hold::Tap).unwrap();
|
|
match &acts[..] {
|
|
[Action::Send { key: k1, .. }, Action::Send { key: k2, .. }] => {
|
|
assert_eq!(k1, "Shift+F3");
|
|
assert_eq!(k2, "g");
|
|
}
|
|
other => panic!("expected [ctm_on, interact] only, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn interact_smart_shortcut_hold_emits_press_only() {
|
|
// style = hold: press fires CTM-on + IWT (Down); ctm_off must be in
|
|
// release_steps explicitly. The smart shortcut does not emit a tail.
|
|
let mut e = sample();
|
|
e.profile.interact.style = crate::profile::InteractStyle::Hold;
|
|
e.profile.interact.walk_delay_ms = 5000;
|
|
let map = crate::profile::Map {
|
|
name: "interact_hold".into(),
|
|
hotkey: Hotkey("Alt+I".into()),
|
|
hold: true,
|
|
steps: vec![crate::profile::Step {
|
|
key: None,
|
|
bind: Some("smart_interact".into()),
|
|
delay_ms: None,
|
|
target: "others".into(),
|
|
}],
|
|
release_steps: vec![crate::profile::Step {
|
|
key: None,
|
|
bind: Some("ctm_off".into()),
|
|
delay_ms: None,
|
|
target: "others".into(),
|
|
}],
|
|
};
|
|
e.profile.maps.push(map);
|
|
let press = e.fire("Alt+I", Hold::Down).unwrap();
|
|
match &press[..] {
|
|
[Action::Send { key: k1, hold: h1, .. },
|
|
Action::Send { key: k2, hold: h2, .. }] => {
|
|
assert_eq!(k1, "Shift+F3");
|
|
assert_eq!(k2, "g");
|
|
assert_eq!(*h1, Hold::Down);
|
|
assert_eq!(*h2, Hold::Down);
|
|
}
|
|
other => panic!("press not 2 actions, got {other:?}"),
|
|
}
|
|
let release = e.fire("Alt+I", Hold::Up).unwrap();
|
|
match &release[..] {
|
|
[Action::Send { key: k, hold: h, .. }] => {
|
|
assert_eq!(k, "Shift+F4");
|
|
assert_eq!(*h, Hold::Up);
|
|
}
|
|
other => panic!("release not 1 action, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_mode_intercepts_nothing() {
|
|
let mut e = sample();
|
|
e.mode = Mode::Off;
|
|
assert!(!e.should_intercept("1"));
|
|
assert!(e.fire("1", Hold::Tap).unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn assist_follows_leader_character() {
|
|
let mut e = sample();
|
|
e.profile.characters = vec![
|
|
crate::profile::Character {
|
|
slot: 1,
|
|
name: "Main".into(),
|
|
match_title: None,
|
|
assist_key: "Shift+F2".into(),
|
|
follow_key: "Shift+F1".into(),
|
|
lutris_game: None,
|
|
wine_prefix: None,
|
|
auto_apply: false,
|
|
},
|
|
crate::profile::Character {
|
|
slot: 2,
|
|
name: "Alt".into(),
|
|
match_title: None,
|
|
assist_key: "F9".into(),
|
|
follow_key: "F10".into(),
|
|
lutris_game: None,
|
|
wine_prefix: None,
|
|
auto_apply: false,
|
|
},
|
|
];
|
|
e.set_leader(2);
|
|
let acts = e.fire("1", Hold::Tap).unwrap();
|
|
match &acts[0] {
|
|
Action::Send { key, .. } => assert_eq!(key, "F9"),
|
|
_ => panic!("expected send"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn three_modes() {
|
|
let mut e = sample();
|
|
e.mode = Mode::Maps;
|
|
assert!(e.should_intercept("1"));
|
|
assert!(!e.should_intercept("q"));
|
|
e.mode = Mode::Mirror;
|
|
assert!(e.should_intercept("q"));
|
|
let acts = e.fire("q", Hold::Tap).unwrap();
|
|
assert_eq!(
|
|
acts,
|
|
vec![Action::Send {
|
|
key: "q".into(),
|
|
slots: vec![2, 3],
|
|
hold: Hold::Tap,
|
|
}]
|
|
);
|
|
assert!(!e.should_intercept("e")); // passthrough ESDF
|
|
e.mode = Mode::Off;
|
|
assert!(!e.should_intercept("1"));
|
|
assert!(!e.should_intercept("q"));
|
|
}
|
|
|
|
#[test]
|
|
fn bar_sends_assist_to_others_and_key_to_all() {
|
|
let mut e = sample();
|
|
let actions = e.fire("1", Hold::Tap).unwrap();
|
|
assert_eq!(
|
|
actions,
|
|
vec![
|
|
Action::Send {
|
|
key: "Shift+F2".into(),
|
|
slots: vec![2, 3],
|
|
hold: Hold::Tap,
|
|
},
|
|
Action::Send {
|
|
key: "1".into(),
|
|
slots: vec![1, 2, 3],
|
|
hold: Hold::Tap,
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
/// Build a profile with one round-robin map and N slots.
|
|
fn rr_sample(map_name: &str, target: &str, n: u32) -> Engine {
|
|
let profile = Profile {
|
|
name: "rr".into(),
|
|
client: "wow-retail".into(),
|
|
slots: n,
|
|
window_match: Default::default(),
|
|
passthrough: vec![],
|
|
mode_default: Mode::Maps,
|
|
repeater: Default::default(),
|
|
game_binds: BTreeMap::new(),
|
|
interact: Default::default(),
|
|
session_hotkeys: BTreeMap::new(),
|
|
characters: vec![],
|
|
groups: BTreeMap::new(),
|
|
maps: vec![Map {
|
|
name: map_name.into(),
|
|
hotkey: Hotkey("F8".into()),
|
|
hold: false,
|
|
steps: vec![Step {
|
|
key: Some("g".into()),
|
|
bind: None,
|
|
delay_ms: None,
|
|
target: target.into(),
|
|
}],
|
|
release_steps: vec![],
|
|
}],
|
|
video_fx: vec![],
|
|
layout: Default::default(),
|
|
};
|
|
Engine::new(profile).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn rr_cycles_one_two_three_around_three_presses() {
|
|
let mut e = rr_sample("rez_cycle", "round_robin", 3);
|
|
let slots1 = e.resolve_targets("round_robin", "rez_cycle").unwrap();
|
|
let slots2 = e.resolve_targets("round_robin", "rez_cycle").unwrap();
|
|
let slots3 = e.resolve_targets("round_robin", "rez_cycle").unwrap();
|
|
assert_eq!(slots1, vec![1]);
|
|
assert_eq!(slots2, vec![2]);
|
|
assert_eq!(slots3, vec![3]);
|
|
}
|
|
|
|
#[test]
|
|
fn rr_wraps_back_to_one_after_slots_exhausted() {
|
|
let mut e = rr_sample("rez_cycle", "rr", 3);
|
|
let _ = e.resolve_targets("rr", "rez_cycle").unwrap();
|
|
let _ = e.resolve_targets("rr", "rez_cycle").unwrap();
|
|
let _ = e.resolve_targets("rr", "rez_cycle").unwrap();
|
|
let slots4 = e.resolve_targets("rr", "rez_cycle").unwrap();
|
|
assert_eq!(slots4, vec![1], "wraps after slots exhaust");
|
|
}
|
|
|
|
#[test]
|
|
fn rr_cursor_is_per_map() {
|
|
let mut e = rr_sample("alpha", "rr", 3);
|
|
let _ = e.resolve_targets("rr", "alpha").unwrap();
|
|
let _ = e.resolve_targets("rr", "alpha").unwrap();
|
|
// alpha is at slot 3; beta is fresh, so it returns slot 1.
|
|
let beta = e.resolve_targets("rr", "beta").unwrap();
|
|
assert_eq!(beta, vec![1]);
|
|
// Advancing alpha now goes to its next slot.
|
|
let alpha = e.resolve_targets("rr", "alpha").unwrap();
|
|
assert_eq!(alpha, vec![3]);
|
|
}
|
|
|
|
#[test]
|
|
fn rr_cursors_reset_on_fresh_engine() {
|
|
let mut a = rr_sample("map", "rr", 3);
|
|
let _ = a.resolve_targets("rr", "map").unwrap();
|
|
let _ = a.resolve_targets("rr", "map").unwrap();
|
|
let mut b = rr_sample("map", "rr", 3);
|
|
assert_eq!(b.resolve_targets("rr", "map").unwrap(), vec![1]);
|
|
}
|
|
|
|
#[test]
|
|
fn rr_works_through_fire_map() {
|
|
let mut e = rr_sample("rez_cycle", "round_robin", 3);
|
|
let a = e.fire("F8", Hold::Tap).unwrap();
|
|
let b = e.fire("F8", Hold::Tap).unwrap();
|
|
let c = e.fire("F8", Hold::Tap).unwrap();
|
|
let target = |a: Vec<Action>| -> Vec<u32> {
|
|
match a.into_iter().next() {
|
|
Some(Action::Send { slots, .. }) => slots,
|
|
_ => panic!("expected send"),
|
|
}
|
|
};
|
|
assert_eq!(target(a), vec![1]);
|
|
assert_eq!(target(b), vec![2]);
|
|
assert_eq!(target(c), vec![3]);
|
|
}
|
|
}
|