- benilla loads real 1.12 addons from Interface/AddOns — TOC, XML and Lua through one loader, with per-addon saved variables, Bindings.xml and the lifecycle events addons expect - both AddOns screens are in: the character-select list with per-addon enable state, and the ESC menu's in-game list - the API surface is measured, not guessed — a corpus harness runs hundreds of real 1.12 addons and ranks exactly which globals, templates and verbs they demand - the Lua dialect is the client's own, read off the binary — 5.0 semantics with the 1.12 additions, and UI source files are bytes, not UTF-8 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
231 lines
8.7 KiB
Rust
231 lines
8.7 KiB
Rust
//! The Cooldown widget kind (decision 0137 phase 4): the `SetCooldown` API, the reference
|
|
//! machine's derived phases through extraction, and the tick auto-hide (`Cooldown.lua`'s
|
|
//! `OnAnimFinished` → `Hide` edge), plus the per-action dynamic-state read API.
|
|
|
|
use super::common::script;
|
|
use crate::script::*;
|
|
|
|
fn cooldown_content(s: &UiScript) -> Option<(f32, Option<f32>)> {
|
|
s.extract().into_iter().find_map(|q| match q.content {
|
|
QuadContent::Cooldown { fraction, flash } => Some((fraction, flash)),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
/// The machine, end to end on the engine clock: hidden → sweep (fraction scrubs with GetTime) →
|
|
/// the 1 s finish flash → auto-hidden by tick.
|
|
#[test]
|
|
fn cooldown_widget_sweeps_flashes_and_hides() {
|
|
let mut s = script();
|
|
s.set_screen_size(800.0, 600.0);
|
|
s.run(
|
|
r#"
|
|
cd = CreateFrame("Cooldown", "TestCooldown")
|
|
cd:SetWidth(36); cd:SetHeight(36); cd:SetPoint("CENTER")
|
|
cd:Hide()
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
s.resolve();
|
|
assert_eq!(cooldown_content(&s), None, "hidden — no slot emitted");
|
|
|
|
// Arm a 10 s cooldown that started 2.5 s ago (the reference's start-in-the-past shape from
|
|
// GetActionCooldown) and show — the sweep reads 25 %.
|
|
s.run("cd:SetCooldown(GetTime() - 2.5, 10); cd:Show()")
|
|
.unwrap();
|
|
s.resolve();
|
|
let (fraction, flash) = cooldown_content(&s).expect("shown — the slot is emitted");
|
|
assert!(
|
|
(fraction - 0.25).abs() < 1e-3,
|
|
"sweep at 25%, got {fraction}"
|
|
);
|
|
assert_eq!(flash, None, "no flash while the sweep runs");
|
|
|
|
// Advance past the sweep end into the flash's middle: fraction ≥ 1, flash ≈ 0.5.
|
|
s.tick(8.0); // now 0.5 s into the flash window
|
|
s.resolve();
|
|
let (fraction, flash) = cooldown_content(&s).expect("flash phase still emits");
|
|
assert!(fraction >= 1.0);
|
|
let f = flash.expect("the finish flash runs for 1 s after the sweep");
|
|
assert!((f - 0.5).abs() < 0.05, "flash midway, got {f}");
|
|
|
|
// Past the flash: tick hides the widget (the OnAnimFinished → Hide edge).
|
|
s.tick(1.0);
|
|
s.resolve();
|
|
assert_eq!(cooldown_content(&s), None, "finished — tick hid the widget");
|
|
assert!(
|
|
!s.eval::<bool>("return cd:IsVisible() == 1").unwrap(),
|
|
"the frame itself is hidden, not just skipped"
|
|
);
|
|
}
|
|
|
|
/// The per-action dynamic-state API: the 1/nil conventions, IsActionInRange's tri-state, and
|
|
/// GetActionCooldown's GetTime-space triple that goes cold at expiry.
|
|
#[test]
|
|
fn action_state_bindings_answer_the_reference_conventions() {
|
|
let mut s = script();
|
|
|
|
// No state pushed: everything nil / cold.
|
|
assert!(s.eval::<bool>("return IsUsableAction(3) == nil").unwrap());
|
|
assert!(s.eval::<bool>("return IsActionInRange(3) == nil").unwrap());
|
|
assert!(s
|
|
.eval::<bool>("local st, d, e = GetActionCooldown(3); return st == 0 and d == 0 and e == 1")
|
|
.unwrap());
|
|
|
|
s.tick(100.0); // an arbitrary clock epoch
|
|
s.set_action_state(
|
|
3,
|
|
Some(ActionState {
|
|
usable: false,
|
|
not_enough_mana: true,
|
|
in_range: Some(false),
|
|
has_range: true,
|
|
current: true,
|
|
auto_repeat: false,
|
|
is_attack: false,
|
|
consumable: true,
|
|
equipped: false,
|
|
// 4 s remaining of a 10 s cooldown, running: started at GetTime 94.
|
|
cooldown: Some((94_000, 10_000, true)),
|
|
}),
|
|
);
|
|
|
|
// The 1/nil pairs, exactly as the transcribed `if` reads them.
|
|
assert!(s
|
|
.eval::<bool>("local u, oom = IsUsableAction(3); return u == nil and oom == 1")
|
|
.unwrap());
|
|
assert!(s.eval::<bool>("return IsActionInRange(3) == 0").unwrap());
|
|
assert!(s.eval::<bool>("return ActionHasRange(3) == 1").unwrap());
|
|
assert!(s.eval::<bool>("return IsCurrentAction(3) == 1").unwrap());
|
|
assert!(s
|
|
.eval::<bool>("return IsAutoRepeatAction(3) == nil")
|
|
.unwrap());
|
|
assert!(s.eval::<bool>("return IsConsumableAction(3) == 1").unwrap());
|
|
|
|
// GetActionCooldown: the pushed absolute start, verbatim in seconds.
|
|
assert!(s
|
|
.eval::<bool>(
|
|
"local st, d, e = GetActionCooldown(3); \
|
|
return math.abs(st - 94) < 0.001 and d == 10 and e == 1"
|
|
)
|
|
.unwrap());
|
|
|
|
// 5 s later the same stored pair still answers (4 s window has 0 left → but 100+? no: it
|
|
// expires at start+10 = now+4) …after the remaining 4 s pass, the read goes cold — the
|
|
// stale-refeed guard (a re-fed finished pair must not replay the sweep/flash).
|
|
s.tick(3.9);
|
|
assert!(s
|
|
.eval::<bool>("local st, d = GetActionCooldown(3); return d == 10")
|
|
.unwrap());
|
|
s.tick(0.2);
|
|
assert!(s
|
|
.eval::<bool>("local st, d, e = GetActionCooldown(3); return st == 0 and d == 0 and e == 1")
|
|
.unwrap());
|
|
|
|
// An on-hold (enable == 0) cooldown never goes cold on its own — parked until the event.
|
|
s.set_action_state(
|
|
3,
|
|
Some(ActionState {
|
|
cooldown: Some((100_000, 30_000, false)),
|
|
..Default::default()
|
|
}),
|
|
);
|
|
s.tick(120.0);
|
|
assert!(s
|
|
.eval::<bool>("local st, d, e = GetActionCooldown(3); return d == 30 and e == 0")
|
|
.unwrap());
|
|
|
|
// Clearing the state clears the reads.
|
|
s.set_action_state(3, None);
|
|
assert!(s.eval::<bool>("return IsCurrentAction(3) == nil").unwrap());
|
|
}
|
|
|
|
/// The absolute-start triple is the anchor — both director regressions pin here. Reset-on-kill
|
|
/// ("the cooldown indicator resets when you kill a mob"): an unrelated field flip re-pushes the
|
|
/// same running cooldown, whose triple carries the SAME start, so the sweep holds. The vanished
|
|
/// GCD pie ("spamming Rend during Charge never shows the pie"): a fail-clear + re-arm between
|
|
/// feeds pushes a triple with a NEW start — under the old `(remaining, duration)` shape the two
|
|
/// arms read byte-identical and the seam kept the first, long-elapsed anchor.
|
|
#[test]
|
|
fn the_absolute_start_triple_holds_the_anchor_and_a_rearm_moves_it() {
|
|
let mut s = script();
|
|
s.tick(100.0);
|
|
let cooling = |usable: bool| ActionState {
|
|
usable,
|
|
cooldown: Some((100_000, 15_000, true)),
|
|
..Default::default()
|
|
};
|
|
s.set_action_state(3, Some(cooling(false)));
|
|
assert!(s
|
|
.eval::<bool>("local st = GetActionCooldown(3); return math.abs(st - 100) < 1e-3")
|
|
.unwrap());
|
|
|
|
// 8 s later the kill drops combat and `usable` flips; the re-push carries the same start.
|
|
// The anchor holds at 100 — the sweep never restarts (the reset-on-kill invariant).
|
|
s.tick(8.0);
|
|
s.set_action_state(3, Some(cooling(true)));
|
|
assert!(s
|
|
.eval::<bool>(
|
|
"local st, d = GetActionCooldown(3); \
|
|
return math.abs(st - 100) < 1e-3 and d == 15"
|
|
)
|
|
.unwrap());
|
|
|
|
// A RE-ARM carries a fresh start and moves the anchor — even with the same duration (the
|
|
// vanished-pie shape: same spell, same 15 s, armed again at t=107).
|
|
s.set_action_state(
|
|
3,
|
|
Some(ActionState {
|
|
cooldown: Some((107_000, 15_000, true)),
|
|
..Default::default()
|
|
}),
|
|
);
|
|
assert!(s
|
|
.eval::<bool>("local st = GetActionCooldown(3); return math.abs(st - 107) < 1e-3")
|
|
.unwrap());
|
|
}
|
|
|
|
/// The same absolute-start law on the other two converters: the stance bar's form cooldown and
|
|
/// the bag slot's item cooldown hold their anchors across unchanged re-pushes.
|
|
#[test]
|
|
fn shapeshift_and_container_cooldowns_keep_their_anchors_too() {
|
|
let mut s = script();
|
|
s.tick(50.0);
|
|
let form = || ShapeshiftFormView {
|
|
spell_id: 2457,
|
|
name: "Battle Stance".into(),
|
|
cooldown: Some((50_000, 10_000, true)),
|
|
..Default::default()
|
|
};
|
|
s.set_shapeshift_forms(vec![form()]);
|
|
let slot = || ContainerSlot {
|
|
item_id: 118,
|
|
count: 1,
|
|
cooldown: Some((50_000, 30_000, true)),
|
|
..Default::default()
|
|
};
|
|
let bag = || ContainerState {
|
|
name: Some("Backpack".into()),
|
|
num_slots: 16,
|
|
slots: std::collections::HashMap::from([(1, slot())]),
|
|
};
|
|
s.set_container(0, Some(bag()));
|
|
assert!(s
|
|
.eval::<bool>("local st = GetShapeshiftFormCooldown(1); return math.abs(st - 50) < 1e-3")
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>("local st = GetContainerItemCooldown(0, 1); return math.abs(st - 50) < 1e-3")
|
|
.unwrap());
|
|
|
|
// Re-push both with the SAME raw triples 5 s later: the anchors hold at 50.
|
|
s.tick(5.0);
|
|
s.set_shapeshift_forms(vec![form()]);
|
|
s.set_container(0, Some(bag()));
|
|
assert!(s
|
|
.eval::<bool>("local st = GetShapeshiftFormCooldown(1); return math.abs(st - 50) < 1e-3")
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>("local st = GetContainerItemCooldown(0, 1); return math.abs(st - 50) < 1e-3")
|
|
.unwrap());
|
|
}
|