1use std::collections::HashMap;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum SentimentPolarity {
27 Positive,
29 Negative,
31 Mixed,
33 Neutral,
35}
36
37#[derive(Debug, Clone, PartialEq)]
40pub struct SentimentScore {
41 pub positive: f64,
43 pub negative: f64,
45 pub neutral: f64,
47 pub compound: f64,
49}
50
51impl SentimentScore {
52 pub fn zero() -> Self {
54 Self {
55 positive: 0.0,
56 negative: 0.0,
57 neutral: 1.0,
58 compound: 0.0,
59 }
60 }
61
62 pub fn polarity(&self) -> SentimentPolarity {
67 if self.positive > 0.1 && self.negative > 0.1 {
68 SentimentPolarity::Mixed
69 } else if self.compound > 0.05 {
70 SentimentPolarity::Positive
71 } else if self.compound < -0.05 {
72 SentimentPolarity::Negative
73 } else {
74 SentimentPolarity::Neutral
75 }
76 }
77}
78
79#[derive(Debug, Clone)]
81pub struct AspectSentiment {
82 pub aspect: String,
84 pub sentiment: SentimentScore,
86 pub mentions: Vec<String>,
88}
89
90#[derive(Debug, Clone)]
92pub struct SentimentResult {
93 pub text_id: String,
95 pub overall: SentimentScore,
97 pub aspects: Vec<AspectSentiment>,
99 pub word_count: usize,
101 pub sentiment_word_count: usize,
103}
104
105#[derive(Debug, Clone)]
107pub struct LexiconEntry {
108 pub word: String,
110 pub positive_score: f64,
112 pub negative_score: f64,
114 pub intensifier: f64,
120}
121
122impl LexiconEntry {
123 pub fn positive(word: impl Into<String>, score: f64) -> Self {
125 Self {
126 word: word.into(),
127 positive_score: score,
128 negative_score: 0.0,
129 intensifier: 1.0,
130 }
131 }
132
133 pub fn negative(word: impl Into<String>, score: f64) -> Self {
135 Self {
136 word: word.into(),
137 positive_score: 0.0,
138 negative_score: score,
139 intensifier: 1.0,
140 }
141 }
142
143 pub fn modifier(word: impl Into<String>, intensifier: f64) -> Self {
145 Self {
146 word: word.into(),
147 positive_score: 0.0,
148 negative_score: 0.0,
149 intensifier,
150 }
151 }
152}
153
154#[derive(Debug, Clone)]
156pub struct SentimentConfig {
157 pub window_size: usize,
160 pub negation_window: usize,
162 pub aspect_keywords: Vec<String>,
164}
165
166impl Default for SentimentConfig {
167 fn default() -> Self {
168 Self {
169 window_size: 5,
170 negation_window: 3,
171 aspect_keywords: vec![
172 "quality".to_string(),
173 "price".to_string(),
174 "service".to_string(),
175 "speed".to_string(),
176 "reliability".to_string(),
177 "performance".to_string(),
178 ],
179 }
180 }
181}
182
183#[derive(Debug, Clone)]
185pub struct SentimentAnalyzerStats {
186 pub total_analyzed: usize,
188 pub positive_count: usize,
190 pub negative_count: usize,
192 pub neutral_count: usize,
194 pub mixed_count: usize,
196 pub avg_compound: f64,
198}
199
200const NEGATIONS: &[&str] = &[
206 "not", "never", "no", "isn't", "wasn't", "aren't", "weren't", "doesn't", "didn't", "don't",
207 "nor", "neither", "without", "lacks", "lack",
208];
209
210fn tokenize(text: &str) -> Vec<String> {
213 text.split(|c: char| !c.is_alphanumeric() && c != '\'')
214 .filter(|s| !s.is_empty())
215 .map(|s| s.to_lowercase())
216 .collect()
217}
218
219fn contains_negation(window: &[String]) -> bool {
221 window.iter().any(|w| NEGATIONS.contains(&w.as_str()))
222}
223
224fn find_intensity_multiplier(window: &[String], lexicon: &HashMap<String, LexiconEntry>) -> f64 {
227 let mut multiplier = 1.0_f64;
228 for token in window {
229 if let Some(entry) = lexicon.get(token) {
230 if entry.positive_score == 0.0
232 && entry.negative_score == 0.0
233 && entry.intensifier != 1.0
234 {
235 if (entry.intensifier - 1.0).abs() > (multiplier - 1.0).abs() {
237 multiplier = entry.intensifier;
238 }
239 }
240 }
241 }
242 multiplier
243}
244
245pub struct SentimentAnalyzer {
256 pub config: SentimentConfig,
258 pub lexicon: HashMap<String, LexiconEntry>,
260}
261
262impl SentimentAnalyzer {
263 pub fn new(config: SentimentConfig) -> Self {
265 let mut analyzer = Self {
266 config,
267 lexicon: HashMap::new(),
268 };
269 analyzer.populate_builtin_lexicon();
270 analyzer
271 }
272
273 pub fn with_lexicon_entry(mut self, entry: LexiconEntry) -> Self {
275 self.lexicon.insert(entry.word.clone(), entry);
276 self
277 }
278
279 pub fn analyze(&self, text_id: String, text: &str) -> SentimentResult {
285 let tokens = tokenize(text);
286 let word_count = tokens.len();
287
288 let (raw_pos, raw_neg, sentiment_word_count) = self.accumulate_scores(&tokens);
290
291 let overall = self.build_score(raw_pos, raw_neg, word_count);
292
293 let aspect_keywords: Vec<String> = self
295 .config
296 .aspect_keywords
297 .iter()
298 .map(|k| k.to_lowercase())
299 .collect();
300
301 let mut aspects = Vec::new();
302 for (idx, token) in tokens.iter().enumerate() {
303 if aspect_keywords.contains(token) {
304 let start = idx.saturating_sub(self.config.window_size);
305 let end = (idx + self.config.window_size + 1).min(tokens.len());
306 let window: Vec<String> = tokens[start..end].to_vec();
307
308 let (a_pos, a_neg, _) = self.accumulate_scores(&window);
309 let a_score = self.build_score(a_pos, a_neg, window.len());
310
311 let mentions: Vec<String> = window
313 .iter()
314 .filter(|t| {
315 self.lexicon
316 .get(*t)
317 .is_some_and(|e| e.positive_score > 0.0 || e.negative_score > 0.0)
318 })
319 .cloned()
320 .collect();
321
322 aspects.push(AspectSentiment {
323 aspect: token.clone(),
324 sentiment: a_score,
325 mentions,
326 });
327 }
328 }
329
330 SentimentResult {
331 text_id,
332 overall,
333 aspects,
334 word_count,
335 sentiment_word_count,
336 }
337 }
338
339 pub fn batch_analyze(&self, texts: &[(String, String)]) -> Vec<SentimentResult> {
341 texts
342 .iter()
343 .map(|(id, text)| self.analyze(id.clone(), text))
344 .collect()
345 }
346
347 pub fn top_positive<'a>(
353 &self,
354 results: &'a [SentimentResult],
355 n: usize,
356 ) -> Vec<&'a SentimentResult> {
357 let mut sorted: Vec<&SentimentResult> = results.iter().collect();
358 sorted.sort_by(|a, b| {
359 b.overall
360 .compound
361 .partial_cmp(&a.overall.compound)
362 .unwrap_or(std::cmp::Ordering::Equal)
363 });
364 sorted.truncate(n);
365 sorted
366 }
367
368 pub fn top_negative<'a>(
370 &self,
371 results: &'a [SentimentResult],
372 n: usize,
373 ) -> Vec<&'a SentimentResult> {
374 let mut sorted: Vec<&SentimentResult> = results.iter().collect();
375 sorted.sort_by(|a, b| {
376 a.overall
377 .compound
378 .partial_cmp(&b.overall.compound)
379 .unwrap_or(std::cmp::Ordering::Equal)
380 });
381 sorted.truncate(n);
382 sorted
383 }
384
385 pub fn aggregate_sentiment(&self, results: &[SentimentResult]) -> SentimentScore {
389 if results.is_empty() {
390 return SentimentScore::zero();
391 }
392 let n = results.len() as f64;
393 let pos = results.iter().map(|r| r.overall.positive).sum::<f64>() / n;
394 let neg = results.iter().map(|r| r.overall.negative).sum::<f64>() / n;
395 let neu = results.iter().map(|r| r.overall.neutral).sum::<f64>() / n;
396 let cmp = results.iter().map(|r| r.overall.compound).sum::<f64>() / n;
397 SentimentScore {
398 positive: pos,
399 negative: neg,
400 neutral: neu,
401 compound: cmp,
402 }
403 }
404
405 pub fn stats(&self, results: &[SentimentResult]) -> SentimentAnalyzerStats {
407 let total_analyzed = results.len();
408 let mut positive_count = 0usize;
409 let mut negative_count = 0usize;
410 let mut neutral_count = 0usize;
411 let mut mixed_count = 0usize;
412 let mut compound_sum = 0.0_f64;
413
414 for r in results {
415 compound_sum += r.overall.compound;
416 match r.overall.polarity() {
417 SentimentPolarity::Positive => positive_count += 1,
418 SentimentPolarity::Negative => negative_count += 1,
419 SentimentPolarity::Neutral => neutral_count += 1,
420 SentimentPolarity::Mixed => mixed_count += 1,
421 }
422 }
423
424 let avg_compound = if total_analyzed > 0 {
425 compound_sum / total_analyzed as f64
426 } else {
427 0.0
428 };
429
430 SentimentAnalyzerStats {
431 total_analyzed,
432 positive_count,
433 negative_count,
434 neutral_count,
435 mixed_count,
436 avg_compound,
437 }
438 }
439
440 fn accumulate_scores(&self, tokens: &[String]) -> (f64, f64, usize) {
448 let mut raw_pos = 0.0_f64;
449 let mut raw_neg = 0.0_f64;
450 let mut sentiment_word_count = 0usize;
451
452 for (i, token) in tokens.iter().enumerate() {
453 let entry = match self.lexicon.get(token) {
454 Some(e) => e,
455 None => continue,
456 };
457
458 if entry.positive_score == 0.0 && entry.negative_score == 0.0 {
461 continue;
462 }
463
464 sentiment_word_count += 1;
465
466 let ctx_start = i.saturating_sub(self.config.negation_window);
468 let preceding: Vec<String> = tokens[ctx_start..i].to_vec();
469
470 let negated = contains_negation(&preceding);
471 let intensity = find_intensity_multiplier(&preceding, &self.lexicon);
472
473 let mut pos = entry.positive_score * intensity;
474 let mut neg = entry.negative_score * intensity;
475
476 if negated {
477 std::mem::swap(&mut pos, &mut neg);
479 }
480
481 raw_pos += pos;
482 raw_neg += neg;
483 }
484
485 (raw_pos, raw_neg, sentiment_word_count)
486 }
487
488 fn build_score(&self, raw_pos: f64, raw_neg: f64, word_count: usize) -> SentimentScore {
490 let wc = word_count.max(1) as f64;
491
492 let pos = (raw_pos / wc).min(1.0_f64);
494 let neg = (raw_neg / wc).min(1.0_f64);
495 let neu = (1.0_f64 - pos - neg).max(0.0_f64);
496
497 let denom = (pos + neg + neu + 0.001_f64).max(0.001_f64);
498 let compound = (pos - neg) / denom;
499
500 SentimentScore {
501 positive: pos,
502 negative: neg,
503 neutral: neu,
504 compound,
505 }
506 }
507
508 fn populate_builtin_lexicon(&mut self) {
511 let positive_words = [
513 ("good", 0.7),
514 ("great", 0.85),
515 ("excellent", 0.95),
516 ("amazing", 0.90),
517 ("wonderful", 0.90),
518 ("fantastic", 0.95),
519 ("love", 0.85),
520 ("best", 0.90),
521 ("perfect", 1.00),
522 ("outstanding", 0.95),
523 ("brilliant", 0.90),
524 ("superb", 0.90),
525 ("incredible", 0.90),
526 ("awesome", 0.85),
527 ("positive", 0.65),
528 ("helpful", 0.70),
529 ("fast", 0.60),
530 ("reliable", 0.70),
531 ("efficient", 0.70),
532 ("clear", 0.55),
533 ("smooth", 0.60),
534 ("easy", 0.60),
535 ("simple", 0.55),
536 ("pleasant", 0.65),
537 ("satisfied", 0.70),
538 ("happy", 0.80),
539 ("delighted", 0.85),
540 ("impressed", 0.75),
541 ("accurate", 0.65),
542 ("responsive", 0.65),
543 ("innovative", 0.70),
544 ("intuitive", 0.65),
545 ];
546
547 for (word, score) in &positive_words {
548 self.lexicon
549 .insert(word.to_string(), LexiconEntry::positive(*word, *score));
550 }
551
552 let negative_words = [
554 ("bad", 0.70),
555 ("terrible", 0.90),
556 ("awful", 0.90),
557 ("horrible", 0.95),
558 ("hate", 0.85),
559 ("worst", 0.95),
560 ("poor", 0.65),
561 ("broken", 0.80),
562 ("slow", 0.60),
563 ("difficult", 0.55),
564 ("frustrating", 0.80),
565 ("disappointing", 0.75),
566 ("unreliable", 0.75),
567 ("complex", 0.50),
568 ("confusing", 0.65),
569 ("annoying", 0.70),
570 ("useless", 0.85),
571 ("failed", 0.80),
572 ("error", 0.65),
573 ("problem", 0.60),
574 ("issue", 0.50),
575 ("bug", 0.65),
576 ("crash", 0.85),
577 ("delay", 0.60),
578 ("expensive", 0.60),
579 ("lacking", 0.55),
580 ("outdated", 0.55),
581 ("clunky", 0.65),
582 ];
583
584 for (word, score) in &negative_words {
585 self.lexicon
586 .insert(word.to_string(), LexiconEntry::negative(*word, *score));
587 }
588
589 let intensifiers = [
591 ("very", 1.5),
592 ("extremely", 1.8),
593 ("incredibly", 1.7),
594 ("absolutely", 1.8),
595 ("totally", 1.6),
596 ("highly", 1.5),
597 ("really", 1.4),
598 ("deeply", 1.5),
599 ("utterly", 1.7),
600 ("truly", 1.4),
601 ];
602
603 for (word, mult) in &intensifiers {
604 self.lexicon
605 .insert(word.to_string(), LexiconEntry::modifier(*word, *mult));
606 }
607
608 let diminishers = [
610 ("slightly", 0.5),
611 ("somewhat", 0.6),
612 ("barely", 0.3),
613 ("hardly", 0.3),
614 ("rarely", 0.4),
615 ("mildly", 0.5),
616 ("partially", 0.6),
617 ("almost", 0.7),
618 ];
619
620 for (word, mult) in &diminishers {
621 self.lexicon
622 .insert(word.to_string(), LexiconEntry::modifier(*word, *mult));
623 }
624
625 let negations = [
628 "not", "never", "no", "isn't", "wasn't", "aren't", "weren't", "doesn't", "didn't",
629 "don't", "nor", "neither", "without",
630 ];
631 for word in &negations {
632 self.lexicon
633 .entry(word.to_string())
634 .or_insert_with(|| LexiconEntry::modifier(*word, 1.0));
635 }
636 }
637}
638
639#[cfg(test)]
644mod tests {
645 use super::{
646 tokenize, AspectSentiment, LexiconEntry, SentimentAnalyzer, SentimentConfig,
647 SentimentPolarity, SentimentResult, SentimentScore,
648 };
649
650 fn default_analyzer() -> SentimentAnalyzer {
653 SentimentAnalyzer::new(SentimentConfig::default())
654 }
655
656 #[test]
659 fn test_sentiment_score_zero_is_neutral() {
660 let s = SentimentScore::zero();
661 assert_eq!(s.polarity(), SentimentPolarity::Neutral);
662 }
663
664 #[test]
665 fn test_polarity_positive_compound() {
666 let s = SentimentScore {
667 positive: 0.8,
668 negative: 0.0,
669 neutral: 0.2,
670 compound: 0.8,
671 };
672 assert_eq!(s.polarity(), SentimentPolarity::Positive);
673 }
674
675 #[test]
676 fn test_polarity_negative_compound() {
677 let s = SentimentScore {
678 positive: 0.0,
679 negative: 0.8,
680 neutral: 0.2,
681 compound: -0.8,
682 };
683 assert_eq!(s.polarity(), SentimentPolarity::Negative);
684 }
685
686 #[test]
687 fn test_polarity_mixed_both_significant() {
688 let s = SentimentScore {
689 positive: 0.4,
690 negative: 0.3,
691 neutral: 0.3,
692 compound: 0.1,
693 };
694 assert_eq!(s.polarity(), SentimentPolarity::Mixed);
696 }
697
698 #[test]
699 fn test_polarity_neutral_small_compound() {
700 let s = SentimentScore {
701 positive: 0.02,
702 negative: 0.01,
703 neutral: 0.97,
704 compound: 0.02,
705 };
706 assert_eq!(s.polarity(), SentimentPolarity::Neutral);
707 }
708
709 #[test]
710 fn test_compound_range_positive() {
711 let s = SentimentScore {
712 positive: 0.9,
713 negative: 0.0,
714 neutral: 0.1,
715 compound: 0.9,
716 };
717 assert!(s.compound > 0.05, "should be positive");
718 }
719
720 #[test]
721 fn test_compound_range_negative() {
722 let s = SentimentScore {
723 positive: 0.0,
724 negative: 0.9,
725 neutral: 0.1,
726 compound: -0.9,
727 };
728 assert!(s.compound < -0.05, "should be negative");
729 }
730
731 #[test]
734 fn test_tokenize_basic() {
735 let tokens = tokenize("Hello, World!");
736 assert_eq!(tokens, vec!["hello", "world"]);
737 }
738
739 #[test]
740 fn test_tokenize_contractions_preserved() {
741 let tokens = tokenize("it isn't broken");
742 assert!(tokens.contains(&"isn't".to_string()));
743 }
744
745 #[test]
746 fn test_tokenize_empty_string() {
747 assert!(tokenize("").is_empty());
748 }
749
750 #[test]
751 fn test_tokenize_punctuation_only() {
752 assert!(tokenize("... --- !!!").is_empty());
753 }
754
755 #[test]
756 fn test_tokenize_lowercases() {
757 let tokens = tokenize("GOOD BAD");
758 assert_eq!(tokens, vec!["good", "bad"]);
759 }
760
761 #[test]
764 fn test_lexicon_entry_positive() {
765 let e = LexiconEntry::positive("good", 0.7);
766 assert_eq!(e.word, "good");
767 assert!(e.positive_score > 0.0);
768 assert_eq!(e.negative_score, 0.0);
769 assert_eq!(e.intensifier, 1.0);
770 }
771
772 #[test]
773 fn test_lexicon_entry_negative() {
774 let e = LexiconEntry::negative("bad", 0.7);
775 assert_eq!(e.positive_score, 0.0);
776 assert!(e.negative_score > 0.0);
777 }
778
779 #[test]
780 fn test_lexicon_entry_modifier_intensifier() {
781 let e = LexiconEntry::modifier("very", 1.5);
782 assert_eq!(e.positive_score, 0.0);
783 assert_eq!(e.negative_score, 0.0);
784 assert_eq!(e.intensifier, 1.5);
785 }
786
787 #[test]
788 fn test_lexicon_entry_modifier_diminisher() {
789 let e = LexiconEntry::modifier("slightly", 0.5);
790 assert_eq!(e.intensifier, 0.5);
791 }
792
793 #[test]
796 fn test_new_has_positive_words() {
797 let a = default_analyzer();
798 assert!(a.lexicon.contains_key("good"));
799 assert!(a.lexicon.contains_key("excellent"));
800 }
801
802 #[test]
803 fn test_new_has_negative_words() {
804 let a = default_analyzer();
805 assert!(a.lexicon.contains_key("bad"));
806 assert!(a.lexicon.contains_key("terrible"));
807 }
808
809 #[test]
810 fn test_new_has_intensifiers() {
811 let a = default_analyzer();
812 let e = a.lexicon.get("very").expect("very must exist");
813 assert!(e.intensifier > 1.0);
814 }
815
816 #[test]
817 fn test_new_has_diminishers() {
818 let a = default_analyzer();
819 let e = a.lexicon.get("slightly").expect("slightly must exist");
820 assert!(e.intensifier < 1.0);
821 }
822
823 #[test]
824 fn test_lexicon_has_at_least_60_entries() {
825 let a = default_analyzer();
826 assert!(
827 a.lexicon.len() >= 60,
828 "expected ≥60 entries, got {}",
829 a.lexicon.len()
830 );
831 }
832
833 #[test]
836 fn test_with_lexicon_entry_adds_word() {
837 let a = default_analyzer().with_lexicon_entry(LexiconEntry::positive("stellar", 0.9));
838 assert!(a.lexicon.contains_key("stellar"));
839 }
840
841 #[test]
842 fn test_with_lexicon_entry_overrides_existing() {
843 let a = default_analyzer().with_lexicon_entry(LexiconEntry::positive("good", 0.999));
844 let e = a.lexicon.get("good").expect("good must exist");
845 assert!((e.positive_score - 0.999).abs() < 1e-9);
846 }
847
848 #[test]
851 fn test_analyze_positive_text() {
852 let a = default_analyzer();
853 let r = a.analyze(
854 "t1".to_string(),
855 "This is absolutely excellent and amazing!",
856 );
857 assert_eq!(r.overall.polarity(), SentimentPolarity::Positive);
858 }
859
860 #[test]
861 fn test_analyze_negative_text() {
862 let a = default_analyzer();
863 let r = a.analyze("t1".to_string(), "The service is terrible and frustrating.");
864 assert_eq!(r.overall.polarity(), SentimentPolarity::Negative);
865 }
866
867 #[test]
868 fn test_analyze_neutral_text() {
869 let a = default_analyzer();
870 let r = a.analyze("t1".to_string(), "The document is a plain text file.");
871 assert_eq!(r.overall.polarity(), SentimentPolarity::Neutral);
872 }
873
874 #[test]
877 fn test_analyze_word_count() {
878 let a = default_analyzer();
879 let r = a.analyze("t1".to_string(), "good bad");
880 assert_eq!(r.word_count, 2);
881 }
882
883 #[test]
884 fn test_analyze_sentiment_word_count_non_zero_for_sentiment_text() {
885 let a = default_analyzer();
886 let r = a.analyze("t1".to_string(), "excellent");
887 assert!(r.sentiment_word_count > 0);
888 }
889
890 #[test]
891 fn test_analyze_empty_text() {
892 let a = default_analyzer();
893 let r = a.analyze("t1".to_string(), "");
894 assert_eq!(r.word_count, 0);
895 assert_eq!(r.sentiment_word_count, 0);
896 }
897
898 #[test]
901 fn test_negation_flips_positive_to_negative() {
902 let a = default_analyzer();
903 let pos = a.analyze("pos".to_string(), "excellent");
904 let neg = a.analyze("neg".to_string(), "not excellent");
905 assert!(
907 neg.overall.compound < pos.overall.compound,
908 "negation should reduce compound: {} vs {}",
909 neg.overall.compound,
910 pos.overall.compound
911 );
912 }
913
914 #[test]
915 fn test_negation_flips_negative_to_positive() {
916 let a = default_analyzer();
917 let base = a.analyze("base".to_string(), "terrible");
918 let neg = a.analyze("neg".to_string(), "not terrible");
919 assert!(neg.overall.compound > base.overall.compound);
920 }
921
922 #[test]
923 fn test_contraction_negation() {
924 let a = default_analyzer();
925 let r = a.analyze("t1".to_string(), "isn't broken");
926 assert!(r.overall.compound >= 0.0);
928 }
929
930 #[test]
933 fn test_intensifier_boosts_positive() {
934 let a = default_analyzer();
938 let base = a.analyze("base".to_string(), "the good thing");
939 let boosted = a.analyze("boosted".to_string(), "extremely good thing");
940 assert!(
942 boosted.overall.positive >= base.overall.positive,
943 "intensifier should boost positive: base={}, boosted={}",
944 base.overall.positive,
945 boosted.overall.positive
946 );
947 }
948
949 #[test]
950 fn test_diminisher_reduces_sentiment() {
951 let a = default_analyzer();
952 let base = a.analyze("base".to_string(), "good");
953 let reduced = a.analyze("reduced".to_string(), "slightly good");
954 assert!(reduced.overall.positive <= base.overall.positive);
955 }
956
957 #[test]
960 fn test_aspect_detected_for_keyword() {
961 let a = default_analyzer();
962 let r = a.analyze("t1".to_string(), "The service is excellent and fast.");
963 let service_aspects: Vec<&AspectSentiment> = r
964 .aspects
965 .iter()
966 .filter(|asp| asp.aspect == "service")
967 .collect();
968 assert!(!service_aspects.is_empty(), "expected a 'service' aspect");
969 }
970
971 #[test]
972 fn test_aspect_not_detected_for_missing_keyword() {
973 let a = default_analyzer();
974 let r = a.analyze("t1".to_string(), "Everything is fine.");
975 assert!(r.aspects.is_empty());
976 }
977
978 #[test]
979 fn test_aspect_mentions_non_empty() {
980 let a = default_analyzer();
981 let r = a.analyze("t1".to_string(), "The quality is great and reliable.");
982 let aspect = r.aspects.iter().find(|a| a.aspect == "quality");
983 assert!(aspect.is_some());
984 if let Some(asp) = aspect {
985 assert!(!asp.mentions.is_empty(), "mentions should not be empty");
987 }
988 }
989
990 #[test]
991 fn test_aspect_multiple_occurrences() {
992 let a = default_analyzer();
993 let r = a.analyze(
994 "t1".to_string(),
995 "The performance is great. Performance is also reliable.",
996 );
997 let perf_count = r
998 .aspects
999 .iter()
1000 .filter(|a| a.aspect == "performance")
1001 .count();
1002 assert_eq!(perf_count, 2, "two occurrences of 'performance'");
1003 }
1004
1005 #[test]
1008 fn test_batch_analyze_returns_correct_count() {
1009 let a = default_analyzer();
1010 let texts = vec![
1011 ("t1".to_string(), "great product".to_string()),
1012 ("t2".to_string(), "terrible service".to_string()),
1013 ("t3".to_string(), "average experience".to_string()),
1014 ];
1015 let results = a.batch_analyze(&texts);
1016 assert_eq!(results.len(), 3);
1017 }
1018
1019 #[test]
1020 fn test_batch_analyze_empty() {
1021 let a = default_analyzer();
1022 let results = a.batch_analyze(&[]);
1023 assert!(results.is_empty());
1024 }
1025
1026 #[test]
1027 fn test_batch_analyze_preserves_text_ids() {
1028 let a = default_analyzer();
1029 let texts = vec![
1030 ("id_one".to_string(), "good".to_string()),
1031 ("id_two".to_string(), "bad".to_string()),
1032 ];
1033 let results = a.batch_analyze(&texts);
1034 assert_eq!(results[0].text_id, "id_one");
1035 assert_eq!(results[1].text_id, "id_two");
1036 }
1037
1038 #[test]
1041 fn test_top_positive_ordering() {
1042 let a = default_analyzer();
1043 let texts = vec![
1044 ("neg".to_string(), "horrible terrible awful".to_string()),
1045 ("pos".to_string(), "excellent amazing perfect".to_string()),
1046 ("neu".to_string(), "the cat sat on a mat".to_string()),
1047 ];
1048 let results = a.batch_analyze(&texts);
1049 let top = a.top_positive(&results, 1);
1050 assert_eq!(top[0].text_id, "pos");
1051 }
1052
1053 #[test]
1054 fn test_top_negative_ordering() {
1055 let a = default_analyzer();
1056 let texts = vec![
1057 ("neg".to_string(), "horrible terrible awful".to_string()),
1058 ("pos".to_string(), "excellent amazing perfect".to_string()),
1059 ];
1060 let results = a.batch_analyze(&texts);
1061 let bottom = a.top_negative(&results, 1);
1062 assert_eq!(bottom[0].text_id, "neg");
1063 }
1064
1065 #[test]
1066 fn test_top_positive_n_larger_than_results() {
1067 let a = default_analyzer();
1068 let texts = vec![("t1".to_string(), "good".to_string())];
1069 let results = a.batch_analyze(&texts);
1070 let top = a.top_positive(&results, 100);
1071 assert_eq!(top.len(), 1);
1072 }
1073
1074 #[test]
1075 fn test_top_negative_n_larger_than_results() {
1076 let a = default_analyzer();
1077 let texts = vec![("t1".to_string(), "bad".to_string())];
1078 let results = a.batch_analyze(&texts);
1079 let bottom = a.top_negative(&results, 100);
1080 assert_eq!(bottom.len(), 1);
1081 }
1082
1083 #[test]
1086 fn test_aggregate_empty_returns_zero() {
1087 let a = default_analyzer();
1088 let agg = a.aggregate_sentiment(&[]);
1089 assert_eq!(agg.polarity(), SentimentPolarity::Neutral);
1090 }
1091
1092 #[test]
1093 fn test_aggregate_single_equals_result() {
1094 let a = default_analyzer();
1095 let r = a.analyze("t1".to_string(), "excellent");
1096 let agg = a.aggregate_sentiment(std::slice::from_ref(&r));
1097 assert!((agg.compound - r.overall.compound).abs() < 1e-9);
1098 }
1099
1100 #[test]
1101 fn test_aggregate_mixed_batch() {
1102 let a = default_analyzer();
1103 let texts = vec![
1104 (
1105 "pos".to_string(),
1106 "excellent perfect outstanding".to_string(),
1107 ),
1108 ("neg".to_string(), "terrible awful horrible".to_string()),
1109 ];
1110 let results = a.batch_analyze(&texts);
1111 let agg = a.aggregate_sentiment(&results);
1112 assert!(agg.positive > 0.0);
1114 assert!(agg.negative > 0.0);
1115 }
1116
1117 #[test]
1120 fn test_stats_empty() {
1121 let a = default_analyzer();
1122 let s = a.stats(&[]);
1123 assert_eq!(s.total_analyzed, 0);
1124 assert_eq!(s.avg_compound, 0.0);
1125 }
1126
1127 #[test]
1128 fn test_stats_counts_positive() {
1129 let a = default_analyzer();
1130 let texts = vec![
1131 ("t1".to_string(), "excellent amazing".to_string()),
1132 ("t2".to_string(), "great wonderful".to_string()),
1133 ];
1134 let results = a.batch_analyze(&texts);
1135 let s = a.stats(&results);
1136 assert!(s.positive_count > 0);
1137 }
1138
1139 #[test]
1140 fn test_stats_counts_negative() {
1141 let a = default_analyzer();
1142 let texts = vec![("t1".to_string(), "terrible awful".to_string())];
1143 let results = a.batch_analyze(&texts);
1144 let s = a.stats(&results);
1145 assert!(s.negative_count > 0);
1146 }
1147
1148 #[test]
1149 fn test_stats_total_analyzed() {
1150 let a = default_analyzer();
1151 let texts: Vec<(String, String)> = (0..7)
1152 .map(|i| (format!("t{}", i), "good".to_string()))
1153 .collect();
1154 let results = a.batch_analyze(&texts);
1155 let s = a.stats(&results);
1156 assert_eq!(s.total_analyzed, 7);
1157 }
1158
1159 #[test]
1160 fn test_stats_avg_compound_single() {
1161 let a = default_analyzer();
1162 let r = a.analyze("t1".to_string(), "excellent");
1163 let compound = r.overall.compound;
1164 let s = a.stats(&[r]);
1165 assert!((s.avg_compound - compound).abs() < 1e-9);
1166 }
1167
1168 #[test]
1171 fn test_result_text_id_preserved() {
1172 let a = default_analyzer();
1173 let r = a.analyze("my-unique-id".to_string(), "good");
1174 assert_eq!(r.text_id, "my-unique-id");
1175 }
1176
1177 #[test]
1178 fn test_result_compound_in_valid_range() {
1179 let a = default_analyzer();
1180 let texts = [
1181 "absolutely fantastic amazing wonderful",
1182 "horrible terrible awful crash",
1183 "the quick brown fox jumps",
1184 ];
1185 for text in &texts {
1186 let r = a.analyze("t".to_string(), text);
1187 assert!(
1188 r.overall.compound >= -1.0 && r.overall.compound <= 1.0,
1189 "compound out of range for {:?}: {}",
1190 text,
1191 r.overall.compound
1192 );
1193 }
1194 }
1195
1196 #[test]
1199 fn test_custom_aspect_keywords() {
1200 let config = SentimentConfig {
1201 aspect_keywords: vec!["battery".to_string(), "camera".to_string()],
1202 ..SentimentConfig::default()
1203 };
1204 let a = SentimentAnalyzer::new(config);
1205 let r = a.analyze(
1206 "t1".to_string(),
1207 "The battery is amazing but the camera is slow.",
1208 );
1209 let aspects: Vec<&str> = r.aspects.iter().map(|a| a.aspect.as_str()).collect();
1210 assert!(aspects.contains(&"battery"));
1211 assert!(aspects.contains(&"camera"));
1212 }
1213
1214 #[test]
1215 fn test_custom_window_size_zero() {
1216 let config = SentimentConfig {
1217 window_size: 0,
1218 ..SentimentConfig::default()
1219 };
1220 let a = SentimentAnalyzer::new(config);
1221 let r = a.analyze("t1".to_string(), "quality is great");
1222 assert!(!r.aspects.is_empty());
1224 }
1225
1226 #[test]
1227 fn test_stats_mixed_counted_correctly() {
1228 let a = default_analyzer();
1230 let result = SentimentResult {
1232 text_id: "m1".to_string(),
1233 overall: SentimentScore {
1234 positive: 0.3,
1235 negative: 0.3,
1236 neutral: 0.4,
1237 compound: 0.0,
1238 },
1239 aspects: vec![],
1240 word_count: 10,
1241 sentiment_word_count: 4,
1242 };
1243 let s = a.stats(&[result]);
1244 assert_eq!(s.mixed_count, 1);
1245 }
1246
1247 #[test]
1248 fn test_stats_neutral_counted_correctly() {
1249 let a = default_analyzer();
1250 let result = SentimentResult {
1251 text_id: "n1".to_string(),
1252 overall: SentimentScore {
1253 positive: 0.02,
1254 negative: 0.01,
1255 neutral: 0.97,
1256 compound: 0.01,
1257 },
1258 aspects: vec![],
1259 word_count: 5,
1260 sentiment_word_count: 0,
1261 };
1262 let s = a.stats(&[result]);
1263 assert_eq!(s.neutral_count, 1);
1264 }
1265}