Files
benilla-octo/crates/benilla-app/tests/item_text_frame.rs
T
SamandClaude Fable 5 693c26d9c4 held weapons pick, book images size to their art, the panel opens again
- a unit's pick geometry is every model chained to it — body, held items, mount — one candidate per root resolving to the same unit, which is how the reference registers its pick (it walks the attachment tree and registers each model); Naxxramas' floating weapons could only be selected by their name plate before (no body, no candidate), and every armed unit gains its gear as clickable surface (our own body 12 → 21 tested parts); the broad phase stays the body's sphere, so a very long polearm on a small body is the named residual
- the selection ring's writer has two branches and we had transcribed one: a model whose stand box is degenerate (zero extent in x and y) rings at the literal 1.2 × scale — 2.7 yd under a trigger creature — instead of `sqrt(0.5·sqrt(dx²+dy²))` = 0 clamped to an invented 0.05 floor; both invented fallbacks are gone
- an HTML book image with no width/height is its art's texel square (one texel = one FrameXML unit, bit-for-bit the `<AbsDimension>` converter), not the rest of the page — *A Treatise on Military Ranks* drew its crest twice too big and stretched to the page's aspect; a texture-size oracle answers off the same decoder the renderer draws with, and an unfloated height-less `<IMG>` reserves its art's height in the flow; 797 shipped textures swept, exactly two zero-size axes move and neither is visible
- the dev debug panel killed the client on open with "Attachments have differing sample counts": Bevy's 2-D depth texture is keyed on the render target alone, so two Camera2ds on one window at different MSAA counts cannot both be valid, and the egui overlay camera had silently kept the default 4× after the player-UI camera went to Off; it now says `Msaa::Off`, and a preflight check reports any two 2-D cameras that disagree on a shared target, every run

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 17:16:04 +07:00

