Files
benilla-octo/crates/benilla-app/src/ui_script/quest_tests.rs
T
SamandClaude Fable 5 d99ad8bc7d the dressing room opens, and the pet gets its paper doll
- ctrl-click an item to try it on: the dressing room dresses your double, and shift-click posts the item link to chat
- the pet tab lands in the character window: paper doll stats, family and diet, the right-click menu, and dropping food on the pet feeds it
- water-only spells refuse on dry land: aquatic form gates to swimming, client-side like the real one
- the macro icon picker's white squares get their art back, and option rows explain themselves on hover

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

783 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The shipped **questgiver window** open/close sound, driven engine-only (no Bevy): the real
//! `assets/ui/QuestFrame.xml` loaded behind `UiPanels.xml` and shown/hidden through the wire events.
//! The window-sound convention's machine check for the quest arc (decision 0090), the sibling of the
//! merchant/gossip/bag/loot sound tests.
use benilla_ui::script::{QuestPanel, QuestState, ScriptValue, SoundRequest, UiScript};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the panel tests'
/// loader, duplicated so this file is self-contained).
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// The questgiver window's open/close kits — the window-sound convention (decision 0090). The real
/// QuestFrame.lua plays igQuestListOpen in QuestFrame_OnShow (l.285) and igQuestListClose in
/// QuestFrame_OnHide (l.294), wired via the frame OnShow/OnHide (QuestFrame.xml l.1012/1015). A
/// questgiver event (QUEST_DETAIL here) → ShowUIPanel → Show() fires OnShow; QUEST_FINISHED →
/// HideUIPanel → Hide() fires OnHide. Nothing queues at load (the frame is authored hidden="true").
/// Same pair as gossip — questgiver and gossip are the same "list" surface to the client.
#[test]
fn questgiver_show_hide_plays_open_and_close_kits() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
// The BenillaMoney_* purse helpers the quest reward/progress panels repaint through live in
// MerchantFrame.xml (the same documented cross-window dep the bag tests load).
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
// Hidden at load: no open sound (never transitions on startup).
assert!(
s.take_sounds().is_empty(),
"no sound at load (never transitions)"
);
// A quest-details panel arrives (SMSG_QUESTGIVER_QUEST_DETAILS → QUEST_DETAIL) and shows the
// window through ShowUIPanel.
s.set_quest(Some(QuestState {
panel: QuestPanel::Detail,
title: "A Threat Within".into(),
body: "Speak with the captain.".into(),
objectives: "Report to the captain.".into(),
..QuestState::default()
}));
s.fire_event("QUEST_DETAIL", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return BenillaQuestFrame:IsVisible()")
.unwrap(),
"the questgiver window opened onto the left slot"
);
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igQuestListOpen".into())],
"opening the questgiver window plays igQuestListOpen"
);
// QUEST_FINISHED hides it through HideUIPanel → OnHide.
s.fire_event("QUEST_FINISHED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaQuestFrame:IsVisible()")
.unwrap(),
"QUEST_FINISHED hid the questgiver window"
);
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igQuestListClose".into())],
"closing the questgiver window plays igQuestListClose"
);
}
/// The four-child-panel restructure (this pass): each questgiver event shows exactly ONE of the four
/// real sub-panels and hides the other three — mirroring the ref's own `QuestFrame*Panel_OnShow`
/// (each hides its three siblings on show). No sound on a panel SWITCH (only the outer window's
/// OnShow/OnHide play kits, per the ref `QuestFrame_OnShow`/`OnHide` — a panel change while the window
/// stays open is silent).
#[test]
fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
let panels = [
"BenillaQuestGreetingPanel",
"BenillaQuestDetailPanel",
"BenillaQuestProgressPanel",
"BenillaQuestRewardPanel",
];
let is_shown =
|s: &UiScript, name: &str| s.eval::<bool>(&format!("return {name}:IsShown()")).unwrap();
s.set_quest(Some(QuestState {
panel: QuestPanel::Greeting,
greeting: "What can I do for you?".into(),
active_titles: vec!["Report to Goldshire".into()],
..QuestState::default()
}));
s.fire_event(
"QUEST_GREETING",
vec![ScriptValue::Str("Deputy Willem".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
for name in panels {
assert_eq!(
is_shown(&s, name),
name == "BenillaQuestGreetingPanel",
"only the greeting panel is shown after QUEST_GREETING"
);
}
assert_eq!(
s.eval::<String>("return BenillaQuestNpcNameText:GetText()")
.unwrap(),
"Deputy Willem"
);
// No sound on the panel event itself — the window was already open (well, first open here DOES
// play the open kit; drain it so the assertion below is about the SWITCH, not this first show).
s.take_sounds();
s.set_quest(Some(QuestState {
panel: QuestPanel::Detail,
title: "A Threat Within".into(),
..QuestState::default()
}));
s.fire_event(
"QUEST_DETAIL",
vec![ScriptValue::Str("Deputy Willem".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
for name in panels {
assert_eq!(
is_shown(&s, name),
name == "BenillaQuestDetailPanel",
"only the detail panel is shown after QUEST_DETAIL"
);
}
assert!(
s.take_sounds().is_empty(),
"a panel switch with the window already open plays no kit (only OnShow/OnHide of the window do)"
);
}
/// The reward/choice grid, ported from the ref's shared `QuestFrameItems_Update` (the SAME idiom the
/// quest log's own reward rows already prove out) — now on REAL per-panel widgets
/// (`BenillaQuestDetailChoice*`/`BenillaQuestDetailReward*`), exercised on the Detail panel with the
/// fixture shape the task's capture drives: 2 choices + 1 fixed reward + money.
#[test]
fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
let choice = |name: &str, quality: u32| benilla_ui::script::QuestItemView {
item_id: 0,
name: Some(name.into()),
texture: None,
count: 1,
quality,
usable: true,
..Default::default()
};
s.set_quest(Some(QuestState {
panel: QuestPanel::Detail,
title: "A Threat Within".into(),
body: "Kill the kobolds infesting the mine.".into(),
objectives: "Slay 10 Kobold Vermin.".into(),
choices: vec![choice("Worn Sword", 1), choice("Worn Mace", 1)],
rewards: vec![choice("Militia Hammer", 1)],
reward_money: 150,
..QuestState::default()
}));
s.fire_event(
"QUEST_DETAIL",
vec![ScriptValue::Str("Marshal McBride".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaQuestDetailChoice1:IsShown()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaQuestDetailChoice2:IsShown()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaQuestDetailChoice3:IsShown()")
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaQuestDetailChoice1Name:GetText()")
.unwrap(),
"Worn Sword"
);
assert_eq!(
s.eval::<String>("return BenillaQuestDetailItemChooseText:GetText()")
.unwrap(),
"You may choose one of these rewards:"
);
assert_eq!(
s.eval::<String>("return BenillaQuestDetailItemReceiveText:GetText()")
.unwrap(),
"You will also receive:",
"choices present -> the ref's \"also\" wording (QuestFrame.lua:461-462)"
);
assert!(s
.eval::<bool>("return BenillaQuestDetailReward1:IsShown()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaQuestDetailReward2:IsShown()")
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaQuestDetailReward1Name:GetText()")
.unwrap(),
"Militia Hammer"
);
assert_eq!(
s.eval::<String>("return BenillaQuestDetailRewardTitleText:GetText()")
.unwrap(),
"Rewards"
);
// The detail panel's choice rows are informational only (ref: only the REWARD panel's rows
// select). They DO carry an OnClick now (`BenillaQuestItem_OnClick`, the ref's own
// QuestItemTemplate script — the ctrl/shift fork, decisions 1059/1060), but it has no select
// arm at all: an unmodified click must not raise and must not set an itemChoice.
s.run("BenillaQuestDetailChoice1:Click()").ok();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
0,
"a detail-panel row never selects a reward"
);
}
/// The reward panel's choice rows ARE selectable (ref `QuestRewardItem_OnClick`) — clicking one moves
/// the highlight and arms `GetQuestReward`'s 1-based→0-based conversion.
#[test]
fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
let choice = |name: &str| benilla_ui::script::QuestItemView {
item_id: 0,
name: Some(name.into()),
texture: None,
count: 1,
quality: 1,
usable: true,
..Default::default()
};
s.set_quest(Some(QuestState {
panel: QuestPanel::Reward,
title: "A Threat Within".into(),
body: "Well done.".into(),
choices: vec![choice("Worn Sword"), choice("Worn Mace")],
..QuestState::default()
}));
s.fire_event(
"QUEST_COMPLETE",
vec![ScriptValue::Str("Marshal McBride".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaQuestRewardChoiceHighlight:IsShown()")
.unwrap(),
"no row picked yet"
);
// Completing without a choice picked is a no-op (ref: QuestChooseRewardError guard).
s.run("BenillaQuestRewardCompleteButton:Click()").unwrap();
assert!(
s.take_quest_actions().is_empty(),
"must pick a choice first"
);
s.run("BenillaQuestRewardChoice2:Click()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return BenillaQuestRewardChoiceHighlight:IsShown()")
.unwrap(),
"picking a choice row shows the highlight"
);
s.run("BenillaQuestRewardCompleteButton:Click()").unwrap();
assert_eq!(
s.take_quest_actions(),
vec![benilla_ui::script::QuestAction::Reward(1)],
"row 2 (1-based) -> wire index 1 (0-based)"
);
}
/// The greeting panel's own Goodbye button (ref `QuestFrameGreetingGoodbyeButton`, a widget the flat
/// v1 layout never carried) — a plain client-side close, no `DeclineQuest()` call (ref l.748-752).
#[test]
fn greeting_goodbye_button_closes_the_window() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
s.set_quest(Some(QuestState {
panel: QuestPanel::Greeting,
greeting: "Hello".into(),
..QuestState::default()
}));
s.fire_event(
"QUEST_GREETING",
vec![ScriptValue::Str("Deputy Willem".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaQuestFrame:IsVisible()")
.unwrap());
s.run("BenillaQuestGreetingGoodbyeButton:Click()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaQuestFrame:IsVisible()")
.unwrap(),
"Goodbye closes the questgiver window"
);
}
/// A rect-level regression guard for the exact bug the capture-loop caught: `BenillaQuestPanelButtonTemplate`
/// is a virtual template declared `hidden="true"` (never itself drawn) — an instance that doesn't
/// explicitly `:Show()` stays invisible even though it isn't declared `hidden="true"` itself. Every
/// panel's action buttons must show, extracted at real on-window rects (never dropped/hidden).
#[test]
fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
s.set_quest(Some(QuestState {
panel: QuestPanel::Detail,
title: "A Threat Within".into(),
body: "Kill kobolds.".into(),
..QuestState::default()
}));
s.fire_event(
"QUEST_DETAIL",
vec![ScriptValue::Str("Deputy Willem".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaQuestAcceptButton:IsShown()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaQuestDeclineButton:IsShown()")
.unwrap());
s.resolve();
let button_art = |s: &mut UiScript| {
let mut v = Vec::new();
for q in s.extract() {
if let benilla_ui::script::QuadContent::Texture { path: Some(p), .. } = &q.content {
if p.starts_with("Interface\\Buttons\\UI-Panel-Button-") {
v.push((
p.clone(),
q.rect.expect("button quad carries a resolved rect"),
));
}
}
}
v
};
// The instant-text arm (QUEST_FADING_DISABLE pinned "1"): OnShow still disables Accept and
// zeroes the block for the ref's one-frame window — the reveal edge just starts at 1024.
let writing = button_art(&mut s);
assert_eq!(
writing.len(),
2,
"Accept + Decline both extract a real rect (not dropped for being unshown)"
);
assert!(
writing
.iter()
.any(|(p, _)| p.ends_with("UI-Panel-Button-Disabled")),
"Accept draws the Disabled art inside the one-frame arm window"
);
for (_, r) in &writing {
assert!(
r.top <= 512.0 && r.bottom >= 0.0 && r.left >= 0.0 && r.right <= 384.0,
"button {r:?} must land inside the 384x512 window, not off-screen/at the origin"
);
}
// First tick: instant text keeps the writing sound — exactly one quill scratch — then the
// gradient runs off, the objectives/rewards block SNAPS to opaque (no fade) and Accept wakes.
let scratches = |sounds: Vec<SoundRequest>| {
sounds
.iter()
.filter(|r| **r == SoundRequest::KitName("WriteQuest".into()))
.count()
};
s.tick(0.05);
assert!(s.errors().is_empty(), "tick errors: {:?}", s.errors());
assert_eq!(
scratches(s.take_sounds()),
1,
"instant text keeps the writing sound: one scratch on the wake tick"
);
assert!(s
.eval::<bool>("return BenillaQuestAcceptButton:IsEnabled()")
.unwrap());
assert_eq!(
s.eval::<f32>("return BenillaQuestDetailTextAlphaFrame:GetAlpha()")
.unwrap(),
1.0,
"the block snaps straight to opaque — no QUESTINFO_FADE_IN ramp in instant mode"
);
s.tick(0.5);
assert_eq!(
scratches(s.take_sounds()),
0,
"the quill stops after the single instant-mode scratch"
);
s.resolve();
let written = button_art(&mut s);
assert!(
written
.iter()
.all(|(p, _)| p.ends_with("UI-Panel-Button-Up")),
"both buttons draw live art once the text has written on: {written:?}"
);
}
/// The ref write-on survives verbatim behind the flag: with QUEST_FADING_DISABLE = "0" (the
/// ref's own default, our pin is "1"), the description writes on at 40 chars/s scratching the
/// quill each tick, Accept stays dead mid-write, and the objectives/rewards block FADES in over
/// QUESTINFO_FADE_IN rather than snapping.
#[test]
fn write_on_still_fades_when_instant_text_is_off() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
s.eval::<()>(r#"QUEST_FADING_DISABLE = "0""#).unwrap();
s.set_quest(Some(QuestState {
panel: QuestPanel::Detail,
title: "A Threat Within".into(),
body: "Kill kobolds.".into(),
..QuestState::default()
}));
s.fire_event(
"QUEST_DETAIL",
vec![ScriptValue::Str("Deputy Willem".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Mid-write (reveal edge at char 4 of 13): the quill scratches, Accept stays dead.
s.tick(0.1);
assert!(
s.take_sounds()
.iter()
.any(|r| *r == SoundRequest::KitName("WriteQuest".into())),
"the write-on scratches the WriteQuest quill each tick"
);
assert!(!s
.eval::<bool>("return BenillaQuestAcceptButton:IsEnabled()")
.unwrap());
// Half a second more runs the 13-char gradient off (40 chars/s): Accept wakes and the block
// is mid-FADE — armed at 0 and ramping over QUESTINFO_FADE_IN, not snapped to opaque.
s.tick(0.5);
assert!(s.errors().is_empty(), "tick errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaQuestAcceptButton:IsEnabled()")
.unwrap());
let alpha = s
.eval::<f32>("return BenillaQuestDetailTextAlphaFrame:GetAlpha()")
.unwrap();
assert!(
alpha < 1.0,
"fading mode ramps the block in (got alpha {alpha})"
);
s.tick(1.1);
assert_eq!(
s.eval::<f32>("return BenillaQuestDetailTextAlphaFrame:GetAlpha()")
.unwrap(),
1.0,
"the fade completes at opaque"
);
}
/// The giver window's title bar shows the NPC name — the panel-open events carry it as arg1 and
/// the in-place QUEST_ITEM_UPDATE refresh (how a late ask-once name arrives in live play) must
/// update it too (the 0112-era capture showed a permanently blank bar).
#[test]
fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
s.set_quest(Some(QuestState {
panel: QuestPanel::Detail,
title: "A Threat Within".into(),
body: "Kill things.".into(),
..QuestState::default()
}));
s.fire_event(
"QUEST_DETAIL",
vec![ScriptValue::Str("Marshal McBride".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<String>("return getglobal('BenillaQuestNpcNameText'):GetText() or ''")
.unwrap(),
"Marshal McBride",
"the panel-open event's arg1 lands in the title bar"
);
// And it RENDERS: the quad extracts with a rect near the window top (the 0112-era capture
// showed a blank bar — pin the whole path, not just the Lua-visible text).
s.resolve();
let name_quad = s
.extract()
.into_iter()
.find(|q| {
matches!(&q.content,
benilla_ui::script::QuadContent::Text { text: Some(t), .. } if t == "Marshal McBride")
})
.expect("the NPC-name FontString extracts a quad");
assert!(
name_quad.rect.is_some(),
"the NPC-name quad carries a resolved rect (got None — under-constrained)"
);
// The late-name path: open with an empty name, the refresh brings it.
s.run("getglobal('BenillaQuestNpcNameText'):SetText('')")
.unwrap();
s.fire_event(
"QUEST_ITEM_UPDATE",
vec![ScriptValue::Str("Marshal McBride".into())],
);
assert_eq!(
s.eval::<String>("return getglobal('BenillaQuestNpcNameText'):GetText() or ''")
.unwrap(),
"Marshal McBride",
"the QUEST_ITEM_UPDATE refresh also carries the name"
);
}
/// The gossip window's twin law, on the greeting panel's quest-title rows: a title long enough to
/// WRAP at the row label's 275 px must grow its row, or the static/Lua-chained rows below print
/// through it (the shape of the gossip overlap the director reported). Same deterministic
/// 6 px/char × 14 px/line measure fake as the gossip row test.
#[test]
fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
s.set_quest(Some(QuestState {
panel: QuestPanel::Greeting,
greeting: "What can I do for you?".into(),
active_titles: vec![
"Deliver Thomas' Report to Marshal Dughan in Goldshire before the Defias move again"
.into(),
"Investigate the Echo Ridge Mine and report back to Marshal McBride at once".into(),
],
..QuestState::default()
}));
s.fire_event(
"QUEST_GREETING",
vec![ScriptValue::Str("Deputy Willem".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let 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) => (r.id, ink.min(w), (ink / w).ceil().max(1.0) * 14.0, r.key),
None => (r.id, ink, 14.0, r.key),
}
})
.collect();
s.set_measured_text_unwrapped(&answers);
};
answer_measures(&mut s);
s.resolve();
s.tick(0.016);
answer_measures(&mut s);
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let row = |i: u32| -> (f32, f32, f32) {
s.eval::<(f32, f32, f32)>(&format!(
"local b = getglobal('BenillaQuestTitleButton{i}')\n\
return b:GetTop(), b:GetBottom(), b:GetFontString():GetStringHeight()"
))
.unwrap()
};
let (t1, b1, h1) = row(1);
let (t2, b2, h2) = row(2);
assert!(h1 >= 28.0 && h2 >= 28.0, "both titles wrap: {h1}, {h2}");
assert!(
(t1 - b1 - (h1 + 2.0)).abs() < 0.5,
"row 1 is its wrapped title + 2: got {}, title {h1}",
t1 - b1
);
assert!(
(t2 - b2 - (h2 + 2.0)).abs() < 0.5,
"row 2 is its wrapped title + 2: got {}, title {h2}",
t2 - b2
);
assert!(
t2 <= b1 + 0.5,
"row 2 starts at/below row 1's bottom: row1 bottom {b1}, row2 top {t2}"
);
}
/// The questgiver rows' modifier fork (decisions 1059/1060), on the panel where it can do the most
/// damage: the REWARD panel, whose choice rows are also the quest's reward *selection*. Ref
/// `QuestRewardItem_OnClick` (QuestFrame.lua:127-141) — ctrl previews, shift posts the link, and the
/// choice SELECT is the third arm, so neither modified click may also pick the reward. The fixed
/// reward row beside it rides the plainer `QuestItem_OnClick` (l.115-125, the base
/// QuestItemTemplate's own OnClick) and previews the same way with no select arm to disturb.
///
/// Ordering note: the dressing room docks the same left UIPanel slot as the questgiver window
/// (`UiPanels.xml`, `pushable = 2` vs the giver's `0`), so the ctrl arm is exercised last — opening
/// it closes the giver window, exactly as it does in play.
#[test]
fn reward_rows_preview_and_post_without_selecting_the_choice() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "QuestFrame.xml");
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
load_xml(&s, "DressUpFrame.xml"); // DressUpItemLink lives here
load_xml(&s, "ChatFrame.xml"); // ChatFrameEditBox lives here
const SWORD: &str = "|cffffffff|Hitem:2299:0:0:0|h[Worn Sword]|h|r";
const MACE: &str = "|cffffffff|Hitem:2300:0:0:0|h[Worn Mace]|h|r";
let item = |id: u32, name: &str, link: &str| benilla_ui::script::QuestItemView {
item_id: id,
name: Some(name.into()),
texture: None,
count: 1,
quality: 1,
usable: true,
link: Some(link.into()),
};
s.set_quest(Some(QuestState {
panel: QuestPanel::Reward,
title: "A Threat Within".into(),
body: "Well done.".into(),
choices: vec![
item(2299, "Worn Sword", SWORD),
item(2300, "Worn Mace", MACE),
],
rewards: vec![item(
2504,
"Worn Shortbow",
"|cffffffff|Hitem:2504:0:0:0|h[Worn Shortbow]|h|r",
)],
..QuestState::default()
}));
s.fire_event(
"QUEST_COMPLETE",
vec![ScriptValue::Str("Marshal McBride".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The regression this fork could break: a PLAIN click still picks the choice (the ref's third
// arm). Row 2 is picked here so the modified clicks below — aimed at row 1 — would visibly
// move it if they leaked into the select arm.
s.run("BenillaQuestRewardChoice2:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
2,
"a plain click still selects the reward choice"
);
assert!(s
.eval::<bool>("return BenillaQuestRewardChoiceHighlight:IsShown()")
.unwrap());
assert!(
s.take_dressup_intents().is_empty(),
"a plain click never opens the dressing room"
);
// SHIFT + chat open → the row's full escaped link, and the selection is untouched.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.run("BenillaQuestRewardChoice1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
SWORD,
"shift-click posted choice 1's link"
);
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
2,
"the shift arm returns — it must NOT also select the clicked choice"
);
// A FIXED reward row (plain BenillaQuestItemTemplate → BenillaQuestItem_OnClick) posts too.
s.run("ChatFrameEditBox:SetText(\"\")").unwrap();
s.set_modifiers(true, false, false);
s.run("BenillaQuestRewardReward1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
"|cffffffff|Hitem:2504:0:0:0|h[Worn Shortbow]|h|r"
);
// CTRL → the dressing room wearing the clicked reward, and STILL no reselect. The intent pair
// is ordered: Dress (the room was closed, so it re-dresses in the player's own gear) then TryOn.
s.set_modifiers(false, true, false);
s.run("BenillaQuestRewardChoice1: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(2299)
],
"ctrl-click opened the room wearing choice 1"
);
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
2,
"the ctrl arm returns — it must NOT also select the clicked choice"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}