- the help window is the reference's own `HelpFrame.xml`, and ours was less than half its size, so this is mostly arrival: the category list, the per-category GM and harassment pages, the stuck page, the bug report, the PvP policy line; four things we had built go with the swap — one-click filing from Home, the Home unstick button, the downed-queue refusal message, and ticket abandonment, a capability loss rather than a look change, since `AbandonTicket` exists in neither the binding table nor the 1.12 FrameXML corpus and any return of it goes on top of the stock window; `GMSURVEY_DISPLAY` arrives with no producer, a real 1.12 event (`SignalEvent` `0x5e797b`) whose whole UI is a LoadOnDemand addon not on our chain; and the stock file needs two orderings ours did not, the micro-button row before its GM status ask and the buff frame before its repoll timer (1889, superseding 1687)
- the cast bar, the action-button regions and the font registry are the reference's own files too: our `CastingBar.xml` was `CastingBarFrame.xml` under another name, with the literal "Failed" where the reference reads the `FAILED` GlobalString (1886); `ActionButtonTemplate.xml` seats above our `ActionBar.xml` because the later-registered template wins, and it declares zero scripts, so the regions migrate while the handlers stay ours until `ActionButton.lua`'s verbs do (1887); `Fonts.xml` is manifest entry 0, all 51 fonts match by name, and its one real difference, `ITEM_QUALITY_COLORS`, moves to UIParent where the reference declares it (1888); the harnesses were the real cost — those reading our files off disk could not name a chain file and now read through the manifest's own rule — and the event-producer gate, having cried wolf at the cast bar's `SPELLCAST_INTERRUPTED` fired through a variable, now matches the fired tuple's shape
- of the 189 events benilla fires, three are not in the client — `BAG_UPDATE_DELAYED` (a Classic Era name), `LOOT_UPDATE` and `UPDATE_LOOT_ROLL` (invented here, no listener anywhere) — found against the reference binary's own string table, and a gate now fails any fire of a name the client cannot dispatch unless it wears a `BENILLA_` prefix (1883); two payloads the reference gives that we did not: `UNIT_HAPPINESS` carries its unit token, without which no `UNIT_*` consumer reads it, and `CHARACTER_POINTS_CHANGED` carries the point deltas `ChatFrame.lua` compares unguarded, so nil raised (1884); and `PLAYER_LEVEL_UP` carries all nine — level, health and mana gain, talent points (not on the wire: the client derives `(newLevel >= 10) ? 1 : 0` at `0x5e407c`) and the five stat gains — matched to the ding by level, a demotion's absent gains sent as zeros since a nil raises where a zero reads as no line
- the chain gap report could not run — one of its six probes had no player seated where a stock addon formats `UnitName("player")` at load, the other five each carrying an identical copy-pasted seed — and the repaired instrument prices each window's cost: 13 stock files load clean with nothing missing (the action bars, the cast bar, the fonts, the help, friends, game-menu and sound-options windows among them) and the near ones name their missing verb, with "loads clean" still not "swapped" (1885); and `UpdateNameplates`, recorded as the missing engine verb blocking the UIParent keystone, is FrameXML — defined in `UIOptionsFrame.lua`, absent from the binding table, its whole body four nameplate verbs that are already ours — so it is twelve lines transcribed into our options frame, the reference's own Show-then-Hide no-op included (1890 corrects 1878)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
204 lines
8.6 KiB
Rust
204 lines
8.6 KiB
Rust
//! 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, "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, "ScrollTemplates.xml");
|
||
load_xml(&s, "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,
|
||
);
|
||
}
|