Files
benilla-octo/crates/benilla-app/src/ui_script/exp_bar_tests.rs
T
SamandClaude Fable 5 50aedb8b05 world books open, and anvils stop offering a cursor gear
- clicking a book in the world opens it and turns its pages: letters and world books share one reader
- the cursor's object rules are the client's own 31-row strategy table now, so decorative objects stop pretending to be usable
- trainer and tradeskill windows draw each row's icon by the reference's own law, and the trainer's filter dropdown toggles again
- water color follows the zone's light blend, so lakes stop snapping between area tints

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

356 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The XP bar's own tests — the hover plate, the rested/exhaustion-tick system and the
//! on-bar numerals (decision 1082) — split from `action_bar_tests.rs`, which keeps the
//! action-button machinery. Same file, two concerns: the strip along the bar's top is the
//! player-progress readout; the buttons are the input surface.
use benilla_ui::script::{QuadContent, ScriptValue, UiScript, UnitState};
/// The XP bar's load set: the manifest prefix it actually needs — fonts, UIParent, the
/// tooltip, `TextStatusBar.xml` (the numerals machinery `BenillaExpBar_OnLoad` wires in),
/// then Cooldown + the bar itself (manifest order).
fn exp_bar_harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UIParent.xml",
"GameTooltip.xml",
"TextStatusBar.xml",
"Cooldown.xml",
"ActionBar.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
s
}
/// The XP bar's hover (ref-MainMenuBar.xml l.136-147): the strip takes the mouse, and the plate is
/// the ref's two-line `GameTooltip_AddNewbieTip` — "XP Bar" over the wrapped explanation.
///
/// `enableMouse` is the load-bearing half and the easy thing to lose: without it the strip is
/// transparent and the hover silently never fires, which no tooltip assertion alone would catch.
#[test]
fn the_xp_bar_takes_the_mouse_and_explains_itself() {
let mut s = exp_bar_harness();
s.resolve();
// Mid-height of the strip (the bar's top 13 px), a quarter of the way along — NOT its
// horizontal center, where the page arrows straddle the strip's lower edge and rightly take
// the mouse ahead of it.
let (x, y) = s
.eval::<(f64, f64)>(
"return BenillaExpBar:GetLeft() + BenillaExpBar:GetWidth() / 4, \
(BenillaExpBar:GetBottom() + BenillaExpBar:GetTop()) / 2",
)
.unwrap();
assert_eq!(
s.hit_test_name(x as f32, y as f32).as_deref(),
Some("BenillaExpBar"),
"the XP strip must be mouse-enabled or the hover never fires"
);
s.run("BenillaExpBar_OnEnter(BenillaExpBar)").unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"XP Bar"
);
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft2:GetText()")
.unwrap(),
s.eval::<String>("return NEWBIE_TOOLTIP_XPBAR").unwrap(),
"line 2 is the ref's NEWBIE_TOOLTIP_XPBAR, verbatim"
);
assert_eq!(
s.eval::<i64>("return GameTooltip.default").unwrap(),
1,
"the default-corner anchor"
);
s.run("BenillaExpBar_OnLeave()").unwrap();
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"leaving hides the plate"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The exhaustion tick (decision 1082, ref `ExhaustionTick_Update`): with 1000/10000 XP and a
/// rested pool of 700 base-XP, the doubled span is 1400 bar-XP, so the tick parks at
/// (1000+1400)/10000 of the strip's width and the pale fill stretches exactly to it; the bar
/// paints rested blue. Draining the pool (normal state) hides both and returns the purple; a
/// pool whose span runs past the level's end hides the tick but keeps the rested blue.
#[test]
fn the_exhaustion_tick_marks_where_rested_runs_out() {
let mut s = exp_bar_harness();
s.set_player_xp(1000, 10000);
s.set_rest_state(1, 700, true);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
// The binding trio underneath (the app-feed shape the XML consumes) — the byte-verified
// contract (wow-re rested-xp-bindings.md, decision 1087).
let (id, name, mult) = s
.eval::<(i64, String, f64)>("return GetRestState()")
.unwrap();
assert_eq!((id, name.as_str(), mult), (1, "Rested", 2.0));
assert_eq!(
s.eval::<Option<i64>>("return GetXPExhaustion()").unwrap(),
Some(1400),
"the pool × Exhaustion.dbc row 1's factor (2.0) — bar-XP, not wire units"
);
assert_eq!(
s.eval::<Option<i64>>("return IsResting()").unwrap(),
Some(1)
);
let ok: bool = s
.eval(
r#"
local tick, fill = BenillaExhaustionTick, BenillaExhaustionLevelFillBar
local expected = (1000 + 1400) / 10000 * BenillaExpBar:GetWidth()
local x = tick:GetCenter()
local r, g, b = BenillaExpBar:GetStatusBarColor()
-- fill's width via its RESOLVED edges: the authored size is 0 x 13, and the engine
-- derives the span from the TOPLEFT + runtime-TOPRIGHT anchor pair (layout.rs's
-- zero-size law), exactly as the real client does.
return tick:IsVisible() and fill:IsShown()
and math.abs((x - BenillaExpBar:GetLeft()) - expected) < 0.5
and math.abs((fill:GetRight() - fill:GetLeft()) - expected) < 0.5
and r == 0.0 and math.abs(g - 0.39) < 0.001 and math.abs(b - 0.88) < 0.001
"#,
)
.unwrap();
assert!(ok, "tick at the rested boundary, fill up to it, bar blue");
// The pool drains dry: state normal, no threshold — tick and fill gone, purple back.
s.set_rest_state(2, 0, false);
s.fire_event("UPDATE_EXHAUSTION", vec![]);
let ok: bool = s
.eval(
r#"
local r, g, b = BenillaExpBar:GetStatusBarColor()
return not BenillaExhaustionTick:IsShown()
and not BenillaExhaustionLevelFillBar:IsShown()
and GetXPExhaustion() == nil
and math.abs(r - 0.58) < 0.001 and g == 0.0 and math.abs(b - 0.55) < 0.001
"#,
)
.unwrap();
assert!(ok, "a dry pool hides the tick and returns the purple bar");
// The nil law is the BYTE's, not the pool's (0x48d3f0's `dec/jne`, decision 1087): a normal-
// state player with a nonzero pool — vmangos's 0 < bonus ≤ 10 hysteresis window — still reads
// nil, and a rested player with a drained pool reads the NUMBER 0 (the tick then parks at the
// bar's current fill). An unmapped byte (0 here) is the binary's nil-triple fail path.
s.set_rest_state(2, 5, false);
s.fire_event("UPDATE_EXHAUSTION", vec![]);
assert_eq!(
s.eval::<Option<i64>>("return GetXPExhaustion()").unwrap(),
None,
"normal state hides the pool even while it holds a remnant"
);
s.set_rest_state(1, 0, true);
s.fire_event("UPDATE_EXHAUSTION", vec![]);
assert_eq!(
s.eval::<Option<i64>>("return GetXPExhaustion()").unwrap(),
Some(0),
"rested with a drained pool is the number 0, not nil"
);
s.set_rest_state(0, 0, false);
assert!(
s.eval::<bool>("local a, b, c = GetRestState() return a == nil and b == nil and c == nil")
.unwrap(),
"an unmapped byte is the binary's (nil, nil, nil) fail path"
);
s.set_rest_state(2, 0, false);
s.fire_event("UPDATE_EXHAUSTION", vec![]);
// A pool past the level's end (6000×2 span from 1000/10000): off the strip → hidden tick,
// still rested blue (the ref's exhaustionTickSet > width branch).
s.set_rest_state(1, 6000, false);
s.fire_event("UPDATE_EXHAUSTION", vec![]);
let ok: bool = s
.eval(
r#"
local r, g, b = BenillaExpBar:GetStatusBarColor()
return not BenillaExhaustionTick:IsShown()
and not BenillaExhaustionLevelFillBar:IsShown()
and math.abs(g - 0.39) < 0.001
"#,
)
.unwrap();
assert!(
ok,
"a span past the level end hides the tick but keeps the blue"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The max-level rail (decision 1094, ref `ReputationWatchBar_Update`'s no-watched-faction arm):
/// at MAX_PLAYER_LEVEL the XP strip and its tick give way to the flat brass rail; below it the
/// strip is back. Walked over a live ding — 59 → 60 via `PLAYER_LEVEL_UP`'s arg1, the value the
/// ref reads (not the level field, which may not have landed yet). The tick's own handler runs
/// first (it loaded first) and re-shows the tick off the rested pool; the rail's handler then
/// hides it — the ref's own load order, ending hidden.
#[test]
fn the_max_level_rail_replaces_the_xp_bar_at_60() {
let mut s = exp_bar_harness();
s.set_player_xp(1000, 10000);
s.set_rest_state(1, 700, true);
s.set_unit(
"player",
Some(UnitState {
exists: true,
level: 59,
..UnitState::default()
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
let ok: bool = s
.eval(
r#"
return BenillaExpBar:IsShown() and not BenillaMaxLevelBar:IsShown()
and BenillaExhaustionTick:IsShown()
"#,
)
.unwrap();
assert!(
ok,
"below 60 the strip and tick show, the rail stays hidden"
);
s.fire_event("PLAYER_LEVEL_UP", vec![ScriptValue::Int(60)]);
let ok: bool = s
.eval(
r#"
return not BenillaExpBar:IsShown() and BenillaMaxLevelBar:IsShown()
and not BenillaExhaustionTick:IsShown()
"#,
)
.unwrap();
assert!(
ok,
"at 60 the rail replaces the strip and the tick goes with it"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The gryphon end caps stay over the strip and the rail across ANY show/hide history —
/// including the faithful hidden→visible tail re-stamp (0x76ae10 / 0884, which a stale-rail
/// recovery exercises: decision 1106's whole bug walked through here). The protection is
/// 0884's own key order: the caps ride the art frame's OVERLAY and the strip's fill its
/// ARTWORK, and within a `(strata, level)` bucket the LAYER outranks every stamp — no
/// re-stamp can lift a lower layer over them.
#[test]
fn the_gryphons_outrank_the_bars_across_hide_show_cycles() {
let mut s = exp_bar_harness();
s.set_player_xp(300, 400);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// The relog shape: the strip hidden (a stale max-level rail), then re-shown; the rail's
// own arrival is ALWAYS a hidden→visible re-stamp (it is born hidden).
s.run("BenillaExpBar:Hide() BenillaExpBar:Show()").unwrap();
s.run("BenillaMaxLevelBar:Show()").unwrap();
s.resolve();
let quads = s.extract();
let z_max = |suffix: &str| {
quads
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.ends_with(suffix))
})
.map(|q| q.z)
.max()
};
let fill = z_max("UI-StatusBar").expect("the strip's fill");
let rail = z_max("UI-MainMenuBar-MaxLevel").expect("the rail's plates");
let cap = z_max("UI-MainMenuBar-EndCap-Dwarf").expect("the gryphon end caps");
assert!(
fill < cap && rail < cap,
"the end caps must paint over the re-shown bars \
(fill z={fill:#x}, rail z={rail:#x}, cap z={cap:#x})"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The hover numerals (decision 1082, ref TextStatusBar.lua over `MainMenuBarExpText`): entering
/// the strip shows "curr / max" centered on the bar, leaving hides it (the lockShow refcount's
/// 0↔1 edge — the `statusBarText` cvar that pins it on permanently reads OFF here).
#[test]
fn the_xp_bar_numerals_show_on_hover() {
let mut s = exp_bar_harness();
s.set_player_xp(1234, 5678);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.run("BenillaExpBar_OnEnter(BenillaExpBar)").unwrap();
assert_eq!(
s.eval::<String>("return BenillaExpBarText:GetText()")
.unwrap(),
"1234 / 5678"
);
assert!(s
.eval::<bool>("return BenillaExpBarText:IsShown()")
.unwrap());
s.run("BenillaExpBar_OnLeave(BenillaExpBar)").unwrap();
assert!(
!s.eval::<bool>("return BenillaExpBarText:IsShown()")
.unwrap(),
"leaving drops the lockShow refcount to 0 and hides the numerals"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The rest-state tooltip line (decision 1082, ref `ExhaustionToolTipText`'s newbie-tips branch):
/// hovering the BAR arms a 1 s timer and the canAddRestStateLine handshake; when the timer runs
/// out on the bar's OnUpdate, the "Rested / 200% of normal experience" line is APPENDED to the
/// held-open newbie tip, once (the handshake is consumed).
#[test]
fn the_rest_state_line_joins_the_held_open_tooltip() {
let mut s = exp_bar_harness();
s.set_player_xp(1000, 10000);
s.set_rest_state(1, 700, true);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// 1.12's default posture (0661): detailed newbie tips ON.
s.run("SHOW_NEWBIE_TIPS = \"1\"").unwrap();
s.run("BenillaExpBar_OnEnter(BenillaExpBar)").unwrap();
assert_eq!(
s.eval::<f64>("return BenillaExhaustionTick.timer").unwrap(),
1.0,
"the bar hover arms the tick's timer"
);
// Three OnUpdate ticks walk the timer 1 → 0.4 → −0.2 → fire (the ref's `< 0` edge).
for _ in 0..3 {
s.run("BenillaExhaustionTick_OnUpdate(0.6)").unwrap();
}
let appended: String = s.eval("return GameTooltipTextLeft3:GetText()").unwrap();
assert!(
appended.contains("Rested") && appended.contains("200%"),
"the rest-state line rides the open plate: {appended:?}"
);
assert!(
s.eval::<bool>("return GameTooltip.canAddRestStateLine == nil")
.unwrap(),
"the handshake is consumed — the line never doubles"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}