Files
benilla-octo/crates/benilla-app/src/ui_script/quest_timer_tests.rs
T
SamandClaude Fable 5.1 f4951470a3 coins ride the cursor, and the trade window and combat text go stock
- coins ride the cursor: `MoneyFrame.lua`/`.xml` load off the player's chain, and the stock kit reads the cursor's coins at load, so the money cursor — payload mode 2, the copper at `[0xb4e2f0]` — is the engine's: `PickupPlayerMoney` puts coins on the cursor and the purse is never debited, and `CoinPickupFrame.xml`, the shift-click coin dialog nothing of ours had ever answered, joins; wow-re's round then set the law: the pickup raises on a non-number and refuses silently on zero, lost control and more than the purse; `AddTradeMoney` folds the coins into the offer as an absolute `CMSG_SET_TRADE_GOLD` gated on the purse; the send-mail setters and `GetCoinIcon` answer as the bytes do; the coinage-change reflex trims an offer the purse no longer covers; and the four send-mail pickup/drop verbs the stock table names do not exist in 5875, so they are not built (1962, 1965)
- the trade window is the reference's own file: `TradeFrame.xml` + `TradeFrame.lua` off the chain, ours deleted; `GameTooltip:SetTradePlayerItem`/`SetTradeTargetItem` (`0x5341e0`/`0x534410`) render the slot's item the way the loot slot's is, and `TRADE_PLAYER_ITEM_CHANGED`/`TRADE_TARGET_ITEM_CHANGED` — which the stock file repaints one slot on, and nothing had fired — fire per changed slot beside `TRADE_UPDATE`; the stock file brings the chain's own `MoneyInputFrame`, a TARGET_TRADE `SmallMoneyFrame`, `ItemButtonTemplate` slots and the enchant slot's own wording — "Item not yet modified" where ours had read "Will Not Be Traded"; the wire's enchant id is not carried into the slot yet, so the tooltip shows the item without its enchant (1966)
- the dialogs' verbs, as the bytes define them: nine of the ten stock `StaticPopup.lua` bindings 1960 had left named are built off wow-re's round with their packets, events and UIParent arms — the bare trade pair, `ForceLogout`, the instance-boot timer (whole seconds), the area spirit healer's accept/cancel/time, `AcceptBattlefieldPort` out of a three-slot queue `SMSG_BATTLEFIELD_STATUS` fills, `CancelMeetingStoneRequest`, and the pet trainer's pair as the talent wipe's twin, latch for latch — `SMSG_PET_UNLEARN_CONFIRM` latches and asks, `ConfirmPetUnlearn` sends behind the money gate, `CheckPetUntrainerDist` reads the leash; not built, named: the healer's proximity scan (`0x4923b0`: 20 yd to acquire, 22 to retain), so no healer is cached yet, and `ReplaceTradeEnchant`, which rides trade-slot enchant targeting this engine does not build (1963)
- the scrolling combat text is the reference's own `Blizzard_CombatText`, LoadOnDemand off the chain: our transcription is deleted, and the addon loads through `UIParentLoadAddOn` at the reference's own sites; the family's fourteen defaults move to the options file where the reference keeps them, because a LoadOnDemand addon cannot define the options that decide whether it loads (1964); and the Looking-for-Group pair is what the bytes define, correcting 1959's flag: `SetLookingForGroup` takes three (type, entry) pairs and a comment, packs each slot as `(type << 24) & entry` — an AND (`0x4e9713`), so every admissible input stores zero — and sends `CMSG_SET_LOOKING_FOR_GROUP` only when the commit changed something; `GetLookingForGroup` answers three nilable slot names then the comment, never 1|nil; and the stock 1.12.1 UI never calls either — both call sites sit in an XML comment (1961)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:58:15 +07:00

