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:
@@ -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());
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user