Files
benilla-octo/crates/benilla-app/src/ui_macro/tests.rs
T
SamandClaude Fable 5 a401e5c43d the dressing room opens, and the pet gets its paper doll
- ctrl-click an item to try it on: the dressing room dresses your double, and shift-click posts the item link to chat
- the pet tab lands in the character window: paper doll stats, family and diet, the right-click menu, and dropping food on the pet feeds it
- water-only spells refuse on dry land: aquatic form gates to swimming, client-side like the real one
- the macro icon picker's white squares get their art back, and option rows explain themselves on hover

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:02:21 +07:00

283 lines
12 KiB
Rust

//! The app half of the macro system (decision 0983): persistence under `benilla/macros/`, the
//! runner's route into the chat drain, and the seed/dirty contract the plugin's systems rely on.
//!
//! The file FORMAT has its own tests in [`super::store`] (including the director's real 1.12
//! `macros-cache.txt`); the API's own round trip is `benilla_ui::script::macros`'; the window's is
//! `crate::ui_script::macro_tests`. What is only testable here is the wiring.
use benilla_ui::script::{MacroState, MacroView, UiScript};
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
fn macro_view(name: &str, body: &str) -> MacroView {
MacroView {
name: name.into(),
texture: Some("Interface\\Icons\\Ability_Ambush".into()),
body: body.into(),
local_only: false,
}
}
/// A save writes the reference's own format under `benilla/macros/`, and a load brings the same
/// macros back — the whole persistence loop over the real `local_state` law.
#[test]
fn a_saved_macro_table_round_trips_through_benilla_macros() {
let _l = ENV_LOCK.lock().unwrap();
let tmp = std::env::temp_dir().join(format!("benilla-macros-{}", std::process::id()));
std::fs::remove_dir_all(&tmp).ok();
let _c = EnvGuard::unset("WOW_CAPTURE");
let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap());
let account = crate::local_state::macros_account_path().unwrap();
let character = crate::local_state::macros_character_path("Test Realm", "Probeone").unwrap();
let state = MacroState {
account: vec![macro_view("Ambush", "/cast Ambush\n/say pew")],
character: vec![macro_view("Charge", "/cast Charge")],
};
crate::local_state::write_atomic(&account, &super::store::write(&state.account)).unwrap();
crate::local_state::write_atomic(&character, &super::store::write(&state.character)).unwrap();
// The file on disk is the reference's own shape — readable, hand-editable, and the exact
// format a vanilla `macros-cache.txt` already has.
assert_eq!(
std::fs::read_to_string(&account).unwrap(),
"MACRO 1 \"Ambush\" Ability_Ambush\n/cast Ambush\n/say pew\nEND\n"
);
let back = MacroState {
account: super::store::parse(&std::fs::read_to_string(&account).unwrap()),
character: super::store::parse(&std::fs::read_to_string(&character).unwrap()),
};
assert_eq!(back, state);
std::fs::remove_dir_all(&tmp).ok();
}
/// A capture run is hermetic (decision 0954): both paths resolve to `None`, so a macro edit during
/// a capture is session-only and nothing is written under anyone's install.
#[test]
fn a_capture_run_persists_nothing() {
let _l = ENV_LOCK.lock().unwrap();
let _h = EnvGuard::set("BENILLA_HOME", "/tmp/benilla-should-not-exist");
let _c = EnvGuard::set("WOW_CAPTURE", "ui-macro");
assert_eq!(crate::local_state::macros_account_path(), None);
assert_eq!(crate::local_state::macros_character_path("R", "C"), None);
}
/// The real UI, so the WHOLE route is under test: `run_macro` fires `EXECUTE_CHAT_LINE`, the
/// shipped ChatFrame1 is registered for it, and its handler calls `SubmitChatInput`. A bare VM
/// would pass this by accident under the old direct-push shape and silently prove nothing under
/// this one.
fn ui() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let failures = crate::ui_script::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
s
}
/// The runner delivers every body line through the reference's own door — one
/// `EXECUTE_CHAT_LINE` event per non-empty line, in order, landing in the chat-input queue via
/// ChatFrame1's handler (0996, wow-re `macro-execution-law.md` §4). That is what makes `/cast`,
/// `/target`, `/script`, the chat types and the 225 emotes all work in a macro without the runner
/// knowing any of them.
#[test]
fn running_a_macro_queues_its_lines_as_chat_input() {
let mut s = ui();
s.set_macros(MacroState {
account: vec![macro_view(
"Ambush",
"/cast Ambush\n\n /say pew \n/target Bob",
)],
character: Vec::new(),
});
assert!(super::run_macro(&mut s, 1));
assert_eq!(
s.take_chat_input(),
vec!["/cast Ambush", "/say pew", "/target Bob"],
"blank lines dropped, each line trimmed, order kept"
);
// An empty macro and an empty slot both run nothing and queue nothing.
s.set_macros(MacroState {
account: vec![macro_view("Blank", " \n\n")],
character: Vec::new(),
});
assert!(!super::run_macro(&mut s, 1));
assert!(!super::run_macro(&mut s, 7));
assert!(s.take_chat_input().is_empty());
}
/// A CHARACTER-range macro runs by its own index — the second half of the space is not a special
/// case anywhere in the runner.
#[test]
fn a_character_macro_runs_by_its_own_index() {
let mut s = ui();
s.set_macros(MacroState {
account: Vec::new(),
character: vec![macro_view("Charge", "/cast Charge")],
});
assert!(super::run_macro(&mut s, 19));
assert_eq!(s.take_chat_input(), vec!["/cast Charge"]);
}
/// The seed→dirty→save contract the plugin's two systems rest on: the app's own load must not look
/// like a change (or every login would rewrite the file), and every script mutation must.
#[test]
fn the_dirty_edge_distinguishes_a_load_from_an_edit() {
let mut s = UiScript::new().unwrap();
s.set_macros(MacroState {
account: vec![macro_view("Ambush", "/cast Ambush")],
character: Vec::new(),
});
assert!(
!s.take_macros_dirty(),
"loading from disk is not an edit — a save here would be a write-back loop"
);
s.run(r#"EditMacro(1, nil, nil, "/cast Backstab")"#)
.unwrap();
assert!(s.take_macros_dirty());
assert_eq!(s.macros().account[0].body, "/cast Backstab");
}
/// The generation counter is the per-frame consumers' gate (the action bar's identity feed): it
/// moves on a seed AND on every mutation, and is never consumed by reading it.
#[test]
fn the_generation_moves_on_every_write_and_is_not_drained() {
let mut s = UiScript::new().unwrap();
let at_start = s.macros_generation();
assert_eq!(s.macros_generation(), at_start, "reading never drains it");
s.set_macros(MacroState {
account: vec![macro_view("Ambush", "/cast Ambush")],
character: Vec::new(),
});
let after_seed = s.macros_generation();
assert_ne!(after_seed, at_start, "a seed changes the bar's icons too");
s.run(r#"EditMacro(1, "Renamed", 1)"#).unwrap();
assert_ne!(s.macros_generation(), after_seed);
}
/// **An addon that registers `EXECUTE_CHAT_LINE` sees macro lines** — the behaviour benilla gained
/// by firing the reference's event instead of calling its own drain (0996). In 1.12 this is not a
/// courtesy: the event is the entire mechanism, and ChatFrame1's registration is just the default
/// UI's use of it.
#[test]
fn a_registered_frame_sees_every_macro_line_as_an_event() {
let mut s = ui();
s.run(
r#"MacroSpy = CreateFrame("Frame")
MacroSpy.seen = {}
MacroSpy:RegisterEvent("EXECUTE_CHAT_LINE")
MacroSpy:SetScript("OnEvent", function()
table.insert(MacroSpy.seen, arg1)
end)"#,
)
.unwrap();
s.set_macros(MacroState {
account: vec![macro_view("Pull", "/cast Charge\n/say Incoming!")],
character: Vec::new(),
});
assert!(super::run_macro(&mut s, 1));
assert_eq!(
s.eval::<(String, String, i64)>(
"return MacroSpy.seen[1], MacroSpy.seen[2], table.getn(MacroSpy.seen)"
)
.unwrap(),
("/cast Charge".into(), "/say Incoming!".into(), 2),
"each line arrives as arg1 of its own event, in body order"
);
// …and the same lines still reach the chat grammar: the spy is an observer, not a diversion.
assert_eq!(s.take_chat_input(), vec!["/cast Charge", "/say Incoming!"]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The reference's tokenizer takes `"\r\n"` as a delimiter SET — either character splits a line
/// (wow-re `macro-execution-law.md` §3). A body carrying lone `\r`s (an old-Mac hand edit, or a
/// file round-tripped through one) is therefore three lines, not one long one.
#[test]
fn either_line_ending_splits_a_body() {
let mut s = ui();
s.set_macros(MacroState {
account: vec![macro_view("Mixed", "/cast Charge\r/say a\r\n/say b")],
character: Vec::new(),
});
assert!(super::run_macro(&mut s, 1));
assert_eq!(
s.take_chat_input(),
vec!["/cast Charge", "/say a", "/say b"],
"\\r alone splits; \\r\\n does not leave an empty token"
);
}
/// **Every icon the chooser offers RESOLVES to real art, and the catalog is the archive's** — the
/// tripwire for B221, where four pages of the picker each showed a solid WHITE cell.
///
/// This is `ui_script::shipped_xml_tests`' resolve sweep for the paths that sweep structurally
/// cannot see. That one walks static `file=` attributes in our own XML; a macro icon never appears
/// in XML — it arrives at runtime as `SetTexture(GetMacroIconInfo(i))`. Nothing checked that those
/// resolve, and an unresolvable path drew as an opaque white rectangle, so the picker shipped with
/// white squares in it and every gate green.
///
/// The catalog is now the archive enumeration the reference itself does
/// ([`benilla_formats::load_macro_icons`]), not a `SpellIcon.dbc` scan, so a name with no file
/// behind it can no longer enter the list at all — what this guards is the other half: that the
/// **resolution rule** still finds every enumerated name. `Ability_Druid_Mangle.tga` is the entry
/// that matters (the chooser stores names extension-stripped, and `…Mangle.tga.blp` ships): it only
/// resolves via the reference's second `.blp` candidate, and the old rule had no second candidate.
///
/// Resolution goes through the renderer's own [`crate::assets::sprite_candidates`], never a copy of
/// it: a sweep re-implementing the rule could agree with itself while disagreeing with what draws.
///
/// Needs client data; skips without it, like the XML sweep.
#[test]
fn every_macro_chooser_icon_resolves_in_the_client_archives() {
/// Icons on a stock 5875 install: `patch.MPQ` 77 + `interface.MPQ` 443 = 520 raw names under
/// `Interface\Icons\` matching `Spell_`/`Ability_`, less 3 that differ only by case or
/// extension — independently counted off the binary's own enumeration by the wow-re note
/// `system/ui/scratch/macro-icon-chooser.md`. The DBC scan this replaced served 521, a
/// different set: it included five names with no file and missed art the archive has.
const CHOOSER_ICONS_5875: usize = 517;
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: no client data at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let icons = benilla_formats::load_macro_icons(&mut chain).expect("load chooser catalog");
assert_eq!(
icons.len(),
CHOOSER_ICONS_5875,
"chooser catalog size moved — the enumeration or its filter changed"
);
let missing: Vec<String> = icons
.iter()
.enumerate()
.filter(|(_, p)| {
!crate::assets::sprite_candidates(p)
.iter()
.any(|c| chain.contains(c))
})
.map(|(i, p)| format!("#{} {p}", i + 1))
.collect();
assert!(
missing.is_empty(),
"chooser icons that resolve to nothing (each draws as a white square): {missing:#?}"
);
// Sorted, not archive order: the reference `qsort`s case-insensitively before deduping, so the
// order the player scrolls is alphabetical.
let mut sorted = icons.clone();
sorted.sort_by_key(|p| p.to_ascii_lowercase());
assert_eq!(
icons, sorted,
"the chooser list must be case-insensitively sorted"
);
}