enBoxer/src/engine.rs
en 8eb5346d92 Split smart interact shortcut from single Alt+J send
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.
2026-09-16 05:41:45 +02:00

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]);
}
}