Files
benilla-octo/crates/benilla-app/src/ui_script/chat_tests.rs
T
SamandClaude Fable 5.1 5c26c233c3 the cooldown goes stock, early-Z comes back, and lists reach the end
- a size-less `<Model>` pane's rect is its file's box in layout units: the reference's geometry overrides (`0x76d080`/`0x76d0d0`) answer the bbox extent whenever no size is authored, `bboxExtent · 768·√(a²+1) · layoutScale` FrameXML units (`1280 · extent` at 4:3), written into the pane's layout input when the file's facts land, re-derived on an aspect change, and ended for good by any authored size; `SetPosition` is the same unit, which is how `0x4a7b20` centres the arrow at `½·GetWidth, ½·GetHeight` through the override — so the map arrow's sprite arm and its hand-sized 33.6 px footprint go (its rect is `0.0262 × 0.0263` layout units, 33.5 × 33.7 FrameXML units at 4:3, 41.0 × 41.2 at 16:9), and the stock `ItemAnim` pane plays the item push unadapted (`ReplaceIconTexture` on the type-14 batch, the 1000 ms clamp, the stock `OnAnimFinished`), `ContainerFrameAdapters.xml` losing 0887's Lua card and its hand-measured 42.41 × 124.72 rect — the implicit one at 16:9 — and keeping the keyring fork and the three bag verbs (2015); and the cooldown is the reference's own model: the texture-transform law `uv' = R_q((uv + t − p) ⊙ s) + p`, `p = (½, ½)`, the raw quaternion component-lerped and never normalised, is one function (`uv_transform`) with the shader's UV fold as its twin — rotation and scale tracks bake per file sequence slot beside the translation, the mat-anim table gains an affine row `[cos − 1, sin, sx − 1, sy − 1]` on `anim_slots.z` whose identity is the zero row so every static batch reads exactly what it did, and a UI tile clones the twin material per animated batch and writes both rows every frame off the pane's play head, never the world clock; the stock `Cooldown.xml` runs off the chain on 2007's two handlers, the file's four quadrant quads turned by their own rotation tracks (`0 → −90°` over each quadrant's 250 ms window, clockwise from 12 o'clock) and its alpha tracks revealing the star for the flash; retired: the native `Cooldown` widget and `SetCooldown` (an Era API 1.12 never had — `CreateFrame("Cooldown")` is the reference's unknown-type refusal now), the loader's `Model → Cooldown` substitution, the app's hand-built pie and star, and our `Cooldown.xml`; `assets/ui` is eight files (2019)
- `UpdateMapHighlight` gets its zone-level exits (B360): at zone zoom ours returned the nil tail for every cursor position, so hovering Deadwind Pass from Duskwood showed no label where 1.12 shows "Deadwind Pass"; the reference's `0x4a7fa0` runs two lookups at zone level and answers a NAME only — first the current zone's exploration-gated overlay list (the builder at `0x4a6ad9` gating on the explored byte), each row's HitRect scaled by 1/1002 and 1/668 against the normalized cursor, edges inclusive, the first hit naming its Area1, then the continent grid re-windowed through the displayed zone's own rect, naming the neighbouring zone or city and nothing for the displayed zone itself — and only the name-only tail is reachable from there, fileName nil and the six coords zero, so the stock frame hides the highlight quad, as the director's own 1.12 shot shows; and `SetVerticalScroll` stores the offset as given (B370, B363): the reference's `0x786db0` compares the new offset against the old and writes it to `[+0x328]` verbatim, `0x787100` re-anchors the scroll child off it, and the range at `[+0x320]` is never read on that path — every clamp the reference exhibits is FrameXML's, through the scroll bar's `[min, max]`; ours clamped into `[0, GetVerticalScrollRange()]`, the child's overflow past the FRAME, which is 40 px short of where `FauxScrollFrame_Update` sends the bar on the 220-tall skills list (three rows lost) and 15 px short on the 287-tall who list (one); the setter fires `OnVerticalScroll` only on a change, as the bytes do, `BenillaScroll_Step` bounds its wheel in Lua where the reference's handler does, and the ScrollFrame kind's prose stops calling its offset unpinned (2017)
- overdraw's cheap half, the sky first: since 0588 every sky element wrote `frag_depth = 0.0`, which costs a pipeline its early-Z, so on an outdoor frame every full-screen dome fragment under the ground and the buildings was shaded in full (the gradient's `asin`, `atan2` and eight table lookups at dawn and dusk) and thrown away; one shared `sky_vertex.wgsl` — bevy's mesh vertex verbatim plus `out.position.z = 0.0` — is now the vertex stage of the gradient dome, the cloud dome, the star patches and the celestial discs and glares, and the model lane's `WOW_SKY_DEPTH` branch moves from its fragment to its vertex, so no sky fragment declares `frag_depth` and the rasterizer rejects the covered screen before the shader runs; every sky `specialize` zeroes the rasterizer bias constant the `sky_order` rung had been leaking into `DepthBiasState::constant`, harmless only while the fragment overwrote its depth, and the prose beside the pin follows it; then the retained `static_gx` pass draws BEFORE bevy's opaque pass (`StartMainPass → static_gx → MainOpaquePass`) — buildings and doodads are the frame's best occluders and a terrain fragment its dearest, six samples a pixel — so walls reject the terrain behind them instead of the reverse, the pass taking the first-use clear with it and the dev depth probe ordered after it; measured on 16 capture scenarios: 14 bit-identical, `northshire-dusk` 1 px and `water-night` 11 px, attributed to exact depth ties on static/terrain silhouettes and last-bit interpolation noise; a depth prepass and near-to-far bins are declined until a journal says where the time goes (2016)
- the grass cut goes per pixel because of a quarter-mip: `0x6813d0` sets `D3DSAMP_MIPMAPLODBIAS = +0.25` on stage 0 (`0x6813f4`) once per frame before the detail-doodad pass — capture-confirmed on all 29 doodad batches, and outside the `[0x6b1000, 0x6b3200)` scan 2004 had called complete; it decides the whole look because the `clutter_cut` probe finds 6 of 31 shipped detail atlases carry a BINARY alpha pyramid (mip 0 has DXT3's 16 levels, every level below exactly two, both starting zones among them), so `a × ramp` degenerates to a global on/off and every texel of a leaf crosses at the same depth — Elwynn's survivor coverage is flat at 47.6 % across the whole 52.5–61.1 yd band, then 0.0 %, while the probe's box-filtered control erodes 47.7 → 20.9 over the same band, so the art has the gradient the shipped pyramid threw away, and `clutter_size` puts the median tuft at 0.92 yd tall, 1.03 yd deep, a full yard for the boundary plane to sweep; the escape is bilinear: full alpha needs a 2×2 all-opaque texel neighbourhood, and those run out between mip 2 and 3 (179/56/10/0/0/0 on the dominant Elwynn cell), so the reference's λ ≈ 2.9–3.3 sits past the cliff and the tuft erodes continuously — 2.2 % of a tuft's pixels leave in the last half-yard with the bias, 11.6 % without, 24.2 % one mip sharper; wgpu has no sampler LOD bias, so the clutter lane re-samples base colour at `view.mip_bias + 0.25`, keeping the sampler every other model batch shares at the reference's 0; ruled out on the way: our BLP decoder (all nine uploaded levels byte-identical against the archive's 52 793 BLP2s), mip regeneration, the `has_mipmaps` byte's value 2 (a plain boolean, an authoring marker), anisotropy and geometry; what it does not fix is resolution-dependent, and so is the reference's own look — λ with the bias is 3.22 at the capture's 648p but 2.48 at 1080p and 1.48 at 2160p, so at a modern resolution the reference itself pops leaf by leaf, and scaling the bias with the viewport is one reversible line left for a look call (2021)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:35:31 +07:00

1294 lines
53 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.
//! Shipped end-to-end tests for the chat window + input (decision 0084, the chat arc): the real
//! `assets/ui/ChatFrame.xml` loaded through the engine loader, driven exactly as the app drives it —
//! `add_chat_message` → `resolve`/`extract` (lines render in the pinned colors), the wheel → the Lua
//! `OnMouseWheel` handler → `ScrollUp` (which freezes the fade), and the input EditBox's ENTER →
//! `OnEnterPressed` → `SubmitChatInput` → `take_chat_input` (the app then parses it — see
//! `crate::ui_chat`'s `parse_line` tests for the `/say`//`/yell`//`/w` mapping).
use benilla_ui::script::{ExtractedQuad, QuadContent, UiScript};
use super::test_ui::load_ui as load_xml;
/// The full ChatFrame stack (fonts first, so the FontString's `inherits="ChatFontNormal"` resolves).
fn chat_frame() -> UiScript {
let mut s = UiScript::new().unwrap();
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
// GameTooltip.xml + UIDropDownMenu.xml are real RUNTIME dependencies of the chat tabs since
// decision 1589: a left click closes any open menu (`CloseDropDownMenus`, the reference's own
// first move in `FCF_Tab_OnClick`) and a right click opens the window's options menu.
// `benilla.toc` already orders both ahead of ChatFrame.xml (l.60/64 vs l.399); the harness
// says so too, rather than a guard that would hide a real ordering fault. (The tooltip file is
// the dropdown kit's own dependency — its MenuBackdrop reads `TOOLTIP_DEFAULT_COLOR`.)
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit the chat menus build from
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
// The UIParent slice (RaiseFrameLevel, MouseIsOver, the fade and flash kits) ahead of the
// bar and the chat files; `FCF_OnUpdate` — the dock's hover, fade and drag driver — runs
// from UIParent's OnUpdate, as in the reference.
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
// `FCF_ValidateChatFramePosition` (a tab-drag stop) reads `MainMenuBar:GetHeight()`; the bar's
// own load-time chain precedes it, as in the action-bar harness.
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml");
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
super::fire_chat_login(&mut s);
s.set_screen_size(1600.0, 900.0);
s.resolve();
s
}
/// The color+alpha of the first Text quad whose text equals `t`.
fn text_color(quads: &[ExtractedQuad], t: &str) -> Option<[f32; 4]> {
quads.iter().find_map(|q| match &q.content {
QuadContent::Text {
text: Some(x),
color: Some(c),
..
} if x == t => Some(*c),
_ => None,
})
}
fn close(a: f32, b: f32) -> bool {
(a - b).abs() < 0.01
}
#[test]
fn injected_lines_render_in_the_pinned_colors() {
let mut s = chat_frame();
// The app's feed passes 0..1 floats from the pinned type→color table; the seam quantizes them.
// SAY white (FFFFFF), SYSTEM yellow (FFFF00 — the GM-feedback color), LOOT green (00AA00).
s.add_chat_message("ChatFrame1", "[Tri] says: hi", 1.0, 1.0, 1.0);
s.add_chat_message("ChatFrame1", "You give 500 copper.", 1.0, 1.0, 0.0);
s.add_chat_message(
"ChatFrame1",
"You receive loot: [Tough Jerky].",
0.0,
170.0 / 255.0,
0.0,
);
s.resolve();
let quads = s.extract();
let say = text_color(&quads, "[Tri] says: hi").expect("say line rendered");
assert!(
close(say[0], 1.0) && close(say[1], 1.0) && close(say[2], 1.0),
"say white: {say:?}"
);
assert!(close(say[3], 1.0), "a fresh line is fully opaque");
let sys = text_color(&quads, "You give 500 copper.").expect("system line rendered");
assert!(
close(sys[0], 1.0) && close(sys[1], 1.0) && close(sys[2], 0.0),
"system yellow: {sys:?}"
);
let loot = text_color(&quads, "You receive loot: [Tough Jerky].").expect("loot line rendered");
assert!(
close(loot[0], 0.0) && close(loot[1], 170.0 / 255.0) && close(loot[2], 0.0),
"loot green: {loot:?}"
);
}
#[test]
fn newest_line_sits_at_the_bottom() {
let mut s = chat_frame();
s.add_chat_message("ChatFrame1", "older", 1.0, 1.0, 1.0);
s.add_chat_message("ChatFrame1", "newer", 1.0, 1.0, 1.0);
s.resolve();
let quads = s.extract();
let y = |t: &str| {
quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Text { text: Some(x), .. } if x == t => q.rect.map(|r| r.bottom),
_ => None,
})
.unwrap()
};
// y-up: the newest line's band is lower than the older one's.
assert!(y("newer") < y("older"), "newest renders at the bottom");
}
/// The whole fade round trip as it reaches a real chat window: a line ramps down, a scroll brings
/// it back to full, the scrolled-up view then holds it there, and returning to the bottom lets it
/// ramp again. The re-arm half is `msgframe-fade-rearm-law.md` — every scroll entry reaches
/// `0x788b80` or the relayout's `0x788af0`; before it, a faded-out chat could not be recovered by
/// any input the client offers (director-reported, 2026-08-29).
#[test]
fn wheel_scroll_re_arms_the_fade_then_freezes_it() {
let mut s = chat_frame();
s.run("ChatFrame1:SetTimeVisible(0); ChatFrame1:SetFadeDuration(4)")
.unwrap();
for t in ["L0", "L1", "L2"] {
s.add_chat_message("ChatFrame1", t, 1.0, 1.0, 1.0);
}
s.resolve();
s.tick(1.0);
s.resolve();
let a1 = text_color(&s.extract(), "L1").expect("L1 visible")[3];
assert!(a1 < 1.0 && a1 > 0.0, "the line faded partway: {a1}");
// The scroll verb is what the reference's scroll buttons call; the 1.12 chat frame takes
// no wheel of its own (`enableMouse="false"`, no OnMouseWheel).
s.run("ChatFrame1:ScrollUp()").unwrap();
s.resolve();
let a2 = text_color(&s.extract(), "L1").expect("L1 still visible")[3];
assert!(close(a2, 1.0), "the scroll brought the line back: {a2}");
s.tick(2.0);
s.resolve();
let a3 = text_color(&s.extract(), "L1").expect("L1 still visible")[3];
assert!(close(a3, 1.0), "frozen while scrolled up: {a3}");
s.run("ChatFrame1:ScrollToBottom()").unwrap();
s.tick(1.0);
s.resolve();
let a4 = text_color(&s.extract(), "L1").expect("L1 visible")[3];
assert!(a4 < 1.0 && a4 > 0.0, "the fade resumed at the bottom: {a4}");
assert!(close(a4, a1), "and from a full re-arm: {a4} vs {a1}");
}
#[test]
fn input_editbox_enter_drains_the_typed_line() {
let mut s = chat_frame();
assert!(s.focus_editbox("ChatFrameEditBox"), "the edit box focuses");
assert!(s.has_keyboard_focus(), "focus gates the world's keys");
s.char_input("/yell hi");
assert!(s.key_input("ENTER"), "the box consumes ENTER");
// The reference's own path: `ChatEdit_OnEnterPressed` → `ChatEdit_SendText` →
// `ChatEdit_ParseText` → `SendChatMessage("hi", "YELL", …)`.
let sends = s.take_chat_sends();
assert_eq!(
sends
.iter()
.map(|c| (c.text.as_str(), c.chat_type.as_str()))
.collect::<Vec<_>>(),
vec![("hi", "YELL")]
);
assert!(
!s.has_keyboard_focus(),
"submit closes the box (ChatEdit_OnEscapePressed: ClearFocus + Hide)"
);
assert!(s.take_chat_sends().is_empty(), "drained");
}
#[test]
fn input_escape_closes_without_submitting() {
let mut s = chat_frame();
assert!(s.focus_editbox("ChatFrameEditBox"));
s.char_input("hello");
assert!(s.key_input("ESCAPE"), "the box consumes ESCAPE");
assert!(s.take_chat_input().is_empty(), "escape submits nothing");
assert!(!s.has_keyboard_focus(), "escape closes the box");
}
/// The shipped chat box takes plain arrows and history recall — end-to-end over the real XML.
/// Guards the exact in-game gap the director hit: the ref template's `ignoreArrows="true"` (which
/// benilla deliberately drops — plain arrows edit; the divergence note in ChatFrame.xml) would
/// leave LEFT/RIGHT consumed-but-dead here, and an unpushed history would leave Up/Down empty.
#[test]
fn chat_box_arrows_edit_and_history_recalls() {
use benilla_ui::script::{EditAction, EditUnit};
let mut s = chat_frame();
assert!(s.focus_editbox("ChatFrameEditBox"));
s.char_input("ab");
// The reference's ChatFrameEditBoxTemplate is `ignoreArrows="true"` — the engine's
// AltArrowKeyMode, which the app's key routing reads to hand a plain arrow to the bindings
// (the character turns while you type) rather than to the box. The box itself still edits.
assert!(
s.editbox_alt_arrow_mode(),
"the stock template's ignoreArrows landed as AltArrowKeyMode on the focused box"
);
s.editbox_action(EditAction::Delete {
unit: EditUnit::Char,
back: true,
});
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
"a"
);
s.run("ChatFrameEditBox:SetText('')").unwrap();
s.char_input("/yell hi");
assert!(s.key_input("ENTER"));
// `ChatEdit_SendText` → `ChatEdit_ParseText` → the YELL type's send, and the reference's
// `AddHistoryLine` on the way.
let sends = s.take_chat_sends();
assert_eq!(
sends
.iter()
.map(|c| (c.text.as_str(), c.chat_type.as_str()))
.collect::<Vec<_>>(),
vec![("hi", "YELL")]
);
assert!(s.focus_editbox("ChatFrameEditBox"));
s.char_input("dra");
s.editbox_action(EditAction::HistoryPrev);
// `ChatEdit_AddHistory` filed `SLASH_YELL1 .. " " .. text` = "/y hi" (ChatFrame.lua
// l.1913-1937); the recall lands it in the box, and the box's own `OnTextChanged` live parse
// (`ChatEdit_ParseText(this, 0)`) turns it straight into the YELL type with "hi" — which is
// what the player sees: a "Yell:" header and the text.
assert!(
s.eval::<bool>(
"return ChatFrameEditBox.chatType == 'YELL' and ChatFrameEditBox:GetText() == 'hi'"
)
.unwrap(),
"Up recalls the filed line, live-parsed: {:?} {:?}",
s.eval::<String>("return ChatFrameEditBox.chatType")
.unwrap(),
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap()
);
s.editbox_action(EditAction::HistoryNext);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
"dra",
"Down past the newest restores the in-progress draft"
);
}
/// Decision 0843 — the chat body as a dismiss surface, the director's stuck-spell gesture end to
/// end through the shipped XML: a spell dragged out of the spellbook and RELEASED over the chat
/// keeps carrying (a drag release is never a click — 0218's byte-verified trigger), the follow-up
/// completed LEFT CLICK on the chat body dismisses it, and the same click leaves an ITEM payload
/// untouched (a silent item dismissal would be a destroy — only the world-drop popup offers that).
#[test]
fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
use benilla_ui::script::{
ContainerSlot, ContainerState, SpellBookState, SpellSlotView, SpellTabView,
};
// The reference's spellbook wants the action-bar chain beneath it (1952); the chat window
// loads after it, as in the manifest.
let mut s = super::spellbook_tests::spellbook_ui(1024.0, 768.0);
for f in [
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
load_xml(&s, f);
}
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.set_spellbook(SpellBookState {
tabs: vec![SpellTabView {
name: "Fire".into(),
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
offset: 0,
num_spells: 1,
}],
slots: vec![SpellSlotView {
spell_id: 133,
name: "Fireball".into(),
rank: Some("Rank 1".into()),
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
passive: false,
current: false,
cooldown: None,
..Default::default()
}],
});
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
s.resolve();
// Drag Fireball off its book button (press → past the 4px threshold → the payload is up).
let (l, r, t, b) = (
s.eval::<f32>("return SpellButton1:GetLeft()").unwrap(),
s.eval::<f32>("return SpellButton1:GetRight()").unwrap(),
s.eval::<f32>("return SpellButton1:GetTop()").unwrap(),
s.eval::<f32>("return SpellButton1:GetBottom()").unwrap(),
);
let (x1, y1) = ((l + r) * 0.5, (t + b) * 0.5);
s.mouse_button(x1, y1, "LeftButton", true);
s.mouse_move(x1 + 20.0, y1);
assert!(
s.cursor_payload().is_some(),
"OnDragStart picked the spell up"
);
// Release the DRAG over the chat body: keeps carrying (OnClick never fires on a drag).
let (cx, cy) = (200.0, 150.0); // inside ChatFrame1 (BOTTOMLEFT 32,85 + 430×120)
s.mouse_move(cx, cy);
s.mouse_button(cx, cy, "LeftButton", false);
assert!(
s.cursor_payload().is_some(),
"a drag release over the chat keeps carrying"
);
// The completed click on the chat body dismisses the stuck spell.
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_button(cx, cy, "LeftButton", false);
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(
s.cursor_payload().is_none(),
"a chat click dismisses a spell payload"
);
// An ITEM payload survives the same click untouched.
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
quality: Some(1),
item_id: 117,
link: None,
locked: false,
equip_slots: Vec::new(),
cooldown: None,
readable: false,
creator: None,
flags: 0,
enchants: Vec::new(),
},
);
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}),
);
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some(), "fixture: the item is held");
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_button(cx, cy, "LeftButton", false);
assert!(
s.cursor_item().is_some(),
"a chat click never touches an item payload"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
// ── ChatTypeInfo: the addon-facing color table ────────────────────────────────────────────────
/// `ChatTypeInfo` carries the shipped default chat colors twice: once in `assets/ui/ChatFrame.xml`
/// for addons to read, and once in [`crate::ui_chat::default_color`] for our own feed to render.
/// Both are the same wow-re byte table (`chat-color-table.md`, the static registry at
/// `.rdata 0x804710`) — so this is the gate that makes the duplication safe: every kind we model
/// must agree to the byte, and the table's shape (`sticky`/`id`) must be the reference's.
#[test]
fn chat_type_info_matches_the_host_color_table() {
use crate::ui_chat::{default_color, ChatEventKind as K};
/// Each modeled kind and its `ChatTypeInfo` key — the reference's own spellings.
const PAIRS: &[(&str, K, i64)] = &[
("SAY", K::Say, 1),
("PARTY", K::Party, 2),
("RAID", K::Raid, 3),
("GUILD", K::Guild, 4),
("OFFICER", K::Officer, 5),
("YELL", K::Yell, 6),
("WHISPER", K::Whisper, 7),
("WHISPER_INFORM", K::WhisperInform, 8),
("EMOTE", K::Emote, 9),
("TEXT_EMOTE", K::TextEmote, 10),
("SYSTEM", K::System, 11),
("MONSTER_SAY", K::MonsterSay, 12),
("MONSTER_YELL", K::MonsterYell, 13),
("MONSTER_EMOTE", K::MonsterEmote, 14),
("MONSTER_WHISPER", K::MonsterWhisper, 27),
("CHANNEL", K::Channel, 15),
("CHANNEL_JOIN", K::ChannelJoin, 16),
("CHANNEL_LEAVE", K::ChannelLeave, 17),
("CHANNEL_NOTICE", K::ChannelNotice, 19),
("CHANNEL_NOTICE_USER", K::ChannelNoticeUser, 20),
("CHANNEL_LIST", K::ChannelList, 18),
("AFK", K::Afk, 21),
("DND", K::Dnd, 22),
("IGNORED", K::Ignored, 23),
("SKILL", K::Skill, 24),
("LOOT", K::Loot, 25),
("MONEY", K::Money, 87),
("COMBAT_XP_GAIN", K::CombatXpGain, 46),
("RAID_LEADER", K::RaidLeader, 88),
("RAID_WARNING", K::RaidWarning, 89),
("RAID_BOSS_EMOTE", K::RaidBossEmote, 91),
("BATTLEGROUND", K::Battleground, 93),
("BATTLEGROUND_LEADER", K::BattlegroundLeader, 94),
("BG_SYSTEM_NEUTRAL", K::BgSystemNeutral, 83),
("BG_SYSTEM_ALLIANCE", K::BgSystemAlliance, 84),
("BG_SYSTEM_HORDE", K::BgSystemHorde, 85),
];
let s = chat_frame();
for (name, kind, want_id) in PAIRS {
let (r, g, b, id): (f64, f64, f64, i64) = s
.eval(&format!(
r#"local i = ChatTypeInfo["{name}"] return i.r, i.g, i.b, i.id"#
))
.unwrap_or_else(|e| panic!(r#"ChatTypeInfo["{name}"]: {e}"#));
let got = [
(r * 255.0).round() as u8,
(g * 255.0).round() as u8,
(b * 255.0).round() as u8,
];
assert_eq!(got, default_color(*kind), r#"ChatTypeInfo["{name}"] color"#);
// `id` is GetChatTypeIndex's 1-based registry slot, asserted EXACTLY rather than as a
// range: four of these ship the same FFDBB7 and a range check would let a transposition
// inside that family through with both colors still matching.
assert_eq!(id, *want_id, r#"ChatTypeInfo["{name}"].id"#);
}
}
/// The table's *shape*, transcribed rather than derived: the reference's 105 keys, its five sticky
/// types (the set `ui_chat::edit`'s `SendType::sticky` already quotes), the two keys FrameXML
/// declares that the engine's color registry does not contain, and the ten boot-seeded channel
/// extras. An addon reads `.sticky` and `.id` as often as it reads the color.
#[test]
fn chat_type_info_has_the_references_shape() {
let s = chat_frame();
let count: i64 = s
.eval("local n = 0 for _ in pairs(ChatTypeInfo) do n = n + 1 end return n")
.unwrap();
assert_eq!(count, 105, "the reference declares 105 keys");
let sticky: String = s
.eval(
"local t = {} for k, v in pairs(ChatTypeInfo) do if v.sticky == 1 then \
table.insert(t, k) end end table.sort(t) return table.concat(t, \" \")",
)
.unwrap();
assert_eq!(sticky, "BATTLEGROUND GUILD PARTY RAID SAY");
// REPLY and COMBAT_ERROR are declared by FrameXML and absent from the engine's 94-entry
// registry, so GetChatTypeIndex answers 0 for both — but their COLORS differ, which is the
// trap this asserts. Nothing ever writes COMBAT_ERROR. REPLY is written by hand inside the
// UPDATE_CHAT_COLOR handler, which mirrors WHISPER into it (ChatFrame.lua l.1357-1365), so
// its end state is WHISPER's FF80FF. Seeding both white reads as symmetric and is wrong.
for (name, want) in [
("REPLY", [255u8, 128, 255]),
("COMBAT_ERROR", [255, 255, 255]),
] {
let (id, r, g, b): (i64, f64, f64, f64) = s
.eval(&format!(
r#"local i = ChatTypeInfo["{name}"] return i.id, i.r, i.g, i.b"#
))
.unwrap();
assert_eq!(id, 0, r#""{name}".id — not in the engine's registry"#);
let got = [
(r * 255.0).round() as u8,
(g * 255.0).round() as u8,
(b * 255.0).round() as u8,
];
assert_eq!(got, want, r#""{name}" color"#);
}
// The extras: CHANNEL1..CHANNEL10, indices 95..104, each the live CHANNEL entry's FFC0C0.
for n in 1..=10 {
let (id, r, g, b): (i64, f64, f64, f64) = s
.eval(&format!(
r#"local i = ChatTypeInfo["CHANNEL{n}"] return i.id, i.r, i.g, i.b"#
))
.unwrap();
assert_eq!(id, 94 + n, "CHANNEL{n}.id");
let rgb = [
(r * 255.0).round() as u8,
(g * 255.0).round() as u8,
(b * 255.0).round() as u8,
];
assert_eq!(rgb, [255, 192, 192], "CHANNEL{n} color");
}
}
/// **Every event name we fire is a key the reference's own `ChatTypeInfo` carries.**
/// `ChatFrame_OnEvent` recovers the type with `strsub(event, 10)` and indexes
/// `ChatTypeInfo[type]` with it — a name that misses that table is a name whose colour, id and
/// sticky flag an addon cannot look up, so this is the check that our `CHAT_MSG_*` spellings are
/// the reference's and not ours.
///
/// Swept over `ChatEventKind::ALL` rather than a second hand-written list, so a kind added without
/// a matching table key fails here instead of shipping a name nothing can resolve.
#[test]
fn fired_event_names_are_all_chat_type_info_keys() {
use crate::ui_chat::{event_name, ChatEventKind as K};
let s = chat_frame();
for &kind in K::ALL {
let name = event_name(kind);
let key = name
.strip_prefix("CHAT_MSG_")
.unwrap_or_else(|| panic!("{name}: every fired chat event is CHAT_MSG_-prefixed"));
let present: bool = s
.eval(&format!(r#"return ChatTypeInfo["{key}"] ~= nil"#))
.unwrap();
assert!(present, r#"{name} → ChatTypeInfo["{key}"] is missing"#);
}
}
// ── The seven chat windows (NUM_CHAT_WINDOWS) ────────────────────────────────────────────────
//
// benilla shipped two windows against a constant the reference sets to seven. Everything below is
// a claim about the five that were missing, checked against the shipped `ChatFrame.xml` itself
// rather than against the loader's idea of it.
/// `NUM_CHAT_WINDOWS = 7` (ref ChatFrame.lua l.5) and every index it promises resolves to a real
/// `ScrollingMessageFrame`. _LazyPig walks exactly this loop and indexes the result with no nil
/// guard (`LazyPig.lua:1992`), so the constant without the frames is worse than neither.
#[test]
fn every_window_num_chat_windows_promises_is_a_real_frame() {
let s = chat_frame();
assert_eq!(s.eval::<i64>("return NUM_CHAT_WINDOWS").unwrap(), 7);
for i in 1..=7 {
let ok: bool = s
.eval(&format!(
"local f = getglobal('ChatFrame{i}') \
return f ~= nil and f.AddMessage ~= nil and f:GetID() == {i}"
))
.unwrap();
assert!(ok, "ChatFrame{i} is a real message frame carrying its id");
}
}
/// The corpus walk itself, verbatim from `_LazyPig/LazyPig.lua:1992` down to the unguarded
/// `getglobal(...):IsVisible()` that used to die at i=3.
#[test]
fn the_lazypig_window_walk_survives_all_seven_indices() {
let s = chat_frame();
let visible: i64 = s
.eval(
"local n = 0\n\
for i = 1, NUM_CHAT_WINDOWS do\n\
local ChatFrame = getglobal('ChatFrame'..i)\n\
if ChatFrame:IsVisible() then n = n + 1 end\n\
end\n\
return n",
)
.unwrap();
assert_eq!(visible, 1, "only the selected dock window is visible");
}
/// ChatFrame3..7 ship hidden and with no `isDocked`, the reference's own chat-cache state
/// (`DOCKED 0 / SHOWN 0`). This is not cosmetic: `Outfitter.lua:3099` reaches an UNGUARDED
/// `getglobal('ChatFrame'..i..'Tab'):GetText()` for any window that is visible *or* docked, and we
/// build no tabs past ChatFrame2Tab.
///
/// **ChatFrame1 and ChatFrame2 DO carry it**, and that is the half this test gained with the
/// move/resize arc: the reference's own gates read `chatFrame.isDocked` — `isDocked and chatFrame
/// ~= DEFAULT_CHAT_FRAME` is what stops the Combat Log being dragged out of the dock it is
/// anchored into — so the flag has a consumer now rather than being an advertisement. Both have
/// tabs (1575), so the Outfitter walk below is still safe.
#[test]
fn the_undocked_windows_are_hidden_and_carry_no_is_docked() {
let s = chat_frame();
for i in 1..=2 {
let docked: bool = s
.eval(&format!("return ChatFrame{i}.isDocked ~= nil"))
.unwrap();
assert!(docked, "ChatFrame{i} is docked and says so");
}
for i in 2..=7 {
let shown: bool = s.eval(&format!("return ChatFrame{i}:IsShown()")).unwrap();
assert!(!shown, "ChatFrame{i} ships hidden");
}
for i in 3..=7 {
let docked: bool = s
.eval(&format!("return ChatFrame{i}.isDocked ~= nil"))
.unwrap();
assert!(!docked, "ChatFrame{i} carries no isDocked");
}
// And therefore the Outfitter walk never reaches a tab that does not exist.
let ok: bool = s
.eval(
"for i = 1, NUM_CHAT_WINDOWS do\n\
local f = getglobal('ChatFrame'..i)\n\
if f and (f:IsVisible() or f.isDocked) then\n\
local tab = getglobal('ChatFrame'..i..'Tab')\n\
if not tab then return false end\n\
local _ = tab:GetText()\n\
end\n\
end\n\
return true",
)
.unwrap();
assert!(ok, "the Outfitter tab walk never touches a missing tab");
}
/// `GetChatWindowInfo`'s `shown` is not an independent opinion — it must agree with the frame the
/// shipped XML actually built, or an addon that trusts the getter and an addon that trusts the
/// frame will disagree about the same window. The drift guard between the Rust table and the XML.
#[test]
fn get_chat_window_info_shown_matches_the_shipped_frames() {
let s = chat_frame();
for i in 1..=7 {
let agrees: bool = s
.eval(&format!(
// Both sides are normalised to a boolean before comparing: `IsShown` answers the
// NUMBER 1 or nil (1830), so comparing it straight against `shown ~= nil` compares
// a number with a boolean and is false for every window. This test is about the two
// AGREEING, not about either one's shape.
"local _, _, _, _, _, _, shown = GetChatWindowInfo({i})\n\
return (shown ~= nil) == (ChatFrame{i}:IsShown() ~= nil)"
))
.unwrap();
assert!(
agrees,
"window {i}: GetChatWindowInfo disagrees with the frame"
);
}
}
/// A named debug sink is a real ring of its own: `ChatFrame3:AddMessage` (IgniteStatus does this
/// seven times, TipBuddy once, Radar guarded, and AceDebug's `debugFrame` stores the frame) lands
/// in ChatFrame3 and nowhere near the window the player is reading.
#[test]
fn a_line_added_to_chat_frame3_lands_in_chat_frame3_only() {
let mut s = chat_frame();
s.add_chat_message("ChatFrame1", "a real line", 1.0, 1.0, 1.0);
s.run("ChatFrame3:AddMessage('Radar: debug', 1, 1, 0)")
.unwrap();
assert_eq!(
s.eval::<i64>("return ChatFrame3:GetNumMessages()").unwrap(),
1
);
assert_eq!(
s.eval::<i64>("return ChatFrame1:GetNumMessages()").unwrap(),
1
);
// Hidden, so nothing of it reaches the screen.
s.resolve();
assert!(
text_color(&s.extract(), "Radar: debug").is_none(),
"a hidden window renders nothing"
);
}
/// `FCF_SelectDockFrame(frame)` — the corpus idiom (CustomNameplates.lua:69, Roid-Macros
/// Utility.lua:40) is "un-hide the default chat frame before printing into it". It takes a FRAME,
/// where `BenillaFCF_SelectDock` takes a dock id.
///
/// **It used to RAISE on an undocked window and no longer does** (1714). The raise was right for a
/// client whose dock was the literal `for i = 1, 2`: "select ChatFrame5" had no meaning, and
/// answering with silence would have left the caller's next `AddMessage` in a still-hidden window.
/// With a real `DOCKED_CHAT_FRAMES` the reference's own answer applies — the assignment, then
/// `FCF_DockUpdate` hiding every docked window (none of them is the selection) and leaving the
/// undocked one exactly as it was. Not a nice answer, but the client's; and neither corpus caller
/// reaches it, since both pass `DEFAULT_CHAT_FRAME`.
#[test]
fn fcf_select_dock_frame_selects_by_frame_and_leaves_an_undocked_one_alone() {
let s = chat_frame();
s.run("FCF_SelectDockFrame(ChatFrame2)").unwrap();
let (one, two): (bool, bool) = (
s.eval("return ChatFrame1:IsShown()").unwrap(),
s.eval("return ChatFrame2:IsShown()").unwrap(),
);
assert!(!one && two, "selecting the Combat Log swaps the dock");
s.run("if not DEFAULT_CHAT_FRAME:IsVisible() then FCF_SelectDockFrame(DEFAULT_CHAT_FRAME) end")
.unwrap();
assert!(
s.eval::<bool>("return ChatFrame1:IsShown()").unwrap(),
"the corpus guard brings the default frame back"
);
// An undocked window: no raise, and the reference's consequence — the dock's members all
// hide, because none of them is the selection, and ChatFrame5 is untouched (still hidden,
// since nothing showed it).
let shown5_before: bool = s
.eval("return ChatFrame5:IsShown() and true or false")
.unwrap();
s.run("FCF_SelectDockFrame(ChatFrame5)").unwrap();
assert_eq!(
s.eval::<String>("return SELECTED_DOCK_FRAME:GetName()")
.unwrap(),
"ChatFrame5"
);
assert!(
!s.eval::<bool>("return ChatFrame1:IsShown()").unwrap()
&& !s.eval::<bool>("return ChatFrame2:IsShown()").unwrap(),
"FCF_DockUpdate hides every docked window when the selection is not one of them"
);
assert_eq!(
s.eval::<bool>("return ChatFrame5:IsShown() and true or false")
.unwrap(),
shown5_before,
"an undocked window is not in the loop, so nothing shows or hides it"
);
}
/// **The idle dock writes nothing** (decision 1396). `FCF_OnUpdate`'s apply block used to run
/// unconditionally — ~24 `getglobal`s, two table builds and ~20 `SetAlpha` calls every frame of
/// every session, writing the values that were already there, at 36 µs/frame (1395 measured it as
/// the largest single handler in the client).
///
/// The probe is a sentinel the applier can never write: it only ever writes `{0, 0.5, 1.0} × reveal`,
/// and `reveal` is pinned at 0 with the cursor away from the dock. If the gate is removed, the very
/// next tick overwrites 0.42 with 0 and this goes red.
#[test]
fn an_idle_dock_stops_rewriting_the_tab_alpha_every_frame() {
let mut s = chat_frame();
s.mouse_move(1500.0, 850.0); // far from the dock: no hover, so `reveal` stays 0
for _ in 0..8 {
s.tick(0.016); // let the label measurements land and the applier reach its resting write
s.resolve();
}
s.run("ChatFrame1Tab:SetAlpha(0.42)").unwrap();
for _ in 0..10 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
let alpha: f64 = s.eval("return ChatFrame1Tab:GetAlpha()").unwrap();
assert!(
(alpha - 0.42).abs() < 1e-6,
"a settled dock must not rewrite its tab alpha — got {alpha}"
);
}
/// The control for the guard above: the gate must not cost the reveal it is gating. A stationary
/// cursor over the dock for longer than `CHAT_TAB_SHOW_DELAY` fades the selected tab to full.
#[test]
fn hovering_the_dock_still_reveals_the_tabs() {
let mut s = chat_frame();
s.mouse_move(1500.0, 850.0);
for _ in 0..4 {
s.tick(0.016);
s.resolve();
}
assert!(
!s.eval::<bool>("return ChatFrame1Tab:IsVisible()").unwrap(),
"the dock starts concealed — the tab template ships hidden"
);
let (x, y): (f32, f32) = s
.eval(
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
(ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2",
)
.unwrap();
s.mouse_move(x, y);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
let alpha: f64 = s.eval("return ChatFrame1Tab:GetAlpha()").unwrap();
assert!(
s.eval::<bool>("return ChatFrame1Tab:IsVisible()").unwrap() && (alpha - 1.0).abs() < 1e-6,
"a stationary hover past CHAT_TAB_SHOW_DELAY reveals the selected tab at full alpha — got {alpha}"
);
}
/// **A view at the bottom writes nothing** — `ChatFrame_OnUpdate`'s at-bottom branch used to call
/// `flash:Hide()` and zero `flashTimer` every frame; the 1.12 reference gates both behind
/// `flash:IsVisible()` (1396's residual item 3, now restored — our `flashTimer = 0` rides inside
/// the reference's gate, so a return-to-bottom during a hidden blink phase keeps the residual
/// phase, a divergence that costs nothing at rest).
///
/// The probe is a sentinel the gated branch can only zero: `flashTimer = 0.42` with the flash
/// hidden survives ten ticks. The controls: scrolled up, the bottom-button blink still runs its
/// 0.5s toggle; and scrolling back while the flash is LIT still hides it and zeroes the timer.
#[test]
fn a_chat_view_at_the_bottom_stops_rewriting_the_flash() {
let mut s = chat_frame();
for t in ["L0", "L1", "L2"] {
s.add_chat_message("ChatFrame1", t, 1.0, 1.0, 1.0);
}
for _ in 0..3 {
s.tick(0.016);
s.resolve();
}
s.run("ChatFrame1.flashTimer = 0.42").unwrap();
for _ in 0..10 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
let timer: f64 = s.eval("return ChatFrame1.flashTimer").unwrap();
assert!(
(timer - 0.42).abs() < 1e-6,
"a settled view must not rewrite flashTimer every frame — got {timer}"
);
assert!(
!s.eval::<bool>("return ChatFrame1BottomButtonFlash:IsVisible()")
.unwrap(),
"the flash stays hidden at rest"
);
// The reference's ChatFrameTemplate takes no mouse and has no wheel script (1.12 scrolls its
// chat by the buttons), so the scroll is the frame's own verb.
s.run("ChatFrame1:ScrollUp()").unwrap();
s.tick(0.3); // 0.42 + 0.3 = 0.72 >= CHAT_BUTTON_FLASH_TIME -> toggle on
s.resolve();
assert!(
s.eval::<bool>("return ChatFrame1BottomButtonFlash:IsVisible()")
.unwrap(),
"scrolled up, the bottom-button blink lights"
);
s.run("ChatFrame1:ScrollToBottom()").unwrap();
s.tick(0.016);
s.resolve();
assert!(
!s.eval::<bool>("return ChatFrame1BottomButtonFlash:IsVisible()")
.unwrap(),
"returning to the bottom hides a lit flash"
);
// …and leaves the residual phase where it was: the reference's at-bottom arm hides the flash
// and returns, it never zeroes the timer (ChatFrame.lua `ChatFrame_OnUpdate`).
let timer: f64 = s.eval("return ChatFrame1.flashTimer").unwrap();
assert!(
(timer - 0.22).abs() < 1e-6,
"the residual phase stands: {timer}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Selecting the Combat Log must not stop the dock.** The falsification for decision 1588.
///
/// `FCF_OnUpdate` is the dock's whole driver — the hover reveal, both tabs' alphas, the label
/// settle, the whisper blink — and it used to ride `ChatFrame1`'s own `OnUpdate`.
/// `BenillaFCF_SelectDock(2)` hides ChatFrame1, and a hidden frame gets no `OnUpdate`
/// ([`UiScript::tick`] runs only effectively-visible frames, which is the reference's rule), so the
/// driver died at the exact moment the feature it drives was first used: the tabs froze mid-write,
/// still showing General as the selected one. That is what the director saw and reported, and every
/// test we had passed straight through it — including this file's two dock tests, because both only
/// ever exercise window 1.
///
/// The trap this walks around: calling `FCF_OnUpdate()` by hand — which the first live probe did,
/// and which every earlier test does — bypasses the visibility gate and reports healthy alphas on a
/// dead driver. So this drives the clock and nothing else.
#[test]
fn selecting_the_combat_log_keeps_the_dock_driver_running() {
let mut s = chat_frame();
let (x, y): (f32, f32) = s
.eval(
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
(ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2",
)
.unwrap();
s.mouse_move(x, y);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
assert!(
close(
s.eval::<f32>("return ChatFrame1Tab:GetAlpha()").unwrap(),
1.0
) && close(
s.eval::<f32>("return ChatFrame2Tab:GetAlpha()").unwrap(),
0.5
),
"the hovered dock starts with General selected — this test must not pass vacuously"
);
s.run("FCF_SelectDockFrame(ChatFrame2)").unwrap();
for _ in 0..10 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(
s.eval::<bool>("return ChatFrame2:IsShown() and not ChatFrame1:IsShown()")
.unwrap(),
"the selection swapped the windows"
);
let (a1, a2): (f32, f32) = s
.eval("return ChatFrame1Tab:GetAlpha(), ChatFrame2Tab:GetAlpha()")
.unwrap();
assert!(
close(a1, 0.5) && close(a2, 1.0),
"the selected tab is the one at full alpha — got General {a1}, Combat Log {a2}"
);
}
/// The other half of the same freeze, and the one a screenshot cannot show: with the driver dead,
/// `reveal` never decayed either, so the chat box and both tabs stayed lit after the cursor left.
#[test]
fn the_dock_still_fades_out_with_the_combat_log_selected() {
let mut s = chat_frame();
let (x, y): (f32, f32) = s
.eval(
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
(ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2",
)
.unwrap();
s.mouse_move(x, y);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
s.run("FCF_SelectDockFrame(ChatFrame2)").unwrap();
for _ in 0..10 {
s.tick(0.016);
s.resolve();
}
assert!(
close(
s.eval::<f32>("return ChatFrame2Tab:GetAlpha()").unwrap(),
1.0
),
"revealed before the cursor leaves"
);
s.mouse_move(1500.0, 850.0); // away from the dock
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
// The reference fades the tab out and `FCF_ChatTabFadeFinished` hides it.
assert!(
!s.eval::<bool>("return ChatFrame2Tab:IsVisible()").unwrap(),
"the dock conceals itself again on leave"
);
}
/// The Combat Log window has the scroll column too — the third part of window 2 that was simply
/// never authored (after the tab, 1575, and the border art, 1579). It is a behavioural check, not
/// a name check: scroll up and the bottom button's flash must start blinking on window 2's own
/// button, which only happens if window 2 both HAS the button and runs `ChatFrame_OnUpdate`.
#[test]
fn the_combat_log_window_runs_its_own_bottom_button_blink() {
let mut s = chat_frame();
s.run("FCF_SelectDockFrame(ChatFrame2)").unwrap();
for i in 0..40 {
s.add_chat_message("ChatFrame2", &format!("line {i}"), 1.0, 1.0, 1.0);
}
s.run("ChatFrame2:ScrollUp()").unwrap();
assert!(
!s.eval::<bool>("return ChatFrame2:AtBottom()").unwrap(),
"scrolled off the bottom — this test must not pass vacuously"
);
// CHAT_BUTTON_FLASH_TIME is 0.5 s; 40 frames of 16 ms crosses it.
for _ in 0..40 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(
s.eval::<bool>("return ChatFrame2BottomButtonFlash:IsVisible()")
.unwrap(),
"window 2's bottom-button flash blinks while it is scrolled up"
);
}
/// **The chat menu runs on the REFERENCE's UIMenu kit** — `Interface\FrameXML\UIMenu.xml` and its
/// own `UIMenu.lua`, not a transcription of them.
///
/// Ours kept the reference's ten names in `ChatFrame.xml`, which loads at manifest 939 against the
/// chain's 194, so our copies overwrote the chain's from the day that entry landed — the reverse of
/// 1855's direction, and nothing drove this menu in a test, so nothing said so. Decision 1869.
#[test]
fn the_chat_menu_builds_its_rows_on_the_references_kit() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in [
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml",
r"Interface\FrameXML\UIMenu.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\UIMenu.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
load_xml(&s, f);
}
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
let _ = s.errors();
// `ChatMenu_OnLoad` calls `UIMenu_Initialize()` bare, relying on `this` — the reference's own
// idiom — then `UIMenu_AddButton` per row. If the kit were missing, the rows would not exist.
s.run("ChatMenu:Show()").unwrap();
assert!(s.errors().is_empty(), "opening it raises: {:?}", s.errors());
assert!(
s.eval::<i64>("return ChatMenu.numButtons or 0").unwrap() >= 7,
"the reference's `UIMenu_AddButton` counted the rows this window adds"
);
assert_eq!(
s.eval::<String>("return ChatMenuButton1:GetText()")
.unwrap(),
"Say",
// The reference labels a row with `button:SetText(text)` — the Button's own text, not a
// named FontString, so there is no `…Button1Text` to read.
"row 1 is the Say row, built by the chain's kit"
);
// A row click runs the owner's func and closes the menu — `UIMenuButton_OnClick`'s contract.
s.run("ChatMenuButton1:Click()").unwrap();
assert!(
s.errors().is_empty(),
"a row click raises: {:?}",
s.errors()
);
assert!(
!s.eval::<bool>("return ChatMenu:IsShown() and true or false")
.unwrap(),
"the reference's row click hides the menu"
);
// `ChatMenu_Say` → `ChatMenu_SetChatType(chatFrame, "SAY")`: the box opens and its type is
// set directly — no slash is typed into it (ChatFrame.lua l.2245-2255).
assert!(
s.eval::<bool>(
"return ChatFrameEditBox:IsVisible() and ChatFrameEditBox.chatType == 'SAY'"
)
.unwrap(),
"and it opened the edit box as SAY"
);
}
/// **A glass window's plate fades in on every hover, not only the first.** A restored window
/// (the cache's `COLOR 0 0 0 0`, the stock row) keeps its nine plate textures at alpha 0 and
/// relies on `FCF_OnUpdate`'s hover arm to lift them to `DEFAULT_CHATFRAME_ALPHA` and drop them
/// back (FloatingChatFrame.lua l.873-877, l.913-916). The director's report (2026-09-05): hovering
/// the chat window shows the tabs and the buttons but no plate.
#[test]
fn a_glass_windows_plate_fades_in_on_every_hover() {
let mut s = chat_frame();
s.mouse_move(1500.0, 850.0);
for _ in 0..4 {
s.tick(0.016);
s.resolve();
}
let bg =
|s: &mut UiScript| -> f64 { s.eval("return ChatFrame1Background:GetAlpha()").unwrap() };
assert_eq!(bg(&mut s), 0.0, "a glass window rests at alpha 0");
let (x, y): (f32, f32) = s
.eval(
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
(ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2",
)
.unwrap();
for round in 1..=3 {
s.mouse_move(x, y);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
let a = bg(&mut s);
assert!(
(a - 0.25).abs() < 1e-6,
"hover {round}: the plate fades to DEFAULT_CHATFRAME_ALPHA — got {a} (oldAlpha={:?})",
s.eval::<Option<f64>>("return ChatFrame1.oldAlpha").unwrap()
);
s.mouse_move(1500.0, 850.0);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
let a = bg(&mut s);
assert!(
a.abs() < 1e-6,
"leave {round}: the plate fades back to its saved alpha — got {a}"
);
}
}
/// The same law under the WHOLE shipped manifest, driven the way the app drives it.
#[test]
fn a_glass_windows_plate_fades_in_on_every_hover_under_the_full_manifest() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "{failures:?}");
s.resolve();
s.mouse_move(1500.0, 850.0);
for _ in 0..4 {
s.tick(0.016);
s.resolve();
}
super::fire_chat_login(&mut s);
s.resolve();
let bg =
|s: &mut UiScript| -> f64 { s.eval("return ChatFrame1Background:GetAlpha()").unwrap() };
assert_eq!(bg(&mut s), 0.0, "a glass window rests at alpha 0");
let (x, y): (f32, f32) = s
.eval(
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
(ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2",
)
.unwrap();
for round in 1..=3 {
s.mouse_move(x, y);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
// The plate as the renderer receives it: one Background quad over ChatFrame1's rect at
// the faded alpha, black.
let plates: Vec<(Option<benilla_ui::layout::Rect>, f32, Option<[f32; 4]>)> = s
.extract()
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color,
..
} if p.to_ascii_lowercase().contains("chatframebackground") => {
Some((q.rect, q.alpha, *color))
}
_ => None,
})
.collect();
let (left, bottom): (f32, f32) = s
.eval("return ChatFrame1:GetLeft(), ChatFrame1:GetBottom()")
.unwrap();
// The texture's own anchors: TOPLEFT (-2, 3) and BOTTOMLEFT (-2, -6) off the frame.
let over_frame1 = plates.iter().find(|(r, _, _)| {
r.is_some_and(|r| {
(r.left - (left - 2.0)).abs() < 1.0 && (r.bottom - (bottom - 6.0)).abs() < 1.0
})
});
assert!(
over_frame1.is_some_and(|(_, a, c)| {
(a - 0.25).abs() < 1e-3 && c.is_some_and(|c| c[0] == 0.0 && c[1] == 0.0 && c[2] == 0.0)
}),
"hover {round}: the extracted plate quad over ChatFrame1 ({left}, {bottom}) — {plates:?}"
);
let a = bg(&mut s);
assert!(
(a - 0.25).abs() < 1e-6,
"hover {round}: the plate fades to DEFAULT_CHATFRAME_ALPHA — got {a} (oldAlpha={:?}, hover={:?}, hasBeenFaded={:?}, init={:?})",
s.eval::<Option<f64>>("return ChatFrame1.oldAlpha").unwrap(),
s.eval::<Option<f64>>("return ChatFrame1.hover").unwrap(),
s.eval::<Option<f64>>("return ChatFrame1.hasBeenFaded").unwrap(),
s.eval::<Option<f64>>("return ChatFrame1.init").unwrap(),
);
s.mouse_move(1500.0, 850.0);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
let a = bg(&mut s);
assert!(
a.abs() < 1e-6,
"leave {round}: the plate fades back to its saved alpha — got {a}"
);
}
}
/// **The plate comes back after a quick exit and re-entry** (decision 1998 — the director's
/// report: an existing window shows tabs but no plate on hover; a new one shows the plate; the
/// tab menu's opacity slider, used once, makes the hover work again).
///
/// The stock `FCF_OnUpdate` traps itself: leave the window (the tabs' fade-out is queued with
/// `FCF_ChatTabFadeFinished` as its finished callback), re-enter inside `CHAT_FRAME_FADE_TIME`,
/// and the callback fires `chatFrame.oldAlpha = nil` under the live hover — after which the
/// plate arm (`oldAlpha < DEFAULT_CHATFRAME_ALPHA`) never passes and no leave clears `hover`, so
/// the hover-start re-read never runs either. Two VMs prove the guard is what makes the
/// difference: the bare chat stack (the reference's Lua alone) traps, the shipped manifest (the
/// guard installed by `bootstrap_positions`) does not. The control keeps this from passing
/// vacuously — if the stock Lua or the engine ever stop trapping, it says so, and the guard is
/// then a repair of nothing.
#[test]
fn a_quick_exit_and_reentry_keeps_the_plates_hover_fade() {
fn drive(s: &mut UiScript) -> (f64, f64, f64) {
let (x, y): (f32, f32) = s
.eval(
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
(ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2",
)
.unwrap();
let bg =
|s: &mut UiScript| -> f64 { s.eval("return ChatFrame1Background:GetAlpha()").unwrap() };
s.mouse_move(1500.0, 850.0);
for _ in 0..4 {
s.tick(0.016);
s.resolve();
}
// A stationary hover past CHAT_TAB_SHOW_DELAY and the ramp: the plate is up.
s.mouse_move(x, y);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
let first = bg(s);
// Out for ~50 ms — inside the 0.15 s fade-out — and back, then stationary.
s.mouse_move(1500.0, 850.0);
for _ in 0..3 {
s.tick(0.016);
s.resolve();
}
s.mouse_move(x, y);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
let reentry = bg(s);
// Away for a full second, then a fresh hover: whatever the re-entry left behind stays.
s.mouse_move(1500.0, 850.0);
for _ in 0..60 {
s.tick(0.016);
s.resolve();
}
s.mouse_move(x, y);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
let later = bg(s);
assert!(s.errors().is_empty(), "{:?}", s.errors());
(first, reentry, later)
}
// The control: the reference's Lua alone.
let mut bare = chat_frame();
let (first, reentry, later) = drive(&mut bare);
assert!((first - 0.25).abs() < 1e-6, "control, first hover: {first}");
assert!(
reentry.abs() < 1e-6 && later.abs() < 1e-6,
"the control must trap — the reference's Lua nils oldAlpha under a live hover \
(re-entry {reentry}, later {later}); if it no longer does, the guard is a repair of nothing"
);
assert!(
bare.eval::<bool>("return ChatFrame1Tab:IsVisible() and ChatFrame1.oldAlpha == nil and ChatFrame1.hover == 1")
.unwrap(),
"the trapped state the director described: tab up, oldAlpha nil, hover stuck"
);
// The shipped interface, with the guard the manifest load installs.
let mut shipped = UiScript::new().unwrap();
shipped.set_screen_size(1600.0, 900.0);
super::test_ui::load_ui(&shipped, "Interface\\FrameXML\\GlobalStrings.lua");
let failures = super::load_default_ui(&shipped);
assert!(failures.is_empty(), "{failures:?}");
shipped.resolve();
super::fire_chat_login(&mut shipped);
shipped.resolve();
let (first, reentry, later) = drive(&mut shipped);
assert!((first - 0.25).abs() < 1e-6, "shipped, first hover: {first}");
assert!(
(reentry - 0.25).abs() < 1e-6,
"shipped: the plate comes back on the quick re-entry — got {reentry}"
);
assert!(
(later - 0.25).abs() < 1e-6,
"shipped: and on every hover after — got {later}"
);
}