Texture and FontString stop answering each other's methods
- the one shared region table splits into the client's own maps: a Texture no longer answers SetText, a FontString no longer answers SetTexture, and feature-detecting addons see the true surface - CreateTitleRegion lands whole — object and drag — so an options window that is its own drag handle actually moves - font-object names resolve whatever their case, and the CursorUpdate/CursorOnUpdate pair arrives for the container-item templates - GetStringHeight is pruned (1.12 has no such method) and SetPortraitToTexture moves to the global it really is Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -412,3 +412,179 @@ fn start_sizing_moves_the_gripped_edge_and_plants_the_other() {
|
||||
"StartSizing must refuse a frame that is not resizable"
|
||||
);
|
||||
}
|
||||
|
||||
/// **`CreateTitleRegion` / `GetTitleRegion` — the object half** (wow-re
|
||||
/// `widget-api-batch-benilla.md` Q6, `0x773910` / `0x773820`).
|
||||
///
|
||||
/// Four details, each one a coin-flip a reimplementation loses, and each one asserted.
|
||||
#[test]
|
||||
fn a_title_region_is_a_plain_region_and_creating_it_twice_is_destructive() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
TFrame = CreateFrame("Frame", "TFrame")
|
||||
TFrame:SetPoint("BOTTOMLEFT", 100, 100)
|
||||
TFrame:SetSize(200, 80)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// GetTitleRegion answers ONE value and it is nil — not zero values, which is the asymmetry
|
||||
// Q6 flags against `GetBackdrop`.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', TFrame:GetTitleRegion())")
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert!(s
|
||||
.eval::<Option<bool>>("return TFrame:GetTitleRegion() ~= nil and true or nil")
|
||||
.unwrap()
|
||||
.is_none());
|
||||
|
||||
// It is a plain REGION — not a Texture wearing a hat.
|
||||
s.run("TR = TFrame:CreateTitleRegion()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return TR:GetObjectType()").unwrap(),
|
||||
"Region"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return TR:IsObjectType('Region')").unwrap(),
|
||||
1
|
||||
);
|
||||
assert!(s
|
||||
.eval::<Option<i64>>("return TR:IsObjectType('Texture')")
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert!(s
|
||||
.eval::<bool>("return TFrame:GetTitleRegion() == TR")
|
||||
.unwrap());
|
||||
|
||||
// **It takes NO argument**: `CreateTitleRegion(frame)` is the no-arg call, which is exactly
|
||||
// what `CustomNameplates/options.lua:73` writes.
|
||||
assert!(s
|
||||
.eval::<bool>("return TFrame:CreateTitleRegion(TFrame) == TR")
|
||||
.unwrap());
|
||||
|
||||
// **Idempotent, and DESTRUCTIVELY so.** A second call returns the same object after clearing
|
||||
// its anchors — so it silently wipes the points a caller already set.
|
||||
s.run("TR:SetAllPoints(TFrame)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<i64>("return TR:GetNumPoints()").unwrap(), 2);
|
||||
assert!(s
|
||||
.eval::<bool>("return TFrame:CreateTitleRegion() == TR")
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return TR:GetNumPoints()").unwrap(),
|
||||
0,
|
||||
"the second call ran ClearAllPoints on the region it returned"
|
||||
);
|
||||
|
||||
// **It answers the 19 Region methods and NOTHING else** — 1250 §5's named divergence, closed.
|
||||
// Q6: no Show/Hide, no scripts, no textures. Ours used to share one metatable with Texture and
|
||||
// FontString, so a title region answered `SetTexture` where the reference raises.
|
||||
for name in crate::script::REGION_MAP_METHODS {
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return type(TR.{name})"))
|
||||
.unwrap(),
|
||||
"function",
|
||||
"a title region answers {name}"
|
||||
);
|
||||
}
|
||||
for absent in [
|
||||
"SetTexture",
|
||||
"GetTexture",
|
||||
"SetTexCoord",
|
||||
"SetVertexColor",
|
||||
"SetDrawLayer",
|
||||
"Show",
|
||||
"Hide",
|
||||
"IsShown",
|
||||
"IsVisible",
|
||||
"SetText",
|
||||
"GetText",
|
||||
"SetAlpha",
|
||||
] {
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return type(TR.{absent})"))
|
||||
.unwrap(),
|
||||
"nil",
|
||||
"a title region must NOT answer {absent} — the reference raises there"
|
||||
);
|
||||
}
|
||||
// It never draws: a title region is a hit rectangle, not a visual.
|
||||
s.run("TR:SetAllPoints(TFrame)").unwrap();
|
||||
s.resolve();
|
||||
let region_quads: Vec<_> = s
|
||||
.extract()
|
||||
.into_iter()
|
||||
.filter(|q| matches!(q.target, crate::script::ZTarget::Region(_)))
|
||||
.collect();
|
||||
assert!(
|
||||
region_quads.is_empty(),
|
||||
"a title region emits no quad of its own (the frame's own quad is not one): {region_quads:?}"
|
||||
);
|
||||
|
||||
// The full region kinds are untouched by the split — asserted LAST, because creating a real
|
||||
// texture would otherwise put a legitimate quad into the check above.
|
||||
s.run("TTex = TFrame:CreateTexture(nil, 'ARTWORK')")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return type(TTex.SetTexture)").unwrap(),
|
||||
"function",
|
||||
"a Texture still answers its own verbs"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The drag half — and the three things that make it not just `StartMoving`.**
|
||||
///
|
||||
/// A mouse-down inside the title region starts a move that (1) SWALLOWS `OnMouseDown`, (2) ends on
|
||||
/// release where a scripted move persists, and (3) does not consult `SetMovable`.
|
||||
#[test]
|
||||
fn a_title_region_drag_swallows_the_press_and_ends_on_release() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
downs = 0
|
||||
TP = CreateFrame("Frame", "TP")
|
||||
TP:SetPoint("BOTTOMLEFT", 100, 100)
|
||||
TP:SetSize(200, 80)
|
||||
TP:EnableMouse(true)
|
||||
TP:SetScript("OnMouseDown", function() downs = downs + 1 end)
|
||||
TP:CreateTitleRegion():SetAllPoints(TP)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let corner = |s: &mut UiScript| {
|
||||
s.resolve();
|
||||
s.eval::<f64>("return TP:GetLeft()").unwrap()
|
||||
};
|
||||
assert_eq!(corner(&mut s), 100.0);
|
||||
|
||||
// Press inside the title region: the move starts and OnMouseDown never runs.
|
||||
s.mouse_button(150.0, 150.0, "LeftButton", true);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return downs").unwrap(),
|
||||
0,
|
||||
"a title-region hit swallows OnMouseDown — a miss would fall through to it"
|
||||
);
|
||||
s.mouse_move(250.0, 150.0);
|
||||
assert_eq!(corner(&mut s), 200.0, "the frame followed the cursor");
|
||||
|
||||
// **Release ends it** — mode 2 auto-cancels. Moving the cursor afterwards must not drag it.
|
||||
s.mouse_button(250.0, 150.0, "LeftButton", false);
|
||||
s.mouse_move(400.0, 150.0);
|
||||
assert_eq!(
|
||||
corner(&mut s),
|
||||
200.0,
|
||||
"the move ended at the release; a scripted StartMoving would still be running"
|
||||
);
|
||||
|
||||
// **No `SetMovable` gate on this path** — TP was never made movable, and it dragged. Q6 marks
|
||||
// the no-gate reading VERIFIED (the movable bit is simply not read on
|
||||
// 0x7662c0->0x765320->0x7652b0->0x768430); a title region on a non-movable frame is the case
|
||||
// that tells it apart from `StartMoving`, which raises "Frame %s is not movable".
|
||||
assert!(!s.eval::<bool>("return TP:IsMovable()").unwrap());
|
||||
}
|
||||
|
||||
@@ -1145,27 +1145,9 @@ fn every_region_map_method_is_callable_on_a_texture_and_a_fontstring() {
|
||||
/// All 19. `GetObjectType`/`IsObjectType` were held out of this list when 1244 landed the other
|
||||
/// four — dispatched rather than guessed — and joined it when wow-re answered
|
||||
/// (`system/ui/scratch/widget-type-identity.md`). The list is the whole map again.
|
||||
const REGION_MAP: &[&str] = &[
|
||||
"GetObjectType",
|
||||
"IsObjectType",
|
||||
"GetName",
|
||||
"GetParent",
|
||||
"SetParent",
|
||||
"GetCenter",
|
||||
"GetLeft",
|
||||
"GetRight",
|
||||
"GetTop",
|
||||
"GetBottom",
|
||||
"GetWidth",
|
||||
"SetWidth",
|
||||
"GetHeight",
|
||||
"SetHeight",
|
||||
"GetNumPoints",
|
||||
"GetPoint",
|
||||
"SetPoint",
|
||||
"SetAllPoints",
|
||||
"ClearAllPoints",
|
||||
];
|
||||
// The one list, shared with the title region's narrower table (`script::REGION_MAP_METHODS`)
|
||||
// so the two can never disagree about what "the Region map" is.
|
||||
const REGION_MAP: [&str; 19] = crate::script::REGION_MAP_METHODS;
|
||||
let mut s = crate::script::UiScript::new().unwrap();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
@@ -1514,3 +1496,44 @@ fn a_frames_type_chain_matches_the_roster() {
|
||||
"the frame's Usage text names the frame: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Every region method belongs to a leaf — no name may be installed and invisible to both.**
|
||||
///
|
||||
/// The split (`script::region`) copies names out of the full table into a Texture leaf and a
|
||||
/// FontString leaf. A name in neither list is still installed, still costs a closure, and is
|
||||
/// reachable from NOTHING — the silent half of a wrong partition.
|
||||
///
|
||||
/// This is not hypothetical: the partition was first built from a grep, and that grep missed
|
||||
/// `SetGradient`/`SetGradientAlpha` because they are installed from a LOOP rather than a literal
|
||||
/// `m.set("…")`. One test caught one of them; this gate catches the whole class, and it reads the
|
||||
/// table the VM actually holds rather than the source that builds it.
|
||||
#[test]
|
||||
fn every_installed_region_method_lands_in_a_leaf() {
|
||||
use crate::script::{
|
||||
FONTSTRING_ONLY_METHODS, REGION_LEAF_SHARED, REGION_MAP_METHODS, TEXTURE_ONLY_METHODS,
|
||||
};
|
||||
let s = crate::script::UiScript::new().unwrap();
|
||||
let full: mlua::Table = s
|
||||
.lua()
|
||||
.named_registry_value(crate::script::REG_REGION_METHODS_FOR_TEST)
|
||||
.expect("the full region method table");
|
||||
|
||||
let mut known: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
known.extend(REGION_MAP_METHODS);
|
||||
known.extend(REGION_LEAF_SHARED);
|
||||
known.extend(TEXTURE_ONLY_METHODS);
|
||||
known.extend(FONTSTRING_ONLY_METHODS);
|
||||
|
||||
let mut orphans: Vec<String> = Vec::new();
|
||||
for pair in full.pairs::<String, mlua::Value>() {
|
||||
let (name, _) = pair.expect("region method entry");
|
||||
if !known.contains(name.as_str()) {
|
||||
orphans.push(name);
|
||||
}
|
||||
}
|
||||
orphans.sort();
|
||||
assert!(
|
||||
orphans.is_empty(),
|
||||
"installed on the region table but in NO leaf list, so no region can call them: {orphans:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -662,3 +662,130 @@ fn a_gradient_is_stored_whole_and_painted_as_its_midpoint() {
|
||||
"SetGradient's stops are opaque, so the midpoint alpha is 1"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The split itself: a Texture answers texture verbs and NOT text ones, and vice versa.**
|
||||
///
|
||||
/// Until this landed, one shared table meant a Texture answered `SetText` and a FontString answered
|
||||
/// `SetTexture` — a superset in both directions (wow-re
|
||||
/// `system/ui/scratch/texture-fontstring-method-split.md`: Texture's map `0x87c128` is 22 entries,
|
||||
/// FontString's `0xcf5400` is 32, both tail-calling the Region map and stopping there).
|
||||
#[test]
|
||||
fn the_two_region_leaves_answer_their_own_maps() {
|
||||
let s = crate::script::UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"
|
||||
LeafOwner = CreateFrame("Frame", "LeafOwner")
|
||||
Tex = LeafOwner:CreateTexture("Tex", "ARTWORK")
|
||||
Str = LeafOwner:CreateFontString("Str", "ARTWORK")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let has = |s: &crate::script::UiScript, obj: &str, m: &str| {
|
||||
s.eval::<String>(&format!("return type({obj}.{m})"))
|
||||
.unwrap()
|
||||
== "function"
|
||||
};
|
||||
|
||||
// Texture-only, and the asymmetry the carve calls out: `SetVertexColor` is on BOTH leaves while
|
||||
// `GetVertexColor` is Texture-only. No reasonable partition invents that.
|
||||
for m in [
|
||||
"SetTexture",
|
||||
"GetTexture",
|
||||
"SetTexCoord",
|
||||
"SetBlendMode",
|
||||
"GetVertexColor",
|
||||
] {
|
||||
assert!(has(&s, "Tex", m), "a Texture answers {m}");
|
||||
assert!(!has(&s, "Str", m), "a FontString must NOT answer {m}");
|
||||
}
|
||||
// FontString-only.
|
||||
for m in [
|
||||
"SetText",
|
||||
"GetText",
|
||||
"GetStringWidth",
|
||||
"SetJustifyH",
|
||||
"SetAlphaGradient",
|
||||
] {
|
||||
assert!(has(&s, "Str", m), "a FontString answers {m}");
|
||||
assert!(!has(&s, "Tex", m), "a Texture must NOT answer {m}");
|
||||
}
|
||||
// On BOTH — and each leaf registers its own copy in the client, so these are NOT on the Region
|
||||
// map and must not be hoisted into it.
|
||||
for m in [
|
||||
"SetVertexColor",
|
||||
"SetAlpha",
|
||||
"Show",
|
||||
"Hide",
|
||||
"IsShown",
|
||||
"SetDrawLayer",
|
||||
] {
|
||||
assert!(
|
||||
has(&s, "Tex", m) && has(&s, "Str", m),
|
||||
"{m} is on both leaves"
|
||||
);
|
||||
}
|
||||
// The Region map reaches both, through each leaf's own fallback.
|
||||
for m in crate::script::REGION_MAP_METHODS {
|
||||
assert!(
|
||||
has(&s, "Tex", m) && has(&s, "Str", m),
|
||||
"{m} is the Region map"
|
||||
);
|
||||
}
|
||||
// **`GetStringHeight` is GONE and must stay gone.** 1.12 has no such method on any table (0
|
||||
// hits in every encoding; the control `GetStringWidth` has 1), Blizzard's own FrameXML calls it
|
||||
// 0 times, and ours was a byte-identical duplicate of `GetHeight` — which is the method the
|
||||
// reference itself uses for this, falling through to the same cached measurement
|
||||
// `GetStringWidth` reads. Keeping the width and dropping the height is not an oversight: the
|
||||
// client really is asymmetric here.
|
||||
assert!(
|
||||
!has(&s, "Str", "GetStringHeight"),
|
||||
"1.12 has no GetStringHeight"
|
||||
);
|
||||
assert!(
|
||||
has(&s, "Str", "GetStringWidth"),
|
||||
"…but it does have GetStringWidth"
|
||||
);
|
||||
assert!(
|
||||
has(&s, "Str", "GetHeight"),
|
||||
"GetHeight is the replacement, via the Region map"
|
||||
);
|
||||
|
||||
// The near-miss pair: Texture has SetGradientAlpha, FontString has SetAlphaGradient.
|
||||
assert!(has(&s, "Tex", "SetGradientAlpha") && !has(&s, "Str", "SetGradientAlpha"));
|
||||
assert!(has(&s, "Str", "SetAlphaGradient") && !has(&s, "Tex", "SetAlphaGradient"));
|
||||
}
|
||||
|
||||
/// **`SetPortraitToTexture` is a GLOBAL in 1.12, not a Texture method.**
|
||||
///
|
||||
/// `reference/1.12-globals.tsv` marks it `engine`, and both of the reference's own call sites pass a
|
||||
/// texture NAME: `ContainerFrame.lua:419` and `MailFrame.lua:174`. The first is the one that binds
|
||||
/// us — we SOURCE `ContainerFrame.lua` off the patch chain, so the client's own file calls this
|
||||
/// global inside our VM.
|
||||
#[test]
|
||||
fn set_portrait_to_texture_is_a_global_taking_a_name() {
|
||||
let s = crate::script::UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"
|
||||
PortHost = CreateFrame("Frame", "PortHost")
|
||||
Port = PortHost:CreateTexture("PortHostPortrait", "ARTWORK")
|
||||
SetPortraitToTexture("PortHostPortrait", "Interface\\ContainerFrame\\KeyRing-Bag-Icon")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return Port:GetTexture()").unwrap(),
|
||||
"Interface\\ContainerFrame\\KeyRing-Bag-Icon"
|
||||
);
|
||||
// The Texture METHOD is gone — 1.12's Texture map has no such entry.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return type(Port.SetPortraitToTexture)")
|
||||
.unwrap(),
|
||||
"nil",
|
||||
"1.12 has no Texture:SetPortraitToTexture — it is a global"
|
||||
);
|
||||
// An unknown name is not an error: the reference's callers compose names that may not exist
|
||||
// yet, and nothing here may raise on the sourced file's behalf.
|
||||
s.run(r#"SetPortraitToTexture("NoSuchPortrait", "Interface\\X")"#)
|
||||
.unwrap();
|
||||
assert!(s.errors().is_empty());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user