1use crate::engine::MatchAlgorithm;
11use crate::{engine::MatchInfo, engine::MultiPatternEngine, variant::VariantDetector};
12use lru::LruCache;
13#[cfg(feature = "parallel")]
14use rayon::prelude::*;
15use regex::Regex;
16use std::collections::HashSet;
17use std::num::NonZero;
18use std::sync::{Arc, Mutex};
19use std::{
20 fs::File,
21 io::{self, BufRead, BufReader},
22 path::Path,
23};
24
25pub struct Filter {
27 engine: MultiPatternEngine, variant_detector: VariantDetector, noise: Regex, cache: Arc<Mutex<LruCache<String, Vec<String>>>>,
31 #[cfg(feature = "net")]
32 http_client: reqwest::blocking::Client, }
34
35#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Match {
41 pub word: String,
43 pub is_variant: bool,
45}
46
47impl std::fmt::Debug for Filter {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 f.debug_struct("Filter")
50 .field("engine", &self.engine)
51 .field("variant_detector", &self.variant_detector)
52 .field("noise", &self.noise)
53 .field("cache", &"<LruCache>")
54 .finish()
55 }
56}
57
58impl Filter {
59 pub fn new() -> Self {
71 Self {
72 engine: MultiPatternEngine::new(None, &[]),
73 variant_detector: VariantDetector::new(),
74 noise: Regex::new(r"[^\w\s\u4e00-\u9fff]").unwrap(),
75 cache: Arc::new(Mutex::new(LruCache::new(NonZero::new(1000).unwrap()))), #[cfg(feature = "net")]
77 http_client: reqwest::blocking::Client::builder()
78 .timeout(std::time::Duration::from_secs(5))
79 .build()
80 .unwrap(),
81 }
82 }
83
84 fn check_cache(&self, text: &str) -> Option<Vec<String>> {
85 self.cache.lock().unwrap_or_else(|e| e.into_inner()).get(text).cloned()
86 }
87
88 fn cache_result(&self, text: &str, results: &[String]) {
89 self.cache.lock().unwrap_or_else(|e| e.into_inner()).put(text.to_string(), results.to_vec());
90 }
91
92 fn word_match_variants(word: &str) -> Vec<String> {
93 let mut variants = vec![word.to_string()];
94 if word.chars().any(char::is_whitespace) {
95 let folded: String = word.chars().filter(|c| !c.is_whitespace()).collect();
96 if !folded.is_empty() && folded != word {
97 variants.push(folded);
98 }
99 }
100 variants
101 }
102
103 fn extend_patterns_with_word_variants(patterns: &mut Vec<String>, words: &[&str]) {
104 let mut seen: HashSet<String> = patterns.iter().cloned().collect();
105 for word in words {
106 for variant in Self::word_match_variants(word) {
107 if seen.insert(variant.clone()) {
108 patterns.push(variant);
109 }
110 }
111 }
112 }
113
114 pub fn clear_cache(&self) {
116 self.cache.lock().unwrap_or_else(|e| e.into_inner()).clear();
117 }
118
119 pub fn with_algorithm(algorithm: MatchAlgorithm) -> Self {
121 Self { engine: MultiPatternEngine::new(Some(algorithm), &[]), ..Self::new() }
122 }
123
124 pub fn with_default_dict() -> io::Result<Self> {
126 let mut filter = Self::new();
127 filter.load_word_dict("dict/dict.txt")?;
128 Ok(filter)
129 }
130
131 pub fn update_noise_pattern(&mut self, pattern: &str) -> Result<(), regex::Error> {
149 self.noise = Regex::new(pattern)?;
150 Ok(())
151 }
152
153 pub fn add_word(&mut self, word: &str) {
155 self.add_words(&[word]);
156 }
157
158 pub fn add_words(&mut self, words: &[&str]) {
170 let mut patterns = self.engine.get_patterns().to_vec();
171 Self::extend_patterns_with_word_variants(&mut patterns, words);
172
173 self.engine.rebuild(&patterns);
174 for word in words {
175 for variant in Self::word_match_variants(word) {
176 self.variant_detector.add_word(&variant);
177 }
178 }
179 self.clear_cache();
180 }
181
182 #[must_use]
184 pub fn current_algorithm(&self) -> MatchAlgorithm {
185 self.engine.current_algorithm()
186 }
187
188 pub fn del_word(&mut self, word: &str) {
190 self.del_words(&[word]);
191 }
192
193 pub fn del_words(&mut self, words: &[&str]) {
195 let word_set: HashSet<String> = words.iter().flat_map(|word| Self::word_match_variants(word)).collect();
196 let patterns: Vec<_> = self.engine.get_patterns().iter().filter(|w| !word_set.contains(*w)).cloned().collect();
197
198 self.engine.rebuild(&patterns);
199 self.clear_cache();
200 }
201
202 pub fn load_word_dict<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
204 let file = File::open(path)?;
205 self.load(BufReader::new(file))
206 }
207
208 pub fn load<R: BufRead>(&mut self, reader: R) -> io::Result<()> {
224 let words: Vec<_> = reader.lines().collect::<Result<_, _>>()?;
225 self.add_words(&words.iter().map(|s| s.as_str()).collect::<Vec<_>>());
226 Ok(())
227 }
228
229 #[cfg(feature = "net")]
231 pub fn load_net_word_dict(&mut self, url: &str) -> io::Result<()> {
232 let response = self.http_client.get(url).send().map_err(io::Error::other)?;
233
234 if !response.status().is_success() {
235 return Err(io::Error::other(format!("HTTP request failed: {}", response.status())));
236 }
237
238 let reader = BufReader::new(response);
239 self.load(reader)
240 }
241
242 #[must_use]
269 pub fn find_first_match(&self, text: &str) -> Option<Match> {
270 let clean_text = self.remove_noise(text);
271
272 if let Some(word) = self.engine.find_first(&clean_text) {
274 return Some(Match { word, is_variant: false });
275 }
276
277 let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
279
280 if let Some(word) = self.variant_detector.detect(&clean_text, &patterns).first() {
281 return Some(Match { word: word.to_string(), is_variant: true });
282 }
283
284 None
285 }
286
287 #[must_use]
303 pub fn find_in(&self, text: &str) -> (bool, String) {
304 match self.find_first_match(text) {
305 Some(m) => (true, m.word),
306 None => (false, String::new()),
307 }
308 }
309
310 #[must_use]
326 pub fn replace(&self, text: &str, replacement: char) -> String {
327 let clean_text = self.remove_noise(text);
328 let repl = replacement.to_string();
329
330 let matches = self.leftmost_longest_matches(&clean_text);
333 let mut result = String::with_capacity(clean_text.len());
334 let mut cursor = 0usize;
335 for m in &matches {
336 result.push_str(&clean_text[cursor..m.start]);
337 result.push_str(&repl.repeat(clean_text[m.start..m.end].chars().count()));
338 cursor = m.end;
339 }
340 result.push_str(&clean_text[cursor..]);
341 result
342 }
343
344 #[must_use]
359 pub fn filter(&self, text: &str) -> String {
360 let clean_text = self.remove_noise(text);
361 self.engine.replace_all(&clean_text, "")
362 }
363
364 #[must_use]
380 pub fn validate(&self, text: &str) -> (bool, String) {
381 self.find_in(text)
382 }
383
384 #[must_use]
386 pub fn remove_noise(&self, text: &str) -> String {
387 self.noise.replace_all(text, "").to_string()
388 }
389
390 #[must_use]
392 pub fn get_noise_pattern(&self) -> &Regex {
393 &self.noise
394 }
395
396 fn leftmost_longest_matches(&self, clean_text: &str) -> Vec<MatchInfo> {
402 let mut matches = self.engine.find_matches_with_positions(clean_text);
403 matches.sort_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end)));
404 let mut kept = Vec::with_capacity(matches.len());
405 let mut cursor = 0usize;
406 for m in matches {
407 if m.start >= cursor {
408 cursor = m.end;
409 kept.push(m);
410 }
411 }
412 kept
413 }
414
415 #[must_use]
431 pub fn find_all(&self, text: &str) -> Vec<String> {
432 let clean_text = self.remove_noise(text);
433
434 if let Some(cached_result) = self.check_cache(&clean_text) {
436 return cached_result;
437 }
438
439 #[cfg(feature = "parallel")]
440 let results = if clean_text.len() > 1000 {
441 self.find_all_parallel(&clean_text) } else {
443 self.find_all_sequential(&clean_text) };
445 #[cfg(not(feature = "parallel"))]
446 let results = self.find_all_sequential(&clean_text);
447
448 self.cache_result(&clean_text, &results);
450
451 results
452 }
453
454 #[cfg(feature = "parallel")]
460 fn find_all_parallel(&self, text: &str) -> Vec<String> {
461 let patterns: Vec<&str> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
462
463 let (engine_results, variant_results) = rayon::join(
464 || self.engine.find_all(text),
465 || self.variant_detector.detect(text, &patterns).into_iter().map(String::from).collect::<Vec<_>>(),
466 );
467
468 let mut results = engine_results;
469 results.extend(variant_results);
470 self.deduplicate_and_sort(results)
471 }
472
473 fn find_all_sequential(&self, text: &str) -> Vec<String> {
475 let mut results = self.engine.find_all(text);
476 let patterns: Vec<_> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
477
478 results.extend(self.variant_detector.detect(text, &patterns).into_iter().map(|s| s.to_string()));
480
481 self.deduplicate_and_sort(results)
482 }
483
484 fn deduplicate_and_sort(&self, mut results: Vec<String>) -> Vec<String> {
486 results.sort_unstable();
487 results.dedup();
488 results
489 }
490
491 #[must_use]
509 pub fn find_all_batch(&self, texts: &[&str]) -> Vec<Vec<String>> {
510 #[cfg(feature = "parallel")]
511 {
512 texts.par_iter().map(|text| self.find_all(text)).collect()
513 }
514 #[cfg(not(feature = "parallel"))]
515 {
516 texts.iter().map(|text| self.find_all(text)).collect()
517 }
518 }
519
520 #[must_use]
537 pub fn find_all_layered(&self, text: &str) -> Vec<String> {
538 let clean_text = self.remove_noise(text);
539 let matches = self.leftmost_longest_matches(&clean_text);
540
541 let mut results: Vec<String> = matches.iter().map(|m| m.pattern.clone()).collect();
543
544 let mut remaining = String::with_capacity(clean_text.len());
547 let mut cursor = 0usize;
548 for m in &matches {
549 remaining.push_str(&clean_text[cursor..m.start]);
550 remaining.push(' ');
551 cursor = m.end;
552 }
553 remaining.push_str(&clean_text[cursor..]);
554
555 let patterns: Vec<&str> = self.engine.get_patterns().iter().map(|s| s.as_str()).collect();
556 results.extend(self.variant_detector.detect(&remaining, &patterns).into_iter().map(String::from));
557
558 self.deduplicate_and_sort(results)
559 }
560
561 pub fn find_all_streaming<R: BufRead>(&self, reader: R) -> io::Result<Vec<String>> {
580 let mut all_results = Vec::new();
581
582 for line in reader.lines() {
583 let line = line?;
584 let results = self.find_all(&line);
585 all_results.extend(results);
586 }
587
588 Ok(self.deduplicate_and_sort(all_results))
589 }
590}
591
592impl Default for Filter {
593 fn default() -> Self {
594 Self::new()
595 }
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601 use std::io::Cursor;
602 #[test]
603 fn test_integration() {
604 let mut filter = Filter::new();
605 filter.add_words(&["赌博", "色情"]);
606
607 assert_eq!(filter.find_in("含有赌博"), (true, "赌博".to_string()));
609
610 assert_eq!(filter.find_in("含有 dubo"), (true, "赌博".to_string()));
612
613 assert_eq!(filter.replace("赌博 色情", '*'), "** **");
615
616 assert_eq!(filter.filter("赌博内容"), "内容");
618 }
619
620 #[test]
621 fn test_noise_handling() {
622 let mut filter = Filter::new();
623 filter.add_word("赌博");
624
625 assert_eq!(filter.remove_noise("赌 博"), "赌 博");
627
628 assert_eq!(filter.remove_noise("赌@#$博"), "赌博");
630 }
631
632 #[test]
633 fn test_replace_vs_filter() {
634 let mut filter = Filter::new();
635 filter.add_words(&["赌博", "色情"]);
636
637 let text = "这里有赌博和色情内容";
638
639 assert_eq!(filter.replace(text, '*'), "这里有**和**内容");
641
642 assert_eq!(filter.filter(text), "这里有和内容");
644 }
645
646 #[test]
647 fn test_replace_single_pass_rebuild() {
648 let mut filter = Filter::new();
651 filter.add_words(&["赌博", "色情"]);
652 assert_eq!(filter.replace("前缀赌博中间色情后缀", '*'), "前缀**中间**后缀");
653 }
654
655 #[test]
656 fn test_variant_detection() {
657 let mut filter = Filter::new();
658 filter.add_word("测试");
659
660 assert_eq!(filter.find_in("ceshi"), (true, "测试".to_string()));
661 }
662
663 #[test]
664 fn test_find_first_match_exact() {
665 let mut filter = Filter::new();
666 filter.add_words(&["赌博", "色情"]);
667
668 assert_eq!(filter.find_first_match("含有赌博"), Some(Match { word: "赌博".to_string(), is_variant: false }));
669 assert_eq!(filter.find_first_match("正常文本"), None);
670 }
671
672 #[test]
673 fn test_find_first_match_variant() {
674 let mut filter = Filter::new();
675 filter.add_word("赌博");
676
677 assert_eq!(filter.find_first_match("含有 dubo"), Some(Match { word: "赌博".to_string(), is_variant: true }));
679 }
680
681 #[test]
682 fn test_find_first_match_prefers_exact_over_variant() {
683 let mut filter = Filter::new();
684 filter.add_word("赌博");
685
686 assert_eq!(filter.find_first_match("赌博 dubo"), Some(Match { word: "赌博".to_string(), is_variant: false }));
688 }
689
690 #[test]
691 fn test_algorithm_switch_one() {
692 let mut small = Filter::new();
694 small.add_words(&["a", "b", "c"]);
695 assert!(matches!(small.engine.current_algorithm(), MatchAlgorithm::WuManber));
696
697 let words: Vec<_> = (0..150).map(|i| format!("word{i}")).collect();
699 let mut medium = Filter::new();
700 medium.add_words(&words.iter().map(|s| s.as_str()).collect::<Vec<_>>());
701 println!("Medium current_algorithm: {:?}", medium.engine.current_algorithm());
702 assert!(matches!(medium.engine.current_algorithm(), MatchAlgorithm::AhoCorasick));
703 }
704
705 #[test]
706 fn test_io_operations() -> io::Result<()> {
707 let mut filter = Filter::new();
708 let cursor = Cursor::new("word1\nword2\nword3");
709 filter.load(cursor)?;
710
711 assert_eq!(filter.find_in("word2"), (true, "word2".to_string()));
712 Ok(())
713 }
714
715 #[test]
716 fn test_space_folded_word_matches_both_forms() {
717 let mut filter = Filter::new();
718 filter.add_word("A 级");
719
720 assert_eq!(filter.find_in("含有 A 级 内容"), (true, "A 级".to_string()));
721 assert_eq!(filter.find_in("含有 A级 内容"), (true, "A级".to_string()));
722
723 let results = filter.find_all("A 级 和 A级");
724 assert!(results.contains(&"A 级".to_string()));
725 assert!(results.contains(&"A级".to_string()));
726 }
727
728 #[test]
729 fn test_loaded_space_word_adds_folded_match() -> io::Result<()> {
730 let mut filter = Filter::new();
731 filter.load(Cursor::new("A 级\n3 级片"))?;
732
733 assert_eq!(filter.find_in("这里有 A级 内容"), (true, "A级".to_string()));
734 assert_eq!(filter.find_in("这里有 3级片 内容"), (true, "3级片".to_string()));
735 Ok(())
736 }
737
738 #[test]
739 fn test_delete_space_word_removes_both_forms() {
740 let mut filter = Filter::new();
741 filter.add_words(&["A 级", "B 级"]);
742
743 filter.del_word("A 级");
744
745 assert_eq!(filter.find_in("含有 A 级 内容"), (false, String::new()));
746 assert_eq!(filter.find_in("含有 A级 内容"), (false, String::new()));
747 assert_eq!(filter.find_in("含有 B级 内容"), (true, "B级".to_string()));
748 }
749
750 #[test]
751 fn test_algorithm_recommendation() {
752 assert_eq!(MultiPatternEngine::recommend_algorithm(50), MatchAlgorithm::WuManber);
753 assert_eq!(MultiPatternEngine::recommend_algorithm(150), MatchAlgorithm::AhoCorasick);
754 assert_eq!(MultiPatternEngine::recommend_algorithm(15000), MatchAlgorithm::Regex);
755 }
756
757 #[test]
758 fn test_algorithm_switch() {
759 let mut small = Filter::new();
761 small.add_words(&["a", "b", "c"]);
762 println!("Small (3 words): {:?}", small.current_algorithm());
763 assert!(matches!(small.current_algorithm(), MatchAlgorithm::WuManber));
764
765 let words: Vec<_> = (0..150).map(|i| format!("word{i}")).collect();
767 let word_refs: Vec<&str> = words.iter().map(|s| s.as_str()).collect();
768
769 let mut medium = Filter::new();
770 medium.add_words(&word_refs);
771
772 println!("Medium (150 words): {:?}", medium.current_algorithm());
773 println!("Pattern count: {}", medium.engine.get_patterns().len());
774
775 let recommended = MultiPatternEngine::recommend_algorithm(150);
777 println!("Recommended algorithm for 150 words: {recommended:?}");
778
779 assert!(matches!(medium.current_algorithm(), MatchAlgorithm::AhoCorasick));
780 }
781
782 #[test]
783 fn test_cache_invalidation_on_add_word() {
784 let mut filter = Filter::new();
785 filter.add_word("赌博");
786
787 let results1 = filter.find_all("这里有赌博");
789 assert!(results1.contains(&"赌博".to_string()));
790
791 filter.add_word("色情");
793
794 let results2 = filter.find_all("这里有赌博和色情");
796 assert!(results2.contains(&"赌博".to_string()));
797 assert!(results2.contains(&"色情".to_string()));
798 }
799
800 #[test]
801 fn test_cache_invalidation_on_del_word() {
802 let mut filter = Filter::new();
803 filter.add_words(&["赌博", "色情"]);
804
805 let results1 = filter.find_all("这里有赌博和色情");
807 assert!(results1.contains(&"赌博".to_string()));
808 assert!(results1.contains(&"色情".to_string()));
809
810 filter.del_word("赌博");
812
813 let results2 = filter.find_all("这里有赌博和色情");
815 assert!(!results2.contains(&"赌博".to_string()));
816 assert!(results2.contains(&"色情".to_string()));
817 }
818
819 #[test]
820 fn test_mutex_poison_recovery() {
821 use std::sync::Arc;
822
823 let filter = Arc::new(Filter::new());
824 let filter_clone = Arc::clone(&filter);
825
826 let handle = std::thread::spawn(move || {
828 let _guard = filter_clone.cache.lock().unwrap();
829 panic!("intentional panic to poison mutex");
830 });
831 let _ = handle.join();
832
833 let results = filter.find_all("test");
835 assert!(results.is_empty());
836 }
837
838 #[test]
839 fn test_parallel_search_cross_boundary() {
840 let mut filter = Filter::new();
841 filter.add_word("赌博");
842
843 let prefix: String = "安全文字".repeat(200); let text = format!("{prefix}这里有赌博内容");
847
848 let results = filter.find_all(&text);
849 assert!(results.contains(&"赌博".to_string()));
850 }
851
852 #[test]
853 fn test_parallel_search_no_duplicates() {
854 let mut filter = Filter::new();
855 filter.add_word("赌博");
856
857 let prefix: String = "安全".repeat(300); let text = format!("{prefix}赌博{prefix}");
860
861 let results = filter.find_all(&text);
862 let count = results.iter().filter(|w| *w == "赌博").count();
863 assert_eq!(count, 1, "expected exactly 1 match, got {count}");
864 }
865
866 #[test]
869 fn test_find_all_batch() {
870 let mut filter = Filter::new();
871 filter.add_words(&["赌博", "色情"]);
872
873 let texts = vec!["含有赌博", "含有色情", "正常内容"];
874 let results = filter.find_all_batch(&texts);
875
876 assert_eq!(results.len(), 3);
877 assert!(results[0].contains(&"赌博".to_string()));
878 assert!(results[1].contains(&"色情".to_string()));
879 assert!(results[2].is_empty());
880 }
881
882 #[test]
883 fn test_find_all_batch_empty() {
884 let filter = Filter::new();
885 let results = filter.find_all_batch(&[]);
886 assert!(results.is_empty());
887 }
888
889 #[test]
890 fn test_find_all_layered_prefers_longest() {
891 let mut filter = Filter::new();
892 filter.add_words(&["赌", "赌博", "赌博机"]);
893
894 let results = filter.find_all_layered("这里有赌博机");
896 assert!(results.contains(&"赌博机".to_string()));
897 assert!(!results.contains(&"赌".to_string()));
898 assert!(!results.contains(&"赌博".to_string()));
899 }
900
901 #[test]
902 fn test_find_all_layered_multi_occurrence() {
903 let mut filter = Filter::new();
906 filter.add_words(&["赌", "赌博", "赌博机"]);
907
908 let results = filter.find_all_layered("赌博和赌博机");
909 assert!(results.contains(&"赌博".to_string()));
910 assert!(results.contains(&"赌博机".to_string()));
911 assert!(!results.contains(&"赌".to_string()));
912 }
913
914 #[test]
915 fn test_find_all_layered_blanks_exact_before_variant() {
916 let mut filter = Filter::new();
919 filter.add_words(&["赌", "赌博", "赌博机"]);
920
921 let results = filter.find_all_layered("这里有赌博机");
922 assert_eq!(results, vec!["赌博机".to_string()]);
925 }
926
927 #[test]
928 fn test_replace_and_filter_are_exact_only() {
929 let mut filter = Filter::new();
933 filter.add_word("赌博");
934
935 assert_eq!(filter.replace("含有赌博", '*'), "含有**");
937 assert_eq!(filter.filter("含有赌博"), "含有");
938 assert_eq!(filter.replace("dubo", '*'), "dubo");
940 assert_eq!(filter.filter("dubo"), "dubo");
941 }
942
943 #[test]
944 fn test_find_all_streaming_multiline() {
945 let mut filter = Filter::new();
946 filter.add_words(&["赌博", "色情"]);
947
948 let input = "第一行含有赌博\n第二行含有色情\n第三行正常";
949 let cursor = std::io::Cursor::new(input);
950 let results = filter.find_all_streaming(cursor).unwrap();
951
952 assert!(results.contains(&"赌博".to_string()));
953 assert!(results.contains(&"色情".to_string()));
954 assert_eq!(results.len(), 2);
955 }
956
957 #[test]
960 fn test_cache_hit_returns_consistent_results() {
961 let mut filter = Filter::new();
962 filter.add_words(&["赌博", "色情"]);
963
964 let r1 = filter.find_all("含有赌博和色情内容");
966 let r2 = filter.find_all("含有赌博和色情内容");
967 assert_eq!(r1, r2);
968 }
969
970 #[test]
971 fn test_cache_clear() {
972 let mut filter = Filter::new();
973 filter.add_word("赌博");
974
975 let _ = filter.find_all("含有赌博"); filter.clear_cache();
977
978 let results = filter.find_all("含有赌博");
980 assert!(results.contains(&"赌博".to_string()));
981 }
982
983 #[test]
986 fn test_empty_text() {
987 let mut filter = Filter::new();
988 filter.add_word("赌博");
989
990 assert_eq!(filter.find_in(""), (false, String::new()));
991 assert!(filter.find_all("").is_empty());
992 assert_eq!(filter.replace("", '*'), "");
993 assert_eq!(filter.filter(""), "");
994 }
995
996 #[test]
997 fn test_empty_dictionary() {
998 let filter = Filter::new();
999
1000 assert_eq!(filter.find_in("任何文本"), (false, String::new()));
1001 assert!(filter.find_all("任何文本").is_empty());
1002 assert_eq!(filter.replace("任何文本", '*'), "任何文本");
1003 assert_eq!(filter.filter("任何文本"), "任何文本");
1004 }
1005
1006 #[test]
1007 fn test_unicode_emoji_does_not_interfere() {
1008 let mut filter = Filter::new();
1009 filter.add_word("赌博");
1010
1011 let (found, word) = filter.find_in("🎉 赌博 🎰");
1013 assert!(found);
1014 assert_eq!(word, "赌博");
1015 }
1016
1017 #[test]
1018 fn test_very_long_text() {
1019 let mut filter = Filter::new();
1020 filter.add_word("赌博");
1021
1022 let long_text = "正常".repeat(100_000) + "赌博";
1024 let results = filter.find_all(&long_text);
1025 assert!(results.contains(&"赌博".to_string()));
1026 }
1027
1028 #[test]
1029 fn test_cjk_extension_b_chars() {
1030 let mut filter = Filter::new();
1031 filter.add_word("赌博");
1032
1033 let text = "含有赌博内容 𠀀𠀁";
1035 let (found, _) = filter.find_in(text);
1036 assert!(found);
1037 }
1038}