- 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>
1944 lines
92 KiB
Rust
1944 lines
92 KiB
Rust
//! 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)"
|
||
);
|
||
}
|