| 1 | """Tests for query.py — shared query utilities.""" |
| 2 | |
| 3 | import unittest |
| 4 | |
| 5 | from lib.query import ( |
| 6 | NOISE_WORDS, |
| 7 | SOCIAL_NOISE, |
| 8 | VIRAL_NOISE, |
| 9 | extract_compound_terms, |
| 10 | extract_core_subject, |
| 11 | infer_query_intent, |
| 12 | ) |
| 13 | |
| 14 | |
| 15 | class TestExtractCoreSubject(unittest.TestCase): |
| 16 | """Tests for extract_core_subject() with default noise set.""" |
| 17 | |
| 18 | def test_strips_what_are_prefix(self): |
| 19 | self.assertEqual(extract_core_subject("what are the best AI tools"), "ai") |
| 20 | |
| 21 | def test_strips_how_to_prefix(self): |
| 22 | self.assertEqual(extract_core_subject("how to use cursor IDE"), "cursor ide") |
| 23 | |
| 24 | def test_strips_what_do_people_think(self): |
| 25 | result = extract_core_subject("what do people think about React Server Components") |
| 26 | self.assertEqual(result, "react server components") |
| 27 | |
| 28 | def test_preserves_product_name(self): |
| 29 | self.assertEqual(extract_core_subject("cursor IDE"), "cursor ide") |
| 30 | |
| 31 | def test_strips_trailing_punctuation(self): |
| 32 | result = extract_core_subject("what is Claude?") |
| 33 | self.assertFalse(result.endswith("?")) |
| 34 | |
| 35 | def test_empty_string(self): |
| 36 | self.assertEqual(extract_core_subject(""), "") |
| 37 | |
| 38 | def test_all_noise_returns_original(self): |
| 39 | # When all words are noise, fall back to original text |
| 40 | result = extract_core_subject("best latest new") |
| 41 | self.assertTrue(len(result) > 0) |
| 42 | |
| 43 | def test_only_first_prefix_stripped(self): |
| 44 | # "how to" should match, stripping once, not recursively |
| 45 | result = extract_core_subject("how to use how to debug") |
| 46 | self.assertIn("debug", result) |
| 47 | |
| 48 | |
| 49 | class TestMaxWords(unittest.TestCase): |
| 50 | """Tests for max_words parameter.""" |
| 51 | |
| 52 | def test_max_words_caps_output(self): |
| 53 | result = extract_core_subject( |
| 54 | "multi agent reinforcement learning framework", |
| 55 | max_words=5, |
| 56 | ) |
| 57 | self.assertLessEqual(len(result.split()), 5) |
| 58 | |
| 59 | def test_max_words_none_no_cap(self): |
| 60 | result = extract_core_subject("cursor IDE react native components") |
| 61 | # Without max_words, no cap applied |
| 62 | self.assertGreaterEqual(len(result.split()), 3) |
| 63 | |
| 64 | def test_max_words_fallback_on_empty(self): |
| 65 | # All words filtered + max_words should fall back to original |
| 66 | result = extract_core_subject("best top latest", max_words=3) |
| 67 | self.assertTrue(len(result) > 0) |
| 68 | |
| 69 | |
| 70 | class TestStripSuffixes(unittest.TestCase): |
| 71 | """Tests for strip_suffixes parameter.""" |
| 72 | |
| 73 | def test_strips_best_practices(self): |
| 74 | result = extract_core_subject( |
| 75 | "claude code best practices", |
| 76 | strip_suffixes=True, |
| 77 | ) |
| 78 | self.assertNotIn("practices", result) |
| 79 | |
| 80 | def test_strips_use_cases(self): |
| 81 | result = extract_core_subject( |
| 82 | "react hooks use cases", |
| 83 | strip_suffixes=True, |
| 84 | ) |
| 85 | self.assertNotIn("cases", result) |
| 86 | |
| 87 | def test_no_strip_without_flag(self): |
| 88 | result = extract_core_subject("claude code best practices") |
| 89 | # "best" and "practices" are noise words so they get filtered anyway |
| 90 | # but the suffix phase doesn't run |
| 91 | self.assertIn("claude", result) |
| 92 | |
| 93 | |
| 94 | class TestCustomNoise(unittest.TestCase): |
| 95 | """Tests for noise override parameter.""" |
| 96 | |
| 97 | def test_custom_noise_keeps_tips(self): |
| 98 | # YouTube keeps tips/tricks/tutorial — pass a noise set without them |
| 99 | youtube_noise = frozenset({ |
| 100 | 'best', 'top', 'good', 'great', 'awesome', 'killer', |
| 101 | 'latest', 'new', 'news', 'update', 'updates', |
| 102 | 'trending', 'hottest', 'popular', 'viral', |
| 103 | 'practices', 'features', |
| 104 | 'recommendations', 'advice', |
| 105 | 'prompt', 'prompts', 'prompting', |
| 106 | 'methods', 'strategies', 'approaches', |
| 107 | }) |
| 108 | result = extract_core_subject("best react tips", noise=youtube_noise) |
| 109 | self.assertIn("tips", result) |
| 110 | |
| 111 | def test_default_noise_removes_tips(self): |
| 112 | result = extract_core_subject("best react tips") |
| 113 | self.assertNotIn("tips", result) |
| 114 | |
| 115 | |
| 116 | class TestNoiseWordsCompleteness(unittest.TestCase): |
| 117 | """Verify NOISE_WORDS superset covers all platform sets.""" |
| 118 | |
| 119 | def test_question_words_present(self): |
| 120 | for w in ('who', 'why', 'when', 'where', 'does', 'should', 'could', 'would'): |
| 121 | self.assertIn(w, NOISE_WORDS, f"Missing question word: {w}") |
| 122 | |
| 123 | def test_core_filler_present(self): |
| 124 | for w in ('the', 'a', 'an', 'is', 'are', 'for', 'with', 'about'): |
| 125 | self.assertIn(w, NOISE_WORDS) |
| 126 | |
| 127 | def test_research_meta_present(self): |
| 128 | for w in ('best', 'top', 'latest', 'trending', 'popular'): |
| 129 | self.assertIn(w, NOISE_WORDS) |
| 130 | |
| 131 | |
| 132 | |
| 133 | class TestSharedAdapterNoiseSets(unittest.TestCase): |
| 134 | """Pin SOCIAL_NOISE / VIRAL_NOISE so adapters can rely on stable membership. |
| 135 | |
| 136 | Bluesky, Threads, Truth Social use SOCIAL_NOISE. |
| 137 | TikTok, Instagram, Pinterest use VIRAL_NOISE. |
| 138 | YouTube extends VIRAL_NOISE with temporal/meta tokens (asserted in its |
| 139 | own adapter test). |
| 140 | """ |
| 141 | |
| 142 | def test_social_noise_membership(self): |
| 143 | # Words shared with the historical _BSKY_NOISE / _TS_NOISE / _THREADS_NOISE. |
| 144 | expected = { |
| 145 | 'best', 'top', 'good', 'great', 'awesome', |
| 146 | 'latest', 'new', 'news', 'update', 'updates', |
| 147 | 'trending', 'hottest', 'popular', 'viral', |
| 148 | 'practices', 'features', 'recommendations', 'advice', |
| 149 | 'or', 'and', |
| 150 | } |
| 151 | self.assertEqual(set(SOCIAL_NOISE), expected) |
| 152 | |
| 153 | def test_viral_noise_is_social_superset(self): |
| 154 | self.assertTrue(SOCIAL_NOISE.issubset(VIRAL_NOISE)) |
| 155 | # The extra words VIRAL adds on top of SOCIAL: the historical |
| 156 | # tiktok / instagram / pinterest delta. |
| 157 | delta = VIRAL_NOISE - SOCIAL_NOISE |
| 158 | self.assertEqual( |
| 159 | delta, |
| 160 | {'killer', 'prompt', 'prompts', 'prompting', |
| 161 | 'methods', 'strategies', 'approaches'}, |
| 162 | ) |
| 163 | |
| 164 | def test_extract_core_subject_with_social_noise(self): |
| 165 | # Sanity: a Bluesky-style query strips through SOCIAL_NOISE. |
| 166 | result = extract_core_subject( |
| 167 | "best new Claude Code update", |
| 168 | noise=SOCIAL_NOISE, |
| 169 | ) |
| 170 | self.assertEqual(result, "claude code") |
| 171 | |
| 172 | def test_extract_core_subject_with_viral_noise(self): |
| 173 | # Viral set strips 'killer' and the prompt cluster. |
| 174 | result = extract_core_subject( |
| 175 | "killer prompting strategies for React", |
| 176 | noise=VIRAL_NOISE, |
| 177 | ) |
| 178 | # 'for' is in the default NOISE_WORDS path but VIRAL_NOISE alone |
| 179 | # doesn't include articles/prepositions; extract_core_subject |
| 180 | # falls back to original when nothing survives, so allow 'for'. |
| 181 | self.assertIn("react", result) |
| 182 | self.assertNotIn("killer", result) |
| 183 | self.assertNotIn("prompting", result) |
| 184 | |
| 185 | |
| 186 | class TestInferQueryIntent(unittest.TestCase): |
| 187 | """Tests for infer_query_intent() — the shared canonical classifier. |
| 188 | |
| 189 | Adapters previously kept five near-duplicate copies with subtle drift |
| 190 | (reddit added `prediction` and the longest `how_to` regex; youtube had |
| 191 | a partial extension; instagram and tiktok lagged). Canonical here is |
| 192 | reddit's superset. |
| 193 | """ |
| 194 | |
| 195 | def test_comparison(self): |
| 196 | self.assertEqual(infer_query_intent("Claude vs Gemini"), "comparison") |
| 197 | self.assertEqual(infer_query_intent("difference between X and Y"), "comparison") |
| 198 | |
| 199 | def test_how_to_base(self): |
| 200 | self.assertEqual(infer_query_intent("how to deploy Kubernetes"), "how_to") |
| 201 | self.assertEqual(infer_query_intent("install nginx"), "how_to") |
| 202 | self.assertEqual(infer_query_intent("setup OAuth tutorial"), "how_to") |
| 203 | |
| 204 | def test_how_to_extended_keywords(self): |
| 205 | # Bare imperatives covered by reddit's extended regex. |
| 206 | self.assertEqual(infer_query_intent("configure DNS"), "how_to") |
| 207 | self.assertEqual(infer_query_intent("troubleshoot router"), "how_to") |
| 208 | self.assertEqual(infer_query_intent("debug python"), "how_to") |
| 209 | self.assertEqual(infer_query_intent("fix kernel panic"), "how_to") |
| 210 | |
| 211 | def test_opinion(self): |
| 212 | self.assertEqual(infer_query_intent("thoughts on Claude Code"), "opinion") |
| 213 | self.assertEqual(infer_query_intent("should I buy a Pixel"), "opinion") |
| 214 | |
| 215 | def test_product(self): |
| 216 | self.assertEqual(infer_query_intent("best laptop for programming"), "product") |
| 217 | self.assertEqual(infer_query_intent("Surface pricing"), "product") |
| 218 | |
| 219 | def test_prediction(self): |
| 220 | self.assertEqual(infer_query_intent("predict the 2028 election"), "prediction") |
| 221 | self.assertEqual(infer_query_intent("odds Trump wins"), "prediction") |
| 222 | self.assertEqual(infer_query_intent("forecast Q4 earnings"), "prediction") |
| 223 | |
| 224 | def test_breaking_news_default(self): |
| 225 | self.assertEqual(infer_query_intent("Kanye West"), "breaking_news") |
| 226 | self.assertEqual(infer_query_intent("OpenAI"), "breaking_news") |
| 227 | |
| 228 | |
| 229 | class TestExtractCompoundTerms(unittest.TestCase): |
| 230 | """Tests for extract_compound_terms().""" |
| 231 | |
| 232 | def test_hyphenated(self): |
| 233 | terms = extract_compound_terms("multi-agent reinforcement learning") |
| 234 | self.assertIn("multi-agent", terms) |
| 235 | |
| 236 | def test_title_case(self): |
| 237 | terms = extract_compound_terms("Claude Code and React Native") |
| 238 | self.assertTrue(any("Claude Code" in t for t in terms)) |
| 239 | self.assertTrue(any("React Native" in t for t in terms)) |
| 240 | |
| 241 | def test_no_compounds(self): |
| 242 | terms = extract_compound_terms("python tutorial") |
| 243 | self.assertEqual(len(terms), 0) |
| 244 | |
| 245 | def test_multiple_hyphens(self): |
| 246 | terms = extract_compound_terms("vc-backed start-up") |
| 247 | self.assertIn("vc-backed", terms) |
| 248 | self.assertIn("start-up", terms) |
| 249 | |
| 250 | if __name__ == "__main__": |
| 251 | unittest.main() |
| 252 |