Files
benilla-octo/crates/benilla-app/src/ui_script/money_frame_tests.rs
T
SamandClaude Fable 5 762646cd61 a width read answers in its own tick, and Trade stays /2
- GetStringWidth answers the moment the text is set — the client's width law is a per-glyph table, no shaping pass — so Bagnon's character dropdown stops collapsing to 40 pixels
- a chat channel keeps its number when another channel leaves: Trade stays /2 through a teleport
- the login splash, the zone music and the minimap label wait for the world under the player instead of announcing whatever tile happened to be loaded
- ending a session empties the chat window and the input box's history

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 19:27:04 +07:00

379 lines
15 KiB
Rust

//! The shipped `assets/ui/MoneyFrame.xml` — the reference's `MoneyFrameTemplate` /
//! `SmallMoneyFrameTemplate` kit and the `MoneyTypeInfo` table behind it (decision 1190: a name the
//! shipped 1.12 UI defines is a name we publish too — under our own implementation of it, 1260).
//!
//! Nothing benilla ships consumes this file yet — our own eight coin displays run on
//! MerchantFrame.xml's `BenillaMoney_*` slot kit, and MoneyFrame.xml's header is where the two are
//! reconciled — so these tests ARE its only driver. That makes them the contract: they exercise it
//! the way an addon does (declare a frame on the template, point it at a type, change the type,
//! read the coins back), because that is who calls it.
//!
//! What they guard: the type switch actually rewiring where the number comes from and whether the
//! coins take the mouse; the collapse rule per type (`collapse`, `showSmallerCoins`, `fixedWidth`);
//! the denomination split; `MoneyFrame_Update`'s by-name entry point over a STATIC frame; and
//! `SetMoneyFrameColor` recolouring the digits and not the icons.
use benilla_ui::script::{QuadContent, UiScript};
/// One shipped file into `s`, asserting it loaded clean.
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 kit plus the two frames an addon would declare: one on each template.
fn harness(money: u64) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_money(money);
// The digit advances the app feeds once per atlas scale. Fed here so the width arithmetic
// below is deliberate rather than an artefact of an unfed VM — a flat 8px per digit makes
// every expected number readable as `digits x 8 + icon`.
s.set_text_measurer(Box::new(super::FixedWidthFont(8.0)));
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
let doc = benilla_ui::framexml::parse(
r#"<Ui>
<Frame name="TestPurse" inherits="SmallMoneyFrameTemplate">
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Frame>
<Frame name="TestBigPurse" inherits="MoneyFrameTemplate">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="0" y="-40"/></Offset></Anchor></Anchors>
</Frame>
</Ui>"#,
)
.unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"loader errors: {:?}",
report.errors
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
}
/// The three coins' text, gold → silver → copper.
fn coins(s: &UiScript, frame: &str) -> (String, String, String) {
let one = |d: &str| {
s.eval::<String>(&format!("return {frame}{d}ButtonText:GetText() or ''"))
.unwrap()
};
(one("Gold"), one("Silver"), one("Copper"))
}
/// Which of the three coins are showing, gold → silver → copper.
fn shown(s: &UiScript, frame: &str) -> (bool, bool, bool) {
let one = |d: &str| {
s.eval::<bool>(&format!("return {frame}{d}Button:IsShown()"))
.unwrap()
};
(one("Gold"), one("Silver"), one("Copper"))
}
/// A frame on either template loads as type PLAYER showing the purse, split into denominations —
/// the reference's `MoneyFrame_OnLoad` / `SmallMoneyFrame_OnLoad`, both of which end in
/// `MoneyFrame_SetType("PLAYER")`.
#[test]
fn a_money_frame_loads_as_the_player_purse_and_splits_the_denominations() {
let s = harness(12_345); // 1g 23s 45c
assert_eq!(
s.eval::<String>("return TestPurse.moneyType").unwrap(),
"PLAYER"
);
assert_eq!(
coins(&s, "TestPurse"),
("1".into(), "23".into(), "45".into())
);
assert_eq!(
coins(&s, "TestBigPurse"),
("1".into(), "23".into(), "45".into())
);
assert_eq!(
s.eval::<i64>("return TestPurse.staticMoney").unwrap(),
12_345,
"the frame remembers what it is displaying (ref l.212)"
);
// `small` is what picks the 13px coin over the 19px one — the only difference between the two
// OnLoads, and the reason both templates exist.
assert_eq!(s.eval::<i64>("return TestPurse.small").unwrap(), 1);
assert!(s.eval::<bool>("return TestBigPurse.small == nil").unwrap());
}
/// **`MoneyFrame_SetType` switching a frame between types** — the verb 11 corpus addons call, and
/// the one that has to rewire three things at once: where the number comes from, whether the coins
/// take the mouse (`canPickup`), and how they collapse.
#[test]
fn set_type_rewires_the_source_the_mouse_and_the_collapse() {
let s = harness(12_345);
// PLAYER: canPickup, so all three coins are mouse-enabled.
for d in ["Gold", "Silver", "Copper"] {
assert!(
s.eval::<bool>(&format!("return TestPurse{d}Button:IsMouseEnabled()"))
.unwrap(),
"PLAYER has canPickup = 1, so {d} takes the mouse"
);
}
// → STATIC: the number now comes from the frame's own staticMoney, and the coins go inert.
s.run("TestPurse.staticMoney = 7 this = TestPurse MoneyFrame_SetType(\"STATIC\") this = nil")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<String>("return TestPurse.moneyType").unwrap(),
"STATIC"
);
assert_eq!(coins(&s, "TestPurse"), ("0".into(), "0".into(), "7".into()));
for d in ["Gold", "Silver", "Copper"] {
assert!(
!s.eval::<bool>(&format!("return TestPurse{d}Button:IsMouseEnabled()"))
.unwrap(),
"STATIC has no canPickup, so {d} does not take the mouse"
);
}
// STATIC collapses and does NOT showSmallerCoins: 7 copper is copper alone.
assert_eq!(shown(&s, "TestPurse"), (false, false, true));
// → back to PLAYER: the purse returns, and so does the mouse.
s.run("this = TestPurse MoneyFrame_SetType(\"PLAYER\") this = nil")
.unwrap();
assert_eq!(
coins(&s, "TestPurse"),
("1".into(), "23".into(), "45".into())
);
assert!(s
.eval::<bool>("return TestPurseGoldButton:IsMouseEnabled()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **collapse rule**, which is the whole reason `MoneyTypeInfo` has fields beyond `UpdateFunc`.
/// PLAYER carries `showSmallerCoins = "Backpack"`, so a round 5 gold still shows all three coins;
/// STATIC does not, so the same amount is gold alone.
#[test]
fn showsmallercoins_is_what_keeps_the_zero_coins_visible() {
let s = harness(50_000); // exactly 5g
assert_eq!(coins(&s, "TestPurse"), ("5".into(), "0".into(), "0".into()));
assert_eq!(
shown(&s, "TestPurse"),
(true, true, true),
"PLAYER showSmallerCoins = \"Backpack\" keeps the zero silver and copper (ref l.30)"
);
s.run(
"TestPurse.staticMoney = 50000 this = TestPurse MoneyFrame_SetType(\"STATIC\") this = nil",
)
.unwrap();
assert_eq!(
shown(&s, "TestPurse"),
(true, false, false),
"STATIC collapses with no showSmallerCoins — 5g is gold alone (ref l.32-38)"
);
// A pure-copper amount under a collapsing type: the leading zeros go, the copper stays.
s.run("TestPurse.staticMoney = 42 this = TestPurse MoneyFrame_UpdateMoney() this = nil")
.unwrap();
assert_eq!(shown(&s, "TestPurse"), (false, false, true));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `MoneyFrame_Update(frameName, money)` — the family's one **by-name** entry point, and how an
/// addon paints a STATIC frame it just filled without going through an event. It is also where the
/// frame's own width is recomputed from the coins that survived the collapse.
#[test]
fn update_paints_a_static_frame_by_name_and_resizes_it() {
let s = harness(0);
s.run("this = TestPurse MoneyFrame_SetType(\"STATIC\") this = nil")
.unwrap();
s.run("MoneyFrame_Update(\"TestPurse\", 20304)").unwrap(); // 2g 3s 4c
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(coins(&s, "TestPurse"), ("2".into(), "3".into(), "4".into()));
assert_eq!(shown(&s, "TestPurse"), (true, true, true));
assert_eq!(
s.eval::<i64>("return TestPurse.staticMoney").unwrap(),
20_304
);
// Every coin is its digits + a 13px icon (MONEY_ICON_WIDTH_SMALL, since this frame is `small`),
// and the frame is the surviving coins packed with the -4px MONEY_BUTTON_SPACING gap:
// 13 (base) + 21 + 21 + 21 - (-4) - (-4) = 84, each 21 being one 8px digit plus the icon
// (2g 3s 4c is a single digit per denomination).
//
// **The digits used to contribute 0 here and this test asserted the bare 13s that came of it.**
// That was the director's cramped gold on a first open: `ShowCoin` sized each coin from a text
// measure that lands a frame later, so it read 0 every first time. It sums the engine's
// `BenillaNumberWidth` feed now, which answers in the same tick.
assert_eq!(
s.eval::<f64>("return TestPurseGoldButton:GetWidth()")
.unwrap(),
21.0
);
assert_eq!(s.eval::<f64>("return TestPurse:GetWidth()").unwrap(), 84.0);
// Collapse away two denominations and the frame shrinks to match: 13 + 21 = 34.
s.run("MoneyFrame_Update(\"TestPurse\", 9)").unwrap();
assert_eq!(shown(&s, "TestPurse"), (false, false, true));
assert_eq!(s.eval::<f64>("return TestPurse:GetWidth()").unwrap(), 34.0);
}
/// The large template uses the 19px icon and its own spacing — the same painter, the other size
/// constants (`MONEY_ICON_WIDTH` / `MONEY_BUTTON_SPACING`), selected purely by `this.small`.
#[test]
fn the_large_template_measures_with_the_nineteen_pixel_icon() {
let s = harness(20_304);
// One 8px digit + the 19px icon — the same digit sum as the small template, over the other
// icon constant. That the two differ ONLY by the icon is what this test is for.
assert_eq!(
s.eval::<f64>("return TestBigPurseGoldButton:GetWidth()")
.unwrap(),
8.0 + 19.0
);
assert_eq!(
s.eval::<f64>("return TestBigPurse:GetWidth()").unwrap(),
19.0 + 27.0 * 3.0 + 8.0,
"19 base + three 27px coins, packed with two -4px gaps"
);
assert_eq!(
s.eval::<i64>("return MONEY_ICON_WIDTH").unwrap(),
19,
"the reference's own constants ship with the kit — addons read them"
);
assert_eq!(s.eval::<i64>("return COPPER_PER_GOLD").unwrap(), 10_000);
}
/// An unknown type is refused rather than half-applied — the reference's `MoneyFrame_SetType`
/// guard (l.144-147). Its `message()` diagnostic is BasicControls.xml's, which we have not
/// transcribed, so the call is guarded; what must hold either way is that the frame keeps the type
/// it had.
#[test]
fn an_unknown_money_type_leaves_the_frame_on_the_one_it_had() {
let s = harness(12_345);
s.run("this = TestPurse MoneyFrame_SetType(\"NOT_A_TYPE\") this = nil")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<String>("return TestPurse.moneyType").unwrap(),
"PLAYER",
"the bad SetType returned before touching info/moneyType"
);
assert_eq!(
coins(&s, "TestPurse"),
("1".into(), "23".into(), "45".into())
);
}
/// `SetMoneyFrameColor` recolours the **digits**, not the coin icons — how the reference reddens a
/// price you cannot afford (l.348-356). Read off the extracted quads, which is where "the digits
/// and not the icons" is actually observable.
#[test]
fn set_money_frame_color_recolours_the_digits_and_not_the_icons() {
let mut s = harness(12_345); // 1g 23s 45c — three distinguishable digit strings
s.run("SetMoneyFrameColor(\"TestPurse\", 1.0, 0.1, 0.1)")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.resolve();
let quads = s.extract();
// Both frames show the same purse, so there are six digit quads; exactly the three belonging to
// the frame we named are red — which also pins that `SetMoneyFrameColor` is scoped by name and
// does not reach a sibling money frame.
let digit_colors: Vec<[f32; 4]> = quads
.iter()
.filter_map(|q| match &q.content {
QuadContent::Text {
text: Some(t),
color: Some(c),
..
} if t == "1" || t == "23" || t == "45" => Some(*c),
_ => None,
})
.collect();
assert_eq!(digit_colors.len(), 6, "two frames * three denominations");
let reddened = |c: &[f32; 4]| {
(c[0] - 1.0).abs() < 0.01 && (c[1] - 0.1).abs() < 0.01 && (c[2] - 0.1).abs() < 0.01
};
assert_eq!(
digit_colors.iter().filter(|c| reddened(c)).count(),
3,
"only TestPurse's digits reddened, got {digit_colors:?}"
);
// The coin icons keep their own texture untinted — the half of the reference's contract that a
// "recolour the whole frame" shortcut would break.
let icon_colors: Vec<[f32; 4]> = quads
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color,
..
} if p.contains("UI-MoneyIcons") => Some(color.unwrap_or([1.0; 4])),
_ => None,
})
.collect();
assert_eq!(icon_colors.len(), 6, "six coin icons drew");
for c in &icon_colors {
assert!(
c[1] > 0.9 && c[2] > 0.9,
"the coin art is untinted, got {c:?}"
);
}
}
/// `MoneyTypeInfo` is read directly by addons, so its shape is part of the contract — all seven
/// reference types, each with an `UpdateFunc`, and the two we cannot source yet answering 0 rather
/// than erroring (the header's `GetSendMailMoney`/`GetSendMailCOD` gap).
#[test]
fn the_money_type_table_carries_all_seven_reference_types() {
let s = harness(12_345);
for t in [
"PLAYER",
"STATIC",
"AUCTION",
"PLAYER_TRADE",
"TARGET_TRADE",
"SEND_MAIL",
"SEND_MAIL_COD",
] {
assert!(
s.eval::<bool>(&format!(
"return MoneyTypeInfo[\"{t}\"] ~= nil and type(MoneyTypeInfo[\"{t}\"].UpdateFunc) == 'function'"
))
.unwrap(),
"MoneyTypeInfo[\"{t}\"] with an UpdateFunc"
);
}
// The two whose engine getters we have not built answer 0 instead of nil-calling.
for t in ["SEND_MAIL", "SEND_MAIL_COD"] {
s.run(&format!(
"this = TestPurse MoneyFrame_SetType(\"{t}\") this = nil"
))
.unwrap();
assert!(
s.errors().is_empty(),
"{t}: script errors: {:?}",
s.errors()
);
assert_eq!(
s.eval::<i64>("return TestPurse.staticMoney").unwrap(),
0,
"{t} reads 0 while its engine getter is missing"
);
}
}