().expect("model");
let fh = model.arena.lookup(name).expect("frame");
model.arena.frame(fh).expect("live frame").regions.len()
}
/// A `SimpleHTML` named `Page`, 270×304 like the reference's `ItemTextPageText`, with one font
/// object behind it (the `` shape).
fn page(s: &UiScript) {
s.run(
r#"
local f = CreateFont("ItemTextFontNormal")
f:SetFont("Fonts\\MORPHEUS.TTF", 15)
f:SetTextColor(0.18, 0.12, 0.06)
Page = CreateFrame("SimpleHTML", "Page")
Page:SetPoint("TOPLEFT", 0, 0)
Page:SetSize(270, 304)
Page:SetFontObject("P", ItemTextFontNormal)
"#,
)
.unwrap();
}
/// Answer every pending measure at 16px a line and 100 wide, so the resolved rects below are
/// arithmetic a reader can check.
///
/// The line count is the client's own kernel `0x5c2070`, not "breaks + 1": it counts a line per
/// class-2 token consumed and exits at the terminator, so a **trailing** break opens no new line
/// and the one-byte `"\n"` a `
` block carries is **one** line, not two (§4.4). An empty string
/// never enters the loop body at all and measures 0 — the empty-`` edge.
fn measure_at_16px(s: &mut UiScript) {
s.resolve();
let reqs = s.fontstrings_needing_measure();
let answers: Vec<(u32, f32, f32, u64)> = reqs
.iter()
.map(|r| (r.id, 100.0, 16.0 * client_lines(&r.text) as f32, r.key))
.collect();
s.set_measured_text_unwrapped(&answers);
s.resolve();
}
/// `0x5c2070`'s line count for a string, at the fidelity these tests need.
fn client_lines(text: &str) -> usize {
if text.is_empty() {
return 0;
}
let breaks = text.matches('\n').count() + text.matches("|n").count();
if text.ends_with('\n') || text.ends_with("|n") {
breaks
} else {
breaks + 1
}
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The walk, as blocks on the frame
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// A well-formed body: one block per tag, each carrying the tag's own `align` and — because only
/// `` was declared — every one of them in `P`'s face, the `` included.
#[test]
fn a_well_formed_body_is_one_block_per_tag() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
page(&s);
s.run(
r#"Page:SetText("" ..
"Title
" ..
"
Left body.
" ..
"Right body.
" ..
"")"#,
)
.unwrap();
let b = blocks(&s, "Page");
assert_eq!(b.len(), 3);
assert_eq!(
b.iter()
.map(|x| (x.text.clone().unwrap(), x.justify_h))
.collect::>(),
[
("Title".to_string(), "CENTER"),
("Left body.".to_string(), "LEFT"),
("Right body.".to_string(), "RIGHT"),
]
);
// §5.3 — `elementFont[1]`'s path is empty, so `0x78ae30` substitutes `elementFont[0]`. There
// is NO header scaling anywhere in the TU: the H1 is the same 15px as the paragraphs.
for blk in &b {
assert_eq!(blk.font.as_deref(), Some("Fonts\\MORPHEUS.TTF"));
assert_eq!(blk.font_height, Some(15.0));
// `SetWidth(frame.GetWidth())`, and NO height — the intrinsic one comes from the measure.
assert_eq!(blk.size, Some((270.0, 0.0)));
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The anchor chain of §4.2 step 2, and the flush default of §4.3: block 0 pins TOPLEFT→frame
/// TOPLEFT, every later block TOPLEFT→**the previous block's** BOTTOMLEFT at `−spacing` — which
/// is `0` out of the box, so the blocks touch.
#[test]
fn blocks_chain_bottom_to_top_and_are_flush_at_spacing_zero() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
page(&s);
s.run(r#"Page:SetText("one
two
three
")"#)
.unwrap();
let b = blocks(&s, "Page");
assert_eq!(
b.iter().map(|x| x.anchor.unwrap()).collect::>(),
[
(Point::TopLeft, Point::TopLeft, 0.0, 0.0, Rel::Frame),
(Point::TopLeft, Point::BottomLeft, 0.0, 0.0, Rel::Block(0)),
(Point::TopLeft, Point::BottomLeft, 0.0, 0.0, Rel::Block(1)),
]
);
// …and the resolved rects really do stack with no gap. Frame top is 600 (screen top, TOPLEFT
// at 0,0); three 16px lines run 600→584→568→552.
measure_at_16px(&mut s);
let tops = resolved_tops(&s, "Page");
assert_eq!(tops, [(600.0, 584.0), (584.0, 568.0), (568.0, 552.0)]);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `SetSpacing` is the only thing that opens a gap, and it opens it by exactly its own value.
#[test]
fn spacing_steps_the_blocks_apart() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
page(&s);
s.run(
r#"
Page:SetSpacing(4)
Page:SetText("one
two
")
"#,
)
.unwrap();
let b = blocks(&s, "Page");
assert_eq!(
b[1].anchor.unwrap(),
(Point::TopLeft, Point::BottomLeft, 0.0, -4.0, Rel::Block(0)),
"block N+1 sits at block N's BOTTOMLEFT displaced by -spacing"
);
assert_eq!(s.eval::("return Page:GetSpacing()").unwrap(), 4.0);
// A negative spacing is clamped to 0 before it is stored (`0x772246`).
s.run("Page:SetSpacing(-9)").unwrap();
assert_eq!(s.eval::("return Page:GetSpacing()").unwrap(), 0.0);
measure_at_16px(&mut s);
assert_eq!(
resolved_tops(&s, "Page"),
[(600.0, 584.0), (580.0, 564.0)],
"the second block starts 4px below the first's bottom"
);
}
/// `
` at BODY level is its own `"\n"` block, and `0x5c2070` counts **one** line for it — one
/// blank line, not two and not a paragraph gap.
#[test]
fn a_body_level_br_is_exactly_one_blank_line() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
page(&s);
s.run(r#"Page:SetText("a
b
")"#)
.unwrap();
assert_eq!(texts(&s, "Page"), ["a", "\n", "b"]);
measure_at_16px(&mut s);
assert_eq!(
resolved_tops(&s, "Page"),
[(600.0, 584.0), (584.0, 568.0), (568.0, 552.0)],
"the BR block is one line tall and pushes `b` down by exactly that"
);
}
/// Inline `
` is a different animal: it splices the two bytes `|n` into the SAME block, which
/// the font engine turns into a line break within it.
#[test]
fn an_inline_br_stays_inside_its_block() {
let s = script();
page(&s);
s.run(r#"Page:SetText("a
b
")"#)
.unwrap();
assert_eq!(texts(&s, "Page"), ["a|nb"]);
}
/// All three fallback routes land on ONE block holding the RAW string — no collapse, so embedded
/// newlines survive as real line breaks. This is the common case for a vmangos `page_text`.
#[test]
fn the_three_fallback_routes_each_land_on_one_raw_block() {
let s = script();
page(&s);
for raw in [
"Plain prose, with an & and a < in it.\nSecond line.",
"",
"nothing",
] {
s.run(&format!("Page:SetText({})", lua_str(raw))).unwrap();
let b = blocks(&s, "Page");
assert_eq!(b.len(), 1, "{raw}");
assert_eq!(b[0].text.as_deref(), Some(raw), "{raw}");
assert_eq!(b[0].justify_h, "LEFT", "{raw}");
assert_eq!(
b[0].anchor.unwrap(),
(Point::TopLeft, Point::TopLeft, 0.0, 0.0, Rel::Frame),
"{raw}"
);
}
}
/// Malformed markup **falls back**; it never raises and never renders half a document.
#[test]
fn malformed_markup_falls_back_rather_than_erroring() {
let s = script();
page(&s);
for raw in [
"a b
",
"hi
",
"hi
\nFrom: Bob",
] {
s.run(&format!("Page:SetText({})", lua_str(raw))).unwrap();
assert_eq!(texts(&s, "Page"), [raw], "{raw}");
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **A second `SetText` destroys the first parse's blocks.** Not hides, not orphans — the regions
/// are gone from the frame, so nothing of the old page can draw behind the new one.
#[test]
fn a_second_set_text_replaces_the_blocks() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
page(&s);
s.run(r#"Page:SetText("one
two
three
")"#)
.unwrap();
assert_eq!(region_count(&s, "Page"), 3);
measure_at_16px(&mut s);
assert_eq!(page_texts_on_screen(&s).len(), 3);
s.run(r#"Page:SetText("only
")"#)
.unwrap();
assert_eq!(texts(&s, "Page"), ["only"]);
assert_eq!(
region_count(&s, "Page"),
1,
"the previous parse's FontStrings are freed, not left on the frame"
);
measure_at_16px(&mut s);
assert_eq!(
page_texts_on_screen(&s),
["only"],
"and nothing of the old page still draws"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The real `page_text` shape §8 quotes, driven through the widget: the `` at the `
` size,
/// every block centred, each `
` one blank line, and the body's own inter-tag newlines adding
/// nothing at all.
#[test]
fn the_page_text_body_renders_the_block_list_a_reader_would_draw() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
page(&s);
s.run(
"Page:SetText(\"\\n
\\n\
The Green Hills of Stranglethorn
\\n
\\n\
Chapter One:\\nThe Mysteries of the Jungle
\\n
\\n\
Deep in the jungle, all is not as it seems.
\\n\
\\n\")",
)
.unwrap();
let b = blocks(&s, "Page");
assert_eq!(
b.iter()
.map(|x| (x.text.clone().unwrap(), x.justify_h, x.font_height))
.collect::>(),
[
(
"The Green Hills of Stranglethorn".to_string(),
"CENTER",
Some(15.0)
),
("\n".to_string(), "LEFT", Some(15.0)),
(
"Chapter One: The Mysteries of the Jungle".to_string(),
"CENTER",
Some(15.0)
),
("\n".to_string(), "LEFT", Some(15.0)),
(
"Deep in the jungle, all is not as it seems.".to_string(),
"CENTER",
Some(15.0)
),
],
"the H1 renders at the P size — nothing in the TU scales a header"
);
// Five blocks flush at 16px each: 600 → 520.
measure_at_16px(&mut s);
let tops = resolved_tops(&s, "Page");
assert_eq!(tops.first(), Some(&(600.0, 584.0)));
assert_eq!(tops.last(), Some(&(536.0, 520.0)));
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **The exact string the reader builds**, on the exact body B240 reports — `ItemTextFrame.xml`'s
/// READY handler pads an authorless page as `"\n" .. ItemTextGetText() .. "\n"`, and that padding
/// is the one thing standing between a formatted page and the raw markup Goudy photographed.
/// Whitespace before and after the root element is legal XML, so this must take the MARKUP path;
/// if it ever takes the fallback, every book in the world silently renders as its own source.
///
/// The body is vmangos `page_text` 2676 verbatim — the *Alliance Military Ranks* plaque in
/// Stormwind's Old Town, `GameObject` 3011, the object in the report's screenshots.
#[test]
fn the_readers_own_newline_padding_still_parses_as_markup() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
page(&s);
s.run(
"Page:SetText(\"\\n\" .. \
\"\\n\\n\
ALLIANCE MILITARY RANKS
\\n\
OFFICERS
\\n\
Grand Marshal
\\n\
Field Marshal
\\n\
Knight
\\n\
ENLISTED
\\n\
Private
\\n\
\\n\" .. \"\\n\")",
)
.unwrap();
let texts = texts(&s, "Page");
assert_eq!(
texts,
[
"ALLIANCE MILITARY RANKS",
"\n",
"OFFICERS",
"\n",
"Grand Marshal",
"Field Marshal",
"Knight",
"\n",
"ENLISTED",
"\n",
"Private",
],
"the padded page took the markup path — one block per tag, one blank line per
, and \
BODY's own inter-tag newlines adding nothing"
);
// The falsification, stated: the fallback would put the WHOLE source in one block.
assert!(
!texts[0].contains('<'),
"a fallback would render the markup itself — the reported symptom"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The other half of the same guard: `page_text` **1510** (the Crystal Pylon manual) ends
/// ` CRYSTAL PYLON USER'S MANUAL
\
Chapter 1: The Northern Pylon
") && texts[0].ends_with("Title
Body