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