1use std::collections::HashMap;
8
9use serde::Deserialize;
10
11#[derive(Clone, Debug, PartialEq, Deserialize)]
13pub struct Emote {
14 pub name: String,
15 pub url: String,
16 pub provider: String,
17 #[serde(default)]
18 pub id: String,
19 #[serde(default)]
20 pub animated: bool,
21 #[serde(rename = "zeroWidth", default)]
22 pub zero_width: bool,
23 #[serde(skip)]
26 pub global: bool,
27}
28
29#[derive(Debug, Deserialize)]
31pub struct EmoteSetResponse {
32 #[serde(default)]
33 pub emotes: Vec<Emote>,
34}
35
36#[derive(Clone, Debug, Default)]
38pub struct EmoteSet {
39 map: HashMap<String, Emote>,
40}
41
42pub fn owner_list(v: &serde_json::Value, base: &str) -> Vec<Emote> {
46 let Some(rows) = v.get("emotes").and_then(|e| e.as_array()) else {
47 return Vec::new();
48 };
49 rows.iter()
50 .filter_map(|r| {
51 let s = |k: &str| r.get(k).and_then(|x| x.as_str()).filter(|x| !x.is_empty());
52 let name = s("name")?;
53 let url = s("url")?;
54 let url = if url.starts_with('/') && !url.starts_with("//") {
55 format!("{base}{url}")
56 } else if url.starts_with("https://") {
57 url.to_string()
58 } else {
59 return None;
60 };
61 Some(Emote {
62 name: name.to_string(),
63 url,
64 provider: s("provider").unwrap_or("upload").to_string(),
65 id: s("hash").unwrap_or_default().to_string(),
66 animated: false,
67 zero_width: false,
68 global: false,
69 })
70 })
71 .collect()
72}
73
74impl EmoteSet {
75 pub fn new() -> EmoteSet {
76 EmoteSet::default()
77 }
78
79 pub fn none() -> &'static EmoteSet {
81 static NONE: std::sync::OnceLock<EmoteSet> = std::sync::OnceLock::new();
82 NONE.get_or_init(EmoteSet::new)
83 }
84
85 pub fn from_list(list: impl IntoIterator<Item = Emote>) -> EmoteSet {
89 let mut set = EmoteSet::new();
90 for e in list {
91 set.map.insert(e.name.clone(), e);
92 }
93 set
94 }
95
96 pub fn from_global_list(list: impl IntoIterator<Item = Emote>) -> EmoteSet {
100 const WINNER_FIRST: [&str; 4] = ["7tv", "bttv", "ffz", "twitch"];
101 let rank = |e: &Emote| {
102 WINNER_FIRST
103 .iter()
104 .position(|p| *p == e.provider)
105 .unwrap_or(WINNER_FIRST.len())
106 };
107 let mut list: Vec<Emote> = list.into_iter().collect();
108 list.sort_by_key(|e| std::cmp::Reverse(rank(e)));
110 let mut set = EmoteSet::from_list(list);
111 set.mark_global();
112 set
113 }
114
115 pub fn merge(&mut self, other: EmoteSet) {
121 for (k, v) in other.map {
122 match self.map.get(&k) {
123 Some(cur) if cur.global && !v.global => {
124 self.map.insert(k, v);
125 }
126 Some(_) => {}
127 None => {
128 self.map.insert(k, v);
129 }
130 }
131 }
132 }
133
134 pub fn mark_global(&mut self) {
136 for e in self.map.values_mut() {
137 e.global = true;
138 }
139 }
140
141 pub fn get(&self, name: &str) -> Option<&Emote> {
142 self.map.get(name)
143 }
144
145 pub fn names(&self) -> impl Iterator<Item = &str> {
147 self.map.keys().map(String::as_str)
148 }
149
150 pub fn len(&self) -> usize {
151 self.map.len()
152 }
153
154 pub fn is_empty(&self) -> bool {
155 self.map.is_empty()
156 }
157}
158
159#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
163pub enum Effect {
164 RotateL, RotateR, FlipX, FlipY, Wide, Tall, Cursed, Hyper, Party, Rainbow, Shake, Bounce, Jam, Slide, Arrive, Leave, Spin, }
182
183const CODES: [(Effect, char); 17] = [
185 (Effect::RotateL, 'l'),
186 (Effect::RotateR, 'r'),
187 (Effect::FlipX, 'x'),
188 (Effect::FlipY, 'y'),
189 (Effect::Wide, 'w'),
190 (Effect::Tall, 't'),
191 (Effect::Cursed, 'c'),
192 (Effect::Hyper, 'H'),
193 (Effect::Party, 'p'),
194 (Effect::Rainbow, 'R'),
195 (Effect::Shake, 's'),
196 (Effect::Bounce, 'b'),
197 (Effect::Jam, 'j'),
198 (Effect::Slide, 'd'),
199 (Effect::Arrive, 'a'),
200 (Effect::Leave, 'e'),
201 (Effect::Spin, 'o'),
202];
203
204#[derive(Clone, Copy)]
206enum Tok {
207 Fx(Effect),
208 Zero,
209}
210
211const TOKENS: [(&str, Tok); 25] = [
214 ("w!", Tok::Fx(Effect::Wide)),
215 ("h!", Tok::Fx(Effect::FlipX)),
216 ("v!", Tok::Fx(Effect::FlipY)),
217 ("z!", Tok::Zero),
218 ("c!", Tok::Fx(Effect::Cursed)),
219 ("l!", Tok::Fx(Effect::RotateL)),
220 ("r!", Tok::Fx(Effect::RotateR)),
221 ("p!", Tok::Fx(Effect::Party)),
222 ("s!", Tok::Fx(Effect::Shake)),
223 ("x!", Tok::Fx(Effect::FlipX)),
224 ("y!", Tok::Fx(Effect::FlipY)),
225 ("ffzX", Tok::Fx(Effect::FlipX)),
226 ("ffzY", Tok::Fx(Effect::FlipY)),
227 ("ffzW", Tok::Fx(Effect::Wide)),
228 ("ffzWide", Tok::Fx(Effect::Wide)),
229 ("ffzTall", Tok::Fx(Effect::Tall)),
230 ("ffzCursed", Tok::Fx(Effect::Cursed)),
231 ("ffzHyper", Tok::Fx(Effect::Hyper)),
232 ("ffzRainbow", Tok::Fx(Effect::Rainbow)),
233 ("ffzBounce", Tok::Fx(Effect::Bounce)),
234 ("ffzJam", Tok::Fx(Effect::Jam)),
235 ("ffzSlide", Tok::Fx(Effect::Slide)),
236 ("ffzArrive", Tok::Fx(Effect::Arrive)),
237 ("ffzLeave", Tok::Fx(Effect::Leave)),
238 ("ffzSpin", Tok::Fx(Effect::Spin)),
239];
240
241const MAX_SCALE: i32 = 4;
243const MAX_REPEAT: usize = 4;
246
247impl Effect {
248 pub fn code(self) -> char {
250 CODES.iter().find(|(e, _)| *e == self).map_or('?', |c| c.1)
251 }
252
253 fn from_code(c: char) -> Option<Effect> {
254 CODES.iter().find(|(_, k)| *k == c).map(|c| c.0)
255 }
256
257 fn composes(self) -> bool {
260 self <= Effect::Tall
261 }
262
263 pub fn is_motion(self) -> bool {
265 self >= Effect::Shake
266 }
267}
268
269#[derive(Clone, Copy, Debug, PartialEq, Eq)]
272pub struct Geometry {
273 pub sx: i32,
274 pub sy: i32,
275 pub rot: u32,
277}
278
279pub fn compose(fx: &[Effect]) -> Geometry {
280 let (mut sx, mut sy, mut rot) = (1i32, 1i32, 0i32);
281 for f in fx {
282 match f {
283 Effect::Wide => sx = sx.saturating_mul(2),
284 Effect::Tall => sy = sy.saturating_mul(2),
285 Effect::FlipX => sx = -sx,
286 Effect::FlipY => sy = -sy,
287 Effect::RotateL => rot -= 90,
288 Effect::RotateR => rot += 90,
289 _ => {}
290 }
291 }
292 Geometry {
293 sx: sx.clamp(-MAX_SCALE, MAX_SCALE),
294 sy: sy.clamp(-MAX_SCALE, MAX_SCALE),
295 rot: rot.rem_euclid(360) as u32,
296 }
297}
298
299fn hex_hue(hex: &str) -> Option<u16> {
301 let v: Vec<f64> = match hex.len() {
302 3 => hex
303 .chars()
304 .map(|c| c.to_digit(16).map(|d| (d * 17) as f64 / 255.0))
305 .collect::<Option<_>>()?,
306 6 => (0..3)
307 .map(|i| {
308 u8::from_str_radix(hex.get(i * 2..i * 2 + 2)?, 16)
309 .ok()
310 .map(|b| b as f64 / 255.0)
311 })
312 .collect::<Option<_>>()?,
313 _ => return None,
314 };
315 let (r, g, b) = (v[0], v[1], v[2]);
316 let (max, min) = (r.max(g).max(b), r.min(g).min(b));
317 let d = max - min;
318 let mut h = 0.0;
319 if d > 0.0 {
320 h = if max == r {
321 ((g - b) / d) % 6.0
322 } else if max == g {
323 (b - r) / d + 2.0
324 } else {
325 (r - g) / d + 4.0
326 } * 60.0;
327 if h < 0.0 {
328 h += 360.0;
329 }
330 }
331 Some(h.round() as u16 % 360)
332}
333
334fn color_prefix(s: &str) -> Option<(u16, usize)> {
337 let rest = s.strip_prefix("c!")?;
338 let (hash, rest) = match rest.strip_prefix('#') {
339 Some(r) => (1, r),
340 None => (0, rest),
341 };
342 for n in [6, 3] {
343 if let Some(h) = rest
344 .get(..n)
345 .filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit()))
346 {
347 return Some((hex_hue(h)?, 2 + hash + n));
348 }
349 }
350 None
351}
352
353#[derive(Clone, Debug, Default, PartialEq)]
355struct Mods {
356 fx: Vec<Effect>,
357 zero: bool,
358 hue: Option<u16>,
359}
360
361impl Mods {
362 fn is_empty(&self) -> bool {
363 self.fx.is_empty() && !self.zero && self.hue.is_none()
364 }
365
366 fn push_tok(&mut self, t: Tok) {
367 match t {
368 Tok::Fx(f) => self.fx.push(f),
369 Tok::Zero => self.zero = true,
370 }
371 }
372
373 fn merge(&mut self, o: Mods) {
375 self.fx.extend(o.fx);
376 self.zero |= o.zero;
377 self.hue = o.hue.or(self.hue);
378 }
379}
380
381fn exact_tok(word: &str) -> Option<Tok> {
382 TOKENS.iter().find(|(k, _)| *k == word).map(|t| t.1)
383}
384
385fn tok_prefix(s: &str, bttv_only: bool) -> Option<(&'static str, Tok)> {
387 TOKENS
388 .iter()
389 .filter(|(k, _)| s.starts_with(k) && (!bttv_only || k.ends_with('!')))
390 .max_by_key(|(k, _)| k.len())
391 .map(|t| (t.0, t.1))
392}
393
394fn peel_chain(word: &str) -> Option<Mods> {
396 let mut m = Mods::default();
397 let mut rem = word;
398 while !rem.is_empty() {
399 if let Some((hue, n)) = color_prefix(rem) {
400 m.hue = Some(hue);
401 rem = &rem[n..];
402 } else if let Some((k, t)) = tok_prefix(rem, false) {
403 m.push_tok(t);
404 rem = &rem[k.len()..];
405 } else {
406 return None;
407 }
408 }
409 (!m.is_empty()).then_some(m)
410}
411
412struct Modifier {
414 mods: Mods,
415 forward: bool,
417 exact: bool,
420}
421
422fn classify(word: &str) -> Option<Modifier> {
423 if let Some(t) = exact_tok(word) {
424 let mut mods = Mods::default();
425 mods.push_tok(t);
426 return Some(Modifier {
427 mods,
428 forward: word.ends_with('!'),
429 exact: true,
430 });
431 }
432 if let Some((hue, n)) = color_prefix(word) {
433 if n == word.len() {
434 let mods = Mods {
435 hue: Some(hue),
436 ..Mods::default()
437 };
438 return Some(Modifier {
440 mods,
441 forward: true,
442 exact: true,
443 });
444 }
445 }
446 peel_chain(word).map(|mods| Modifier {
448 mods,
449 forward: word.ends_with('!'),
450 exact: false,
451 })
452}
453
454#[derive(Clone, Debug, PartialEq)]
457pub struct Layer {
458 pub url: String,
459 pub fx: Vec<Effect>,
461 pub hue: Option<u16>,
463}
464
465impl Layer {
466 fn new(url: impl Into<String>) -> Layer {
467 Layer {
468 url: url.into(),
469 fx: Vec::new(),
470 hue: None,
471 }
472 }
473
474 fn add_fx(&mut self, fx: Effect) {
475 if fx.composes() {
476 if self.fx.iter().filter(|e| **e == fx).count() >= MAX_REPEAT {
477 return;
478 }
479 } else if self.fx.contains(&fx) {
480 return;
481 }
482 let i = self.fx.partition_point(|e| *e <= fx);
483 self.fx.insert(i, fx);
484 }
485
486 fn apply(&mut self, m: &Mods) {
487 for &f in &m.fx {
488 self.add_fx(f);
489 }
490 if m.hue.is_some() {
491 self.hue = m.hue;
492 }
493 }
494
495 fn code_line(&self) -> Option<String> {
497 if self.fx.is_empty() && self.hue.is_none() {
498 return None;
499 }
500 let mut s = String::from("#");
501 s.extend(self.fx.iter().map(|f| f.code()));
502 if let Some(h) = self.hue {
503 s.push('@');
504 s.push_str(&h.to_string());
505 }
506 Some(s)
507 }
508
509 fn set_codes(&mut self, line: &str) {
510 let (codes, hue) = match line.split_once('@') {
511 Some((c, h)) => (c, h.parse::<u16>().ok().map(|h| h % 360)),
512 None => (line, None),
513 };
514 for f in codes.chars().filter_map(Effect::from_code) {
515 self.add_fx(f);
516 }
517 self.hue = hue;
518 }
519}
520
521#[derive(Clone, Debug, PartialEq)]
527pub struct Stack {
528 pub base: String, pub layers: Vec<Layer>,
530}
531
532impl Stack {
533 fn new(base: &str, layer: Layer) -> Stack {
534 Stack {
535 base: base.to_string(),
536 layers: vec![layer],
537 }
538 }
539
540 pub fn wide(&self) -> bool {
543 self.layers
544 .first()
545 .is_some_and(|l| compose(&l.fx).sx.abs() >= 2)
546 }
547
548 pub fn key(&self) -> String {
549 let mut k = String::new();
550 for (i, l) in self.layers.iter().enumerate() {
551 if i > 0 {
552 k.push('\n');
553 }
554 k.push_str(&l.url);
555 if let Some(c) = l.code_line() {
556 k.push('\n');
557 k.push_str(&c);
558 }
559 }
560 k
561 }
562}
563
564pub fn split_key(key: &str) -> Vec<Layer> {
566 let mut layers: Vec<Layer> = Vec::new();
567 for line in key.split('\n') {
568 match line.strip_prefix('#') {
569 Some(codes) => {
570 if let Some(l) = layers.last_mut() {
571 l.set_codes(codes);
572 }
573 }
574 None => layers.push(Layer::new(line)),
575 }
576 }
577 layers
578}
579
580pub fn ref_set(refs: &[Emote]) -> EmoteSet {
584 EmoteSet::from_list(refs.iter().cloned())
585}
586
587pub fn msg_ref(name: &str, url: &str, provider: &str, zero_width: bool) -> Option<Emote> {
591 let name = crate::sanitize::clean(name);
592 let ok_url = url.len() <= 512
593 && url.starts_with("https://")
594 && !url.chars().any(|c| c.is_whitespace() || c.is_control());
595 if name.is_empty() || name.chars().any(char::is_whitespace) || !ok_url {
596 return None;
597 }
598 Some(Emote {
599 name,
600 url: url.to_string(),
601 provider: crate::sanitize::clean(provider),
602 id: String::new(),
603 animated: false,
604 zero_width,
605 global: false,
606 })
607}
608
609fn push_ref(out: &mut Vec<Emote>, e: Option<Emote>) {
610 if let Some(e) = e {
611 if !out.iter().any(|o| o.name == e.name) {
612 out.push(e);
613 }
614 }
615}
616
617pub fn twitch_native(tag: &str, text: &str) -> Vec<Emote> {
622 let chars: Vec<char> = text.chars().collect();
623 let mut out = Vec::new();
624 for part in tag.split('/') {
625 let Some((id, pos)) = part.split_once(':') else {
626 continue;
627 };
628 if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
631 continue;
632 }
633 let Some((s, e)) = pos
634 .split(',')
635 .next()
636 .and_then(|p| p.split_once('-'))
637 .and_then(|(s, e)| Some((s.parse::<usize>().ok()?, e.parse::<usize>().ok()?)))
638 else {
639 continue;
640 };
641 if s > e || e >= chars.len() {
642 continue;
643 }
644 let name: String = chars[s..=e].iter().collect();
645 let url = format!("https://static-cdn.jtvnw.net/emoticons/v2/{id}/default/dark/2.0");
646 push_ref(&mut out, msg_ref(&name, &url, "twitch", false));
647 }
648 out
649}
650
651pub fn kick_native(text: &str) -> (String, Vec<Emote>) {
655 const OPEN: &str = "[emote:";
656 let mut out = String::with_capacity(text.len());
657 let mut refs = Vec::new();
658 let mut rest = text;
659 while let Some(i) = rest.find(OPEN) {
660 out.push_str(&rest[..i]);
661 let after = &rest[i + OPEN.len()..];
662 let token = after.split_once(']').and_then(|(body, tail)| {
663 let (id, name) = body.split_once(':')?;
664 let url = format!("https://files.kick.com/emotes/{id}/fullsize");
665 let ok = !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit());
666 ok.then(|| msg_ref(name, &url, "kick", false).map(|e| (e, tail)))?
667 });
668 match token {
669 Some((e, tail)) => {
670 if !out.is_empty() && !out.ends_with(char::is_whitespace) {
671 out.push(' ');
672 }
673 out.push_str(&e.name);
674 if tail.chars().next().is_some_and(|c| !c.is_whitespace()) {
675 out.push(' ');
676 }
677 push_ref(&mut refs, Some(e));
678 rest = tail;
679 }
680 None => {
681 out.push_str(OPEN);
682 rest = after;
683 }
684 }
685 }
686 out.push_str(rest);
687 (out, refs)
688}
689
690#[derive(Clone, Debug, PartialEq)]
692pub enum Segment {
693 Text(String),
694 Stack(Stack),
695}
696
697struct Lookup<'a> {
702 set: &'a EmoteSet,
703 refs: &'a EmoteSet,
704}
705
706impl<'a> Lookup<'a> {
707 fn get(&self, name: &str) -> Option<&'a Emote> {
708 self.refs.get(name).or_else(|| self.set.get(name))
709 }
710}
711
712struct Resolved<'a> {
714 emote: &'a Emote,
715 overlay: bool,
717 mods: Mods,
719}
720
721fn resolve<'a>(word: &str, look: &Lookup<'a>) -> Option<Resolved<'a>> {
726 if let Some(e) = look.get(word) {
727 return Some(Resolved {
728 emote: e,
729 overlay: e.zero_width,
730 mods: Mods::default(),
731 });
732 }
733 if let Some(base) = word.strip_suffix('0').filter(|b| !b.is_empty()) {
734 if let Some(e) = look.get(base) {
735 return Some(Resolved {
736 emote: e,
737 overlay: true,
738 mods: Mods::default(),
739 });
740 }
741 }
742 if !word.contains('!') && !word.contains("ffz") {
745 return None;
746 }
747 for (i, _) in word.char_indices().rev() {
749 if i < 2 {
750 break;
751 }
752 let (base, rest) = word.split_at(i);
753 if let Some(mods) = peel_chain(rest) {
754 if let Some(e) = look.get(base) {
755 return Some(Resolved {
756 emote: e,
757 overlay: e.zero_width || mods.zero,
758 mods,
759 });
760 }
761 }
762 }
763 let mut mods = Mods::default();
764 let mut rem = word;
765 while let Some((k, t)) = tok_prefix(rem, true).filter(|(k, _)| rem.len() > k.len()) {
766 mods.push_tok(t);
767 rem = &rem[k.len()..];
768 }
769 if rem.len() < word.len() {
770 if let Some(e) = look.get(rem) {
771 return Some(Resolved {
772 emote: e,
773 overlay: e.zero_width || mods.zero,
774 mods,
775 });
776 }
777 }
778 None
779}
780
781fn emote_ahead(words: &[&str], from: usize, look: &Lookup) -> bool {
784 for w in &words[from.min(words.len())..] {
785 if is_modifier(w, look) {
786 continue;
787 }
788 return resolve(w, look).is_some();
789 }
790 false
791}
792
793fn is_modifier(word: &str, look: &Lookup) -> bool {
796 classify(word).is_some_and(|c| c.exact || look.get(word).is_none())
797}
798
799pub fn emotes_in<'a>(content: &str, set: &'a EmoteSet, refs: &'a EmoteSet) -> Vec<&'a Emote> {
803 let look = Lookup { set, refs };
804 let mut out: Vec<&Emote> = Vec::new();
805 for w in content.split_whitespace() {
806 if let Some(e) = look.get(w) {
807 if !out.iter().any(|o| o.url == e.url) {
808 out.push(e);
809 }
810 }
811 }
812 out
813}
814
815pub fn segments(content: &str, set: &EmoteSet) -> Vec<Segment> {
829 segments_with(content, set, &EmoteSet::new())
830}
831
832pub fn segments_with(content: &str, set: &EmoteSet, refs: &EmoteSet) -> Vec<Segment> {
834 let look = Lookup { set, refs };
835 let words: Vec<&str> = content.split_whitespace().collect();
836 let mut out: Vec<Segment> = Vec::new();
837 let mut text = String::new();
838 let mut pending = Mods::default();
841 let mut pending_words: Vec<&str> = Vec::new();
842
843 fn push_text(text: &mut String, w: &str) {
844 if !text.is_empty() {
845 text.push(' ');
846 }
847 text.push_str(w);
848 }
849
850 for (i, &word) in words.iter().enumerate() {
851 if let Some(c) = classify(word).filter(|c| c.exact || look.get(word).is_none()) {
852 let ahead = emote_ahead(&words, i + 1, &look);
853 let back = text.is_empty() && matches!(out.last(), Some(Segment::Stack(_)));
855 let back_ok = back && (!c.mods.fx.is_empty() || c.mods.hue.is_some());
858 let fwd = if c.forward { ahead } else { !back_ok && ahead };
859 if fwd {
860 pending.merge(c.mods);
861 pending_words.push(word);
862 continue;
863 }
864 if back_ok {
865 if let Some(Segment::Stack(s)) = out.last_mut() {
866 if let Some(l) = s.layers.last_mut() {
867 l.apply(&c.mods);
868 continue;
869 }
870 }
871 }
872 for w in pending_words.drain(..) {
874 push_text(&mut text, w);
875 }
876 pending = Mods::default();
877 push_text(&mut text, word);
878 continue;
879 }
880
881 let Some(r) = resolve(word, &look) else {
882 for w in pending_words.drain(..) {
883 push_text(&mut text, w);
884 }
885 pending = Mods::default();
886 push_text(&mut text, word);
887 continue;
888 };
889 if crate::block::is_blocked(r.emote) {
892 pending = Mods::default();
893 pending_words.clear();
894 continue;
895 }
896 let mut mods = std::mem::take(&mut pending);
897 pending_words.clear();
898 mods.merge(r.mods);
899 let overlay = r.overlay || mods.zero;
900 let mut layer = Layer::new(r.emote.url.clone());
901 layer.apply(&mods);
902 if overlay && text.is_empty() {
903 if let Some(Segment::Stack(s)) = out.last_mut() {
904 s.layers.push(layer);
905 continue;
906 }
907 }
908 if !text.is_empty() {
909 out.push(Segment::Text(std::mem::take(&mut text)));
910 }
911 out.push(Segment::Stack(Stack::new(&r.emote.name, layer)));
912 }
913 for w in pending_words.drain(..) {
914 push_text(&mut text, w);
915 }
916 if !text.is_empty() {
917 out.push(Segment::Text(text));
918 }
919 out
920}
921
922#[cfg(test)]
923mod tests {
924 use super::*;
925
926 fn e(name: &str) -> Emote {
927 Emote {
928 name: name.into(),
929 url: format!("https://cdn/{name}.webp"),
930 provider: "7tv".into(),
931 id: name.into(),
932 animated: false,
933 zero_width: false,
934 global: false,
935 }
936 }
937
938 fn zw(name: &str) -> Emote {
939 Emote {
940 zero_width: true,
941 ..e(name)
942 }
943 }
944
945 fn native(name: &str, provider: &str) -> Emote {
946 Emote {
947 url: format!("https://native/{name}"),
948 provider: provider.into(),
949 ..e(name)
950 }
951 }
952
953 fn cast() -> EmoteSet {
955 EmoteSet::from_list([
956 e("KEKW"),
957 e("GAMBA"),
958 e("Kappa"),
959 e("Keepo"),
960 e("lerolero"),
961 zw("notL"),
962 ])
963 }
964
965 fn show_with(msg: &str, set: &EmoteSet, refs: &EmoteSet) -> String {
968 segments_with(msg, set, refs)
969 .iter()
970 .map(|s| match s {
971 Segment::Text(t) => format!("'{t}'"),
972 Segment::Stack(st) => st
973 .layers
974 .iter()
975 .map(|l| {
976 let name = l.url.rsplit('/').next().unwrap();
977 let name = name.strip_suffix(".webp").unwrap_or(name);
978 let codes = l.code_line().unwrap_or_default();
979 match codes.strip_prefix('#') {
980 Some(c) => format!("{name}[{c}]"),
981 None => name.to_string(),
982 }
983 })
984 .collect::<Vec<_>>()
985 .join("+"),
986 })
987 .collect::<Vec<_>>()
988 .join(" ")
989 }
990
991 fn show(msg: &str) -> String {
992 show_with(msg, &cast(), &EmoteSet::new())
993 }
994
995 #[track_caller]
996 fn table(cases: &[(&str, &str)]) {
997 for (input, want) in cases {
998 assert_eq!(show(input), *want, "input: {input}");
999 }
1000 }
1001
1002 #[test]
1003 fn channel_emote_overrides_global_regardless_of_arrival_order() {
1004 let mut set = EmoteSet::new();
1006 let mut globals = EmoteSet::from_list([e("Kappa")]);
1007 globals.mark_global();
1008 set.merge(globals.clone());
1009 set.merge(EmoteSet::from_list([Emote {
1010 provider: "bttv".into(),
1011 ..e("Kappa")
1012 }]));
1013 assert_eq!(set.get("Kappa").unwrap().provider, "bttv");
1014 let mut set = EmoteSet::from_list([Emote {
1016 provider: "bttv".into(),
1017 ..e("Kappa")
1018 }]);
1019 set.merge(globals);
1020 assert_eq!(set.get("Kappa").unwrap().provider, "bttv");
1021 assert_eq!(set.len(), 1);
1022 }
1023
1024 #[test]
1025 fn last_entry_wins_on_collision() {
1026 let p = |n: &str| Emote {
1028 provider: n.into(),
1029 ..e("Kappa")
1030 };
1031 let set = EmoteSet::from_list([p("bttv"), p("7tv"), p("ffz")]);
1032 assert_eq!(set.get("Kappa").unwrap().provider, "ffz");
1033 assert_eq!(set.len(), 1);
1034 let set = EmoteSet::from_list([p("bttv"), p("7tv")]);
1035 assert_eq!(set.get("Kappa").unwrap().provider, "7tv");
1036 }
1037
1038 #[test]
1039 fn globals_rank_7tv_bttv_ffz_whatever_the_row_order() {
1040 let p = |n: &str| Emote {
1041 provider: n.into(),
1042 ..e("Kappa")
1043 };
1044 for rows in [
1045 vec![p("7tv"), p("bttv"), p("ffz"), p("twitch")],
1046 vec![p("twitch"), p("ffz"), p("bttv"), p("7tv")],
1047 vec![p("ffz"), p("7tv"), p("twitch"), p("bttv")],
1048 ] {
1049 let set = EmoteSet::from_global_list(rows);
1050 let k = set.get("Kappa").unwrap();
1051 assert_eq!(k.provider, "7tv");
1052 assert!(k.global);
1053 }
1054 let set = EmoteSet::from_global_list([p("twitch"), p("ffz")]);
1055 assert_eq!(set.get("Kappa").unwrap().provider, "ffz");
1056 }
1057
1058 #[test]
1059 fn text_and_emotes_coalesce() {
1060 table(&[
1061 (
1062 "lol KEKW that GAMBA moment",
1063 "'lol' KEKW 'that' GAMBA 'moment'",
1064 ),
1065 ("a b\tc", "'a b c'"),
1066 ("", ""),
1067 ]);
1068 }
1069
1070 #[test]
1071 fn bttv_binds_forward() {
1072 table(&[
1073 ("w!KEKW", "KEKW[w]"),
1074 ("w! KEKW", "KEKW[w]"),
1075 ("w!h!KEKW", "KEKW[xw]"), ("x! KEKW", "KEKW[x]"),
1077 ("y! KEKW", "KEKW[y]"),
1078 ("v! KEKW", "KEKW[y]"),
1079 ("l! KEKW", "KEKW[l]"),
1080 ("r! KEKW", "KEKW[r]"),
1081 ("c! KEKW", "KEKW[c]"),
1082 ("p! KEKW", "KEKW[p]"),
1083 ("s! KEKW", "KEKW[s]"),
1084 ("Kappa c! Keepo", "Kappa Keepo[c]"),
1086 ("c! ffzX Kappa", "Kappa[xc]"),
1088 ("w! h! KEKW", "KEKW[xw]"),
1089 ]);
1090 }
1091
1092 #[test]
1093 fn ffz_binds_backward() {
1094 table(&[
1095 ("KEKW ffzX", "KEKW[x]"),
1096 ("KEKW ffzY", "KEKW[y]"),
1097 ("KEKW ffzW", "KEKW[w]"),
1098 ("KEKW ffzWide", "KEKW[w]"),
1099 ("KEKW ffzTall", "KEKW[t]"),
1100 ("KEKW ffzCursed", "KEKW[c]"),
1101 ("KEKW ffzHyper", "KEKW[H]"),
1102 ("KEKW ffzRainbow", "KEKW[R]"),
1103 ("KEKW ffzBounce", "KEKW[b]"),
1104 ("KEKW ffzJam", "KEKW[j]"),
1105 ("KEKW ffzSlide", "KEKW[d]"),
1106 ("KEKW ffzArrive", "KEKW[a]"),
1107 ("KEKW ffzLeave", "KEKW[e]"),
1108 ("KEKW ffzSpin", "KEKW[o]"),
1109 ("KEKW ffzX ffzRainbow", "KEKW[xR]"),
1110 ("Kappa ffzX Keepo", "Kappa[x] Keepo"),
1112 ("GAMBA notL ffzX", "GAMBA+notL[x]"),
1114 ]);
1115 }
1116
1117 #[test]
1118 fn fallback_direction_when_canonical_side_has_no_emote() {
1119 table(&[
1120 ("Kappa w!", "Kappa[w]"), ("ffzX Kappa", "Kappa[x]"), ("Kappa c!#00f", "Kappa[@240]"),
1123 ]);
1124 }
1125
1126 #[test]
1127 fn orphan_modifier_stays_literal() {
1128 table(&[
1129 ("w! nothing here", "'w! nothing here'"),
1130 ("lol ffzX", "'lol ffzX'"),
1131 ("KEKW lol ffzX", "KEKW 'lol ffzX'"), ("w! ffzX", "'w! ffzX'"),
1133 ("c!#00f", "'c!#00f'"),
1134 ("z!", "'z!'"),
1135 ("hi w!h!ffzX", "'hi w!h!ffzX'"),
1136 ]);
1137 }
1138
1139 #[test]
1140 fn glued_suffixes() {
1141 table(&[
1142 ("Kappaw!", "Kappa[w]"),
1143 ("KappaffzX", "Kappa[x]"),
1144 ("KappaffzRainbow", "Kappa[R]"),
1145 ("KappaffzLeave", "Kappa[e]"),
1146 ("Kappac!#0f0", "Kappa[@120]"),
1147 ("Kappac!888", "Kappa[@0]"),
1148 ("Kappaw!h!ffzX", "Kappa[xxw]"),
1149 ("Kappaw!c!#00f", "Kappa[w@240]"),
1150 ("GAMBA notLw!", "GAMBA+notL[w]"),
1152 ]);
1153 }
1154
1155 #[test]
1156 fn glued_prefixes_still_work() {
1157 table(&[("w!h!KEKW", "KEKW[xw]"), ("z!KEKW", "KEKW")]);
1158 }
1159
1160 #[test]
1161 fn chained_words_follow_the_trailing_bang() {
1162 table(&[
1163 ("Kappa w!h!ffzX", "Kappa[xxw]"),
1165 ("Kappa Keepo w!h!ffzX", "Kappa Keepo[xxw]"),
1166 ("Kappa w!h! Keepo", "Kappa Keepo[xw]"),
1168 ("w!c!#00fh! Kappa", "Kappa[xw@240]"),
1169 ]);
1170 }
1171
1172 #[test]
1173 fn hex_colors_tint_and_latest_wins() {
1174 table(&[
1175 ("c!#f00 Kappa", "Kappa[@0]"),
1176 ("c!#0f0 Kappa", "Kappa[@120]"),
1177 ("c!#0000ff Kappa", "Kappa[@240]"),
1178 ("c!00f Kappa", "Kappa[@240]"), ("c!#f00 c!#00f Kappa", "Kappa[@240]"),
1180 ]);
1181 assert_eq!(hex_hue("ff8700"), Some(32));
1182 assert_eq!(hex_hue("zzz"), None);
1183 assert_eq!(hex_hue("12"), None);
1184 }
1185
1186 #[test]
1187 fn composition_per_hscomposeall() {
1188 table(&[
1190 ("w! w! KEKW", "KEKW[ww]"),
1191 ("KEKW ffzRainbow ffzRainbow", "KEKW[R]"),
1192 ("KEKW ffzHyper ffzHyper s!", "KEKW[Hs]"),
1193 ("w!w!w!w!w!w!KEKW", "KEKW[wwww]"), ]);
1195 let g = |fx: &[Effect]| compose(fx);
1196 assert_eq!((g(&[]).sx, g(&[]).sy, g(&[]).rot), (1, 1, 0));
1197 let wide3 = g(&[Effect::Wide; 3]);
1198 assert_eq!(wide3.sx, 4); assert_eq!(g(&[Effect::FlipX, Effect::FlipX]).sx, 1); assert_eq!(g(&[Effect::FlipX, Effect::Wide]).sx, -2);
1201 assert_eq!(g(&[Effect::Tall]).sy, 2);
1202 assert_eq!(g(&[Effect::RotateL, Effect::RotateL]).rot, 180);
1203 assert_eq!(g(&[Effect::RotateL, Effect::RotateR]).rot, 0);
1204 assert_eq!(g(&[Effect::RotateL]).rot, 270);
1205 assert_eq!(g(&[Effect::RotateR; 4]).rot, 0);
1206 }
1207
1208 #[test]
1209 fn wide_is_the_base_layers_footprint() {
1210 let set = cast();
1211 let wide = |m: &str| match &segments(m, &set)[0] {
1212 Segment::Stack(s) => s.wide(),
1213 _ => panic!("no stack"),
1214 };
1215 assert!(wide("w! KEKW"));
1216 assert!(wide("KEKW ffzWide"));
1217 assert!(!wide("KEKW ffzTall"));
1218 assert!(wide("w! w! w! KEKW")); assert!(wide("w! h! KEKW")); assert!(!wide("GAMBA notL ffzW")); assert!(!wide("KEKW"));
1222 }
1223
1224 #[test]
1225 fn zero_width_overlays_stack() {
1226 table(&[
1227 ("GAMBA notL", "GAMBA+notL"),
1228 ("GAMBA notL notL", "GAMBA+notL+notL"),
1229 ("notL", "notL"), ("GAMBA lol notL", "GAMBA 'lol' notL"),
1231 ("GAMBA w! notL", "GAMBA+notL[w]"),
1232 ("GAMBA notL w!", "GAMBA+notL[w]"),
1233 ]);
1234 }
1235
1236 #[test]
1237 fn z_forces_overlay() {
1238 table(&[
1239 ("GAMBA z!KEKW", "GAMBA+KEKW"),
1240 ("GAMBA z! KEKW", "GAMBA+KEKW"),
1241 ("GAMBA z!w!KEKW", "GAMBA+KEKW[w]"),
1242 ("GAMBA z!", "GAMBA 'z!'"), ]);
1244 }
1245
1246 #[test]
1247 fn name0_overlay_convention() {
1248 table(&[
1249 ("GAMBA lerolero0", "GAMBA+lerolero"),
1250 ("lerolero0", "lerolero"), ]);
1252 let set = EmoteSet::from_list([e("GAMBA"), e("lerolero"), e("lerolero0")]);
1254 assert_eq!(
1255 show_with("GAMBA lerolero0", &set, &EmoteSet::new()),
1256 "GAMBA lerolero0"
1257 );
1258 assert_eq!(show("nope0"), "'nope0'");
1260 assert_eq!(show("0"), "'0'");
1261 }
1262
1263 #[test]
1264 fn exact_emote_name_beats_chain_parse() {
1265 let set = EmoteSet::from_list([e("h!i"), e("w!h!")]);
1266 assert_eq!(show_with("h!i", &set, &EmoteSet::new()), "h!i");
1267 assert_eq!(show_with("w!h!", &set, &EmoteSet::new()), "w!h!");
1269 }
1270
1271 #[test]
1272 fn refs_outrank_the_set_native_included() {
1273 let set = EmoteSet::from_list([e("Kappa")]);
1274 let heat = Emote {
1275 url: "https://hs/Kappa".into(),
1276 provider: "heatsync".into(),
1277 ..e("Kappa")
1278 };
1279 let refs = EmoteSet::from_list([heat]);
1281 assert_eq!(show_with("Kappa", &set, &refs), "Kappa");
1282 match &segments_with("Kappa", &set, &refs)[0] {
1283 Segment::Stack(s) => assert_eq!(s.layers[0].url, "https://hs/Kappa"),
1284 _ => panic!(),
1285 }
1286 let refs = EmoteSet::from_list([native("Kappa", "twitch")]);
1288 match &segments_with("Kappa", &set, &refs)[0] {
1289 Segment::Stack(s) => assert_eq!(s.layers[0].url, "https://native/Kappa"),
1290 _ => panic!(),
1291 }
1292 let refs = EmoteSet::from_list([native("SubEmote", "twitch")]);
1294 match &segments_with("w! SubEmote ffzX", &set, &refs)[0] {
1295 Segment::Stack(s) => {
1296 assert_eq!(s.layers[0].url, "https://native/SubEmote");
1297 assert_eq!(s.layers[0].fx, vec![Effect::FlipX, Effect::Wide]);
1298 }
1299 _ => panic!(),
1300 }
1301 }
1302
1303 #[test]
1304 fn keys_are_unique_per_effect_and_roundtrip() {
1305 let set = cast();
1306 let mut keys = std::collections::HashSet::new();
1307 for (name, _) in TOKENS.iter().filter(|(k, _)| *k != "z!") {
1308 let msg = if name.ends_with('!') {
1309 format!("{name} KEKW")
1310 } else {
1311 format!("KEKW {name}")
1312 };
1313 match &segments(&msg, &set)[0] {
1314 Segment::Stack(s) => keys.insert(s.key()),
1315 _ => panic!("{msg}"),
1316 };
1317 }
1318 assert_eq!(keys.len(), 17);
1320 assert_eq!(CODES.len(), 17);
1321 let codes: std::collections::HashSet<_> = CODES.iter().map(|c| c.1).collect();
1322 assert_eq!(codes.len(), 17, "stack-key codes are unique");
1323 }
1324
1325 #[test]
1326 fn key_roundtrips_through_split_key() {
1327 let set = cast();
1328 let s = match &segments("w!GAMBA notL ffzCursed c!#0f0", &set)[0] {
1329 Segment::Stack(s) => s.clone(),
1330 _ => panic!(),
1331 };
1332 assert_eq!(
1333 s.key(),
1334 "https://cdn/GAMBA.webp\n#w\nhttps://cdn/notL.webp\n#c@120"
1335 );
1336 assert_eq!(split_key(&s.key()), s.layers);
1337 let plain = match &segments("GAMBA notL", &set)[0] {
1339 Segment::Stack(s) => s.key(),
1340 _ => panic!(),
1341 };
1342 assert_eq!(plain, "https://cdn/GAMBA.webp\nhttps://cdn/notL.webp");
1343 assert_eq!(
1344 split_key("https://x/a\n#xw@7")[0].fx,
1345 vec![Effect::FlipX, Effect::Wide]
1346 );
1347 assert_eq!(split_key("https://x/a\n#xw@7")[0].hue, Some(7));
1348 assert!(split_key("#xw").is_empty());
1350 }
1351
1352 #[test]
1353 fn twitch_native_positions_name_the_covered_text() {
1354 let u =
1355 |id: &str| format!("https://static-cdn.jtvnw.net/emoticons/v2/{id}/default/dark/2.0");
1356 for (tag, text, want) in [
1357 ("25:0-4", "Kappa hi", vec![("Kappa", "25")]),
1358 ("25:0-4,12-16", "Kappa hi Kappa", vec![("Kappa", "25")]),
1359 (
1360 "25:0-4/1902:6-10",
1361 "Kappa Keepo",
1362 vec![("Kappa", "25"), ("Keepo", "1902")],
1363 ),
1364 (
1365 "emotesv2_ab12:3-5",
1366 "hi LUL",
1367 vec![("LUL", "emotesv2_ab12")],
1368 ),
1369 ("25:3-7", "\u{1f600}\u{1f600} Kappa", vec![("Kappa", "25")]),
1371 ("25:0-4/", "Kappa hi", vec![("Kappa", "25")]),
1372 ("", "Kappa", vec![]),
1373 ] {
1374 let got: Vec<(String, String)> = twitch_native(tag, text)
1375 .into_iter()
1376 .map(|e| (e.name, e.url))
1377 .collect();
1378 let want: Vec<(String, String)> =
1379 want.into_iter().map(|(n, i)| (n.into(), u(i))).collect();
1380 assert_eq!(got, want, "{tag} / {text}");
1381 }
1382 }
1383
1384 #[test]
1385 fn twitch_native_rejects_bad_input() {
1386 for (tag, text) in [
1387 ("25:0-9", "Kappa"), ("25:4-0", "Kappa"), ("25:a-b", "Kappa"), ("25", "Kappa"), ("25?x=1:0-4", "Kappa"), ("25:0-6", "Kappa hi"), ] {
1394 let got = twitch_native(tag, text);
1395 assert!(got.is_empty(), "{tag}: {got:?}");
1396 }
1397 }
1398
1399 #[test]
1400 fn kick_native_tokens_become_names_and_refs() {
1401 let (text, refs) = kick_native("hi [emote:37226:KEKW] there");
1402 assert_eq!(text, "hi KEKW there");
1403 assert_eq!(refs.len(), 1);
1404 assert_eq!(refs[0].name, "KEKW");
1405 assert_eq!(refs[0].url, "https://files.kick.com/emotes/37226/fullsize");
1406 assert_eq!(refs[0].provider, "kick");
1407 assert_eq!(kick_native("a[emote:1:X]b").0, "a X b");
1409 assert_eq!(kick_native("[emote:1:X][emote:2:Y]").0, "X Y");
1410 assert_eq!(kick_native("[emote:1:X][emote:2:Y]").1.len(), 2);
1411 assert_eq!(kick_native("[emote:1:X] [emote:1:X]").1.len(), 1);
1413 for bad in [
1415 "[emote:abc:X]",
1416 "[emote::X]",
1417 "[emote:1:]",
1418 "[emote:1:X Y]",
1419 "[emote:1:X",
1420 "[emote:../x:X]",
1421 "[emote:12]",
1422 ] {
1423 let (t, r) = kick_native(bad);
1424 assert_eq!(t, bad, "{bad}");
1425 assert!(r.is_empty(), "{bad}");
1426 }
1427 assert_eq!(kick_native("plain").0, "plain");
1428 }
1429
1430 #[test]
1431 fn msg_ref_guards_the_trust_boundary() {
1432 assert!(msg_ref("KEKW", "https://cdn/k.webp", "7tv", false).is_some());
1433 for (n, u) in [
1434 ("", "https://cdn/k"),
1435 ("a b", "https://cdn/k"),
1436 ("KEKW", "http://cdn/k"),
1437 ("KEKW", "javascript:x"),
1438 ("KEKW", "https://cdn/k k"),
1439 ("KEKW", "https://cdn/k\u{1b}[2J"),
1440 ] {
1441 assert!(msg_ref(n, u, "x", false).is_none(), "{n} {u}");
1442 }
1443 assert_eq!(
1445 msg_ref("KE\u{1b}KW", "https://cdn/k", "x", false)
1446 .unwrap()
1447 .name,
1448 "KEKW"
1449 );
1450 }
1451
1452 #[test]
1453 fn native_refs_drive_a_full_parse() {
1454 let set = cast();
1455 let (text, refs) = kick_native("[emote:9:SubOnly] w! KEKW");
1456 let r = ref_set(&refs);
1457 let segs = segments_with(&text, &set, &r);
1458 let bases: Vec<&str> = segs
1459 .iter()
1460 .filter_map(|s| match s {
1461 Segment::Stack(st) => Some(st.base.as_str()),
1462 _ => None,
1463 })
1464 .collect();
1465 assert_eq!(bases, vec!["SubOnly", "KEKW"]);
1466 match &segs[0] {
1467 Segment::Stack(st) => {
1468 assert_eq!(st.layers[0].url, "https://files.kick.com/emotes/9/fullsize")
1469 }
1470 _ => panic!(),
1471 }
1472 }
1473
1474 #[test]
1475 fn parses_real_api_json() {
1476 let json = r#"{"channel":"xqc","emotes":[
1477 {"name":"GAMBA","url":"https://cdn.7tv.app/emote/x/1x.webp","provider":"7tv","id":"x","animated":true}
1478 ],"count":1}"#;
1479 let resp: EmoteSetResponse = serde_json::from_str(json).unwrap();
1480 let set = EmoteSet::from_list(resp.emotes);
1481 assert!(set.get("GAMBA").unwrap().animated);
1482 }
1483
1484 #[test]
1485 fn owner_emotes_are_lenient_per_row_and_https_only() {
1486 let v = serde_json::json!({"emotes": [
1487 {"name": "A", "url": "https://cdn.heatsync.org/a.webp", "provider": "upload", "hash": "h1"},
1488 {"name": "B", "url": "/emotes/b.webp"},
1489 {"name": "C", "url": "http://x/c.webp"},
1490 {"name": "D", "url": "//evil/d.webp"},
1491 {"url": "https://x/e.webp"},
1492 {"name": "F", "url": ""},
1493 "junk"]});
1494 let got = owner_list(&v, "https://heatsync.org");
1495 let names: Vec<_> = got.iter().map(|e| e.name.as_str()).collect();
1496 assert_eq!(names, ["A", "B"]);
1497 assert_eq!(got[0].id, "h1");
1498 assert_eq!(got[1].url, "https://heatsync.org/emotes/b.webp");
1499 assert_eq!(got[1].provider, "upload");
1500 assert!(owner_list(&serde_json::json!({}), "x").is_empty());
1501 assert!(owner_list(&serde_json::json!({"emotes": 5}), "x").is_empty());
1502 }
1503
1504 #[test]
1505 fn owner_emotes_listed_last_win_a_name() {
1506 let chan = vec![e("W")];
1507 let mut mine = e("W");
1508 mine.provider = "upload".into();
1509 mine.url = "https://heatsync.org/w.webp".into();
1510 let set = EmoteSet::from_list(chan.into_iter().chain([mine]));
1511 assert_eq!(set.get("W").unwrap().provider, "upload");
1512 }
1513
1514 #[test]
1515 fn a_blocked_emote_vanishes_but_its_same_named_twin_stays() {
1516 let mut mine = e("BlockTwin");
1517 mine.url = "https://block.test/seg/mine.webp".into();
1518 let mut other = e("BlockTwin");
1519 other.url = "https://block.test/seg/other.webp".into();
1520 let set_a = EmoteSet::from_list([mine.clone()]);
1521 let set_b = EmoteSet::from_list([other.clone()]);
1522 let text = |s: &[Segment]| -> String {
1523 s.iter()
1524 .map(|g| match g {
1525 Segment::Text(t) => t.clone(),
1526 Segment::Stack(_) => "<emote>".into(),
1527 })
1528 .collect::<Vec<_>>()
1529 .join("|")
1530 };
1531 assert_eq!(text(&segments("hi BlockTwin yo", &set_a)), "hi|<emote>|yo");
1532 assert!(crate::block::block(&mine));
1533 assert_eq!(
1534 text(&segments("hi BlockTwin yo", &set_a)),
1535 "hi yo",
1536 "hidden"
1537 );
1538 assert_eq!(
1539 text(&segments("hi BlockTwin yo", &set_b)),
1540 "hi|<emote>|yo",
1541 "the other emote with that name is untouched"
1542 );
1543 assert_eq!(text(&segments("w! BlockTwin", &set_a)), "");
1545 assert_eq!(
1547 emotes_in("a BlockTwin BlockTwin", &set_a, EmoteSet::none()).len(),
1548 1
1549 );
1550 crate::block::unblock_key(&crate::block::key(&mine).unwrap());
1551 assert_eq!(text(&segments("BlockTwin", &set_a)), "<emote>");
1552 }
1553}