212 lines
9.1 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 timed-quest countdown window against the transcribed reference behaviour (decision 1150,
//! closing B234): the extracted 1.12 `QuestTimerFrame.lua` is the spec — one `SecondsToTime` line
//! per timed quest, the frame's height `45 + 16·n`, hidden entirely at zero timers, and every row
//! mapping back to its quest for the click and the hover.
//!
//! Three halves, and the last two are the load-bearing ones (`mirror_timer_tests`' lesson, and
//! 0675's): the state tests read the Lua back, which is blind to what actually *paints*; the
//! [`draw list`](UiScript::extract) test is what would catch a window that is shown, correctly
//! filled and invisible; and the geometry diff is what would catch every number being wrong while
//! all of the above passes.
use benilla_ui::script::{
QuestLogEntryView, QuestLogObjectiveView, QuestLogState, ScriptValue, UiScript,
};
use super::test_ui::load_ui as load_xml;
/// Fonts + UIParent (SecondsToTime lives there) + the window itself. Deliberately NOT the whole
/// manifest: the window must stand up on its own dependencies, and a harness that loads everything
/// hides a missing one.
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The window's title is `text="QUEST_TIMERS"`, resolved against the global at load — without
// the player's own strings it draws the KEY, which is exactly what a `text=` attribute does
// with an unknown name.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
// `SecondsToTime`, which the reference's `QuestTimerFrame_Update` formats every row with, and
// `UIParent_ManageFramePositions`, which its OnShow/OnHide call.
load_xml(&s, "UIParent.xml");
// `MAX_QUESTS`, the loop bound the reference's repaint hides its spare rows with. 1.12
// declares it on QuestLogFrame.lua:2 and so do we (1751 window 16) — a nil there is
// `'for' limit must be a number` on the first repaint, not a missing row.
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "UiPanels.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");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "MicroMenu.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestTimerFrame.xml");
s
}
/// A quest-log row. `timer` is the descriptor slot's absolute deadline (0 = untimed).
fn quest(id: u32, title: &str, timer: u32) -> QuestLogEntryView {
QuestLogEntryView {
quest_id: id,
title: title.into(),
level: 10,
timer,
objectives: vec![QuestLogObjectiveView {
text: "Bloodscalp Scout slain: 0/8".into(),
kind: "monster".into(),
finished: false,
cur: 0,
req: 8,
}],
..Default::default()
}
}
fn log(entries: Vec<QuestLogEntryView>) -> QuestLogState {
QuestLogState {
num_quests: entries.len() as u32,
entries,
detail: None,
}
}
fn shown(s: &UiScript, frame: &str) -> bool {
s.eval::<bool>(&format!("return {frame}:IsShown()"))
.unwrap()
}
fn row_text(s: &UiScript, i: u32) -> String {
s.eval::<String>(&format!("return QuestTimer{i}Text:GetText()"))
.unwrap()
}
/// The reference's whole visible contract in one pass: nothing at all with no timed quest; the
/// window with one line per timed quest, in log order, captioned by `SecondsToTime`; the height
/// `45 + 16·n`; and back to hidden when the last timer goes.
#[test]
fn the_window_follows_the_timed_quests() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
// An untimed log: QUEST_LOG_UPDATE fires, the frame stays down.
s.set_quest_log(log(vec![quest(783, "A Threat Within", 0)]));
s.set_server_unix_time(1_000_000.0);
s.fire_event("QUEST_LOG_UPDATE", vec![]);
assert!(!shown(&s, "QuestTimerFrame"), "no timed quest, no window");
// Two timed quests either side of an untimed one. 14:20 and 45 s out.
s.set_quest_log(log(vec![
quest(1, "Deliver the Message", 1_000_860),
quest(2, "A Threat Within", 0),
quest(3, "Escort the Caravan", 1_000_045),
]));
s.fire_event("QUEST_LOG_UPDATE", vec![]);
assert!(shown(&s, "QuestTimerFrame"));
assert!(shown(&s, "QuestTimer1") && shown(&s, "QuestTimer2"));
assert!(!shown(&s, "QuestTimer3"), "only two timers are live");
// SecondsToTime's shipped spelling, plural rule and trailing space, all of it (ref
// UIParent.lua:1004-1031) — over the reference's own `−1` (decision 1154), so a 860-second
// gap reads 859 and a 45-second one 44.
assert_eq!(row_text(&s, 1), "14 Mins 19 Secs ");
assert_eq!(row_text(&s, 2), "44 Secs ");
assert_eq!(
s.eval::<f64>("return QuestTimerFrame:GetHeight()").unwrap(),
45.0 + 16.0 * 2.0
);
// Row 2 belongs to the THIRD log entry — the untimed row in the middle must not shift it.
assert_eq!(s.eval::<i64>("return GetQuestIndexForTimer(2)").unwrap(), 3);
// The clock alone moves and the OnUpdate repaints — no QUEST_LOG_UPDATE, which is the whole
// point of the engine owning the subtraction.
s.set_server_unix_time(1_000_030.0);
s.tick(0.2);
assert_eq!(row_text(&s, 2), "14 Secs ");
// The last timer expires out of the list: the window goes with it.
s.set_quest_log(log(vec![quest(2, "A Threat Within", 0)]));
s.fire_event("QUEST_LOG_UPDATE", vec![]);
assert!(!shown(&s, "QuestTimerFrame"));
}
/// The countdown must actually reach the screen. The state test above passes just as happily for a
/// window that is shown, correctly filled, and drawing nothing — the failure mode `cast_tests`
/// and `mirror_timer_tests` were both written for.
#[test]
fn the_countdown_reaches_the_draw_list() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.set_quest_log(log(vec![quest(1, "Deliver the Message", 1_000_860)]));
s.set_server_unix_time(1_000_000.0);
s.fire_event("QUEST_LOG_UPDATE", vec![]);
s.tick(0.0);
let quads = s.extract();
let text: Vec<String> = quads
.iter()
.filter_map(|q| match &q.content {
benilla_ui::script::QuadContent::Text { text, .. } => Some(text.clone()),
_ => None,
})
.flatten()
.collect();
assert!(
text.iter().any(|t| t.contains("14 Mins")),
"the countdown never reached the draw list: {text:?}"
);
assert!(
text.iter().any(|t| t == "Quest Timers"),
"the QUEST_TIMERS title never reached the draw list: {text:?}"
);
}
/// PLAYER_ENTERING_WORLD is the other event the reference registers (`QuestTimerFrame.lua:3`) —
/// a world entry with a timed quest already in the log must paint without waiting for the log to
/// change again.
#[test]
fn a_world_entry_paints_a_quest_already_running() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.set_quest_log(log(vec![quest(1, "Deliver the Message", 1_000_090)]));
s.set_server_unix_time(1_000_000.0);
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Bool(true)]);
assert!(shown(&s, "QuestTimerFrame"));
// Singular at exactly one minute — SecondsToTime's `_P1` plural rule, the ref's own; 90 s of
// gap reads 89 through the `−1`.
assert_eq!(row_text(&s, 1), "1 Min 29 Secs ");
}
/// **The reference file is the test oracle** (decision 0675). Scrapes both this window and the
/// extracted reference `QuestTimerFrame.xml` for `<AbsDimension>` pairs per named element and
/// asserts every shared element's numbers match — the guard that catches a transcription whose
/// frames all load, click and populate while every number is wrong.
///
/// Verified to fail: perturbing `QuestTimer1`'s TOP offset from -30 to -29 reports
/// `QuestTimer1: ours [(140.0, 16.0), (0.0, -29.0)] != ref [(140.0, 16.0), (0.0, -30.0)]`.
#[test]
fn the_window_geometry_matches_the_reference_framexml() {
let _data = benilla_formats::wow_data_or_skip!();
let Some(reference) =
super::framexml_diff::reference("Interface\\FrameXML\\QuestTimerFrame.xml")
else {
eprintln!("skipping: no extracted FrameXML");
return;
};
// No exemptions: this window's frames keep the reference's bare names (decision 0591 §3 — the
// manage pass and the ref's own row lookup both resolve by literal name), and every number in
// it is the reference's. If an entry ever needs to go here, it names its reason.
super::framexml_diff::assert_geometry_matches(
"Interface\\FrameXML\\QuestTimerFrame.xml",
&reference,
&[],
22,
);
}