- the action-bar cluster is the reference's own: `MainMenuBar.xml`, `ActionBarFrame.xml`, `MultiActionBars.xml` and `BonusActionBarFrame.xml` run off the player's own chain, and our `ActionBar.xml` (2047 lines), `MultiBars.xml` and `StanceBar.xml` are deleted — three, since the stock bonus-bar file declares the shapeshift bar too; one engine verb, `ChangeActionBarPage`, fires `ACTIONBAR_PAGE_CHANGED` and nothing else; one load blocker: stock `MultiActionBars.lua` writes the options window's table from its OnLoad, the reference's own load-order hack, so our options window moves to the reference's toc slot; and ten things the reference does that ours did not: an empty button is HIDDEN while showgrid is 0, the bonus overlay sits one pixel right of the main buttons, every slide takes one frame more than its time, and `ActionButton_ShowGrid` ghosts EVERY ring, occupied included (1938) - a gift can be wrapped: right-clicking wrapping paper fell through to plain use and sent `CMSG_USE_ITEM`, so nothing happened — you could receive a gift and never give one; the wrap is a displayed-cursor MODE plus a stashed slot, never a payload, so `CursorHasItem()` stays false and every stock gate on it stays shut: right-click arms it, a left-click on a bag slot sends `CMSG_WRAP_ITEM`, and the client applies no eligibility test — the server's `ERR_CANT_WRAP_*` reasons are the error line (1934); a duration-limited item counts down: `SMSG_ITEM_TIME_UPDATE` parks a deadline and the tooltip's `ITEM_DURATION` line runs the enchant countdown's bucket ladder, so "Duration: 1 days" is what the reference renders; an update for an item we do not hold is DROPPED, no queue (1933); and a renamed crowd-control refusal names its mechanic — "Can't do that while stunned" — off `SpellMechanic.dbc` (1943) - a creature paths on a boat: `SMSG_MONSTER_MOVE_TRANSPORT` carries the transport's guid and deck-local coordinates, and is the attach/detach edge; a deck sample lands on the rider or nowhere, never as a world write a few yards from the map origin; and "there is no terrain under a boat" turned out wrong: the ground probe admits GameObject meshes — a boat's `.wmo` is a GameObject, so the probe hits the deck (1936); `MSG_MOVE_TIME_SKIPPED` is read, not dropped — the reference adds the lag to the observed unit's last-wire stamp, so dropping it scheduled their next packet `lag` ms late, a hitch for every player who stalled zoning in near us; and our own settle hold reports once (1935); and the tradeskill tool order was backwards — focus FIRST, so every two-tool recipe had read "Blacksmith Hammer, Anvil" for the reference's "Anvil, Blacksmith Hammer"; the focus's `hasTool` is 1.0 in the reference too (1942) - the crowd's second sitting, instruments first: the probe line carries the tail — cpu p95/p99 and the six worst frames — because a mean under budget hides the one frame in twenty over it (raid legs railed at 16.67 with p95 at 18–20 ms) and whether that frame was work or a wait is the first question about a stutter, and the visible meshes are counted by lane; then two cuts — a terrain tile furnishes as 4×4 cells of 4×4 chunks, one mesh, one entity, one draw per 133-yd cell instead of per 33-yd chunk (a city horizon is over a thousand chunks a frame, and a cell changes nothing a pixel can see; Stormwind 10.55 → 10.32 `cpu_ms`), and an authored point light is our own `WorldPointLight`, not Bevy's `PointLight`, which was only a data carrier while Bevy ran its whole light lane over every lamp, ~2 % of the alone frame; the sitting's record is 1945, and the code says so (1945) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1251 lines
56 KiB
Rust
1251 lines
56 KiB
Rust
//! The shipped **character window** driven end-to-end, engine-only (no Bevy) — and since decision
|
||
//! 1751's character swap, "shipped" means the **reference's own**
|
||
//! `Interface\FrameXML\CharacterFrame.xml`, `PaperDollFrame.xml` and `PetPaperDollFrame.xml`, read
|
||
//! off the player's patch chain. Our `assets/ui/CharacterFrame.xml` and `PetPaperDollFrame.xml` are
|
||
//! deleted; [`super::test_ui::CHARACTER_UI`] is the load list, in `benilla.toc`'s own order, and
|
||
//! every test here opens with `wow_data_or_skip!()` because a chain entry needs the install.
|
||
//!
|
||
//! The fixtures are unchanged (a synthetic player snapshot + combat stats + one equipped item), but
|
||
//! the behaviour under them is the stock file's, not ours. Where the two differed the divergence is
|
||
//! named at the test that used to pin it, with the stock file and line that replaces it — the
|
||
//! per-window re-examination 1751 asks for, done in the open rather than silently.
|
||
//!
|
||
//! **Handlers are driven through the mouse, never called by name.** The reference's slot handlers
|
||
//! read `this` (`PaperDollItemSlotButton_OnClick(button, ignoreModifiers)` takes the MOUSE button
|
||
//! and gets the frame from `this`, `PaperDollFrame.lua:647`), and only the engine sets `this`.
|
||
|
||
use benilla_ui::script::{
|
||
ExtractedQuad, InvSlotView, InventorySlots, QuadContent, ScriptValue, SoundRequest, UiScript,
|
||
UnitCombatStats, UnitState,
|
||
};
|
||
|
||
use super::test_ui::load_ui as load_xml;
|
||
|
||
/// A Night Elf Warrior, level 12 — the fixture every test below shares for the level/name line.
|
||
fn player_unit() -> UnitState {
|
||
UnitState {
|
||
exists: true,
|
||
name: Some("Benilla".into()),
|
||
health: 100,
|
||
max_health: 100,
|
||
level: 12,
|
||
power_type: 0,
|
||
power: 50,
|
||
max_power: 50,
|
||
dead: false,
|
||
reaction: 0,
|
||
race: Some("Night Elf".into()),
|
||
race_file: Some("NightElf".into()),
|
||
class: Some("Warrior".into()),
|
||
class_file: Some("WARRIOR".into()),
|
||
sex: 2,
|
||
is_player: true,
|
||
player_controlled: true,
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// A minimal combat-stats snapshot: STR effective 15 (a +2 pos buff, so the stat line shows green),
|
||
/// 120 armor (school 0), 3 arcane resistance (school 6), a mainhand-only melee weapon (2.6s speed,
|
||
/// 10-15 damage, 80 AP) and NO ranged weapon equipped (exercises the "N/A" ranged fallback).
|
||
fn combat_stats() -> UnitCombatStats {
|
||
UnitCombatStats {
|
||
stats: [15, 12, 20, 8, 9],
|
||
stat_pos: [2, 0, 0, 0, 0],
|
||
resistances: [120, 5, 0, 0, 0, 0, 3],
|
||
min_damage: 10.0,
|
||
max_damage: 15.0,
|
||
attack_power: 80,
|
||
main_attack_time_ms: 2600,
|
||
main_weapon_skill: (300, 5),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// One item in the head slot (inventory slot id 1, per `GetInventorySlotInfo("HeadSlot")`) —
|
||
/// everything else stays empty (exercises the empty-slot art + slot-name tooltip fallback).
|
||
/// `equip_slots: vec![1]` — fits ONLY the head slot, decision 0208 phase 1b's fit rule.
|
||
fn inventory_with_head_item() -> InventorySlots {
|
||
let mut slots: InventorySlots = Default::default();
|
||
slots[1] = Some(InvSlotView {
|
||
duration_ms: None,
|
||
already_bound: false,
|
||
bar_placeable: true,
|
||
durability: None,
|
||
flags: 0,
|
||
item_id: 1234,
|
||
icon: Some("Interface\\Icons\\INV_Helmet_01".into()),
|
||
count: 1,
|
||
contents_count: None,
|
||
quality: 2,
|
||
name: Some("Test Helm".into()),
|
||
link: Some("|cff1eff00|Hitem:1234:0:0:0|h[Test Helm]|h|r".into()),
|
||
locked: false,
|
||
equip_slots: vec![1],
|
||
creator: None,
|
||
enchants: Vec::new(),
|
||
});
|
||
slots
|
||
}
|
||
|
||
/// A one-item backpack whose slot 1 item fits ONLY the head slot (`equip_slots: vec![1]`) — the
|
||
/// bag-side half of the doll-interaction tests below.
|
||
fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState {
|
||
let mut slots = std::collections::HashMap::new();
|
||
slots.insert(
|
||
1,
|
||
benilla_ui::script::ContainerSlot {
|
||
duration_ms: None,
|
||
petition: None,
|
||
already_bound: false,
|
||
bar_placeable: true,
|
||
durability: None,
|
||
texture: Some("Interface\\Icons\\INV_Helmet_02".into()),
|
||
count: 1,
|
||
quality: Some(3),
|
||
item_id: 2000,
|
||
link: Some("|cff0070dd|Hitem:2000:0:0:0|h[Another Helm]|h|r".into()),
|
||
locked: false,
|
||
equip_slots: vec![1],
|
||
cooldown: None,
|
||
readable: false,
|
||
creator: None,
|
||
flags: 0,
|
||
enchants: Vec::new(),
|
||
},
|
||
);
|
||
benilla_ui::script::ContainerState {
|
||
name: Some("Backpack".into()),
|
||
num_slots: 16,
|
||
slots,
|
||
}
|
||
}
|
||
|
||
/// The loader itself: every file the window depends on parses and materializes with no errors, and
|
||
/// the two chain files this module owns each land their own frame count.
|
||
///
|
||
/// **A count is a fingerprint of a file, not a target** (decision 1800's closing note). These two
|
||
/// are the reference's — `CharacterFrame.xml`'s container + close button + name frame + five tabs,
|
||
/// and `PaperDollFrame.xml`'s 19 slots + ammo + their cooldown children + the stat/resistance rows
|
||
/// + the model pane and its two rotate buttons. They change only when the player's own file does.
|
||
#[test]
|
||
fn shipped_character_frame_loads_clean() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let s = UiScript::new().unwrap();
|
||
let mut counts = std::collections::HashMap::new();
|
||
for f in super::test_ui::CHARACTER_UI {
|
||
counts.insert(*f, super::test_ui::load_ui_strict(&s, f));
|
||
}
|
||
assert!(s.errors().is_empty(), "load errors: {:?}", s.errors());
|
||
assert_eq!(
|
||
counts.get("Interface\\FrameXML\\CharacterFrame.xml"),
|
||
Some(&8),
|
||
"the reference's own CharacterFrame.xml"
|
||
);
|
||
assert_eq!(
|
||
counts.get("Interface\\FrameXML\\PaperDollFrame.xml"),
|
||
Some(&75),
|
||
"the reference's own PaperDollFrame.xml"
|
||
);
|
||
}
|
||
|
||
/// The whole contract in one end-to-end drive: `ToggleCharacter` (the 'C' binding's entry point)
|
||
/// opens through `ShowUIPanel`, the level/name lines read the player snapshot, the stat/armor/
|
||
/// resistance lines read the combat-stats snapshot (with the ref's own buff-coloring), the ranged
|
||
/// block falls back to N/A with no ranged weapon, an equipped item's icon renders once
|
||
/// `UNIT_INVENTORY_CHANGED` fires, rotating the model pane advances the booth yaw + plays the kit,
|
||
/// and a second toggle closes it again — each transition its own sound.
|
||
#[test]
|
||
fn shipped_character_frame_drives_end_to_end() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
for f in super::test_ui::CHARACTER_UI {
|
||
super::test_ui::load_ui_strict(&s, f);
|
||
}
|
||
|
||
s.set_unit("player", Some(player_unit()));
|
||
s.set_player_combat_stats(Some(combat_stats()));
|
||
s.set_inventory_slots(inventory_with_head_item());
|
||
|
||
// Hidden at load: no sound queued (never transitions on startup).
|
||
assert!(
|
||
s.take_sounds().is_empty(),
|
||
"no sound at load (never transitions)"
|
||
);
|
||
assert!(!s.eval::<bool>("return CharacterFrame:IsVisible()").unwrap());
|
||
|
||
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
|
||
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
|
||
assert!(s.eval::<bool>("return CharacterFrame:IsVisible()").unwrap());
|
||
assert_eq!(
|
||
s.take_sounds(),
|
||
vec![SoundRequest::KitName("igCharacterInfoOpen".into())],
|
||
"opening plays igCharacterInfoOpen"
|
||
);
|
||
|
||
assert_eq!(
|
||
s.eval::<String>("return CharacterNameText:GetText()")
|
||
.unwrap(),
|
||
"Benilla"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return CharacterLevelText:GetText()")
|
||
.unwrap(),
|
||
"Level 12 Night Elf Warrior"
|
||
);
|
||
// STR effective 15, posBuff 2 > 0 → green (ref PaperDollFrame_SetStats).
|
||
assert_eq!(
|
||
s.eval::<String>("return CharacterStatFrame1StatText:GetText()")
|
||
.unwrap(),
|
||
"|cff20ff2015|r"
|
||
);
|
||
// The labels are the TEMPLATE's own $parentLabel, filled from Lua exactly like the ref
|
||
// (PaperDollFrame_SetStats l.140 for the attributes, _OnLoad l.7-13 for the fixed rows) —
|
||
// never a re-declared region in an instance, which this engine renders as a SECOND
|
||
// anchorless default-font FontString (the white/overlapping-label regression).
|
||
assert_eq!(
|
||
s.eval::<String>("return CharacterStatFrame1Label:GetText()")
|
||
.unwrap(),
|
||
"Strength:"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return CharacterAttackFrameLabel:GetText()")
|
||
.unwrap(),
|
||
"Melee Attack"
|
||
);
|
||
// Geometry resolves below (GetLeft is nil until the first resolve).
|
||
s.resolve();
|
||
// The template's label sits LEFT-anchored at its row's left edge.
|
||
assert_eq!(
|
||
s.eval::<f32>("return CharacterStatFrame1Label:GetLeft()")
|
||
.unwrap(),
|
||
s.eval::<f32>("return CharacterStatFrame1:GetLeft()")
|
||
.unwrap(),
|
||
);
|
||
// Exactly ONE region carries the label text, in the small gold font (GameFontNormalSmall:
|
||
// 10px, 1.0/0.82/0) — a duplicate or a default-font fallback both fail here.
|
||
let quads = s.extract();
|
||
let strength_labels: Vec<_> = quads
|
||
.iter()
|
||
.filter_map(|q| match &q.content {
|
||
QuadContent::Text {
|
||
text: Some(t),
|
||
color,
|
||
font_height,
|
||
..
|
||
} if t == "Strength:" => Some((*color, *font_height)),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(
|
||
strength_labels.len(),
|
||
1,
|
||
"exactly one Strength: label region, got {}",
|
||
strength_labels.len()
|
||
);
|
||
let (color, height) = strength_labels[0];
|
||
assert_eq!(height, Some(10.0), "GameFontNormalSmall is 10px");
|
||
let c = color.expect("the label carries the font object's color");
|
||
assert!(
|
||
(c[0] - 1.0).abs() < 1e-3 && (c[1] - 0.82).abs() < 1e-3 && c[2].abs() < 1e-3,
|
||
"gold NORMAL_FONT_COLOR, got {c:?}"
|
||
);
|
||
// Armor (school 0) reads straight off UnitArmor's base.
|
||
assert_eq!(
|
||
s.eval::<String>("return CharacterArmorFrameStatText:GetText()")
|
||
.unwrap(),
|
||
"120"
|
||
);
|
||
// MagicResFrame1 = id 6 (arcane) — resistances[6] = 3.
|
||
assert_eq!(
|
||
s.eval::<String>("return MagicResText1:GetText()").unwrap(),
|
||
"3"
|
||
);
|
||
// No ranged weapon in this fixture: the N/A fallback (ref PaperDollFrame_SetRangedAttack).
|
||
assert_eq!(
|
||
s.eval::<String>("return CharacterRangedAttackFrameStatText:GetText()")
|
||
.unwrap(),
|
||
"N/A"
|
||
);
|
||
|
||
// The equipped head item's icon shows once UNIT_INVENTORY_CHANGED fires (the real app's own
|
||
// sequencing: a snapshot push is followed by the event, never inferred from the push alone).
|
||
s.fire_event(
|
||
"UNIT_INVENTORY_CHANGED",
|
||
vec![ScriptValue::Str("player".into())],
|
||
);
|
||
assert!(
|
||
s.errors().is_empty(),
|
||
"inventory refresh errors: {:?}",
|
||
s.errors()
|
||
);
|
||
s.resolve();
|
||
let head_icon = s.extract().iter().any(|q| {
|
||
matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains("INV_Helmet_01"))
|
||
});
|
||
assert!(head_icon, "the head slot renders the equipped item's icon");
|
||
|
||
// Hovering an EMPTY slot (Neck, never populated) shows the slot-name tooltip fallback.
|
||
let neck_center = {
|
||
let l: f32 = s.eval("return CharacterNeckSlot:GetLeft()").unwrap();
|
||
let r: f32 = s.eval("return CharacterNeckSlot:GetRight()").unwrap();
|
||
let t: f32 = s.eval("return CharacterNeckSlot:GetTop()").unwrap();
|
||
let b: f32 = s.eval("return CharacterNeckSlot:GetBottom()").unwrap();
|
||
((l + r) * 0.5, (t + b) * 0.5)
|
||
};
|
||
s.mouse_move(neck_center.0, neck_center.1);
|
||
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
|
||
assert!(
|
||
s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
|
||
"the empty Neck slot shows a tooltip"
|
||
);
|
||
|
||
// Hovering a resistance icon WRAPS its long subtext at the tooltip wrap column — the ref
|
||
// passes wrap=1 on every stat/resistance subtext AddLine (ref-PaperDollFrame.xml:115 and
|
||
// siblings); a transcription that drops the flag renders the ~380px sentence as one
|
||
// unwrapped line (the director's tooltip max-width report).
|
||
let res_center = {
|
||
let l: f32 = s.eval("return MagicResFrame1:GetLeft()").unwrap();
|
||
let r: f32 = s.eval("return MagicResFrame1:GetRight()").unwrap();
|
||
let t: f32 = s.eval("return MagicResFrame1:GetTop()").unwrap();
|
||
let b: f32 = s.eval("return MagicResFrame1:GetBottom()").unwrap();
|
||
((l + r) * 0.5, (t + b) * 0.5)
|
||
};
|
||
s.mouse_move(res_center.0, res_center.1);
|
||
assert!(s.errors().is_empty(), "res hover errors: {:?}", s.errors());
|
||
assert!(
|
||
s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
|
||
"the resistance icon shows a tooltip"
|
||
);
|
||
s.resolve();
|
||
let quads = s.extract();
|
||
let sub_rect = quads
|
||
.iter()
|
||
.find_map(|q| match &q.content {
|
||
QuadContent::Text { text: Some(t), .. }
|
||
if t.starts_with("Increases the ability to resist") =>
|
||
{
|
||
q.rect
|
||
}
|
||
_ => None,
|
||
})
|
||
.expect("the resistance subtext line renders");
|
||
let w = sub_rect.right - sub_rect.left;
|
||
assert!(
|
||
w <= benilla_ui::widget::TOOLTIP_WRAP_WIDTH + 0.5,
|
||
"the subtext line wraps at the tooltip wrap column, got width {w}"
|
||
);
|
||
|
||
// Rotating the model pane advances the booth yaw from the ref's default 0.61 by +0.03/click and
|
||
// plays the rotate kit. The pane's own rotate button calls `Model_RotateRight(this:GetParent())`
|
||
// (stock `PaperDollFrame.xml:265`), the reference's shared turntable out of `UIParent.lua:1421`,
|
||
// declared in our `UIParent.xml` — our `BenillaPaperDollModel_*` pair is gone with the file
|
||
// that used it (decision 1751).
|
||
s.run("Model_RotateRight(CharacterModelFrame)").unwrap();
|
||
assert!(
|
||
(s.model_pane_facing("CharacterModelFrame") - 0.64).abs() < 0.001,
|
||
"yaw = {}",
|
||
s.model_pane_facing("CharacterModelFrame")
|
||
);
|
||
assert_eq!(
|
||
s.take_sounds(),
|
||
vec![SoundRequest::KitName("igInventoryRotateCharacter".into())]
|
||
);
|
||
|
||
// ToggleCharacter() again closes it through HideUIPanel, playing the close kit.
|
||
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
|
||
assert!(s.errors().is_empty(), "close errors: {:?}", s.errors());
|
||
assert!(!s.eval::<bool>("return CharacterFrame:IsVisible()").unwrap());
|
||
assert_eq!(
|
||
s.take_sounds(),
|
||
vec![SoundRequest::KitName("igCharacterInfoClose".into())],
|
||
"closing plays igCharacterInfoClose"
|
||
);
|
||
}
|
||
|
||
/// Regression — the close button's z-order (the reported "missing red X"): the character window
|
||
/// carries no border art of its own; its whole frame-and-background IS `PaperDollFrame`'s
|
||
/// BACKGROUND layer, a full-window page created AFTER the close button. Every frame defaults to
|
||
/// level 0, so within the window draw order is creation order (`order.rs`) — the page's art painted
|
||
/// over the earlier-created button and it vanished. The button's `OnLoad` raises its frame level
|
||
/// (the same idiom `CharacterNameFrame` uses), so its art must paint AFTER every one of the
|
||
/// page's own quads. Checked on the real extracted draw order, not merely the level integer.
|
||
#[test]
|
||
fn close_button_draws_above_the_paper_doll_page() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
for f in super::test_ui::CHARACTER_UI {
|
||
super::test_ui::load_ui_strict(&s, f);
|
||
}
|
||
s.set_unit("player", Some(player_unit()));
|
||
// The window must be SHOWN for extract() to emit quads (hidden="true" by default).
|
||
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
|
||
s.resolve();
|
||
|
||
// extract() returns quads already sorted ascending by draw order, so index == paint order.
|
||
let quads = s.extract();
|
||
let owner = |q: &ExtractedQuad| s.quad_owner_name(q.target);
|
||
|
||
// The window's page art draws first; its LAST quad is the top-most page pixel.
|
||
let page_last = quads
|
||
.iter()
|
||
.rposition(|q| owner(q).as_deref() == Some("PaperDollFrame"))
|
||
.expect("the paper-doll page renders its background art");
|
||
|
||
// The close button's red-X normal texture must render at all …
|
||
let close_x = quads
|
||
.iter()
|
||
.position(|q| {
|
||
owner(q).as_deref() == Some("CharacterFrameCloseButton")
|
||
&& matches!(&q.content,
|
||
QuadContent::Texture { path: Some(p), .. } if p.contains("MinimizeButton-Up"))
|
||
})
|
||
.expect("the close button renders its normal (red-X) texture");
|
||
|
||
// … and it must paint AFTER the page — otherwise it's buried, the reported bug.
|
||
assert!(
|
||
close_x > page_last,
|
||
"close button (draw #{close_x}) must paint after the paper-doll page (last at #{page_last})"
|
||
);
|
||
}
|
||
|
||
/// **The level/race/class line, and the one event that repaints it.** `PaperDollFrame_SetLevel`
|
||
/// formats `PLAYER_LEVEL` ("Level %d %s %s", `GlobalStrings.lua:3075`) from `UnitLevel`/`UnitRace`/
|
||
/// `UnitClass` and writes it to BOTH `CharacterLevelText` and the honor page's `HonorLevelText`
|
||
/// "while we at it" (stock `PaperDollFrame.lua:100-103`); `UNIT_LEVEL` for `"player"` is the only
|
||
/// event that calls it again (`PaperDollFrame.lua:48-49`), and only while the page is visible
|
||
/// (`:42-44`).
|
||
///
|
||
/// **Retired divergence (decision 1751's character swap).** This test used to be
|
||
/// `level_line_survives_no_player_snapshot_yet`: it opened the window with NO player snapshot and
|
||
/// pinned `"Level 0 ? ?"`, because our deleted `assets/ui/CharacterFrame.xml` carried `or "?"`
|
||
/// placeholders around the two nils. The reference has no such guard — `PaperDollFrame.lua:101`
|
||
/// passes `UnitRace("player")` straight into `format`, which raises `bad argument #3 to 'format'`
|
||
/// on a nil — so on the stock file that state is not "survived", it is a hard error, and pinning
|
||
/// our softening would have been pinning the thing the swap deleted.
|
||
///
|
||
/// It is also **unreachable in the app**, which is why nothing replaces the guard rather than the
|
||
/// expectation moving to "it raises": `ui_unit::feed_units` pushes `set_unit("player", …)` earlier
|
||
/// in the same system than it fires `PLAYER_ENTERING_WORLD`, and that fire is gated on our avatar's
|
||
/// descriptor existing (decisions 1087/1094) — while the window can only be opened in-world at all.
|
||
/// So the state under test is the one below: a snapshot is always there first.
|
||
#[test]
|
||
fn level_line_reads_the_snapshot_and_repaints_on_unit_level() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
for f in super::test_ui::CHARACTER_UI {
|
||
super::test_ui::load_ui_strict(&s, f);
|
||
}
|
||
s.set_unit("player", Some(player_unit()));
|
||
s.set_player_combat_stats(Some(combat_stats()));
|
||
|
||
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
|
||
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
|
||
assert_eq!(
|
||
s.eval::<String>("return CharacterLevelText:GetText()")
|
||
.unwrap(),
|
||
"Level 12 Night Elf Warrior"
|
||
);
|
||
// The honor page's twin, written by the same call — the reason `HonorFrame.xml` is in
|
||
// CHARACTER_UI at all (its absence loads clean and raises on the first SHOW).
|
||
assert_eq!(
|
||
s.eval::<String>("return HonorLevelText:GetText()").unwrap(),
|
||
"Level 12 Night Elf Warrior"
|
||
);
|
||
|
||
// Ding: a new snapshot plus `UNIT_LEVEL` for the player, the app's own sequencing.
|
||
let mut leveled = player_unit();
|
||
leveled.level = 13;
|
||
s.set_unit("player", Some(leveled));
|
||
s.fire_event("UNIT_LEVEL", vec![ScriptValue::Str("player".into())]);
|
||
assert!(s.errors().is_empty(), "level-up errors: {:?}", s.errors());
|
||
assert_eq!(
|
||
s.eval::<String>("return CharacterLevelText:GetText()")
|
||
.unwrap(),
|
||
"Level 13 Night Elf Warrior",
|
||
"UNIT_LEVEL repaints the line (PaperDollFrame.lua:48-49)"
|
||
);
|
||
|
||
// …and it is really on screen, not merely in the FontString.
|
||
s.resolve();
|
||
let quads: Vec<ExtractedQuad> = s.extract();
|
||
assert!(
|
||
quads.iter().any(|q| matches!(&q.content,
|
||
QuadContent::Text { text: Some(t), .. } if t == "Level 13 Night Elf Warrior")),
|
||
"the level line renders"
|
||
);
|
||
}
|
||
|
||
/// Decision 0208 phase 1b, end to end: clicking an OCCUPIED doll slot picks the item up onto the
|
||
/// cursor (the SAME payload `GetCursorInfo`/`CursorHasItem` read) and dims the slot's icon
|
||
/// (`IsInventoryItemLocked` — the held-here derivation, no server round-trip). A second click on
|
||
/// the SAME slot cancels: the cursor empties and the dim clears.
|
||
///
|
||
/// **Driven through the mouse since decision 1751's character swap**, not `s.run("Handler(frame)")`:
|
||
/// the stock handler is `PaperDollItemSlotButton_OnClick(button, ignoreModifiers)`
|
||
/// (`PaperDollFrame.lua:647`), which takes the MOUSE button as its first argument and reads the
|
||
/// frame off `this` — and only the engine sets `this`. That also puts the template's own
|
||
/// `RegisterForClicks("LeftButtonUp", "RightButtonUp")` (`PaperDollFrame.lua:86`) and its
|
||
/// `<OnClick>PaperDollItemSlotButton_OnClick(arg1)</OnClick>` (`PaperDollFrame.xml:15-17`) under
|
||
/// test, which the old by-name call skipped.
|
||
///
|
||
/// **Retired divergence:** the dim used to be asserted as "darker than 0.5". The reference's exact
|
||
/// value is 0.5 — `PaperDollItemSlotButton_UpdateLock` calls
|
||
/// `SetItemButtonDesaturated(this, 1, 0.5, 0.5, 0.5)` (`PaperDollFrame.lua:729-737`), and
|
||
/// `ItemButtonTemplate.lua:61-82` both desaturates the icon and vertex-colours it with exactly
|
||
/// those numbers. So the assertion is the pair, `desaturated` included.
|
||
#[test]
|
||
fn clicking_an_occupied_doll_slot_picks_it_up_and_locks_it() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
for f in super::test_ui::CHARACTER_UI {
|
||
super::test_ui::load_ui_strict(&s, f);
|
||
}
|
||
s.set_unit("player", Some(player_unit()));
|
||
s.set_player_combat_stats(Some(combat_stats()));
|
||
s.set_inventory_slots(inventory_with_head_item());
|
||
// The window must be SHOWN for `extract()` to emit its quads below (hidden="true" by
|
||
// default) — the icon-dim check needs a real render pass, not just the engine-side state.
|
||
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
|
||
s.take_sounds();
|
||
s.resolve();
|
||
|
||
assert!(!s.eval::<bool>("return IsInventoryItemLocked(1)").unwrap());
|
||
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
|
||
|
||
super::test_ui::click(&mut s, "CharacterHeadSlot", "LeftButton");
|
||
// The repaint is one frame behind the pickup, and that is the reference's shape, not a
|
||
// convenience: `PickupInventoryItem` locks the slot and QUEUES `ITEM_LOCK_CHANGED(-1, id)`
|
||
// (`benilla-ui/src/script/cursor/doll.rs:78`, the one transition seam of decision 0216), and
|
||
// that event is what runs `PaperDollItemSlotButton_UpdateLock` (stock
|
||
// `PaperDollFrame.lua:601-604`). The app ticks every frame; a test has to say so.
|
||
s.tick(0.0);
|
||
assert!(s.errors().is_empty(), "click errors: {:?}", s.errors());
|
||
|
||
assert!(s.eval::<bool>("return CursorHasItem()").unwrap());
|
||
let (kind, id) = s
|
||
.eval::<(String, i64)>("local k, id = GetCursorInfo() return k, id")
|
||
.unwrap();
|
||
assert_eq!((kind.as_str(), id), ("item", 1234));
|
||
assert!(
|
||
s.eval::<bool>("return IsInventoryItemLocked(1)").unwrap(),
|
||
"the picked slot locks"
|
||
);
|
||
// The icon desaturates and vertex-dims to the reference's own 0.5 triple.
|
||
s.resolve();
|
||
let head_icon_dim = s.extract().iter().any(|q| {
|
||
matches!(&q.content, QuadContent::Texture { path: Some(p), color: Some(c), desaturated, .. }
|
||
if p.contains("INV_Helmet_01") && *desaturated && c[0] == 0.5 && c[1] == 0.5 && c[2] == 0.5)
|
||
});
|
||
assert!(head_icon_dim, "the picked slot's icon dims");
|
||
|
||
// Clicking the SAME slot again cancels — mirrors the bag's own same-slot-cancel contract.
|
||
super::test_ui::click(&mut s, "CharacterHeadSlot", "LeftButton");
|
||
s.tick(0.0);
|
||
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
|
||
assert!(!s.eval::<bool>("return IsInventoryItemLocked(1)").unwrap());
|
||
s.resolve();
|
||
let head_icon_lit = s.extract().iter().any(|q| {
|
||
matches!(&q.content, QuadContent::Texture { path: Some(p), color: Some(c), desaturated, .. }
|
||
if p.contains("INV_Helmet_01") && !*desaturated && c[0] == 1.0)
|
||
});
|
||
assert!(head_icon_lit, "cancelling clears the dim");
|
||
}
|
||
|
||
/// Decision 0208 phase 1b's `CURSOR_UPDATE` highlight: holding a BAG item that fits ONLY the
|
||
/// head slot lights the head slot's highlight ring and leaves every other slot dark (the neck slot
|
||
/// checked here). Stock `PaperDollItemSlotButton_OnEvent`'s `CURSOR_UPDATE` arm is
|
||
/// `if ( CursorCanGoInSlot(this:GetID()) ) then this:LockHighlight() else this:UnlockHighlight()`
|
||
/// (`PaperDollFrame.lua:609-616`).
|
||
///
|
||
/// **Retired divergence (decision 1751's character swap).** This used to spy on a pair of our own
|
||
/// `BenillaPaperDollSlot_LockHighlight`/`_UnlockHighlight` wrappers, which vertex-tinted the
|
||
/// Quickslot2 ring because the engine had no `LockHighlight`. It has both verbs now
|
||
/// (`benilla-ui/src/script/button.rs`), the stock file calls them, and the emulation is gone with
|
||
/// the file that held it — so the assertion is the real locked-highlight state, read where a player
|
||
/// reads it: `ItemButtonTemplate`'s own `HighlightTexture`
|
||
/// (`Interface\Buttons\ButtonHilight-Square`, `ItemButtonTemplate.xml:44`) is emitted for a locked
|
||
/// button and not for an unlocked, unhovered one (`ButtonState::region_visible`). The mouse is
|
||
/// parked clear of the doll first so nothing is hovered and the lock is the only thing that can
|
||
/// light a ring.
|
||
#[test]
|
||
fn cursor_update_highlights_fitting_doll_slots_while_holding_a_bag_item() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
for f in super::test_ui::CHARACTER_UI {
|
||
super::test_ui::load_ui_strict(&s, f);
|
||
}
|
||
s.set_unit("player", Some(player_unit()));
|
||
s.set_player_combat_stats(Some(combat_stats()));
|
||
// The window must be SHOWN for extract() to emit the slots' quads at all.
|
||
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
|
||
super::test_ui::unhover(&mut s);
|
||
s.resolve();
|
||
|
||
/// Is `slot`'s highlight ring in the render list? (`None` for "no such owner drew anything".)
|
||
fn ring_lit(s: &mut UiScript, slot: &str) -> bool {
|
||
s.resolve();
|
||
let quads = s.extract();
|
||
quads.iter().any(|q| {
|
||
s.quad_owner_name(q.target).as_deref() == Some(slot)
|
||
&& matches!(&q.content,
|
||
QuadContent::Texture { path: Some(p), .. } if p.contains("ButtonHilight-Square"))
|
||
})
|
||
}
|
||
|
||
assert!(
|
||
!ring_lit(&mut s, "CharacterHeadSlot"),
|
||
"nothing is held yet, so no ring is lit"
|
||
);
|
||
|
||
s.set_container(0, Some(backpack_with_fitting_helm()));
|
||
s.run("PickupContainerItem(0, 1)").unwrap();
|
||
s.tick(0.0); // dispatches the queued CURSOR_UPDATE to every registered doll slot
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
|
||
// The engine's own predicate agrees with the fixture's `equip_slots: vec![1]` — the input the
|
||
// reference's arm branches on, pinned beside the effect so a wrong answer here can't read as a
|
||
// wiring failure there.
|
||
assert!(s.eval::<bool>("return CursorCanGoInSlot(1)").unwrap());
|
||
assert!(!s.eval::<bool>("return CursorCanGoInSlot(2)").unwrap());
|
||
|
||
assert!(
|
||
ring_lit(&mut s, "CharacterHeadSlot"),
|
||
"the fitting slot locks its highlight"
|
||
);
|
||
assert!(
|
||
!ring_lit(&mut s, "CharacterNeckSlot"),
|
||
"a non-fitting slot stays unhighlighted"
|
||
);
|
||
}
|
||
|
||
/// The model pane's click-with-payload path (ref `CharacterModelFrame_OnMouseUp`): a left-click
|
||
/// release on the model pane while holding a fitting BAG item auto-equips it — the queued
|
||
/// `(bag, slot)` source, cursor cleared.
|
||
#[test]
|
||
fn model_pane_click_auto_equips_a_held_bag_item() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
for f in super::test_ui::CHARACTER_UI {
|
||
super::test_ui::load_ui_strict(&s, f);
|
||
}
|
||
s.set_unit("player", Some(player_unit()));
|
||
s.set_player_combat_stats(Some(combat_stats()));
|
||
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
|
||
s.take_sounds();
|
||
s.resolve();
|
||
|
||
s.set_container(0, Some(backpack_with_fitting_helm()));
|
||
s.run("PickupContainerItem(0, 1)").unwrap();
|
||
assert!(s.eval::<bool>("return CursorHasItem()").unwrap());
|
||
|
||
let center = {
|
||
let l: f32 = s.eval("return CharacterModelFrame:GetLeft()").unwrap();
|
||
let r: f32 = s.eval("return CharacterModelFrame:GetRight()").unwrap();
|
||
let t: f32 = s.eval("return CharacterModelFrame:GetTop()").unwrap();
|
||
let b: f32 = s.eval("return CharacterModelFrame:GetBottom()").unwrap();
|
||
((l + r) * 0.5, (t + b) * 0.5)
|
||
};
|
||
s.mouse_button(center.0, center.1, "LeftButton", true);
|
||
s.mouse_button(center.0, center.1, "LeftButton", false);
|
||
assert!(s.errors().is_empty(), "click errors: {:?}", s.errors());
|
||
|
||
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
|
||
assert_eq!(s.take_container_autoequips(), vec![(0, 1)]);
|
||
}
|
||
|
||
/// A BROKEN equipped item (durability 0) paints its doll slot's **ring** red — the reference's own
|
||
/// 0.9,0,0 on that slot's `NormalTexture` (`Interface\Buttons\UI-Quickslot2`,
|
||
/// `ItemButtonTemplate.xml:31`). Repairing it restores the ring to white, and no neighbouring slot
|
||
/// is ever tinted. Director-directed with the armor guy.
|
||
///
|
||
/// **Retired divergence (decision 1751's character swap): the ICON does not go red, and in the
|
||
/// reference it never did.** Stock `PaperDollItemSlotButton_Update` really does call BOTH setters
|
||
/// on a broken item — `SetItemButtonTextureVertexColor(this, 0.9, 0, 0)` and
|
||
/// `SetItemButtonNormalTextureVertexColor(this, 0.9, 0, 0)` (`PaperDollFrame.lua:670-672`) — and
|
||
/// `ItemButtonTemplate.lua` paints `$parentIconTexture` from the first and `$parentNormalTexture`
|
||
/// from the second (`:53-59` / `:84-90`). But the same `Update` ends with
|
||
/// `PaperDollItemSlotButton_UpdateLock()` (`:714`), whose unlocked arm is
|
||
/// `SetItemButtonDesaturated(this, nil)` (`:735`), and that function's `if ( not desaturated )`
|
||
/// branch overwrites `r,g,b` with 1,1,1 and re-`SetVertexColor`s **the icon**
|
||
/// (`ItemButtonTemplate.lua:71-81`). Forty-four lines later in the same call, the icon's red is
|
||
/// gone; only the ring survives. Our deleted `assets/ui/CharacterFrame.xml` painted both and made
|
||
/// them stick, which is why this test asserted the icon — that half is the transcription's, not the
|
||
/// reference's, and it goes with the file. Verified on a real run: the head slot's `INV_Helmet_01`
|
||
/// quad extracts white while its `UI-Quickslot2` quad extracts [0.9, 0, 0, 1].
|
||
#[test]
|
||
fn broken_equipped_item_tints_its_doll_slot_red() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
for f in super::test_ui::CHARACTER_UI {
|
||
super::test_ui::load_ui_strict(&s, f);
|
||
}
|
||
s.set_unit("player", Some(player_unit()));
|
||
s.set_player_combat_stats(Some(combat_stats()));
|
||
|
||
let mut inv = inventory_with_head_item();
|
||
inv[1].as_mut().unwrap().durability = Some((0, 40));
|
||
s.set_inventory_slots(inv);
|
||
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
|
||
s.fire_event(
|
||
"UNIT_INVENTORY_CHANGED",
|
||
vec![ScriptValue::Str("player".into())],
|
||
);
|
||
assert!(s.errors().is_empty(), "update errors: {:?}", s.errors());
|
||
s.resolve();
|
||
/// The colour of `slot`'s own quad whose texture path contains `needle` — asked per OWNER, so
|
||
/// the nineteen slots that share the ring art can be told apart.
|
||
fn slot_color(s: &mut UiScript, slot: &str, needle: &str) -> Option<[f32; 4]> {
|
||
s.resolve();
|
||
let quads = s.extract();
|
||
quads.iter().find_map(|q| match &q.content {
|
||
QuadContent::Texture {
|
||
path: Some(p),
|
||
color,
|
||
..
|
||
} if p.contains(needle) && s.quad_owner_name(q.target).as_deref() == Some(slot) => {
|
||
Some(color.unwrap_or([1.0, 1.0, 1.0, 1.0]))
|
||
}
|
||
_ => None,
|
||
})
|
||
}
|
||
let red = [0.9, 0.0, 0.0, 1.0];
|
||
let white = [1.0, 1.0, 1.0, 1.0];
|
||
assert_eq!(
|
||
slot_color(&mut s, "CharacterHeadSlot", "Quickslot2"),
|
||
Some(red),
|
||
"the broken slot's ring paints red"
|
||
);
|
||
assert_eq!(
|
||
slot_color(&mut s, "CharacterNeckSlot", "Quickslot2"),
|
||
Some(white),
|
||
"…and only that slot's — its neighbours share the art and rest white"
|
||
);
|
||
// The icon stays white: see the retired divergence in this test's doc comment.
|
||
assert_eq!(
|
||
slot_color(&mut s, "CharacterHeadSlot", "INV_Helmet_01"),
|
||
Some(white),
|
||
"the icon's red is overwritten by UpdateLock's SetItemButtonDesaturated(this, nil)"
|
||
);
|
||
|
||
// Repaired: the ring restores to white.
|
||
let mut inv = inventory_with_head_item();
|
||
inv[1].as_mut().unwrap().durability = Some((40, 40));
|
||
s.set_inventory_slots(inv);
|
||
s.fire_event(
|
||
"UNIT_INVENTORY_CHANGED",
|
||
vec![ScriptValue::Str("player".into())],
|
||
);
|
||
s.resolve();
|
||
assert_eq!(
|
||
slot_color(&mut s, "CharacterHeadSlot", "Quickslot2"),
|
||
Some(white),
|
||
"repair restores the ring"
|
||
);
|
||
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// The director's own gesture, by screen point (2026-07-17 report: "tab switching flaky, can't
|
||
/// switch back to Character from Skills"): open the window, click the Skills tab, click a skill
|
||
/// row (arming the detail pane), click the Character tab, and round-trip once more — every click
|
||
/// routed through the pointer pipeline (`mouse_move`/`mouse_button`) exactly as the app does, so
|
||
/// a frame silently eating the tab row's clicks (or a handler error breaking the chain) fails
|
||
/// HERE, not on the director's screen. Zero collected handler errors allowed anywhere.
|
||
#[test]
|
||
fn tab_round_trip_with_a_selected_skill_by_point() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
use benilla_ui::script::{SkillEntry, SkillsState};
|
||
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
for f in super::test_ui::CHARACTER_UI {
|
||
super::test_ui::load_ui_strict(&s, f);
|
||
}
|
||
// `ScrollTemplates.xml` and `SkillFrame.xml` used to be loaded again here, because the old
|
||
// per-file preamble did not carry them and a MISSING template is a loader *warning*, not an
|
||
// error (`SkillDetailScrollFrame` inherits `UIPanelScrollFrameTemplate`, so it would have
|
||
// loaded clean with no trough and failed later on geometry that silently never got built).
|
||
// `CHARACTER_UI` carries both now — `CharacterFrame_ShowSubFrame` hides all five pages by name
|
||
// on the first `ToggleCharacter` — and `load_ui_strict` fails on the warning, so a second load
|
||
// would only re-declare frames the list already built.
|
||
s.set_unit("player", Some(player_unit()));
|
||
s.set_skills(SkillsState {
|
||
entries: vec![
|
||
SkillEntry {
|
||
skill_id: 95,
|
||
name: "Defense".into(),
|
||
value: 12,
|
||
max: 60,
|
||
temp_bonus: 0,
|
||
perm_bonus: 0,
|
||
min_level: 0,
|
||
cost_index: 0,
|
||
category_id: 6,
|
||
category_name: "Weapon Skills".into(),
|
||
category_order: 1,
|
||
description: "Defensive expertise.".into(),
|
||
abandonable: false,
|
||
mono: false,
|
||
},
|
||
SkillEntry {
|
||
skill_id: 164,
|
||
name: "Blacksmithing".into(),
|
||
value: 62,
|
||
max: 75,
|
||
temp_bonus: 0,
|
||
perm_bonus: 0,
|
||
min_level: 0,
|
||
cost_index: 0,
|
||
category_id: 11,
|
||
category_name: "Professions".into(),
|
||
category_order: 2,
|
||
description: "Working with metals.".into(),
|
||
// The real 5875 split: primary professions carry SkillRaceClassInfo 0x20.
|
||
abandonable: true,
|
||
mono: false,
|
||
},
|
||
],
|
||
});
|
||
|
||
// The center of the unique visible Text quad `text` — resolves layout first, so it's always
|
||
// the CURRENT rect. Panics with the visible-text inventory if absent (a paint regression).
|
||
fn text_center(s: &mut UiScript, text: &str) -> (f32, f32) {
|
||
s.resolve();
|
||
let quads = s.extract();
|
||
let rect = quads
|
||
.iter()
|
||
.find_map(|q| match &q.content {
|
||
QuadContent::Text { text: Some(t), .. } if t == text => q.rect,
|
||
_ => None,
|
||
})
|
||
.unwrap_or_else(|| {
|
||
let visible: Vec<String> = quads
|
||
.iter()
|
||
.filter_map(|q| match &q.content {
|
||
QuadContent::Text { text: Some(t), .. } => Some(t.clone()),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
panic!("no visible text quad {text:?}; visible texts: {visible:?}");
|
||
});
|
||
(
|
||
(rect.left + rect.right) * 0.5,
|
||
(rect.bottom + rect.top) * 0.5,
|
||
)
|
||
}
|
||
fn click(s: &mut UiScript, (x, y): (f32, f32)) {
|
||
s.mouse_move(x, y);
|
||
s.mouse_button(x, y, "LeftButton", true);
|
||
s.mouse_button(x, y, "LeftButton", false);
|
||
}
|
||
let shown = |s: &mut UiScript, name: &str| {
|
||
s.eval::<bool>(&format!("return {name}:IsVisible()"))
|
||
.unwrap()
|
||
};
|
||
|
||
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
|
||
assert!(shown(&mut s, "PaperDollFrame"), "opens on the doll page");
|
||
|
||
// Tab to Skills — by point, the director's gesture.
|
||
let skills_tab = text_center(&mut s, "Skills");
|
||
click(&mut s, skills_tab);
|
||
assert!(shown(&mut s, "SkillFrame"), "Skills tab shows the page");
|
||
assert!(!shown(&mut s, "PaperDollFrame"), "doll page yields");
|
||
|
||
// Select a skill row — arms the detail pane (the report's precondition).
|
||
let row = text_center(&mut s, "Blacksmithing");
|
||
click(&mut s, row);
|
||
assert!(
|
||
s.eval::<i64>("return GetSelectedSkill()").unwrap() > 0,
|
||
"the row click selects"
|
||
);
|
||
assert!(shown(&mut s, "SkillDetailStatusBar"), "detail bar arms");
|
||
// The description body renders through the SKILL_DESCRIPTION format (skillType is "" in every
|
||
// reachable 1.12 branch) — the nil-global half of the report, pinned.
|
||
assert_eq!(
|
||
s.eval::<String>("return SkillDetailDescriptionText:GetText()")
|
||
.unwrap(),
|
||
"|cffffffff|r Working with metals.",
|
||
"the detail description renders via SKILL_DESCRIPTION"
|
||
);
|
||
|
||
// The abandon slice: a primary profession offers the unlearn button; its confirm formats the
|
||
// name; accepting queues the CMSG_UNLEARN_SKILL intent BY SKILL ID and removes nothing
|
||
// locally (the server's SetSkill(id,0,0) round trip owns the removal).
|
||
assert!(
|
||
shown(&mut s, "SkillDetailStatusBarUnlearnButton"),
|
||
"the unlearn button shows for a profession"
|
||
);
|
||
s.run("SkillDetailStatusBarUnlearnButton:Click()").unwrap();
|
||
assert!(
|
||
shown(&mut s, "StaticPopup1"),
|
||
"the UNLEARN_SKILL confirm opens"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return StaticPopup1Text:GetText()")
|
||
.unwrap(),
|
||
"Do you want to unlearn Blacksmithing?"
|
||
);
|
||
s.run("StaticPopup1Button1:Click()").unwrap();
|
||
assert_eq!(
|
||
s.take_skill_abandons(),
|
||
vec![164],
|
||
"accept queues the skill id"
|
||
);
|
||
assert!(!shown(&mut s, "StaticPopup1"), "accept closes the confirm");
|
||
assert_eq!(
|
||
s.eval::<i64>("return GetNumSkillLines()").unwrap(),
|
||
4,
|
||
"nothing is removed locally"
|
||
);
|
||
|
||
// A weapon line never offers it.
|
||
let defense = text_center(&mut s, "Defense");
|
||
click(&mut s, defense);
|
||
assert!(
|
||
!shown(&mut s, "SkillDetailStatusBarUnlearnButton"),
|
||
"no unlearn button for Defense"
|
||
);
|
||
// Restore the profession selection so the round trip below leaves familiar state.
|
||
let row = text_center(&mut s, "Blacksmithing");
|
||
click(&mut s, row);
|
||
|
||
// Tab BACK to Character — the reported failure.
|
||
let character_tab = text_center(&mut s, "Character");
|
||
assert_eq!(
|
||
s.hit_test_name(character_tab.0, character_tab.1).as_deref(),
|
||
Some("CharacterFrameTab1"),
|
||
"the Character tab OWNS its point while Skills is up (the wheel catcher must not)"
|
||
);
|
||
click(&mut s, character_tab);
|
||
assert!(
|
||
shown(&mut s, "PaperDollFrame"),
|
||
"the Character tab switches back (the 2026-07-17 report)"
|
||
);
|
||
assert!(!shown(&mut s, "SkillFrame"), "Skills page yields");
|
||
|
||
// Once more around — "flaky" only shows up on repetition.
|
||
let skills_tab = text_center(&mut s, "Skills");
|
||
click(&mut s, skills_tab);
|
||
assert!(shown(&mut s, "SkillFrame"), "second trip to Skills");
|
||
let character_tab = text_center(&mut s, "Character");
|
||
click(&mut s, character_tab);
|
||
assert!(shown(&mut s, "PaperDollFrame"), "second trip back");
|
||
|
||
assert!(
|
||
s.errors().is_empty(),
|
||
"zero handler errors across the whole trip: {:?}",
|
||
s.errors()
|
||
);
|
||
}
|
||
|
||
/// The rotate arrows, driven by the pointer exactly as a hand does (the director's report:
|
||
/// "the 2 arrows for turning barely move it at all"). The ref's feel is TWO mechanisms, and the
|
||
/// pane used to ship only the smaller one:
|
||
///
|
||
/// - a **tap** fires `OnClick` on BOTH edges (`RegisterForClicks("LeftButtonDown","LeftButtonUp")`,
|
||
/// ref PaperDollFrame.xml l.241-243) — 2 × 0.03 rad, ~3.4°, which is all a tap is meant to be;
|
||
/// - **holding** spins at `ROTATIONS_PER_SECOND` = half a turn per second through the pane's
|
||
/// `OnUpdate` (ref `Model_OnUpdate`, UIParent.lua:1444-1462) — 100× the tap per second, and the
|
||
/// only reason the arrows feel like anything.
|
||
///
|
||
/// The ref's held branches also run OPPOSITE to its click helpers (held-LEFT adds where
|
||
/// click-LEFT subtracts); that quirk is quoted, so it is asserted here rather than "corrected".
|
||
#[test]
|
||
fn rotate_arrows_tap_twice_and_spin_while_held() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
for f in super::test_ui::CHARACTER_UI {
|
||
super::test_ui::load_ui_strict(&s, f);
|
||
}
|
||
s.set_unit("player", Some(player_unit()));
|
||
s.set_player_combat_stats(Some(combat_stats()));
|
||
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
|
||
s.resolve();
|
||
|
||
// The left arrow's centre, in screen points.
|
||
let btn = "CharacterModelFrameRotateLeftButton";
|
||
let (bx, by) = {
|
||
let l: f32 = s.eval(&format!("return {btn}:GetLeft()")).unwrap();
|
||
let r: f32 = s.eval(&format!("return {btn}:GetRight()")).unwrap();
|
||
let t: f32 = s.eval(&format!("return {btn}:GetTop()")).unwrap();
|
||
let b: f32 = s.eval(&format!("return {btn}:GetBottom()")).unwrap();
|
||
((l + r) * 0.5, (t + b) * 0.5)
|
||
};
|
||
|
||
// Press and HOLD. The press edge is itself a click (−0.03 off the 0.61 default).
|
||
s.mouse_move(bx, by);
|
||
s.mouse_button(bx, by, "LeftButton", true);
|
||
let pressed = s.model_pane_facing("CharacterModelFrame");
|
||
assert!(
|
||
(pressed - 0.58).abs() < 1e-4,
|
||
"the press edge nudges once: {pressed}"
|
||
);
|
||
|
||
// Held for half a second: half of half a turn = +π/2 (the held-LEFT branch ADDS).
|
||
s.tick(0.5);
|
||
assert!(s.errors().is_empty(), "OnUpdate errors: {:?}", s.errors());
|
||
let spun = s.model_pane_facing("CharacterModelFrame");
|
||
assert!(
|
||
(spun - (pressed + std::f32::consts::FRAC_PI_2)).abs() < 1e-3,
|
||
"half a second held spins half of half a turn: {pressed} → {spun}"
|
||
);
|
||
|
||
// Release: the second click edge (another −0.03), and the spin stops dead.
|
||
s.mouse_button(bx, by, "LeftButton", false);
|
||
let released = s.model_pane_facing("CharacterModelFrame");
|
||
assert!(
|
||
(released - (spun - 0.03)).abs() < 1e-4,
|
||
"the release edge nudges again: {spun} → {released}"
|
||
);
|
||
s.tick(0.5);
|
||
assert!(
|
||
(s.model_pane_facing("CharacterModelFrame") - released).abs() < 1e-6,
|
||
"a released button does not keep spinning"
|
||
);
|
||
|
||
// The right arrow spins the other way (held-RIGHT subtracts).
|
||
let rbtn = "CharacterModelFrameRotateRightButton";
|
||
let (rx, ry) = {
|
||
let l: f32 = s.eval(&format!("return {rbtn}:GetLeft()")).unwrap();
|
||
let r: f32 = s.eval(&format!("return {rbtn}:GetRight()")).unwrap();
|
||
let t: f32 = s.eval(&format!("return {rbtn}:GetTop()")).unwrap();
|
||
let b: f32 = s.eval(&format!("return {rbtn}:GetBottom()")).unwrap();
|
||
((l + r) * 0.5, (t + b) * 0.5)
|
||
};
|
||
s.mouse_move(rx, ry);
|
||
s.mouse_button(rx, ry, "LeftButton", true);
|
||
let before = s.model_pane_facing("CharacterModelFrame");
|
||
s.tick(0.25);
|
||
let after = s.model_pane_facing("CharacterModelFrame");
|
||
assert!(
|
||
(after - (before - std::f32::consts::FRAC_PI_4)).abs() < 1e-3,
|
||
"the right arrow spins the other way: {before} → {after}"
|
||
);
|
||
s.mouse_button(rx, ry, "LeftButton", false);
|
||
assert!(s.errors().is_empty(), "no handler errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// **The keybind and the tab row must agree** (the director's 2026-08-06 report: *"go on skills tab
|
||
/// and press c, it goes back to char tab but without switching the actual tab ui below properly,
|
||
/// then if I click on char tab, it closes the whole window"*).
|
||
///
|
||
/// The cause was a dropped line: the reference selects the tab inside `ToggleCharacter` itself
|
||
/// (`PanelTemplates_SetTab(CharacterFrame, subFrame:GetID())`, ref `CharacterFrame.lua:11`), on
|
||
/// EVERY path. Ours only did it in the tab button's own `OnClick`, so the two entry points into the
|
||
/// window diverged: a keybind switched the page and left the row selecting the old tab, and the
|
||
/// next click on the *now-showing* page's tab hit `ToggleCharacter`'s "this page is already
|
||
/// visible" branch and shut the window instead of switching back.
|
||
///
|
||
/// Driven through the same entry point the `C` / `SHIFT-P` bindings use — a bare
|
||
/// `ToggleCharacter(page)` call, never the button — because that is precisely the path the row used
|
||
/// to miss.
|
||
#[test]
|
||
fn a_keybind_page_switch_moves_the_tab_row_with_it() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
for f in super::test_ui::CHARACTER_UI {
|
||
super::test_ui::load_ui_strict(&s, f);
|
||
}
|
||
s.set_unit("player", Some(player_unit()));
|
||
|
||
// THE INVARIANT the fix rests on: a page's `id=` is the slot in this window's tab row that the
|
||
// tab row's own dispatch sends to that page. `ToggleCharacter` selects the row with
|
||
// `PanelTemplates_SetTab(CharacterFrame, subFrame:GetID())` (stock `CharacterFrame.lua:10`), so
|
||
// if a page's id and its tab's id ever disagree, a keybind lights the wrong tab.
|
||
//
|
||
// **Retired divergence (decision 1751's character swap).** This used to loop over
|
||
// `BENILLA_CHARACTERFRAME_SUBFRAMES` asserting `page i must carry id=i`, because our deleted
|
||
// `assets/ui/CharacterFrame.xml` listed the pages in tab order. The reference does not:
|
||
// `CHARACTERFRAME_SUBFRAMES` is `{PaperDoll, PetPaperDoll, Skill, Reputation, Honor}`
|
||
// (`CharacterFrame.lua:1`) while the tab row is Character/Pet/**Reputation**/**Skills**/Honor
|
||
// (`CharacterFrame.xml:79-168`) — index 3 is SkillFrame with id 4, index 4 is ReputationFrame
|
||
// with id 3. That list is a set to iterate for show/hide, not an ordering, so the ordering
|
||
// assertion moves to where the reference really states it: `CharacterFrameTab_OnClick`'s five
|
||
// name branches (`CharacterFrame.lua:35-48`).
|
||
let row: [(i64, &str); 5] = [
|
||
(1, "PaperDollFrame"),
|
||
(2, "PetPaperDollFrame"),
|
||
(3, "ReputationFrame"),
|
||
(4, "SkillFrame"),
|
||
(5, "HonorFrame"),
|
||
];
|
||
for (id, page) in row {
|
||
assert_eq!(
|
||
s.eval::<i64>(&format!("return {page}:GetID()")).unwrap(),
|
||
id,
|
||
"{page} is the page CharacterFrameTab{id} toggles, so it must carry id={id}"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<i64>(&format!("return CharacterFrameTab{id}:GetID()"))
|
||
.unwrap(),
|
||
id,
|
||
);
|
||
// …and it is one of the five the show/hide sweep knows about, so a page cannot arrive with
|
||
// a tab and stay invisible to `CharacterFrame_ShowSubFrame` (`CharacterFrame.lua:25-33`).
|
||
assert!(
|
||
s.eval::<bool>(&format!(
|
||
"for _, v in CHARACTERFRAME_SUBFRAMES do if v == \"{page}\" then return true end \
|
||
end return false"
|
||
))
|
||
.unwrap(),
|
||
"{page} must be in CHARACTERFRAME_SUBFRAMES"
|
||
);
|
||
}
|
||
assert_eq!(
|
||
s.eval::<i64>("return getn(CHARACTERFRAME_SUBFRAMES)")
|
||
.unwrap(),
|
||
5,
|
||
"…and there is nothing past the end for a sixth page to arrive into unnoticed"
|
||
);
|
||
|
||
let selected = |s: &mut UiScript| {
|
||
s.eval::<i64>("return PanelTemplates_GetSelectedTab(CharacterFrame)")
|
||
.unwrap()
|
||
};
|
||
// A tab wears its "Active" (=Disabled) art exactly when it is the selected one — the visible
|
||
// half of the same fact, so a SetTab that updated the number without repainting fails here too.
|
||
let wearing_active_art = |s: &mut UiScript, tab: u32| {
|
||
s.eval::<bool>(&format!(
|
||
"return CharacterFrameTab{tab}MiddleDisabled:IsVisible()"
|
||
))
|
||
.unwrap()
|
||
};
|
||
let shown = |s: &mut UiScript, name: &str| {
|
||
s.eval::<bool>(&format!("return {name}:IsVisible()"))
|
||
.unwrap()
|
||
};
|
||
|
||
// Open on Character (the `C` binding), then move to Skills the same way.
|
||
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
|
||
assert_eq!(selected(&mut s), 1);
|
||
s.run(r#"ToggleCharacter("SkillFrame")"#).unwrap();
|
||
assert!(shown(&mut s, "SkillFrame"));
|
||
assert_eq!(selected(&mut s), 4, "the row follows a keybind to Skills");
|
||
assert!(wearing_active_art(&mut s, 4));
|
||
assert!(!wearing_active_art(&mut s, 1));
|
||
|
||
// THE REPORT: `C` from the Skills page. The page goes back to Character — and so must the row.
|
||
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
|
||
assert!(shown(&mut s, "PaperDollFrame"));
|
||
assert!(!shown(&mut s, "SkillFrame"));
|
||
assert_eq!(
|
||
selected(&mut s),
|
||
1,
|
||
"the tab row followed the keybind back to Character"
|
||
);
|
||
assert!(wearing_active_art(&mut s, 1));
|
||
assert!(!wearing_active_art(&mut s, 4));
|
||
|
||
// …and the second half of the report — *"then if I click on char tab, it closes the whole
|
||
// window"* — falls out of the same fix rather than needing its own. `PanelTemplates_SelectTab`
|
||
// DISABLES the tab it selects (a selected tab is not re-clickable), so with the row in step the
|
||
// Character tab is inert while the Character page is up. The window only closed because the row
|
||
// still said Skills: tab 1 was left enabled, the click reached `ToggleCharacter`, and the page
|
||
// it named was already visible — the HideUIPanel arm.
|
||
//
|
||
// The MECHANISM is asserted first and the click second, deliberately: a tab whose OnClick never
|
||
// ran would sail through the click assertions below for the wrong reason, and this is the one
|
||
// fact that cannot.
|
||
assert_eq!(
|
||
s.eval::<i64>("return CharacterFrameTab1:IsEnabled()")
|
||
.unwrap(),
|
||
0,
|
||
"the selected tab is DISABLED — that is what makes the click below inert"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<i64>("return CharacterFrameTab4:IsEnabled()")
|
||
.unwrap(),
|
||
1,
|
||
"…while an unselected one stays clickable"
|
||
);
|
||
s.run("CharacterFrameTab1:Click()").unwrap();
|
||
assert!(
|
||
shown(&mut s, "CharacterFrame"),
|
||
"the selected tab is disabled — clicking it cannot close the window"
|
||
);
|
||
assert!(shown(&mut s, "PaperDollFrame"), "…or change the page");
|
||
assert!(s.errors().is_empty(), "no handler errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// **The tab kit's XML-facing entry point, driven the way an addon drives it.**
|
||
///
|
||
/// Four corpus addons (Enchantrix, Outfitter, SimpleActionSets, TheoryCraft) put
|
||
/// `PanelTemplates_Tab_OnClick(<frame>)` straight into a tab's `<OnClick>` and let the kit do the
|
||
/// rest; this client had every other member of the kit and not that one, because our own windows
|
||
/// wire their tabs to their own handlers and never reached for the generic entry point.
|
||
///
|
||
/// **It is built on a row of this test's own, and that is deliberate.** When it was written our
|
||
/// character row carried no `id=` at all (it was id-based through a `BenillaCharacterFrameTab_OnClick(id)`
|
||
/// closure), so driving it through the generic entry point would have selected tab 0. Decision
|
||
/// 1751's swap fixed that from the other end — the row is the reference's own now and every tab
|
||
/// carries its id (`CharacterFrame.xml:79-168`) — but the standalone row stays: this test is about
|
||
/// the KIT, and an addon's window is exactly what it builds, so it must not need a shipped window
|
||
/// loaded to run.
|
||
///
|
||
/// The `this`/frame split is the whole reason the function exists: `this` is the clicked TAB and
|
||
/// the owning FRAME is a separate argument, which is the shape `SimpleActionSets.xml:342` relies on
|
||
/// when it passes `this:GetParent()`.
|
||
#[test]
|
||
fn an_addons_tab_click_selects_through_the_generic_entry_point() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
||
load_xml(&s, "MoneyFrame.xml");
|
||
load_xml(&s, "UiPanels.xml");
|
||
// The reference's `PanelTemplates_SelectTab` ends with `if GameTooltip:IsOwned(tab)` —
|
||
// an arm our deleted copy omitted ("our tabs set no tooltip"), so selecting a tab now needs
|
||
// the tooltip to exist (1860).
|
||
load_xml(&s, "GameTooltip.xml");
|
||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
||
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
||
load_xml(&s, "UIParent.xml");
|
||
|
||
// A conforming row: tabs named `<frame>Tab1..N` (what `PanelTemplates_UpdateTabs` getglobals)
|
||
// and each carrying its own id, exactly as an addon's XML declares them.
|
||
s.run(
|
||
r#"
|
||
TabKitFrame = CreateFrame("Frame", "TabKitFrame", UIParent)
|
||
PanelTemplates_SetNumTabs(TabKitFrame, 2)
|
||
for i = 1, 2 do
|
||
local t = CreateFrame("Button", "TabKitFrameTab" .. i, TabKitFrame,
|
||
"CharacterFrameTabButtonTemplate")
|
||
t:SetID(i)
|
||
end
|
||
PanelTemplates_SetTab(TabKitFrame, 1)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let selected = |s: &mut UiScript| {
|
||
s.eval::<i64>("return PanelTemplates_GetSelectedTab(TabKitFrame)")
|
||
.unwrap()
|
||
};
|
||
assert_eq!(selected(&mut s), 1);
|
||
|
||
// The addon idiom, verbatim: `this` is the tab, the frame is the argument.
|
||
s.run("this = TabKitFrameTab2; PanelTemplates_Tab_OnClick(TabKitFrame); this = nil")
|
||
.unwrap();
|
||
assert_eq!(
|
||
selected(&mut s),
|
||
2,
|
||
"the kit takes the tab's OWN id from `this`, not from the frame it was handed"
|
||
);
|
||
// The row repainted, not just the number — the same visible half the keybind test asserts.
|
||
assert!(
|
||
s.eval::<bool>("return TabKitFrameTab2MiddleDisabled:IsVisible()")
|
||
.unwrap(),
|
||
"the newly selected tab wears the Active art"
|
||
);
|
||
assert!(s.errors().is_empty(), "no handler errors: {:?}", s.errors());
|
||
}
|