benilla: initial public release

A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-24 14:27:01 +07:00
co-authored by Claude Fable 5
commit 2ad0043523
778 changed files with 295134 additions and 0 deletions
@@ -0,0 +1,109 @@
//! SetPoint + resolve → GetWidth/GetHeight + rect.
use super::common::script;
use crate::layout::Rect;
// Regression: an *explicit* `nil` relativeTo must still consume its argument slot, so the
// relativePoint + offsets that follow line up. The `SetPoint("P", nil, "P", x, y)` form is the
// common FrameXML idiom for "anchor to the screen at an offset"; a leading nil that failed to
// advance the cursor silently dropped the offsets (screen-anchored frames pinned to the corner).
#[test]
fn setpoint_explicit_nil_relative_to_keeps_offsets() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "Nil")
f:SetPoint("TOPLEFT", nil, "TOPLEFT", 40, -40)
f:SetSize(300, 200)
"#,
)
.unwrap();
s.resolve();
let rect = s
.extract()
.iter()
.find_map(|q| match q.target {
crate::order::ZTarget::Frame(_) => q.rect,
_ => None,
})
.expect("resolved frame rect");
// screen [0,0,600,800], TOPLEFT+(40,-40): left 40, top 560, size 300×200.
assert_eq!(rect, Rect::new(360.0, 40.0, 560.0, 340.0));
}
#[test]
fn setpoint_resolve_size_and_rect() {
let mut s = script();
s.set_screen_size(800.0, 600.0); // screen rect [bottom 0, left 0, top 600, right 800]
s.run(
r#"
local f = CreateFrame("Frame", "Sized")
f:SetPoint("TOPLEFT", 10, -5) -- relativeTo = screen (default), relativePoint = TOPLEFT
f:SetSize(200, 50)
"#,
)
.unwrap();
s.resolve();
let (w, h): (f32, f32) = s
.eval("return Sized:GetWidth(), Sized:GetHeight()")
.unwrap();
assert_eq!(w, 200.0);
assert_eq!(h, 50.0);
// Hand-computed (layout.md oracle): TOPLEFT anchored to screen [0,0,600,800] at (10,-5), size
// 200×50 → Rect(bottom 545, left 10, top 595, right 210).
let quads = s.extract();
let frame_rect = quads
.iter()
.find_map(|q| match q.target {
crate::order::ZTarget::Frame(_) => q.rect,
_ => None,
})
.expect("resolved frame rect");
assert_eq!(frame_rect, Rect::new(545.0, 10.0, 595.0, 210.0));
}
#[test]
fn getwidth_falls_back_to_explicit_size_before_resolve() {
let s = script();
// No SetPoint ⇒ unresolvable; GetWidth returns the explicit SetSize value.
let w: f32 = s
.eval(r#"local f = CreateFrame("Frame"); f:SetWidth(123); return f:GetWidth()"#)
.unwrap();
assert_eq!(w, 123.0);
}
// A *named* relativeTo that doesn't resolve falls back to the parent/owner — the client's
// behavior — but must SAY so: the silent version misdirected ItemTextFrame's scrollbar track
// onto the parchment (an XML forward reference; anchors resolve at SetPoint time). Both the
// frame and the region SetPoint paths warn.
#[test]
fn setpoint_unresolved_name_warns() {
let mut s = script();
s.run(
r#"
local f = CreateFrame("Frame", "Orphan")
f:SetPoint("TOPLEFT", "NoSuchFrame", "TOPLEFT", 0, 0)
local t = f:CreateTexture(nil, "ARTWORK")
t:SetPoint("TOPRIGHT", "NoSuchRegion")
"#,
)
.unwrap();
let w = s.take_warnings();
assert!(
w.iter()
.any(|w| w.contains("Orphan") && w.contains("NoSuchFrame")),
"frame path: {w:#?}"
);
assert!(
w.iter()
.any(|w| w.contains("Orphan") && w.contains("NoSuchRegion")),
"region path: {w:#?}"
);
// A resolvable name stays silent.
s.run(r#"CreateFrame("Frame", "Target"); Orphan:SetPoint("BOTTOMLEFT", "Target", "TOPLEFT")"#)
.unwrap();
assert!(s.take_warnings().is_empty());
}
@@ -0,0 +1,67 @@
//! Backdrop: Lua verbs + extract emission (backdrop-mechanism.md).
use super::common::script;
use crate::script::*;
// SetBackdrop installs the plate; SetBackdropColor tints the bg only, SetBackdropBorderColor all 8
// border pieces; extract emits bg-then-border at the frame's own slot with those colors.
#[test]
fn backdrop_installs_and_extracts_pieces_with_colors() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "Plate")
f:SetPoint("TOPLEFT", nil, "TOPLEFT", 100, -100)
f:SetSize(200, 100)
f:SetBackdrop({
bgFile = "bg", edgeFile = "edge", tile = true,
tileSize = 16, edgeSize = 16,
insets = { left = 5, right = 5, top = 5, bottom = 5 },
})
f:SetBackdropColor(0.09, 0.09, 0.19)
f:SetBackdropBorderColor(1, 1, 1)
"#,
)
.unwrap();
s.resolve();
let pieces: Vec<(String, [f32; 4])> = s
.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::Backdrop { path, color, .. } => Some((path, color)),
_ => None,
})
.collect();
// bg (1) + 8 border pieces.
assert_eq!(pieces.len(), 9);
// First is the bg, tinted the tooltip background color.
assert_eq!(pieces[0].0, "bg");
assert_eq!(pieces[0].1, [0.09, 0.09, 0.19, 1.0]);
// The remaining 8 are the border, white, from the edge file.
assert!(pieces[1..]
.iter()
.all(|(p, c)| p == "edge" && *c == [1.0, 1.0, 1.0, 1.0]));
}
// SetBackdrop(nil) tears the plate down (no pieces after).
#[test]
fn set_backdrop_nil_tears_down() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "Plate2")
f:SetPoint("CENTER")
f:SetSize(100, 100)
f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" })
f:SetBackdrop(nil)
"#,
)
.unwrap();
s.resolve();
assert!(s
.extract()
.iter()
.all(|q| !matches!(q.content, QuadContent::Backdrop { .. })));
}
@@ -0,0 +1,396 @@
//! Button / CheckButton: method sets, state textures, click registration, additive highlight.
use super::common::script;
use crate::script::*;
#[test]
fn button_methods_exist_only_on_buttons() {
let s = script();
s.run(
r#"
f = CreateFrame("Frame", "PlainF")
b = CreateFrame("Button", "Btn")
cb = CreateFrame("CheckButton", "CBtn")
"#,
)
.unwrap();
// Duck-typing honesty across the class chain (RF-28 method sets).
assert!(s.eval::<bool>("return f.SetText == nil").unwrap());
assert!(s.eval::<bool>("return f.SetChecked == nil").unwrap());
assert!(s.eval::<bool>("return b.SetText ~= nil").unwrap());
assert!(s.eval::<bool>("return b.SetChecked == nil").unwrap());
assert!(s.eval::<bool>("return b.SetValue == nil").unwrap());
assert!(s.eval::<bool>("return cb.SetChecked ~= nil").unwrap());
assert!(s.eval::<bool>("return cb.SetNormalTexture ~= nil").unwrap());
// Buttons are mouse-enabled by construction (the client ctor enables input).
assert!(s.eval::<bool>("return b:IsMouseEnabled()").unwrap());
assert!(!s.eval::<bool>("return f:IsMouseEnabled()").unwrap());
}
#[test]
fn button_state_textures_switch_with_interaction() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local b = CreateFrame("Button", "StateBtn")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(100, 100)
b:SetNormalTexture("Interface\\N.blp")
b:SetPushedTexture("Interface\\P.blp")
b:SetDisabledTexture("Interface\\D.blp")
b:SetHighlightTexture("Interface\\H.blp")
b:SetText("Go")
"#,
)
.unwrap();
s.resolve();
let visible = |s: &UiScript| -> Vec<String> {
s.extract()
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. } => Some(p.clone()),
_ => None,
})
.collect()
};
// Idle: normal only (pushed/disabled suppressed; not hovered ⇒ no highlight).
assert_eq!(visible(&s), vec!["Interface\\N.blp".to_string()]);
// The ButtonText always draws.
assert!(s
.extract()
.iter()
.any(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "Go")));
// Hover: normal + additive highlight.
s.mouse_move(50.0, 50.0);
let v = visible(&s);
assert!(
v.contains(&"Interface\\N.blp".to_string()) && v.contains(&"Interface\\H.blp".to_string())
);
// Held down over it: pushed replaces normal (highlight still hovering).
s.mouse_button(50.0, 50.0, "LeftButton", true);
let v = visible(&s);
assert!(
v.contains(&"Interface\\P.blp".to_string()) && !v.contains(&"Interface\\N.blp".to_string())
);
s.mouse_button(50.0, 50.0, "LeftButton", false);
// Disabled: disabled texture only, and no highlight even under the cursor.
s.run("StateBtn:Disable()").unwrap();
assert_eq!(visible(&s), vec!["Interface\\D.blp".to_string()]);
assert!(!s.eval::<bool>("return StateBtn:IsEnabled()").unwrap());
s.run("StateBtn:Enable()").unwrap();
assert_eq!(s.eval::<String>("return StateBtn:GetText()").unwrap(), "Go");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `SetButtonState`/`GetButtonState` (`0x780270`/`0x780180`) — the scripted press state the ref's
/// `ActionButtonDown/Up` keybind pair drives: PUSHED shows the pushed texture with no mouse
/// involved, NORMAL restores, unknown states error, and a disabled button answers DISABLED.
#[test]
fn set_button_state_drives_the_pushed_visual() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local b = CreateFrame("Button", "PushBtn")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(100, 100)
b:SetNormalTexture("Interface\\N.blp")
b:SetPushedTexture("Interface\\P.blp")
"#,
)
.unwrap();
s.resolve();
let shows = |s: &UiScript, path: &str| {
s.extract()
.iter()
.any(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == path))
};
assert_eq!(
s.eval::<String>("return PushBtn:GetButtonState()").unwrap(),
"NORMAL"
);
s.run(r#"PushBtn:SetButtonState("PUSHED")"#).unwrap();
assert_eq!(
s.eval::<String>("return PushBtn:GetButtonState()").unwrap(),
"PUSHED"
);
assert!(shows(&s, "Interface\\P.blp") && !shows(&s, "Interface\\N.blp"));
s.run(r#"PushBtn:SetButtonState("NORMAL")"#).unwrap();
assert!(shows(&s, "Interface\\N.blp") && !shows(&s, "Interface\\P.blp"));
// Unknown state: a runtime error, not a silent no-op.
assert!(s.run(r#"PushBtn:SetButtonState("SIDEWAYS")"#).is_err());
// Disabled wins the read (Enable/Disable owns that state, not SetButtonState).
s.run("PushBtn:Disable()").unwrap();
assert_eq!(
s.eval::<String>("return PushBtn:GetButtonState()").unwrap(),
"DISABLED"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn disabled_button_swallows_clicks_checkbutton_toggles_before_onclick() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
clicks, seen_checked = 0, nil
local cb = CreateFrame("CheckButton", "Toggler")
cb:SetPoint("BOTTOMLEFT", 0, 0); cb:SetSize(100, 100)
cb:SetScript("OnClick", function(self, button, down)
clicks = clicks + 1
seen_checked = self:GetChecked()
end)
"#,
)
.unwrap();
s.resolve();
// Physical click: the checked state flips BEFORE OnClick (the documented contract).
s.mouse_button(50.0, 50.0, "LeftButton", true);
s.mouse_button(50.0, 50.0, "LeftButton", false);
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 1);
assert!(s.eval::<bool>("return seen_checked == true").unwrap());
assert!(s.eval::<bool>("return Toggler:GetChecked()").unwrap());
// Programmatic Click() rides the same path: toggles back off.
s.run("Toggler:Click()").unwrap();
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 2);
assert!(!s.eval::<bool>("return Toggler:GetChecked()").unwrap());
// Disabled: the click is swallowed — no OnClick, no toggle.
s.run("Toggler:Disable()").unwrap();
s.mouse_button(50.0, 50.0, "LeftButton", true);
s.mouse_button(50.0, 50.0, "LeftButton", false);
s.run("Toggler:Click()").unwrap();
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 2);
assert!(!s.eval::<bool>("return Toggler:GetChecked()").unwrap());
// SetChecked takes 1/true/nil (1.12 and Era forms).
s.run("Toggler:SetChecked(1)").unwrap();
assert!(s.eval::<bool>("return Toggler:GetChecked()").unwrap());
s.run("Toggler:SetChecked(nil)").unwrap();
assert!(!s.eval::<bool>("return Toggler:GetChecked()").unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn default_registration_is_left_click_only_right_click_reaches_nothing() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
clicks = 0
local btn = CreateFrame("Button", "Vendor")
btn:SetPoint("BOTTOMLEFT", 0, 0); btn:SetSize(100, 100)
btn:SetScript("OnClick", function(self, button, down) clicks = clicks + 1 end)
"#,
)
.unwrap();
s.resolve();
// A right-click (press+release, same frame) reaches OnMouseDown/OnMouseUp but never OnClick —
// the client's own default registered-click set is {"LeftButtonUp"} only.
s.mouse_button(50.0, 50.0, "RightButton", true);
s.mouse_button(50.0, 50.0, "RightButton", false);
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 0);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn register_for_clicks_grows_right_click_and_carries_the_button_name() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
clicks, click_btn, arg1_btn = 0, nil, nil
local btn = CreateFrame("Button", "Vendor")
btn:SetPoint("BOTTOMLEFT", 0, 0); btn:SetSize(100, 100)
btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
btn:SetScript("OnClick", function(self, button, down)
clicks = clicks + 1
click_btn = button
arg1_btn = arg1 -- the 1.12 legacy-global convention, same value
end)
"#,
)
.unwrap();
s.resolve();
s.mouse_button(50.0, 50.0, "RightButton", true);
s.mouse_button(50.0, 50.0, "RightButton", false);
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 1);
assert_eq!(s.eval::<String>("return click_btn").unwrap(), "RightButton");
assert_eq!(s.eval::<String>("return arg1_btn").unwrap(), "RightButton");
// RegisterForClicks replaces the set with its whole vararg list — LeftButtonUp still fires.
s.mouse_button(50.0, 50.0, "LeftButton", true);
s.mouse_button(50.0, 50.0, "LeftButton", false);
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 2);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn down_registration_fires_on_press_and_toggles_checked_once() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
clicks, seen_down = 0, nil
local cb = CreateFrame("CheckButton", "QuickSell")
cb:SetPoint("BOTTOMLEFT", 0, 0); cb:SetSize(100, 100)
cb:RegisterForClicks("LeftButtonDown")
cb:SetScript("OnClick", function(self, button, down)
clicks = clicks + 1
seen_down = down
end)
"#,
)
.unwrap();
s.resolve();
// The press alone fires OnClick (down=true) and toggles Checked exactly once.
s.mouse_button(50.0, 50.0, "LeftButton", true);
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 1);
assert!(s.eval::<bool>("return seen_down == true").unwrap());
assert!(s.eval::<bool>("return QuickSell:GetChecked()").unwrap());
// The release does not fire again — "LeftButtonUp" is no longer in the registered set
// (RegisterForClicks replaced it, it did not add to it).
s.mouse_button(50.0, 50.0, "LeftButton", false);
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 1);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn highlight_is_additive_and_sized_regions_center() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local b = CreateFrame("Button", "AddBtn")
b:SetPoint("BOTTOMLEFT", 100, 100); b:SetSize(36, 36)
b:SetNormalTexture("Interface\\Ring.blp")
b:GetNormalTexture():SetSize(64, 64)
b:SetHighlightTexture("Interface\\Hi.blp")
"#,
)
.unwrap();
s.resolve();
s.mouse_move(118.0, 118.0); // hover so the highlight draws
let quads = s.extract();
let find = |path: &str| {
quads
.iter()
.find(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == path))
.expect(path)
};
// The highlight carries the ADD contract (the client's SetHighlightTexture default).
assert!(matches!(
&find("Interface\\Hi.blp").content,
QuadContent::Texture { additive: true, .. }
));
// The sized ring centers on the 36px button: 100..136 → 64px box at 86..150.
let r = find("Interface\\Ring.blp").rect.unwrap();
assert_eq!(
(r.left, r.right, r.bottom, r.top),
(86.0, 150.0, 86.0, 150.0)
);
// SetBlendMode overrides back to straight alpha.
s.run(r#"AddBtn:GetHighlightTexture():SetBlendMode("BLEND")"#)
.unwrap();
let quads = s.extract();
let hi = quads
.iter()
.find(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == "Interface\\Hi.blp"))
.unwrap();
assert!(matches!(
&hi.content,
QuadContent::Texture {
additive: false,
..
}
));
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The per-state label fonts (`SetTextFontObject`/`SetDisabledFontObject`, XML `<NormalFont>` etc.):
/// the ButtonText re-points to the CURRENT state's font object at extract — gold while enabled,
/// gray after Disable() — with no Lua repaint (the client's UIPanelButtonTemplate label behavior).
/// An explicit SetTextColor survives the re-point (the client's explicitly-set mask).
#[test]
fn button_label_repaints_by_state_font_object() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.register_font_object(
"GoldFont",
FontObject {
color: Some([1.0, 0.82, 0.0, 1.0]),
height: Some(12.0),
..Default::default()
},
);
s.register_font_object(
"GrayFont",
FontObject {
color: Some([0.5, 0.5, 0.5, 1.0]),
height: Some(12.0),
..Default::default()
},
);
s.run(
r#"
b = CreateFrame("Button", "FontBtn")
b:SetPoint("CENTER", 0, 0); b:SetSize(100, 20)
b:SetText("Label")
b:SetTextFontObject("GoldFont")
b:SetDisabledFontObject("GrayFont")
"#,
)
.unwrap();
let label_color = |s: &mut crate::script::UiScript| {
s.resolve();
s.extract()
.into_iter()
.find_map(|q| match q.content {
QuadContent::Text {
text: Some(t),
color,
..
} if t == "Label" => Some(color),
_ => None,
})
.expect("label text quad")
};
assert_eq!(
label_color(&mut s),
Some([1.0, 0.82, 0.0, 1.0]),
"enabled: gold"
);
s.run("b:Disable()").unwrap();
assert_eq!(
label_color(&mut s),
Some([0.5, 0.5, 0.5, 1.0]),
"disabled: gray"
);
s.run("b:Enable()").unwrap();
assert_eq!(
label_color(&mut s),
Some([1.0, 0.82, 0.0, 1.0]),
"re-enabled: gold again"
);
// An explicit SetTextColor wins over the state font's color (explicitly-set mask).
s.run("b:GetFontString():SetTextColor(0.1, 0.2, 0.3)")
.unwrap();
let c = label_color(&mut s).expect("colored");
assert!((c[0] - 0.1).abs() < 1e-6 && (c[1] - 0.2).abs() < 1e-6);
}
@@ -0,0 +1,7 @@
//! Shared fixtures for the script test modules.
use crate::script::UiScript;
pub(super) fn script() -> UiScript {
UiScript::new().expect("construct UiScript")
}
@@ -0,0 +1,230 @@
//! 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 = C_Container.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 = C_Container.GetContainerItemCooldown(0, 1); return math.abs(st - 50) < 1e-3")
.unwrap());
}
@@ -0,0 +1,78 @@
//! End-to-end: build a two-frame anchored tree with textures, extract in ZKey order.
use super::common::script;
use crate::layout::Rect;
use crate::script::*;
#[test]
fn end_to_end_two_frame_tree_extracts_in_zkey_order() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
-- A parent frame with a background texture, and a child frame (lower in insertion order,
-- same strata/level) with a texture and a fontstring. ~30 lines of ordinary FrameXML Lua.
local parent = CreateFrame("Frame", "Root")
parent:SetPoint("TOPLEFT", 0, 0) -- anchored to the screen root
parent:SetSize(400, 300)
local pbg = parent:CreateTexture(nil, "BACKGROUND")
pbg:SetTexture("Interface\\Parent.blp")
local child = CreateFrame("Frame", "Leaf", parent)
child:SetPoint("TOPLEFT", parent, "TOPLEFT", 10, -10)
child:SetSize(100, 50)
local cbg = child:CreateTexture(nil, "ARTWORK")
cbg:SetTexture("Interface\\Child.blp")
local ctext = child:CreateFontString(nil, "OVERLAY")
ctext:SetText("Hello")
ctext:SetVertexColor(1, 0, 0, 1)
"#,
)
.unwrap();
s.resolve();
let quads = s.extract();
// The renderable content, in painter order: parent's bg, child's bg, child's text — parent frame
// draws before child (earlier insertion, same strata/level), each frame's regions grouped behind
// it (order.rs ZKey).
let content: Vec<String> = quads
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. } => Some(format!("tex:{p}")),
QuadContent::Text { text: Some(t), .. } => Some(format!("txt:{t}")),
_ => None,
})
.collect();
assert_eq!(
content,
vec![
"tex:Interface\\Parent.blp",
"tex:Interface\\Child.blp",
"txt:Hello",
]
);
// Region rects are their owner frame's resolved rect (v1). Parent → Rect(300,0,600,400);
// child (TOPLEFT+10,-10 of parent, 100×50) → Rect(540,10,590,110).
let child_text = quads
.iter()
.find(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "Hello"))
.unwrap();
assert_eq!(child_text.rect, Some(Rect::new(540.0, 10.0, 590.0, 110.0)));
// The fontstring got a red vertex color via SetVertexColor(1,0,0,1).
assert!(
matches!(&child_text.content, QuadContent::Text { color: Some(c), .. } if *c == [1.0, 0.0, 0.0, 1.0])
);
let parent_bg = quads
.iter()
.find(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.ends_with("Parent.blp")))
.unwrap();
assert_eq!(parent_bg.rect, Some(Rect::new(300.0, 0.0, 600.0, 400.0)));
// ZKey order is ascending and strictly increasing across the emitted list.
let zs: Vec<u64> = quads.iter().map(|q| q.z).collect();
let mut sorted = zs.clone();
sorted.sort_unstable();
assert_eq!(zs, sorted, "extract must already be in ZKey order");
}
@@ -0,0 +1,114 @@
//! RegisterEvent + fire_event via BOTH conventions (RF-0025).
use super::common::script;
use crate::script::*;
#[test]
fn fire_event_both_conventions_in_one_handler() {
let mut s = script();
s.run(
r#"
local f = CreateFrame("Frame", "EF")
f:RegisterEvent("UNIT_HEALTH")
f:SetScript("OnEvent", function(self, event, ...)
r_this_eq_self = (this == self) -- legacy `this` global == modern `self`
r_event_global = event -- modern `event` arg
r_event_eq = (event == _G.event) -- == legacy `event` global
r_arg1_eq = (arg1 == select(1, ...)) -- legacy `arg1` == modern select(1,...)
r_arg1 = arg1
r_arg2 = select(2, ...)
end)
"#,
)
.unwrap();
s.fire_event(
"UNIT_HEALTH",
vec![ScriptValue::Str("player".into()), ScriptValue::Int(42)],
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(s.eval::<bool>("return r_this_eq_self").unwrap());
assert_eq!(
s.eval::<String>("return r_event_global").unwrap(),
"UNIT_HEALTH"
);
assert!(s.eval::<bool>("return r_event_eq").unwrap());
assert!(s.eval::<bool>("return r_arg1_eq").unwrap());
assert_eq!(s.eval::<String>("return r_arg1").unwrap(), "player");
assert_eq!(s.eval::<i64>("return r_arg2").unwrap(), 42);
}
#[test]
fn globals_are_restored_after_firing_nesting_safe() {
let mut s = script();
s.run(
r#"
this, event, arg1 = "outer_this", "outer_event", "outer_arg1"
local f = CreateFrame("Frame", "NF")
f:RegisterEvent("E")
f:SetScript("OnEvent", function() end)
"#,
)
.unwrap();
s.fire_event("E", vec![ScriptValue::Str("x".into())]);
// After firing, the prior global values must be restored (RF-0025 set-then-restore).
let (t, e, a): (String, String, String) = s.eval("return this, event, arg1").unwrap();
assert_eq!(
(t.as_str(), e.as_str(), a.as_str()),
("outer_this", "outer_event", "outer_arg1")
);
}
#[test]
fn handler_errors_are_collected_not_panicked() {
let mut s = script();
s.run(
r#"
local f = CreateFrame("Frame", "BoomF")
f:RegisterEvent("E")
f:SetScript("OnEvent", function() error("boom") end)
"#,
)
.unwrap();
s.fire_event("E", vec![]);
let errs = s.errors();
assert_eq!(errs.len(), 1, "{errs:?}");
assert!(errs[0].contains("boom"), "{errs:?}");
}
/// The cross-frame dispatch ORDER law (wow-re `event-dispatch-order.md`, VERIFIED): the client's
/// per-event listener list is tail-appended (`0x7052d0`) and walked head-first (`0x703e50`) —
/// **FIFO: registration order = firing order**. Duplicate registration keeps the original
/// position (`0x702264` dup ret); unregister+re-register moves to the tail. The ZoneText frames
/// depend on this: both write PVPInfoTextString on one event — the last writer decides.
#[test]
fn events_fire_in_registration_order_fifo() {
let mut s = script();
s.run(
r#"
order = ""
local a = CreateFrame("Frame", "FA")
local b = CreateFrame("Frame", "FB")
local c = CreateFrame("Frame", "FC")
a:RegisterEvent("E"); b:RegisterEvent("E"); c:RegisterEvent("E")
a:SetScript("OnEvent", function() order = order .. "A" end)
b:SetScript("OnEvent", function() order = order .. "B" end)
c:SetScript("OnEvent", function() order = order .. "C" end)
"#,
)
.unwrap();
s.fire_event("E", vec![]);
assert_eq!(s.eval::<String>("return order").unwrap(), "ABC");
// Duplicate registration keeps A's position (the client's dup early-ret).
s.run("FA:RegisterEvent('E'); order = ''").unwrap();
s.fire_event("E", vec![]);
assert_eq!(s.eval::<String>("return order").unwrap(), "ABC");
// Unregister + re-register moves B to the TAIL (the node is freed, the re-add appends).
s.run("FB:UnregisterEvent('E'); FB:RegisterEvent('E'); order = ''")
.unwrap();
s.fire_event("E", vec![]);
assert_eq!(s.eval::<String>("return order").unwrap(), "ACB");
}
@@ -0,0 +1,88 @@
//! Small frame-API wins: GetLocale, SetAllPoints, SetFormattedText, SetShown, and GetPoint readback.
use super::common::script;
#[test]
fn consensus_api_small_wins() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
// GetLocale — benilla is enUS-data-only.
assert_eq!(s.eval::<String>("return GetLocale()").unwrap(), "enUS");
s.run(
r#"
p = CreateFrame("Frame", "SAP_Parent")
p:SetPoint("BOTTOMLEFT", 100, 100); p:SetSize(200, 50)
c = CreateFrame("Frame", "SAP_Child", p)
c:SetAllPoints() -- default: the parent
f = CreateFrame("Frame", "SAP_Free")
f:SetAllPoints("SAP_Parent") -- by name
fs = p:CreateFontString(nil, "OVERLAY")
fs:SetFormattedText("%2$s %1$s (%3$d)", "world", "hello", 7)
"#,
)
.unwrap();
s.resolve();
// SetAllPoints pins the full target rect, parent-default and by-name forms alike.
let ok: bool = s
.eval(
r#"
return c:GetWidth() == 200 and c:GetHeight() == 50
and f:GetWidth() == 200 and f:GetHeight() == 50
"#,
)
.unwrap();
assert!(ok, "SetAllPoints matched the target rect");
// SetFormattedText routes through the positional-aware format.
assert_eq!(
s.eval::<String>("return fs:GetText()").unwrap(),
"hello world (7)"
);
// SetShown: truthy shows, nil/false hides.
s.run("p:SetShown(false)").unwrap();
assert!(!s.eval::<bool>("return p:IsShown()").unwrap());
s.run("p:SetShown(1)").unwrap();
assert!(s.eval::<bool>("return p:IsShown()").unwrap());
s.run("p:SetShown(nil)").unwrap();
assert!(!s.eval::<bool>("return p:IsShown()").unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn getpoint_reads_back_anchors() {
let s = script();
s.run(
r#"
a = CreateFrame("Frame", "GP_A")
a:SetPoint("BOTTOMLEFT", 100, 100)
b = CreateFrame("Frame", "GP_B")
b:SetPoint("TOPLEFT", a, "BOTTOMRIGHT", 4, -8)
"#,
)
.unwrap();
// Screen-anchored: relativeTo is nil (no UIParent wrapper yet — stated).
let ok: bool = s
.eval(
r#"
local p, rel, rp, x, y = GP_A:GetPoint()
return p == "BOTTOMLEFT" and rel == nil and rp == "BOTTOMLEFT" and x == 100 and y == 100
"#,
)
.unwrap();
assert!(ok, "screen anchor reads back");
// Frame-anchored: relativeTo is the same wrapper table (stable identity).
let ok: bool = s
.eval(
r#"
local p, rel, rp, x, y = GP_B:GetPoint(1)
return p == "TOPLEFT" and rel == a and rp == "BOTTOMRIGHT" and x == 4 and y == -8
"#,
)
.unwrap();
assert!(ok, "frame anchor reads back with wrapper identity");
// Out-of-range n: all nils.
assert!(s.eval::<bool>("return GP_B:GetPoint(9) == nil").unwrap());
}
+207
View File
@@ -0,0 +1,207 @@
//! Input / hit-testing (decision 0068; spec-faithful, not byte-pinned).
use super::common::script;
#[test]
fn enable_mouse_gates_hit_testing() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
who = nil
-- Two full-screen frames. `b` is created later (drawn on top) but mouse-disabled, so it is
-- transparent to hits; the enabled frame behind it (`a`) must capture.
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(800, 600); a:EnableMouse(true)
a:SetScript("OnEnter", function(self) who = self:GetName() end)
local b = CreateFrame("Frame", "B")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(800, 600); b:EnableMouse(false)
b:SetScript("OnEnter", function(self) who = self:GetName() end)
assert(a:IsMouseEnabled() == true and b:IsMouseEnabled() == false)
"#,
)
.unwrap();
s.resolve();
let hit = s.mouse_move(400.0, 300.0);
assert!(
hit.is_some(),
"a mouse-enabled frame under the cursor captures"
);
assert_eq!(
s.eval::<String>("return who").unwrap(),
"A",
"the top frame is mouse-disabled ⇒ the enabled frame behind it captures"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn topmost_by_draw_order_captures_among_overlapping_enabled_frames() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
who = nil
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(800, 600); a:EnableMouse(true)
a:SetScript("OnEnter", function(self) who = self:GetName() end)
local b = CreateFrame("Frame", "B")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(800, 600); b:EnableMouse(true)
b:SetScript("OnEnter", function(self) who = self:GetName() end)
"#,
)
.unwrap();
s.resolve();
// Same strata/level: B has the later insertion ⇒ drawn on top ⇒ captures.
s.mouse_move(400.0, 300.0);
assert_eq!(s.eval::<String>("return who").unwrap(), "B");
// Move off everything to clear the mouseover (fires OnLeave, resets focus) before re-testing.
s.mouse_move(-10.0, -10.0);
// Raise A above B by strata (no rect change ⇒ no re-resolve needed): A now captures.
s.run("A:SetFrameStrata('DIALOG')").unwrap();
s.mouse_move(400.0, 300.0);
assert_eq!(s.eval::<String>("return who").unwrap(), "A");
s.mouse_move(-10.0, -10.0);
// Put B in the same (DIALOG) strata but a higher frame level: B captures again.
s.run("B:SetFrameStrata('DIALOG'); B:SetFrameLevel(10)")
.unwrap();
s.mouse_move(400.0, 300.0);
assert_eq!(s.eval::<String>("return who").unwrap(), "B");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn mouse_move_fires_enter_then_leave_across_a_boundary_with_correct_self() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
enters, leaves = 0, 0
enter_self_ok, leave_self_ok = false, false
local a = CreateFrame("Frame", "A") -- left half only
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true)
a:SetScript("OnEnter", function(self) enters = enters + 1; enter_self_ok = (self == a) end)
a:SetScript("OnLeave", function(self) leaves = leaves + 1; leave_self_ok = (self == a) end)
"#,
)
.unwrap();
s.resolve();
s.mouse_move(200.0, 300.0); // inside A ⇒ OnEnter
s.mouse_move(600.0, 300.0); // outside A ⇒ OnLeave
let (enters, leaves): (i64, i64) = s.eval("return enters, leaves").unwrap();
assert_eq!((enters, leaves), (1, 1));
assert!(
s.eval::<bool>("return enter_self_ok").unwrap(),
"OnEnter self is the frame"
);
assert!(
s.eval::<bool>("return leave_self_ok").unwrap(),
"OnLeave self is the frame"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn onclick_fires_on_press_release_same_frame_not_when_release_lands_elsewhere() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
clicks_a, clicks_b, click_btn = 0, 0, nil
local a = CreateFrame("Frame", "A") -- left half
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true)
a:SetScript("OnClick", function(self, button, down) clicks_a = clicks_a + 1; click_btn = button end)
local b = CreateFrame("Frame", "B") -- right half
b:SetPoint("BOTTOMLEFT", 400, 0); b:SetSize(400, 600); b:EnableMouse(true)
b:SetScript("OnClick", function(self, button, down) clicks_b = clicks_b + 1 end)
"#,
)
.unwrap();
s.resolve();
// Press + release both on A ⇒ OnClick on A (button arg == "LeftButton").
s.mouse_button(200.0, 300.0, "LeftButton", true);
s.mouse_button(200.0, 300.0, "LeftButton", false);
assert_eq!(s.eval::<i64>("return clicks_a").unwrap(), 1);
assert_eq!(s.eval::<String>("return click_btn").unwrap(), "LeftButton");
// Press on A, release on B ⇒ no OnClick on either frame.
s.mouse_button(200.0, 300.0, "LeftButton", true);
s.mouse_button(600.0, 300.0, "LeftButton", false);
assert_eq!(
s.eval::<i64>("return clicks_a").unwrap(),
1,
"release landed off A ⇒ no click on A"
);
assert_eq!(
s.eval::<i64>("return clicks_b").unwrap(),
0,
"release on B was not preceded by a press on B"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn hidden_or_effective_hidden_frame_never_captures() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
entered = false
-- own shown = false
local own = CreateFrame("Frame", "OwnHidden")
own:SetPoint("BOTTOMLEFT", 0, 0); own:SetSize(800, 600); own:EnableMouse(true)
own:SetScript("OnEnter", function() entered = true end)
own:Hide()
-- effective-hidden: child is shown but its parent is hidden
local parent = CreateFrame("Frame", "Par")
parent:SetPoint("BOTTOMLEFT", 0, 0); parent:SetSize(800, 600)
local child = CreateFrame("Frame", "Ch", parent)
child:SetPoint("BOTTOMLEFT", 0, 0); child:SetSize(800, 600); child:EnableMouse(true)
child:SetScript("OnEnter", function() entered = true end)
parent:Hide()
"#,
)
.unwrap();
s.resolve();
assert!(
s.hit_test(400.0, 300.0).is_none(),
"no visible mouse-enabled frame ⇒ no capture"
);
assert!(s.mouse_move(400.0, 300.0).is_none());
assert!(
!s.eval::<bool>("return entered").unwrap(),
"hidden / effective-hidden frames fire no OnEnter"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn mouse_wheel_passes_delta_to_the_captured_frame() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
wheel = nil
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(800, 600); a:EnableMouse(true)
a:SetScript("OnMouseWheel", function(self, delta) wheel = delta end)
"#,
)
.unwrap();
s.resolve();
s.mouse_wheel(400.0, 300.0, 1.0);
assert!((s.eval::<f64>("return wheel").unwrap() - 1.0).abs() < 1e-6);
s.mouse_wheel(400.0, 300.0, -1.0);
assert!((s.eval::<f64>("return wheel").unwrap() + 1.0).abs() < 1e-6);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -0,0 +1,170 @@
//! The measure round-trip + frame→region anchors, end to end.
use super::common::script;
use crate::script::*;
/// The measure round-trip + frame→region anchors, end to end: a height-less FontString reports a
/// [`MeasureRequest`]; the host answer becomes its implicit size; and a FRAME anchored to that
/// FontString by name binds to its measured bottom in resolve's second round — the real gossip
/// structure (option rows hang off the greeting's laid-out height, ref-GossipFrame.xml l.258-261).
#[test]
fn measured_fontstring_height_feeds_frame_anchors() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local w = CreateFrame("Frame", "Win")
w:SetPoint("TOPLEFT", 0, -100); w:SetSize(384, 512)
local g = w:CreateFontString("Greeting", "ARTWORK")
g:SetText("a long greeting that wraps")
g:SetWidth(270)
g:SetPoint("TOPLEFT", 33, -91)
local row = CreateFrame("Button", "Row1")
row:SetSize(300, 16)
row:SetPoint("TOPLEFT", "Greeting", "BOTTOMLEFT", -10, -20)
"#,
)
.unwrap();
s.resolve();
let reqs = s.fontstrings_needing_measure();
assert_eq!(reqs.len(), 1, "one height-less FontString wants measuring");
let r = &reqs[0];
assert_eq!(r.wrap_width, Some(270.0));
assert_eq!(r.text, "a long greeting that wraps");
// Host answers: 3 wrapped lines of 16px ⇒ 48 tall.
s.set_measured_text(&[(r.id, 250.0, 48.0, r.key)]);
s.resolve();
assert!(
s.fontstrings_needing_measure().is_empty(),
"cache key satisfied — no re-measure on a quiet frame"
);
let quads = s.extract();
// Greeting: TOPLEFT of Win +(33,-91) ⇒ top 409 (win top 500), measured height 48 ⇒ bottom 361.
let g = quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Text { text: Some(t), .. } if t.starts_with("a long") => q.rect,
_ => None,
})
.expect("greeting rect");
assert_eq!((g.top, g.bottom, g.left), (409.0, 361.0, 33.0));
// Row1: TOPLEFT → Greeting BOTTOMLEFT +(-10,-20) ⇒ top 341, left 23 — bound in round 2.
let row = quads
.iter()
.find_map(|q| match q.target {
crate::order::ZTarget::Frame(_) => q.rect.filter(|r| (r.width() - 300.0).abs() < 0.1),
_ => None,
})
.expect("row rect");
assert_eq!((row.top, row.left), (341.0, 23.0));
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A stored measure serves ONLY the current text: after `SetText` to a DIFFERENT string, the old
/// string's width must not leak through `GetWidth`/`GetStringWidth` — the whisper-header cursor
/// bug: the chat edit box ran `SetTextInsets(15 + header:GetWidth(), …)` on a type switch
/// (Say → "Tell Alice:") and its `w > 1` settle gate passed with the PREVIOUS header's measure,
/// latching the caret inside the new header. The metric read is key-checked
/// ([`crate::script::RegionData`]'s measure key): a changed string reads 0 until its own measure
/// lands, so poll-until-nonzero callers converge on the RIGHT width.
#[test]
fn a_changed_text_reads_zero_until_its_own_measure_lands() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local w = CreateFrame("Frame", "Win")
w:SetPoint("TOPLEFT", 0, -100); w:SetSize(384, 512)
local h = w:CreateFontString("Header", "ARTWORK")
h:SetText("Say: ")
h:SetPoint("LEFT", 13, 0)
"#,
)
.unwrap();
s.resolve();
let reqs = s.fontstrings_needing_measure();
assert_eq!(reqs.len(), 1);
let r = reqs[0].clone();
assert_eq!(r.text, "Say: ");
s.set_measured_text(&[(r.id, 30.0, 16.0, r.key)]);
assert_eq!(
s.eval::<f64>(r#"return getglobal("Header"):GetWidth()"#)
.unwrap(),
30.0
);
// The type switch: same region, new text. The old measure must NOT serve.
s.run(r#"getglobal("Header"):SetText("Tell Alice: ")"#)
.unwrap();
assert_eq!(
s.eval::<f64>(r#"return getglobal("Header"):GetWidth()"#)
.unwrap(),
0.0,
"a stale measure must not serve for changed text"
);
// The round-trip re-measures the new string; the true width serves.
s.resolve();
let reqs = s.fontstrings_needing_measure();
assert_eq!(reqs.len(), 1, "the changed text wants re-measuring");
let r2 = reqs[0].clone();
assert_eq!(r2.text, "Tell Alice: ");
assert_ne!(r2.key, r.key, "the key tracks the text");
s.set_measured_text(&[(r2.id, 72.0, 16.0, r2.key)]);
assert_eq!(
s.eval::<f64>(r#"return getglobal("Header"):GetWidth()"#)
.unwrap(),
72.0
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A zero-WIDTH FontString with an explicit height auto-sizes its width to the measured line —
/// the reference label idiom (`<Size x="0" y="16"/>` anchored TOPRIGHT→TOPLEFT: MailFrame's
/// "From:"/"Subject:" labels end at the anchor and grow leftward, and the value string anchored
/// LEFT→label RIGHT starts past them, never overlapping). Gating the measure on height alone
/// left these rects zero-width — "From" and the sender name painted on top of each other.
#[test]
fn zero_width_fontstring_autosizes_to_its_line() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local w = CreateFrame("Frame", "Win")
w:SetPoint("TOPLEFT", 0, -100); w:SetSize(384, 512)
local label = w:CreateFontString("FromLabel", "ARTWORK")
label:SetText("From:")
label:SetSize(0, 16)
label:SetPoint("TOPRIGHT", "Win", "TOPLEFT", 114, -45)
local value = w:CreateFontString("FromValue", "ARTWORK")
value:SetText("Thrall")
value:SetSize(110, 0)
value:SetPoint("LEFT", "FromLabel", "RIGHT", 5, 0)
"#,
)
.unwrap();
s.resolve();
let reqs = s.fontstrings_needing_measure();
let label_req = reqs
.iter()
.find(|r| r.text == "From:")
.expect("the zero-width label asks for a measure");
assert_eq!(
label_req.wrap_width, None,
"width 0 = unwrapped single line"
);
let value_req = reqs.iter().find(|r| r.text == "Thrall").expect("value");
let answers = [
(label_req.id, 40.0, 16.0, label_req.key),
(value_req.id, 45.0, 16.0, value_req.key),
];
s.set_measured_text(&answers);
s.resolve();
// Label: right edge pinned at Win left +114, measured width 40 ⇒ [74, 114].
let (l_left, l_right, l_w): (f32, f32, f32) = s
.eval("return FromLabel:GetLeft(), FromLabel:GetRight(), FromLabel:GetStringWidth()")
.unwrap();
assert_eq!((l_left, l_right, l_w), (74.0, 114.0, 40.0));
// Value: starts 5 past the label's real right edge — no overlap.
let v_left: f32 = s.eval("return FromValue:GetLeft()").unwrap();
assert_eq!(v_left, 119.0);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -0,0 +1,128 @@
//! The Minimap widget kind (decision 0203): zoom API + the extracted content hole.
use super::common::script;
use crate::script::*;
use crate::widget::{MINIMAP_DEFAULT_ZOOM, MINIMAP_ZOOM_LEVELS};
/// A `<Minimap>`-kind frame carries its zoom out through extraction as [`QuadContent::Minimap`]
/// at the frame's own draw slot, and the zoom API clamps like the client's `set_zoom` (0..=5).
#[test]
fn minimap_zoom_api_and_extract() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
m = CreateFrame("Minimap", "TestMinimap")
m:SetWidth(140); m:SetHeight(140); m:SetPoint("TOPRIGHT")
"#,
)
.unwrap();
// Defaults + the clamp law. Both indices seed from the CVar default "3", not 0.
assert_eq!(
s.eval::<u8>("return m:GetZoom()").unwrap(),
MINIMAP_DEFAULT_ZOOM
);
assert_eq!(
s.eval::<u8>("return m:GetZoomLevels()").unwrap(),
MINIMAP_ZOOM_LEVELS
);
s.run("m:SetZoom(3)").unwrap();
assert_eq!(s.eval::<u8>("return m:GetZoom()").unwrap(), 3);
s.run("m:SetZoom(99)").unwrap();
assert_eq!(
s.eval::<u8>("return m:GetZoom()").unwrap(),
MINIMAP_ZOOM_LEVELS - 1,
"SetZoom clamps at levels-1 like the client's 0x6daa10"
);
s.run("m:SetZoom(-2)").unwrap();
assert_eq!(s.eval::<u8>("return m:GetZoom()").unwrap(), 0);
// The widget's own slot extracts as the Minimap content hole, carrying the zoom.
s.resolve();
let mm = s
.extract()
.into_iter()
.find(|q| matches!(&q.content, QuadContent::Minimap { .. }))
.expect("the Minimap content quad");
assert!(
matches!(
&mm.content,
QuadContent::Minimap {
zoom: 0,
inside_zoom: 3
}
),
"extract carries both live indices: the outdoor one we drove to 0, the indoor one still at \
its untouched default, got {:?}",
mm.content
);
assert!(
mm.rect.is_some(),
"a sized+anchored Minimap resolves a rect"
);
// Duck-typing: the zoom API must NOT leak onto other kinds (per-kind method registries).
s.run(r#"plain = CreateFrame("Frame", "PlainF")"#).unwrap();
assert!(
s.eval::<bool>("return plain.SetZoom == nil").unwrap(),
"SetZoom must resolve nil on a plain Frame"
);
}
/// The client keeps **two** zoom indices and routes `GetZoom`/`SetZoom` on WMO containment (the
/// inside flag `0xceaa60` → outdoor `0x86f698` / indoor `0x86f69c`). Each persists across the
/// transition: zooming the inn's map right in must not disturb the zoom you left outside.
#[test]
fn minimap_indoor_and_outdoor_zoom_indices_are_independent() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
m = CreateFrame("Minimap", "TestMinimap")
m:SetWidth(140); m:SetHeight(140); m:SetPoint("TOPRIGHT")
"#,
)
.unwrap();
// Outside: the zoom API drives the outdoor index.
s.run("m:SetZoom(2)").unwrap();
assert_eq!(s.eval::<u8>("return m:GetZoom()").unwrap(), 2);
// Step inside: the API now reads/writes the indoor index, still at its own untouched default.
s.set_minimap_inside(true);
assert_eq!(
s.eval::<u8>("return m:GetZoom()").unwrap(),
MINIMAP_DEFAULT_ZOOM,
"indoors reads the separate indoor index, not the outdoor 2"
);
s.run("m:SetZoom(5)").unwrap();
assert_eq!(s.eval::<u8>("return m:GetZoom()").unwrap(), 5);
// Both indices ride out through extraction, whatever the flag says.
s.resolve();
let mm = s
.extract()
.into_iter()
.find(|q| matches!(&q.content, QuadContent::Minimap { .. }))
.expect("the Minimap content quad");
assert!(
matches!(
&mm.content,
QuadContent::Minimap {
zoom: 2,
inside_zoom: 5
}
),
"extract carries both indices independently, got {:?}",
mm.content
);
// Step back outside: the outdoor zoom is exactly where we left it.
s.set_minimap_inside(false);
assert_eq!(
s.eval::<u8>("return m:GetZoom()").unwrap(),
2,
"the outdoor index survived an indoor zoom"
);
}
+32
View File
@@ -0,0 +1,32 @@
//! Rust-driven tests of the Lua host: the object model, layout+size reads, show/hide + event + tick
//! firing (both RF-0025 conventions), the WoW stdlib (positional `format`, `strsplit`, `wipe`,
//! `getglobal`), the sandbox holes, and an end-to-end extract in ZKey order.
//!
//! Split by subject; the shared `script()` fixture lives in [`common`].
mod anchors;
mod backdrop;
mod button;
mod common;
mod cooldown;
mod end_to_end;
mod events;
mod frame_api;
mod input;
mod measure;
mod minimap;
mod object_model;
mod regions;
mod scrollframe;
mod slider;
mod statusbar;
mod stdlib;
mod talent;
mod taxi;
mod texcoord_font;
mod tooltip;
mod tooltip_item;
mod tooltip_spell;
mod tooltip_unit;
mod visibility;
mod worldmap;
@@ -0,0 +1,51 @@
//! The frame object model (RF-0023).
use super::common::script;
#[test]
fn create_frame_publishes_global_and_names_resolve() {
let s = script();
s.run(
r#"
local p = CreateFrame("Frame", "ParentF")
local c = CreateFrame("Frame", "ChildF", p)
assert(ParentF == p, "named frame auto-publishes to _G")
assert(getglobal("ParentF") == p, "getglobal resolves the published name")
assert(p:GetName() == "ParentF")
assert(c:GetParent() == p, "GetParent returns the parent wrapper")
assert(c:GetParent():GetName() == "ParentF")
"#,
)
.unwrap();
}
#[test]
fn wrapper_identity_is_stable_across_lookups() {
let s = script();
let same: bool = s
.eval(
r#"
local p = CreateFrame("Frame", "IdF")
local c = CreateFrame("Frame", nil, p)
-- GetParent twice, and the _G publish, must all be the SAME table (cached wrapper).
return (c:GetParent() == c:GetParent()) and (c:GetParent() == p) and (IdF == p)
"#,
)
.unwrap();
assert!(same, "wrapper identity must be cached/stable");
}
#[test]
fn non_overwriting_named_publish() {
let s = script();
let ok: bool = s
.eval(
r#"
local a = CreateFrame("Frame", "Dup")
local b = CreateFrame("Frame", "Dup") -- same name; must NOT overwrite _G
return (Dup == a) and (a ~= b)
"#,
)
.unwrap();
assert!(ok);
}
@@ -0,0 +1,452 @@
//! Regions join the anchor layout (decision 0068).
//!
//! The smeared-merchant root cause: region `<Size>`/`<Anchors>` used to be dropped — a region either
//! filled its owner or (with a size) drew centered, and its anchors were ignored. Regions now resolve
//! through the same leaf math as frames, owner-relative, inheriting any edge their anchors don't pin.
use super::common::script;
use crate::layout::Rect;
use crate::script::*;
/// A texture region's resolved rect, found by (a fragment of) its texture path.
fn region_tex_rect(s: &UiScript, needle: &str) -> Rect {
s.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. } if p.contains(needle) => q.rect,
_ => None,
})
.unwrap_or_else(|| panic!("no texture region rect for {needle}"))
}
/// A fontstring region's resolved rect, found by its exact text.
fn region_text_rect(s: &UiScript, text: &str) -> Rect {
s.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Text { text: Some(t), .. } if t == text => q.rect,
_ => None,
})
.unwrap_or_else(|| panic!("no text region rect for {text:?}"))
}
#[test]
fn region_texture_anchored_topleft_resolves_exact_rect() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "Owner")
f:SetPoint("BOTTOMLEFT", 0, 0) -- owner [bottom 0, left 0, top 50, right 100]
f:SetSize(100, 50)
local t = f:CreateTexture("Tex", "ARTWORK")
t:SetTexture("Interface\\Icon")
t:SetSize(24, 24)
t:SetPoint("TOPLEFT", 4, -4)
"#,
)
.unwrap();
s.resolve();
// TOPLEFT +(4,-4), 24×24 in the owner: left 4, top 46, right 28, bottom 22.
assert_eq!(
region_tex_rect(&s, "Interface\\Icon"),
Rect::new(22.0, 4.0, 46.0, 28.0)
);
}
/// A FontString's implicit extent is its measured TEXT — an unpinned edge COLLAPSES onto its
/// pinned opposite while the measure is pending (empty text never measures at all), never onto
/// the owner's edge; only an axis with NO pinned edge keeps the v1 owner fallback. (The old
/// owner-edge inheritance stretched an empty tooltip line to the plate's bottom and marched the
/// line chain out of the plate — the live NPC-tooltip spill.) Textures keep the owner fallback:
/// [`region_texture_anchored_topleft_resolves_exact_rect`]'s sized leg is unaffected either way.
#[test]
fn region_fontstring_unpinned_edge_collapses_until_measured() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "Owner")
f:SetPoint("BOTTOMLEFT", 0, 0) -- owner [0, 0, 50, 100]
f:SetSize(100, 50)
local fs = f:CreateFontString("FS", "ARTWORK")
fs:SetText("Name")
fs:SetJustifyH("LEFT")
fs:SetPoint("LEFT", 5, 0) -- left edge pinned; no size
"#,
)
.unwrap();
s.resolve();
// Pending measure: x collapses onto the pinned left (5); the y-axis has no pinned EDGE
// (a LEFT point pins the y-CENTER only), so it keeps the owner fallback.
assert_eq!(region_text_rect(&s, "Name"), Rect::new(0.0, 5.0, 50.0, 5.0));
// Measured, the rect is the text extent seated on the anchor: 40×12 around y-center 25.
let answers: Vec<(u32, f32, f32, u64)> = s
.fontstrings_needing_measure()
.iter()
.map(|r| (r.id, 40.0, 12.0, r.key))
.collect();
s.set_measured_text(&answers);
s.resolve();
assert_eq!(
region_text_rect(&s, "Name"),
Rect::new(19.0, 5.0, 31.0, 45.0)
);
}
#[test]
fn region_sized_no_anchor_centers_on_owner() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "Owner")
f:SetPoint("BOTTOMLEFT", 0, 0) -- owner [0, 0, 50, 100]
f:SetSize(100, 50)
local t = f:CreateTexture("Tex", "ARTWORK")
t:SetTexture("Interface\\Ring")
t:SetSize(24, 24) -- no anchors ⇒ centered on the owner
"#,
)
.unwrap();
s.resolve();
// center (50, 25), 24×24 → [bottom 13, left 38, top 37, right 62].
assert_eq!(
region_tex_rect(&s, "Interface\\Ring"),
Rect::new(13.0, 38.0, 37.0, 62.0)
);
}
#[test]
fn region_set_all_points_fills_owner() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "Owner")
f:SetPoint("BOTTOMLEFT", 0, 0) -- owner [0, 0, 50, 100]
f:SetSize(100, 50)
local t = f:CreateTexture("Tex", "ARTWORK")
t:SetTexture("Interface\\Fill")
t:SetSize(24, 24) -- size present, but setAllPoints wins
t:SetAllPoints()
"#,
)
.unwrap();
s.resolve();
assert_eq!(
region_tex_rect(&s, "Interface\\Fill"),
Rect::new(0.0, 0.0, 50.0, 100.0)
);
}
/// A region anchored to a **sibling region by name** resolves against the sibling's rect — the
/// merchant label-plate shape (`plate LEFT → $parentSlot RIGHT −9`), which the old owner-fallback
/// mis-anchored to the row's right edge (the jutting-plates bug the director's A/B caught). The
/// plate is declared BEFORE the slot so only the resolve fixpoint (not declaration order) can
/// order the chain; the slot itself is region-anchored to the row, one deeper than owner-direct.
#[test]
fn region_anchors_to_sibling_region_by_name() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local row = CreateFrame("Button", "Row")
row:SetPoint("BOTTOMLEFT", 100, 100); row:SetSize(153, 44)
-- Plate first: its target doesn't exist yet at SetPoint time in XML order terms; the
-- name lookup happens at SetPoint (both exist by then in real loads), the RECT ordering
-- is the fixpoint's job.
local plate = row:CreateTexture("RowPlate", "BACKGROUND")
plate:SetSize(128, 78)
local slot = row:CreateTexture("RowSlot", "BACKGROUND")
slot:SetSize(64, 64)
slot:SetPoint("TOPLEFT", "Row", "TOPLEFT", -13, 13)
plate:SetPoint("LEFT", "RowSlot", "RIGHT", -9, -18)
"#,
)
.unwrap();
s.resolve();
let quads = s.extract();
let rect = |name: &str| {
quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path, .. } if path.is_none())
&& q.rect.is_some_and(|r| {
(r.width() - if name == "RowSlot" { 64.0 } else { 128.0 }).abs() < 0.1
})
})
.and_then(|q| q.rect)
.unwrap_or_else(|| panic!("no rect for {name}"))
};
// Slot: row TOPLEFT (100..253, 100..144) → slot at (87, 157)-(151, 93)… y-up: top = 144+13 = 157.
let slot = rect("RowSlot");
assert_eq!(
(slot.left, slot.top),
(87.0, 157.0),
"slot at row TOPLEFT (-13,13)"
);
assert_eq!(slot.right, 151.0);
// Plate LEFT anchors to the SLOT's RIGHT (151) − 9 = 142 — NOT the row's right edge (253),
// which is where the old owner-fallback shoved it (253 − 9 + 128 = jutting past everything).
let plate = rect("RowPlate");
assert_eq!(plate.left, 142.0, "plate.LEFT = slot.RIGHT − 9");
assert_eq!(
plate.right, 270.0,
"plate spans its 128 width from the slot edge"
);
// The LEFT single-point anchor centers the plate vertically on the target point (y-up):
// slot center-y = (93+157)/2 = 125, −18 → 107; ±39.
assert_eq!((plate.bottom, plate.top), (68.0, 146.0));
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `SetPortraitTexture(region, unit)` (the live model-bake portrait) binds a Texture region to a unit
/// token, carried out through [`QuadContent::Texture::portrait_unit`] with no BLP path/color of its
/// own (the app supplies the off-screen bake). A later `SetTexture` makes it an ordinary texture again,
/// dropping the binding.
#[test]
fn set_portrait_texture_binds_unit_token_then_settexture_clears_it() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "PFrame")
f:SetPoint("TOPLEFT", 0, 0)
f:SetSize(100, 100)
local p = f:CreateTexture("PFramePortrait", "BACKGROUND")
p:SetSize(64, 64)
p:SetPoint("TOPLEFT", 0, 0)
SetPortraitTexture(p, "player")
"#,
)
.unwrap();
s.resolve();
let bound = s.extract().into_iter().find_map(|q| match q.content {
QuadContent::Texture {
portrait_unit: Some(u),
path,
color,
circular,
..
} => Some((u, path, color, circular)),
_ => None,
});
let (unit, path, color, circular) = bound.expect("a portrait-bound quad is extracted");
assert_eq!(unit, "player");
assert_eq!(path, None, "the model bake carries no BLP path");
assert_eq!(color, None, "the model bake carries no vertex color");
assert!(circular, "the frame-ring portrait is the round stencil");
// SetTexture reverts the region to an ordinary texture — the live-unit binding drops.
s.run(r#" PFramePortrait:SetTexture("Interface\\Icons\\INV_Misc_QuestionMark") "#)
.unwrap();
s.resolve();
assert!(
!s.extract().iter().any(|q| matches!(
&q.content,
QuadContent::Texture {
portrait_unit: Some(_),
..
}
)),
"SetTexture clears the live-unit portrait binding"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `BenillaSetBoothTexture(region, token)` — the paper doll's **square** booth binding (decision
/// 0208 §5): the same `portrait_unit` carriage as `SetPortraitTexture`, but `circular` stays
/// false (the model pane samples the body bake edge to edge, no frame ring to mask for).
#[test]
fn benilla_set_booth_texture_binds_square() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "DollFrame")
f:SetPoint("TOPLEFT", 0, 0)
f:SetSize(200, 300)
local m = f:CreateTexture("DollFrameModel", "ARTWORK")
m:SetAllPoints()
BenillaSetBoothTexture(m, "paperdoll")
"#,
)
.unwrap();
s.resolve();
let bound = s.extract().into_iter().find_map(|q| match q.content {
QuadContent::Texture {
portrait_unit: Some(u),
circular,
..
} => Some((u, circular)),
_ => None,
});
let (token, circular) = bound.expect("a booth-bound quad is extracted");
assert_eq!(token, "paperdoll");
assert!(
!circular,
"the booth pane draws square — no inscribed-circle mask"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The quest-log detail pane's shape (the 0109 look fix): a LONG region anchor chain — the real
/// client's QuestLogFrame chains ~15 regions deep (title → objectives → obj1..10 → description →
/// rewards) — with a frame (the reward item button) anchored to the chain's tail. The resolver
/// must run to a true fixpoint: the old bounded rounds (2 frame × 3 region passes) left links past
/// ~6 on silent owner-edge fallbacks and dropped the tail-anchored FRAME to the screen origin —
/// decision 0088 §2's "button anchored to a chained region falls to the screen origin" finding,
/// which forced every window into invented fixed offsets instead of ref-verbatim chains.
#[test]
fn long_region_chain_resolves_and_a_frame_binds_to_its_tail() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "Book")
f:SetPoint("TOPLEFT", 0, 0)
f:SetSize(384, 512)
for i = 1, 12 do
local t = f:CreateTexture("Line" .. i, "ARTWORK")
t:SetTexture("Interface\\Line" .. i)
t:SetSize(300, 10)
if i == 1 then
t:SetPoint("TOPLEFT", "Book", "TOPLEFT", 5, -5)
else
t:SetPoint("TOPLEFT", "Line" .. (i - 1), "BOTTOMLEFT", 0, -2)
end
end
local b = CreateFrame("Button", "TailButton", f)
b:SetSize(147, 41)
b:SetPoint("TOPLEFT", "Line12", "BOTTOMLEFT", 0, -6)
"#,
)
.unwrap();
s.resolve();
// Screen top 600; Line1 top = 600−5 = 595; each link steps 12 (10 height + 2 gap).
for i in 1..=12u32 {
let top = 595.0 - 12.0 * (i as f32 - 1.0);
assert_eq!(
region_tex_rect(&s, &format!("Interface\\Line{i}")),
Rect::new(top - 10.0, 5.0, top, 305.0),
"link {i} must resolve at its true chain position, not an owner-edge fallback"
);
}
// The tail frame: TOPLEFT = Line12's BOTTOMLEFT (5, 453) − 6 → top 447, spans 147×41.
let button = s
.extract()
.iter()
.find_map(|q| match q.target {
crate::order::ZTarget::Frame(_) => q.rect.filter(|r| (r.width() - 147.0).abs() < 0.1),
_ => None,
})
.expect("the tail-anchored button resolved a rect (not dropped to origin)");
assert_eq!(
button,
Rect::new(406.0, 5.0, 447.0, 152.0),
"the frame binds to the CHAIN TAIL's resolved rect"
);
}
/// Decision 0088 §2 pinned "a child frame shown at runtime does not draw its own `<Layers>`
/// FontStrings" — the engine constraint that forced every window FLAT. Re-tested after the
/// resolver fixpoint (decision 0112): a child frame created and SHOWN at runtime, carrying its own
/// text + texture regions, must extract both quads at the child's resolved position.
#[test]
fn child_frame_layers_regions_render_after_the_fixpoint() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local parent = CreateFrame("Frame", "Win")
parent:SetPoint("TOPLEFT", 10, -10)
parent:SetSize(400, 300)
local child = CreateFrame("Frame", "SubPanel", parent)
child:SetPoint("TOPLEFT", "Win", "TOPLEFT", 20, -20)
child:SetSize(200, 100)
local fs = child:CreateFontString("SubText", "ARTWORK")
fs:SetPoint("TOPLEFT", "SubPanel", "TOPLEFT", 5, -5)
fs:SetSize(150, 12)
fs:SetText("hello from the child")
local tex = child:CreateTexture("SubTex", "BACKGROUND")
tex:SetTexture("Interface\\SubFill")
tex:SetAllPoints()
child:Hide()
child:Show()
"#,
)
.unwrap();
s.resolve();
let quads = s.extract();
let text = quads.iter().find(|q| {
matches!(&q.content, crate::script::QuadContent::Text { text: Some(t), .. } if t == "hello from the child")
});
assert!(
text.is_some_and(|q| q.rect.is_some_and(|r| (r.left - 35.0).abs() < 0.5)),
"the child frame's own FontString must extract at its resolved spot (got {:?})",
text.and_then(|q| q.rect)
);
let tex = quads.iter().find(|q| {
matches!(&q.content, crate::script::QuadContent::Texture { path: Some(p), .. } if p == "Interface\\SubFill")
});
assert!(
tex.is_some_and(|q| q.rect.is_some()),
"the child's texture too"
);
}
/// `SetAlpha`/`GetAlpha` on a Texture/FontString — the region's *own* alpha, distinct from its
/// owner frame's. A region draws at `ownAlpha × ownerFrame.alpha`: a single hop to the immediate
/// owner (wow-re `propagation.md` — frame SetAlpha overwrite-cascades onto child *frames* and only
/// invalidates child regions). The getter must return the region's value, never the frame's: the
/// ref kit ramps a texture by reading it back (`CastingBarFlash:SetAlpha(GetAlpha() + step)`).
#[test]
fn region_alpha_is_its_own_and_multiplies_the_owner_frames() {
let mut s = script();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
f = CreateFrame("Frame", "AlphaOwner")
f:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, 0)
f:SetSize(10, 10)
tex = f:CreateTexture("AlphaTex", "ARTWORK")
tex:SetTexture("Interface\\Foo")
assert(tex:GetAlpha() == 1, "an untouched region is opaque")
"#,
)
.unwrap();
s.resolve();
let quad_alpha = |s: &UiScript| {
s.extract()
.iter()
.find(|q| matches!(&q.content, QuadContent::Texture { path: Some(_), .. }))
.expect("texture quad")
.alpha
};
assert_eq!(quad_alpha(&s), 1.0);
// The region's own alpha alone.
s.run("tex:SetAlpha(0.5)").unwrap();
assert_eq!(s.eval::<f32>("return tex:GetAlpha()").unwrap(), 0.5);
assert_eq!(quad_alpha(&s), 0.5);
// …times the owner frame's. The frame's SetAlpha does NOT overwrite the region's own value.
s.run("f:SetAlpha(0.5)").unwrap();
assert_eq!(
s.eval::<f32>("return tex:GetAlpha()").unwrap(),
0.5,
"the frame's SetAlpha leaves the region's own alpha alone"
);
assert_eq!(quad_alpha(&s), 0.25, "0.5 region × 0.5 frame");
// A hidden region draws nothing regardless of alpha.
s.run("tex:SetAlpha(1); tex:Hide()").unwrap();
assert!(
!s.extract()
.iter()
.any(|q| matches!(&q.content, QuadContent::Texture { path: Some(_), .. })),
"a hidden region emits no quad"
);
}
@@ -0,0 +1,441 @@
//! ScrollFrame (decision 0112 — the ScrollFrame mechanism): the scroll-child layout override, the
//! live vertical-scroll range, the ScrollFrame clip on `extract`, and the clip-gated `hit_test`.
use super::common::script;
use crate::layout::Rect;
use crate::script::QuadContent;
/// The rect of the first Texture quad whose path equals `marker` (a bare texture with no anchors of
/// its own draws at exactly its owner frame's resolved rect — the cheapest way to read a frame's
/// resolved rect back out through the public `extract` surface).
fn marker_rect(quads: &[crate::script::ExtractedQuad], marker: &str) -> Option<Rect> {
quads.iter().find_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. } if p == marker => q.rect,
_ => None,
})
}
/// `Some(clip)` when a marker quad was found (`clip` itself `Option<Rect>`); `None` if no such
/// marker quad exists at all (a test-authoring mistake, not a legitimate "unclipped").
fn marker_clip(quads: &[crate::script::ExtractedQuad], marker: &str) -> Option<Option<Rect>> {
quads.iter().find_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. } if p == marker => Some(q.clip),
_ => None,
})
}
#[test]
fn scrollframe_methods_exist_only_on_scrollframes() {
let s = script();
let ok: bool = s
.eval(
r#"
local sf = CreateFrame("ScrollFrame", "SfDuck")
local plain = CreateFrame("Frame")
-- Duck-typing: addons branch on `if frame.SetScrollChild then` — a plain frame says nil.
return (type(sf.SetScrollChild) == "function") and (plain.SetScrollChild == nil)
and (type(sf.SetVerticalScroll) == "function") and (plain.SetVerticalScroll == nil)
and (type(sf.GetVerticalScrollRange) == "function")
and (type(sf.Show) == "function") -- base methods still reachable through the fallback
"#,
)
.unwrap();
assert!(ok);
}
#[test]
fn get_scroll_child_roundtrips_wrapper_and_name_and_clears_on_nil() {
let s = script();
s.run(
r#"
local sf = CreateFrame("ScrollFrame", "SF")
assert(sf:GetScrollChild() == nil, "no child yet")
local child = CreateFrame("Frame", "Child")
sf:SetScrollChild(child)
assert(sf:GetScrollChild() == child, "stable wrapper identity round-trips")
sf:SetScrollChild(nil)
assert(sf:GetScrollChild() == nil, "nil clears")
-- A name string resolves like every other frame-target arg (SetParent, SetPoint's relativeTo).
local byName = CreateFrame("Frame", "ByName")
sf:SetScrollChild("ByName")
assert(sf:GetScrollChild() == byName)
"#,
)
.unwrap();
}
/// The sign convention (verified against the design's own worked example): frame top 500, vertical
/// 40 ⇒ child top 540. Also covers the 0 / clamped-to-range cases and that `SetScrollChild(nil)`
/// restores the child's own authored anchor (never mutated — the override is a local map).
#[test]
fn scroll_child_top_tracks_vertical_scroll_and_clamps_then_restores_on_clear() {
let mut s = script();
s.set_screen_size(800.0, 600.0); // screen rect: bottom 0, left 0, top 600, right 800
s.run(
r#"
local frame = CreateFrame("ScrollFrame", "SF")
frame:SetPoint("TOPLEFT", 0, -100) -- screen top 600 -> frame top 500
frame:SetSize(300, 200) -- frame bottom 300
local child = CreateFrame("Frame", "Child")
-- Authored anchor: relative to the screen (child has no parent) — overridden while scrolled,
-- and must be exactly what re-applies once the scroll child is cleared.
child:SetPoint("TOPLEFT", 20, -20)
child:SetSize(300, 600)
local marker = child:CreateTexture(nil, "ARTWORK")
marker:SetTexture("marker:child")
frame:SetScrollChild(child)
"#,
)
.unwrap();
s.resolve();
let quads = s.extract();
assert_eq!(
marker_rect(&quads, "marker:child").map(|r| r.top),
Some(500.0),
"vertical=0 -> child top = frame top"
);
s.run("SF:SetVerticalScroll(40)").unwrap();
s.resolve();
let quads = s.extract();
assert_eq!(
marker_rect(&quads, "marker:child").map(|r| r.top),
Some(540.0),
"vertical=40 -> child top = frame top + 40 (a positive offset lifts the child)"
);
// range = child_h(600) - frame_h(200) = 400; asking for 9999 clamps to it.
s.run("SF:SetVerticalScroll(9999)").unwrap();
s.resolve();
assert_eq!(
s.eval::<f32>("return SF:GetVerticalScroll()").unwrap(),
400.0
);
let quads = s.extract();
assert_eq!(
marker_rect(&quads, "marker:child").map(|r| r.top),
Some(900.0),
"clamped: frame top 500 + range 400"
);
// SetScrollChild(nil): the override stops being computed — the child's own authored anchor
// (TOPLEFT, screen, +20,-20) re-applies with no restore step needed (it was never touched).
s.run("SF:SetScrollChild(nil)").unwrap();
s.resolve();
let quads = s.extract();
assert_eq!(
marker_rect(&quads, "marker:child").map(|r| r.top),
Some(580.0),
"authored anchor survives: screen top 600 - 20"
);
}
#[test]
fn vertical_scroll_range_is_live_and_zero_when_unresolved_or_childless_or_shorter() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local frame = CreateFrame("ScrollFrame", "SF")
frame:SetPoint("TOPLEFT", 0, -100)
frame:SetSize(300, 200)
local child = CreateFrame("Frame", "Child")
child:SetSize(300, 600)
frame:SetScrollChild(child)
"#,
)
.unwrap();
// Before resolve: no resolved rects yet -> 0.
assert_eq!(
s.eval::<f32>("return SF:GetVerticalScrollRange()").unwrap(),
0.0,
"unresolved -> 0"
);
s.resolve();
assert_eq!(
s.eval::<f32>("return SF:GetVerticalScrollRange()").unwrap(),
400.0,
"600 - 200"
);
// A child shorter than the frame: 0, not negative.
s.run("Child:SetSize(300, 150)").unwrap();
s.resolve();
assert_eq!(
s.eval::<f32>("return SF:GetVerticalScrollRange()").unwrap(),
0.0,
"child shorter than frame -> 0, not negative"
);
// No child at all: 0.
s.run("SF:SetScrollChild(nil)").unwrap();
assert_eq!(
s.eval::<f32>("return SF:GetVerticalScrollRange()").unwrap(),
0.0,
"no child -> 0"
);
}
#[test]
fn vertical_scroll_fires_clamped_and_update_rect_fires_range_changed() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local frame = CreateFrame("ScrollFrame", "SF")
frame:SetPoint("TOPLEFT", 0, -100)
frame:SetSize(300, 200)
local child = CreateFrame("Frame", "Child")
child:SetSize(300, 600)
frame:SetScrollChild(child)
seen_v = nil
frame:SetScript("OnVerticalScroll", function(self, offset)
seen_v = offset
assert(self == frame and arg1 == offset, "RF-0025 conventions carry the value")
end)
seen_lo, seen_hi = nil, nil
frame:SetScript("OnScrollRangeChanged", function(self, lo, hi)
seen_lo, seen_hi = lo, hi
end)
"#,
)
.unwrap();
s.resolve();
s.run("SF:SetVerticalScroll(9999)").unwrap();
assert_eq!(
s.eval::<f32>("return seen_v").unwrap(),
400.0,
"fires with the CLAMPED value"
);
s.run("SF:UpdateScrollChildRect()").unwrap();
let (lo, hi): (f32, f32) = s.eval("return seen_lo, seen_hi").unwrap();
assert_eq!((lo, hi), (0.0, 400.0));
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The Era idiom `UpdateScrollChildRect` exists to serve — resize the content, then immediately ask
/// for the new range, in ONE script tick with no intervening [`UiScript::resolve`] call in between
/// (exactly how the app's own drive loop runs Lua: `ui_script/mod.rs` resolves once per FRAME, not
/// once per script statement) — must see the JUST-set size, not the last frame's stale `resolved`
/// rect. Before `UpdateScrollChildRect` forced its own fresh resolve, this fired the OLD (here,
/// zero) range on the very call whose whole job is reporting the new one.
#[test]
fn update_scroll_child_rect_sees_a_same_tick_resize_with_no_intervening_resolve() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local frame = CreateFrame("ScrollFrame", "SF")
frame:SetPoint("TOPLEFT", 0, -100)
frame:SetSize(300, 200)
local child = CreateFrame("Frame", "Child")
child:SetSize(300, 200) -- starts exactly frame-height: range 0
frame:SetScrollChild(child)
"#,
)
.unwrap();
s.resolve(); // the "last frame" resolve, before this tick's resize
s.run(
r#"
seen_hi = nil
SF:SetScript("OnScrollRangeChanged", function(self, lo, hi) seen_hi = hi end)
Child:SetSize(300, 600) -- grows well past the frame — all in this ONE tick
SF:UpdateScrollChildRect()
"#,
)
.unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert_eq!(
s.eval::<f32>("return seen_hi").unwrap(),
400.0,
"UpdateScrollChildRect must resolve the JUST-set height itself, not report the stale \
(pre-resize) range from the last full UiScript::resolve"
);
// GetVerticalScrollRange() also benefits (same fresh `resolved`), and a same-tick
// SetVerticalScroll now clamps against the true, current range instead of the stale one.
assert_eq!(
s.eval::<f32>("return SF:GetVerticalScrollRange()").unwrap(),
400.0
);
}
/// The clip on `extract`: the scroll child's own quads, and a grandchild region nested arbitrarily
/// deep under it, carry `clip = Some(the scrollframe's resolved rect)`; a sibling outside the
/// ScrollFrame entirely carries `None`.
#[test]
fn extract_clips_the_scroll_childs_whole_subtree_and_leaves_a_sibling_unclipped() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local frame = CreateFrame("ScrollFrame", "SF")
frame:SetPoint("TOPLEFT", 0, -100) -- top 500
frame:SetSize(300, 200) -- bottom 300, right 300
local child = CreateFrame("Frame", "Child")
child:SetSize(300, 600)
frame:SetScrollChild(child)
local cm = child:CreateTexture(nil, "ARTWORK")
cm:SetTexture("marker:child")
-- a grandchild FRAME (a real arena descendant of the scroll child) with its own region
local grand = CreateFrame("Frame", "Grand", child)
grand:SetPoint("TOPLEFT", child, "TOPLEFT", 0, 0)
grand:SetSize(50, 50)
local gm = grand:CreateTexture(nil, "ARTWORK")
gm:SetTexture("marker:grand")
-- a sibling entirely outside the ScrollFrame
local sib = CreateFrame("Frame", "Sib")
sib:SetPoint("TOPLEFT", 0, 0)
sib:SetSize(50, 50)
local sm = sib:CreateTexture(nil, "ARTWORK")
sm:SetTexture("marker:sib")
"#,
)
.unwrap();
s.resolve();
let quads = s.extract();
let sf_rect = Rect::new(300.0, 0.0, 500.0, 300.0);
assert_eq!(marker_clip(&quads, "marker:child"), Some(Some(sf_rect)));
assert_eq!(
marker_clip(&quads, "marker:grand"),
Some(Some(sf_rect)),
"a descendant of the scroll child clips too, not just the child itself"
);
assert_eq!(
marker_clip(&quads, "marker:sib"),
Some(None),
"a sibling outside the ScrollFrame is unclipped"
);
}
/// Nested ScrollFrames intersect their rects (decision 0112 §4): an inner ScrollFrame living inside
/// an outer one's scroll child clips its own content to the intersection of both rects, not just
/// the innermost.
#[test]
fn nested_scrollframes_intersect_their_clip_rects() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local outer = CreateFrame("ScrollFrame", "Outer")
outer:SetPoint("TOPLEFT", 0, -50) -- top 550
outer:SetSize(400, 300) -- bottom 250, right 400
local om = outer:CreateTexture(nil, "ARTWORK"); om:SetTexture("marker:outer")
local c1 = CreateFrame("Frame", "C1")
c1:SetSize(400, 1000)
outer:SetScrollChild(c1)
local c1m = c1:CreateTexture(nil, "ARTWORK"); c1m:SetTexture("marker:c1")
-- The inner ScrollFrame is a real arena child of C1 (so walking its ancestors reaches C1,
-- Outer's registered scroll child) and sits far enough right that its own rect pokes past
-- Outer's right edge — an intersection distinct from either rect alone.
local inner = CreateFrame("ScrollFrame", "Inner", c1)
inner:SetPoint("TOPLEFT", "C1", "TOPLEFT", 350, -30)
inner:SetSize(200, 150)
local im = inner:CreateTexture(nil, "ARTWORK"); im:SetTexture("marker:inner")
local c2 = CreateFrame("Frame", "C2", inner)
c2:SetSize(200, 500)
inner:SetScrollChild(c2)
local leaf = c2:CreateTexture(nil, "ARTWORK"); leaf:SetTexture("marker:leaf")
"#,
)
.unwrap();
s.resolve();
let quads = s.extract();
let outer_rect = Rect::new(250.0, 0.0, 550.0, 400.0);
// Inner's OWN rect (unclipped by itself): TOPLEFT anchored to C1 (top 550) at (350,-30) -> top
// 520, left 350; size 200x150 -> bottom 370, right 550.
let inner_rect = Rect::new(370.0, 350.0, 520.0, 550.0);
let intersection = Rect::new(
inner_rect.bottom.max(outer_rect.bottom),
inner_rect.left.max(outer_rect.left),
inner_rect.top.min(outer_rect.top),
inner_rect.right.min(outer_rect.right),
);
// A genuine intersection, not just "pick the smaller rect": inner's right edge (550) is cut
// back to outer's (400), while inner's tighter bottom/left/top edges win.
assert_eq!(intersection, Rect::new(370.0, 350.0, 520.0, 400.0));
assert_eq!(
marker_clip(&quads, "marker:outer"),
Some(None),
"Outer itself is not clipped"
);
assert_eq!(
marker_clip(&quads, "marker:c1"),
Some(Some(outer_rect)),
"Outer's own registered child clips to Outer's rect"
);
assert_eq!(
marker_clip(&quads, "marker:inner"),
Some(Some(outer_rect)),
"Inner (the widget itself) lives inside C1's clipped content -> clipped by Outer too"
);
assert_eq!(
marker_clip(&quads, "marker:leaf"),
Some(Some(intersection)),
"Inner's own scroll child intersects both ScrollFrames' rects"
);
}
/// Hit-testing (decision 0112 §5): a button inside the scroll child, scrolled out of the frame's
/// rect, must not hit even though its own resolved rect contains the cursor; scrolled into view, it
/// hits normally.
#[test]
fn hit_test_denies_a_button_clipped_out_and_admits_it_once_scrolled_into_view() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local frame = CreateFrame("ScrollFrame", "SF")
frame:SetPoint("TOPLEFT", 0, -100) -- top 500
frame:SetSize(300, 200) -- bottom 300
frame:EnableMouse(false) -- isolate the button's own clip-gated hit (§5's subject)
local child = CreateFrame("Frame", "Child")
child:SetSize(300, 600)
frame:SetScrollChild(child)
local btn = CreateFrame("Button", "Btn", child)
btn:SetPoint("TOPLEFT", "Child", "TOPLEFT", 0, -400)
btn:SetSize(50, 20)
"#,
)
.unwrap();
s.resolve();
// vertical=0: child top 500, button top 500-400=100, bottom 80 — its OWN rect contains (25,90),
// but that's well outside the frame's clip [300,500].
assert_eq!(
s.hit_test(25.0, 90.0),
None,
"the button's own rect contains the point, but the ScrollFrame's clip excludes it"
);
// Scroll 250px: button top 500+250-400=350, bottom 330 — now inside both its own rect AND the
// frame's clip window.
s.run("SF:SetVerticalScroll(250)").unwrap();
s.resolve();
assert!(
s.hit_test(25.0, 340.0).is_some(),
"scrolled into view, the button hits again"
);
}
@@ -0,0 +1,285 @@
//! Slider (per-kind behavior; RF-28-grounded, decision 0250). The value/step/orientation contract,
//! the VERTICAL ctor default, the no-swap divergence from StatusBar, and the change-gate that keeps
//! the real scrollbar wiring from recursing.
use super::common::script;
use crate::script::QuadContent;
/// Find the thumb quad (the texture whose path carries `needle`) and return its
/// `(left, right, bottom, top)` rect. Panics if absent — the render must emit it.
fn thumb_rect(quads: &[crate::script::ExtractedQuad], needle: &str) -> (f32, f32, f32, f32) {
let q = quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle))
})
.expect("thumb quad");
let r = q.rect.expect("thumb rect resolved");
(r.left, r.right, r.bottom, r.top)
}
#[test]
fn slider_thumb_draws_at_value_fraction_along_the_track() {
let mut s = script();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
local sl = CreateFrame("Slider", "SlRender")
-- A vertical scrollbar: 16 wide, 100 tall, bottom-left at (100, 100) -> track y in [100, 200].
sl:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
sl:SetSize(16, 100)
sl:SetThumbTexture("Interface\\Buttons\\UI-ScrollBar-Knob")
local t = sl:GetThumbTexture()
t:SetSize(16, 16)
sl:SetMinMaxValues(0, 100)
sl:SetValue(0)
"#,
)
.unwrap();
s.resolve();
// value=min: the thumb sits flush at the track TOP (a scrollbar at 0 is scrolled to the top).
// travel = 100 - 16 = 84; thumb centered on cx=108 -> [100,116], top edge at track.top=200.
assert_eq!(
thumb_rect(&s.extract(), "Knob"),
(100.0, 116.0, 184.0, 200.0),
"value=min: thumb flush at track top, centered on the cross-axis"
);
// value=max: the thumb sits flush at the track BOTTOM.
s.run(r#"SlRender:SetValue(100)"#).unwrap();
assert_eq!(
thumb_rect(&s.extract(), "Knob"),
(100.0, 116.0, 100.0, 116.0),
"value=max: thumb flush at track bottom"
);
// Halfway: thumb centered in the track (midpoint 150).
s.run(r#"SlRender:SetValue(50)"#).unwrap();
let (_, _, bottom, top) = thumb_rect(&s.extract(), "Knob");
assert_eq!(
(bottom, top),
(142.0, 158.0),
"value=mid: thumb centered in the track"
);
}
#[test]
fn slider_thumb_drag_maps_cursor_to_value() {
// The same vertical scrollbar as the render test: track y in [100, 200], 16x16 thumb. At value=0
// the thumb spans y[184,200] (its top edge at the track top), centered on x=108.
let mut s = script();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
bar = CreateFrame("Slider", "SlDrag")
bar:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
bar:SetSize(16, 100)
bar:SetThumbTexture("Interface\\Buttons\\UI-ScrollBar-Knob")
bar:GetThumbTexture():SetSize(16, 16)
bar:SetMinMaxValues(0, 100)
bar:SetValue(0)
"#,
)
.unwrap();
s.resolve();
// Grab the thumb at its center (108, 192) and drag down the track. travel = 84; the grab keeps
// the same thumb point under the cursor, so the value tracks the cursor absolutely.
s.mouse_button(108.0, 192.0, "LeftButton", true);
s.mouse_move(108.0, 150.0);
let v: f32 = s.eval("return SlDrag:GetValue()").unwrap();
assert_eq!(v, 50.0, "cursor halfway down the track -> value 50");
s.mouse_move(108.0, 108.0);
let v: f32 = s.eval("return SlDrag:GetValue()").unwrap();
assert_eq!(
v, 100.0,
"cursor at the track bottom -> value 100 (clamped)"
);
// Release ends the capture: a later move must not move the value.
s.mouse_button(108.0, 108.0, "LeftButton", false);
s.mouse_move(108.0, 192.0);
let v: f32 = s.eval("return SlDrag:GetValue()").unwrap();
assert_eq!(v, 100.0, "no capture after release");
}
#[test]
fn slider_track_click_and_disabled_thumb_do_not_drag() {
let mut s = script();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
bar = CreateFrame("Slider", "SlNoDrag")
bar:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
bar:SetSize(16, 100)
bar:SetThumbTexture("Interface\\Buttons\\UI-ScrollBar-Knob")
bar:GetThumbTexture():SetSize(16, 16)
bar:SetMinMaxValues(0, 100)
bar:SetValue(0)
"#,
)
.unwrap();
s.resolve();
// A press on the TRACK below the thumb (108, 120) — inside the slider but not on the thumb
// [184,200] — captures nothing: only the thumb drags (the arrows step, decision 0250).
s.mouse_button(108.0, 120.0, "LeftButton", true);
s.mouse_move(108.0, 150.0);
let v: f32 = s.eval("return SlNoDrag:GetValue()").unwrap();
assert_eq!(v, 0.0, "track click does not drag");
s.mouse_button(108.0, 150.0, "LeftButton", false);
// A disabled slider ignores a press even on the thumb.
s.run(r#"SlNoDrag:Disable()"#).unwrap();
s.mouse_button(108.0, 192.0, "LeftButton", true);
s.mouse_move(108.0, 150.0);
let v: f32 = s.eval("return SlNoDrag:GetValue()").unwrap();
assert_eq!(v, 0.0, "disabled slider does not drag");
}
#[test]
fn slider_methods_exist_only_on_sliders() {
let s = script();
let ok: bool = s
.eval(
r#"
local sl = CreateFrame("Slider", "SlDuck")
local plain = CreateFrame("Frame")
-- Duck-typing: addons branch on `if frame.SetValueStep then` — a plain frame must say nil.
return (type(sl.SetValue) == "function") and (plain.SetValue == nil)
and (type(sl.SetValueStep) == "function") and (plain.SetValueStep == nil)
and (type(sl.SetThumbTexture) == "function") and (plain.SetThumbTexture == nil)
and (type(sl.Show) == "function") -- base methods still reachable through the fallback
"#,
)
.unwrap();
assert!(ok);
}
#[test]
fn slider_default_orientation_is_vertical() {
// The load-bearing fact of decision 0250: every scrollbar omits `orientation` and is vertical,
// so the CSimpleSlider ctor default is VERTICAL — the opposite of StatusBar's HORIZONTAL.
let s = script();
s.run(
r#"
local sl = CreateFrame("Slider", "SlOrient")
assert(sl:GetOrientation() == "VERTICAL", "ctor default is VERTICAL")
sl:SetOrientation("HORIZONTAL")
assert(sl:GetOrientation() == "HORIZONTAL", "explicit horizontal takes")
"#,
)
.unwrap();
}
#[test]
fn slider_value_clamps_and_does_not_swap_minmax() {
let s = script();
s.run(
r#"
local sl = CreateFrame("Slider", "SlClamp")
sl:SetMinMaxValues(0, 100)
sl:SetValueStep(5)
assert(sl:GetValueStep() == 5, "step round-trips")
sl:SetValue(250)
assert(sl:GetValue() == 100, "clamped to max")
sl:SetValue(-5)
assert(sl:GetValue() == 0, "clamped to min")
-- Unlike StatusBar, a reversed pair is NOT swapped (Slider LoadXML stores min + range, RF-28).
sl:SetMinMaxValues(80, 20)
local mn, mx = sl:GetMinMaxValues()
assert(mn == 80 and mx == 20, "reversed pair kept as given, not swapped")
"#,
)
.unwrap();
}
#[test]
fn slider_setvalue_fires_onvaluechanged_only_on_change() {
let s = script();
s.run(
r#"
local sl = CreateFrame("Slider", "SlEvt")
sl:SetMinMaxValues(0, 10)
seen = {}
sl:SetScript("OnValueChanged", function(self, value)
table.insert(seen, value)
assert(self == sl and arg1 == value, "RF-0025 conventions carry the value")
end)
sl:SetValue(4)
sl:SetValue(4) -- no change, no fire
sl:SetMinMaxValues(0, 3) -- re-clamp 4 -> 3: a value change, fires
assert(table.getn(seen) == 2 and seen[1] == 4 and seen[2] == 3,
"fired once per actual change: " .. table.getn(seen))
"#,
)
.unwrap();
}
#[test]
fn slider_enable_disable_roundtrips() {
let s = script();
s.run(
r#"
local sl = CreateFrame("Slider", "SlEnable")
assert(sl:IsEnabled(), "enabled by ctor")
sl:Disable()
assert(not sl:IsEnabled(), "disabled")
sl:Enable()
assert(sl:IsEnabled(), "re-enabled")
"#,
)
.unwrap();
}
#[test]
fn slider_get_thumb_texture_returns_a_stable_region() {
let s = script();
s.run(
r#"
local sl = CreateFrame("Slider", "SlThumb")
assert(sl:GetThumbTexture() == nil, "no thumb before one is set")
sl:SetThumbTexture("Interface\\Buttons\\UI-ScrollBar-Knob")
local t = sl:GetThumbTexture()
assert(t ~= nil and t == sl:GetThumbTexture(), "stable region wrapper")
"#,
)
.unwrap();
}
#[test]
fn slider_scrollbar_wiring_does_not_recurse() {
// The real FauxScrollFrame wiring (UIPanelTemplates.xml): the scrollbar Slider's OnValueChanged
// drives the ScrollFrame's SetVerticalScroll, whose OnVerticalScroll drives the Slider's
// SetValue back the other way. A fire-always SetValue would recurse forever; the change-gate
// (SliderState::store_value returns Some only on an actual change) breaks the loop after one hop.
// This test *is* the recursion — if the gate regressed it would stack-overflow, not assert-fail.
let mut s = script();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
local sf = CreateFrame("ScrollFrame", "SlSF")
sf:SetPoint("TOPLEFT", nil, "TOPLEFT", 0, 0)
sf:SetSize(100, 100)
local child = CreateFrame("Frame", "SlSFChild", sf)
child:SetSize(100, 300) -- 200px taller than the frame -> scroll range 200
sf:SetScrollChild(child)
bar = CreateFrame("Slider", "SlSFBar")
bar:SetMinMaxValues(0, 200)
bar:SetScript("OnValueChanged", function() sf:SetVerticalScroll(arg1) end)
sf:SetScript("OnVerticalScroll", function() bar:SetValue(arg1) end)
"#,
)
.unwrap();
// A resolve populates the rects SetVerticalScroll's live range clamps against.
s.resolve();
s.run(
r#"
bar:SetValue(50)
assert(bar:GetValue() == 50, "slider settled at 50")
assert(SlSF:GetVerticalScroll() == 50, "scroll followed the slider")
"#,
)
.unwrap();
}
@@ -0,0 +1,198 @@
//! StatusBar (per-kind behavior; RF-28-grounded).
use super::common::script;
use crate::script::*;
#[test]
fn statusbar_methods_exist_only_on_statusbars() {
let s = script();
let ok: bool = s
.eval(
r#"
local bar = CreateFrame("StatusBar", "SbDuck")
local plain = CreateFrame("Frame")
-- Duck-typing: addons branch on `if frame.SetValue then` — a plain frame must say nil.
return (type(bar.SetValue) == "function") and (plain.SetValue == nil)
and (type(bar.SetMinMaxValues) == "function") and (plain.SetMinMaxValues == nil)
and (type(bar.Show) == "function") -- base methods still reachable through the fallback
"#,
)
.unwrap();
assert!(ok);
}
#[test]
fn statusbar_value_clamps_and_minmax_swaps() {
let s = script();
s.run(
r#"
local b = CreateFrame("StatusBar", "SbClamp")
b:SetMinMaxValues(0, 100)
b:SetValue(250)
assert(b:GetValue() == 100, "clamped to max")
b:SetValue(-5)
assert(b:GetValue() == 0, "clamped to min")
-- A reversed pair is swapped (RF-28's LoadXML behavior, one rule for both paths).
b:SetMinMaxValues(80, 20)
local mn, mx = b:GetMinMaxValues()
assert(mn == 20 and mx == 80, "reversed pair swapped")
assert(b:GetValue() == 20, "held value re-clamped into the new range")
"#,
)
.unwrap();
}
#[test]
fn statusbar_setvalue_fires_onvaluechanged() {
let s = script();
s.run(
r#"
local b = CreateFrame("StatusBar", "SbEvt")
b:SetMinMaxValues(0, 10)
seen = {}
b:SetScript("OnValueChanged", function(self, value)
table.insert(seen, value)
assert(self == b and arg1 == value, "RF-0025 conventions carry the value")
end)
b:SetValue(4)
b:SetValue(4) -- no change, no fire
b:SetMinMaxValues(0, 3) -- re-clamp 4 -> 3: a value change, fires
assert(table.getn(seen) == 2 and seen[1] == 4 and seen[2] == 3,
"fired once per actual change: " .. table.getn(seen))
"#,
)
.unwrap();
}
#[test]
fn statusbar_bar_region_scales_by_fraction_on_extract() {
let mut s = script();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
local b = CreateFrame("StatusBar", "SbFill")
b:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
b:SetSize(200, 20)
b:SetStatusBarTexture(0.2, 0.7, 0.2)
b:SetMinMaxValues(0, 100)
b:SetValue(25)
"#,
)
.unwrap();
s.resolve();
let quads = s.extract();
// The bar-fill texture: a solid-color region whose rect is 25% of the frame width.
let bar = quads
.iter()
.find(|q| {
matches!(
&q.content,
QuadContent::Texture {
path: None,
color: Some(_),
..
}
)
})
.expect("bar fill quad");
let r = bar.rect.expect("bar rect resolved");
assert_eq!(
(r.left, r.right, r.bottom, r.top),
(100.0, 150.0, 100.0, 120.0),
"horizontal fill: left-pinned, 25% of 200px"
);
// Vertical: fills bottom-up.
s.run(r#"SbFill:SetOrientation("VERTICAL")"#).unwrap();
let quads = s.extract();
let bar = quads
.iter()
.find(|q| {
matches!(
&q.content,
QuadContent::Texture {
path: None,
color: Some(_),
..
}
)
})
.expect("bar fill quad");
let r = bar.rect.expect("bar rect resolved");
assert_eq!(
(r.left, r.right, r.bottom, r.top),
(100.0, 300.0, 100.0, 105.0),
"vertical fill: bottom-pinned, 25% of 20px"
);
}
/// The fill CROPs its art, never squeezes it (wow-re `nameplate-vkey.md`, VERIFIED): `SetValue`
/// rewrites the region's 4-corner UV block with `u1 = GetValue()` *and* shrinks the quad. Squeezing
/// instead would run a bar texture's whole horizontal ramp inside every partial fill.
#[test]
fn statusbar_bar_region_crops_its_texture_rather_than_stretching_it() {
let mut s = script();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
local b = CreateFrame("StatusBar", "SbCrop")
b:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, 0)
b:SetSize(200, 20)
b:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar")
b:SetMinMaxValues(0, 100)
b:SetValue(25)
"#,
)
.unwrap();
let uv = |s: &UiScript| -> [f32; 4] {
s.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Texture {
path: Some(_),
tex_coords,
..
} => tex_coords.map(|tc| tc.edges()),
_ => None,
})
.expect("the bar fill always carries its crop")
};
s.resolve();
assert_eq!(
uv(&s),
[0.0, 0.25, 0.0, 1.0],
"horizontal: u cropped to the fill fraction, v whole"
);
// Vertical fills bottom-up, so the art's BOTTOM edge is the one pinned.
s.run(r#"SbCrop:SetOrientation("VERTICAL")"#).unwrap();
assert_eq!(
uv(&s),
[0.0, 1.0, 0.75, 1.0],
"vertical: v cropped up from the bottom, u whole"
);
// An empty bar samples nothing; a full one samples everything.
s.run("SbCrop:SetValue(0)").unwrap();
assert_eq!(uv(&s), [0.0, 1.0, 1.0, 1.0], "empty");
s.run("SbCrop:SetValue(100)").unwrap();
assert_eq!(uv(&s), [0.0, 1.0, 0.0, 1.0], "full");
}
#[test]
fn statusbar_get_texture_returns_the_bar_region() {
let s = script();
s.run(
r#"
local b = CreateFrame("StatusBar", "SbTex")
assert(b:GetStatusBarTexture() == nil, "no bar before one is set")
b:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar")
local t = b:GetStatusBarTexture()
assert(t ~= nil and t == b:GetStatusBarTexture(), "stable region wrapper")
b:SetStatusBarColor(1, 0, 0)
local r, g, bb, a = b:GetStatusBarColor()
assert(r == 1 and g == 0 and bb == 0 and a == 1)
"#,
)
.unwrap();
}
@@ -0,0 +1,128 @@
//! The WoW stdlib: positional `format`, the `getglobal`/`strsplit`/`wipe` alias layer, and the
//! sandbox holes (`loadstring` text-only, dangerous globals removed).
use super::common::script;
// ── The positional format wrapper ───────────────────────────────────────────────────────────────
#[test]
fn positional_format_reorders_and_mix_is_an_error() {
let s = script();
assert_eq!(
s.eval::<String>(r#"return format("%2$s %1$s", "a", "b")"#)
.unwrap(),
"b a"
);
// width/precision travel with the positional spec
assert_eq!(
s.eval::<String>(r#"return format("%1$05d", 42)"#).unwrap(),
"00042"
);
// sequential still works (and via string.format too, which we patched)
assert_eq!(
s.eval::<String>(r#"return string.format("%d-%s", 1, "x")"#)
.unwrap(),
"1-x"
);
// %% is preserved
assert_eq!(
s.eval::<String>(r#"return format("%1$d%%", 50)"#).unwrap(),
"50%"
);
// mixing positional and sequential is an error (matches Blizzard erroring)
let mixed_ok: bool = s
.eval(r#"return pcall(format, "%1$s %s", "a", "b")"#)
.unwrap();
assert!(!mixed_ok, "mixed positional+sequential must error");
}
// ── getglobal / strsplit / wipe / the alias layer ───────────────────────────────────────────────
#[test]
fn stdlib_aliases_and_helpers() {
let s = script();
s.run(
r#"
-- getglobal on a named frame
local f = CreateFrame("Frame", "GG")
assert(getglobal("GG") == f)
-- strsplit returns pieces (empty fields preserved)
local a, b, c = strsplit(",", "x,y,z")
assert(a == "x" and b == "y" and c == "z")
local e1, e2 = strsplit(",", ",tail")
assert(e1 == "" and e2 == "tail")
-- strjoin / strconcat / strtrim
assert(strjoin("-", "a", "b", "c") == "a-b-c")
assert(strconcat("a", "b", "c") == "abc")
assert(strtrim(" hi \t") == "hi")
-- the bare-global aliases
assert(strupper("ab") == "AB" and strlower("AB") == "ab")
assert(strsub("hello", 2, 3) == "el")
assert(strlen("hello") == 5)
local t = {}
tinsert(t, 10); tinsert(t, 20)
assert(getn(t) == 2)
tremove(t, 1)
assert(t[1] == 20)
-- wipe empties a table in place
local w = { 1, 2, x = 3 }
assert(wipe(w) == w and next(w) == nil)
-- tostringall
local s1, s2 = tostringall(1, true)
assert(s1 == "1" and s2 == "true")
"#,
)
.unwrap();
}
// ── Sandbox holes ───────────────────────────────────────────────────────────────────────────────
#[test]
fn sandbox_removes_dangerous_globals() {
let s = script();
let all_nil: bool = s
.eval(
r#"return io == nil and os == nil and package == nil and require == nil
and dofile == nil and loadfile == nil and debug == nil"#,
)
.unwrap();
assert!(all_nil);
// debugstack stub survives and returns "".
assert_eq!(s.eval::<String>("return debugstack()").unwrap(), "");
}
#[test]
fn loadstring_is_text_only_bytecode_rejected() {
let s = script();
let ok: bool = s
.eval(
r#"
-- valid source compiles
local f = loadstring("return 1 + 1")
assert(type(f) == "function" and f() == 2)
-- bytecode is rejected: returns nil + error message
local bc = string.dump(function() return 7 end)
local g, err = loadstring(bc)
return (g == nil) and (type(err) == "string")
"#,
)
.unwrap();
assert!(ok, "loadstring must reject bytecode");
}
// ── GetTime: the session clock (decision 0137 — the reference cast bar anchors on it) ───────────
#[test]
fn gettime_starts_at_zero_and_tracks_tick() {
let mut s = script();
assert_eq!(s.eval::<f64>("return GetTime()").unwrap(), 0.0);
s.tick(0.25);
s.tick(0.25);
let t = s.eval::<f64>("return GetTime()").unwrap();
assert!((t - 0.5).abs() < 1e-6, "two 0.25s ticks = 0.5 (got {t})");
}
@@ -0,0 +1,212 @@
//! The talent engine seam (decision 0304): the Era binding tuples over a pushed snapshot, the
//! learn-click queue, and the `SetTalent` tooltip — the spell builder with the talent
//! interleave (rank line white, req lines red, next-rank block, learn hint green).
use super::common::script;
use crate::script::*;
fn one_tab_state() -> TalentUiState {
TalentUiState {
tabs: vec![
TalentTabView {
name: "Fire".into(),
background: "MageFire".into(),
points_spent: 7,
},
TalentTabView {
name: "Frost".into(),
background: "MageFrost".into(),
points_spent: 0,
},
],
talents: vec![
vec![
TalentView {
name: "Improved Fireball".into(),
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
tier: 1,
column: 1,
rank: 3,
max_rank: 5,
exceptional: false,
meets_prereq: true,
prereqs: Vec::new(),
display_spell: 11070,
next_spell: 11071,
req_lines: Vec::new(),
learnable: true,
},
TalentView {
name: "Ignite".into(),
texture: None,
tier: 2,
column: 2,
rank: 0,
max_rank: 5,
exceptional: false,
meets_prereq: true,
prereqs: vec![TalentPrereqView {
tier: 1,
column: 1,
learnable: false,
}],
display_spell: 11119,
next_spell: 0,
req_lines: vec!["Requires 5 points in Improved Fireball".into()],
learnable: false,
},
],
Vec::new(),
],
points: (2, 1),
}
}
/// The Era tuples read back exactly what the app pushed — including the 1-based grid seats,
/// the flat prereq triplets, and the points pair.
#[test]
fn bindings_read_the_pushed_snapshot() {
let mut s = script();
s.set_talents(one_tab_state());
s.run(
r#"
assert(GetNumTalentTabs() == 2)
local name, texture, points, file = GetTalentTabInfo(1)
assert(name == "Fire" and texture == nil and points == 7 and file == "MageFire")
assert(GetTalentTabInfo(3) == nil, "out of range is nil")
assert(GetNumTalents(1) == 2 and GetNumTalents(2) == 0 and GetNumTalents(9) == 0)
local n, icon, tier, col, rank, max, exc, meets = GetTalentInfo(1, 1)
assert(n == "Improved Fireball" and tier == 1 and col == 1)
assert(rank == 3 and max == 5 and exc == 0 and meets == true)
assert(icon == "Interface\\Icons\\Spell_Fire_FlameBolt")
assert(GetTalentInfo(1, 9) == nil)
-- The prereq triplets, flat (the reference walks arg[5], arg[6], arg[7], ...).
local pt, pc, pl = GetTalentPrereqs(1, 2)
assert(pt == 1 and pc == 1 and pl == false)
assert(GetTalentPrereqs(1, 1) == nil, "no prereqs is empty")
local cp1, cp2 = UnitCharacterPoints("player")
assert(cp1 == 2 and cp2 == 1)
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// LearnTalent queues (tab, index) verbatim for the app's wire drain.
#[test]
fn learn_talent_queues_for_the_app_drain() {
let mut s = script();
s.set_talents(one_tab_state());
s.run("LearnTalent(1, 1); LearnTalent(1, 2)").unwrap();
assert_eq!(s.take_talent_learns(), vec![(1, 1), (1, 2)]);
assert!(s.take_talent_learns().is_empty(), "drain empties the queue");
assert!(s.take_errors().is_empty());
}
/// SetTalent = the spell builder + the talent interleave: name, "Rank r/m" white, cost line,
/// gold description, "Next rank:" + the next rank's gold description, green "Click to learn".
#[test]
fn set_talent_renders_the_interleaved_tooltip() {
let mut s = script();
s.set_talents(one_tab_state());
s.set_spell_tooltip(
11070,
SpellTooltipView {
name: "Improved Fireball".into(),
cast_time: Some("Instant".into()),
description: "Reduces the casting time of your Fireball spell by 0.3 sec.".into(),
..Default::default()
},
);
s.set_spell_tooltip(
11071,
SpellTooltipView {
name: "Improved Fireball".into(),
description: "Reduces the casting time of your Fireball spell by 0.4 sec.".into(),
..Default::default()
},
);
s.run(
r#"
local a = CreateFrame("Button", "TB1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetTalent(1, 1)
assert(tt:IsShown(), "SetTalent shows")
-- name, Rank 3/5, Instant, desc, Next rank:, next desc, Click to learn = 7 lines.
assert(tt:NumLines() == 7, "got " .. tt:NumLines())
assert(TTTextLeft1:GetText() == "Improved Fireball")
assert(TTTextLeft2:GetText() == "Rank 3/5")
assert(TTTextLeft3:GetText() == "Instant")
assert(TTTextLeft5:GetText() == "Next rank:")
assert(TTTextLeft7:GetText() == "Click to learn")
"#,
)
.unwrap();
// The learn hint wears the tooltip green; the requirement red is exercised below.
s.resolve();
let quads = s.extract();
let green = quads.iter().any(|q| {
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
if t == "Click to learn" && c[0] < 1e-6 && (c[1] - 1.0).abs() < 1e-6)
});
assert!(green, "the learn hint is green");
assert!(s.take_errors().is_empty());
}
/// A locked talent shows its red requirement line; a missing spell view falls back to the rank
/// line alone and records the ask for the app resolver (the shared ask-once channel).
#[test]
fn set_talent_locked_reqs_and_the_ask_once_miss() {
let mut s = script();
s.set_talents(one_tab_state());
// No spell view pushed for Ignite (11119): the render falls back, the ask is recorded.
s.run(
r#"
local a = CreateFrame("Button", "TB2"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT2")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetTalent(1, 2)
assert(tt:IsShown())
assert(TT2TextLeft1:GetText() == "Rank 0/5", "fallback shows the rank head")
"#,
)
.unwrap();
let asks = s.take_spell_tooltip_asks();
assert!(
asks.contains(&11119),
"the display spell was asked: {asks:?}"
);
// With the view landed, the full render carries the red requirement line.
s.set_spell_tooltip(
11119,
SpellTooltipView {
name: "Ignite".into(),
description: "Your critical strikes ignite the target.".into(),
..Default::default()
},
);
s.run(
r#"
local tt = getglobal("TT2")
tt:SetOwner(getglobal("TB2"), "ANCHOR_RIGHT")
tt:SetTalent(1, 2)
-- name, Rank 0/5, req(red), desc = 4 lines; rank 0 has no next block, locked has no hint.
assert(tt:NumLines() == 4, "got " .. tt:NumLines())
assert(TT2TextLeft3:GetText() == "Requires 5 points in Improved Fireball")
"#,
)
.unwrap();
s.resolve();
let quads = s.extract();
let red = quads.iter().any(|q| {
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
if t.starts_with("Requires 5") && (c[0] - 1.0).abs() < 1e-6 && c[1] < 0.2)
});
assert!(red, "the requirement line is red");
assert!(s.take_errors().is_empty());
}
@@ -0,0 +1,79 @@
//! The taxi seam (decision 0484): the app-pushed snapshot surfaces through the Era bindings the
//! reference `TaxiFrame.lua` reads, and the click/close intents drain back out.
use super::common::script;
use crate::script::*;
/// The full round trip: push a two-node snapshot, read it back exactly as the reference Lua does
/// (count, type, position, name, cost, route segments), click a node + close, drain both intents,
/// and `SetTaxiMap` paints the pushed art onto the target texture region.
#[test]
fn taxi_snapshot_surfaces_and_intents_drain() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_taxi(Some(TaxiUiState {
art: "Interface\\TaxiFrame\\TAXIMAP1".into(),
nodes: vec![
TaxiUiNode {
name: "Crossroads, The Barrens".into(),
node_type: TaxiNodeType::Current,
pos: (0.5, 0.6),
cost: 0,
routes: vec![],
},
TaxiUiNode {
name: "Orgrimmar, Durotar".into(),
node_type: TaxiNodeType::Reachable,
pos: (0.55, 0.8),
cost: 110,
routes: vec![[0.5, 0.6, 0.55, 0.8]],
},
],
}));
s.run(
r#"
f = CreateFrame("Frame", "TaxiHost")
f:SetWidth(316); f:SetHeight(352); f:SetPoint("CENTER")
map = f:CreateTexture("TaxiMapTex", "OVERLAY")
SetTaxiMap(map)
assert(NumTaxiNodes() == 2)
assert(TaxiNodeGetType(1) == "CURRENT")
assert(TaxiNodeGetType(2) == "REACHABLE")
assert(TaxiNodeGetType(3) == "NONE") -- out of range hides, ref-style
local x, y = TaxiNodePosition(2)
assert(math.abs(x - 0.55) < 1e-6 and math.abs(y - 0.8) < 1e-6)
assert(TaxiNodeName(2) == "Orgrimmar, Durotar")
assert(TaxiNodeCost(2) == 110)
TaxiNodeSetCurrent(2) -- faithful no-op
assert(GetNumRoutes(1) == 0 and GetNumRoutes(2) == 1)
assert(math.abs(TaxiGetSrcX(2, 1) - 0.5) < 1e-6)
assert(math.abs(TaxiGetDestY(2, 1) - 0.8) < 1e-6)
assert(UnitOnTaxi("player") == false)
TakeTaxiNode(2)
CloseTaxiMap()
"#,
)
.unwrap();
assert_eq!(s.take_taxi_node(), vec![2]);
assert!(s.take_taxi_close());
assert!(!s.take_taxi_close(), "the close flag drains");
// SetTaxiMap assigned the pushed art onto the texture region.
s.resolve();
assert!(
s.extract().iter().any(|q| matches!(
&q.content,
QuadContent::Texture { path: Some(p), .. } if p == "Interface\\TaxiFrame\\TAXIMAP1"
)),
"the continent art draws on the SetTaxiMap target"
);
// The ride flag reaches UnitOnTaxi.
s.set_on_taxi(true);
s.run(r#"assert(UnitOnTaxi("player") == true)"#).unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -0,0 +1,207 @@
//! TexCoords + Font objects (decision 0084).
use super::common::script;
use crate::script::*;
/// `SetTexCoord(l, r, t, b)` writes the region's UV sub-rect; it surfaces on the extracted quad,
/// `GetTexCoord` reads it back; the 8-arg affine form carries per-corner UVs (the route-line
/// draw's rotated quads).
#[test]
fn set_tex_coord_changes_extracted_uv() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
f = CreateFrame("Frame", "TcF")
f:SetWidth(100); f:SetHeight(100); f:SetPoint("CENTER")
t = f:CreateTexture("TcTex", "ARTWORK")
t:SetTexture("Interface\\Foo")
t:SetTexCoord(0.1, 0.6, 0.2, 0.8)
"#,
)
.unwrap();
s.resolve();
let tex = s
.extract()
.into_iter()
.find(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains("Foo")))
.expect("the Foo texture quad");
assert!(
matches!(&tex.content, QuadContent::Texture { tex_coords: Some(TexCoords::Rect(tc)), .. } if *tc == [0.1, 0.6, 0.2, 0.8]),
"SetTexCoord surfaces on the extracted quad, got {:?}",
tex.content
);
let (l, r, t, b): (f32, f32, f32, f32) = s.eval("return t:GetTexCoord()").unwrap();
assert_eq!((l, r, t, b), (0.1, 0.6, 0.2, 0.8));
// The 8-arg affine form (arg order UL, LL, UR, LR) lands as per-corner UVs in screen order
// [TL, TR, BR, BL] — here a 90° rotation of the full texture.
s.run("t:SetTexCoord(0,1, 1,1, 0,0, 1,0)").unwrap();
s.resolve();
let tex = s
.extract()
.into_iter()
.find(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains("Foo")))
.unwrap();
assert!(
matches!(
&tex.content,
QuadContent::Texture { tex_coords: Some(TexCoords::Corners(c)), .. }
if *c == [[0.0, 1.0], [0.0, 0.0], [1.0, 0.0], [1.0, 1.0]]
),
"the affine form carries corners in screen winding, got {:?}",
tex.content
);
// A no-arg reset returns to the full texture.
s.run("t:SetTexCoord()").unwrap();
s.resolve();
let tex = s
.extract()
.into_iter()
.find(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains("Foo")))
.unwrap();
assert!(matches!(
&tex.content,
QuadContent::Texture {
tex_coords: None,
..
}
));
}
/// `SetFontObject("Name")` copies a registered [`FontObject`]'s face/height/color onto the
/// FontString; `GetFontObject` reads the name back; a later call re-points it (the resolved paint on
/// the extracted quad follows). An unknown name errors.
#[test]
fn set_font_object_repoints_fontstring() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.register_font_object(
"Big",
FontObject {
font: Some("Fonts\\MORPHEUS.TTF".into()),
height: Some(18.0),
color: Some([0.0, 0.0, 0.0, 1.0]),
outline: Outline::None,
justify_h: None,
justify_v: None,
shadow: None,
},
);
s.register_font_object(
"Small",
FontObject {
font: Some("Fonts\\FRIZQT__.TTF".into()),
height: Some(10.0),
color: Some([1.0, 1.0, 1.0, 1.0]),
outline: Outline::None,
justify_h: None,
justify_v: None,
shadow: None,
},
);
s.run(
r#"
f = CreateFrame("Frame", "FoF")
f:SetWidth(120); f:SetHeight(30); f:SetPoint("CENTER")
fs = f:CreateFontString("FoText", "ARTWORK")
fs:SetText("Hi")
fs:SetFontObject("Big")
"#,
)
.unwrap();
assert_eq!(
s.eval::<String>("return fs:GetFontObject()").unwrap(),
"Big"
);
let resolved = |s: &UiScript| -> (Option<String>, Option<f32>, Option<[f32; 4]>) {
s.extract()
.into_iter()
.find_map(|q| match q.content {
QuadContent::Text {
font,
font_height,
color,
..
} => Some((font, font_height, color)),
_ => None,
})
.expect("a text quad")
};
s.resolve();
assert_eq!(
resolved(&s),
(
Some("Fonts\\MORPHEUS.TTF".into()),
Some(18.0),
Some([0.0, 0.0, 0.0, 1.0])
)
);
// Re-point.
s.run("fs:SetFontObject('Small')").unwrap();
s.resolve();
assert_eq!(
resolved(&s),
(
Some("Fonts\\FRIZQT__.TTF".into()),
Some(10.0),
Some([1.0, 1.0, 1.0, 1.0])
)
);
// An unknown font object errors (never a silent no-op).
assert!(s.run("fs:SetFontObject('Nope')").is_err());
}
/// `justifyV`: MIDDLE is the default (the client's own FontString default — what seats the money
/// numbers on their coins' centerline), `SetJustifyV` overrides it, and a font object carrying one
/// applies it on `SetFontObject`.
#[test]
fn justify_v_defaults_middle_and_overrides() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
f = CreateFrame("Frame", "JvF")
f:SetWidth(100); f:SetHeight(30); f:SetPoint("CENTER")
fs = f:CreateFontString("JvText", "ARTWORK")
fs:SetText("Hi")
"#,
)
.unwrap();
let justify_v = |s: &UiScript| {
s.extract()
.into_iter()
.find_map(|q| match q.content {
QuadContent::Text { justify_v, .. } => Some(justify_v),
_ => None,
})
.expect("a text quad")
};
s.resolve();
assert_eq!(justify_v(&s), JustifyV::Middle, "the client default");
s.run("fs:SetJustifyV('BOTTOM')").unwrap();
s.resolve();
assert_eq!(justify_v(&s), JustifyV::Bottom);
// A font object carrying justify_v re-points it.
s.register_font_object(
"TopFont",
FontObject {
font: Some("Fonts\\FRIZQT__.TTF".into()),
height: Some(12.0),
color: None,
outline: Outline::None,
justify_h: None,
justify_v: Some(JustifyV::Top),
shadow: None,
},
);
s.run("fs:SetFontObject('TopFont')").unwrap();
s.resolve();
assert_eq!(justify_v(&s), JustifyV::Top);
}
@@ -0,0 +1,424 @@
//! The GameTooltip widget's engine mechanics (decision 0274): the line stack + named line
//! regions, auto-size from the measure round-trip, the right-flush, SetOwner's anchor law +
//! IsOwned, SetText's implicit show, both AddLine shapes, ClearLines/Hide firing
//! `OnTooltipCleared`, SetMinimumWidth's floor, and FadeOut's ramp-then-hide.
use super::common::script;
use crate::script::*;
/// Answer every pending line-measure with deterministic per-text sizes.
fn measure_all(s: &mut UiScript, sizes: &[(&str, f32, f32)]) {
let reqs = s.fontstrings_needing_measure();
let answers: Vec<(u32, f32, f32, u64)> = reqs
.iter()
.filter_map(|r| {
sizes
.iter()
.find(|(t, _, _)| *t == r.text)
.map(|&(_, w, h)| (r.id, w, h, r.key))
})
.collect();
s.set_measured_text(&answers);
s.resolve();
}
/// The full stack: three lines (one double), measured, auto-sized, right-flushed, and the named
/// line globals published.
#[test]
fn line_stack_autosize_and_right_flush() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local owner = CreateFrame("Button", "Slot")
owner:SetPoint("TOPLEFT", 100, -100); owner:SetSize(40, 40)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(owner, "ANCHOR_RIGHT")
tt:AddLine("Tough Jerky", 1, 1, 1)
tt:AddDoubleLine("One-Hand", "Sword", 1, 1, 1, 1, 1, 1)
tt:AddLine("5 - 9 Damage")
tt:Show()
assert(tt:NumLines() == 3, "NumLines")
assert(TTTextLeft1 and TTTextRight2, "line globals published")
assert(TTTextLeft1:GetText() == "Tough Jerky", "line 1 text readable by name")
"#,
)
.unwrap();
s.resolve();
measure_all(
&mut s,
&[
("Tough Jerky", 80.0, 14.0),
("One-Hand", 50.0, 12.0),
("Sword", 30.0, 12.0),
("5 - 9 Damage", 70.0, 12.0),
],
);
// maxw = max(80, 50+40+30, 70) = 120 ⇒ width 140; totalh = 14+2+12+2+12 = 42 ⇒ height 62.
// ANCHOR_RIGHT: tooltip BOTTOMLEFT at owner TOPRIGHT (140, 500).
s.run(
r#"
assert(TT:GetWidth() == 140, "auto width, got " .. TT:GetWidth())
assert(TT:GetHeight() == 62, "auto height, got " .. TT:GetHeight())
"#,
)
.unwrap();
let quads = s.extract();
let rect_of = |needle: &str| {
quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Text { text: Some(t), .. } if t == needle => q.rect,
_ => None,
})
.unwrap_or_else(|| panic!("no quad for {needle}"))
};
let l1 = rect_of("Tough Jerky");
// Frame: left 140, bottom 500 ⇒ text inset TOPLEFT (150, 552).
assert_eq!((l1.left, l1.top), (150.0, 552.0));
let r2 = rect_of("Sword");
// Right-flushed: right edge at frame.right − pad = 280 − 10 = 270.
assert_eq!(r2.right, 270.0);
// Seated on line 2's band (left2 top = 552 − 14 − 2 = 536).
let l2 = rect_of("One-Hand");
assert_eq!(l2.top, 536.0);
}
/// An EMPTY line mid-stack (the corpus' `AddLine("")`; the live shape was an empty-string
/// subtitle from the wire): a ZERO-height row that still charges its 2px slot gap — the ref's
/// empty-line shape. Pre-fix, an empty FontString never measures, so its unpinned bottom edge
/// fell back to the OWNER frame's bottom (the v1 region fallback): the line stretched to the
/// plate's bottom and the anchor chain marched every later line OUT of the plate (the live
/// NPC-tooltip spill — name inside, "Level 20"/"PvP" below the frame under the health bar).
#[test]
fn empty_line_is_a_zero_row_and_the_chain_stays_inside_the_plate() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local owner = CreateFrame("Button", "Slot2")
owner:SetPoint("TOPLEFT", 100, -100); owner:SetSize(40, 40)
local tt = CreateFrame("GameTooltip", "TTE")
tt:SetOwner(owner, "ANCHOR_RIGHT")
tt:AddLine("Marshal McBride", 0.25, 0.75, 0.25)
tt:AddLine("")
tt:AddLine("Level 20")
tt:AddLine("PvP")
tt:Show()
assert(tt:NumLines() == 4, "NumLines counts the empty line")
"#,
)
.unwrap();
s.resolve();
measure_all(
&mut s,
&[
("Marshal McBride", 90.0, 14.0),
("Level 20", 50.0, 12.0),
("PvP", 24.0, 12.0),
],
);
// Rows 14 + 0 + 12 + 12 with 3 slot gaps ⇒ totalh 44, height 64; maxw 90 ⇒ width 110.
s.run(
r#"
assert(TTE:GetWidth() == 110, "auto width, got " .. tostring(TTE:GetWidth()))
assert(TTE:GetHeight() == 64, "auto height, got " .. tostring(TTE:GetHeight()))
-- The chain stays contiguous through the zero row: Level 20 sits gap+0+gap under the name.
assert(TTETextLeft3:GetTop() == TTETextLeft1:GetBottom() - 4,
"chain contiguous through the empty row, got " .. tostring(TTETextLeft3:GetTop())
.. " vs " .. tostring(TTETextLeft1:GetBottom()))
-- And the tail line lands INSIDE the plate, a full pad above its bottom edge.
assert(TTETextLeft4:GetBottom() == TTE:GetBottom() + 10,
"tail line inside the plate, got " .. tostring(TTETextLeft4:GetBottom())
.. " vs plate bottom " .. tostring(TTE:GetBottom()))
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// SetOwner clears previous content (firing OnTooltipCleared), owns the frame for IsOwned, and
/// Hide drops the owner + content and fires OnTooltipCleared again.
#[test]
fn owner_clear_and_hide_lifecycle() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
cleared = 0
local a = CreateFrame("Button", "A"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local b = CreateFrame("Button", "B"); b:SetPoint("CENTER", 50, 0); b:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT2")
tt:SetScript("OnTooltipCleared", function() cleared = cleared + 1 end)
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:AddLine("first hover")
tt:Show()
assert(tt:IsOwned(a) and not tt:IsOwned(b), "owned by a")
assert(tt:GetOwner() == a, "GetOwner returns the owner wrapper")
tt:SetOwner(b, "ANCHOR_LEFT")
assert(cleared >= 1, "SetOwner cleared the old content")
assert(tt:NumLines() == 0, "content cleared on re-own")
assert(tt:IsOwned(b) and not tt:IsOwned(a), "owner moved")
tt:AddLine("second hover")
tt:Hide()
assert(not tt:IsShown(), "hidden")
assert(not tt:IsOwned(b), "owner dropped on hide")
assert(tt:NumLines() == 0, "content cleared on hide")
assert(cleared >= 2, "hide fired OnTooltipCleared")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// SetText shows the tooltip implicitly (the PaperDoll empty-slot flow calls no Show); AddLine
/// does not (the corpus' `AddLine … Show()`). The colour law: positional `r, g, b` apply only
/// when the r-slot is a number — the corpus' archaic `(text, "", r, g, b)` shape has a string
/// there, so the real 1.12 binding drops the whole colour tail and renders the DEFAULT GOLD
/// (the reference's zone tooltip, director-matched; the trailing numbers never shift into place).
#[test]
fn settext_shows_and_both_addline_shapes() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local a = CreateFrame("Button", "A3"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT3")
tt:Hide() -- the XML instance ships hidden="true"; CreateFrame defaults shown
tt:SetOwner(a, "ANCHOR_RIGHT")
assert(not tt:IsShown(), "SetOwner alone does not show")
tt:AddLine("plain")
assert(not tt:IsShown(), "AddLine does not show")
tt:SetText("Head", 1, 1, 1)
assert(tt:IsShown(), "SetText shows")
assert(tt:NumLines() == 1, "SetText replaced the stack")
-- the archaic 1.12 shape: AddLine(text, "", r, g, b) — the "" kills the colour tail
tt:AddLine("Zone Name", "", 1.0, 0.25, 0.5)
-- no colour at all — same default
tt:AddLine("plain gold")
-- the modern shape with wrap flag
tt:AddLine("wrapped tail", 0.2, 0.4, 0.6, 1)
-- a numeric r gates the block ON; missing g/b are UNGATED tonumber reads -> 0.0
tt:AddLine("red only", 1)
-- SetText requires its text: the binding raises its Usage error and adds no line
assert(not pcall(function() tt:SetText() end), "SetText() must error")
"#,
)
.unwrap();
s.resolve();
let quads = s.extract();
let color_of = |needle: &str| {
quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Text {
text: Some(t),
color,
..
} if t == needle => *color,
_ => None,
})
.unwrap_or_else(|| panic!("no quad for {needle}"))
};
// The engine default gold 0xffffd200 (both no-colour shapes land on it).
let gold = [1.0, 210.0 / 255.0, 0.0, 1.0];
assert_eq!(color_of("Zone Name"), gold);
assert_eq!(color_of("plain gold"), gold);
assert_eq!(color_of("wrapped tail"), [0.2, 0.4, 0.6, 1.0]);
// The partial tail: r gates the block on, missing g/b read as 0.0 (byte-pinned).
assert_eq!(color_of("red only"), [1.0, 0.0, 0.0, 1.0]);
assert!(s.take_errors().is_empty());
}
/// A tooltip whose lines haven't been measured yet holds its declared size — the gaps alone must
/// never resolve it to a gaps-only rect (the first cut summed LINE_GAP/DOUBLE_GAP for unmeasured
/// rows and collapsed a fresh 4-line tooltip to 60×26; caught during the 0274 call-site
/// migration).
#[test]
fn unmeasured_lines_hold_declared_size() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local a = CreateFrame("Button", "A6"); a:SetPoint("TOPLEFT", 100, -100); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT6")
tt:SetSize(120, 32)
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:AddLine("Tough Jerky")
tt:AddDoubleLine("One-Hand", "Sword")
tt:AddLine("5 - 9 Damage")
tt:AddLine("(3.7 damage per second)")
tt:Show()
"#,
)
.unwrap();
// Resolve WITHOUT answering the measure round-trip: the declared 120×32 must hold.
s.resolve();
s.run(
r#"
assert(TT6:GetWidth() == 120, "declared width holds unmeasured, got " .. TT6:GetWidth())
assert(TT6:GetHeight() == 32, "declared height holds unmeasured, got " .. TT6:GetHeight())
"#,
)
.unwrap();
// Then the measures land and the auto-size takes over.
measure_all(
&mut s,
&[
("Tough Jerky", 80.0, 14.0),
("One-Hand", 50.0, 12.0),
("Sword", 30.0, 12.0),
("5 - 9 Damage", 70.0, 12.0),
("(3.7 damage per second)", 110.0, 12.0),
],
);
s.run(r#"assert(TT6:GetWidth() == 140, "auto width after measures, got " .. TT6:GetWidth())"#)
.unwrap();
}
/// SetMinimumWidth floors the auto width (the money row's law), and clears with the content.
#[test]
fn minimum_width_floors_autosize() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local a = CreateFrame("Button", "A4"); a:SetPoint("TOPLEFT", 100, -100); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT4")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:AddLine("tiny")
tt:SetMinimumWidth(90)
tt:Show()
"#,
)
.unwrap();
s.resolve();
measure_all(&mut s, &[("tiny", 20.0, 12.0)]);
// floor 90 beats content 20 ⇒ width 110.
s.run(r#"assert(TT4:GetWidth() == 110, "floored width, got " .. TT4:GetWidth())"#)
.unwrap();
}
/// FadeOut ramps the frame alpha down and hides at the end of the ramp (owner + lines dropped,
/// OnTooltipCleared fired); fresh content mid-fade cancels the ramp at full alpha.
#[test]
fn fadeout_ramps_then_hides() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
cleared = 0
local a = CreateFrame("Button", "A5"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT5")
tt:SetScript("OnTooltipCleared", function() cleared = cleared + 1 end)
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetText("Fading Unit")
tt:FadeOut()
"#,
)
.unwrap();
// Half the ramp: still shown, alpha well below 1.
s.tick(0.25);
s.run(r#"assert(TT5:IsShown(), "still shown mid-fade")"#)
.unwrap();
s.resolve();
let mid_alpha = s
.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Text { text: Some(t), .. } if t == "Fading Unit" => Some(q.alpha),
_ => None,
})
.expect("fading line still draws");
assert!(
mid_alpha > 0.2 && mid_alpha < 0.8,
"mid-fade alpha ~0.5, got {mid_alpha}"
);
// Re-content cancels the fade at full alpha.
s.run(
r#"
TT5:SetText("Fresh Hover")
"#,
)
.unwrap();
s.tick(0.05);
s.resolve();
let fresh_alpha = s
.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Text { text: Some(t), .. } if t == "Fresh Hover" => Some(q.alpha),
_ => None,
})
.expect("fresh line draws");
assert_eq!(fresh_alpha, 1.0, "fresh content restored full alpha");
// Run a full ramp to the end: hidden + cleared.
s.run("TT5:FadeOut()").unwrap();
s.tick(0.6);
s.run(
r#"
assert(not TT5:IsShown(), "hidden at ramp end")
assert(TT5:NumLines() == 0, "content dropped at ramp end")
assert(cleared >= 1, "OnTooltipCleared fired")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// SetOwner's anchor law at the screen edge CLAMPS — the client's geometry-flags-bit4 clamp
/// (`assemble 0x767a20`, wow-re layout.md), carried by every GameTooltip frame **by
/// construction** (decision 0352: no tooltip ever leaves the window). The reproduction is the
/// minimap zone-text hover (MinimapCluster.xml: `ANCHOR_LEFT` on a button at the very top of
/// the screen — plate bottom-right at the owner's top-left seats it wholly ABOVE the window):
/// the reference plate hangs DOWN from the screen top instead, size preserved, X untouched.
#[test]
fn owner_anchored_tooltip_clamps_to_screen() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local owner = CreateFrame("Button", "ZoneTextBtn")
owner:SetPoint("TOPRIGHT", -100, 0); owner:SetSize(90, 14)
local tt = CreateFrame("GameTooltip", "TTC")
assert(tt:IsClampedToScreen(), "a GameTooltip clamps by construction")
assert(not owner:IsClampedToScreen(), "a plain frame does not")
tt:SetOwner(owner, "ANCHOR_LEFT")
tt:AddLine("Goldshire")
tt:AddLine("Alliance Territory")
tt:Show()
"#,
)
.unwrap();
s.resolve();
measure_all(
&mut s,
&[
("Goldshire", 70.0, 14.0),
("Alliance Territory", 120.0, 12.0),
],
);
// Auto-size: maxw 120 ⇒ width 140; totalh 14+2+12 ⇒ height 48. Unclamped, the plate's
// BOTTOMRIGHT sits at the owner's TOPLEFT (610, 600) — top 648, wholly above the window.
// The clamp shifts it back down: top at the screen top, size preserved, X untouched.
s.run(
r#"
assert(TTC:GetTop() == 600, "top clamped to the screen top, got " .. TTC:GetTop())
assert(TTC:GetBottom() == 552, "size preserved, got " .. TTC:GetBottom())
assert(TTC:GetRight() == 610, "X untouched (inside), got " .. TTC:GetRight())
TTC:SetClampedToScreen(false)
"#,
)
.unwrap();
s.resolve();
// The flag routes: unclamped, the plate returns to the raw anchor law above the window.
s.run(
r#"
assert(not TTC:IsClampedToScreen(), "flag readable")
assert(TTC:GetBottom() == 600, "unclamped: bottom back at the owner's top")
assert(TTC:GetTop() == 648, "unclamped: off the window again")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
@@ -0,0 +1,260 @@
//! The Set* entry points: the shift-compare seats and their CURRENTLY_EQUIPPED shape,
//! SetInventoryItem outside compare, and SetHyperlink's item-link filter.
use std::collections::HashMap;
use super::script;
use crate::script::*;
/// The shopping-compare pipeline end-to-end (0274 P4): a bag-ring hover on the main GameTooltip
/// with shift held fires `SHOW_COMPARE_TOOLTIP` once per finger slot; a ref-shaped listener
/// (PaperDollFrame.lua:621-640) seats ShoppingTooltip1/2, whose ARMED `SetInventoryItem` renders
/// the byte law's compare shape — gray "Currently Equipped", WHITE name, the compact cut (the
/// description never prints). Releasing shift hides the pair; a shift-up hover fires nothing
/// until the rising edge.
#[test]
fn shift_compare_fires_seats_and_renders_the_compare_shape() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
let mut inv: InventorySlots = Default::default();
inv[11] = Some(InvSlotView {
durability: None,
item_id: 7000,
name: Some("Old Loop".into()),
quality: 1,
..Default::default()
});
inv[12] = Some(InvSlotView {
durability: None,
item_id: 7001,
name: Some("Older Loop".into()),
quality: 1,
..Default::default()
});
s.set_inventory_slots(inv);
s.set_item_template(
7000,
ItemTemplateView {
name: "Old Loop".into(),
quality: 1,
inventory_type: 11,
description: "Round.".into(),
..Default::default()
},
);
s.set_item_template(
7001,
ItemTemplateView {
name: "Older Loop".into(),
quality: 1,
inventory_type: 11,
..Default::default()
},
);
s.set_item_template(
7002,
ItemTemplateView {
name: "New Loop".into(),
quality: 2,
inventory_type: 11,
..Default::default()
},
);
let mut slots = HashMap::new();
slots.insert(
1,
ContainerSlot {
durability: None,
texture: None,
count: 1,
quality: Some(2),
item_id: 7002,
link: Some("|cff1eff00|Hitem:7002:0:0:0|h[New Loop]|h|r".into()),
locked: false,
equip_slots: vec![11, 12],
cooldown: None,
readable: false,
creator: None,
flags: 0,
},
);
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 4,
slots,
}),
);
s.run(
r#"
local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
-- CreateFrame'd frames start SHOWN; the shipped XML instances are hidden="true".
CreateFrame("GameTooltip", "GameTooltip"):Hide()
CreateFrame("GameTooltip", "ShoppingTooltip1"):Hide()
CreateFrame("GameTooltip", "ShoppingTooltip2"):Hide()
compare_calls = {}
for slot = 11, 12 do
local f = CreateFrame("Button", "Doll" .. slot)
f:SetPoint("CENTER", 100, 0); f:SetSize(8, 8)
f.invSlotId = slot
f:RegisterEvent("SHOW_COMPARE_TOOLTIP")
f:SetScript("OnEvent", function()
if arg1 ~= this.invSlotId or arg2 > 2 then return end
table.insert(compare_calls, arg1 .. ":" .. arg2)
local tooltip = getglobal("ShoppingTooltip" .. arg2)
local anchor = "ANCHOR_RIGHT"
if arg2 > 1 then anchor = "ANCHOR_BOTTOMRIGHT" end
tooltip:SetOwner(this, anchor)
local hasItem = tooltip:SetInventoryItem("player", this.invSlotId)
if not hasItem then tooltip:Hide() end
end)
end
"#,
)
.unwrap();
// Shift up: the hover renders the main tooltip, no compare fires.
s.run(
r#"
GameTooltip:SetOwner(Slot, "ANCHOR_RIGHT")
GameTooltip:SetBagItem(0, 1)
assert(table.getn(compare_calls) == 0, "no compare while shift is up")
assert(not ShoppingTooltip1:IsShown())
"#,
)
.unwrap();
// The rising edge fires both ring slots in order.
s.set_modifiers(true, false, false);
s.run(
r#"
assert(table.getn(compare_calls) == 2, "two ring compares, got " .. table.getn(compare_calls))
assert(compare_calls[1] == "11:1" and compare_calls[2] == "12:2", "slot:index order")
assert(ShoppingTooltip1:IsShown() and ShoppingTooltip2:IsShown())
assert(ShoppingTooltip1TextLeft1:GetText() == "Currently Equipped")
assert(ShoppingTooltip1TextLeft2:GetText() == "Old Loop")
assert(ShoppingTooltip2TextLeft2:GetText() == "Older Loop")
-- The compact cut at 0x52e14c: the description never prints on a compare.
for i = 1, ShoppingTooltip1:NumLines() do
assert(getglobal("ShoppingTooltip1TextLeft" .. i):GetText() ~= "\"Round.\"",
"compact cut dropped the description")
end
-- The pair anchors to the DOLL slots (ref: SetOwner(this, ...)), index 2 below-right.
local p1, r1 = ShoppingTooltip1:GetPoint()
local p2, r2 = ShoppingTooltip2:GetPoint()
assert(r1:GetName() == "Doll11" and p1 == "BOTTOMLEFT", "1 rides ANCHOR_RIGHT")
assert(r2:GetName() == "Doll12" and p2 == "TOPLEFT", "2 rides ANCHOR_BOTTOMRIGHT")
"#,
)
.unwrap();
// The compare colors: gray header, WHITE name (never the quality color) — the byte law.
s.resolve();
let quads = s.extract();
let color_of = |txt: &str| {
quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Text {
text: Some(t),
color,
..
} if t == txt => *color,
_ => None,
})
.unwrap_or([0.0; 4])
};
let gray = color_of("Currently Equipped");
assert!(
(gray[0] - 128.0 / 255.0).abs() < 0.01 && (gray[1] - 128.0 / 255.0).abs() < 0.01,
"Currently Equipped is gray, got {gray:?}"
);
let name = color_of("Old Loop");
assert_eq!(name, [1.0, 1.0, 1.0, 1.0], "compare name is WHITE");
// Releasing shift hides the pair; the main tooltip stays.
s.set_modifiers(false, false, false);
s.run(
r#"
assert(not ShoppingTooltip1:IsShown() and not ShoppingTooltip2:IsShown(), "release hides")
assert(GameTooltip:IsShown(), "the item hover itself stays")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// `SetInventoryItem` outside a compare: the FULL line law (quality name, description — no cut),
/// return 1 on an occupied slot, nil on empty/foreign units.
#[test]
fn set_inventory_item_renders_full_outside_compare() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
let mut inv: InventorySlots = Default::default();
inv[16] = Some(InvSlotView {
durability: None,
item_id: 8000,
name: Some("Worn Axe".into()),
quality: 2,
..Default::default()
});
s.set_inventory_slots(inv);
s.set_item_template(
8000,
ItemTemplateView {
name: "Worn Axe".into(),
quality: 2,
inventory_type: 21,
description: "Chipped.".into(),
..Default::default()
},
);
s.run(
r#"
local a = CreateFrame("Button", "Slot9"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
assert(tt:SetInventoryItem("player", 16) == 1, "occupied slot answers 1")
assert(TTTextLeft1:GetText() == "Worn Axe")
local quoted = false
for i = 1, tt:NumLines() do
if getglobal("TTTextLeft" .. i):GetText() == "\"Chipped.\"" then quoted = true end
end
assert(quoted, "full render keeps the description")
assert(tt:SetInventoryItem("player", 5) == nil, "empty slot answers nil")
assert(tt:SetInventoryItem("target", 16) == nil, "self-only feed")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// `SetHyperlink`: the full escaped chat link and the bare `item:` form both render through the
/// shared law; non-item links no-op without touching the current content.
#[test]
fn set_hyperlink_renders_items_and_ignores_other_links() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_item_template(
7002,
ItemTemplateView {
name: "New Loop".into(),
quality: 2,
inventory_type: 11,
..Default::default()
},
);
s.run(
r#"
local a = CreateFrame("Button", "Slot10"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetHyperlink("|cff1eff00|Hitem:7002:0:0:0|h[New Loop]|h|r")
assert(TTTextLeft1:GetText() == "New Loop", "escaped link renders")
tt:SetHyperlink("player:Bob")
assert(TTTextLeft1:GetText() == "New Loop", "non-item link leaves the content alone")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetHyperlink("item:7002")
assert(TTTextLeft1:GetText() == "New Loop", "bare item: form renders")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
@@ -0,0 +1,512 @@
//! The emission-order law and the red requirement checks: the verified line families, the
//! player-state-tracked reds (level/class/rep), and the slot|type cells' independent
//! proficiency reds (hard miss on TYPE; alternate/dual-wield on SLOT).
use std::collections::HashMap;
use super::{axe, lines_of, right_color, script};
use crate::script::*;
/// The full line law over a rich weapon + the red checks: a level-25 warrior fails the level
/// requirement (red) but passes the class list (white).
#[test]
fn item_line_law_and_red_requirements() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_item_template(871, axe());
s.set_player_req_state(PlayerReqState {
level: 25,
class_id: 1, // Warrior — allowed
race_id: 1,
skills: HashMap::new(),
..Default::default()
});
s.run(
r#"
local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetItemById(871)
assert(tt:IsShown(), "SetItemById shows")
"#,
)
.unwrap();
let lines = lines_of(&mut s);
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(
texts,
vec![
"Ravager",
"Binds when equipped",
"Two-Hand",
"68 - 103 Damage",
"+ 2 - 4 Shadow Damage",
"(25.3 damage per second)",
// Display order, NOT wire order: the fixture feeds (Stamina, Strength) but the
// 0x808e88 table prints Strength first (STR,AGI,STA,INT,SPI,HP,MANA).
"+9 Strength",
"+12 Stamina",
"+10 Shadow Resistance",
"Durability 90 / 90",
"Classes: Warrior, Rogue",
"Requires Level 37",
"Chance on hit: Ravager",
"\"A wicked axe of the Scarlet Crusade.\"",
],
"the verified line order (0276)"
);
// The name wears the Rare blue; the failed level line is red; the passed class line white.
assert_eq!(lines[0].1, [0.0, 0.439, 0.867, 1.0], "rare-blue name");
let class_line = &lines[10];
assert_eq!(
class_line.1,
[1.0, 1.0, 1.0, 1.0],
"class list passes → white"
);
let level_line = &lines[11];
assert_eq!(
level_line.1,
[1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0],
"level 25 < 37 → red"
);
// The right column of the slot|type pair carries the subclass.
let ty: String = s.eval("return TTTextRight3:GetText()").unwrap();
assert_eq!(ty, "Axe");
// The speed column of the damage pair.
let speed: String = s.eval("return TTTextRight4:GetText()").unwrap();
assert_eq!(speed, "Speed 3.50");
assert!(s.take_errors().is_empty());
}
/// The red law flips with player state: leveling past the requirement turns the line white; a
/// class outside the list turns the class line red.
#[test]
fn red_lines_track_player_state() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_item_template(871, axe());
s.set_player_req_state(PlayerReqState {
level: 60,
class_id: 8, // Mage — not in {Warrior, Rogue}
race_id: 1,
skills: HashMap::new(),
..Default::default()
});
s.run(
r#"
local a = CreateFrame("Button", "Slot2"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetItemById(871)
"#,
)
.unwrap();
let lines = lines_of(&mut s);
let find = |needle: &str| {
lines
.iter()
.find(|(t, _)| t.starts_with(needle))
.unwrap_or_else(|| panic!("no line starting {needle}"))
.1
};
assert_eq!(
find("Requires Level"),
[1.0, 1.0, 1.0, 1.0],
"60 ≥ 37 → white"
);
assert_eq!(
find("Classes:"),
[1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0],
"mage → red"
);
assert!(s.take_errors().is_empty());
}
/// The §5-verified families folded back 2026-07-10 (tooltip-content-law.md): SIGNABLE green,
/// UNIQUE before STARTS_QUEST, LOCKED red, six-equal resistances collapse to the ALL line (and
/// Holy never prints singly), a known taught spell reds "Already known", the description gold —
/// and NO openable line: `SetItemById` is a template source, and the whole openable/readable/
/// creator tail rides the ref's item-OBJECT gate (`0x52e1c7`/`0x52e2e0` — byte-read 2026-07-20;
/// the instance-tail law itself is in [`instance_tail_creator_and_readable`]).
#[test]
fn verified_families_signable_locked_resists_known() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_item_template(
5518,
ItemTemplateView {
name: "Sealed Charter".into(),
quality: 1,
flags: 0x2000 | 0x4, // signable + openable
max_count: 1,
start_quest: 42,
lock_id: 7,
resistances: [5, 5, 5, 5, 5, 5],
spell_triggers: vec![(6, 2020, "Recipe: Stew".into())],
description: "Sign here.".into(),
..Default::default()
},
);
// The player already knows the taught spell 2020.
s.set_spellbook(SpellBookState {
tabs: Vec::new(),
slots: vec![SpellSlotView {
spell_id: 2020,
..Default::default()
}],
});
s.run(
r#"
local a = CreateFrame("Button", "Slot5"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetItemById(5518)
"#,
)
.unwrap();
let lines = lines_of(&mut s);
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(
texts,
vec![
"Sealed Charter",
"<Right Click for Details>",
"Unique",
"This Item Begins a Quest",
"Locked",
"+5 to All Resistances",
"Already known",
"\"Sign here.\"",
],
"the verified gated families in the verified order"
);
let color = |needle: &str| lines.iter().find(|(t, _)| t == needle).unwrap().1;
let red = [1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0];
assert_eq!(color("Locked"), red, "LOCKED is red");
assert_eq!(
color("Already known"),
red,
"SPELL_KNOWN is unconditional red"
);
assert_eq!(
color("<Right Click for Details>"),
[0.0, 1.0, 0.0, 1.0],
"SIGNABLE is green"
);
assert_eq!(
color("\"Sign here.\""),
[1.0, 210.0 / 255.0, 0.0, 1.0],
"the description is the byte-verified gold"
);
// Holy exclusion: a lone Holy resist prints nothing.
s.set_item_template(
5519,
ItemTemplateView {
name: "Blessed Trinket".into(),
quality: 1,
resistances: [10, 0, 0, 0, 0, 0],
..Default::default()
},
);
s.run(
r#"
TT:SetOwner(Slot5, "ANCHOR_RIGHT")
TT:SetItemById(5519)
assert(TT:NumLines() == 1, "a lone Holy resist prints no line, got " .. TT:NumLines())
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// The slot|type line's independent cell reds (byte-read at the builder's coloring legs
/// against the verified AddLine-CORE signature — NB law §10's prose swaps the cells): a hard
/// proficiency miss (`0xc4d4a0[class]` bit `1 << subclass`) reds the TYPE cell; the SLOT cell
/// reds when the weapon is usable only via its alternate subclass (ItemSubClass
/// prereq/postreq) or is an off-hand weapon without Dual Wield (`0x5eab70`). Plus the
/// reputation-requirement red (§1-RED's standing leg) and the hidden-type suppression.
#[test]
fn proficiency_and_reputation_reds() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
let mut item = axe(); // class 2, subclass 1 (2H axe), invtype 17 "Two-Hand"
item.required_rep_faction = 72; // Stormwind
item.required_rep_rank = 5; // Honored
item.required_rep_line = Some("Requires Stormwind - Honored".into());
s.set_item_template(871, item.clone());
// A player who knows 2H axes (bit 1) and stands Honored: everything white.
let mut req = PlayerReqState {
level: 60,
class_id: 1,
race_id: 1,
..Default::default()
};
req.proficiency.insert(2, 1 << 1);
req.rep_ranks.insert(72, 5);
s.set_player_req_state(req.clone());
s.run(
r#"
local a = CreateFrame("Button", "Slot9"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetItemById(871)
"#,
)
.unwrap();
let white = [1.0, 1.0, 1.0, 1.0];
let red = [1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0];
let color = |lines: &[(String, [f32; 4])], needle: &str| {
lines
.iter()
.find(|(t, _)| t == needle)
.unwrap_or_else(|| panic!("no line {needle:?}"))
.1
};
let lines = lines_of(&mut s);
assert_eq!(color(&lines, "Two-Hand"), white, "proficient slot is white");
assert_eq!(
right_color(&mut s, "Axe"),
white,
"proficient type is white"
);
assert_eq!(
color(&lines, "Requires Stormwind - Honored"),
white,
"met reputation is white"
);
// 1H-axes-only mask, no alternate on the view: the hard miss reds the TYPE cell and the
// slot stays white. Friendly standing reds the rep line.
let mut req2 = req;
req2.proficiency.insert(2, 1 << 0);
req2.rep_ranks.insert(72, 4);
s.set_player_req_state(req2.clone());
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(871)"#)
.unwrap();
let lines = lines_of(&mut s);
assert_eq!(
color(&lines, "Two-Hand"),
white,
"slot survives a hard miss"
);
assert_eq!(right_color(&mut s, "Axe"), red, "the type cell carries it");
assert_eq!(
color(&lines, "Requires Stormwind - Honored"),
red,
"unmet reputation is red"
);
// The same mask with the view's alternate resolved to 1H axes (the real (2,1) row's
// prerequisite): usable-via-alternate reds the SLOT cell instead; the type goes white.
let mut alt = item;
alt.proficiency_alt = Some(0);
s.set_item_template(871, alt);
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(871)"#)
.unwrap();
let lines = lines_of(&mut s);
assert_eq!(color(&lines, "Two-Hand"), red, "alt-usable reds the slot");
assert_eq!(
right_color(&mut s, "Axe"),
white,
"the alternate covers the type"
);
// An off-hand weapon with its dagger bit set still reds the SLOT cell without Dual
// Wield; learning it (an effect-40 spell) clears the red.
s.set_item_template(
872,
ItemTemplateView {
name: "Left-Hand Blade".into(),
class: 2,
subclass: 15,
inventory_type: 22,
..Default::default()
},
);
let mut req3 = req2.clone();
req3.proficiency.insert(2, 1 << 15);
s.set_player_req_state(req3.clone());
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(872)"#)
.unwrap();
let lines = lines_of(&mut s);
assert_eq!(
color(&lines, "Off Hand"),
red,
"no Dual Wield reds the slot"
);
assert_eq!(
right_color(&mut s, "Dagger"),
white,
"the type is proficient"
);
req3.can_dual_wield = true;
s.set_player_req_state(req3);
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(872)"#)
.unwrap();
let lines = lines_of(&mut s);
assert_eq!(color(&lines, "Off Hand"), white, "Dual Wield clears it");
// An item class with NO proficiency entry never reds (the map only ever holds classes
// the server sent masks for).
s.set_item_template(
118,
ItemTemplateView {
name: "Tattered Cloth Vest".into(),
class: 4, // armor — but req2 carries no class-4 entry
subclass: 1, // cloth
inventory_type: 5,
..Default::default()
},
);
s.run(r#"TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(118)"#)
.unwrap();
let lines = lines_of(&mut s);
assert_eq!(
color(&lines, "Chest"),
white,
"a class with no mask entry stays white"
);
// A hidden subclass (displayFlags bit 0 — the Miscellaneous family) prints no type cell.
s.set_item_template(
889,
ItemTemplateView {
name: "Plain Band".into(),
class: 4,
subclass: 0,
inventory_type: 11,
hide_subclass: true,
..Default::default()
},
);
s.run(
r#"
TT:SetOwner(Slot9, "ANCHOR_RIGHT"); TT:SetItemById(889)
assert(TTTextLeft2:GetText() == "Finger")
assert(TTTextRight2:GetText() == nil or TTTextRight2:GetText() == "",
"a ring never prints its Miscellaneous type")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// ITEM_MIN_LEVEL's `> 1` gate (byte-VERIFIED `0x52d2cf`: `cmp esi,0x1 / jle skip`): a level-1
/// requirement — every starter consumable's shape (bread/water carry `RequiredLevel 1`) —
/// prints NO line, exactly like no requirement at all; level 2 is the first that prints.
#[test]
fn required_level_one_is_hidden() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
for (id, req) in [(11, 0u32), (12, 1), (13, 2)] {
s.set_item_template(
id,
ItemTemplateView {
name: format!("Req{req}"),
required_level: req,
..Default::default()
},
);
}
s.run(
r#"
local a = CreateFrame("Button", "SlotR"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetItemById(11)
assert(tt:NumLines() == 1, "req 0: name only, got " .. tt:NumLines())
tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetItemById(12)
assert(tt:NumLines() == 1, "req 1 hides like req 0, got " .. tt:NumLines())
tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetItemById(13)
assert(tt:NumLines() == 2, "req 2 prints, got " .. tt:NumLines())
assert(TTTextLeft2:GetText() == "Requires Level 2")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// The instance tail (byte-read 2026-07-20; wow-re §1-CREATOR/-OPENABLE cross-checked, their
/// 0f4824e2): a REAL-instance hover (`SetBagItem`) appends the creator line — "Written by %s"
/// (white) when the instance carries letter text, the green-escaped "<Made by %s>" otherwise —
/// then READABLE off the instance text id (its template's PageText is 0 — the
/// director-reported gap). An unresolved creator (name query in flight) emits no line. LOCKED
/// yields to the instance UNLOCKED bit — and ITEM_OPENABLE never emits from `SetBagItem` (a
/// p6=1 source: the `0x52e2e8` bypass), so the unlocked chest shows its bare name.
#[test]
fn instance_tail_creator_and_readable() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_item_template(
889,
ItemTemplateView {
name: "Plain Letter".into(),
quality: 1,
..Default::default()
},
);
s.set_item_template(
2589,
ItemTemplateView {
name: "Heavy Chest".into(),
quality: 1,
flags: 0x4,
lock_id: 7,
..Default::default()
},
);
let slot = |id: u32, readable: bool, creator: Option<&str>, flags: u32| ContainerSlot {
item_id: id,
count: 1,
quality: Some(1),
readable,
creator: creator.map(str::to_string),
flags,
..Default::default()
};
let mut slots = HashMap::new();
slots.insert(1, slot(889, true, Some("One"), 0)); // the mail letter
slots.insert(2, slot(889, true, None, 0)); // creator name still in flight
slots.insert(3, slot(2589, false, Some("Geoffrey"), 0)); // crafted, still locked
slots.insert(4, slot(2589, false, None, 0x4)); // unlocked → LOCKED gone, and NO open line
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 4,
slots,
}),
);
s.run(
r#"
local a = CreateFrame("Button", "SlotL"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetBagItem(0, 1)
"#,
)
.unwrap();
let lines = lines_of(&mut s);
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(
texts,
vec!["Plain Letter", "Written by One", "<Right Click to Read>"],
"the letter: writer line + instance-gated READABLE"
);
assert_eq!(lines[1].1, [1.0, 1.0, 1.0, 1.0], "WRITTEN_BY is white");
assert_eq!(lines[2].1, [0.0, 1.0, 0.0, 1.0], "READABLE is green");
// Creator unresolved → no writer line, READABLE stays (the re-push repaints later).
s.run(r#"TT:SetOwner(getglobal("SlotL"), "ANCHOR_RIGHT"); TT:SetBagItem(0, 2)"#)
.unwrap();
let texts: Vec<String> = lines_of(&mut s).into_iter().map(|(t, _)| t).collect();
assert_eq!(texts, vec!["Plain Letter", "<Right Click to Read>"]);
// Crafted + locked: the green-escaped Made-by; no open line while LockID gates it.
s.run(r#"TT:SetOwner(getglobal("SlotL"), "ANCHOR_RIGHT"); TT:SetBagItem(0, 3)"#)
.unwrap();
let texts: Vec<String> = lines_of(&mut s).into_iter().map(|(t, _)| t).collect();
assert_eq!(
texts,
vec!["Heavy Chest", "Locked", "|cff00ff00<Made by Geoffrey>|r"],
"CREATED_BY carries the string's own green escape; locked chest hides OPENABLE"
);
// The instance UNLOCKED bit retires the LOCKED line — and OPENABLE still never emits
// (SetBagItem is p6=1; only send-mail/auction/buyback sources reach the emitter in 5875).
s.run(r#"TT:SetOwner(getglobal("SlotL"), "ANCHOR_RIGHT"); TT:SetBagItem(0, 4)"#)
.unwrap();
let texts: Vec<String> = lines_of(&mut s).into_iter().map(|(t, _)| t).collect();
assert_eq!(texts, vec!["Heavy Chest"]);
assert!(s.take_errors().is_empty());
}
@@ -0,0 +1,368 @@
//! Render mechanics around the line law: the sell-price money protocol (OnTooltipAddMoney +
//! ITEM_UNSELLABLE), wrapped-line measurement through the re-enter loop, and the unit health
//! bar hiding on an item render.
use std::collections::HashMap;
use super::script;
use crate::script::*;
/// The sell-price money protocol: SetBagItem at an open merchant fires OnTooltipAddMoney with
/// SellPrice × stack; price 0 prints the ITEM_UNSELLABLE line; no merchant → neither; the
/// in-flight template renders the link-name fallback and records the ask.
#[test]
fn bag_item_money_law_and_fallback() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
let mut slots = HashMap::new();
slots.insert(
1,
ContainerSlot {
count: 4,
quality: Some(1),
item_id: 2318,
link: Some("|cffffffff|Hitem:2318|h[Light Leather]|h|r".into()),
..Default::default()
},
);
slots.insert(
2,
ContainerSlot {
count: 1,
quality: Some(1),
item_id: 9999,
link: Some("|cffffffff|Hitem:9999|h[Shadowforge Key]|h|r".into()),
..Default::default()
},
);
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}),
);
s.set_item_template(
2318,
ItemTemplateView {
name: "Light Leather".into(),
quality: 1,
sell_price: 13,
..Default::default()
},
);
s.run(
r#"
money_fired = nil
local a = CreateFrame("Button", "Slot3"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetScript("OnTooltipAddMoney", function(self) money_fired = arg1 end)
-- No merchant: no money handler fires.
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetBagItem(0, 1)
assert(money_fired == nil, "no merchant, no money")
"#,
)
.unwrap();
s.set_merchant(Some(MerchantState::default()));
s.run(
r#"
TT:SetOwner(Slot3, "ANCHOR_RIGHT")
TT:SetBagItem(0, 1)
assert(money_fired == 52, "SellPrice 13 × stack 4, got " .. tostring(money_fired))
-- The unresolved key: the link-name fallback line + nothing else.
TT:SetOwner(Slot3, "ANCHOR_RIGHT")
TT:SetBagItem(0, 2)
assert(TT:NumLines() == 1, "fallback one-liner")
assert(TTTextLeft1:GetText() == "Shadowforge Key", "fallback carries the link name")
"#,
)
.unwrap();
assert_eq!(s.take_item_stat_asks(), vec![9999], "the miss asked");
// Zero sell price at a merchant: the ITEM_UNSELLABLE line, no money.
s.set_item_template(
9999,
ItemTemplateView {
name: "Shadowforge Key".into(),
quality: 1,
sell_price: 0,
..Default::default()
},
);
s.run(
r#"
money_fired = nil
TT:SetOwner(Slot3, "ANCHOR_RIGHT")
TT:SetBagItem(0, 2)
assert(money_fired == nil, "unsellable fires no money")
local last = getglobal("TTTextLeft" .. TT:NumLines())
assert(last:GetText() == "No sell price", "ITEM_UNSELLABLE line")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// A wrap-flagged line carries its wrap column on the VERY FIRST measure ask (append_line pins
/// the region width at write time), so ONE round-trip answers the wrapped extent — and the
/// plate height/width converge even when the hover's re-enter loop clears + rebuilds the
/// content every frame. The old two-step (layout re-pinning after an overflowing unwrapped
/// measure) never converged under that loop: the wrapped re-measure was wiped before it could
/// land, wrap lines contributed no height, and everything after them spilled below the plate
/// (the live bread/hearthstone bug).
#[test]
fn wrap_lines_measure_wrapped_in_one_pass_and_survive_the_reenter_loop() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_item_template(
7,
ItemTemplateView {
name: "Storybook".into(),
quality: 1,
charges: 1,
description:
"An exceedingly long tale of adventure and woe that would never fit on one line."
.into(),
..Default::default()
},
);
s.run(
r#"
local a = CreateFrame("Button", "Slot4"); a:SetPoint("TOPLEFT", 10, -10); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetItemById(7)
"#,
)
.unwrap();
s.resolve();
// ONE measure pass, the frame loop's shape: the wrap-flagged description asks WITH the
// wrap column; plain lines ask unconstrained.
let answer = |s: &mut UiScript| {
let reqs = s.fontstrings_needing_measure();
let answers: Vec<(u32, f32, f32, u64)> = reqs
.iter()
.map(|r| {
if r.text.starts_with('"') {
assert_eq!(
r.wrap_width,
Some(crate::widget::TOOLTIP_WRAP_WIDTH),
"a wrap line's FIRST ask carries the wrap column"
);
(r.id, 250.0, 36.0, r.key) // wrapped: 3 rows tall, inside the column
} else {
(r.id, 60.0, 14.0, r.key)
}
})
.collect();
s.set_measured_text(&answers);
s.resolve();
};
answer(&mut s);
// name 14 + gap 2 + "1 Charge" 14 + gap 2 + description 36 + 2·pad 20 = 88.
s.run(
r#"
assert(TT:GetWidth() == 270, "wrap column + padding, got " .. TT:GetWidth())
assert(TT:GetHeight() == 88, "wrap rows counted, got " .. TT:GetHeight())
"#,
)
.unwrap();
// The re-enter loop: clear + rebuild + one measure pass per frame, three frames — the size
// must HOLD (the oscillation regression).
for _ in 0..3 {
s.run(
r#"
TT:SetOwner(Slot4, "ANCHOR_RIGHT")
TT:SetItemById(7)
"#,
)
.unwrap();
s.resolve();
answer(&mut s);
s.run(
r#"
assert(TT:GetWidth() == 270 and TT:GetHeight() == 88,
"re-enter holds the wrapped size, got " .. TT:GetWidth() .. "x" .. TT:GetHeight())
"#,
)
.unwrap();
}
assert!(s.take_errors().is_empty());
}
/// The mouseover health bar is UNIT content: an item render on the same tooltip HIDES it (the
/// live bug: the bar from the last mob hover rode under every item tooltip).
#[test]
fn item_render_hides_the_unit_health_bar() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_unit(
"mouseover",
Some(UnitState {
exists: true,
name: Some("Timber Wolf".into()),
health: 30,
max_health: 50,
level: 10,
reaction: 2,
..Default::default()
}),
);
s.set_item_template(
9,
ItemTemplateView {
name: "Plain Rock".into(),
quality: 1,
..Default::default()
},
);
s.run(
r#"
local a = CreateFrame("Button", "Slot6"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "GameTooltip"); tt:Hide()
local bar = CreateFrame("StatusBar", "GameTooltipStatusBar", tt)
bar:SetPoint("TOPLEFT", tt, "BOTTOMLEFT", 2, -1); bar:SetSize(100, 8)
"#,
)
.unwrap();
assert!(s.world_tooltip_unit("mouseover"), "the unit hover shows");
s.run(r#"assert(GameTooltipStatusBar:IsShown(), "unit hover shows the bar")"#)
.unwrap();
s.run(
r#"
GameTooltip:SetOwner(Slot6, "ANCHOR_RIGHT")
GameTooltip:SetItemById(9)
assert(not GameTooltipStatusBar:IsShown(), "item content hides the unit bar")
assert(GameTooltip:IsShown(), "the item tooltip itself shows")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// A REAL-INSTANCE hover renders the instance's LIVE durability pair, not the template's full
/// max/max — the wire updates `ITEM_FIELD_DURABILITY` on damage (death 10%, spirit healer 25%;
/// director-reported: the 25% loss showed nowhere). A plain template/link hover keeps max/max.
#[test]
fn real_instance_hover_renders_live_durability() {
let mut s = script();
let mut slots = std::collections::HashMap::new();
slots.insert(
2,
ContainerSlot {
count: 1,
quality: Some(2),
item_id: 2264,
link: Some("|cff1eff00|Hitem:2264|h[Mantle of Doan]|h|r".into()),
durability: Some((30, 40)), // the spirit healer's 25% off a 40-max piece
..Default::default()
},
);
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}),
);
s.set_item_template(
2264,
ItemTemplateView {
name: "Mantle of Doan".into(),
quality: 2,
max_durability: 40,
..Default::default()
},
);
s.run(
r#"
local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(Slot, "ANCHOR_RIGHT")
tt:SetBagItem(0, 2)
found = nil
for i = 1, tt:NumLines() do
local t = getglobal("TTTextLeft" .. i):GetText()
if t and string.find(t, "Durability") then found = t end
end
assert(found == "Durability 30 / 40", "live pair on a bag hover, got " .. tostring(found))
-- The template/link hover of the SAME item keeps the authored full pair.
tt:SetItemById(2264)
found = nil
for i = 1, tt:NumLines() do
local t = getglobal("TTTextLeft" .. i):GetText()
if t and string.find(t, "Durability") then found = t end
end
assert(found == "Durability 40 / 40", "template hover stays full, got " .. tostring(found))
"#,
)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// FULLY BROKEN gear renders its true `0 / max` pair (director-caught: gear ground to durability
/// 0 by repeated spirit rezzes read as 100% — the create block omits the zero `DURABILITY` field,
/// and the sparse `None` fell back to the template's max/max; `ObjectFields`' created semantics
/// now feed the explicit 0 through).
#[test]
fn broken_instance_hover_renders_zero_durability() {
let mut s = script();
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
count: 1,
quality: Some(1),
item_id: 2264,
durability: Some((0, 40)),
..Default::default()
},
);
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}),
);
s.set_item_template(
2264,
ItemTemplateView {
name: "Mantle of Doan".into(),
quality: 2,
max_durability: 40,
..Default::default()
},
);
s.run(
r#"
local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(Slot, "ANCHOR_RIGHT")
tt:SetBagItem(0, 1)
found = nil
for i = 1, tt:NumLines() do
local t = getglobal("TTTextLeft" .. i):GetText()
if t and string.find(t, "Durability") then found = t end
end
assert(found == "Durability 0 / 40", "broken gear shows its true 0, got " .. tostring(found))
"#,
)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Red iff broken — the byte law ("durability (red iff broken==0)", wow-re ui.md): the 0/40
// line paints the requirement red, never white.
let lines = super::lines_of(&mut s);
let dur = lines
.iter()
.find(|(t, _)| t.starts_with("Durability"))
.expect("the durability line renders");
assert_eq!(
dur.1,
[1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0],
"broken (0) paints red"
);
}
@@ -0,0 +1,80 @@
//! The engine item-tooltip renderer (decision 0274 P1, law per 0276): the verified line law
//! end-to-end, the red failed-requirement law against live player state, the sell-price money
//! protocol (engine fires `OnTooltipAddMoney` — merchant open + real instance + repair off),
//! and the in-flight-template fallback.
use super::common::script;
use crate::script::*;
/// A full weapon view exercising most line families.
fn axe() -> ItemTemplateView {
ItemTemplateView {
name: "Ravager".into(),
quality: 3,
class: 2,
subclass: 1, // two-hand axe shares the "Axe" display name
inventory_type: 17,
bonding: 2,
stats: vec![(7, 12), (4, 9)],
damages: vec![(68.0, 103.0, 0), (2.0, 4.0, 5)],
delay_ms: 3500,
resistances: [0, 0, 0, 0, 10, 0],
max_durability: 90,
required_level: 37,
allowable_class: (1 << 0) | (1 << 3), // Warrior, Rogue
spell_triggers: vec![(2, 9633, "Ravager".into())],
description: "A wicked axe of the Scarlet Crusade.".into(),
sell_price: 15230,
..Default::default()
}
}
/// The tooltip's left-column lines in order, each with its draw color (read from the extract —
/// the color a renderer would actually paint).
fn lines_of(s: &mut UiScript) -> Vec<(String, [f32; 4])> {
s.resolve();
let quads = s.extract();
let count: i64 = s.eval("return TT:NumLines()").unwrap();
(1..=count)
.map(|i| {
let t: String = s
.eval(&format!(
"return getglobal('TTTextLeft{i}'):GetText() or ''"
))
.unwrap();
let color = quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Text {
text: Some(txt),
color,
..
} if *txt == t => *color,
_ => None,
})
.unwrap_or([0.0; 4]);
(t, color)
})
.collect()
}
/// A right-cell color, matched by its unique text in the extract.
fn right_color(s: &mut UiScript, needle: &str) -> [f32; 4] {
s.resolve();
s.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Text {
text: Some(t),
color,
..
} if t == needle => *color,
_ => None,
})
.unwrap_or_else(|| panic!("no right cell {needle:?}"))
}
mod bindings;
mod law;
mod mechanics;
mod set;
@@ -0,0 +1,163 @@
//! The §22 item-SET block: order (blank, gold header, skill line, member ladder, blank,
//! bonuses), the threshold-ascending sort, the skill gate, member cream/gray, and the
//! ask-once for unseen set ids.
use super::{lines_of, script};
use crate::script::*;
/// The §22 SET block, byte-read: a blank gold line, the gold "name (owned/total)" header, the
/// skill line (white/red) between header and the member ladder (cream when equipped / gray;
/// in-flight names wait), a second blank, then the threshold bonuses SORTED ascending — green
/// only when the skill requirement is met AND owned ≥ threshold — plus the ask-once for an
/// unseen set id.
#[test]
fn item_set_block_counts_and_colors() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
let mut vest = ItemTemplateView {
name: "Defias Mark".into(),
quality: 2,
class: 4,
subclass: 2,
inventory_type: 5,
item_set: 161,
..Default::default()
};
vest.stats.clear();
s.set_item_template(6303, vest);
// Two members equipped (the chest itself + the belt).
let mut slots: InventorySlots = Default::default();
slots[4] = Some(InvSlotView {
item_id: 6303,
..Default::default()
});
slots[8] = Some(InvSlotView {
item_id: 6304,
..Default::default()
});
s.set_inventory_slots(slots);
let set_view = ItemSetView {
name: "Defias Leather".into(),
members: vec![
(6303, Some("Defias Mark".into())),
(6304, Some("Defias Belt".into())),
(6305, Some("Defias Gloves".into())),
(6306, None), // template still in flight — no line yet
(6307, Some("Defias Boots".into())),
],
// Stored high-first (the catalog keeps DBC slot order — The Gladiator ships
// 3,2,5,4): the renderer must sort ascending at print time.
bonuses: vec![
(4, "+10 Attack Power.".into()),
(2, "Increases movement speed slightly.".into()),
],
..Default::default()
};
s.set_item_set(161, set_view.clone());
s.run(
r#"
local a = CreateFrame("Button", "Slot10"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetItemById(6303)
assert(TTTextLeft3:GetText() == "" or TTTextLeft3:GetText() == nil, "blank before the header")
assert(TTTextLeft4:GetText() == "Defias Leather (2/5)", "got " .. tostring(TTTextLeft4:GetText()))
assert(TTTextLeft5:GetText() == " Defias Mark")
assert(TTTextLeft8:GetText() == " Defias Boots", "in-flight member renders no line")
assert(TTTextLeft9:GetText() == "" or TTTextLeft9:GetText() == nil, "blank before the bonuses")
assert(TTTextLeft10:GetText() == "(2) Set: Increases movement speed slightly.",
"thresholds sort ascending, got " .. tostring(TTTextLeft10:GetText()))
assert(TTTextLeft11:GetText() == "(4) Set: +10 Attack Power.")
"#,
)
.unwrap();
let lines = lines_of(&mut s);
let color = |lines: &[(String, [f32; 4])], needle: &str| {
lines
.iter()
.find(|(t, _)| t == needle)
.unwrap_or_else(|| panic!("no line {needle:?}"))
.1
};
let gold = [1.0, 210.0 / 255.0, 0.0, 1.0];
let cream = [1.0, 1.0, 151.0 / 255.0, 1.0];
let gray = [128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0];
let green = [0.0, 1.0, 0.0, 1.0];
let red = [1.0, 32.0 / 255.0, 32.0 / 255.0, 1.0];
assert_eq!(
color(&lines, "Defias Leather (2/5)"),
gold,
"set name is gold"
);
assert_eq!(
color(&lines, " Defias Mark"),
cream,
"equipped member is cream"
);
assert_eq!(
color(&lines, " Defias Gloves"),
gray,
"unowned member is gray"
);
assert_eq!(
color(&lines, "(2) Set: Increases movement speed slightly."),
green,
"active bonus is green"
);
assert_eq!(
color(&lines, "(4) Set: +10 Attack Power."),
gray,
"inactive bonus is gray"
);
// The skill gate (`0x5eaae0`): an unmet set-level skill reds its line — seated between
// the header and the first member — and grays every bonus, met thresholds included.
let mut gated = set_view;
gated.required_skill = 165;
gated.required_skill_rank = 250;
gated.required_skill_name = Some("Leatherworking".into());
s.set_item_set(161, gated);
s.run(
r#"
TT:SetOwner(Slot10, "ANCHOR_RIGHT"); TT:SetItemById(6303)
assert(TTTextLeft5:GetText() == "Requires Leatherworking (250)",
"the skill line sits between header and members")
assert(TTTextLeft6:GetText() == " Defias Mark")
"#,
)
.unwrap();
let lines = lines_of(&mut s);
assert_eq!(color(&lines, "Requires Leatherworking (250)"), red);
assert_eq!(
color(&lines, "(2) Set: Increases movement speed slightly."),
gray,
"an unmet skill grays even a met threshold"
);
let mut req = PlayerReqState::default();
req.skills.insert(165, 300);
s.set_player_req_state(req);
s.run(r#"TT:SetOwner(Slot10, "ANCHOR_RIGHT"); TT:SetItemById(6303)"#)
.unwrap();
let lines = lines_of(&mut s);
assert_eq!(
color(&lines, "Requires Leatherworking (250)"),
[1.0, 1.0, 1.0, 1.0]
);
assert_eq!(
color(&lines, "(2) Set: Increases movement speed slightly."),
green,
"meeting the skill restores the green"
);
// An unseen set id records the ask-once.
s.set_item_template(
9999,
ItemTemplateView {
name: "Gladiator Piece".into(),
item_set: 1,
..Default::default()
},
);
s.run(r#"TT:SetOwner(Slot10, "ANCHOR_RIGHT"); TT:SetItemById(9999)"#)
.unwrap();
assert_eq!(s.take_item_set_asks(), vec![1], "the set ask recorded");
assert!(s.take_errors().is_empty());
}
@@ -0,0 +1,315 @@
//! The engine spell/aura tooltip channel (decision 0274 P2, law per 0276): the verified line
//! shapes — name|rank, ONE cost|range line, ONE casttime|cooldown line, the passive omission,
//! the aura variant (white description + the SetPlayerBuff-only remaining line), and SetAction's
//! pure delegation.
use super::common::script;
use crate::script::*;
fn fireball() -> SpellTooltipView {
SpellTooltipView {
name: "Fireball".into(),
rank: Some("Rank 1".into()),
cost: Some("30 Mana".into()),
range: Some("30 yd range".into()),
cast_time: Some("1.5 sec cast".into()),
cooldown: None,
description: "Hurls a fiery ball that causes 14 to 22 Fire damage.".into(),
..Default::default()
}
}
/// The spellbook hover: name|rank gray, cost|range one line, casttime line, gold description.
#[test]
fn spellbook_hover_renders_the_verified_shape() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_spell_tooltip(133, fireball());
s.set_spellbook(SpellBookState {
tabs: Vec::new(),
slots: vec![SpellSlotView {
spell_id: 133,
name: "Fireball".into(),
..Default::default()
}],
});
s.run(
r#"
local a = CreateFrame("Button", "SB1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetSpell(1, "spell")
assert(tt:IsShown(), "SetSpell shows")
assert(tt:NumLines() == 4, "name + cost|range + casttime + description, got " .. tt:NumLines())
assert(TTTextLeft1:GetText() == "Fireball")
-- Byte-verified: SetSpell passes showRank=0 — the spellbook hover shows NO rank column
-- (only SetAction shows it).
assert(not TTTextRight1:IsShown(), "no rank column from SetSpell")
assert(TTTextLeft2:GetText() == "30 Mana")
assert(TTTextRight2:GetText() == "30 yd range", "cost|range is ONE line")
assert(TTTextLeft3:GetText() == "1.5 sec cast")
assert(TTTextLeft4:GetText() ~= nil)
"#,
)
.unwrap();
// The description wears the byte-verified gold.
s.resolve();
let quads = s.extract();
let gold = quads.iter().any(|q| {
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
if t.starts_with("Hurls") && (c[1] - 210.0/255.0).abs() < 1e-6)
});
assert!(gold, "spell description is gold");
assert!(s.take_errors().is_empty());
}
/// The tracking icon's hover (SetTrackingSpell): a GOLD name over the white (aura-variant)
/// description — the shape the director's reference A/B pinned (2026-07-20), distinct from
/// SetPlayerBuff's white name. No cost/casttime lines, no duration-remaining line.
#[test]
fn tracking_hover_renders_gold_name_over_white_description() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_tracking(Some(TrackingState {
spell_id: 2580,
name: Some("Find Minerals".into()),
icon: Some("Interface\\Icons\\Trade_Mining".into()),
cancelable: true,
}));
s.set_spell_tooltip(
2580,
SpellTooltipView {
name: "Find Minerals".into(),
cost: Some("a cost cell that must NOT render".into()),
cast_time: Some("nor this".into()),
description: "Finding Minerals.".into(),
aura_description: "Finding Minerals.".into(),
..Default::default()
},
);
s.run(
r#"
local f = CreateFrame("Frame", "TRK"); f:SetPoint("CENTER", 0, 0); f:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(f, "ANCHOR_BOTTOMLEFT")
tt:SetTrackingSpell()
assert(tt:IsShown(), "SetTrackingSpell shows")
assert(tt:NumLines() == 2, "name + description only, got " .. tt:NumLines())
assert(TTTextLeft1:GetText() == "Find Minerals")
assert(TTTextLeft2:GetText() == "Finding Minerals.")
"#,
)
.unwrap();
s.resolve();
let quads = s.extract();
let gold_name = quads.iter().any(|q| {
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
if t == "Find Minerals" && (c[1] - 210.0/255.0).abs() < 1e-6)
});
let white_desc = quads.iter().any(|q| {
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
if t == "Finding Minerals." && (c[1] - 1.0).abs() < 1e-6 && (c[2] - 1.0).abs() < 1e-6)
});
assert!(gold_name, "the tracking name line is GOLD (director A/B)");
assert!(white_desc, "the description is the aura-variant white");
assert!(s.take_errors().is_empty());
}
/// A passive spell omits the casttime|cooldown line whole — never a "Passive" text line.
#[test]
fn passive_omits_the_casttime_line() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_spell_tooltip(
1461,
SpellTooltipView {
name: "Frost Warding".into(),
rank: None,
cost: None,
range: None,
cast_time: None, // passive
cooldown: Some("never shows without a casttime".into()),
description: "Increases your frost ward.".into(),
..Default::default()
},
);
s.set_spellbook(SpellBookState {
tabs: Vec::new(),
slots: vec![SpellSlotView {
spell_id: 1461,
name: "Frost Warding".into(),
..Default::default()
}],
});
s.run(
r#"
local a = CreateFrame("Button", "SB2"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetSpell(1, "spell")
assert(tt:NumLines() == 2, "name + description only, got " .. tt:NumLines())
local all = ""
for i = 1, tt:NumLines() do all = all .. (getglobal("TTTextLeft" .. i):GetText() or "") end
assert(not string.find(all, "Passive"), "no Passive text line")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// The buff hover: the aura variant — WHITE description + the duration-remaining line (only
/// this entry point), computed off the aura's GetTime expiry.
#[test]
fn player_buff_hover_is_the_aura_variant() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_spell_tooltip(
1459,
SpellTooltipView {
name: "Arcane Intellect".into(),
rank: Some("Rank 1".into()),
cost: Some("60 Mana".into()), // ignored by the aura variant
range: Some("30 yd range".into()), // ignored
cast_time: Some("Instant".into()), // ignored
cooldown: None,
description: "Increases Intellect by 2.".into(),
aura_description: "Intellect increased by 2.".into(),
..Default::default()
},
);
s.tick(10.0); // GetTime = 10
s.set_auras(
"player",
Some(vec![AuraState {
spell_id: 1459,
name: Some("Arcane Intellect".into()),
duration: 1800.0,
expiration_time: 100.0, // 90 s left at now = 10
helpful: true, // the HELPFUL-default filter indexes buffs
..Default::default()
}]),
);
s.run(
r#"
local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetPlayerBuff(1)
assert(tt:NumLines() == 3, "name + description + remaining, got " .. tt:NumLines())
assert(TTTextLeft2:GetText() == "Intellect increased by 2.", "the AURA description column")
assert(TTTextLeft3:GetText() == "2 minutes remaining", "got " .. TTTextLeft3:GetText())
"#,
)
.unwrap();
// The aura description is WHITE (the byte-verified aura/spell difference).
s.resolve();
let quads = s.extract();
let white = quads.iter().any(|q| {
matches!(&q.content, QuadContent::Text { text: Some(t), color: Some(c), .. }
if t.starts_with("Intellect increased") && *c == [1.0, 1.0, 1.0, 1.0])
});
assert!(white, "aura description is white, not gold");
assert!(s.take_errors().is_empty());
}
/// The target-frame aura hover: SetUnitBuff/SetUnitDebuff render the aura variant for the token's
/// sign-filtered list — WITHOUT the duration-remaining line (byte-verified: only SetPlayerBuff
/// appends it, and no other unit carries a duration on the 1.12 wire anyway).
#[test]
fn unit_buff_and_debuff_hover_render_the_aura_variant_without_remaining() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_spell_tooltip(
589,
SpellTooltipView {
name: "Shadow Word: Pain".into(),
aura_description: "Suffering 12 Shadow damage every 3 sec.".into(),
..Default::default()
},
);
s.set_spell_tooltip(
1126,
SpellTooltipView {
name: "Mark of the Wild".into(),
aura_description: "Armor increased by 25.".into(),
..Default::default()
},
);
s.set_auras(
"target",
Some(vec![
AuraState {
spell_id: 1126,
name: Some("Mark of the Wild".into()),
helpful: true,
..Default::default()
},
AuraState {
spell_id: 589,
name: Some("Shadow Word: Pain".into()),
helpful: false,
..Default::default()
},
]),
);
s.run(
r#"
local a = CreateFrame("Button", "TF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_BOTTOMRIGHT", 15, -25)
tt:SetUnitDebuff("target", 1)
assert(tt:NumLines() == 2, "name + description, NO remaining line, got " .. tt:NumLines())
assert(TTTextLeft1:GetText() == "Shadow Word: Pain", "the sign filter picks the debuff")
assert(TTTextLeft2:GetText() == "Suffering 12 Shadow damage every 3 sec.")
tt:SetOwner(a, "ANCHOR_BOTTOMRIGHT", 15, -25)
tt:SetUnitBuff("target", 1)
assert(TTTextLeft1:GetText() == "Mark of the Wild", "the sign filter picks the buff")
tt:SetOwner(a, "ANCHOR_BOTTOMRIGHT", 15, -25)
tt:SetUnitDebuff("target", 2)
assert(tt:NumLines() == 0 and not tt:IsShown(), "past the list: no empty plate")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// SetAction delegates by payload kind: a SPELL slot renders the spell view; an empty slot shows
/// nothing; a miss records the ask.
#[test]
fn action_hover_delegates_by_kind() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_spell_tooltip(133, fireball());
s.set_action(
24,
Some(ActionSlot {
kind: 0x00, // SPELL
action: 133,
..Default::default()
}),
);
s.set_action(
25,
Some(ActionSlot {
kind: 0x00,
action: 5143, // not in the store — the ask channel fires
..Default::default()
}),
);
s.run(
r#"
local a = CreateFrame("Button", "AB1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetAction(24)
assert(TTTextLeft1:GetText() == "Fireball", "spell slot renders the spell view")
assert(TTTextRight1:GetText() == "Rank 1", "SetAction shows the rank column")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetAction(25)
assert(tt:NumLines() == 0 and not tt:IsShown(), "a total miss shows no empty plate")
"#,
)
.unwrap();
assert_eq!(s.take_spell_tooltip_asks(), vec![5143], "the miss asked");
assert!(s.take_errors().is_empty());
}
@@ -0,0 +1,388 @@
//! The engine unit tooltip builder (decision 0274 P3, law per 0276): the level-line composition
//! (four templates, the rank words, "??", Corpse, "Race Class (Player)"), the flag lines
//! (PvP white / Skinnable red / Civilian green), the world-mouseover drive (default anchor +
//! `UPDATE_MOUSEOVER_UNIT` recolor + the fade arm on loss), and the health-bar watcher.
use super::common::script;
use crate::script::*;
fn wolf() -> UnitState {
UnitState {
exists: true,
name: Some("Timber Wolf".into()),
health: 30,
max_health: 50,
level: 10,
reaction: 2, // hostile
creature_type_name: Some("Beast".into()),
rank: 2, // rare-elite prints ELITE (the byte table)
skinnable: true,
..Default::default()
}
}
/// The creature law: gold name, subtitle, "Level 10 Beast (Elite)", red Skinnable.
#[test]
fn creature_line_law() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
let mut u = wolf();
u.subtitle = Some("Alpha".into());
s.set_unit("target", Some(u));
s.set_player_req_state(PlayerReqState {
level: 12,
..Default::default()
});
s.run(
r#"
local a = CreateFrame("Button", "UF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
assert(tt:SetUnit("target") == 1, "SetUnit returns 1 on a live unit")
assert(TTTextLeft1:GetText() == "Timber Wolf")
assert(TTTextLeft2:GetText() == "Alpha")
assert(TTTextLeft3:GetText() == "Level 10 Beast (Elite)", "got " .. TTTextLeft3:GetText())
assert(TTTextLeft4:GetText() == "Skinnable")
assert(tt:SetUnit("nosuch") == nil, "a missing unit answers nil")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// The faction-name line sits between the level line and "PvP" (the builder-tail block the §2
/// order omitted — the director's Marshal McBride reference: Level, Stormwind, PvP); the
/// CIVILIAN line is the dishonorable-kill warning, whole gate (`0x612550`): PvP bit + civilian
/// flag + HOSTILE + GREY/trivial — a friendly (or non-grey) civilian never shows it; LEADER
/// (white) needs only the PvP bit + the flag (`0x6125c0`).
#[test]
fn faction_line_and_civilian_gate() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_player_req_state(PlayerReqState {
level: 30,
..Default::default()
});
// The reference shot's shape: a FRIENDLY civilian guard — faction line, PvP, NO Civilian.
s.set_unit(
"target",
Some(UnitState {
exists: true,
name: Some("Marshal McBride".into()),
level: 20,
reaction: 6, // friendly
creature_type_name: Some("Humanoid".into()),
faction_name: Some("Stormwind".into()),
pvp: true,
civilian: true,
..Default::default()
}),
);
s.run(
r#"
local a = CreateFrame("Button", "UF9"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetUnit("target")
assert(TTTextLeft2:GetText() == "Level 20", "friendly creature: no type word; got " .. TTTextLeft2:GetText())
assert(TTTextLeft3:GetText() == "Stormwind", "faction line before PvP; got " .. TTTextLeft3:GetText())
assert(TTTextLeft4:GetText() == "PvP")
assert(TTTextLeft5 == nil or TTTextLeft5:GetText() == nil, "friendly civilian shows NO Civilian line")
"#,
)
.unwrap();
// The warning case: HOSTILE + grey (level 20 vs player 30 → gap 10 > band 8) + PvP-flagged.
s.set_unit(
"target",
Some(UnitState {
exists: true,
name: Some("Defias Civilian".into()),
level: 20,
reaction: 2, // hostile
creature_type_name: Some("Humanoid".into()),
pvp: true,
civilian: true,
racial_leader: true,
..Default::default()
}),
);
s.run(
r#"
TT:SetOwner(UF9, "ANCHOR_RIGHT")
TT:SetUnit("target")
assert(TTTextLeft2:GetText() == "Level 20 Humanoid", "got " .. TTTextLeft2:GetText())
assert(TTTextLeft3:GetText() == "PvP")
assert(TTTextLeft4:GetText() == "Civilian", "hostile+grey+pvp civilian warns; got " .. TTTextLeft4:GetText())
assert(TTTextLeft5:GetText() == "Leader")
"#,
)
.unwrap();
// Same unit but NOT grey (level 25, gap 5 ≤ band 8): the warning drops.
s.set_unit(
"target",
Some(UnitState {
exists: true,
name: Some("Defias Civilian".into()),
level: 25,
reaction: 2,
creature_type_name: Some("Humanoid".into()),
pvp: true,
civilian: true,
..Default::default()
}),
);
s.run(
r#"
TT:SetOwner(UF9, "ANCHOR_RIGHT")
TT:SetUnit("target")
assert(TTTextLeft3:GetText() == "PvP")
assert(TTTextLeft4 == nil or TTTextLeft4:GetText() == nil, "a non-grey civilian does not warn")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// Players read "Race Class (Player)"; the dead read "Corpse"; a world boss reads "??".
#[test]
fn level_line_variants() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_player_req_state(PlayerReqState {
level: 60,
..Default::default()
});
s.set_unit(
"target",
Some(UnitState {
exists: true,
name: Some("Bandit".into()),
level: 32,
is_player: true,
race: Some("Human".into()),
class: Some("Rogue".into()),
pvp: true,
..Default::default()
}),
);
s.run(
r#"
local a = CreateFrame("Button", "UF2"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetUnit("target")
assert(TTTextLeft2:GetText() == "Level 32 Human Rogue (Player)", "got " .. TTTextLeft2:GetText())
assert(TTTextLeft3:GetText() == "PvP")
"#,
)
.unwrap();
// Dead: the class slot becomes Corpse.
s.set_unit(
"target",
Some(UnitState {
exists: true,
name: Some("Slain Wolf".into()),
level: 3,
dead: true,
reaction: 2,
creature_type_name: Some("Beast".into()),
..Default::default()
}),
);
s.run(
r#"
TT:SetOwner(UF2, "ANCHOR_RIGHT")
TT:SetUnit("target")
assert(TTTextLeft2:GetText() == "Level 3 Corpse", "got " .. TTTextLeft2:GetText())
"#,
)
.unwrap();
// A world boss (rank 3) reads "??" + (Boss).
s.set_unit(
"target",
Some(UnitState {
exists: true,
name: Some("Kazzak".into()),
level: 63,
reaction: 2,
creature_type_name: Some("Demon".into()),
rank: 3,
..Default::default()
}),
);
s.run(
r#"
TT:SetOwner(UF2, "ANCHOR_RIGHT")
TT:SetUnit("target")
assert(TTTextLeft2:GetText() == "Level ?? Demon (Boss)", "got " .. TTTextLeft2:GetText())
"#,
)
.unwrap();
// The "??" gate, byte-pinned: HOSTILE (reaction ≤ 2) + 10 levels up reads "??"…
let ten_up = |reaction: u8, is_player: bool| UnitState {
exists: true,
name: Some("Elder".into()),
level: 70,
reaction,
is_player,
creature_type_name: (!is_player).then(|| "Beast".into()),
race: is_player.then(|| "Orc".into()),
class: is_player.then(|| "Shaman".into()),
..Default::default()
};
s.set_unit("target", Some(ten_up(2, false)));
s.run(
r#"
TT:SetOwner(UF2, "ANCHOR_RIGHT"); TT:SetUnit("target")
assert(TTTextLeft2:GetText() == "Level ?? Beast", "hostile 10-up, got " .. TTTextLeft2:GetText())
"#,
)
.unwrap();
// …but UNFRIENDLY (reaction 3) does not — the internal bound is hated/hostile only…
s.set_unit("target", Some(ten_up(3, false)));
s.run(
r#"
TT:SetOwner(UF2, "ANCHOR_RIGHT"); TT:SetUnit("target")
assert(TTTextLeft2:GetText() == "Level 70 Beast", "unfriendly 10-up, got " .. TTTextLeft2:GetText())
"#,
)
.unwrap();
// …and PLAYERS never read "??" regardless of reaction/delta.
s.set_unit("target", Some(ten_up(2, true)));
s.run(
r#"
TT:SetOwner(UF2, "ANCHOR_RIGHT"); TT:SetUnit("target")
assert(TTTextLeft2:GetText() == "Level 70 Orc Shaman (Player)", "hostile player, got " .. TTTextLeft2:GetText())
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// The world-mouseover drive: `world_tooltip_unit` fires the default-anchor script, renders,
/// fires `UPDATE_MOUSEOVER_UNIT` (the Lua recolor), and the health bar tracks later pushes;
/// hover loss arms the fade (alpha ramps, then hides).
#[test]
fn world_hover_drive_and_health_watcher() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_unit("mouseover", Some(wolf()));
s.run(
r#"
anchored, recolored = 0, 0
-- A real full-screen UIParent, like the shipped UIParent.xml provides: the default-anchor
-- handler must do the REAL seating (a counter-only stub is exactly the hole that let the
-- unwired live handler park the plate at screen center).
UIParent = CreateFrame("Frame", "UIParent"); UIParent:SetAllPoints()
local tt = CreateFrame("GameTooltip", "GameTooltip")
tt:Hide()
-- The XML wiring, test-side: default anchor + the reaction recolor + a status bar child.
tt:SetScript("OnTooltipSetDefaultAnchor", function()
anchored = anchored + 1
GameTooltip:SetOwner(UIParent, "ANCHOR_NONE")
GameTooltip:SetPoint("BOTTOMRIGHT", "UIParent", "BOTTOMRIGHT", -13, 70)
end)
tt:RegisterEvent("UPDATE_MOUSEOVER_UNIT")
tt:SetScript("OnEvent", function()
recolored = recolored + 1
getglobal("GameTooltipTextLeft1"):SetTextColor(1, 0, 0)
end)
local bar = CreateFrame("StatusBar", "GameTooltipStatusBar", tt)
bar:SetPoint("TOPLEFT", tt, "BOTTOMLEFT", 2, -1); bar:SetSize(100, 8)
"#,
)
.unwrap();
assert!(s.world_tooltip_unit("mouseover"), "the hover shows");
s.run(
r#"
assert(anchored == 1, "default anchor fired")
assert(recolored == 1, "UPDATE_MOUSEOVER_UNIT fired")
assert(GameTooltip:IsShown())
assert(GameTooltipTextLeft1:GetText() == "Timber Wolf")
local _, mx = GameTooltipStatusBar:GetMinMaxValues()
assert(mx == 50 and GameTooltipStatusBar:GetValue() == 30, "bar seeded from the snapshot")
"#,
)
.unwrap();
// The handler's seating resolved: bottom-right of the 800×600 screen, −13 in, 70 up.
// (Answer the line measures first — the auto-size needs them before the plate has a rect.)
let answers: Vec<(u32, f32, f32, u64)> = s
.fontstrings_needing_measure()
.iter()
.map(|r| (r.id, 80.0, 10.0, r.key))
.collect();
s.set_measured_text(&answers);
s.resolve();
s.run(
r#"assert(GameTooltip:GetRight() == 787 and GameTooltip:GetBottom() == 70,
"plate at the default corner, got " .. tostring(GameTooltip:GetRight()) .. "," .. tostring(GameTooltip:GetBottom()))"#,
)
.unwrap();
// A health push for the LIVE token re-drives the bar without a rebuild.
let mut hurt = wolf();
hurt.health = 12;
s.set_unit("mouseover", Some(hurt));
s.run(
r#"assert(GameTooltipStatusBar:GetValue() == 12, "the health watcher tracked the push")"#,
)
.unwrap();
// Hover loss: the fade arms; past the ramp the tooltip hides.
s.world_tooltip_fade();
s.tick(0.6);
s.run(r#"assert(not GameTooltip:IsShown(), "faded out after the ramp")"#)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// The minimap BLIP tooltip (`minimap_tooltip`): refuses without a Minimap widget; with one, one
/// white line seated against the widget (the INTERIM ANCHOR_LEFT law), world-owned so the shared
/// fade arm hides it on hover loss.
#[test]
fn minimap_blip_tooltip_shows_and_fades() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(r#"CreateFrame("GameTooltip", "GameTooltip")"#)
.unwrap();
assert!(
!s.minimap_tooltip("Stormwind", 700.0, 500.0, false),
"no UIParent yet: the show refuses"
);
s.run(
r#"
local up = CreateFrame("Frame", "UIParent")
up:SetPoint("BOTTOMLEFT"); up:SetSize(800, 600)
"#,
)
.unwrap();
assert!(s.minimap_tooltip("Stormwind", 795.0, 500.0, false));
s.run(
r#"
assert(GameTooltipTextLeft1:GetText() == "Stormwind")
assert(GameTooltip:IsShown(), "the blip tooltip shows")
"#,
)
.unwrap();
// Seated 5 px from the 800-wide screen's right edge: the clamp (the client's G bit4)
// slides the plate back inside instead of letting it clip (director-caught).
let answers: Vec<(u32, f32, f32, u64)> = s
.fontstrings_needing_measure()
.iter()
.map(|r| (r.id, 80.0, 10.0, r.key))
.collect();
s.set_measured_text(&answers);
s.resolve();
s.run(
r#"assert(GameTooltip:GetRight() <= 800, "clamped inside the screen, got " .. tostring(GameTooltip:GetRight()))"#,
)
.unwrap();
// Follow: a move re-seats without rebuilding the line.
s.world_tooltip_move(400.0, 300.0);
s.run(r#"assert(GameTooltipTextLeft1:GetText() == "Stormwind", "content survives a move")"#)
.unwrap();
// Hover loss rides the shared world fade: armed, then hidden past the ramp.
s.world_tooltip_fade();
s.tick(0.6);
s.run(r#"assert(not GameTooltip:IsShown(), "faded out after the ramp")"#)
.unwrap();
assert!(s.take_errors().is_empty());
}
@@ -0,0 +1,66 @@
//! Show/Hide fire OnShow/OnHide exactly on effective transitions; OnUpdate ticks gate on visibility.
use super::common::script;
#[test]
fn show_hide_fires_only_on_effective_transitions() {
let s = script();
s.run(
r#"
shows, hides = 0, 0
local f = CreateFrame("Frame", "Vis")
f:SetScript("OnShow", function() shows = shows + 1 end)
f:SetScript("OnHide", function() hides = hides + 1 end)
f:Hide() -- true→false: OnHide (1)
f:Hide() -- no transition: nothing
f:Show() -- false→true: OnShow (1)
f:Show() -- no transition: nothing
"#,
)
.unwrap();
let (shows, hides): (i64, i64) = s.eval("return shows, hides").unwrap();
assert_eq!((shows, hides), (1, 1));
assert!(s.errors().is_empty(), "no script errors: {:?}", s.errors());
}
#[test]
fn mid_tree_hide_fires_onhide_for_the_subtree() {
let s = script();
s.run(
r#"
childhides = 0
local p = CreateFrame("Frame", "PH")
local c = CreateFrame("Frame", "CH", p)
c:SetScript("OnHide", function() childhides = childhides + 1 end)
p:Hide() -- child loses effective visibility though its own shown stays true
"#,
)
.unwrap();
let hides: i64 = s.eval("return childhides").unwrap();
assert_eq!(hides, 1);
}
#[test]
fn tick_runs_onupdate_only_when_effectively_visible() {
let mut s = script();
s.run(
r#"
ticks, last = 0, 0
local f = CreateFrame("Frame", "UF")
f:SetScript("OnUpdate", function(self, elapsed) ticks = ticks + 1; last = elapsed end)
"#,
)
.unwrap();
s.tick(0.25);
assert_eq!(s.eval::<i64>("return ticks").unwrap(), 1);
assert!((s.eval::<f64>("return last").unwrap() - 0.25).abs() < 1e-6);
s.run("UF:Hide()").unwrap();
s.tick(0.25); // hidden → no fire
assert_eq!(s.eval::<i64>("return ticks").unwrap(), 1);
s.run("UF:Show()").unwrap();
s.tick(0.25); // visible again → fires
assert_eq!(s.eval::<i64>("return ticks").unwrap(), 2);
}
@@ -0,0 +1,346 @@
//! The world-map bindings (decision 0203 phase 2): catalog/feed pushes, the engine-owned
//! selection, and the deferred WORLD_MAP_UPDATE queue.
use super::common::script;
use crate::script::*;
/// A two-continent catalog shaped like the real 5875 one (Kalimdor first — the app's push order
/// defines every Lua-visible index; the sheet rects are the real disjoint kernel outputs). The
/// EK zone grid is sparse: exactly the real Goldshire cell (12735, verified against the shipped
/// Azeroth.zmp) marked as zone 1.
fn push_catalog(s: &mut UiScript) {
let mut ek_grid = vec![0u16; 128 * 128];
ek_grid[12735] = 1;
// Cell 8801 = the child (0.5, 0.5) resolves to through Elwynn's OWN loc rect — a "Stormwind
// City" peer child. Clicking it from the Elwynn zone map drills into the city (the §3 path).
ek_grid[8801] = 2;
s.set_world_map_catalog(vec![
WorldMapContinentView {
name: "Kalimdor".into(),
map_file: "Kalimdor".into(),
world_rect: (0.08882, 0.07910, 0.40020, 0.86952),
loc_rect: (17066.6, -19733.2, 12799.9, -11733.3),
zone_grid: Vec::new(),
zones: vec![
WorldMapZoneView {
name: "Durotar".into(),
area_id: 14,
map_file: "Durotar".into(),
loc_rect: (-1800.0, -6800.0, -3800.0, -8500.0),
overlays: Vec::new(),
},
WorldMapZoneView {
name: "The Barrens".into(),
area_id: 17,
map_file: "Barrens".into(),
loc_rect: (-1000.0, -11000.0, -5500.0, -17000.0),
overlays: Vec::new(),
},
],
},
WorldMapContinentView {
name: "Eastern Kingdoms".into(),
map_file: "Azeroth".into(),
world_rect: (0.62375, 0.02695, 0.92315, 0.87126),
loc_rect: (16000.0, -19199.9, 7466.6, -16000.0),
zone_grid: ek_grid,
zones: vec![
WorldMapZoneView {
name: "Elwynn Forest".into(),
area_id: 12,
map_file: "Elwynn".into(),
// A clean synthetic loc rect inside the EK continent rect: w=2000, h=1500 → the
// continent-highlight formula lands on round-ish expected coords (asserted below).
loc_rect: (-8000.0, -10000.0, -400.0, -1900.0),
// Two overlays shaped like real Elwynn rows: Northshire reveals on explore bit
// 125, Goldshire on bit 124 (the real AreaTable exploreFlags).
overlays: vec![
WorldMapOverlayView {
texture: "Interface\\WorldMap\\Elwynn\\NORTHSHIREVALLEY".into(),
width: 200,
height: 176,
offset_x: 490,
offset_y: 200,
explore_bits: vec![125],
},
WorldMapOverlayView {
texture: "Interface\\WorldMap\\Elwynn\\GOLDSHIRE".into(),
width: 172,
height: 128,
offset_x: 380,
offset_y: 320,
explore_bits: vec![124],
},
],
},
// A city peer of Elwynn — a WorldMapArea child of the continent with its own art
// folder, indistinguishable from a zone in code (wow-re 15b2a8ea Part 2a).
WorldMapZoneView {
name: "Stormwind City".into(),
area_id: 1519,
map_file: "StormwindCity".into(),
loc_rect: (-8200.0, -8900.0, -400.0, -1100.0),
overlays: Vec::new(),
},
],
},
]);
}
/// The navigation surface: lists come from the catalog, SetMapZoom's selection reads back
/// synchronously in the same Lua breath (the reference's dropdown-click contract), zone/continent
/// arguments clamp, and GetMapInfo names the displayed art folder (nil at the world level — the
/// reference Lua's own "World" fallback).
#[test]
fn worldmap_navigation_and_map_info() {
let mut s = script();
push_catalog(&mut s);
s.run(
r#"
conts = { GetMapContinents() }
kalimdor_zones = { GetMapZones(1) }
"#,
)
.unwrap();
assert_eq!(
s.eval::<(String, String)>("return conts[1], conts[2]")
.unwrap(),
("Kalimdor".into(), "Eastern Kingdoms".into()),
"display names are the Map.dbc localized ones, not the art folders"
);
assert_eq!(
s.eval::<(String, String)>("return kalimdor_zones[1], kalimdor_zones[2]")
.unwrap(),
("Durotar".into(), "The Barrens".into())
);
// World level by default: continent 0, nil map info.
assert_eq!(
s.eval::<(i64, i64)>("return GetCurrentMapContinent(), GetCurrentMapZone()")
.unwrap(),
(0, 0)
);
assert!(s.eval::<bool>("return GetMapInfo() == nil").unwrap());
// The synchronous read-back the reference relies on, and the art-folder name per level.
s.run("SetMapZoom(2)").unwrap();
assert_eq!(
s.eval::<(i64, i64, String)>(
"return GetCurrentMapContinent(), GetCurrentMapZone(), GetMapInfo()"
)
.unwrap(),
(2, 0, "Azeroth".into())
);
s.run("SetMapZoom(1, 2)").unwrap();
assert_eq!(
s.eval::<String>("return GetMapInfo()").unwrap(),
"Barrens",
"a zone selection names the ZONE's art folder"
);
// Out-of-range arguments clamp (never a Lua error — addons pass garbage).
s.run("SetMapZoom(9, 9)").unwrap();
assert_eq!(
s.eval::<(i64, i64)>("return GetCurrentMapContinent(), GetCurrentMapZone()")
.unwrap(),
(2, 2),
"continent clamps to the catalog, zone to that continent's list (EK has 2 zones)"
);
}
/// SetMapToCurrentZone lands on the app-fed player zone; the feed's projection + facing surface
/// through GetPlayerMapPosition/GetPlayerFacing (and only for "player" — party is a later arc).
#[test]
fn worldmap_current_zone_and_player_feed() {
let mut s = script();
push_catalog(&mut s);
s.set_world_map_feed(Some((1, 2)), Some((0.25, 0.75)), 1.5, None);
s.run("SetMapToCurrentZone()").unwrap();
assert_eq!(
s.eval::<(i64, i64)>("return GetCurrentMapContinent(), GetCurrentMapZone()")
.unwrap(),
(1, 2)
);
let (x, y) = s
.eval::<(f64, f64)>(r#"return GetPlayerMapPosition("player")"#)
.unwrap();
assert!((x - 0.25).abs() < 1e-6 && (y - 0.75).abs() < 1e-6);
assert!((s.eval::<f64>("return GetPlayerFacing()").unwrap() - 1.5).abs() < 1e-6);
assert_eq!(
s.eval::<(f64, f64)>(r#"return GetPlayerMapPosition("party1")"#)
.unwrap(),
(0.0, 0.0),
"non-player units are off-map until the party arc"
);
// No feed → the world sheet (never an error).
s.set_world_map_feed(None, None, 0.0, None);
s.run("SetMapToCurrentZone()").unwrap();
assert_eq!(s.eval::<i64>("return GetCurrentMapContinent()").unwrap(), 0);
}
/// World-level ProcessMapClick picks the continent whose sheet-rect contains the click (the
/// 0x4a7100 AABB walk — the real kernel rects are disjoint); continent-level clicks resolve
/// through the 0x4a6ec0 zone grid; hover names ride the same cell law.
#[test]
fn worldmap_click_containment_and_zone_grid() {
let mut s = script();
push_catalog(&mut s);
// Mid-Kalimdor on the world sheet.
s.run("ProcessMapClick(0.25, 0.45)").unwrap();
assert_eq!(s.eval::<i64>("return GetCurrentMapContinent()").unwrap(), 1);
// Kalimdor ships no grid in this fixture: continent-level clicks are inert.
s.run("ProcessMapClick(0.5, 0.5)").unwrap();
assert_eq!(
s.eval::<(i64, i64)>("return GetCurrentMapContinent(), GetCurrentMapZone()")
.unwrap(),
(1, 0)
);
// Back at the world level, EK land picks continent 2.
s.run("SetMapZoom(0)").unwrap();
s.run("ProcessMapClick(0.72, 0.63)").unwrap();
assert_eq!(s.eval::<i64>("return GetCurrentMapContinent()").unwrap(), 2);
// Continent level: Goldshire's UV inside the EK rect lands on grid cell 12735 (the real
// Azeroth.zmp index for that world position) → zone 1. The zone lights up: name + fileName +
// the six geometry values (wow-re 15b2a8ea continent branch), all against the zone's loc rect
// within the continent. cont_w=35199.9, cont_h=23466.6; w=2000, h=1500.
let (name, file, tpx, tpy, tx, ty, sx, sy) = s
.eval::<(String, String, f64, f64, f64, f64, f64, f64)>(
"return UpdateMapHighlight(0.452843, 0.720880)",
)
.unwrap();
assert_eq!(name, "Elwynn Forest");
assert_eq!(
file, "Elwynn",
"fileName = the zone art folder → <file>Highlight"
);
assert_eq!(
tpx, 1.0,
"continent-branch texPercentageX is the constant 1.0"
);
assert!(
(tpy - 0.75).abs() < 1e-6,
"texPercentageY = dim/potdim = 96/128 = 0.75, got {tpy}"
);
assert!(
(tx - 2000.0 / 35199.9).abs() < 1e-4,
"textureX = w/contW, got {tx}"
);
assert!(
(ty - 1500.0 / 23466.6).abs() < 1e-4,
"textureY = h/contH, got {ty}"
);
assert!(
(sx - 24000.0 / 35199.9).abs() < 1e-4,
"scrollChildX = (contX0-zoneX0)/contW, got {sx}"
);
assert!(
(sy - 7866.6 / 23466.6).abs() < 1e-4,
"scrollChildY = (contY0-zoneY0)/contH, got {sy}"
);
// Ocean at continent level: off the grid → the nil/zero tail (frame hides the quad).
assert!(s
.eval::<Option<String>>("return UpdateMapHighlight(0.99, 0.01)")
.unwrap()
.is_none());
s.run("ProcessMapClick(0.452843, 0.720880)").unwrap();
assert_eq!(
s.eval::<(i64, i64)>("return GetCurrentMapContinent(), GetCurrentMapZone()")
.unwrap(),
(2, 1),
"the grid click drills into the zone"
);
// Now at the ZONE level (Elwynn): a click re-expressed through ELWYNN's own rect lands on
// cell 8801 = the Stormwind City peer child → drills into the city map. This is the path that
// was a no-op before (wow-re 15b2a8ea Part 2: continent- and zone-level clicks run identical
// code, only the windowing rect differs).
s.run("ProcessMapClick(0.5, 0.5)").unwrap();
assert_eq!(
s.eval::<(i64, i64)>("return GetCurrentMapContinent(), GetCurrentMapZone()")
.unwrap(),
(2, 2),
"a zone-level click on a city footprint drills into the city"
);
// Open ocean at the world level: outside both rects, no selection change.
s.run("SetMapZoom(0)").unwrap();
s.run("ProcessMapClick(0.01, 0.99)").unwrap();
assert_eq!(s.eval::<i64>("return GetCurrentMapContinent()").unwrap(), 0);
}
/// SetMapZoom queues WORLD_MAP_UPDATE through the pending-event queue: nothing fires inside the
/// call (a binding can't re-enter dispatch), the registered frame hears it on the next tick.
#[test]
fn worldmap_update_event_fires_on_next_tick() {
let mut s = script();
push_catalog(&mut s);
// Drain the catalog push's own queued event first.
s.tick(0.01);
s.run(
r#"
heard = 0
f = CreateFrame("Frame", "MapListener")
f:RegisterEvent("WORLD_MAP_UPDATE")
f:SetScript("OnEvent", function() heard = heard + 1 end)
SetMapZoom(1)
"#,
)
.unwrap();
assert_eq!(
s.eval::<i64>("return heard").unwrap(),
0,
"nothing fires inside the SetMapZoom call itself"
);
s.tick(0.01);
assert_eq!(s.eval::<i64>("return heard").unwrap(), 1);
s.tick(0.01);
assert_eq!(
s.eval::<i64>("return heard").unwrap(),
1,
"the queue drains — no re-fire"
);
}
/// The exploration fog: overlays reveal per the pushed bitset — none before any push, the
/// matching subset after (bit 125 = Northshire in the fixture), the full info tuple comes back,
/// and re-pushing the same bitset queues no extra repaint.
#[test]
fn worldmap_overlays_reveal_by_explored_bits() {
let mut s = script();
push_catalog(&mut s);
s.run("SetMapZoom(2, 1)").unwrap(); // the Elwynn zone map (EK's only fixture zone)
// Nothing explored: the zone shows no overlays (all parchment).
assert_eq!(s.eval::<i64>("return GetNumMapOverlays()").unwrap(), 0);
// Bit 125 set (word 3, bit 29): Northshire reveals, Goldshire stays fogged.
let mut explored = vec![0u32; 64];
explored[3] = 1 << 29;
s.set_world_map_explored(explored);
assert_eq!(s.eval::<i64>("return GetNumMapOverlays()").unwrap(), 1);
let (tex, w, h, ox, oy) = s
.eval::<(String, i64, i64, i64, i64)>("return GetMapOverlayInfo(1)")
.unwrap();
assert_eq!(tex, "Interface\\WorldMap\\Elwynn\\NORTHSHIREVALLEY");
assert_eq!((w, h, ox, oy), (200, 176, 490, 200));
// Both bits: both overlays, catalog order; out-of-range asks read nil.
let mut explored = vec![0u32; 64];
explored[3] = (1 << 29) | (1 << 28);
s.set_world_map_explored(explored);
assert_eq!(s.eval::<i64>("return GetNumMapOverlays()").unwrap(), 2);
assert!(s
.eval::<bool>("return GetMapOverlayInfo(3) == nil")
.unwrap());
// At the continent level the overlay family reads empty (fog is a zone-map thing).
s.run("SetMapZoom(2)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumMapOverlays()").unwrap(), 0);
}