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:
@@ -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