Files
benilla-octo/crates/benilla-app/src/ui_script/questlog_tests.rs
T
SamandClaude Fable 5.1 31371641eb the mailbox takes typing again, and events carry their real arguments
- the mail window's send tab takes typing again: the subject, body and money boxes were taking no input while the recipient box swallowed every key, and a sent letter clears its subject and item icon instead of putting them straight back (2145)
- seven events fire the way the reference does: UNIT_DYNAMIC_FLAGS fires at all, swapping two identical stacks between bank slots fires PLAYERBANKSLOTS_CHANGED twice, the pet sheet's training points repaint, and ITEM_LOCK_CHANGED loses its invented arguments (2140)
- nine widget methods 1.12 addons probe for are in: ShaguTweaks stops recolouring textures the reference leaves alone, pfUI and _Nameplates stop raising on a tooltip's GetAnchorType, and 63 AceEvent addons get RegisterAllEvents (2142)
- six post-1.12 methods leave the widget surface (SetSize, SetShown, HasFocus, SetFormattedText and two tooltip verbs), so an addon reaching for a later client's API finds what a real 1.12 client gives it (2142, 2146)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 20:52:50 +07:00

1944 lines
92 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 shipped **quest log window** driven end-to-end, engine-only (no Bevy): the real
//! `Interface\FrameXML\QuestLogFrame.xml` loaded behind `UIParent.xml`/`MerchantFrame.xml` (the
//! money helpers its reward rows reuse) and fed a synthetic 8-entry log + a resolved detail —
//! mirroring `quest_tests.rs`/`bag_tests.rs`'s engine-only harness for the quest-log slice (decision 0088 arc).
use benilla_ui::script::{
ExtractedQuad, PartyMemberInfo, PartyState, QuadContent, QuestItemView, QuestLogDetail,
QuestLogEntryView, QuestLogObjectiveView, QuestLogState, SoundRequest, UiScript,
};
use super::test_ui::load_ui as load_xml;
/// Find a bare frame's own rect via its `QuadContent::Frame` entry (`panel_tests.rs`'s helper,
/// duplicated) — used to locate the window so the wheel test's coordinates land inside it.
fn frame_rect(quads: &[ExtractedQuad], w: f32, h: f32) -> benilla_ui::layout::Rect {
quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Frame => q
.rect
.filter(|r| (r.width() - w).abs() < 0.5 && (r.height() - h).abs() < 0.5),
_ => None,
})
.unwrap_or_else(|| panic!("no bare-frame quad sized {w}x{h}"))
}
/// 8 flat quest entries (exercises the 6-row faux-scroll), the first carrying one objective line
/// (the auto-picked first selection reads it), + a resolved detail for that selection (no choices,
/// 1 fixed reward + money) — the fixture every test below shares. Each entry gets a distinct
/// `quest_id` (the watch set's stable key — `benilla-ui`'s `quest_log.rs` module doc) so the
/// watch/tracker tests below can tell entries apart across a scroll/reselect.
fn eight_entries() -> QuestLogState {
let entries = (1..=8)
.map(|i| QuestLogEntryView {
quest_id: i,
title: format!("Quest {i}"),
level: 5,
complete: 0,
objectives: if i == 1 {
vec![QuestLogObjectiveView {
text: "Kobold Vermin slain: 3/10".into(),
kind: "monster".into(),
finished: false,
cur: 3,
req: 10,
}]
} else {
vec![]
},
..Default::default()
})
.collect();
QuestLogState {
num_quests: 8,
entries,
detail: Some(QuestLogDetail {
description: "Speak with Marshal McBride.".into(),
objectives_text: "Report to Marshal McBride.".into(),
required_money: 0,
reward_money: 40,
choices: vec![],
rewards: vec![QuestItemView {
item_id: 2024,
name: Some("Militia Hammer".into()),
texture: Some("Interface\\Icons\\INV_Hammer_15".into()),
count: 1,
quality: 1,
usable: true,
// The reward row's ctrl/shift payload (`GetQuestLogItemLink`, decisions 1059/1060)
// — `ui_quest_log.rs` builds it through `ui_items::item_link` once the template
// lands, so the fixture carries that exact shape.
link: Some(HAMMER_LINK.into()),
}],
reward_spell: None,
}),
}
}
/// The fixture reward's full escaped link — a real 1.12 item, quality white.
const HAMMER_LINK: &str = "|cffffffff|Hitem:2024:0:0:0|h[Militia Hammer]|h|r";
/// The loader itself: every file the window depends on parses and materializes with no errors.
#[test]
fn shipped_questlog_frame_loads_clean() {
let _data = benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
}
/// The whole contract in one end-to-end drive: open plays the kit and renders row 1 + the count +
/// the auto-picked first selection + the pushed detail text; the wheel scrolls the list; the abandon
/// two-step (mark on click, confirm Yes/No) drains (or doesn't) the right intent; close plays the
/// close kit.
#[test]
fn shipped_questlog_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);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(eight_entries());
// 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 QuestLogFrame:IsVisible()").unwrap());
// ToggleQuestLog() (the 'L' binding's entry point) opens it through ShowUIPanel.
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s.eval::<bool>("return QuestLogFrame:IsVisible()").unwrap());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igQuestLogOpen".into())],
"opening the quest log plays igQuestLogOpen"
);
// Row 1 carries entry 1's title (indented), the count line reads "Quests: |cffffffff8/20|r", and nothing having been
// selected, the first non-header entry auto-selects (pin §2's SetFirstValidSelection).
assert!(s
.eval::<String>("return QuestLogTitle1NormalText:GetText()")
.unwrap()
.contains("Quest 1"));
assert_eq!(
s.eval::<String>("return QuestLogQuestCount:GetText()")
.unwrap(),
"Quests: |cffffffff8/20|r"
);
assert_eq!(s.eval::<i64>("return GetQuestLogSelection()").unwrap(), 1);
// The detail pane carries the pushed title + objective text.
assert_eq!(
s.eval::<String>("return QuestLogQuestTitle:GetText()")
.unwrap(),
"Quest 1"
);
assert_eq!(
s.eval::<String>("return QuestLogObjective1:GetText()")
.unwrap(),
"Kobold Vermin slain: 3/10"
);
// The reward row (QuestFrame.xml's item-row pattern, reused) + its money.
assert_eq!(
s.eval::<String>("return QuestLogItem1Name:GetText()")
.unwrap(),
"Militia Hammer"
);
assert_ne!(
s.eval::<String>("return QuestLogRewardTitleText:GetText()")
.unwrap(),
"",
"the Rewards header shows once there's a reward to show"
);
// Lua-managed Description header (no longer a static `text=` — QuestLogFrame.xml's header
// comment on why it used to float over the empty-log parchment).
assert_eq!(
s.eval::<String>("return QuestLogDescriptionTitle:GetText()")
.unwrap(),
"Description"
);
// No choices in this fixture: "You will receive:" (REWARD_ITEMS_ONLY), not "...also...", and
// the choose-text/choice rows stay blank/hidden (QuestFrame.lua:454-473).
assert_eq!(
s.eval::<String>("return QuestLogItemReceiveText:GetText()")
.unwrap(),
"You will receive:"
);
// …hidden, not blanked: stock QuestFrameItems_Update Show()s/Hide()s the choose text
// (QuestFrame.lua:454-473) and leaves its string alone.
assert!(
!s.eval::<bool>("return QuestLogItemChooseText:IsShown()")
.unwrap(),
"no choices: the choose text is hidden"
);
// No choices, so the pool's first slot IS the fixed reward and the SECOND is the first
// unused one. (One pool now — the ref's shape; there is no separate "choice row 1".)
assert!(!s.eval::<bool>("return QuestLogItem2:IsShown()").unwrap());
// Wheel DOWN over an actual list ROW (delta -1) advances the faux-scroll offset by one: row 1 now
// shows entry 2's title. Aiming over a row (not the empty margin) is the case that matters — a spin
// over a row bubbles up the parent chain to the window's OnMouseWheel; when that handler lived only
// on the sibling catcher, wheeling over a row did nothing (the trainer window hit the same bug).
// Row 3 ("Quest 3") is unambiguously a list row — the detail pane shows the selected Quest 1.
s.resolve();
let quads = s.extract();
let (wx, wy) = quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Text { text: Some(t), .. } if t.contains("Quest 3") => q
.rect
.map(|r| ((r.left + r.right) * 0.5, (r.bottom + r.top) * 0.5)),
_ => None,
})
.expect("a 'Quest 3' row text quad");
let before = s
.eval::<String>("return QuestLogTitle1NormalText:GetText()")
.unwrap();
s.mouse_wheel(wx, wy, -1.0);
assert!(s.errors().is_empty(), "wheel errors: {:?}", s.errors());
// **Not "by one row" any more.** That was our own `ScrollFrameTemplate_OnMouseWheel`, which
// stepped one `valueStep`. The reference's moves the bar by **half its own height** in value
// units (`UIPanelTemplates.lua`), which over a 16px row and a 237px bar is roughly seven rows.
// The migration reverts our step; how far a notch should travel is a look call, recorded in
// 1846 rather than re-authored back in.
assert_ne!(
s.eval::<String>("return QuestLogTitle1NormalText:GetText()")
.unwrap(),
before,
"wheel-down scrolled the list"
);
// Selection itself is untouched by scrolling.
assert_eq!(s.eval::<i64>("return GetQuestLogSelection()").unwrap(), 1);
// Abandon, No path: marks the selection, shows the registry's ABANDON_QUEST entry on the
// shared StaticPopup engine (decision 0308 §3), but No drains nothing and hides it.
s.run("QuestLogFrameAbandonButton:Click()").unwrap();
assert!(s.eval::<bool>("return StaticPopup1:IsShown()").unwrap());
assert_eq!(
s.eval::<String>("return GetAbandonQuestName()").unwrap(),
"Quest 1"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Abandon \"Quest 1\"?"
);
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
assert!(!s.eval::<bool>("return StaticPopup1:IsShown()").unwrap());
assert!(
s.take_quest_log_abandons().is_empty(),
"No queues no abandon intent"
);
// No plays the dialog's own open/close pair only (ref StaticPopup_OnShow/OnHide,
// StaticPopup.lua:1832/1841) — never the abandon kit.
assert_eq!(
s.take_sounds(),
vec![
SoundRequest::KitName("igMainMenuOpen".into()),
SoundRequest::KitName("igMainMenuClose".into()),
],
"No: dialog open/close kits only, no abandon kit"
);
// Abandon, Yes path: the pinned index (1, the selection at click time) drains, plus the kit.
s.run("QuestLogFrameAbandonButton:Click()").unwrap();
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_quest_log_abandons(), vec![1]);
// Show → igMainMenuOpen; the ref OnClick runs OnAccept FIRST (the abandon kit), THEN hides
// (igMainMenuClose) — StaticPopup.lua:1850-1868's order, which the old shim had reversed.
assert_eq!(
s.take_sounds(),
vec![
SoundRequest::KitName("igMainMenuOpen".into()),
SoundRequest::KitName("igQuestLogAbandonQuest".into()),
SoundRequest::KitName("igMainMenuClose".into()),
]
);
assert!(!s.eval::<bool>("return StaticPopup1:IsShown()").unwrap());
// ToggleQuestLog() again closes it through HideUIPanel, playing the close kit.
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(!s.eval::<bool>("return QuestLogFrame:IsVisible()").unwrap());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igQuestLogClose".into())],
"closing the quest log plays igQuestLogClose"
);
}
/// The quest-watch tracker slice: SHIFT+left-click toggles the watch (the ref's own fork,
/// QuestLogFrame.lua:469-505 — driven through the `set_modifiers` mirror the cursor arc landed),
/// toggling both the row's watch checkbox (a `SetTexture` toggle — bare Texture regions have no
/// Show/Hide, same limit as the FontString regions elsewhere in this window) and the
/// always-on-screen tracker HUD (`QuestWatchFrame`), which the log window itself doesn't
/// own or gate on visibility.
#[test]
fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// Stock seats the watch check at update time off `questNormalText:GetWidth()` (QuestLogFrame.lua:205-215),
// synchronously — the app's measure seam is synchronous too (measure.rs); its stand-in here.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
// A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()`
// (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944).
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml");
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(eight_entries());
// The app fires QUEST_LOG_UPDATE when the log lands; stock QuestLog_OnEvent's arm runs the
// first QuestWatch_Update, which is what hides an empty tracker.
s.fire_event("QUEST_LOG_UPDATE", vec![]);
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Nothing watched yet — the tracker HUD is hidden and row 1 shows no checkbox texture.
assert!(!s
.eval::<bool>("return QuestWatchFrame:IsVisible()")
.unwrap());
let checkbox_shown = |s: &mut UiScript| {
s.resolve();
s.extract().iter().any(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("UI-CheckBox-Check"))
})
};
assert!(!checkbox_shown(&mut s), "no checkbox before any watch");
// Shift-click row 1 ("Quest 1", the entry `eight_entries` gives one objective — a zero-objective
// quest can't be watched, ref QUEST_WATCH_NO_OBJECTIVES). Row 1 sits at the list's TOPLEFT+(19,
// -75), 300×16 (`QuestLogFrame.xml`'s own row-chain comment) — its center is inside the button
// but outside the checkbox's outside-the-row gutter, so this can't be mistaken for a checkbox hit.
s.resolve();
let win = frame_rect(&s.extract(), 384.0, 512.0);
let (rx, ry) = (win.left + 19.0 + 150.0, win.top - 75.0 - 8.0);
s.set_modifiers(true, false, false);
s.mouse_button(rx, ry, "LeftButton", true);
s.mouse_button(rx, ry, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(
s.errors().is_empty(),
"shift-click errors: {:?}",
s.errors()
);
assert!(s.eval::<bool>("return IsQuestWatched(1)").unwrap());
assert_eq!(s.eval::<i64>("return GetNumQuestWatches()").unwrap(), 1);
assert!(checkbox_shown(&mut s), "watched row shows its checkbox");
// The check SEATS just past the title's ink (ref QuestLog.lua:224 — dummy width + 24 from the
// row's left, netting ink end + 4 behind the text's own +20 inset), NOT the first build's
// outside-the-row gutter at row_left − 14. The engine-only harness has no font atlas, so
// answer the measure round-trip with a deterministic 6 px/char fake (the app-side extract
// answers from the real atlas) — the assertion pins the ANCHOR GRAPH seating the check at
// measured ink + 4, in a loose band that bakes no glyph metrics into the test.
let reqs = s.fontstrings_needing_measure();
let answers: Vec<(u32, f32, f32, u64)> = reqs
.iter()
.map(|r| (r.id, r.text.chars().count() as f32 * 6.0, 12.0, r.key))
.collect();
s.set_measured_text_unwrapped(&answers);
s.resolve();
let check = s
.extract()
.into_iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("UI-CheckBox-Check"))
})
.expect("check quad");
let check_left = check.rect.expect("check rect resolved").left;
let row_left = win.left + 19.0;
assert!(
check_left > row_left + 40.0 && check_left < row_left + 150.0,
"check seats after the title ink, got left = {check_left} (row_left = {row_left})"
);
// The tracker HUD is now visible: the flat line pool (ref MAX_QUESTWATCH_LINES) carries the
// watched quest's title on line 1 and its objective on line 2, ref-colored (title dark-gold —
// objectives incomplete; objective 0.8-gray — unfinished).
assert!(s
.eval::<bool>("return QuestWatchFrame:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>("return QuestWatchLine1:GetText()")
.unwrap(),
"Quest 1"
);
assert_eq!(
s.eval::<String>("return QuestWatchLine2:GetText()")
.unwrap(),
" - Kobold Vermin slain: 3/10"
);
// A manual watch is permanent: stock's shift-click inserts it with QUEST_WATCH_NO_EXPIRE
// (QuestLogFrame.lua:496), the timer that AutoQuestWatch_OnUpdate never counts down.
assert!(s
.eval::<bool>(
"return QUEST_WATCH_LIST[1] ~= nil and QUEST_WATCH_LIST[1].timer == QUEST_WATCH_NO_EXPIRE"
)
.unwrap());
// Shift-click again unwatches: the checkbox clears and — nothing left watched — the whole HUD
// hides (the ref hides the FRAME and returns before touching the lines; stale text stays,
// drawn by nothing).
s.set_modifiers(true, false, false);
s.mouse_button(rx, ry, "LeftButton", true);
s.mouse_button(rx, ry, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(!s.eval::<bool>("return IsQuestWatched(1)").unwrap());
assert!(!checkbox_shown(&mut s), "unwatching clears the checkbox");
assert!(!s
.eval::<bool>("return QuestWatchFrame:IsVisible()")
.unwrap());
}
/// Shift-clicking a quest with zero objectives (ref `QUEST_WATCH_NO_OBJECTIVES`) and shift-clicking
/// past `GetNumQuestWatches() >= 5` (ref `QUEST_WATCH_TOO_MANY`) both refuse the watch and put the
/// ref's red line on the errors frame through its own `UIErrorsFrame:AddMessage`; the click still
/// selects the row exactly like any other click (pin §5).
#[test]
fn watch_guards_no_op_without_erroring() {
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, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIErrorsFrame.xml"); // the guards' red-line surface
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
// A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()`
// (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944).
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml");
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(eight_entries());
s.run("ToggleQuestLog()").unwrap();
// Row 1's TOPLEFT is the window's (19,-75); each of the 6 visible rows is 300×16 chained with a
// 1px OVERLAP, not a gap (`QuestLogFrame.xml`'s row-chain comment) — pitch 16-1=15px. Row n's
// center, screen space.
s.resolve();
let win = frame_rect(&s.extract(), 384.0, 512.0);
let row_center = |n: u32| win.top - 75.0 - 15.0 * (n - 1) as f32 - 8.0;
let x = win.left + 19.0 + 150.0;
// Row 2 ("Quest 2") carries no objectives in this fixture — shift-clicking it can't watch.
s.set_modifiers(true, false, false);
s.mouse_button(x, row_center(2), "LeftButton", true);
s.mouse_button(x, row_center(2), "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(
s.errors().is_empty(),
"no-objectives guard errors: {:?}",
s.errors()
);
assert!(!s.eval::<bool>("return IsQuestWatched(2)").unwrap());
// Read the toast off the drawn quads: `UIErrorsFrame` is a real `<MessageFrame>` and its
// lines are the widget's own bands, not named FontStrings.
s.resolve();
assert!(
s.extract().iter().any(|q| matches!(
&q.content,
QuadContent::Text { text: Some(t), .. } if t == "This quest has no objectives to track"
)),
"the ref's QUEST_WATCH_NO_OBJECTIVES red line surfaces"
);
assert_eq!(
s.eval::<i64>("return GetQuestLogSelection()").unwrap(),
1,
"a shift-click never selects — stock QuestLogTitleButton_OnClick's shift branch returns \
before the plain-click arm's QuestLog_SetSelection (QuestLogFrame.lua:472-500)"
);
// Fill the watch list to the cap (5) with quests 2-6 via the Lua API directly — the engine-level
// AddQuestWatch (`quest_log.rs`) has no objectives guard of its own (that guard is this file's,
// in the click handler only — mirroring the ref's own split between the native call and
// `QuestLogTitleButton_OnClick`'s Lua-side checks), so a zero-objective quest CAN be watched this
// way. This deliberately leaves quest 1 (the fixture's only objective-bearing entry) unwatched.
for i in 2..=6 {
s.run(&format!("AddQuestWatch({i})")).unwrap();
}
assert_eq!(s.eval::<i64>("return GetNumQuestWatches()").unwrap(), 5);
// Row 1 ("Quest 1", unwatched, HAS an objective) — shift-clicking it now clears the
// no-objectives guard but hits the watch-list-full guard instead: still a no-op.
s.set_modifiers(true, false, false);
s.mouse_button(x, row_center(1), "LeftButton", true);
s.mouse_button(x, row_center(1), "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(
s.errors().is_empty(),
"too-many guard errors: {:?}",
s.errors()
);
assert!(!s.eval::<bool>("return IsQuestWatched(1)").unwrap());
assert_eq!(s.eval::<i64>("return GetNumQuestWatches()").unwrap(), 5);
s.resolve();
assert!(
s.extract().iter().any(|q| matches!(
&q.content,
QuadContent::Text { text: Some(t), .. } if t == "You may only watch 5 quests at a time"
)),
"the ref's QUEST_WATCH_TOO_MANY red line surfaces"
);
}
/// The auto quest watch (ref `AUTO_QUEST_WATCH` default-on, QuestLogFrame.lua:702-786 —
/// "Quests are automatically watched for 5 minutes when you achieve a quest objective."): the
/// engine's per-quest `BENILLA_QUEST_PROGRESS(logIndex)` arms a 300 s timed watch (the native
/// QUEST_WATCH_UPDATE carries the WATCH-LIST index per the §5-verified byte law — repaint only,
/// the shipped 1.12 auto-watch chain being broken at that seam), fresh progress
/// re-arms it, and expiry (the watch frame's OnUpdate) unwatches and hides the empty HUD.
#[test]
fn progress_auto_watches_for_five_minutes() {
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, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
// AUTO_QUEST_WATCH is the options window's (UIOptionsFrame_Init in the reference; 1944), so the
// auto-watch needs that window loaded, as the manifest has it long before the log.
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(eight_entries());
// A kill credit lands: the engine fires the event with the quest's 1-based log index.
s.fire_event(
"QUEST_WATCH_UPDATE",
vec![benilla_ui::script::ScriptValue::Int(1)],
);
assert!(s.errors().is_empty(), "auto-watch errors: {:?}", s.errors());
assert!(s.eval::<bool>("return IsQuestWatched(1)").unwrap());
assert!(s
.eval::<bool>("return QuestWatchFrame:IsVisible()")
.unwrap());
assert!(
s.eval::<bool>(
"return QUEST_WATCH_LIST[1] ~= nil and QUEST_WATCH_LIST[1].index == 1 \
and QUEST_WATCH_LIST[1].timer == MAX_QUEST_WATCH_TIMER"
)
.unwrap(),
"the reference's QUEST_WATCH_LIST carries the armed timer (QuestLogFrame.lua:752-769)"
);
// 299 s in it still holds; fresh progress re-arms the timer; expiry unwatches and hides.
s.tick(299.0);
assert!(s.eval::<bool>("return IsQuestWatched(1)").unwrap());
s.fire_event(
"QUEST_WATCH_UPDATE",
vec![benilla_ui::script::ScriptValue::Int(1)],
);
s.tick(299.0);
assert!(
s.eval::<bool>("return IsQuestWatched(1)").unwrap(),
"re-armed by fresh progress"
);
s.tick(2.0);
assert!(
!s.eval::<bool>("return IsQuestWatched(1)").unwrap(),
"expired after 300 s without progress"
);
assert!(!s
.eval::<bool>("return QuestWatchFrame:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "expiry errors: {:?}", s.errors());
}
/// The flag that gates the above is the **reference's** uvar under the reference's name and value
/// shape (decision 1136 — it was `BENILLA_AUTO_QUEST_WATCH`, a boolean, while nothing could change
/// it): `AUTO_QUEST_WATCH`, a `"1"`/`"0"` string, which is what the Interface page's row writes.
/// Off, progress no longer watches anything; back on, it does again.
///
/// The default asserted first is the one 1.12 *advertises* and does not deliver: it assigns the
/// NUMBER `1` (UIOptionsFrame.lua:122) while every consumer compares `== "1"`, so the shipped
/// client's auto-watch is dead until the player toggles the box. Ours ships the string.
#[test]
fn the_auto_watch_flag_is_the_references_uvar_and_gates_the_watch() {
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, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
// The flag is declared by the options window's init since 1944, as the reference declares it
// in UIOptionsFrame_Init — so the window that owns it loads first, as in the manifest.
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(eight_entries());
assert_eq!(s.eval::<String>("return AUTO_QUEST_WATCH").unwrap(), "1");
s.run(r#"AUTO_QUEST_WATCH = "0""#).unwrap();
s.fire_event(
"QUEST_WATCH_UPDATE",
vec![benilla_ui::script::ScriptValue::Int(1)],
);
assert!(
!s.eval::<bool>("return IsQuestWatched(1)").unwrap(),
"the flag off means progress watches nothing"
);
s.run(r#"AUTO_QUEST_WATCH = "1""#).unwrap();
s.fire_event(
"QUEST_WATCH_UPDATE",
vec![benilla_ui::script::ScriptValue::Int(1)],
);
assert!(s.eval::<bool>("return IsQuestWatched(1)").unwrap());
assert!(s.errors().is_empty(), "auto-watch errors: {:?}", s.errors());
}
/// The empty-log v1 simplification (this file's XML header comment): zero entries hides every row,
/// disables Abandon, and shows the centered empty-state message instead of `EmptyQuestLogFrame`.
/// The quest TAG on the list row (`GetQuestLogTitle`'s third return): it paints on its own
/// right-flush `$parentTag` string, wrapped in the ref's parentheses, and a finished quest's
/// Failed/Complete word OVERWRITES it — the ref's own precedence (`QuestLogFrame.lua:187-195`),
/// which is why one row never shows both. Headers carry no tag. The title keeps the bare quest
/// name: the state word is NOT appended to it any more.
#[test]
fn the_row_tag_is_its_own_right_flush_string_and_the_state_word_wins() {
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");
// The state word comes from the reference's OWN globals (`COMPLETE`/`FAILED`) as of this row's
// ref-verbatim tag handling — the app loads GlobalStrings.lua as `benilla.toc`'s second entry,
// so this harness must too or the override silently reads nil.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
let mut state = eight_entries();
// Row 1: a plain elite quest. Row 2: an elite quest that is COMPLETE — the state word wins.
// Row 3: no tag at all, in progress. Row 4: a header, which can carry neither.
state.entries[0].tag = Some("Elite".into());
state.entries[1].tag = Some("Raid".into());
state.entries[1].complete = 1;
state.entries[2].complete = -1;
state.entries[3].is_header = true;
state.entries[3].title = "Elwynn Forest".into();
state.entries[3].tag = None;
s.set_quest_log(state);
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// `Option`: an untagged row's string is blank, and a blank FontString reads back **nil** —
// `FontString:GetText 0x79d690` substitutes nil for an empty string (decision 2110).
let tag = |s: &mut UiScript, i: u32| {
s.eval::<Option<String>>(&format!("return QuestLogTitle{i}Tag:GetText()"))
.unwrap()
};
assert_eq!(
tag(&mut s, 1).as_deref(),
Some("(Elite)"),
"the engine's tag, in parentheses"
);
assert_eq!(
tag(&mut s, 2).as_deref(),
Some("(Complete)"),
"COMPLETE overwrites the quest's own tag — ref l.190-192"
);
assert_eq!(
tag(&mut s, 3).as_deref(),
Some("(Failed)"),
"FAILED, same override"
);
assert_eq!(tag(&mut s, 4), None, "a header carries no tag");
// The title is the bare (indented) name — the state word lives on the tag string now, so
// appending it here too would double it.
let title1 = s
.eval::<String>("return QuestLogTitle1NormalText:GetText()")
.unwrap();
assert_eq!(title1, " Quest 1");
let title2 = s
.eval::<String>("return QuestLogTitle2NormalText:GetText()")
.unwrap();
assert_eq!(title2, " Quest 2", "no \"(Complete)\" on the title");
// Right-flush at the row's right edge less 2 — the ref's own anchor (its
// QuestLogFrame.xml:10-21).
let row_right = s.eval::<f32>("return QuestLogTitle1:GetRight()").unwrap();
let tag_right = s
.eval::<f32>("return QuestLogTitle1Tag:GetRight()")
.unwrap();
assert!(
(row_right - tag_right - 2.0).abs() < 0.5,
"tag right-flush at row right -2 (row {row_right}, tag {tag_right})"
);
}
#[test]
fn empty_quest_log_hides_rows_and_disables_abandon() {
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, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(QuestLogState::default());
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The empty log says the reference's QUESTLOG_NO_QUESTS_TEXT (GlobalStrings.lua:3225) through
// stock QuestLogNoQuestsText; "Your quest log is empty." was ours (1944). Regions carry the
// real `Show`/`Hide`/`IsShown`/`IsVisible` (0138), so the label's visibility is assertable
// too — and nothing ever hides this one: `QuestLogFrame.xml:427` is its only mention in the
// whole chain, so it stands behind the rows whether or not the log is empty.
assert_eq!(
s.eval::<String>("return QuestLogNoQuestsText:GetText()")
.unwrap(),
"No Active Quests"
);
assert!(s
.eval::<bool>("return QuestLogNoQuestsText:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return QuestLogTitle1:IsVisible()").unwrap());
assert!(!s
.eval::<bool>("return QuestLogFrameAbandonButton:IsEnabled() ~= 0")
.unwrap());
// With no selection the reference hides the whole detail pane (stock
// `QuestLog_UpdateQuestDetails`: `QuestLogDetailScrollFrame:Hide()`), header included — it
// keeps its static `text="QUEST_DESCRIPTION"`; ours used to blank the header instead (1944).
assert!(
!s.eval::<bool>("return QuestLogDetailScrollFrame:IsVisible()")
.unwrap(),
"no selection: the detail pane is hidden, header and all"
);
assert_eq!(
s.eval::<String>("return QuestLogQuestCount:GetText()")
.unwrap(),
"Quests: |cffffffff0/20|r"
);
}
/// `BenillaQuestLogRewards_Update`'s ported `QuestFrameItems_Update` layout (QuestFrame.lua:311-522)
/// — 2 choices + 1 fixed reward + money, the same shape the capture fixture (`ui-questlog`) drives:
/// choice rows 2-per-row, "You will also receive:" (choices present) anchored under the LEFT column
/// of the last choice row, the fixed reward below that, unused rows/choices past the count hidden.
#[test]
fn reward_rows_follow_the_refs_two_per_row_layout() {
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, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
let mut state = eight_entries();
state.detail = Some(QuestLogDetail {
description: "Speak with Marshal McBride.".into(),
objectives_text: "Report to Marshal McBride.".into(),
required_money: 0,
reward_money: 150, // 1s50c, the fixture's own amount
choices: vec![
QuestItemView {
item_id: 0,
name: Some("Worn Sword".into()),
texture: None,
count: 1,
quality: 1,
usable: true,
..Default::default()
},
QuestItemView {
item_id: 0,
name: Some("Worn Mace".into()),
texture: None,
count: 1,
quality: 1,
usable: true,
..Default::default()
},
],
rewards: vec![QuestItemView {
item_id: 0,
name: Some("Militia Hammer".into()),
texture: None,
count: 1,
quality: 1,
usable: true,
..Default::default()
}],
reward_spell: None,
});
s.set_quest_log(state);
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Both choice rows show, the rest of the 6-row cap stays hidden.
// 2 choices then 1 fixed reward, in ONE pool: Item1/Item2 are the choices, Item3 the reward,
// Item4 the first unused slot.
assert!(s.eval::<bool>("return QuestLogItem1:IsShown()").unwrap());
assert!(s.eval::<bool>("return QuestLogItem2:IsShown()").unwrap());
assert!(!s.eval::<bool>("return QuestLogItem4:IsShown()").unwrap());
assert_eq!(
s.eval::<String>("return QuestLogItem1Name:GetText()")
.unwrap(),
"Worn Sword"
);
assert_eq!(
s.eval::<String>("return QuestLogItem2Name:GetText()")
.unwrap(),
"Worn Mace"
);
assert_eq!(
s.eval::<String>("return QuestLogItemChooseText:GetText()")
.unwrap(),
"You will be able to choose one of these rewards:"
);
// With choices present, the receive text reads "...also..." (QuestFrame.lua:461-462) — it
// anchors under choice row 1 (the left/odd column: index=2 is even -> anchorIndex-1 -> 1, this
// function's own logic). Regions carry `GetPoint` (1244), so the anchor CHAIN is introspected
// here rather than deferred to a screenshot: TOPLEFT of QuestLogItem1's BOTTOMLEFT, +3/-5.
assert_eq!(
s.eval::<String>("return QuestLogItemReceiveText:GetText()")
.unwrap(),
"You will also receive:"
);
assert_eq!(
s.eval::<String>(
"local p, rel, rp = QuestLogItemReceiveText:GetPoint() \
return p .. '|' .. rel:GetName() .. '|' .. rp"
)
.unwrap(),
"TOPLEFT|QuestLogItem1|BOTTOMLEFT"
);
assert_eq!(
s.eval::<(f64, f64)>(
"local _, _, _, x, y = QuestLogItemReceiveText:GetPoint() return x, y"
)
.unwrap(),
(3.0, -5.0)
);
// One fixed reward shows, chained under the receive text (its own SetPoint target).
assert!(s.eval::<bool>("return QuestLogItem3:IsShown()").unwrap());
assert!(!s.eval::<bool>("return QuestLogItem4:IsShown()").unwrap());
assert_eq!(
s.eval::<String>("return QuestLogItem3Name:GetText()")
.unwrap(),
"Militia Hammer"
);
assert_eq!(
s.eval::<String>("return QuestLogRewardTitleText:GetText()")
.unwrap(),
"Rewards"
);
}
/// One entry whose selected detail overflows the 261px-tall `QuestLogDetailScrollFrame`: 10
/// objective lines (each a FIXED 12px slot, `QuestLogObjective1`'s own XML comment) plus a
/// long description and a reward row push the child's summed height (`BenillaQuestLogDetail_
/// ResizeChild`) well past the pane — load-bearing for the scroll/clip tests below even
/// engine-only (no font atlas, so every auto-height FontString measures 0 here — this fixture
/// overflows on the FIXED-height portions alone: 10×12px objective rows + the reward row).
/// The measure round-trip the app's resolve pass drives with its font engine: answer every
/// FontString the layout asked about with the 6px-per-glyph, 12px-per-line stand-in the
/// synchronous `FixedWidthFont(6.0)` uses, so the two agree.
fn answer_measures(s: &mut UiScript) {
let answers: Vec<(u32, f32, f32, u64)> = s
.fontstrings_needing_measure()
.into_iter()
.map(|r| {
let ink = r.text.chars().count() as f32 * 6.0;
match r.wrap_width {
Some(w) if w > 0.0 && ink > w => (r.id, w, (ink / w).ceil() * 12.0, r.key),
_ => (r.id, ink, 12.0, r.key),
}
})
.collect();
s.set_measured_text_unwrapped(&answers);
}
fn overflowing_entry() -> QuestLogState {
let objectives = (1..=10)
.map(|i| QuestLogObjectiveView {
text: format!("Objective {i} of 10 slain: 0/5"),
kind: "monster".into(),
finished: false,
cur: 0,
req: 5,
})
.collect();
QuestLogState {
num_quests: 1,
entries: vec![QuestLogEntryView {
quest_id: 1,
title: "A Very Long Quest".into(),
level: 5,
complete: 0,
objectives,
..Default::default()
}],
detail: Some(QuestLogDetail {
description: "A very long description. ".repeat(20),
objectives_text: "Report back once every objective below is complete.".into(),
required_money: 0,
reward_money: 0,
choices: vec![],
rewards: vec![QuestItemView {
item_id: 0,
name: Some("Militia Hammer".into()),
texture: None,
count: 1,
quality: 1,
usable: true,
..Default::default()
}],
reward_spell: None,
}),
}
}
/// The ScrollFrame clip (decision 0112 §4): every quad in the moved detail chain's subtree —
/// `QuestLogDetailScrollChildFrame` and its descendants (here, a reward row's icon-slot texture) —
/// carries `QuestLogDetailScrollFrame`'s own resolved rect as its clip, so content that overflows
/// the pane never draws past its bottom edge; a sibling entirely outside the scroll child (the
/// window's own book-icon chrome) stays unclipped, mirroring `scrollframe.rs`'s own unit test for
/// the mechanism, just through the shipped window instead of a synthetic fixture.
#[test]
fn overflowing_detail_content_clips_to_the_scrollframe_rect() {
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, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(overflowing_entry());
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.resolve();
let quads = s.extract();
let scroll_rect = frame_rect(&quads, 300.0, 261.0);
let reward_plate_clips: Vec<Option<benilla_ui::layout::Rect>> = quads
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. } if p.contains("UI-QuestItemNameFrame") => {
Some(q.clip)
}
_ => None,
})
.collect();
assert!(
!reward_plate_clips.is_empty(),
"expected at least one reward-row name-plate quad in the fixture"
);
for clip in reward_plate_clips {
assert_eq!(
clip,
Some(scroll_rect),
"a reward row (inside the scroll child's subtree) clips to the ScrollFrame's own rect"
);
}
let book_icon_clip = quads.iter().find_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. } if p.contains("UI-QuestLog-BookIcon") => {
Some(q.clip)
}
_ => None,
});
assert_eq!(
book_icon_clip,
Some(None),
"the window's own chrome sits outside the scroll child and is unclipped"
);
}
/// Wheeling over the detail pane changes `GetVerticalScroll()` — the ScrollFrame is
/// mouse-wheel-interactive by construction (decision 0112), and it is the only frame in this
/// window's bucket covering the detail rect, so the spin lands on it outright.
#[test]
fn wheel_over_the_detail_pane_changes_vertical_scroll() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(overflowing_entry());
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The detail pane's scroll range is the union of its measured content (0128): the layout's
// measure round-trip has to be answered, as the app's resolve pass answers it every frame.
s.resolve();
answer_measures(&mut s);
s.resolve(); // the measured span reaches the scroll range, and the bar, on this pass
s.resolve();
let quads = s.extract();
let scroll_rect = frame_rect(&quads, 300.0, 261.0);
// Near the pane's own top-left, clear of the reward row (which sits further down the chain).
let (x, y) = (scroll_rect.left + 10.0, scroll_rect.top - 10.0);
assert_eq!(
s.eval::<f32>("return QuestLogDetailScrollFrame:GetVerticalScroll()")
.unwrap(),
0.0
);
s.mouse_wheel(x, y, -1.0); // WoW convention: -1 = down
assert!(s.errors().is_empty(), "wheel errors: {:?}", s.errors());
let after = s
.eval::<f32>("return QuestLogDetailScrollFrame:GetVerticalScroll()")
.unwrap();
assert!(
after > 0.0,
"wheel-down over the detail pane increased its scroll offset, got {after}"
);
}
/// Pin §3/§5's doNotScroll asymmetry (QUESTLOG-PIN.md, `QuestLogFrame.lua:348/455-457`): a manual
/// reselect always snaps the detail pane's scroll back to the top, but the `QUEST_LOG_UPDATE`
/// data-refresh path must NOT — a quest updating while already being read shouldn't yank the
/// reader's scroll position out from under them.
#[test]
fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(overflowing_entry());
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The detail pane's scroll range is the union of its measured content (0128): the layout's
// measure round-trip has to be answered, as the app's resolve pass answers it every frame.
s.resolve();
answer_measures(&mut s);
s.resolve(); // the measured span reaches the scroll range, and the bar, on this pass
s.resolve(); // GetVerticalScrollRange (the bar's [min, max]) reads resolved rects.
// Scroll THROUGH the bar, as the wheel and the thumb do (ScrollFrameTemplate_OnMouseWheel sets
// the bar; its OnValueChanged moves the frame): a reselect's `ScrollBar:SetValue(0)` is what
// snaps the pane back, and a bar already at 0 would fire no change over a frame-only scroll.
s.run("QuestLogDetailScrollFrameScrollBar:SetValue(10)")
.unwrap();
assert_eq!(
s.eval::<f32>("return QuestLogDetailScrollFrame:GetVerticalScroll()")
.unwrap(),
10.0
);
// A data refresh (fired the same way the app's own quest-log feed does) must not reset it.
s.fire_event("QUEST_LOG_UPDATE", vec![]);
assert!(s.errors().is_empty(), "refresh errors: {:?}", s.errors());
assert_eq!(
s.eval::<f32>("return QuestLogDetailScrollFrame:GetVerticalScroll()")
.unwrap(),
10.0,
"QUEST_LOG_UPDATE's data-refresh path must not yank the scroll position (pin §5)"
);
// A manual reselect (the row-click path — no doNotScroll) DOES reset it.
s.run("QuestLogTitle1:Click()").unwrap();
assert!(s.errors().is_empty(), "click errors: {:?}", s.errors());
assert_eq!(
s.eval::<f32>("return QuestLogDetailScrollFrame:GetVerticalScroll()")
.unwrap(),
0.0,
"a manual reselect snaps the detail pane's scroll back to the top"
);
}
/// The SHARED item tooltip on a quest reward row (the director's "same tooltip as vendor items"):
/// hovering the reward fires the row's OnEnter → `SetQuestLogItem` → the shared id-keyed render
/// → the ask-once store. First hover
/// (store cold) shows the fallback name line AND records the ask; after the app's push, a re-hover
/// renders the full stat head — the identical lines a vendor row/bag slot gets for this item.
#[test]
fn reward_row_hover_serves_the_shared_item_tooltip() {
let _data = benilla_formats::wow_data_or_skip!();
use benilla_ui::script::ItemTemplateView;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
// This used to load LootFrame.xml for `BENILLA_LOOT_QUALITY_COLORS`, an alias our own copy of
// that window set to `ITEM_QUALITY_COLORS`. 1751 retired the window and the alias with it, so
// Fonts.xml alone is the source now. Formerly: it
// needs the dropdown kit its GroupLootDropDown initializes against (benilla.toc l.64 vs 383).
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(eight_entries());
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.resolve();
let icon_rect = s
.extract()
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains("INV_Hammer_15"))
})
.and_then(|q| q.rect)
.expect("the reward row icon");
// Extracted rects and mouse_move share the same y-up UI space (the merchant hover test's
// own convention).
let (cx, cy) = (
(icon_rect.left + icon_rect.right) * 0.5,
(icon_rect.bottom + icon_rect.top) * 0.5,
);
let has_text = |s: &mut UiScript, t: &str| {
s.resolve();
s.extract()
.iter()
.any(|q| matches!(&q.content, QuadContent::Text { text: Some(x), .. } if x == t))
};
// Cold store: the hover shows the tooltip (fallback name only) and records the ask.
s.mouse_move(cx, cy);
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"tooltip shows on hover"
);
assert_eq!(
s.take_item_stat_asks(),
vec![2024],
"the miss recorded the ask"
);
// The app answers; a re-hover renders the full stat head.
s.set_item_template(
2024,
ItemTemplateView {
name: "Militia Hammer".into(),
quality: 1,
inventory_type: 21, // Main Hand
class: 2,
subclass: 4, // mace
damages: vec![(4.0, 9.0, 0)],
delay_ms: 2200,
..Default::default()
},
);
s.mouse_move(0.0, 0.0);
s.mouse_move(cx, cy);
assert!(s.errors().is_empty(), "re-hover errors: {:?}", s.errors());
assert!(
has_text(&mut s, "Main Hand"),
"slot line from the shared store"
);
assert!(
has_text(&mut s, "4 - 9 Damage"),
"damage line from the shared store"
);
}
/// A DIALOG-strata popup's CHILDREN inherit the stratum (and parent level + 1) at creation —
/// the client's CreateFrame law. Regression: the popup's translucent backdrop used to draw OVER
/// its own Yes/No buttons (children stuck in MEDIUM while the popup sat in DIALOG — the abandon
/// dialog's dark buttons, pixel-diffed against the raw UI-DialogBox-Button-Up art).
#[test]
fn popup_children_inherit_the_dialog_stratum() {
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, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
s.run(
"StaticPopupDialogs[\"TEST_STRATUM\"] = { text = \"Abandon?\", button1 = \"Yes\", \
button2 = \"No\", timeout = 0 }\n\
StaticPopup_Show(\"TEST_STRATUM\")",
)
.unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
s.resolve();
let quads = s.extract();
let backdrop_z = quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Backdrop { path, .. } if path.contains("UI-DialogBox-Background") => {
Some(q.z)
}
_ => None,
})
.expect("the popup backdrop");
let button_z = quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. } if p.contains("UI-DialogBox-Button-Up") => {
Some(q.z)
}
_ => None,
})
.expect("a popup button face");
assert!(
button_z > backdrop_z,
"the buttons draw ABOVE their dialog's backdrop (button z {button_z:x} vs backdrop z {backdrop_z:x})"
);
}
/// The quest log's reward rows carry the same modifier fork as the questgiver's (decisions
/// 1059/1060) — ref `QuestLogRewardItem_OnClick` (QuestLogFrame.lua:542-552), the OnClick the ref's
/// own `QuestLogRewardItemTemplate` adds (ref QuestLogFrame.xml:118-120). There is no third arm
/// here: a quest-log reward row is never selectable, so a plain click stays inert — which is the
/// regression this pins, since the row had NO OnClick at all before this arc.
///
/// Ordering note: the dressing room docks the same left UIPanel slot as the log window, so the ctrl
/// arm runs last — opening the room closes the log, exactly as it does in play.
#[test]
fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() {
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, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
// A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()`
// (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944).
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
load_xml(&s, "Interface\\FrameXML\\DressUpFrame.xml"); // DressUpItemLink lives here
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit its menus build from
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml"); // ChatFrameEditBox lives here
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
s.set_quest_log(eight_entries());
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return QuestLogItem1:IsShown()").unwrap(),
"the fixture's one fixed reward row is laid out"
);
let _ = s.take_dressup_intents();
// A PLAIN click is inert: no room, no chat text, no error (the row has no select arm).
s.run("QuestLogItem1:Click()").unwrap();
assert!(
s.take_dressup_intents().is_empty(),
"a plain click never opens the dressing room"
);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
"",
"a plain click posts nothing"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// SHIFT + chat open → the reward's full escaped link (ref l.547-550).
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.run("QuestLogItem1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
HAMMER_LINK,
"shift-click posted the reward's link"
);
assert!(
s.take_dressup_intents().is_empty(),
"the shift arm never opens the room"
);
// CTRL → the room wearing the reward (ref l.543-546): re-dress first, then try it on.
s.set_modifiers(false, true, false);
s.run("QuestLogItem1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
vec![
benilla_ui::script::DressUpIntent::Dress,
benilla_ui::script::DressUpIntent::TryOn(2024)
],
"ctrl-click opened the room wearing the reward"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A quest log TITLE row is a quest NAME, not an item — ref `QuestLogTitleButton_OnClick`
/// (QuestLogFrame.lua:469-505): with the chat edit box up, shift-click inserts the row's own text
/// with its leading indent trimmed (l.478-480, plain text — 1.12 has no `|Hquest:` link); the
/// quest-watch toggle is that branch's `else` (l.481-500). Both halves in one test because it is the
/// *fork* that matters: watching is the behaviour that already shipped, and the insert must not have
/// eaten it. The select-and-repaint tail (l.503-504) runs either way.
#[test]
fn shift_click_on_a_title_posts_the_quest_name_with_chat_open_and_watches_with_it_closed() {
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, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
// A shift-click reaches stock QuestLogTitleButton_OnClick's unguarded `ChatFrameEditBox:IsVisible()`
// (QuestLogFrame.lua:478) — the chat window is on the manifest long before the log (1944).
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit its menus build from
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml"); // ChatFrameEditBox lives here
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
s.set_quest_log(eight_entries());
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Row 1's centre — the same seat `shift_click_toggles_the_watch_checkbox_and_the_tracker_hud`
// computes (list TOPLEFT+(19,-75), 300×16).
s.resolve();
let win = frame_rect(&s.extract(), 384.0, 512.0);
let (rx, ry) = (win.left + 19.0 + 150.0, win.top - 75.0 - 8.0);
let shift_click = |s: &mut UiScript| {
s.set_modifiers(true, false, false);
s.mouse_button(rx, ry, "LeftButton", true);
s.mouse_button(rx, ry, "LeftButton", false);
s.set_modifiers(false, false, false);
};
// Chat CLOSED → the watch toggle, exactly as before.
shift_click(&mut s);
assert!(
s.eval::<bool>("return IsQuestWatched(1)").unwrap(),
"with chat closed, shift-click still toggles the quest watch"
);
shift_click(&mut s); // and back off, so the assertion below is unambiguous
assert!(!s.eval::<bool>("return IsQuestWatched(1)").unwrap());
// Chat OPEN → the bare quest name, and NO watch.
assert!(s.focus_editbox("ChatFrameEditBox"));
shift_click(&mut s);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
"Quest 1",
"the trimmed quest name landed in the chat box (ref's gsub result, no row indent)"
);
assert!(
!s.eval::<bool>("return IsQuestWatched(1)").unwrap(),
"with chat open the watch must NOT toggle — the ref makes watching the else"
);
// The tail runs either way (ref l.503-504): the click still selects the row.
assert_eq!(s.eval::<i64>("return GetQuestLogSelection()").unwrap(), 1);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── The Share Quest button (decision 1733) ───────────────────────────────────────────────────────
/// A party of `n` others; `GetNumPartyMembers` is that list's length.
fn party(n: usize) -> PartyState {
PartyState {
members: (0..n)
.map(|i| PartyMemberInfo {
name: format!("Mate{i}"),
guid: 0x300 + i as u64,
})
.collect(),
..PartyState::default()
}
}
/// The 8-entry fixture with `pushable` set on the entries named by `ids` — the app sets it from
/// the cached template's `QUEST_FLAGS_SHARABLE` bit, so a fixture is how the button's predicate is
/// exercised without a wire.
fn entries_sharable(ids: &[u32]) -> QuestLogState {
let mut state = eight_entries();
for e in &mut state.entries {
e.pushable = ids.contains(&e.quest_id);
}
state
}
/// The button's predicate is a CONJUNCTION, and each half is tested by moving only itself: a
/// sharable quest with no party is dark, a party with an unsharable selection is dark, and only
/// both together light it (ref `QuestLogFrame.lua:299-305`).
#[test]
fn share_quest_needs_both_a_sharable_selection_and_a_party() {
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, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
// Solo, quest 1 sharable and selected by the auto-selection.
s.set_quest_log(entries_sharable(&[1]));
s.set_party(PartyState::default());
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(s.eval::<i64>("return GetQuestLogSelection()").unwrap(), 1);
// `1`/nil, never true/false (decision 1738) — compared the way the reference's own predicate
// does, which is why the button below reads the same either way.
assert!(
s.eval::<bool>("return GetQuestLogPushable() ~= nil")
.unwrap(),
"quest 1 carries the sharable bit"
);
assert!(
!s.eval::<bool>("return QuestFramePushQuestButton:IsEnabled() ~= 0")
.unwrap(),
"solo: sharable is not enough"
);
// Join a party — the event alone must light it, with no quest-log change at all.
s.set_party(party(2));
s.fire_event("PARTY_MEMBERS_CHANGED", vec![]);
assert!(
s.eval::<bool>("return QuestFramePushQuestButton:IsEnabled() ~= 0")
.unwrap(),
"PARTY_MEMBERS_CHANGED alone re-tests the button"
);
// Select an unsharable quest: party unchanged, button goes dark.
s.run("SelectQuestLogEntry(2)").unwrap();
s.run("QuestLog_Update()").unwrap();
assert!(
s.eval::<bool>("return GetQuestLogPushable() == nil")
.unwrap(),
"quest 2 has no sharable bit"
);
assert!(
!s.eval::<bool>("return QuestFramePushQuestButton:IsEnabled() ~= 0")
.unwrap(),
"in a party: an unsharable selection is still dark"
);
// Leaving the party darkens it again from the other side.
s.run("SelectQuestLogEntry(1)").unwrap();
s.run("QuestLog_Update()").unwrap();
assert!(s
.eval::<bool>("return QuestFramePushQuestButton:IsEnabled() ~= 0")
.unwrap());
s.set_party(PartyState::default());
s.fire_event("PARTY_MEMBERS_CHANGED", vec![]);
assert!(!s
.eval::<bool>("return QuestFramePushQuestButton:IsEnabled() ~= 0")
.unwrap());
}
/// An EMPTY log disables the button outright, ahead of both other conjuncts — the ref's own first
/// guard, and the one that matters because `GetQuestLogPushable()` on no selection is false anyway
/// but `GetNumPartyMembers() > 0` is not.
#[test]
fn share_quest_is_dark_on_an_empty_log_even_in_a_party() {
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, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(QuestLogState::default());
s.set_party(party(4));
s.run("ToggleQuestLog()").unwrap();
assert!(!s
.eval::<bool>("return QuestFramePushQuestButton:IsEnabled() ~= 0")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The click queues the SELECTION's quest id — not its list index, and not the row under the
/// mouse. The fixture's ids are deliberately not equal to their 1-based positions after a
/// selection move, so an index/id confusion shows up here.
#[test]
fn share_quest_click_queues_the_selected_quests_id() {
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, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest_log(entries_sharable(&[1, 5]));
s.set_party(party(1));
s.run("ToggleQuestLog()").unwrap();
s.run("SelectQuestLogEntry(5)").unwrap();
s.run("QuestLog_Update()").unwrap();
s.run("QuestFramePushQuestButton:Click()").unwrap();
assert_eq!(s.take_quest_log_pushes(), vec![5]);
assert!(s.take_quest_log_pushes().is_empty(), "drained");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The reference's title width cap is live, it bites, and it does not wrap the row.**
///
/// Stock `QuestLogFrame.lua:196-203` shrinks a tagged row's title to `275 − 15 − tagWidth` so a
/// long name cannot run under the right-flush `(Elite)`. Decision 1873 declined to implement that
/// cap in our own (now deleted) copy of the window, on the reading that an explicit width is a
/// **wrap** width here and would spill the title onto a second line inside the 16-unit row;
/// 1944 then put the reference's own file on the chain and the cap arrived with it, unrevisited.
///
/// It is correct, and the reading was wrong about our own engine: the title FontString carries a
/// DECLARED height (`<ButtonText …><AbsDimension x="0" y="10"/>`), which arms both overflow
/// regimes — the line stack and the height-gated ellipsis — so the paint is one truncated line,
/// which is the reference's own result (wow-re `fontstring-overflow.md`: the ellipsis gate is
/// `boxW > 0 && boxH > 0`, and only an AUTO-height FontString escapes it). The render half is
/// pinned on the real font in `ui_text::layout`'s
/// `a_capped_quest_log_title_ellipsizes_on_one_line`; this is the geometry half — that the row
/// really hands that pass a box narrower than its own text.
#[test]
fn the_stock_title_width_cap_bites_and_is_sticky_per_row_button() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The cap is arithmetic over `questTitleTag:GetWidth()`, read inside the update — the app's
// measure seam is synchronous (`extract::seat_text_measurer`), so the harness installs its
// stand-in or every tag measures 0 and the cap comes out at the wrong number.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
// A real 1.11 Dungeon-tagged quest, and the longest title the tag branch has to survive.
const LONG: &str = "The Left Piece of Lord Valthalak's Amulet";
let mut state = eight_entries();
state.entries[0].title = LONG.into();
state.entries[0].tag = Some("Dungeon".into());
// Row 2 carries the same name with NO tag: the else branch never sets a width at all.
state.entries[1].title = LONG.into();
state.entries[1].tag = None;
s.set_quest_log(state.clone());
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let width =
|s: &mut UiScript, q: &str| s.eval::<f32>(&format!("return {q}:GetWidth()")).unwrap();
let ink = |s: &mut UiScript, q: &str| {
s.eval::<f32>(&format!("return {q}:GetStringWidth()"))
.unwrap()
};
// The cap is the reference's own arithmetic, over the tag string it just painted.
let tag_w = width(&mut s, "QuestLogTitle1Tag");
let capped = width(&mut s, "QuestLogTitle1NormalText");
assert!(
(capped - (275.0 - 15.0 - tag_w)).abs() < 0.5,
"the row's title is capped at 275-15-tagWidth ({tag_w} wide): got {capped}"
);
// And it BITES — the box is narrower than the title's own extent, which is the whole case
// 1873 refused. What the paint does with that is the render half's test.
let natural = ink(&mut s, "QuestLogTitle1NormalText");
assert!(natural > capped, "the fixture title ({natural} px) must overflow the {capped} px cap or this test proves nothing");
// The row's title box is one line tall — the declared height that arms both overflow regimes.
// An auto-height FontString would escape the ellipsis gate and stack a second line into a
// 16-unit row, which is exactly the defect that was feared.
assert!(
(s.eval::<f32>("return QuestLogTitle1NormalText:GetHeight()")
.unwrap()
- 10.0)
.abs()
< 0.5,
"the ButtonText's declared height (QuestLogFrame.xml:94-96)"
);
// An untagged row is never capped: the else branch's reset only fires above 275.
assert!(
(width(&mut s, "QuestLogTitle2NormalText") - ink(&mut s, "QuestLogTitle2NormalText")).abs()
< 0.5,
"an untagged title keeps its natural width"
);
// **The cap is sticky per row button, and that is the reference's too**: once the title has an
// explicit width, `questNormalText:GetWidth()` answers it (ours and the client's both), so the
// else branch's `> 275` reset can never fire and a row that has shown a tagged quest keeps the
// narrow box when an untagged one scrolls into it. Reproduced, not a benilla defect — but the
// day our metric reads stop being sticky this row silently changes width, so it is pinned.
state.entries[0].tag = None;
s.set_quest_log(state);
s.run("QuestLog_Update()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
(width(&mut s, "QuestLogTitle1NormalText") - capped).abs() < 0.5,
"the capped box survives the row going untagged (the ref's own 275 reset never fires)"
);
}