476 lines
20 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.
//! Drives the REAL `assets/ui/ItemTextFrame.xml` through the engine — the reader window for bag
//! letters (mail-made permanent copies) and, later, books. Loads the same file chain the app does
//! (cut to the reader's dependency prefix), pushes an `ItemTextState`, fires the reference event
//! flow `ITEM_TEXT_BEGIN` → `ITEM_TEXT_READY` → `ITEM_TEXT_CLOSED`, and asserts the transcribed
//! Lua actually paints (title, the "From," creator tail, page-button visibility, the close intent).
use benilla_ui::script::{ItemTextState, UiScript};
const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
/// The reader's dependency prefix, in the manifest's own order. `ScrollTemplates.xml` and
/// `UIPanelTemplates.xml` joined it with decisions 1337/1338: the page sits in a real ScrollFrame
/// now, whose template is in the second and whose `ScrollFrame_OnLoad` is in the first.
const FILES: [&str; 6] = [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
"ItemTextFrame.xml",
];
fn load_ui(script: &UiScript) {
let dir = std::path::Path::new(UI_DIR);
let provider = |req: &str| -> Option<Vec<u8>> {
let norm = req.replace('\\', "/");
let base = norm.rsplit('/').next().unwrap_or(&norm);
std::fs::read(dir.join(&norm))
.or_else(|_| std::fs::read(dir.join(base)))
.ok()
};
for file in FILES {
let text = std::fs::read_to_string(dir.join(file)).unwrap_or_else(|e| {
panic!("reading {file}: {e}");
});
let doc = benilla_ui::framexml::parse(&text).unwrap_or_else(|e| {
panic!("parsing {file}: {e}");
});
let report = benilla_ui::loader::load(script, &doc, &provider);
assert!(
report.errors.is_empty(),
"{file} loaded with errors: {:#?}",
report.errors
);
}
}
/// The page body as the reader actually DRAWS it, one string per block.
///
/// `ItemTextPageText` is a `SimpleHTML` since decisions 1337/1338, and 5875's SimpleHTML has no
/// `GetText` — its Lua table is 19 entries and none of them is a text getter (wow-re
/// `simplehtml-markup-engine.md` §5.1; later clients grew one, this one has not). So the page is
/// read the way it is seen: off the render list. A plain body is one block through the engine's
/// raw-text fallback, which is what every letter here is.
fn page_blocks(s: &UiScript) -> Vec<String> {
use benilla_ui::script::QuadContent;
s.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::Text { text, .. } => text,
_ => None,
})
.collect()
}
fn letter() -> ItemTextState {
ItemTextState {
item: "Plain Letter".into(),
creator: Some("One".into()),
text: "asd".into(),
page: 1,
has_next: false,
material: None,
}
}
/// The letter flow: BEGIN paints the title, READY paints the body with the reference "From," tail
/// and shows the window; a single page shows no page number and no page-turn buttons.
#[test]
fn a_letter_reads_with_the_creator_tail() {
let mut s = UiScript::new().unwrap();
load_ui(&s);
assert!(!s.eval::<bool>("return ItemTextFrame:IsShown()").unwrap());
s.set_item_text(Some(letter()));
s.fire_event("ITEM_TEXT_BEGIN", vec![]);
assert_eq!(
s.eval::<String>("return ItemTextTitleText:GetText()")
.unwrap(),
"Plain Letter"
);
s.fire_event("ITEM_TEXT_READY", vec![]);
assert!(s.eval::<bool>("return ItemTextFrame:IsShown()").unwrap());
assert!(
page_blocks(&s).contains(&"\nasd\n\nFrom,\nOne\n\n".to_string()),
"the reference creator tail (ITEM_TEXT_FROM really is comma'd), drawn as one raw block: {:?}",
page_blocks(&s)
);
for hidden in [
"ItemTextCurrentPage",
"ItemTextPrevPageButton",
"ItemTextNextPageButton",
"ItemTextStatusBar",
] {
assert!(
!s.eval::<bool>(&format!("return {hidden}:IsShown()"))
.unwrap(),
"{hidden} must stay hidden on a single-page letter"
);
}
assert!(s.take_errors().is_empty());
}
/// The scrollbar track (the ref's black `$parentMiddle` strip) belongs in the scrollbar column,
/// right of the page — regression for the black bar over the parchment: the ref declares the
/// ARTWORK layer before BACKGROUND because `Middle` anchors to `Top` by name and anchors resolve
/// at SetPoint time; a reordered transcription silently fell back to the parent. The warning
/// check pins the tripwire that now catches any such unresolved named anchor at load.
#[test]
fn the_scrollbar_track_sits_right_of_the_page() {
let mut s = UiScript::new().unwrap();
load_ui(&s);
let unresolved: Vec<String> = s
.warnings()
.into_iter()
.filter(|w| w.contains("does not resolve"))
.collect();
assert!(unresolved.is_empty(), "unresolved anchors: {unresolved:#?}");
s.set_screen_size(1024.0, 768.0);
s.set_item_text(Some(letter()));
s.fire_event("ITEM_TEXT_BEGIN", vec![]);
s.fire_event("ITEM_TEXT_READY", vec![]);
s.resolve();
let (track_left, page_right): (f32, f32) = s
.eval(
"return ItemTextScrollFrameMiddle:GetLeft(), \
ItemTextScrollFrame:GetRight()",
)
.unwrap();
assert!(
track_left >= page_right - 0.5,
"the track ({track_left}) must not overlap the page (right edge {page_right})"
);
assert!(s.take_errors().is_empty());
}
/// An authorless multi-page text (a book): no "From," tail, the page number and the page-turn
/// buttons show per the reference visibility rules (page 1 with a next → Next only).
#[test]
fn a_book_page_shows_the_paging_chrome() {
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_item_text(Some(ItemTextState {
item: "Lament of the Highborne".into(),
creator: None,
text: "page one".into(),
page: 1,
has_next: true,
material: None,
}));
s.fire_event("ITEM_TEXT_BEGIN", vec![]);
s.fire_event("ITEM_TEXT_READY", vec![]);
assert!(
page_blocks(&s).contains(&"\npage one\n".to_string()),
"the page body drawn: {:?}",
page_blocks(&s)
);
assert_eq!(
s.eval::<String>("return ItemTextCurrentPage:GetText()")
.unwrap(),
"1"
);
assert!(!s
.eval::<bool>("return ItemTextPrevPageButton:IsShown()")
.unwrap());
assert!(s
.eval::<bool>("return ItemTextNextPageButton:IsShown()")
.unwrap());
s.run("ItemTextNextPageButton:Click()").unwrap();
assert_eq!(s.take_item_text_page_turns(), vec![1]);
assert!(s.take_errors().is_empty());
}
/// Closing: the X hides the panel, the OnHide queues the `CloseItemText` intent (the app then
/// clears the session and fires `ITEM_TEXT_CLOSED` — which hides again, harmlessly).
#[test]
fn closing_queues_the_close_intent() {
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_item_text(Some(letter()));
s.fire_event("ITEM_TEXT_BEGIN", vec![]);
s.fire_event("ITEM_TEXT_READY", vec![]);
let _ = s.take_item_text_close();
s.run("ItemTextCloseButton:Click()").unwrap();
assert!(!s.eval::<bool>("return ItemTextFrame:IsShown()").unwrap());
assert!(s.take_item_text_close(), "OnHide queued the close intent");
// The app's answer: session cleared + ITEM_TEXT_CLOSED (stays hidden, no errors).
s.set_item_text(None);
s.fire_event("ITEM_TEXT_CLOSED", vec![]);
assert!(!s.eval::<bool>("return ItemTextFrame:IsShown()").unwrap());
assert!(s.take_errors().is_empty());
}
/// **B240's render half, on the reported page.** Goudy's plaque body (`page_text` 2676, the
/// *Alliance Military Ranks* wall plaque in Stormwind's Old Town) went through the reader and came
/// out as its own source — `<HTML><BODY><H1 align="center">…` drawn literally, and cut off with
/// "..." partway down. Both were the page being a plain FontString where the reference has a
/// `SimpleHTML` (decisions 1337/1338).
///
/// What this pins is the whole path the report exercises: the app's page feed → the reader's
/// `"\n" .. body .. "\n"` padding → the markup parse → the drawn blocks. The falsification is
/// stated at the bottom: if the parse ever regresses to the raw-text fallback, the page draws as
/// one block that still has angle brackets in it, which is exactly what was reported.
#[test]
fn the_reported_html_page_draws_as_blocks_not_as_its_own_markup() {
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_item_text(Some(ItemTextState {
item: "Alliance Military Ranks".into(),
creator: None,
text: "<HTML>\n<BODY>\n\
<H1 align=\"center\">ALLIANCE MILITARY RANKS</H1><BR/>\n\
<P align=\"center\">OFFICERS</P><BR/>\n\
<P align=\"center\">Grand Marshal</P>\n\
<P align=\"center\">Knight</P><BR/>\n\
<P align=\"center\">ENLISTED</P><BR/>\n\
<P align=\"center\">Private</P>\n\
</BODY>\n</HTML>"
.into(),
page: 1,
has_next: false,
material: None,
}));
s.fire_event("ITEM_TEXT_BEGIN", vec![]);
s.fire_event("ITEM_TEXT_READY", vec![]);
assert!(s.eval::<bool>("return ItemTextFrame:IsShown()").unwrap());
let drawn = page_blocks(&s);
for line in [
"ALLIANCE MILITARY RANKS",
"OFFICERS",
"Grand Marshal",
"Knight",
"ENLISTED",
"Private",
] {
assert!(
drawn.contains(&line.to_string()),
"{line:?} should draw as its own block; drawn: {drawn:?}"
);
}
// The reported symptom, stated as the thing that must not come back.
assert!(
!drawn
.iter()
.any(|b| b.contains("<HTML>") || b.contains("<P align")),
"the markup itself must never reach the page — that is what was photographed: {drawn:?}"
);
// And the second half of the same symptom: the body is no longer a height-pinned FontString,
// so decision 1332's ellipsis seam cannot cut a long page short. No block ends in the
// truncation marker.
assert!(
!drawn.iter().any(|b| b.ends_with("...")),
"a block was truncated — the page is height-pinned again: {drawn:?}"
);
}
/// **B342, on the reported page, through the real archives.** Goudy, 2026-08-27 (`#bugs`
/// `1542371921486811236`): *"html images in books are not scaled correctly"* — the Alliance crest
/// on *A Treatise on Military Ranks* drawn several times the reference's size with the page's own
/// text over it, beside a 1.12.1 shot of the same page for comparison.
///
/// The body is `page_text` 2654, quoted verbatim below, and its one `<IMG>` carries **no `width=`
/// and no `height=`**. In the reference that is the CONTENT-derived span: the resolver's size call
/// is virtual, and `CSimpleTexture`'s override answers an authored `0.0` with the loaded texture's
/// texel extent, one texel to one FrameXML unit (wow-re `region-size-fallback.md` §2, decision
/// 1349). `Interface\PvPRankBadges\PvPRankAlliance` is a 128×128 BLP, so the crest is a 128-unit
/// square inside a 270-wide page.
///
/// The engine half is pinned in `benilla-ui`'s own `simplehtml` tests against a stub oracle; what
/// this adds is the two ends only the app has — the **real** reader XML the report was
/// photographed against, and the **real** file, measured by the same decoder the renderer draws
/// with. It skips on a machine with no client data, like every other archive-backed sweep.
#[test]
fn the_reported_book_crest_draws_at_the_blps_own_size() {
let data = benilla_formats::wow_data_or_skip!();
let chain = std::sync::Mutex::new(benilla_formats::open_chain(&data).expect("open chain"));
let mut s = UiScript::new().unwrap();
// The host oracle, wired exactly as `ui_script::lifecycle::install_texture_resolvers` wires the
// live one: the same decoder, so the size the layout resolves with is the size the screen shows.
s.set_texture_size_probe(Box::new(move |path| {
benilla_assets::sprite_dimensions(&chain, None, path)
}));
load_ui(&s);
s.set_item_text(Some(ItemTextState {
item: "A Treatise on Military Ranks".into(),
creator: None,
text: "<HTML>\n<BODY>\n\
<H1 align=\"center\">A TREATISE ON MILITARY RANKS</H1>\n\
<BR/>\n<BR/>\n\
<IMG src=\"Interface\\PvPRankBadges\\PvPRankAlliance\" align=\"left\"/>\n\
<BR/>\n\
<P align=\"right\">What follows are</P>\n\
<P align=\"right\">the military ranks</P>\n\
</BODY>\n</HTML>"
.into(),
page: 1,
has_next: true,
material: None,
}));
s.fire_event("ITEM_TEXT_BEGIN", vec![]);
s.fire_event("ITEM_TEXT_READY", vec![]);
s.resolve();
let crest: Vec<_> = s
.extract()
.into_iter()
.filter(|q| match &q.content {
benilla_ui::script::QuadContent::Texture { path, .. } => {
path.as_deref().is_some_and(|t| {
t.eq_ignore_ascii_case("Interface\\PvPRankBadges\\PvPRankAlliance")
})
}
_ => false,
})
.filter_map(|q| q.rect)
.collect();
assert_eq!(crest.len(), 1, "one crest quad on the page");
let r = crest[0];
assert_eq!(
(r.right - r.left, r.top - r.bottom),
(128.0, 128.0),
"the crest draws at the BLP's own 128x128 — a texel is a FrameXML unit"
);
// The reported symptom, stated as the thing that must not come back: the page is 270 units
// wide, and the crest used to be stretched across all of it with the text over the top.
assert!(
r.right - r.left < 270.0,
"the crest spans the whole page again — that is the photograph"
);
// **The adjacent state**: page 2 of the same book (`page_text` 2655) is five unsized `<IMG>`s,
// one rank badge per officer rank, and every one of them is a 32x32 BLP. Under the reported
// defect all five were page-wide slabs stacked over each other's text; each is now its own
// 32-unit square. (What separates them vertically is the `<BR/>`/`<P>` blocks between them —
// a floated image reserves nothing — so their spacing is the font engine's business and this VM
// has none: every text block measures zero here and the five land on one line. The SIZES are
// the claim.)
s.set_item_text(Some(ItemTextState {
item: "A Treatise on Military Ranks".into(),
creator: None,
text: "<HTML>\n<BODY>\n\
<H1 align=\"center\">OFFICER RANKS OF THE ALLIANCE</H1><BR/>\n\
<P align=\"center\">Part 1</P>\n\
<IMG src=\"Interface\\PvPRankBadges\\PvPRank14\" align=\"left\"/><BR/>\n\
<P align=\"right\">Grand Marshal</P><BR/><BR/>\n\
<IMG src=\"Interface\\PvPRankBadges\\PvPRank13\" align=\"left\"/><BR/>\n\
<P align=\"right\">Field Marshal</P><BR/><BR/>\n\
<IMG src=\"Interface\\PvPRankBadges\\PvPRank12\" align=\"left\"/><BR/>\n\
<P align=\"right\">Marshal</P><BR/><BR/>\n\
<IMG src=\"Interface\\PvPRankBadges\\PvPRank11\" align=\"left\"/><BR/>\n\
<P align=\"right\">Commander</P><BR/><BR/>\n\
<IMG src=\"Interface\\PvPRankBadges\\PvPRank10\" align=\"left\"/><BR/>\n\
<P align=\"right\">Lieutenant Commander</P><BR/><BR/>\n\
</BODY>\n</HTML>"
.into(),
page: 2,
has_next: true,
material: None,
}));
s.fire_event("ITEM_TEXT_READY", vec![]);
s.resolve();
let badges: Vec<_> = s
.extract()
.into_iter()
.filter(|q| match &q.content {
benilla_ui::script::QuadContent::Texture { path, .. } => path
.as_deref()
.is_some_and(|t| t.starts_with("Interface\\PvPRankBadges\\PvPRank")),
_ => false,
})
.filter_map(|q| q.rect)
.collect();
assert_eq!(badges.len(), 5, "one quad per officer rank");
for b in &badges {
assert_eq!(
(b.right - b.left, b.top - b.bottom),
(32.0, 32.0),
"each rank badge is its own 32x32 BLP, not a page-wide slab"
);
}
}
/// **B288, closed at the reported symptom** (CarlG, decision 1507): the Verdant Note open from
/// the bag, then a quest giver's gossip — both frames drew at the same TOPLEFT 0,-104 anchor,
/// page text and greeting interleaved. The cause was the reader's missing `UIPanelWindows` row:
/// registered (the ref's own `{ area = "left", pushable = 0 }`, UIParent.lua l.20), the two are
/// left-slot rivals and showing one replaces the other — in BOTH orders, each displaced window's
/// OnHide ending its own session exactly as the merchant/gossip pair already does.
#[test]
fn a_quest_givers_gossip_displaces_the_open_reader() {
use benilla_ui::script::GossipMenu;
let mut s = UiScript::new().unwrap();
load_ui(&s);
// The other rival, loaded exactly as the app's manifest does (after the reader's prefix).
{
let dir = std::path::Path::new(UI_DIR);
let text = std::fs::read_to_string(dir.join("GossipFrame.xml")).unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"GossipFrame.xml: {:?}",
report.errors
);
}
// The note is open and holding the left slot.
s.set_item_text(Some(letter()));
s.fire_event("ITEM_TEXT_BEGIN", vec![]);
s.fire_event("ITEM_TEXT_READY", vec![]);
assert!(s.eval::<bool>("return ItemTextFrame:IsShown()").unwrap());
assert!(
s.eval::<bool>("return GetLeftFrame():GetName() == 'ItemTextFrame'")
.unwrap(),
"the registered reader seats at the left slot, not a bare Show"
);
let _ = s.take_item_text_close();
// The quest giver's gossip opens: one window, not two — the reported stack cannot form.
s.set_gossip(Some(GossipMenu {
greeting: "What can I do for you?".into(),
quests: Vec::new(),
options: Vec::new(),
}));
s.fire_event("GOSSIP_SHOW", vec![]);
assert!(s.take_errors().is_empty());
assert!(
!s.eval::<bool>("return ItemTextFrame:IsShown()").unwrap(),
"gossip replaced the note — the B288 stack (both drawn at 0,-104) cannot form"
);
assert!(s
.eval::<bool>("return GossipFrame:IsShown() and GetLeftFrame():GetName() == 'GossipFrame'")
.unwrap());
assert!(
s.take_item_text_close(),
"the displaced reader's OnHide ended the read session (CloseItemText)"
);
// The app's answer to that close must not disturb the gossip that displaced it.
s.set_item_text(None);
s.fire_event("ITEM_TEXT_CLOSED", vec![]);
assert!(s.eval::<bool>("return GossipFrame:IsShown()").unwrap());
// The reverse order: reading the note over an open gossip ends the gossip session.
let _ = s.take_gossip_close();
s.set_item_text(Some(letter()));
s.fire_event("ITEM_TEXT_BEGIN", vec![]);
s.fire_event("ITEM_TEXT_READY", vec![]);
assert!(s.take_errors().is_empty());
assert!(
s.eval::<bool>("return ItemTextFrame:IsShown() and not GossipFrame:IsShown()")
.unwrap(),
"the reader replaces gossip the same way"
);
assert!(
s.take_gossip_close(),
"the displaced gossip's OnHide ended the gossip session (CloseGossip)"
);
}