Files
benilla-octo/crates/benilla-app/src/ui_chat/tests.rs
T
Sam SungandClaude Opus 5.5 e12ca6769c /wave <name> targets the named player; two DoEmote gates follow 1.12.1 (#769)
Three ways `DoEmote` and the chat talk gesture differed from 1.12.1:

- `/wave Bob` ignored the name and targeted the selection. 1.12.1 resolves a name that does not start with `%` as a player, at any range (`0x49fdb1`-`0x49fdf0`, `0x493aa0`), and sends the emote untargeted on a miss. `DoEmote(token, name)` from Lua does the same.
- A polymorphed, stunned or sleeping speaker still played the talk gesture. `UNIT_FIELD_FLAGS 0x20000000` suppresses it when the sender's name is cached (`0x49d7f7`).
- `DoEmote` sent while our unit was possessed (`0x5ef57e`). A chat-only emote now reads `Emotes.dbc` row 0's flags, so it is refused while asleep or dead (`0x5ef5b1`, `0x47db8e`).

Tests cover the name, miss, `%t` and self cases, the gesture gate, and both `DoEmote` gates against the install's row 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 15:48:21 +01:00

2656 lines
95 KiB
Rust

use super::event::{default_color, ChatEvent, ChatEventKind as K};
use super::input::{emote_gate, emote_send_eligible, emote_target, EmoteGate, ParsedChat};
thread_local! {
/// The shipped `GlobalStrings.lua`, run in a VM once per test thread; built lazily, so a
/// caller's `wow_data_or_skip!()` runs first.
static GLOBAL_STRINGS: benilla_ui::script::UiScript = {
let s = benilla_ui::script::UiScript::new().expect("VM");
crate::ui_script::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
s
};
}
/// Run `f` with a key lookup into the shipped string table.
fn with_strings<T>(f: impl FnOnce(&dyn Fn(&str) -> Option<String>) -> T) -> T {
GLOBAL_STRINGS.with(|s| f(&|key: &str| s.lua().globals().get::<String>(key).ok()))
}
/// [`super::frames::compose`] against the shipped table, for this file and [`super::broadcast`].
pub(super) fn compose(event: &ChatEvent, kind: K, default_language: &str) -> Option<String> {
GLOBAL_STRINGS.with(|s| {
super::frames::compose(event, kind, default_language, &|key| {
s.lua().globals().get::<String>(key).ok()
})
})
}
/// A player-line event, as the wire bridge builds it.
fn ev(kind: K, text: &str, sender: &str) -> ChatEvent {
ChatEvent {
kind: Some(kind),
text: text.into(),
sender: sender.into(),
..Default::default()
}
}
#[test]
fn player_lines_link_the_name_except_emote() {
let _data = benilla_formats::wow_data_or_skip!();
// The `|Hplayer` link of `ChatFrame.lua:1451`.
assert_eq!(
compose(&ev(K::Say, "hi there", "Bob"), K::Say, "Common").unwrap(),
"|Hplayer:Bob|h[Bob]|h says: hi there"
);
assert_eq!(
compose(
&ev(K::WhisperInform, "hey", "Bob"),
K::WhisperInform,
"Common"
)
.unwrap(),
"To |Hplayer:Bob|h[Bob]|h: hey"
);
// EMOTE takes the bare name (`ChatFrame.lua:1450`).
assert_eq!(
compose(&ev(K::Emote, "dances.", "Bob"), K::Emote, "Common").unwrap(),
"Bob dances."
);
}
#[test]
fn group_prefixed_kinds_wear_their_brackets() {
let _data = benilla_formats::wow_data_or_skip!();
assert_eq!(
compose(&ev(K::Party, "inc 3", "Ann"), K::Party, "Common").unwrap(),
"[Party] |Hplayer:Ann|h[Ann]|h: inc 3"
);
assert_eq!(
compose(&ev(K::Guild, "gz", "Ann"), K::Guild, "Common").unwrap(),
"[Guild] |Hplayer:Ann|h[Ann]|h: gz"
);
assert_eq!(
compose(&ev(K::RaidWarning, "move", "Ann"), K::RaidWarning, "Common").unwrap(),
"[Raid Warning] |Hplayer:Ann|h[Ann]|h: move"
);
}
#[test]
fn flags_prefix_the_name_and_afk_uses_its_get() {
let _data = benilla_formats::wow_data_or_skip!();
let mut e = ev(K::Say, "brb", "Bob");
e.flag = "GM".into();
assert_eq!(
compose(&e, K::Say, "Common").unwrap(),
"<GM>|Hplayer:Bob|h[Bob]|h says: brb"
);
assert_eq!(
compose(&ev(K::Afk, "farming", "Bob"), K::Afk, "Common").unwrap(),
"|Hplayer:Bob|h[Bob]|h is Away From Keyboard: farming"
);
}
#[test]
fn language_header_rides_non_default_tongues() {
let _data = benilla_formats::wow_data_or_skip!();
let mut e = ev(K::Say, "throm-ka", "Grunk");
e.language = "Orcish".into();
assert_eq!(
compose(&e, K::Say, "Common").unwrap(),
"|Hplayer:Grunk|h[Grunk]|h says: [Orcish] throm-ka"
);
// The frame's default tongue renders no header.
e.language = "Common".into();
assert_eq!(
compose(&e, K::Say, "Common").unwrap(),
"|Hplayer:Grunk|h[Grunk]|h says: throm-ka"
);
}
#[test]
fn system_and_loot_lines_are_verbatim() {
let _data = benilla_formats::wow_data_or_skip!();
assert_eq!(
compose(
&ChatEvent::text_only(K::System, "Additem: Wool Cloth added.".into()),
K::System,
"Common"
)
.unwrap(),
"Additem: Wool Cloth added."
);
// A LOOT line arrives composed, item link and all; its escapes pass through untouched.
assert_eq!(
compose(
&ChatEvent::text_only(
K::Loot,
"You receive loot: |cffffffff|Hitem:117:0:0:0|h[Tough Jerky]|h|r.".into()
),
K::Loot,
"Common"
)
.unwrap(),
"You receive loot: |cffffffff|Hitem:117:0:0:0|h[Tough Jerky]|h|r."
);
}
/// The performer rides in `sender` (arg2, for addons) without the composer bracketing it on.
#[test]
fn text_emote_lines_are_verbatim_and_never_wear_the_senders_name() {
let _data = benilla_formats::wow_data_or_skip!();
let e = ev(K::TextEmote, "Bob waves at you.", "Bob");
assert_eq!(
compose(&e, K::TextEmote, "Common").unwrap(),
"Bob waves at you."
);
// The control: a SAY does get the bracketed link.
assert!(compose(&ev(K::Say, "hi", "Bob"), K::Say, "Common")
.unwrap()
.contains("[Bob]"));
}
/// `DoEmote` sends a self-target as guid 0 (`0x5ef611`), so 1.12 has no self-emote sentence.
#[test]
fn emoting_at_your_own_selection_sends_an_untargeted_emote() {
let _data = benilla_formats::wow_data_or_skip!();
use crate::target::Selection;
use bevy::prelude::Entity;
let me = Entity::from_raw_u32(7).unwrap();
let them = Entity::from_raw_u32(9).unwrap();
let sel = Selection {
target: Some(me),
guid: Some(0xdead_beef),
};
assert_eq!(emote_target(None, &sel, no_name, Some(me)), 0);
let sel = Selection {
target: Some(them),
guid: Some(0xdead_beef),
};
assert_eq!(emote_target(None, &sel, no_name, Some(me)), 0xdead_beef);
// No selection is untargeted, and with no self entity yet a selection goes out untouched.
assert_eq!(
emote_target(None, &Selection::default(), no_name, Some(me)),
0
);
let sel = Selection {
target: Some(them),
guid: Some(0xdead_beef),
};
assert_eq!(emote_target(None, &sel, no_name, None), 0xdead_beef);
}
fn no_name(_: &str) -> Option<(bevy::prelude::Entity, u64)> {
None
}
/// `0x49fdb1`: a word not starting with `%` names a player and replaces the selection; a miss is
/// guid 0; no argument or a `%` word is the selection.
#[test]
fn an_emote_argument_targets_the_named_player_and_not_the_selection() {
use crate::target::Selection;
use bevy::prelude::Entity;
let me = Entity::from_raw_u32(7).unwrap();
let bob = Entity::from_raw_u32(9).unwrap();
let sel = Selection {
target: Some(Entity::from_raw_u32(11).unwrap()),
guid: Some(0x5e1),
..Default::default()
};
let find = |n: &str| (n == "Bob").then_some((bob, 0xb0b));
assert_eq!(emote_target(Some("Bob"), &sel, find, Some(me)), 0xb0b);
assert_eq!(
emote_target(Some("Nobody"), &sel, find, Some(me)),
0,
"a miss is untargeted"
);
for arg in [None, Some(""), Some("%t"), Some("%x")] {
let panics = |_: &str| -> Option<(Entity, u64)> { panic!("no search for {arg:?}") };
assert_eq!(emote_target(arg, &sel, panics, Some(me)), 0x5e1, "{arg:?}");
}
// Naming yourself is the self-target edge: untargeted.
let own = |_: &str| Some((me, 0x111));
assert_eq!(emote_target(Some("Me"), &sel, own, Some(me)), 0);
}
#[test]
fn a_received_text_emote_composes_its_sentence_and_names_the_performer() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let cat = benilla_formats::load_emote_text_catalog(&mut chain).expect("emote text catalog");
const WAVE: u32 = 101;
const SIT: u32 = 86;
fn them(target: &'static str) -> benilla_formats::EmoteLine<'static> {
benilla_formats::EmoteLine {
performer: "Bob",
performer_is_you: false,
performer_female: false,
target: if target == "-" { "" } else { target },
your_name: "Me",
}
}
fn mine(target: &'static str) -> benilla_formats::EmoteLine<'static> {
benilla_formats::EmoteLine {
performer: "Me",
performer_is_you: true,
..them(target)
}
}
let line = |text_id, l| super::feed::text_emote_event(&cat, text_id, &l);
for (l, expected) in [
(them("Jane"), "Bob waves at Jane."),
(them("Me"), "Bob waves at you."),
(them("-"), "Bob waves."),
] {
let e = line(WAVE, l).expect("a sentence");
assert_eq!(e.text, expected);
assert_eq!(e.kind, Some(K::TextEmote));
// arg2 is the performer, not the target (`0x49b47c`).
assert_eq!(e.sender, "Bob");
}
for (l, expected) in [
(mine("Jane"), "You wave at Jane."),
(mine("-"), "You wave."),
] {
let e = line(WAVE, l).expect("a sentence");
assert_eq!(e.text, expected);
assert_eq!(e.sender, "Me");
}
// SIT's EmotesTextData rows ship blank: no line, not an empty one.
assert!(line(SIT, them("-")).is_none(), "/sit prints nothing");
}
/// `COMBATLOG_HONORAWARD`, `COMBATLOG_HONORGAIN` and `COMBATLOG_DISHONORGAIN`
/// (`GlobalStrings.lua:785-787`).
#[test]
fn honor_gain_lines_pick_the_reference_form() {
let _data = benilla_formats::wow_data_or_skip!();
assert_eq!(
with_strings(|g| super::feed::honor_gain_line(None, None, 42, g)).as_deref(),
Some("You have been awarded 42 honor points.")
);
assert_eq!(
with_strings(|g| super::feed::honor_gain_line(Some("Grimtusk"), Some("Sergeant"), 137, g))
.as_deref(),
Some("Grimtusk dies, honorable kill Rank: Sergeant (Estimated Honor Points: 137)")
);
// A dishonorable kill: vmangos sends a negative honor (`HonorMgr.cpp:807`).
assert_eq!(
with_strings(|g| super::feed::honor_gain_line(Some("Innkeeper Renee"), None, -37, g))
.as_deref(),
Some("Innkeeper Renee dies, dishonorable kill.")
);
// The fork is `honor <= 0` (`0x625270`): zero honor is dishonorable.
assert_eq!(
with_strings(|g| super::feed::honor_gain_line(Some("Grimtusk"), Some("Sergeant"), 0, g))
.as_deref(),
Some("Grimtusk dies, dishonorable kill.")
);
// No rank title: the clause stays, empty.
assert_eq!(
with_strings(|g| super::feed::honor_gain_line(Some("Grimtusk"), None, 5, g)).as_deref(),
Some("Grimtusk dies, honorable kill Rank: (Estimated Honor Points: 5)")
);
}
#[test]
fn xp_gain_lines_pick_the_reference_form() {
let _data = benilla_formats::wow_data_or_skip!();
// `COMBATLOG_XPGAIN_FIRSTPERSON`, `COMBATLOG_XPGAIN_EXHAUSTION1` (rested) and
// `COMBATLOG_XPGAIN_FIRSTPERSON_UNNAMED` (`GlobalStrings.lua:801`, `:789`, `:804`).
assert_eq!(
with_strings(|g| super::feed::xp_gain_line(Some("Kobold Vermin"), 35, 0, g)).as_deref(),
Some("Kobold Vermin dies, you gain 35 experience.")
);
assert_eq!(
with_strings(|g| super::feed::xp_gain_line(Some("Kobold Vermin"), 52, 17, g)).as_deref(),
Some("Kobold Vermin dies, you gain 52 experience. (+17 exp Rested bonus)")
);
assert_eq!(
with_strings(|g| super::feed::xp_gain_line(None, 120, 0, g)).as_deref(),
Some("You gain 120 experience.")
);
// The shipped lavender, 0x6F6FFF, chat-cache row 46.
assert_eq!(default_color(K::CombatXpGain), [111, 111, 255]);
}
#[test]
fn exploration_lines_pick_the_reference_form() {
let _data = benilla_formats::wow_data_or_skip!();
// `ERR_ZONE_EXPLORED` (`GlobalStrings.lua:1925`) is the toast on every exploration packet;
// `ERR_ZONE_EXPLORED_XP` (`:1926`) is the chat line, only when xp > 0 (`0x5e422f`).
assert_eq!(
with_strings(|g| super::feed::exploration_toast("Westfall", g)).as_deref(),
Some("Discovered: Westfall")
);
assert_eq!(
with_strings(|g| super::feed::exploration_line("Westfall", 85, g)).as_deref(),
Some("Discovered Westfall: 85 experience gained")
);
}
#[test]
fn monster_lines_use_the_bare_inline_name() {
let _data = benilla_formats::wow_data_or_skip!();
assert_eq!(
compose(
&ev(K::MonsterSay, "Intruders!", "Guard"),
K::MonsterSay,
"Common"
)
.unwrap(),
"Guard says: Intruders!"
);
// MONSTER_EMOTE embeds %s where the name goes (CHAT_MONSTER_EMOTE_GET = "").
assert_eq!(
compose(
&ev(K::MonsterEmote, "%s beckons you closer.", "Sentinel"),
K::MonsterEmote,
"Common"
)
.unwrap(),
"Sentinel beckons you closer."
);
}
#[test]
fn channel_line_prefixes_the_stripped_channel() {
let _data = benilla_formats::wow_data_or_skip!();
let mut e = ev(K::Channel, "wts boar livers", "Bob");
e.channel = "General - Elwynn Forest".into();
assert_eq!(
compose(&e, K::Channel, "Common").unwrap(),
"[General] |Hplayer:Bob|h[Bob]|h: wts boar livers"
);
}
/// The notice arms print arg4 whole, zone tail and all: the strip at `ChatFrame.lua:1463` is the
/// speech arm's alone. The bridge builds each fixture, so each takes the arm its byte selects.
#[test]
fn channel_notices_compose_by_the_notice_law() {
let _data = benilla_formats::wow_data_or_skip!();
let notice = |byte: u8, channel: &str, a: Option<&str>, b: Option<&str>| {
let e = super::feed::notice_event(
byte,
channel.to_string(),
a.map(str::to_string),
b.map(str::to_string),
)
.expect("the bridge builds an event for this notice");
let kind = e.kind.expect("a built notice always carries its kind");
compose(&e, kind, "Common")
};
assert_eq!(
notice(0x02, "General - Elwynn Forest", None, None).unwrap(), // YOU_JOINED
"Joined Channel: [General - Elwynn Forest]"
);
assert_eq!(
notice(0x12, "World", Some("Ann"), Some("Mod")).unwrap(), // PLAYER_KICKED
"[World] Player Ann kicked by Mod."
);
// A member join is a CHANNEL_JOIN event, linked like a player line.
let mut join = ev(K::ChannelJoin, "", "Ann");
join.channel = "World".into();
assert_eq!(
compose(&join, K::ChannelJoin, "Common").unwrap(),
"[World] |Hplayer:Ann|h[Ann]|h joined channel."
);
}
// ── the Lua face: the CHAT_MSG_* fire ───────────────────────────────────────────────────────────
/// A fresh VM with the chat stack the app loads, so `ChatFrame1` is the real window.
fn chat_vm() -> benilla_ui::script::UiScript {
let mut s = benilla_ui::script::UiScript::new().unwrap();
// The chat tabs call the dropdown kit (`CloseDropDownMenus` on a click), which reads
// `TOOLTIP_DEFAULT_COLOR`: both load ahead of ChatFrame.xml, as in `benilla.toc`.
for file in [
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\UIMenu.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
crate::ui_script::load_ui_for_test(&s, file);
}
crate::ui_script::fire_chat_login(&mut s);
s.set_screen_size(1600.0, 900.0);
s.resolve();
s
}
/// An addon that records a `CHAT_MSG_*` fire: the count, the event and `arg1..arg10` joined with
/// `|`, which raises on a `nil` in any slot.
const SPY: &str = r#"
SpyN, SpyEvent, SpyLine = 0, "", ""
Spy = CreateFrame("Frame", "BenillaChatSpy")
Spy:SetScript("OnEvent", function()
SpyN = SpyN + 1
SpyEvent = event
SpyLine = arg1.."|"..arg2.."|"..arg3.."|"..arg4.."|"..arg5.."|"..arg6..
"|"..arg7.."|"..arg8.."|"..arg9.."|"..arg10
end)
"#;
/// How many lines `ChatFrame1` is holding (`GetNumMessages`).
fn lines_in_window(s: &benilla_ui::script::UiScript) -> i64 {
s.eval::<i64>("return ChatFrame1:GetNumMessages()").unwrap()
}
/// A routed line prints once, even after an addon registers `ChatFrame1` for its event again.
#[test]
fn an_addon_registering_our_own_chat_frame_does_not_double_print() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_vm();
let mut windows = super::frames::ChatWindows::default();
s.run(SPY).unwrap();
s.run(r#"BenillaChatSpy:RegisterEvent("CHAT_MSG_SAY")"#)
.unwrap();
assert_eq!(lines_in_window(&s), 0, "the window starts empty");
super::frames::route(&mut s, &mut windows, &ev(K::Say, "hi there", "Bob"));
assert_eq!(lines_in_window(&s), 1, "our window prints exactly once");
assert_eq!(
s.eval::<i64>("return SpyN").unwrap(),
1,
"the addon saw the fire — otherwise the count above proves nothing"
);
// An addon registers ChatFrame1 for the event again: the double-print case, if there is one.
s.run(r#"ChatFrame1:RegisterEvent("CHAT_MSG_SAY")"#)
.unwrap();
super::frames::route(&mut s, &mut windows, &ev(K::Say, "hi again", "Bob"));
assert_eq!(
lines_in_window(&s),
2,
"one more line, not two — registering an event again never fires it twice"
);
assert_eq!(s.eval::<i64>("return SpyN").unwrap(), 2);
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
}
/// Listeners run in registration order, as the reference's do, and `ChatFrame1` registered first.
#[test]
fn an_addons_handler_sees_the_line_already_in_the_window() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_vm();
let mut windows = super::frames::ChatWindows::default();
s.run(
r#"
SeenAtFireTime = -1
Spy = CreateFrame("Frame", "BenillaChatSpy")
Spy:SetScript("OnEvent", function()
SeenAtFireTime = ChatFrame1:GetNumMessages()
end)
BenillaChatSpy:RegisterEvent("CHAT_MSG_SAY")
"#,
)
.unwrap();
super::frames::route(&mut s, &mut windows, &ev(K::Say, "hi there", "Bob"));
assert_eq!(
s.eval::<i64>("return SeenAtFireTime").unwrap(),
1,
"the handler ran AFTER our window took the line, as registration order requires"
);
}
/// arg7 and arg10 are numbers, zero for a non-channel line.
#[test]
fn a_say_line_fires_chat_msg_say_in_the_references_arg_positions() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_vm();
let mut windows = super::frames::ChatWindows::default();
s.run(SPY).unwrap();
s.run(r#"BenillaChatSpy:RegisterEvent("CHAT_MSG_SAY")"#)
.unwrap();
let mut e = ev(K::Say, "throm-ka", "Grunk");
e.language = "Orcish".into();
e.flag = "GM".into();
super::frames::route(&mut s, &mut windows, &e);
assert_eq!(s.eval::<String>("return SpyEvent").unwrap(), "CHAT_MSG_SAY");
// arg1 is the raw body: the reference's Lua adds "%s says: " and the link.
assert_eq!(
s.eval::<String>("return SpyLine").unwrap(),
"throm-ka|Grunk|Orcish|||GM|0|0||0"
);
}
/// A channel notice fires its token in arg1 and the numbered name in arg4; the handler repeats
/// `ChatFrame_OnEvent`'s bare `arg7 > 0` and `arg10 > 0`, which raise on a `nil`.
#[test]
fn a_channel_notice_fires_its_token_and_the_reference_reads_arg7_and_arg10_bare() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_vm();
let mut windows = super::frames::ChatWindows::default();
s.run(SPY).unwrap();
s.run(
r#"
SpyZone, SpySuffix = nil, nil
BenillaChatSpy:SetScript("OnEvent", function()
SpyN = SpyN + 1
SpyEvent = event
SpyLine = arg1.."|"..arg2.."|"..arg3.."|"..arg4.."|"..arg5.."|"..arg6..
"|"..arg7.."|"..arg8.."|"..arg9.."|"..arg10
-- ChatFrame_OnEvent l.1379 and l.1421, verbatim shape.
if arg7 > 0 then SpyZone = arg7 end
if arg10 > 0 then SpySuffix = arg10 end
end)
BenillaChatSpy:RegisterEvent("CHAT_MSG_CHANNEL_NOTICE")
"#,
)
.unwrap();
// The window prints only a channel it carries (`ChatFrame.lua:1374-1391`); `/join` adds it.
s.run("ChatFrame_AddChannel(ChatFrame1, 'General - Elwynn Forest')")
.unwrap();
let mut e = ChatEvent::text_only(K::ChannelNotice, String::new());
e.notice = "2".into(); // YOU_JOINED
e.channel = "1. General - Elwynn Forest".into();
e.channel_base = "General - Elwynn Forest".into();
e.channel_number = 1;
e.zone_channel_id = 1; // ChatChannels.dbc General
super::frames::route(&mut s, &mut windows, &e);
assert_eq!(s.eval::<i64>("return SpyN").unwrap(), 1);
assert_eq!(
s.eval::<String>("return SpyEvent").unwrap(),
"CHAT_MSG_CHANNEL_NOTICE"
);
assert_eq!(
s.eval::<String>("return SpyLine").unwrap(),
"YOU_JOINED|||1. General - Elwynn Forest|||1|1|General - Elwynn Forest|0"
);
assert_eq!(s.eval::<i64>("return SpyZone").unwrap(), 1);
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
assert_eq!(lines_in_window(&s), 1);
}
/// The notice switch's MODE_CHANGE arm (`0x49c24d`) calls `0x49e910` and returns before the fire.
#[test]
fn a_mode_change_notice_never_becomes_an_event() {
let _data = benilla_formats::wow_data_or_skip!();
use benilla_protocol::messages::{channel_notice, ChannelNoticeTail};
let mut log = super::feed::ChatLog::default();
log.push_channel_notice(
channel_notice::MODE_CHANGE,
"World".into(),
&ChannelNoticeTail::ModeChange {
guid: 42,
old_flags: 0,
new_flags: 1,
},
);
assert_eq!(
log.pending_len(),
0,
"MODE_CHANGE is dropped at the feed — the reference's 0x0C arm fires nothing"
);
// The control: a notice that fires is queued.
log.push_channel_notice(
channel_notice::YOU_JOINED,
"World".into(),
&ChannelNoticeTail::YouJoined { flags: 0 },
);
assert_eq!(log.pending_len(), 1);
}
/// arg4, arg7, arg8 and arg9 are one channel record in the reference, defaulted together
/// (`0x49b12f`).
#[test]
fn a_channel_we_are_not_in_fires_the_bare_name_and_zeroes() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_vm();
let mut windows = super::frames::ChatWindows::default();
s.run(SPY).unwrap();
s.run(r#"BenillaChatSpy:RegisterEvent("CHAT_MSG_CHANNEL")"#)
.unwrap();
let channels = super::edit::ChannelState::default();
let mut e = ev(K::Channel, "wts boar livers", "Bob");
e.channel = "SomeoneElsesChannel".into();
channels.stamp_channel(&mut e);
super::frames::route(&mut s, &mut windows, &e);
assert_eq!(
s.eval::<String>("return SpyLine").unwrap(),
"wts boar livers|Bob||SomeoneElsesChannel|||0|0||0",
"arg4 keeps the bare INCOMING name (the miss leg still has one); arg7/8/9/10 are the \
record we do not have, so 0/0/\"\"/0"
);
}
#[test]
fn stamping_a_channel_splits_the_display_form_from_the_base_name() {
let _data = benilla_formats::wow_data_or_skip!();
let mut channels = super::edit::ChannelState::default();
channels.claim_slot("World");
channels.claim_slot("General - Elwynn Forest");
let mut e = ev(K::Channel, "wts boar livers", "Bob");
e.channel = "General - Elwynn Forest".into();
channels.stamp_channel(&mut e);
assert_eq!(e.channel, "2. General - Elwynn Forest"); // arg4
assert_eq!(e.channel_number, 2); // arg8
assert_eq!(e.channel_base, "General - Elwynn Forest"); // arg9, zone tail intact
// A channel we are not in: its bare name in arg4, the rest empty (the miss leg `0x49aa86`).
let mut other = ev(K::Channel, "hi", "Bob");
other.channel = "SomeoneElsesChannel".into();
channels.stamp_channel(&mut other);
assert_eq!(other.channel, "SomeoneElsesChannel"); // arg4: the bare incoming name
assert_eq!(other.channel_number, 0); // arg8
assert_eq!(other.channel_base, ""); // arg9, not the name
assert_eq!(other.zone_channel_id, 0); // arg7
}
/// The channel family's colour is `ChatTypeInfo["CHANNEL"..arg8]` (`ChatFrame.lua:1381`).
#[test]
fn a_channel_notice_renders_in_the_channels_color_not_the_notice_row() {
let _data = benilla_formats::wow_data_or_skip!();
use benilla_ui::script::QuadContent;
let mut s = chat_vm();
let mut windows = super::frames::ChatWindows::default();
let mut channels = super::edit::ChannelState::default();
channels.claim_slot("General - Elwynn Forest");
// The window prints only a channel it carries (`ChatFrame.lua:1374-1391`).
s.run("ChatFrame_AddChannel(ChatFrame1, 'General - Elwynn Forest')")
.unwrap();
let mut e = ChatEvent::text_only(K::ChannelNotice, String::new());
e.channel = "General - Elwynn Forest".into();
e.notice = "2".into(); // YOU_JOINED
super::feed::deliver(&mut s, &mut windows, &mut channels, &mut e);
s.resolve();
let line = "Joined Channel: [1. General - Elwynn Forest]";
let color = s
.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Text {
text: Some(t),
color: Some(c),
..
} if t == line => Some(*c),
_ => None,
})
.unwrap_or_else(|| panic!("the notice line {line:?} rendered"));
let near = |a: f32, b: f32| (a - b).abs() < 0.01;
assert!(
near(color[0], 1.0) && near(color[1], 192.0 / 255.0) && near(color[2], 192.0 / 255.0),
"FFC0C0 (the CHANNEL1 row), not C0C0C0 (the CHANNEL_NOTICE row): {color:?}"
);
}
/// The stock `YOU_LEFT` arm deletes the window's registration for the channel it matches
/// (`ChatFrame.lua:1382-1384`) and no join re-adds it, so the leave of a renamed slot must match
/// nothing. The window registers by `ChannelID`, as the chat cache does (`ChatFrame.lua:1379`).
#[test]
fn a_zone_change_must_not_deregister_the_channel_it_renames() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_vm();
let mut windows = super::frames::ChatWindows::default();
let mut channels = super::edit::ChannelState {
channels: benilla_formats::ChatChannelsCatalog::from_rows(vec![
benilla_formats::ChatChannelRow {
id: 1,
flags: 0x0_0003,
pattern: "General - %s".into(),
shortcut: "General".into(),
},
]),
..Default::default()
};
channels.claim_slot("General - Elwynn Forest");
// `ChatFrame_RegisterForChannels(GetChatWindowChannels(1))`'s two writes, by hand.
s.run("ChatFrame1.channelList[1] = 'General' ChatFrame1.zoneChannelList[1] = 1")
.unwrap();
let channel_line = |name: &str| {
let mut e = ChatEvent::text_only(K::Channel, "anybody out here".into());
e.sender = "Bob".into();
e.channel = name.into();
e
};
let notice = |name: &str, byte: &str| {
let mut e = ChatEvent::text_only(K::ChannelNotice, String::new());
e.channel = name.into();
e.notice = byte.into();
e
};
// The control: registered by id, a General line reaches the window.
let before = lines_in_window(&s);
super::feed::deliver(
&mut s,
&mut windows,
&mut channels,
&mut channel_line("General - Elwynn Forest"),
);
assert_eq!(
lines_in_window(&s),
before + 1,
"the control must print — otherwise the assertion below proves nothing"
);
// The border crossing: the leave goes out, the slot is renamed in place (`0x49bc50`), the join
// goes out, and only then do the two notices land.
let renamed = channels.rename_slot("General - Elwynn Forest", "General - Westfall");
assert_eq!(
renamed,
Some(1),
"renamed in place — the slot number does not move"
);
let before = lines_in_window(&s);
super::feed::deliver(
&mut s,
&mut windows,
&mut channels,
&mut notice("General - Elwynn Forest", "3"), // YOU_LEFT
);
assert_eq!(
lines_in_window(&s),
before,
"the leave prints NOTHING: no slot carries the old name any more, so arg7/arg8/arg9 come \
out defaulted and the stock handler returns at `found == 0` — which is also why it never \
reaches the arm that would deregister the channel"
);
super::feed::deliver(
&mut s,
&mut windows,
&mut channels,
&mut notice("General - Westfall", "2"), // YOU_JOINED
);
assert_eq!(
s.eval::<Option<i64>>("return ChatFrame1.zoneChannelList[1]")
.unwrap(),
Some(1),
"the window must still be registered for ChannelID 1 after the rename — a nil here is \
General going silent for the rest of the session"
);
// Speech from the new zone still lands.
let before = lines_in_window(&s);
super::feed::deliver(
&mut s,
&mut windows,
&mut channels,
&mut channel_line("General - Westfall"),
);
assert_eq!(
lines_in_window(&s),
before + 1,
"a General line in the new zone must reach the window"
);
}
/// Leaving a city (`0x49a3b8`) sends the leave but keeps Trade's slot in state 3 (`0x49bcf0`): the
/// notice comes back as `SUSPENDED`, the stock `YOU_LEFT` arm never runs, and a re-join takes the
/// same slot.
#[test]
fn leaving_a_capital_suspends_trade_rather_than_deregistering_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_vm();
let mut windows = super::frames::ChatWindows::default();
let mut channels = super::edit::ChannelState {
channels: benilla_formats::ChatChannelsCatalog::from_rows(vec![
benilla_formats::ChatChannelRow {
id: 2,
flags: 0x0_003B,
pattern: "Trade - %s".into(),
shortcut: "Trade".into(),
},
]),
..Default::default()
};
channels.claim_slot("Trade - City");
s.run("ChatFrame1.channelList[1] = 'Trade' ChatFrame1.zoneChannelList[1] = 2")
.unwrap();
let notice = |name: &str, byte: &str| {
let mut e = ChatEvent::text_only(K::ChannelNotice, String::new());
e.channel = name.into();
e.notice = byte.into();
e
};
// The walk: LEAVE goes out, then the eligibility test suspends the slot.
assert_eq!(channels.suspend_slot("Trade - City"), Some(1));
let before = lines_in_window(&s);
super::feed::deliver(
&mut s,
&mut windows,
&mut channels,
&mut notice("Trade - City", "3"), // YOU_LEFT
);
s.resolve();
// `CHAT_SUSPENDED_NOTICE` reads as `CHAT_YOU_LEFT_NOTICE` does; only arg1, which the stock
// handler branches on, differs.
assert_eq!(lines_in_window(&s), before + 1);
assert!(
s.extract().iter().any(|q| matches!(
&q.content,
benilla_ui::script::QuadContent::Text { text: Some(t), .. }
if t == "Left Channel: [1. Trade - City]"
)),
"the suspended leave renders the same text as an ordinary one"
);
assert_eq!(
channels.number_of("Trade - City"),
Some(1),
"the record and its number survive — `/1` still addresses Trade, and the state-3 bypass \
needs the slot to be there to bypass onto"
);
assert_eq!(
s.eval::<Option<i64>>("return ChatFrame1.zoneChannelList[1]")
.unwrap(),
Some(2),
"and the window is still registered for it: the notice carried the SUSPENDED token, so \
the stock handler never reached the arm that deletes the registration"
);
// Walking back in: the same slot and number, and the notice prints.
let before = lines_in_window(&s);
super::feed::deliver(
&mut s,
&mut windows,
&mut channels,
&mut notice("Trade - City", "2"), // YOU_JOINED
);
assert_eq!(
lines_in_window(&s),
before + 1,
"the re-join prints — it could not have, with the registration gone"
);
assert_eq!(channels.number_of("Trade - City"), Some(1));
assert_eq!(
channels.slot_state("Trade - City"),
Some(super::edit::SlotState::Joined),
"and the slot is back to plain joined"
);
}
/// A renamed slot's join notice reads as `YOU_CHANGED`: the `0x02` arm splits on `rec+0x9c == 2`.
#[test]
fn a_renamed_zone_channel_confirms_as_changed_not_joined() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_vm();
let mut windows = super::frames::ChatWindows::default();
let mut channels = super::edit::ChannelState {
channels: benilla_formats::ChatChannelsCatalog::from_rows(vec![
benilla_formats::ChatChannelRow {
id: 1,
flags: 0x0_0003,
pattern: "General - %s".into(),
shortcut: "General".into(),
},
]),
..Default::default()
};
channels.claim_slot("General - Elwynn Forest");
s.run("ChatFrame1.channelList[1] = 'General' ChatFrame1.zoneChannelList[1] = 1")
.unwrap();
channels.rename_slot("General - Elwynn Forest", "General - Westfall");
let mut e = ChatEvent::text_only(K::ChannelNotice, String::new());
e.channel = "General - Westfall".into();
e.notice = "2".into(); // YOU_JOINED
super::feed::deliver(&mut s, &mut windows, &mut channels, &mut e);
s.resolve();
let want = "Changed Channel: [1. General - Westfall]";
assert!(
s.extract().iter().any(|q| matches!(
&q.content,
benilla_ui::script::QuadContent::Text { text: Some(t), .. } if t == want
)),
"expected {want:?} in the window"
);
assert_eq!(
channels.slot_state("General - Westfall"),
Some(super::edit::SlotState::Joined),
"and the confirming notice resolves the state — a second crossing must read as a rename \
of its own, not as a leftover"
);
}
/// The `YOU_LEFT` arm tears the record down after the fire (`0x49c5b0`, then `0x49bbd0` at
/// `0x49c5c2`), so [`super::feed::deliver`] stamps the line before it frees the slot.
#[test]
fn a_leave_notice_keeps_its_number_because_the_record_dies_after_the_line() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_vm();
let mut windows = super::frames::ChatWindows::default();
let mut channels = super::edit::ChannelState::default();
channels.claim_slot("World");
channels.claim_slot("General - Elwynn Forest");
let mut e = ChatEvent::text_only(K::ChannelNotice, String::new());
e.channel = "General - Elwynn Forest".into();
e.notice = "3".into(); // YOU_LEFT
super::feed::deliver(&mut s, &mut windows, &mut channels, &mut e);
assert_eq!(e.channel, "2. General - Elwynn Forest", "arg4 was stamped");
assert_eq!(
e.channel_number, 2,
"arg8 — what the color resolves through"
);
assert_eq!(
compose(&e, K::ChannelNotice, "Common").unwrap(),
"Left Channel: [2. General - Elwynn Forest]"
);
assert_eq!(
channels.names(),
[Some("World".to_string()), None],
"and only THEN is the record gone — as a HOLE at slot 2, not a shortened list"
);
}
/// The reference frees a slot in place and refills the first free one, so no other number moves.
#[test]
fn a_freed_slot_is_reused_and_the_others_keep_their_numbers() {
let _data = benilla_formats::wow_data_or_skip!();
let mut c = super::edit::ChannelState::default();
assert_eq!(c.claim_slot("General - Teldrassil"), Some(1));
assert_eq!(c.claim_slot("Trade - City"), Some(2));
assert_eq!(c.claim_slot("LocalDefense - Teldrassil"), Some(3));
// Cross a zone border: General and LocalDefense rename, Trade is untouched.
assert_eq!(c.free_slot("General - Teldrassil"), Some(1));
assert_eq!(
c.claim_slot("General - The Barrens"),
Some(1),
"the freed slot is reused — the client scans for a zeroed record before growing"
);
assert_eq!(c.free_slot("LocalDefense - Teldrassil"), Some(3));
assert_eq!(c.claim_slot("LocalDefense - The Barrens"), Some(3));
assert_eq!(
c.number_of("Trade - City"),
Some(2),
"Trade never moved: /2 still reaches it, which is the whole complaint"
);
// Leaving the city drops Trade; the hole it leaves is what the next join takes.
assert_eq!(c.free_slot("Trade - City"), Some(2));
assert!(c.joined[1].is_none(), "a hole answers 'not joined'");
assert_eq!(c.number_of("General - The Barrens"), Some(1), "still 1");
assert_eq!(
c.claim_slot("Trade - City"),
Some(2),
"and back into slot 2"
);
// The ceiling is ten slots (`0x49b9c0`).
for i in 4..=super::edit::MAX_CHANNELS {
assert_eq!(c.claim_slot(&format!("Custom{i}")), Some(i as u32));
}
assert_eq!(c.claim_slot("OneTooMany"), None);
assert_eq!(c.free_slot("Custom7"), Some(7));
assert_eq!(
c.claim_slot("OneTooMany"),
Some(7),
"full means no free slot, not a permanent ceiling"
);
}
/// The reference destroys its Lua state at logout, so the next character's window is empty.
#[test]
fn a_session_end_empties_the_window_and_the_boxs_memory() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_vm();
let mut windows = super::frames::ChatWindows::default();
let mut log = super::ChatLog::default();
super::frames::route(&mut s, &mut windows, &ev(K::Say, "hi there", "Bob"));
super::frames::route(&mut s, &mut windows, &ev(K::Whisper, "psst", "Ann"));
log.push_event(ChatEvent::text_only(K::System, "queued".into()));
assert_eq!(
lines_in_window(&s),
2,
"the window has this session's lines"
);
super::end_chat_session(Some(&mut s), &mut log);
assert_eq!(
lines_in_window(&s),
0,
"and the next character starts clean"
);
}
/// Each token names a `CHAT_<X>_NOTICE` string (`ChatFrame.lua:1416`, `:1424`); the tokenless bytes
/// are MODE_CHANGE (`0x0C`), which fires nothing, and the member lines JOINED and LEFT.
#[test]
fn every_notice_token_resolves_and_the_tokenless_bytes_stay_silent() {
let _data = benilla_formats::wow_data_or_skip!();
for byte in 0x00u8..=0x21 {
let mut e = ChatEvent::text_only(K::ChannelNotice, String::new());
e.channel = "World".into();
e.notice = byte.to_string();
let rendered = GLOBAL_STRINGS.with(|s| {
super::frames::compose_notice(&e, K::ChannelNotice, &|key| {
s.lua().globals().get::<String>(key).ok()
})
});
match super::event::notice_token(byte, None) {
Some(token) => assert!(
rendered.is_some(),
"notice {byte:#04x} has token {token} but CHAT_{token}_NOTICE resolves to nothing"
),
None => assert_eq!(
rendered, None,
"notice {byte:#04x} has no token and must render nothing"
),
}
}
}
/// `CHAT_INVITE_NOTICE` is `"%2$s has invited you to join the channel '%1$s'."`, filled from the
/// fixed `(arg4, arg2)` list (`ChatFrame.lua:1418`).
#[test]
fn the_invite_notice_reorders_its_two_names() {
let _data = benilla_formats::wow_data_or_skip!();
let mut e = ChatEvent::text_only(K::ChannelNoticeUser, String::new());
e.channel = "2. Trade - City".into();
e.sender = "Ann".into();
e.notice = benilla_protocol::messages::channel_notice::INVITE.to_string();
let line = GLOBAL_STRINGS.with(|s| {
super::frames::compose_notice(&e, K::ChannelNoticeUser, &|key| {
s.lua().globals().get::<String>(key).ok()
})
});
assert_eq!(
line.as_deref(),
Some("Ann has invited you to join the channel '2. Trade - City'."),
"the inviter fills %2$s and the channel — arg4, zone tail and all — fills %1$s"
);
}
/// A new kind breaks [`super::event::event_name`]'s exhaustive match; this makes it join `ALL`.
#[test]
fn every_kind_is_in_all() {
let _data = benilla_formats::wow_data_or_skip!();
let mut seen: Vec<&str> = K::ALL
.iter()
.map(|&k| super::event::event_name(k))
.collect();
let before = seen.len();
seen.sort_unstable();
seen.dedup();
assert_eq!(seen.len(), before, "a kind is listed twice in ALL");
assert_eq!(before, 93, "93 kinds — update this when the kind set grows");
}
#[test]
fn colors_match_the_shipped_table() {
let _data = benilla_formats::wow_data_or_skip!();
assert_eq!(default_color(K::Say), [255, 255, 255]);
assert_eq!(default_color(K::System), [255, 255, 0]);
assert_eq!(default_color(K::Yell), [255, 64, 64]);
assert_eq!(default_color(K::Emote), [255, 128, 64]);
assert_eq!(default_color(K::MonsterSay), [255, 255, 159]);
assert_eq!(default_color(K::Loot), [0, 170, 0]);
assert_eq!(default_color(K::Money), [255, 255, 0]);
assert_eq!(default_color(K::ChannelNotice), [192, 192, 192]);
assert_eq!(default_color(K::RaidWarning), [255, 219, 183]);
assert_eq!(default_color(K::BgSystemAlliance), [0, 174, 239]);
}
// ── the submitted-line grammar: type switches and action commands ──────────────────────────
/// A command table from a stub of the shipped `SLASH_<INDEX><n>` and `EMOTE<i>_CMD<j>` strings.
fn stub_table() -> super::commands::SlashCommands {
const STRINGS: &[(&str, &str)] = &[
("SLASH_JOIN1", "/join"),
("SLASH_LEAVE1", "/leave"),
("SLASH_LIST_CHANNEL1", "/chatlist"),
("SLASH_CHAT_AFK1", "/afk"),
("SLASH_CHAT_DND1", "/dnd"),
("SLASH_RANDOM1", "/random"),
("SLASH_RANDOM2", "/roll"),
("SLASH_PLAYED1", "/played"),
("SLASH_HELP1", "/help"),
("SLASH_PVP1", "/pvp"),
("SLASH_REPLY1", "/r"),
("SLASH_LOGOUT1", "/logout"),
("SLASH_LOGOUT2", "/camp"),
("SLASH_QUIT1", "/quit"),
("SLASH_TRADE1", "/trade"),
("SLASH_SCRIPT1", "/script"),
// One emote index, in the two-table shape: the alias, and the token it resolves through.
("EMOTE1_CMD1", "/wave"),
("EMOTE1_CMD2", "/hello"), // an alias that is not the token
("EMOTE1_TOKEN", "WAVE"),
];
super::commands::SlashCommands::build(
|key| {
STRINGS
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| (*v).to_string())
},
|token| (token == "WAVE").then_some(101),
)
}
#[test]
fn action_commands_parse() {
let _data = benilla_formats::wow_data_or_skip!();
let t = stub_table();
let parse_line = |line: &str| super::input::parse_line(&t, line);
assert_eq!(
parse_line("/join world secret"),
ParsedChat::Join {
name: "world".into(),
password: "secret".into(),
}
);
assert_eq!(
parse_line("/leave world"),
ParsedChat::Leave {
name: "world".into()
}
);
assert_eq!(
parse_line("/chatlist world"),
ParsedChat::ChatList {
name: "world".into()
}
);
// `/afk` and `/dnd` run the stock `SlashCmdList` bodies, with the argument whole.
assert_eq!(
parse_line("/afk farming"),
ParsedChat::Lua {
body: "SlashCmdList[\"CHAT_AFK\"](\"farming\")".into()
}
);
// Bare is the toggle: the empty string survives to the call.
assert_eq!(
parse_line("/dnd"),
ParsedChat::Lua {
body: "SlashCmdList[\"CHAT_DND\"](\"\")".into()
}
);
assert_eq!(parse_line("/roll"), ParsedChat::Random { min: 1, max: 100 });
assert_eq!(
parse_line("/random 50"),
ParsedChat::Random { min: 1, max: 50 }
);
assert_eq!(
parse_line("/random 2 8"),
ParsedChat::Random { min: 2, max: 8 }
);
assert_eq!(parse_line("/played"), ParsedChat::Played);
assert_eq!(parse_line("/help"), ParsedChat::Help);
// /pvp takes no argument; a trailing word is ignored.
assert_eq!(parse_line("/pvp"), ParsedChat::Pvp);
assert_eq!(parse_line("/pvp on"), ParsedChat::Pvp);
// /r rides its own arm: the reply state lives on ChatEditState.
assert_eq!(
parse_line("/r hey"),
ParsedChat::Reply { text: "hey".into() }
);
}
fn emote(text_id: u32, arg: Option<&str>) -> ParsedChat {
ParsedChat::TextEmote {
text_id,
arg: arg.map(str::to_string),
}
}
#[test]
fn emote_aliases_resolve_through_the_table() {
let _data = benilla_formats::wow_data_or_skip!();
let t = stub_table();
let parse_line = |line: &str| super::input::parse_line(&t, line);
assert_eq!(parse_line("/wave"), emote(101, None));
// An alias that is not its token's `EmotesText` name resolves too, as `/lol` (LAUGH) does.
assert_eq!(parse_line("/hello"), emote(101, None));
// An emote takes an argument (`DoEmote(token, msg)`): the command is the first word only.
assert_eq!(parse_line("/wave Bob"), emote(101, Some("Bob")));
assert_eq!(parse_line("/wave %t"), emote(101, Some("%t")));
assert_eq!(parse_line("/nosuch"), ParsedChat::Unknown);
}
#[test]
fn logout_and_camp_parse() {
let _data = benilla_formats::wow_data_or_skip!();
let t = stub_table();
let parse_line = |line: &str| super::input::parse_line(&t, line);
for line in ["/logout", "/camp", "/LOGOUT", "/logout now"] {
assert_eq!(parse_line(line), ParsedChat::Logout);
}
assert_eq!(parse_line("/quit"), ParsedChat::Quit);
}
#[test]
fn one_line_reference_bodies_run_in_the_vm() {
let _data = benilla_formats::wow_data_or_skip!();
let t = stub_table();
let parse_line = |line: &str| super::input::parse_line(&t, line);
// `/trade` is the stock `InitiateTrade("target")`.
assert_eq!(
parse_line("/trade"),
ParsedChat::Lua {
body: "InitiateTrade(\"target\")".into()
}
);
// `/script` runs the typed text as the chunk (stock `RunScript(msg)`); bare is a no-op.
assert_eq!(
parse_line("/script Print(\"hi\")"),
ParsedChat::Lua {
body: "Print(\"hi\")".into()
}
);
assert_eq!(parse_line("/script"), ParsedChat::Unknown);
}
/// `/castvis` is a dev instrument: a player build never claims it (`run_mode::dev_affordances()`).
#[test]
fn castvis_parses_id_and_phase() {
let _data = benilla_formats::wow_data_or_skip!();
use crate::creature_anim::CastEventKind;
let t = stub_table();
let parse_line = |line: &str| super::input::parse_line(&t, line);
if !crate::run_mode::dev_affordances() {
assert_eq!(
parse_line("/castvis 133"),
ParsedChat::Unknown,
"a player build must not claim an instrument's alias"
);
return;
}
assert_eq!(
parse_line("/castvis 133"),
ParsedChat::CastVis {
spell_id: 133,
kind: CastEventKind::Start,
ground: false
}
);
assert_eq!(
parse_line("/castvis 133 go"),
ParsedChat::CastVis {
spell_id: 133,
kind: CastEventKind::Go,
ground: false
}
);
// `ground` is a GO too, the pure-destination shape.
assert_eq!(
parse_line("/castvis 1543 GROUND"),
ParsedChat::CastVis {
spell_id: 1543,
kind: CastEventKind::Go,
ground: true
}
);
assert_eq!(
parse_line("/castvis 689 FAIL"),
ParsedChat::CastVis {
spell_id: 689,
kind: CastEventKind::Fail,
ground: false
}
);
assert_eq!(parse_line("/castvis"), ParsedChat::Unknown);
assert_eq!(parse_line("/castvis abc"), ParsedChat::Unknown);
assert_eq!(parse_line("/castvis 133 nope"), ParsedChat::Unknown);
}
#[test]
fn unknown_slash_command_is_dropped_not_said_aloud() {
let _data = benilla_formats::wow_data_or_skip!();
let t = stub_table();
let parse_line = |line: &str| super::input::parse_line(&t, line);
assert_eq!(parse_line("/dancemove"), ParsedChat::Unknown);
assert_eq!(parse_line("/frobnicate"), ParsedChat::Unknown);
}
/// The command table built as boot builds it: `GlobalStrings.lua` and `ChatFrame.lua`'s token
/// table run in a VM, joined to `EmotesText.dbc`.
#[test]
fn real_alias_table_resolves_the_shipped_commands() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let s = benilla_ui::script::UiScript::new().expect("VM");
for file in ["GlobalStrings.lua", "ChatFrame.lua"] {
let src = chain
.read_file(&format!("Interface\\FrameXML\\{file}"))
.expect("FrameXML file in the chain");
let src = String::from_utf8_lossy(&src).into_owned();
// GlobalStrings runs whole; ChatFrame contributes its token table alone.
let src = if file == "ChatFrame.lua" {
src.lines()
.map(str::trim)
.filter(|l| crate::ui_script::is_emote_token_line(l))
.collect::<Vec<_>>()
.join("\n")
} else {
src
};
s.run(&src).expect("runs clean");
}
let cat = benilla_formats::load_emote_sound_catalog(&mut chain).expect("emote catalog");
let globals = s.lua().globals();
let table = super::commands::SlashCommands::build(
|name| globals.get::<String>(name).ok().filter(|v| !v.is_empty()),
|token| cat.text_id(token),
);
let parse_line = |line: &str| super::input::parse_line(&table, line);
// `/sit` is EmotesText 86, whose `Emotes.dbc` row 13 (STATE_SIT) sets stand state 1.
assert_eq!(parse_line("/sit"), emote(86, None));
assert_eq!(
cat.text_emote(86).and_then(|e| cat.posture_state(e)),
Some(1)
);
for (line, state) in [
("/stand", 0),
("/sit", 1),
("/sleep", 3),
("/liedown", 3),
("/kneel", 8),
] {
let ParsedChat::TextEmote { text_id, .. } = parse_line(line) else {
panic!("{line} is an emote");
};
let posture = cat.text_emote(text_id).and_then(|e| cat.posture_state(e));
assert_eq!(posture, Some(state), "{line} sets stand state {state}");
}
// Aliases that differ from their token's DBC name.
for line in [
"/lol",
"/hi",
"/ty",
"/thanks",
"/congrats",
"/sorry",
"/yes",
"/bravo",
"/weep",
"/goodbye",
"/pizza",
"/strong",
] {
assert!(
matches!(parse_line(line), ParsedChat::TextEmote { .. }),
"{line} resolves to an emote"
);
}
// DBC emote names the reference has no command for.
for line in ["/joke", "/puzzle", "/attackmytarget"] {
assert_eq!(parse_line(line), ParsedChat::Unknown, "{line}");
}
// `SlashCmdList["FOLLOW"]`, over the three distinct aliases of SLASH_FOLLOW1-6.
for line in ["/follow", "/f", "/fol"] {
assert_eq!(
parse_line(line),
ParsedChat::Follow { name: None },
"{line}"
);
}
assert_eq!(
parse_line("/follow Probeone"),
ParsedChat::Follow {
name: Some("Probeone".into())
}
);
// A command whose handler benilla does not register answers like any unknown command.
assert_eq!(parse_line("/ginvite"), ParsedChat::Unknown);
// /target and /assist take the whole argument as one name; `/tar` and `/a` are shipped aliases.
assert_eq!(
parse_line("/target Kobold Vermin"),
ParsedChat::Target {
name: Some("Kobold Vermin".into())
},
"the argument is trimmed WHOLE — `GetSlashCmdTarget`'s gsub, not a first-word split"
);
assert_eq!(
parse_line("/tar Hogger "),
ParsedChat::Target {
name: Some("Hogger".into())
}
);
assert_eq!(parse_line("/target"), ParsedChat::Target { name: None });
assert_eq!(
parse_line("/a Bob"),
ParsedChat::Assist {
name: Some("Bob".into())
}
);
assert_eq!(parse_line("/assist"), ParsedChat::Assist { name: None });
// `/cast` and `/spell` run the stock `CastSpellByName(msg)`; `/macro` and `/m` open the window.
assert_eq!(
parse_line("/cast Fireball(Rank 1)"),
ParsedChat::Lua {
body: "CastSpellByName(\"Fireball(Rank 1)\")".into()
}
);
assert_eq!(
parse_line("/spell Frostbolt"),
ParsedChat::Lua {
body: "CastSpellByName(\"Frostbolt\")".into()
}
);
assert_eq!(
parse_line("/cast"),
ParsedChat::Unknown,
"a bare /cast is the ref's own no-op (`if msg ~= \"\"`)"
);
for line in ["/macro", "/m"] {
assert_eq!(
parse_line(line),
ParsedChat::Lua {
body: "ShowMacroFrame()".into()
},
"{line}"
);
}
assert_eq!(parse_line("/macrohelp"), ParsedChat::MacroHelp);
assert_eq!(parse_line("/convertraid"), ParsedChat::ConvertRaid);
// `/console` from a line that skipped the stock edit box forwards to the stock handler's verb.
assert_eq!(
parse_line("/console fpsJournal 1"),
ParsedChat::Lua {
body: "ConsoleExec(\"fpsJournal 1\")".into()
}
);
assert_eq!(
parse_line("/console reloadUI"),
ParsedChat::Lua {
body: "ConsoleExec(\"reloadUI\")".into()
}
);
// The quoting is a short string: 1.12's Lua lexer has no long-string levels.
assert_eq!(super::input::lua_quoted_string("a]]b"), "\"a]]b\"");
assert_eq!(super::input::lua_quoted_string("a]]b]=]c"), "\"a]]b]=]c\"");
assert_eq!(
super::input::lua_quoted_string("say \"hi\"\\n"),
"\"say \\\"hi\\\"\\\\n\""
);
// The literal round-trips through a real VM.
{
let vm = benilla_ui::script::UiScript::new().expect("VM");
for payload in [
"fpsJournal 1",
"a]]b",
"a]]b]=]c",
"quote \" and backslash \\",
"tab\there",
] {
let lit = super::input::lua_quoted_string(payload);
let got: String = vm
.eval(&format!("return {lit}"))
.unwrap_or_else(|e| panic!("{lit} must compile on a 1.12-grammar VM: {e}"));
assert_eq!(got, payload, "and must carry the text unchanged");
}
}
// The shipped surface: 68 distinct aliases over 36 `SlashCmdList` indices and 225 emote
// commands over 169 `EmotesText` names (aliases repeat; EMOTE27 "UNUSED" has no row). Then
// benilla's own `/reload`, `/errors`, `/err` and `/convertraid`, which are not 1.12 commands,
// in every build, and 7 instrument aliases, in dev builds only.
let instruments = if crate::run_mode::dev_affordances() {
7
} else {
0
};
assert_eq!(
table.counts(),
(68, 225, 4, instruments),
"(slash, emote, benilla addition, instrument) aliases"
);
}
/// The `0x4000` "stand still" flag reports [`EmoteGate::Moving`] on any bit of the reference's
/// `0x20ff` mask (`move_flags::INTEGRATED`), which holds no SWIMMING.
#[test]
fn the_standing_still_arm_reports_moving_and_ignores_swimming() {
use super::input::EmoteGate;
use crate::creature_anim::move_flags;
const STILL: u32 = 0x4000;
assert_eq!(emote_send_eligible(STILL, 0, false, 0), EmoteGate::Send);
// Any INTEGRATED bit trips it: a direction, a turn or a fall.
for f in [
move_flags::FORWARD,
move_flags::BACKWARD,
move_flags::STRAFE_LEFT,
move_flags::TURN_RIGHT,
move_flags::FALLING,
] {
assert_eq!(
emote_send_eligible(STILL, 0, false, f),
EmoteGate::Moving,
"move flag {f:#x}"
);
}
// SWIMMING is not in `0x20ff`: a still swimmer is still.
assert_eq!(
emote_send_eligible(STILL, 0, false, move_flags::SWIMMING),
EmoteGate::Send,
"the mover-integration mask carries no SWIMMING"
);
// WALK_MODE and LEVITATING are mode bits, not motion.
assert_eq!(
emote_send_eligible(STILL, 0, false, move_flags::WALK_MODE),
EmoteGate::Send
);
// Without the flag, movement is irrelevant.
assert_eq!(
emote_send_eligible(0, 0, false, move_flags::FORWARD),
EmoteGate::Send
);
// An unconditionally suppressed emote (`0x0400`) never reaches this arm: it stays silent.
assert_eq!(
emote_send_eligible(0x4400, 0, false, move_flags::FORWARD),
EmoteGate::Suppressed
);
}
/// `IsSelfControlled` (`0x5fa550`) is false only while confused, fleeing or move-disabled, the
/// `0xc00004` mask, which leaves STUNNED out.
#[test]
fn self_controlled_is_true_for_an_ordinary_player() {
use crate::player::self_controlled;
assert!(self_controlled(0));
assert!(self_controlled(1 << 3), "an ordinary PvP-attackable player");
assert!(!self_controlled(0x0040_0000), "confused");
assert!(!self_controlled(0x0080_0000), "fleeing");
assert!(!self_controlled(0x0000_0004), "move-disabled");
assert!(
self_controlled(0x0004_0000),
"STUNNED is not in the 0xc00004 mask — it has its own gate"
);
}
// ── the send-side emote gate, `CheckEmoteEligible` (`0x47db40`), on real `EmoteFlags` values ──
const BOW: u32 = 0x4801;
const RUDE: u32 = 0x0001;
const APPLAUD: u32 = 0x0000;
const CHEER: u32 = 0x0800;
const SALUTE: u32 = 0x0800;
const LAUGH: u32 = 0x0980;
/// `0x5ef57e`: a unit with `UNIT_FLAG_POSSESSED` (`UNIT_FIELD_FLAGS & 0x01000000`) refuses every
/// emote, silently; a missing `Emotes.dbc` row returns too (`0x5ef5b1`).
#[test]
fn a_possessed_unit_or_a_missing_row_refuses_the_emote() {
assert_eq!(emote_gate(Some(0), 0, 0, false, 0), EmoteGate::Send);
assert_eq!(
emote_gate(Some(0), 0x0100_0000, 0, false, 0),
EmoteGate::Suppressed
);
assert_eq!(
emote_gate(Some(0), 0x0100_0008, 0, false, 0),
EmoteGate::Suppressed
);
assert_eq!(
emote_gate(Some(0), 0x0200_0000, 0, false, 0),
EmoteGate::Send,
"a neighbour bit"
);
assert_eq!(emote_gate(None, 0, 0, false, 0), EmoteGate::Suppressed);
}
/// A chat-only text emote has `EmoteID` 0, so `DoEmote` reads `Emotes.dbc` row 0 (flags 0), which
/// lacks `0x200`: dead (7) and asleep (3) refuse it, as they refuse a `/wave` (`0x47db8e`).
#[test]
fn a_chat_only_emote_reads_row_zero_and_is_refused_dead_or_asleep() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let cat = benilla_formats::load_emote_sound_catalog(&mut chain).expect("emote catalog");
let smile = cat.text_id("smile").expect("SMILE");
assert_eq!(cat.text_emote(smile), None, "SMILE is chat-only");
let row = cat.emote_flags(cat.text_emote(smile).unwrap_or(0));
assert_eq!(row, Some(0), "row 0, no flags");
assert_eq!(
emote_gate(row, 0, 7, false, 0),
EmoteGate::Suppressed,
"dead"
);
assert_eq!(
emote_gate(row, 0, 3, false, 0),
EmoteGate::Suppressed,
"asleep"
);
assert_eq!(emote_gate(row, 0, 0, false, 0), EmoteGate::Send, "standing");
assert_eq!(emote_gate(row, 0, 1, false, 0), EmoteGate::Send, "sitting");
}
#[test]
fn seated_stand_required_emotes_are_suppressed() {
let _data = benilla_formats::wow_data_or_skip!();
assert_eq!(emote_send_eligible(BOW, 1, false, 0), EmoteGate::Suppressed); // 0x1 needs STAND
assert_eq!(
emote_send_eligible(RUDE, 1, false, 0),
EmoteGate::Suppressed
);
}
#[test]
fn seated_non_stand_emotes_pass() {
let _data = benilla_formats::wow_data_or_skip!();
assert_eq!(emote_send_eligible(APPLAUD, 1, false, 0), EmoteGate::Send);
assert_eq!(emote_send_eligible(CHEER, 1, false, 0), EmoteGate::Send);
assert_eq!(emote_send_eligible(LAUGH, 1, false, 0), EmoteGate::Send);
assert_eq!(emote_send_eligible(SALUTE, 1, false, 0), EmoteGate::Send);
}
#[test]
fn swimming_suppresses_only_the_0x80_emotes() {
let _data = benilla_formats::wow_data_or_skip!();
assert_eq!(
emote_send_eligible(LAUGH, 0, true, 0),
EmoteGate::Suppressed
); // 0x0980 has 0x80
assert_eq!(emote_send_eligible(CHEER, 0, true, 0), EmoteGate::Send);
}
#[test]
fn standing_and_dry_everyone_is_eligible() {
let _data = benilla_formats::wow_data_or_skip!();
for flags in [BOW, RUDE, APPLAUD, CHEER, SALUTE, LAUGH] {
assert_eq!(
emote_send_eligible(flags, 0, false, 0),
EmoteGate::Send,
"flags {flags:#x}"
);
}
}
#[test]
fn unconditional_and_sleep_dead_rules() {
let _data = benilla_formats::wow_data_or_skip!();
assert_eq!(
emote_send_eligible(0x0400, 0, false, 0),
EmoteGate::Suppressed
); // unconditional suppress
assert_eq!(emote_send_eligible(0, 3, false, 0), EmoteGate::Suppressed); // SLEEP, no allow bit
assert_eq!(emote_send_eligible(0, 7, false, 0), EmoteGate::Suppressed); // DEAD, no allow bit
assert_eq!(emote_send_eligible(0x0200, 3, false, 0), EmoteGate::Send); // 0x0200 allows it
}
// ── received lines: the addon lane, the language header, the talk gesture ──────────────────
/// The text divides on its first tab (`0x49a8d0`), and with no tab it is all prefix; a lane
/// without a name reports `"UNKNOWN"` (`0x49aff4`).
#[test]
fn an_inbound_addon_line_splits_on_the_first_tab_only() {
let _data = benilla_formats::wow_data_or_skip!();
// The lane bytes come from the protocol crate, never copied by hand.
use benilla_protocol::messages as m;
let party = m::CHAT_TYPE_PARTY as u8;
let raid = m::CHAT_TYPE_RAID as u8;
let guild = m::CHAT_TYPE_GUILD as u8;
let battleground = m::CHAT_TYPE_BATTLEGROUND as u8;
let say = m::CHAT_TYPE_SAY as u8;
#[allow(non_snake_case)]
let (PARTY, RAID, GUILD, BATTLEGROUND, SAY) = (party, raid, guild, battleground, say);
let mut log = super::feed::ChatLog::default();
log.push_addon("oRA\tSYNC:1", PARTY, 7);
// Tabs in the message: only the first divides.
log.push_addon("CTRA\tA\tB\tC", RAID, 7);
// No tab: all prefix, an empty message.
log.push_addon("BareTag", GUILD, 7);
// A trailing tab: an empty message.
log.push_addon("Tag\t", BATTLEGROUND, 7);
// A lane with no name still arrives, labelled.
log.push_addon("X\ty", SAY, 7);
assert_eq!(
log.pending_addons(),
vec![
("oRA".into(), "SYNC:1".into(), "PARTY".into()),
("CTRA".into(), "A\tB\tC".into(), "RAID".into()),
("BareTag".into(), String::new(), "GUILD".into()),
("Tag".into(), String::new(), "BATTLEGROUND".into()),
("X".into(), "y".into(), "UNKNOWN".into()),
]
);
}
/// `CHAT_MSG_ADDON` carries prefix, message, distribution, sender, in that order (`0x49a95f`).
#[test]
fn the_addon_event_reaches_lua_with_four_arguments_in_order() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.run(
r#"
seen = nil
f = CreateFrame("Frame", "AddonSink")
f:RegisterEvent("CHAT_MSG_ADDON")
f:SetScript("OnEvent", function()
seen = { arg1, arg2, arg3, arg4 }
end)
"#,
)
.unwrap();
super::feed::fire_addon_message(
&mut s,
"oRA".into(),
"SYNC:1".into(),
"PARTY".into(),
"Someone".into(),
);
assert!(
s.errors().is_empty(),
"the fire must not raise: {:?}",
s.errors()
);
assert_eq!(
s.eval::<String>("return seen[1]").unwrap(),
"oRA",
"arg1 is the PREFIX"
);
assert_eq!(
s.eval::<String>("return seen[2]").unwrap(),
"SYNC:1",
"arg2 is the MESSAGE"
);
assert_eq!(
s.eval::<String>("return seen[3]").unwrap(),
"PARTY",
"arg3 is the DISTRIBUTION, not the sender"
);
assert_eq!(
s.eval::<String>("return seen[4]").unwrap(),
"Someone",
"arg4 is the SENDER, and a name rather than a guid"
);
}
/// The send joins on a tab (`0x49f9b3`) and the receive splits on the first (`0x49a8d0`), so a
/// payload's own tabs come back intact.
#[test]
fn an_addon_message_survives_its_own_send_and_receive() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.run(r#"SendAddonMessage("oRA", "SYNC\t1\t2", "PARTY")"#)
.unwrap();
assert!(s.errors().is_empty(), "send raised: {:?}", s.errors());
let sends = s.take_addon_sends();
assert_eq!(sends.len(), 1, "one broadcast queued");
let sent = &sends[0];
assert_eq!(sent.distribution.token(), "PARTY");
// The same text arrives as a PARTY line in LANG_ADDON.
let mut log = super::feed::ChatLog::default();
log.push_addon(&sent.text, 0x01, 7);
assert_eq!(
log.pending_addons(),
vec![(
"oRA".to_string(),
"SYNC\t1\t2".to_string(),
"PARTY".to_string()
)],
"what one half composed, the other must recover — tabs in the payload included"
);
}
/// The header shows unless arg3 is empty, "Universal" or `this.defaultLanguage`
/// (`ChatFrame.lua:1442`), the faction's language (`GetDefaultLanguage`, `0x5ec890`).
#[test]
fn the_language_header_suppresses_only_the_frames_own_default_tongue() {
let _data = benilla_formats::wow_data_or_skip!();
let orcish = ChatEvent {
language: "Orcish".into(),
..ev(K::Say, "lok'tar", "Grom")
};
let common = ChatEvent {
language: "Common".into(),
..ev(K::Say, "hello", "Ann")
};
// An Alliance body (default Common): Orcish is tagged, Common is not.
assert_eq!(
compose(&orcish, K::Say, "Common").unwrap(),
"|Hplayer:Grom|h[Grom]|h says: [Orcish] lok'tar"
);
assert_eq!(
compose(&common, K::Say, "Common").unwrap(),
"|Hplayer:Ann|h[Ann]|h says: hello"
);
// A Horde body (default Orcish): the mirror.
assert_eq!(
compose(&orcish, K::Say, "Orcish").unwrap(),
"|Hplayer:Grom|h[Grom]|h says: lok'tar"
);
assert_eq!(
compose(&common, K::Say, "Orcish").unwrap(),
"|Hplayer:Ann|h[Ann]|h says: [Common] hello"
);
// Language 0 arrives as an empty arg3 and is never tagged.
let universal = ev(K::Say, "system", "Ann");
assert_eq!(
compose(&universal, K::Say, "Orcish").unwrap(),
"|Hplayer:Ann|h[Ann]|h says: system"
);
// Comprehension does not matter: an understood foreign tongue is still tagged.
let dwarvish = ChatEvent {
language: "Dwarvish".into(),
..ev(K::Say, "here we go", "Bran")
};
assert_eq!(
compose(&dwarvish, K::Say, "Common").unwrap(),
"|Hplayer:Bran|h[Bran]|h says: [Dwarvish] here we go"
);
}
/// The gesture selector in the parser `0x49d560` (`0x49d820`-`0x49d8ae`) matches the plaintext that
/// `0x49dbc2` then hands to the display path `0x49a870`, so a laugh is language-independent.
#[test]
fn the_talk_gesture_reads_the_plaintext_not_the_garbled_line() {
let _data = benilla_formats::wow_data_or_skip!();
use crate::creature_anim::{select_gesture, Gesture};
use benilla_protocol::messages::CHAT_MSG_SAY;
let Some(data) = benilla_formats::wow_data() else {
return; // no client data
};
let mut chain = benilla_formats::Chain::open(&data).expect("open patch chain");
let words = benilla_formats::load_language_words(&mut chain).expect("load word pools");
// An Orcish `lol` heard by someone with no Orcish at all.
let garbled = benilla_formats::garble_chat(&words, 1, 0, "lol");
assert_ne!(garbled, "lol", "the two inputs must actually differ");
let laugh_words = |n: u32| (n == 1).then(|| "lol".to_string());
assert_eq!(
select_gesture(CHAT_MSG_SAY, "lol", laugh_words),
Some(Gesture::Laugh),
"the plaintext laughs"
);
assert_eq!(
select_gesture(CHAT_MSG_SAY, &garbled, laugh_words),
Some(Gesture::Talk),
"the garbled form would NOT laugh — which is why the feed must pass the plaintext"
);
}
/// `0x49d7f7`: a speaker whose `UNIT_FIELD_FLAGS` hold `0x20000000` (Polymorph, Kidney Shot, ...)
/// plays no talk gesture on the immediate path; the copies behind a name query (`0x49ccc0`,
/// `0x49d230`) have no such test.
#[test]
fn a_speaker_that_cannot_animate_queues_no_talk_gesture() {
use super::feed::gesture_prevented;
use crate::creature_anim::select_gesture;
use benilla_protocol::messages::CHAT_MSG_SAY;
let laugh_words = |n: u32| (n == 1).then(|| "lol".to_string());
let speak = |tries: u16, flags: Option<u32>| {
select_gesture(CHAT_MSG_SAY, "lol", laugh_words)
.filter(|_| !gesture_prevented(tries, flags))
.into_iter()
.count()
};
assert_eq!(speak(0, Some(0x2000_0000)), 0, "a polymorphed speaker");
assert_eq!(speak(0, Some(0x2000_0000 | 0x8)), 0, "among other bits");
assert_eq!(speak(0, Some(0)), 1, "an unflagged speaker laughs");
assert_eq!(
speak(0, Some(0x1000_0000 | 0x0100_0000)),
1,
"neighbouring bits do not gate"
);
assert_eq!(
speak(0, None),
1,
"an unstreamed speaker is the queue's call, not this gate"
);
assert_eq!(
speak(3, Some(0x2000_0000)),
1,
"a line held for the name has no such test"
);
}
// ── the combat log reaches addons ────────────────────────────────────────────────────────────
/// Combat-log addons parse arg1 with patterns built from the GlobalStrings, so arg1 is the whole
/// sentence.
#[test]
fn an_addon_sees_the_combat_log_line_it_registers_for() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_vm();
let mut windows = super::frames::ChatWindows::default();
s.run(SPY).unwrap();
s.run(r#"BenillaChatSpy:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE")"#)
.unwrap();
let line = "Your Fireball hits Kobold Vermin for 120 fire damage.";
super::frames::route(
&mut s,
&mut windows,
&ChatEvent::text_only(K::SpellSelfDamage, line.into()),
);
assert_eq!(
s.eval::<String>("return SpyEvent").unwrap(),
"CHAT_MSG_SPELL_SELF_DAMAGE",
"the event an addon registered is the event that fired"
);
assert_eq!(s.eval::<i64>("return SpyN").unwrap(), 1);
let seen: String = s.eval("return SpyLine").unwrap();
assert!(
seen.starts_with(&format!("{line}|")),
"arg1 must be the whole sentence, not a fragment — got {seen:?}"
);
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
}
#[test]
fn every_combat_log_kind_reaches_an_addon() {
let _data = benilla_formats::wow_data_or_skip!();
for kind in K::ALL.iter().copied().filter(|k| k.is_combat_log()) {
let name = super::event::event_name(kind);
let mut s = chat_vm();
let mut windows = super::frames::ChatWindows::default();
s.run(SPY).unwrap();
s.run(&format!(r#"BenillaChatSpy:RegisterEvent("{name}")"#))
.unwrap();
let line = format!("a line for {name}");
super::frames::route(
&mut s,
&mut windows,
&ChatEvent::text_only(kind, line.clone()),
);
assert_eq!(
s.eval::<String>("return SpyEvent").unwrap(),
name,
"{name} did not fire under its own name"
);
assert_eq!(
s.eval::<i64>("return SpyN").unwrap(),
1,
"{name} fired more than once"
);
let seen: String = s.eval("return SpyLine").unwrap();
assert!(
seen.starts_with(&format!("{line}|")),
"{name}: arg1 must be the whole sentence — got {seen:?}"
);
assert!(
s.errors().is_empty(),
"{name} handler errors: {:?}",
s.errors()
);
}
}
/// The `COMBAT_` and `SPELL_` arms only `AddMessage(arg1)` (`ChatFrame.lua:1397`, `:1399`).
#[test]
fn a_combat_log_line_renders_verbatim() {
let _data = benilla_formats::wow_data_or_skip!();
let default_language = String::from("Common");
for kind in K::ALL.iter().copied().filter(|k| k.is_combat_log()) {
let mut e = ChatEvent::text_only(kind, "You hit Kobold Vermin for 5.".into());
// Populated so a fall-through to the player branch would show.
e.sender = "Somebody".into();
e.flag = "AFK".into();
e.language = "Orcish".into();
assert_eq!(
compose(&e, kind, &default_language).as_deref(),
Some("You hit Kobold Vermin for 5."),
"{} must render verbatim",
super::event::event_name(kind)
);
}
}
#[test]
fn both_dock_tabs_exist_and_select_their_window() {
let _data = benilla_formats::wow_data_or_skip!();
let s = chat_vm();
for id in [1, 2] {
assert!(
s.eval::<bool>(&format!("return ChatFrame{id}Tab ~= nil"))
.unwrap(),
"ChatFrame{id}Tab is missing — its window is unreachable from the screen"
);
}
// The default dock: General selected and shown, Combat Log hidden behind its tab.
assert_eq!(
s.eval::<i64>("return SELECTED_DOCK_FRAME:GetID()").unwrap(),
1
);
assert!(s.eval::<bool>("return ChatFrame1:IsShown()").unwrap());
assert!(!s.eval::<bool>("return ChatFrame2:IsShown()").unwrap());
// Clicking the Combat Log tab swaps them.
s.run("FCF_SelectDockFrame(ChatFrame2)").unwrap();
assert_eq!(
s.eval::<i64>("return SELECTED_DOCK_FRAME:GetID()").unwrap(),
2
);
assert!(s.eval::<bool>("return ChatFrame2:IsShown()").unwrap());
assert!(!s.eval::<bool>("return ChatFrame1:IsShown()").unwrap());
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
}
/// The stock tabs default to the `GENERAL` and `COMBAT_LOG` GlobalStrings
/// (`FloatingChatFrame.lua:684-686`).
#[test]
fn the_dock_tab_labels_come_from_the_install() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
.expect("GlobalStrings.lua in the chain");
let s = benilla_ui::script::UiScript::new().expect("VM");
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
for key in ["GENERAL", "COMBAT_LOG"] {
let text: String = s.lua().globals().get(key).unwrap_or_default();
assert!(!text.is_empty(), "{key} is not a GlobalString");
}
}
#[test]
fn the_combat_log_window_has_the_docks_rect() {
let _data = benilla_formats::wow_data_or_skip!();
let s = chat_vm();
let edge = |frame: &str, get: &str| {
s.eval::<Option<f64>>(&format!("return {frame}:{get}()"))
.unwrap()
};
for get in ["GetLeft", "GetRight", "GetTop", "GetBottom"] {
let one = edge("ChatFrame1", get);
let two = edge("ChatFrame2", get);
assert!(
one.is_some(),
"ChatFrame1:{get}() is nil — the dock has no rect"
);
assert_eq!(
two, one,
"ChatFrame2:{get}() must equal ChatFrame1's — a docked window shares the dock's rect"
);
}
// `FCF_DockUpdate` anchors a docked window by three points (`FloatingChatFrame.lua:1059-1063`),
// so 3 means the dock seeding ran.
assert_eq!(
s.eval::<i64>("return ChatFrame2:GetNumPoints()").unwrap(),
3,
"FCF_DockUpdate's three points — any other count means the dock never seeded this window"
);
assert_eq!(
s.eval::<i64>("return table.getn(DOCKED_CHAT_FRAMES)")
.unwrap(),
2,
"the dock is ChatFrame1 + ChatFrame2, seeded from GetChatWindowInfo's stored positions"
);
assert_eq!(
s.eval::<String>("return SELECTED_DOCK_FRAME:GetName()")
.unwrap(),
"ChatFrame1"
);
assert!(
s.eval::<bool>(
"return ChatFrame1.isDocked and ChatFrame2.isDocked and not ChatFrame3.isDocked"
)
.unwrap(),
"FCF_DockFrame set the flag on the dock's two and nothing else"
);
}
/// The stock fade and tint walk `CHAT_FRAME_TEXTURES` by frame name (`FloatingChatFrame.lua:14`),
/// so each dock window carries all nine pieces.
#[test]
fn both_dock_windows_carry_the_same_chrome() {
let _data = benilla_formats::wow_data_or_skip!();
let s = chat_vm();
// Read out of the VM, so the check follows the list.
let suffixes: Vec<String> = (1..=9)
.map(|i| {
s.eval::<String>(&format!("return CHAT_FRAME_TEXTURES[{i}]"))
.unwrap()
})
.collect();
assert_eq!(
suffixes.len(),
9,
"the background plus the eight resize grips' textures"
);
for suffix in &suffixes {
for id in [1, 2] {
assert!(
s.eval::<bool>(&format!("return ChatFrame{id}{suffix} ~= nil"))
.unwrap(),
"ChatFrame{id}{suffix} is missing — the dock's chrome dies when window {id} is up"
);
}
}
// Each texture's owning grip Button exists too.
for grip in [
"TopLeft",
"TopRight",
"BottomLeft",
"BottomRight",
"Top",
"Bottom",
"Left",
"Right",
] {
for id in [1, 2] {
assert!(
s.eval::<bool>(&format!("return ChatFrame{id}Resize{grip} ~= nil"))
.unwrap(),
"ChatFrame{id}Resize{grip} is missing — the window has art where a handle should be"
);
}
}
}
/// The stock pass anchors its `ChatFrame2` row (`UIParent.lua:1582`), then `FCF_DockUpdate()`
/// re-anchors every docked window onto `DEFAULT_CHAT_FRAME`.
#[test]
fn a_docked_chat_window_survives_the_managed_position_pass() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = benilla_ui::script::UiScript::new().expect("VM");
s.set_screen_size(1024.0, 768.0);
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probefour".into()),
level: 60,
..Default::default()
}),
);
assert!(crate::ui_script::load_default_ui(&s).is_empty());
s.resolve();
// Dock the combat log the way the reference's own default layout does, then run the pass.
s.run("FCF_DockFrame(ChatFrame2, 2, nil) UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
let rect = |name: &str| {
s.eval::<(f64, f64, f64, f64)>(&format!(
"return {name}:GetLeft(), {name}:GetBottom(), {name}:GetRight(), {name}:GetTop()"
))
.unwrap_or_else(|e| panic!("{name}: {e}"))
};
assert_eq!(
rect("ChatFrame2"),
rect("ChatFrame1"),
"the docked window still sits exactly on the dock"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The swim bit (`EmoteFlags & 0x0080`, `0x47db7d`) is clear on every posture emote, so the
/// refusal to sit in water is the stand-state setter's (`player::state::stand_state_refused`).
#[test]
fn the_posture_emotes_carry_no_swim_suppression_flag() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let cat = benilla_formats::load_emote_sound_catalog(&mut chain).expect("emote catalog");
// Every posture row, found by scanning.
let posture: Vec<(u32, u32, u32)> = (0..600u32)
.filter_map(|id| Some((id, cat.posture_state(id)?, cat.emote_flags(id)?)))
.collect();
let states: Vec<u32> = posture.iter().map(|&(_, s, _)| s).collect();
for state in [0u32, 1, 3, 8] {
assert!(
states.contains(&state),
"the shipped table has a posture emote for stand state {state}; found {states:?}"
);
}
for (id, state, flags) in posture {
assert_eq!(
flags & 0x0080,
0,
"posture emote {id} (state {state}) carries the swim-suppress bit: {flags:#x}"
);
// STATE_DEAD (emote 65, `0x6602`) carries the unconditional suppress bit `0x0400`.
if flags & 0x0400 != 0 {
assert_eq!(state, 7, "only STATE_DEAD is unconditionally suppressed");
continue;
}
// A standing swimmer's posture emote passes the gate.
assert_eq!(
super::input::emote_send_eligible(flags, 0, true, 0),
super::input::EmoteGate::Send,
"posture emote {id} passes the emote gate while swimming"
);
}
}
/// `SMSG_LEVELUP_INFO`'s gains park on `ChatLog` until the level edge that matches them.
#[test]
fn level_up_gains_are_matched_by_level_and_a_miss_is_not_an_absence() {
use benilla_protocol::messages::LevelUpInfo;
let mut log = super::feed::ChatLog::default();
let ding = LevelUpInfo {
level: 10,
health: 22,
powers: [15, 0, 0, 0, 0],
stats: [0, 1, 2, 3, 0],
};
log.push_level_up_gains(&ding, 1);
// A level edge that is not this ding's leaves the entry parked...
assert!(log.take_level_up_gains(11).is_none(), "wrong level matched");
// ...and the right one takes it, exactly once.
let (got, talent_points) = log.take_level_up_gains(10).expect("parked for level 10");
assert_eq!(got, ding);
assert_eq!(talent_points, 1);
assert!(
log.take_level_up_gains(10).is_none(),
"the entry was taken, not copied"
);
}
/// Stock `ChatFrame_OnEvent` prints the whole level-up block from `PLAYER_LEVEL_UP`
/// (`ChatFrame.lua:1283-1323`); the app composes none of it.
#[test]
fn the_ding_block_is_printed_once() {
let _data = benilla_formats::wow_data_or_skip!();
use benilla_protocol::messages::LevelUpInfo;
let mut s = chat_vm();
let mut log = super::ChatLog::default();
let info = LevelUpInfo {
level: 10,
health: 22,
powers: [15, 0, 0, 0, 0],
stats: [1, 0, 0, 0, 0],
};
// The packet's apply parks the gains and prints nothing.
log.push_level_up_gains(&info, 1);
let before = lines_in_window(&s);
assert_eq!(
lines_in_window(&s) - before,
0,
"the app composes no ding line of its own"
);
// Tap the window's `AddMessage` to read the block itself.
s.run(
r#"
DingLines = {}
local add = ChatFrame1.AddMessage
ChatFrame1.AddMessage = function(self, text, ...)
table.insert(DingLines, text)
return add(self, text, unpack(arg))
end
"#,
)
.unwrap();
// The event `ui_unit` fires, with the reference's nine arguments.
let args: Vec<benilla_ui::script::ScriptValue> = [10i64, 22, 15, 1, 1, 0, 0, 0, 0]
.into_iter()
.map(benilla_ui::script::ScriptValue::Int)
.collect();
s.fire_event("PLAYER_LEVEL_UP", args);
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
assert_eq!(
lines_in_window(&s) - before,
4,
"LEVEL_UP, the health/mana pair, CHAR_POINTS, and one STAT — once each"
);
// The singular `LEVEL_UP_CHAR_POINTS` (`GetText`'s plural pick) and one `LEVEL_UP_STAT`.
let lines: Vec<String> = (1..=4)
.map(|i| s.eval::<String>(&format!("return DingLines[{i}]")).unwrap())
.collect();
assert_eq!(
lines,
[
"Congratulations, you have reached level 10!",
"You have gained 22 hit points and 15 mana.",
"You have gained 1 talent point.",
"Your Strength increases by 1.",
]
);
}
/// On `CHARACTER_POINTS_CHANGED` with `arg2 > 0`, stock `ChatFrame_OnEvent` prints
/// `LEVEL_UP_SKILL_POINTS` (`ChatFrame.lua:1324-1334`).
#[test]
fn the_free_professions_line_is_printed_once() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_vm();
s.set_talents(benilla_ui::script::TalentUiState {
points: (0, 2),
..Default::default()
});
s.run(
r#"
SkillLines = {}
local add = ChatFrame1.AddMessage
ChatFrame1.AddMessage = function(self, text, ...)
table.insert(SkillLines, text)
return add(self, text, unpack(arg))
end
"#,
)
.unwrap();
let before = lines_in_window(&s);
// `ui_talent`'s fire: arg1 = the talent delta, arg2 = the profession delta.
s.fire_event(
"CHARACTER_POINTS_CHANGED",
vec![
benilla_ui::script::ScriptValue::Int(0),
benilla_ui::script::ScriptValue::Int(1),
],
);
assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors());
assert_eq!(
lines_in_window(&s) - before,
1,
"the stock frame prints the professions line from the event"
);
assert_eq!(
s.eval::<String>("return SkillLines[1]").unwrap(),
"You now have 2 free professions.",
"LEVEL_UP_SKILL_POINTS_P1, plural-picked by GetText on cp2"
);
}
// ───────────── the chat cache restores inside the login ─────────
/// `UPDATE_CHAT_WINDOWS` registers the chat frames for `CHAT_MSG_*`, and the `UPDATE_CHAT_COLOR`
/// burst repaints id-0 lines through `ChatTypeInfo["REPLY"]`, so both come before `PLAYER_LOGIN`.
/// The probe is a loose addon, because the entry load builds the VM it runs on.
#[test]
fn the_chat_cache_restore_is_finished_before_player_login() {
let _data = benilla_formats::wow_data_or_skip!();
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = std::env::temp_dir().join(format!("benilla-chat-order-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let home = tmp.join("benilla-config");
let probe = home.join("AddOns").join("ChatOrderProbe");
std::fs::create_dir_all(&probe).expect("hermetic home + probe addon dir");
std::fs::write(
probe.join("ChatOrderProbe.toc"),
"## Interface: 11200\nChatOrderProbe.lua\n",
)
.expect("probe toc");
std::fs::write(
probe.join("ChatOrderProbe.lua"),
r#"
ChatOrderProbe = { order = "" }
local f = CreateFrame("Frame")
f:RegisterEvent("VARIABLES_LOADED")
f:RegisterEvent("UPDATE_CHAT_WINDOWS")
f:RegisterEvent("UPDATE_CHAT_COLOR")
f:RegisterEvent("PLAYER_LOGIN")
f:SetScript("OnEvent", function()
-- The burst is 100+ events; record it once so the order string stays readable.
if not string.find(ChatOrderProbe.order, event, 1, 1) then
ChatOrderProbe.order = ChatOrderProbe.order .. event .. " "
end
if event == "UPDATE_CHAT_WINDOWS" then
ChatOrderProbe.windows = (ChatOrderProbe.windows or 0) + 1
elseif event == "UPDATE_CHAT_COLOR" then
ChatOrderProbe.colors = (ChatOrderProbe.colors or 0) + 1
elseif event == "PLAYER_LOGIN" then
ChatOrderProbe.loginWindows = ChatOrderProbe.windows or 0
ChatOrderProbe.loginColors = ChatOrderProbe.colors or 0
ChatOrderProbe.loginRegistered =
(ChatFrame1 and ChatFrame1.messageTypeList
and table.concat(ChatFrame1.messageTypeList, ",")) or ""
end
end)
"#,
)
.expect("probe lua");
let _capture = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE");
let _home =
crate::local_state::test_env::EnvGuard::set("BENILLA_HOME", home.to_str().expect("utf-8"));
let mut world = bevy::prelude::World::new();
world.init_resource::<crate::ui_script::AddOnIdentity>();
world.init_resource::<crate::minimap::MinimapZoom>();
world.init_resource::<crate::ui_script::ReloadUiPending>();
world.init_resource::<super::edit::ChannelState>();
world.init_resource::<super::settings::ChatWindowFile>();
crate::ui_script::setup_script(&mut world);
world.insert_resource(crate::char_select::Roster::with_pending_pick(
vec![benilla_protocol::Character {
guid: 1,
name: "Probeorder".into(),
race: 1, // Human → Alliance
class: 1, // Warrior
gender: 0,
level: 60,
skin: 0,
face: 0,
hair_style: 0,
hair_color: 0,
facial_hair: 0,
zone: 0,
map: 0,
position: benilla_protocol::wire::Vector3d {
x: 0.0,
y: 0.0,
z: 0.0,
},
flags: 0,
equipment: [benilla_protocol::CharEnumItem::default(); 19],
pet_display_id: 0,
pet_level: 0,
pet_family: 0,
}],
1,
));
crate::ui_script::load_ingame_ui_on_world_entry(&mut world);
let read = |expr: &str| -> String {
world
.non_send_resource::<benilla_ui::script::UiScript>()
.eval::<Option<String>>(&format!("return tostring({expr})"))
.ok()
.flatten()
.unwrap_or_default()
};
assert_eq!(
read("ChatOrderProbe.loginWindows"),
"1",
"UPDATE_CHAT_WINDOWS must have fired before PLAYER_LOGIN — it is the only thing that \
registers a chat frame for CHAT_MSG_*, so a line routed before it is dropped in silence"
);
assert_ne!(
read("ChatOrderProbe.loginColors"),
"0",
"the UPDATE_CHAT_COLOR burst must precede PLAYER_LOGIN — after it, its WHISPER→REPLY \
mirror repaints every already-printed AceConsole line whisper-pink"
);
assert!(
read("ChatOrderProbe.loginRegistered").contains("SYSTEM"),
"ChatFrame1 must carry the SYSTEM message group at PLAYER_LOGIN, not {:?}",
read("ChatOrderProbe.loginRegistered")
);
// The reference's login order: `ADDON_LOADED` (`0x4900a3`), `VARIABLES_LOADED` (`0x4900b2`),
// the chat-cache burst (`0x4900d6`), `PLAYER_LOGIN` (`0x490959`).
assert_eq!(
read("ChatOrderProbe.order"),
"VARIABLES_LOADED UPDATE_CHAT_WINDOWS UPDATE_CHAT_COLOR PLAYER_LOGIN ",
"the reference fires the chat-cache burst BETWEEN VARIABLES_LOADED and PLAYER_LOGIN"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// `/afk` then `/dnd` through the real drain: every type but AFK clears a standing AFK first
/// (`0x49f4f6 jne 0x49f3c7`), so `/dnd` (`0x15`) prints the clear and sends the empty `0x14` ahead
/// of its own line and packet, three lines in all.
#[test]
fn afk_then_dnd_clears_the_afk_first() {
use crate::net::{ChatKind, ClientCommand, NetCommands};
use bevy::ecs::system::RunSystemOnce;
let mut world = bevy::prelude::World::new();
world.insert_non_send_resource(benilla_ui::script::UiScript::new().expect("VM"));
let (tx, rx) = crossbeam_channel::unbounded();
world.insert_resource(NetCommands(tx));
world.init_resource::<super::feed::ChatLog>();
world.init_resource::<super::away::AfkMirror>();
world.init_resource::<crate::cvars::Cvars>();
world.init_resource::<super::edit::ChannelState>();
// The five `GlobalStrings.lua` keys the away lines read, at their enUS values.
world
.non_send_resource::<benilla_ui::script::UiScript>()
.run(
r#"
MARKED_AFK_MESSAGE = "You are now AFK: %s"
CLEARED_AFK = "You are no longer AFK."
MARKED_DND = "You are now DND: %s."
DEFAULT_AFK_MESSAGE = "Away from Keyboard"
DEFAULT_DND_MESSAGE = "Do not Disturb"
SendChatMessage("", "AFK")
SendChatMessage("", "DND")
"#,
)
.expect("lua");
world
.run_system_once(super::input::drain_addon_chat_sends)
.expect("drain");
assert_eq!(
world.resource::<super::feed::ChatLog>().pending_lines(),
vec![
"You are now AFK: Away from Keyboard",
"You are no longer AFK.",
"You are now DND: Do not Disturb.",
]
);
let sent: Vec<(ChatKind, String)> = rx
.try_iter()
.map(|c| match c {
ClientCommand::Chat { kind, text, .. } => (kind, text),
other => panic!("unexpected command {other:?}"),
})
.collect();
assert_eq!(
sent,
vec![
(ChatKind::Afk, "Away from Keyboard".into()),
(ChatKind::Afk, String::new()),
(ChatKind::Dnd, "Do not Disturb".into()),
]
);
assert!(!world.resource::<super::away::AfkMirror>().is_afk());
}
/// `SendChatMessage`'s `CHANNEL` target through the real drain: `SStrToInt` into `0x49be50`
/// (`0x49f4d9`-`0x49f4ea`), so the packet carries the numbered slot's name, and a number naming
/// no confirmed slot, or a name, sends nothing at all.
#[test]
fn a_channel_send_carries_the_numbered_slots_name() {
use super::edit::{ChannelSlot, ChannelState, SlotState};
use crate::net::{ChatKind, ClientCommand, NetCommands};
use bevy::ecs::system::RunSystemOnce;
let mut world = bevy::prelude::World::new();
world.insert_non_send_resource(benilla_ui::script::UiScript::new().expect("VM"));
let (tx, rx) = crossbeam_channel::unbounded();
world.insert_resource(NetCommands(tx));
world.init_resource::<super::feed::ChatLog>();
world.init_resource::<super::away::AfkMirror>();
world.init_resource::<crate::cvars::Cvars>();
world.insert_resource(ChannelState {
joined: vec![
Some(ChannelSlot::joined("General - Elwynn Forest")),
Some(ChannelSlot::joined("Trade - City")),
None,
Some(ChannelSlot {
name: "LocalDefense - Elwynn Forest".into(),
state: SlotState::Suspended,
}),
],
..Default::default()
});
world
.non_send_resource::<benilla_ui::script::UiScript>()
.run(
r#"
SendChatMessage("lf1m", "CHANNEL", nil, 2)
SendChatMessage("hello", "CHANNEL", nil, "1")
SendChatMessage("float", "CHANNEL", nil, 2.7)
SendChatMessage("hole", "CHANNEL", nil, 3)
SendChatMessage("suspended", "CHANNEL", nil, 4)
SendChatMessage("past the end", "CHANNEL", nil, 9)
SendChatMessage("by name", "CHANNEL", nil, "General - Elwynn Forest")
SendChatMessage("zero", "CHANNEL", nil, "0")
SendChatMessage("none", "CHANNEL")
SendChatMessage("tell", "WHISPER", nil, "2")
"#,
)
.expect("lua");
world
.run_system_once(super::input::drain_addon_chat_sends)
.expect("drain");
let sent: Vec<(ChatKind, Option<String>, String)> = rx
.try_iter()
.map(|c| match c {
ClientCommand::Chat { kind, target, text } => (kind, target, text),
other => panic!("unexpected command {other:?}"),
})
.collect();
let chan = |target: &str, text: &str| {
(
ChatKind::Channel,
Some(target.to_string()),
text.to_string(),
)
};
assert_eq!(
sent,
vec![
chan("Trade - City", "lf1m"),
chan("General - Elwynn Forest", "hello"),
chan("Trade - City", "float"),
(ChatKind::Whisper, Some("2".into()), "tell".into()),
]
);
}