the guild window opens: roster, ranks, notes and the message of the day
- the social window's third tab is live — the roster in both its views, the member card with its promote/demote/remove verbs, the rank editor's thirteen permissions, the guild information board, and the invite, leave, kick and note dialogs behind them - setting your hearthstone at an innkeeper now asks before it takes and says so afterwards, and the innkeeper's gossip lines get their own icons back - unavailable talents grey out, and every greyed icon in the client now goes through the same luminance fold the original used - the buff tooltip's remaining-time line reads gold with the client's own hour/minute ladder, the macro editor takes a shift-click to paste a spell, trainer rows repaint after a purchase, and a long book page stops flickering its ellipsis Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
//! The innkeeper bind confirm (decision 1331, BinderConfirm.xml): the dialog `ui_binder`'s feed
|
||||
//! raises, its Accept, and the range poll that takes it away — driven exactly as that feed and the
|
||||
//! app's NPC-session guard drive it.
|
||||
|
||||
use benilla_ui::script::{ScriptValue, UiScript};
|
||||
|
||||
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the duel tests'
|
||||
/// loader, duplicated so this file is self-contained).
|
||||
fn load_xml(s: &UiScript, file: &str) -> usize {
|
||||
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
|
||||
);
|
||||
report.frames
|
||||
}
|
||||
|
||||
/// The app's own pre-state: a question is pending and in range, which is what
|
||||
/// `CheckBinderDist()` reports while the dialog is up.
|
||||
fn setup() -> UiScript {
|
||||
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, "BinderConfirm.xml");
|
||||
s.set_binder_pending(true);
|
||||
s
|
||||
}
|
||||
|
||||
/// `CONFIRM_BINDER(area)` raises the dialog with the area name filled in, and Accept queues the
|
||||
/// one `ConfirmBinder()` that becomes `CMSG_BINDER_ACTIVATE`. The whole point of the arc: before
|
||||
/// this wiring the click produced no dialog and no packet at all (B249).
|
||||
#[test]
|
||||
fn the_confirm_shows_the_area_and_accept_queues_the_bind() {
|
||||
let mut s = setup();
|
||||
s.fire_event(
|
||||
"CONFIRM_BINDER",
|
||||
vec![ScriptValue::Str("Dolanaar".to_string())],
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||||
"CONFIRM_BINDER shows the dialog"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return StaticPopup1Text:GetText()")
|
||||
.unwrap(),
|
||||
"Do you want to make Dolanaar your new home?",
|
||||
"arg1 fills the GlobalStrings template's %s"
|
||||
);
|
||||
|
||||
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
|
||||
assert_eq!(s.take_binder_confirms(), 1);
|
||||
assert!(
|
||||
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||||
"accepting closes it"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancel and ESC both send **nothing**: declining an innkeeper is silent on the wire (there is
|
||||
/// no decline opcode), so the only observable is that no confirm was queued.
|
||||
#[test]
|
||||
fn declining_sends_nothing() {
|
||||
let mut s = setup();
|
||||
s.fire_event("CONFIRM_BINDER", vec![ScriptValue::Str("Goldshire".into())]);
|
||||
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
|
||||
assert_eq!(s.take_binder_confirms(), 0);
|
||||
|
||||
s.fire_event("CONFIRM_BINDER", vec![ScriptValue::Str("Goldshire".into())]);
|
||||
s.run("ToggleGameMenu()").unwrap();
|
||||
assert_eq!(s.take_binder_confirms(), 0);
|
||||
assert!(
|
||||
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||||
"hideOnEscape takes it down"
|
||||
);
|
||||
}
|
||||
|
||||
/// Walking away takes the question off screen: the entry's OnUpdate polls `CheckBinderDist()`,
|
||||
/// which the app drives from the shared NPC-session range guard. While it holds, ticking changes
|
||||
/// nothing; the frame it goes false, the dialog hides itself — with no packet either way.
|
||||
#[test]
|
||||
fn leaving_the_innkeepers_range_hides_the_confirm() {
|
||||
let mut s = setup();
|
||||
s.fire_event("CONFIRM_BINDER", vec![ScriptValue::Str("Kharanos".into())]);
|
||||
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.1)").unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||||
"in range, the tick leaves it alone"
|
||||
);
|
||||
|
||||
s.set_binder_pending(false);
|
||||
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.1)").unwrap();
|
||||
assert!(
|
||||
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||||
"out of range, the dialog takes itself down"
|
||||
);
|
||||
assert_eq!(s.take_binder_confirms(), 0, "and sends nothing");
|
||||
}
|
||||
@@ -749,3 +749,89 @@ fn the_temporary_enchant_row_shows_a_weapon_enchant_and_moves_the_top_row_aside(
|
||||
assert!((s.eval::<f64>("return BuffFrame:GetRight()").unwrap() - resting).abs() < 1e-3);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The duration line against the **real shipped strings**, end to end — the leg the engine's own
|
||||
/// synthetic-template tests cannot reach.
|
||||
///
|
||||
/// The engine formats through `tooltip::duration_text` (`0x52fa50`'s ladder, wow-re
|
||||
/// §3-BUFF-TIME-FORMAT) over whatever `GlobalStrings.lua` the player's install carries; this runs
|
||||
/// that file into a real VM exactly as the boot's `load_global_strings` does, then asserts the
|
||||
/// wording that comes back. Every expectation below is a reading the PREVIOUS two-arm formatter got
|
||||
/// wrong, so this is the carve's whole delta in one place. Skips without client data.
|
||||
#[test]
|
||||
fn the_duration_line_reads_the_real_global_strings() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
let src = chain
|
||||
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
|
||||
.expect("GlobalStrings.lua in the chain");
|
||||
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
|
||||
s.set_spell_tooltip(
|
||||
1459,
|
||||
benilla_ui::script::SpellTooltipView {
|
||||
name: "Arcane Intellect".into(),
|
||||
aura_description: "Intellect increased by 2.".into(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
s.tick(10.0); // GetTime = 10
|
||||
s.run(
|
||||
r#"
|
||||
BENILLA_ANCHOR = CreateFrame("Button", "BF9")
|
||||
BENILLA_ANCHOR:SetPoint("CENTER", 0, 0); BENILLA_ANCHOR:SetSize(10, 10)
|
||||
BENILLA_TIP = CreateFrame("GameTooltip", "TT9")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// `secs_left` seconds remaining at GetTime = 10, read back as the tooltip's last line.
|
||||
let mut line = |secs_left: f64| -> String {
|
||||
s.set_auras(
|
||||
"player",
|
||||
Some(vec![AuraState {
|
||||
spell_id: 1459,
|
||||
name: Some("Arcane Intellect".into()),
|
||||
duration: 86_400.0,
|
||||
expiration_time: 10.0 + secs_left,
|
||||
helpful: true,
|
||||
..Default::default()
|
||||
}]),
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
BENILLA_TIP:SetOwner(BENILLA_ANCHOR, "ANCHOR_RIGHT")
|
||||
BENILLA_TIP:SetPlayerBuff(0)
|
||||
BENILLA_LAST = TT9TextLeft3:GetText()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.eval::<String>("return BENILLA_LAST").unwrap()
|
||||
};
|
||||
|
||||
// The headline divergence: anything past an hour was reading in MINUTES.
|
||||
assert_eq!(line(7_200.0), "2 hours remaining", "was '120 minutes'");
|
||||
assert_eq!(line(3_600.0), "1 hour remaining", "the hour edge, singular");
|
||||
assert_eq!(
|
||||
line(3_599.999),
|
||||
"60 minutes remaining",
|
||||
"one ms under the hour stays in minutes — no '1 hour' until it is whole"
|
||||
);
|
||||
// The singular half: the shipped pair really is "%d minute" / "%d minutes".
|
||||
assert_eq!(line(60.0), "1 minute remaining", "was '1 minutes'");
|
||||
assert_eq!(line(61.0), "2 minutes remaining", "the minute arm ceils");
|
||||
// The seconds arm truncates where every arm above it ceils.
|
||||
assert_eq!(line(5.4), "5 seconds remaining", "was '6 seconds'");
|
||||
assert_eq!(line(1.0), "1 second remaining", "singular at exactly one");
|
||||
assert_eq!(
|
||||
line(-0.4),
|
||||
"0 seconds remaining",
|
||||
"the lapsing read, plural at zero"
|
||||
);
|
||||
// And the top of the ladder, which had no arm at all.
|
||||
assert_eq!(line(129_600.0), "2 days remaining", "was '2160 minutes'");
|
||||
|
||||
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -29,6 +29,65 @@ fn ui_cost_enabled() -> bool {
|
||||
*ON.get_or_init(|| std::env::var("WOW_UI_COST").as_deref() == Ok("1"))
|
||||
}
|
||||
|
||||
/// `WOW_UI_GATE=1` — the extract gate's miss reporter ([`report_gate_miss`]). Off by default and
|
||||
/// read once, the `[ui-cost]` meter's posture.
|
||||
fn gate_log_enabled() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var("WOW_UI_GATE").as_deref() == Ok("1"))
|
||||
}
|
||||
|
||||
/// One line naming why the extract gate did not skip this frame — the input that differed, and for
|
||||
/// the render list the first entry that moved. A settled UI is what makes the paint pass free; when
|
||||
/// it is never settled, this says what is moving.
|
||||
fn report_gate_miss(
|
||||
script: &UiScript,
|
||||
now: &[benilla_ui::script::ExtractedQuad],
|
||||
prev: &[benilla_ui::script::ExtractedQuad],
|
||||
dims_eq: bool,
|
||||
text_ui_eq: bool,
|
||||
portraits_eq: bool,
|
||||
) {
|
||||
if !dims_eq {
|
||||
eprintln!("[ui-gate] miss: window/scale changed");
|
||||
return;
|
||||
}
|
||||
if !text_ui_eq {
|
||||
eprintln!("[ui-gate] miss: focused editbox text-UI changed");
|
||||
return;
|
||||
}
|
||||
if !portraits_eq {
|
||||
eprintln!("[ui-gate] miss: portrait sources changed");
|
||||
return;
|
||||
}
|
||||
if now.len() != prev.len() {
|
||||
eprintln!(
|
||||
"[ui-gate] miss: render list {} -> {} entries",
|
||||
prev.len(),
|
||||
now.len()
|
||||
);
|
||||
return;
|
||||
}
|
||||
match now.iter().zip(prev).position(|(a, b)| a != b) {
|
||||
Some(i) => {
|
||||
let (a, b) = (&now[i], &prev[i]);
|
||||
let (was, now_s) = (format!("{b:?}"), format!("{a:?}"));
|
||||
let owner = match a.target {
|
||||
benilla_ui::order::ZTarget::Frame(fh) => script.frame_name(fh),
|
||||
benilla_ui::order::ZTarget::Region(_) => None,
|
||||
};
|
||||
eprintln!(
|
||||
"[ui-gate] miss: entry {i}/{} target={:?} name={owner:?}\n was {}\n now {}",
|
||||
now.len(),
|
||||
a.target,
|
||||
&was[..was.len().min(280)],
|
||||
&now_s[..now_s.len().min(280)],
|
||||
);
|
||||
}
|
||||
// Nothing in this pass's own inputs moved — the miss came from the capture-mode arm.
|
||||
None => eprintln!("[ui-gate] miss: capture mode (the gate never skips under a capture)"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Last frame's extract-gate inputs (decision 0740), held together as one `Local` — the gate
|
||||
/// compares all four or none, and a Bevy system has a hard param budget this was eating.
|
||||
#[derive(Default)]
|
||||
@@ -375,6 +434,19 @@ pub(super) fn drive_script(
|
||||
}
|
||||
return;
|
||||
}
|
||||
// `WOW_UI_GATE=1` — why the gate MISSED. A skip is worth the whole conversion, so the
|
||||
// question "what moved?" is where a per-frame cost always ends, and a `Vec` inequality does
|
||||
// not answer it. Off by default, like the `[ui-cost]` meter beside it.
|
||||
if gate_log_enabled() {
|
||||
report_gate_miss(
|
||||
&script,
|
||||
&extracted,
|
||||
&prev.extracted,
|
||||
prev.dims == Some(dims),
|
||||
text_ui == prev.text_ui,
|
||||
booths.images.0 == prev.portraits,
|
||||
);
|
||||
}
|
||||
prev.dims = Some(dims);
|
||||
prev.text_ui = text_ui.clone();
|
||||
prev.portraits = booths.images.0.clone();
|
||||
@@ -436,6 +508,7 @@ pub(super) fn drive_script(
|
||||
circular,
|
||||
portrait_unit,
|
||||
rotation,
|
||||
desaturated,
|
||||
} => {
|
||||
// A live unit portrait (`SetPortraitTexture(region, unit)`): sample this token's
|
||||
// source ([`crate::portrait::PortraitImages`]) — the off-screen model bake, or the
|
||||
@@ -555,6 +628,7 @@ pub(super) fn drive_script(
|
||||
// The engine's SetRotation is counterclockwise-positive; the quad pass spins
|
||||
// clockwise-on-screen (`UiQuad::rotation`) — negate to convert.
|
||||
rotation: -rotation,
|
||||
desaturated,
|
||||
..default()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ pub(super) fn emit(
|
||||
color: [hc[0], hc[1], hc[2], hc[3] * host.alpha],
|
||||
additive: false,
|
||||
circular: false,
|
||||
desaturated: false,
|
||||
clip: text_clip,
|
||||
rotation: 0.0,
|
||||
mask: None,
|
||||
@@ -171,10 +172,11 @@ pub(super) fn emit(
|
||||
// gate inside is geometric and needs no fixed-vs-measured flag. Computed before the shadow
|
||||
// so the shadow pass draws the same display string (the client's shadow is a second draw of
|
||||
// the same truncated CGxString).
|
||||
let ellipsized = if ebox.is_none() && !matches!(host.target, ZTarget::Frame(_)) {
|
||||
crate::ui_text::ellipsize_to_fit(atlas, draw_text, draw_rect, spec)
|
||||
} else {
|
||||
None
|
||||
let ellipsized = match host.target {
|
||||
ZTarget::Region(region) if ebox.is_none() => {
|
||||
crate::ui_text::ellipsize_to_fit(atlas, region, draw_text, draw_rect, spec)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(display) = ellipsized.as_deref() {
|
||||
draw_text = display;
|
||||
@@ -339,6 +341,7 @@ pub(super) fn emit(
|
||||
color: [1.0, 1.0, 1.0, host.alpha],
|
||||
additive: false,
|
||||
circular: false,
|
||||
desaturated: false,
|
||||
clip: text_clip,
|
||||
rotation: 0.0,
|
||||
mask: None,
|
||||
|
||||
@@ -55,6 +55,11 @@ fn setup() -> UiScript {
|
||||
// the manifest includes it (it loads well before FriendsFrame.xml in the real order).
|
||||
load_xml(&s, "UnitPopup.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml");
|
||||
// The guild pane's frames inherit the reference's shared UIPanelButtonTemplate /
|
||||
// UIPanelCloseButton / UIPanelScrollFrameTemplate rather than a private copy (decision 1257),
|
||||
// so this file's slice needs the shared kit — and the strict `load_xml` above is what would
|
||||
// otherwise let those frames load art-less and silent.
|
||||
load_xml(&s, "UIPanelTemplates.xml");
|
||||
load_xml(&s, "FriendsFrame.xml");
|
||||
s
|
||||
}
|
||||
@@ -76,8 +81,17 @@ fn push(s: &mut UiScript, state: SocialState, event: &str) {
|
||||
s.fire_event(event, Vec::new());
|
||||
}
|
||||
|
||||
/// The window loads, opens on the Friends tab, and the GUILD tab comes up disabled — which is
|
||||
/// what a character in no guild sees in the reference, and what we always are.
|
||||
/// The window loads, opens on the Friends tab, and the GUILD tab comes up disabled — the
|
||||
/// GUILDLESS case of `InGuildCheck`.
|
||||
///
|
||||
/// **This assertion changed meaning with decision 1257 and was kept rather than deleted.** It used
|
||||
/// to pin a hardcoded `PanelTemplates_DisableTab(this, 3)`, because there was no guild arc and the
|
||||
/// tab was permanently dead. The tab is live now, and this is the other half of its law: with no
|
||||
/// guild seated in the engine, `IsInGuild()` answers nil, `FriendsFrame_OnShow` runs
|
||||
/// `InGuildCheck()`, and the tab greys — which is exactly what the reference does for a character
|
||||
/// in no guild. The IN-guild half is `guild_tests`. Note that this file seats no guild fixture at
|
||||
/// all: it is asserting against the REAL `script::guild` bindings' empty state, which is the state
|
||||
/// every character starts a session in.
|
||||
#[test]
|
||||
fn the_window_opens_on_friends_with_the_guild_tab_disabled() {
|
||||
let s = setup();
|
||||
@@ -91,12 +105,27 @@ fn the_window_opens_on_friends_with_the_guild_tab_disabled() {
|
||||
.unwrap(),
|
||||
"Friends List"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return IsInGuild() and 1 or 0").unwrap(),
|
||||
0,
|
||||
"no guild is seated, which is what makes the next assertion mean something"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return FriendsFrameTab3.isDisabled or 0")
|
||||
.unwrap(),
|
||||
1,
|
||||
"the guild tab is disabled, not absent"
|
||||
);
|
||||
// …and the tab refuses to be reached by NUMBER either, which is the second of the two locks
|
||||
// (`ToggleFriendsFrame`'s own `tab == 3 and not IsInGuild()` early-out).
|
||||
s.run("ToggleFriendsFrame(3)").unwrap();
|
||||
assert!(
|
||||
!s.eval::<bool>("return GuildFrame:IsVisible()").unwrap(),
|
||||
"a guildless character cannot open the guild pane at all"
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>("return FriendsListFrame:IsVisible()")
|
||||
.unwrap());
|
||||
// The two tab kinds are different templates and must not be swapped: the strip along the
|
||||
// bottom is the big window tab (20px end slices), the Friends/Ignore pair inside tab 1 is the
|
||||
// ref's compact TabButtonTemplate (16px). Getting this wrong is invisible to every behaviour
|
||||
@@ -788,9 +817,13 @@ fn the_window_geometry_matches_the_reference_framexml() {
|
||||
"FriendsFrameFriendsScrollFrame",
|
||||
"FriendsFrameIgnoreScrollFrame",
|
||||
"WhoListScrollFrame",
|
||||
// No UIPanelCloseButton template ships here, so ours states the 32x32 the ref inherits.
|
||||
// …and the guild pane's, the fourth of the same kind (decision 1257).
|
||||
"GuildListScrollFrame",
|
||||
// The window's own close button predates UIPanelTemplates.xml shipping the reference's
|
||||
// `UIPanelCloseButton`, so it states inline the 32x32 that template confers. The guild
|
||||
// pane's two close buttons inherit it and therefore need no entry here.
|
||||
"FriendsFrameCloseButton",
|
||||
];
|
||||
|
||||
super::framexml_diff::assert_geometry_matches("FriendsFrame.xml", &reference, EXPECTED, 61);
|
||||
super::framexml_diff::assert_geometry_matches("FriendsFrame.xml", &reference, EXPECTED, 210);
|
||||
}
|
||||
|
||||
@@ -607,16 +607,16 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
|
||||
s.resolve();
|
||||
|
||||
// The backpack icon + the four slot rings, by name.
|
||||
let art = |s: &UiScript, owner: &str| -> Vec<(String, Option<[f32; 4]>)> {
|
||||
let art = |s: &UiScript, owner: &str| -> Vec<(String, bool)> {
|
||||
s.extract()
|
||||
.into_iter()
|
||||
.filter(|eq| s.quad_owner_name(eq.target).as_deref() == Some(owner))
|
||||
.filter_map(|eq| match &eq.content {
|
||||
benilla_ui::script::QuadContent::Texture {
|
||||
path: Some(p),
|
||||
color,
|
||||
desaturated,
|
||||
..
|
||||
} => Some((p.clone(), *color)),
|
||||
} => Some((p.clone(), *desaturated)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
@@ -650,7 +650,8 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
|
||||
"{owner} must still DRAW under the open menu — greyed is not gone"
|
||||
);
|
||||
}
|
||||
// The backpack icon specifically: still its own art, and tinted to SetDesaturation's grey.
|
||||
// The backpack icon specifically: still its own art, and carrying SetDesaturation's greyscale
|
||||
// flag to the renderer (decision 1327 — before it, the grey was the ref's no-shader 0.5 tint).
|
||||
let toggle = art(&s, "MainMenuBarBackpackButton");
|
||||
assert!(
|
||||
toggle
|
||||
@@ -661,8 +662,7 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
|
||||
assert!(
|
||||
toggle
|
||||
.iter()
|
||||
.any(|(p, c)| p == "Interface\\Buttons\\Button-Backpack-Up"
|
||||
&& c.is_some_and(|c| (c[0] - 0.5).abs() < 0.01)),
|
||||
.any(|(p, grey)| p == "Interface\\Buttons\\Button-Backpack-Up" && *grey),
|
||||
"…and it is greyed, not full-bright: {toggle:?}"
|
||||
);
|
||||
|
||||
@@ -672,8 +672,7 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
|
||||
assert!(
|
||||
toggle
|
||||
.iter()
|
||||
.any(|(p, c)| p == "Interface\\Buttons\\Button-Backpack-Up"
|
||||
&& c.is_none_or(|c| (c[0] - 1.0).abs() < 0.01)),
|
||||
.any(|(p, grey)| p == "Interface\\Buttons\\Button-Backpack-Up" && !*grey),
|
||||
"closing the menu restores full colour: {toggle:?}"
|
||||
);
|
||||
assert!(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -738,6 +738,9 @@ mod delete_item_tests;
|
||||
#[cfg(test)]
|
||||
mod static_popup_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod binder_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod death_tests;
|
||||
|
||||
@@ -754,6 +757,12 @@ mod framexml_diff;
|
||||
#[cfg(test)]
|
||||
mod friends_tests;
|
||||
|
||||
/// The four guild windows — the social window's third tab and its three satellites (decision
|
||||
/// 1257). Its own module rather than more of `friends_tests` because it stands the whole guild
|
||||
/// engine API in for in Lua before the XML loads, which that file must not.
|
||||
#[cfg(test)]
|
||||
mod guild_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod quest_tests;
|
||||
|
||||
|
||||
@@ -348,24 +348,21 @@ fn a_disabled_bar_greys_rather_than_hides() {
|
||||
2,
|
||||
"the bar is still on screen"
|
||||
);
|
||||
// `SetDesaturation` has no shader path in this engine, so the grey is the reference's own
|
||||
// no-shader fallback: vertex colour 0.5 (UiPanels.xml's transcription).
|
||||
let tint = quads
|
||||
// Since decision 1327 `SetDesaturation` takes its SHADER arm — the icon carries the greyscale
|
||||
// flag to the renderer and keeps its own tint, rather than the reference's no-shader 0.5
|
||||
// vertex-colour fallback (which is what this asserted while nothing greyed).
|
||||
let grey = quads
|
||||
.iter()
|
||||
.find_map(|q| match &q.content {
|
||||
QuadContent::Texture {
|
||||
path: Some(p),
|
||||
color,
|
||||
desaturated,
|
||||
..
|
||||
} if p == "Interface\\Icons\\Ability_Druid_Rake" => Some(*color),
|
||||
} if p == "Interface\\Icons\\Ability_Druid_Rake" => Some(*desaturated),
|
||||
_ => None,
|
||||
})
|
||||
.expect("Claw's icon");
|
||||
assert_eq!(
|
||||
tint.map(|c| [c[0], c[1], c[2]]),
|
||||
Some([0.5, 0.5, 0.5]),
|
||||
"a disabled bar greys every icon on it"
|
||||
);
|
||||
assert!(grey, "a disabled bar greys every icon on it");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
|
||||
@@ -547,3 +547,180 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
|
||||
.eval::<bool>("return SpellBookFrame.bookType == BOOKTYPE_SPELL")
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The spellbook's **macro-editor fork**, pinned in both directions (report B248).
|
||||
///
|
||||
/// The reference's contract, read off the 1.12.1 install's own `SpellBookFrame.lua:271-283` and
|
||||
/// `MacroFrame.lua:93-97`, is narrow and easy to "fix" into a later expansion's:
|
||||
///
|
||||
/// * only a **shift**-click reaches the editor at all — a plain left OR **right** click still
|
||||
/// casts, exactly as it does with no macro window on screen;
|
||||
/// * what lands is a whole **`/cast <name>[(<rank>)]` line**, not the bare name (the bare-name
|
||||
/// insert is 2.x's `ChatEdit_InsertLink`, a different client — and `MACRO_HELP_TEXT_LINE5`,
|
||||
/// which advertises this feature, says "*Shift* click");
|
||||
/// * it is **appended with no separator** — the reference's own `GetText()..line` — so two
|
||||
/// shift-clicks run together on one line;
|
||||
/// * a **passive** contributes nothing, and neither does a shift-click while the name/icon
|
||||
/// popup has the body box hidden;
|
||||
/// * with the editor hidden the shift-click is a **pickup** again.
|
||||
///
|
||||
/// DO NOT "FIX" the `/cast` prefix away (the standing rule, decision 1030): B248 reported it as
|
||||
/// wrong, and the install says it is what 1.12.1 does.
|
||||
#[test]
|
||||
fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The macro window's own strings (the app runs the real `GlobalStrings.lua`; `macro_tests`'
|
||||
// harness is the source of this list).
|
||||
s.run(
|
||||
r#"
|
||||
CREATE_MACROS = "Create Macros"
|
||||
GENERAL_MACROS = "General Macros"
|
||||
CHARACTER_SPECIFIC_MACROS = "%s Specific Macros"
|
||||
ENTER_MACRO_LABEL = "Enter Macro Commands:"
|
||||
MACROFRAME_CHAR_LIMIT = "%d/255 Characters Used"
|
||||
MACRO_POPUP_TEXT = "Enter Macro Name (Max 16 Characters):"
|
||||
MACRO_POPUP_CHOOSE_ICON = "Choose an Icon:"
|
||||
CHANGE_MACRO_NAME_ICON = "Change Name/Icon"
|
||||
DELETE = "Delete" NEW = "New" EXIT = "Exit" CANCEL = "Cancel" OKAY = "Okay"
|
||||
MACROS = "Macros"
|
||||
TOOLTIP_DEFAULT_COLOR = { r = 1.0, g = 1.0, b = 1.0 }
|
||||
TOOLTIP_DEFAULT_BACKGROUND_COLOR = { r = 0.09, g = 0.09, b = 0.19 }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
for file in [
|
||||
"Fonts.xml",
|
||||
"UiPanels.xml",
|
||||
"GameTooltip.xml",
|
||||
"Cooldown.xml",
|
||||
"ScrollTemplates.xml",
|
||||
"MicroMenu.xml",
|
||||
"ActionBar.xml",
|
||||
"MacroFrame.xml",
|
||||
"SpellBookFrame.xml",
|
||||
] {
|
||||
load_xml(&s, file);
|
||||
}
|
||||
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||||
|
||||
// The file's own book plus a third Fire spell that is PASSIVE — book id 3, which the ref's
|
||||
// column-major grid puts on SpellButton5.
|
||||
let mut b = book();
|
||||
b.tabs[0].num_spells = 3;
|
||||
b.tabs[1].offset = 3;
|
||||
b.slots.insert(
|
||||
2,
|
||||
SpellSlotView {
|
||||
spell_id: 168,
|
||||
name: "Frost Warding".into(),
|
||||
texture: Some("Interface\\Icons\\Spell_Frost_FrostWard".into()),
|
||||
passive: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
s.set_spellbook(b);
|
||||
|
||||
s.run(r#"CreateMacro("Ambush", 1, "")"#).unwrap();
|
||||
s.run("ShowMacroFrame()").unwrap();
|
||||
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
|
||||
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
|
||||
let body = |s: &UiScript| -> String {
|
||||
s.eval::<String>("return BenillaMacroFrameText:GetText()")
|
||||
.unwrap()
|
||||
};
|
||||
assert!(
|
||||
s.eval::<bool>("return BenillaMacroFrameText:IsVisible()")
|
||||
.unwrap(),
|
||||
"the editor's body box is up — the ref's own `MacroFrame_AddMacroLine` gate"
|
||||
);
|
||||
assert_eq!(body(&s), "");
|
||||
|
||||
// ── B248's reported gesture: a plain RIGHT click still CASTS, and writes nothing ──────────
|
||||
click(&mut s, "SpellButton1", "RightButton");
|
||||
assert!(
|
||||
s.errors().is_empty(),
|
||||
"right-click errors: {:?}",
|
||||
s.errors()
|
||||
);
|
||||
assert_eq!(s.take_spell_casts(), vec![133], "a right-click casts");
|
||||
assert_eq!(
|
||||
body(&s),
|
||||
"",
|
||||
"B248: an UNSHIFTED click must never reach the macro editor (ref l.271 tests shift first)"
|
||||
);
|
||||
assert!(s.cursor_payload().is_none());
|
||||
|
||||
// …and so does a plain left click, the other half of the same arm.
|
||||
click(&mut s, "SpellButton1", "LeftButton");
|
||||
assert_eq!(s.take_spell_casts(), vec![133]);
|
||||
assert_eq!(body(&s), "");
|
||||
|
||||
// ── The reference gesture: shift-click APPENDS a whole `/cast` line, and never casts ──────
|
||||
s.set_modifiers(true, false, false);
|
||||
click(&mut s, "SpellButton1", "LeftButton");
|
||||
assert!(
|
||||
s.errors().is_empty(),
|
||||
"shift-click errors: {:?}",
|
||||
s.errors()
|
||||
);
|
||||
assert_eq!(
|
||||
body(&s),
|
||||
"/cast Fireball(Rank 1)",
|
||||
"ref l.276: SLASH_CAST1 + name + the rank in parens"
|
||||
);
|
||||
assert!(
|
||||
s.take_spell_casts().is_empty(),
|
||||
"the macro arm replaces the cast, it does not add to it"
|
||||
);
|
||||
assert!(
|
||||
s.cursor_payload().is_none(),
|
||||
"with the editor open a shift-click writes instead of picking up"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return BenillaMacroFrame.textChanged == 1")
|
||||
.unwrap(),
|
||||
"the write goes through the box's own OnTextChanged, so the window is dirty"
|
||||
);
|
||||
|
||||
// A second one runs straight on: the reference appends with NO separator (`GetText()..line`),
|
||||
// which is why a two-spell macro built this way needs the player to break the line himself.
|
||||
// DO NOT "fix" this into a newline join.
|
||||
click(&mut s, "SpellButton3", "RightButton"); // shift outranks the button — ref l.271 vs l.284
|
||||
assert_eq!(
|
||||
body(&s),
|
||||
"/cast Fireball(Rank 1)/cast Fire Blast(Rank 1)",
|
||||
"ref MacroFrame.lua:95 — appended, unseparated"
|
||||
);
|
||||
assert!(s.take_spell_casts().is_empty());
|
||||
|
||||
// A PASSIVE writes nothing at all — and does not fall through to a pickup either (ref l.274's
|
||||
// guard sits INSIDE the macro-frame arm).
|
||||
let before = body(&s);
|
||||
click(&mut s, "SpellButton5", "LeftButton");
|
||||
assert_eq!(body(&s), before, "a passive is not a castable line");
|
||||
assert!(s.cursor_payload().is_none());
|
||||
|
||||
// The name/icon popup hides the body box; the ref's AddMacroLine gates on exactly that.
|
||||
s.run("BenillaMacroNewButton_OnClick()").unwrap();
|
||||
assert!(!s
|
||||
.eval::<bool>("return BenillaMacroFrameText:IsVisible()")
|
||||
.unwrap());
|
||||
click(&mut s, "SpellButton1", "LeftButton");
|
||||
assert!(s.errors().is_empty(), "popup-open errors: {:?}", s.errors());
|
||||
assert!(s.cursor_payload().is_none(), "still not a pickup");
|
||||
s.run("BenillaMacroPopupFrame:Hide()").unwrap();
|
||||
s.run("BenillaMacroFrame_Update()").unwrap();
|
||||
assert_eq!(body(&s), before, "nothing landed while the popup was up");
|
||||
|
||||
// ── Editor hidden: the shift-click is a PICKUP again (ref l.281-282's else) ───────────────
|
||||
s.run("HideUIPanel(BenillaMacroFrame)").unwrap();
|
||||
click(&mut s, "SpellButton1", "LeftButton");
|
||||
s.set_modifiers(false, false, false);
|
||||
assert!(s.errors().is_empty(), "pickup errors: {:?}", s.errors());
|
||||
let (kind, spell_id) = s
|
||||
.eval::<(String, i64)>("local k, _, _, id = GetCursorInfo() return k, id")
|
||||
.unwrap();
|
||||
assert_eq!((kind.as_str(), spell_id), ("spell", 133));
|
||||
assert!(s.take_spell_casts().is_empty());
|
||||
}
|
||||
|
||||
@@ -723,3 +723,255 @@ fn a_new_list_packet_resets_the_filter_mask_and_the_collapse_set() {
|
||||
.eval::<bool>("return GetTrainerServiceTypeFilter('unavailable') == 1")
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// A profession trainer's list: one skill line, a long recipe name and a short one, each with a rank
|
||||
/// subtext — the shape of the B253 report (a Leatherworking trainer at "Handstitched Leather Pants").
|
||||
fn recipe_menu() -> TrainerState {
|
||||
let mut long = service(
|
||||
3756,
|
||||
"Handstitched Leather Pants",
|
||||
TrainerServiceCategory::Available,
|
||||
50,
|
||||
1,
|
||||
165,
|
||||
"Leatherworking",
|
||||
None,
|
||||
vec![],
|
||||
);
|
||||
long.subtext = Some("Rank 1".into());
|
||||
let mut short = service(
|
||||
2149,
|
||||
"Belt",
|
||||
TrainerServiceCategory::Available,
|
||||
50,
|
||||
1,
|
||||
165,
|
||||
"Leatherworking",
|
||||
None,
|
||||
vec![],
|
||||
);
|
||||
short.subtext = Some("Rank 1".into());
|
||||
TrainerState {
|
||||
greeting: "Can I teach you how to turn beast hides into armor?".into(),
|
||||
trainer_type: 2,
|
||||
groups: Vec::new(),
|
||||
services: vec![long, short],
|
||||
}
|
||||
}
|
||||
|
||||
/// **B253 — a long row name drew two lines over the row beneath it.** The row is a FLOW, not two
|
||||
/// fixed columns: the name carries no width (a FontString given one wraps at it, and a 16 px row
|
||||
/// cannot grow), and the rank follows the name's right edge rather than sitting at an invented
|
||||
/// x=188. Both halves are the reference's own row
|
||||
/// (`ClassTrainerFrameTemplates.xml`'s `<ButtonText>` at width 0 + the per-row
|
||||
/// `SetPoint("LEFT", <row>Text, "RIGHT", 10, 0)`), read off the player's chain.
|
||||
///
|
||||
/// The mutation check is the first assertion: put a `<Size>` back on `$parentName` and the request
|
||||
/// carries a wrap width, the long name measures two lines, and its height passes the row height.
|
||||
#[test]
|
||||
fn a_long_row_name_stays_on_one_line_and_carries_its_rank_along() {
|
||||
let mut s = trainer_script();
|
||||
s.set_money(5000);
|
||||
s.set_trainer(Some(recipe_menu()));
|
||||
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Nadyia".into())]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
// The host measure: 6 px per character, 14 px per line, wrapped at whatever width was asked for.
|
||||
// Capture the row names' requests on the way past — what they ASK is the structural fact.
|
||||
let mut name_wraps: Vec<Option<f32>> = Vec::new();
|
||||
let mut answer = |s: &mut UiScript, collect: bool| {
|
||||
let reqs = s.fontstrings_needing_measure();
|
||||
if collect {
|
||||
name_wraps.extend(
|
||||
reqs.iter()
|
||||
.filter(|r| r.text.contains("Handstitched"))
|
||||
.map(|r| r.wrap_width),
|
||||
);
|
||||
}
|
||||
let answers: Vec<(u32, f32, f32, u64)> = reqs
|
||||
.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(&mut s, true);
|
||||
s.resolve();
|
||||
s.tick(0.016);
|
||||
answer(&mut s, false);
|
||||
s.resolve();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
assert!(
|
||||
!name_wraps.is_empty() && name_wraps.iter().all(|w| w.is_none()),
|
||||
"the row name must ask for NO wrap width — it is a single line whatever the name is; \
|
||||
got {name_wraps:?}"
|
||||
);
|
||||
|
||||
// Find each name's row by what it painted (the group comparator owns the order, not this test).
|
||||
let row_of = |s: &mut UiScript, needle: &str| -> i64 {
|
||||
s.eval::<i64>(&format!(
|
||||
"for i = 1, 11 do local b = getglobal('BenillaTrainerService' .. i) \
|
||||
local t = b.name:GetText() if t and strfind(t, '{needle}', 1, 1) then return i end \
|
||||
end return 0"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let long_row = row_of(&mut s, "Handstitched");
|
||||
let short_row = row_of(&mut s, "Belt");
|
||||
assert!(long_row > 0 && short_row > 0, "both services painted");
|
||||
|
||||
let geom = |s: &mut UiScript, row: i64| -> (f32, f32, f32, f32) {
|
||||
s.eval::<(f32, f32, f32, f32)>(&format!(
|
||||
"local b = getglobal('BenillaTrainerService{row}') \
|
||||
return b.name:GetHeight(), b.name:GetRight(), b.subtext:GetLeft(), b:GetHeight()"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let (long_h, long_name_right, long_sub_left, row_h) = geom(&mut s, long_row);
|
||||
let (short_h, short_name_right, short_sub_left, _) = geom(&mut s, short_row);
|
||||
|
||||
assert!(
|
||||
long_h <= row_h + 0.5,
|
||||
"the long name is one line inside its own row: name {long_h} px, row {row_h} px"
|
||||
);
|
||||
assert!((long_h - short_h).abs() < 0.5, "and so is the short one");
|
||||
for (name, right, left) in [
|
||||
("long", long_name_right, long_sub_left),
|
||||
("short", short_name_right, short_sub_left),
|
||||
] {
|
||||
assert!(
|
||||
(left - right - 10.0).abs() < 0.5,
|
||||
"the {name} row's rank sits 10 px past its NAME's right edge (the reference's own \
|
||||
offset), not at a fixed column: name right {right}, subtext left {left}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
long_sub_left > short_sub_left + 50.0,
|
||||
"so a longer name pushes its rank along instead of running under it: {long_sub_left} vs \
|
||||
{short_sub_left}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **B256 — "filter to Available, learn a spell, the filter comes back partly reset."** The window
|
||||
/// is driven here through the real [`crate::ui_trainer::TrainerOpen`], so the packet→reset decision
|
||||
/// under test is the app's own and not this test's: the closure below is the trainer feed's three
|
||||
/// lines (`if open.fresh_list { reset } ; set_trainer ; fire`), and everything else is the shipped
|
||||
/// Lua.
|
||||
///
|
||||
/// The reference cannot produce this bug because it never gets a list packet with the window open —
|
||||
/// it repaints a purchase from a client-side state re-derivation (`0x4d7d40`, decision 1128 §4.2).
|
||||
/// benilla re-asks the server instead, so the reference's per-packet mask reset (`0x4d75d9`) was
|
||||
/// riding in on a packet the reference never sends, and taking the player's choice — and their
|
||||
/// collapsed groups, which 1128 recorded as "a collapse does not survive a purchase" — with it.
|
||||
#[test]
|
||||
fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() {
|
||||
use crate::ui_trainer::TrainerOpen;
|
||||
const DAZALAR: u64 = 0xabc;
|
||||
|
||||
let mut s = trainer_script();
|
||||
// The reference's own file-scope defaults (1128), then the player's choice below.
|
||||
s.run(
|
||||
"TRAINER_FILTER_AVAILABLE = 1 TRAINER_FILTER_UNAVAILABLE = 1 TRAINER_FILTER_USED = 0 \
|
||||
BenillaTrainerFrame_ApplyFilter()",
|
||||
)
|
||||
.unwrap();
|
||||
s.set_money(5000);
|
||||
|
||||
// The trainer feed (`ui_trainer::feed_trainer`), reduced to the part this bug lives in.
|
||||
fn feed(s: &mut UiScript, open: &mut TrainerOpen, state: TrainerState, event: &str) {
|
||||
if open.fresh_list {
|
||||
s.reset_trainer_filter(open.trainer_type);
|
||||
open.fresh_list = false;
|
||||
}
|
||||
s.set_trainer(Some(state));
|
||||
s.fire_event(event, vec![ScriptValue::Str("Dazalar".into())]);
|
||||
}
|
||||
let filter_on = |s: &mut UiScript, kind: &str| {
|
||||
s.eval::<bool>(&format!(
|
||||
"return GetTrainerServiceTypeFilter('{kind}') == 1"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let rows = |s: &mut UiScript| s.eval::<i64>("return GetNumTrainerServices()").unwrap();
|
||||
|
||||
// He opens the trainer and filters the list down to what he can actually learn.
|
||||
let mut open = TrainerOpen::default();
|
||||
open.open(DAZALAR, 0, vec![], "Hello, hunter!".into());
|
||||
feed(&mut s, &mut open, menu(), "TRAINER_SHOW");
|
||||
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
|
||||
.unwrap();
|
||||
s.run("this = DropDownList1Button2; UIDropDownMenuButton_OnClick()")
|
||||
.unwrap();
|
||||
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
|
||||
.unwrap();
|
||||
assert!(
|
||||
!filter_on(&mut s, "unavailable"),
|
||||
"his choice reached the engine"
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&mut s),
|
||||
4,
|
||||
"two headers over the two learnable services"
|
||||
);
|
||||
// …and folds the first group away while he is at it.
|
||||
s.run("CollapseTrainerSkillLine(1)").unwrap();
|
||||
assert_eq!(rows(&mut s), 3, "the folded group keeps its header only");
|
||||
|
||||
// He trains. The app re-asks for the list (`trainer_buy_succeeded`) and marks its own answer as
|
||||
// the repaint it is; the bought service comes back gray.
|
||||
let mut learned = menu();
|
||||
learned.services[0].category = TrainerServiceCategory::Used;
|
||||
open.refresh_pending = true;
|
||||
open.open(DAZALAR, 0, vec![], "Hello, hunter!".into());
|
||||
feed(&mut s, &mut open, learned, "TRAINER_UPDATE");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert!(
|
||||
!filter_on(&mut s, "unavailable"),
|
||||
"learning a spell is a repaint, not a new window: his filter stands"
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&mut s),
|
||||
2,
|
||||
"and the list is still his — the folded Arms group is empty of learnables now, so it goes \
|
||||
with its header, leaving Fury over Thunder Clap"
|
||||
);
|
||||
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
|
||||
.unwrap();
|
||||
assert!(
|
||||
!s.eval::<bool>("return DropDownList1Button2Check:IsVisible() and true or false")
|
||||
.unwrap(),
|
||||
"the dropdown agrees with the list, instead of claiming a filter the list ignores"
|
||||
);
|
||||
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
|
||||
.unwrap();
|
||||
|
||||
// He walks away and comes back: THIS packet opens a window, so the reference's reset lands —
|
||||
// and the window's own OnShow/TRAINER_SHOW puts the saved globals straight back over it. The
|
||||
// filter survives (it is saved), the collapse does not (it is engine-side, and the reset clears
|
||||
// it) — the reference's own split.
|
||||
open.clear();
|
||||
open.open(DAZALAR, 0, vec![], "Hello, hunter!".into());
|
||||
let mut learned = menu();
|
||||
learned.services[0].category = TrainerServiceCategory::Used;
|
||||
feed(&mut s, &mut open, learned, "TRAINER_SHOW");
|
||||
assert!(
|
||||
!filter_on(&mut s, "unavailable"),
|
||||
"the saved filter is pushed back over the packet reset"
|
||||
);
|
||||
assert!(
|
||||
filter_on(&mut s, "available"),
|
||||
"and the states he never turned off are still on"
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&mut s),
|
||||
2,
|
||||
"nothing collapsed now — but still filtered"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user