返回 last30days-skill
test_dedupe_v3.py
根目录 / tests / test_dedupe_v3.py
1 """Unit tests for dedupe.py: text normalization, similarity metrics, and deduplication."""
2
3 import unittest
4
5 from lib import dedupe
6 from lib.schema import SourceItem
7
8
9 def _item(title: str, body: str = "", source: str = "reddit", item_id: str = "t1") -> SourceItem:
10 return SourceItem(
11 item_id=item_id, source=source, title=title, body=body,
12 url="https://example.com", engagement={}, metadata={},
13 )
14
15 # ---------------------------------------------------------------------------
16 # normalize_text
17 # ---------------------------------------------------------------------------
18
19
20 class TestNormalizeText(unittest.TestCase):
21
22 def test_lowercases(self):
23 self.assertEqual(dedupe.normalize_text("Hello World"), "hello world")
24
25 def test_strips_punctuation(self):
26 self.assertEqual(dedupe.normalize_text("it's a test!"), "it s a test")
27
28 def test_collapses_whitespace(self):
29 self.assertEqual(dedupe.normalize_text("a b\t\nc"), "a b c")
30
31 def test_empty_string(self):
32 self.assertEqual(dedupe.normalize_text(""), "")
33
34 # ---------------------------------------------------------------------------
35 # get_ngrams
36 # ---------------------------------------------------------------------------
37
38
39 class TestGetNgrams(unittest.TestCase):
40
41 def test_simple_trigrams(self):
42 ngrams = dedupe.get_ngrams("abcde")
43 self.assertEqual(ngrams, {"abc", "bcd", "cde"})
44
45 def test_short_text_returns_whole(self):
46 ngrams = dedupe.get_ngrams("ab")
47 self.assertEqual(ngrams, {"ab"})
48
49 def test_empty_returns_empty_set(self):
50 self.assertEqual(dedupe.get_ngrams(""), set())
51
52 def test_normalizes_before_ngrams(self):
53 # "A!B" -> "a b" after normalization -> {"a b"}
54 ngrams = dedupe.get_ngrams("A!B")
55 self.assertEqual(ngrams, {"a b"})
56
57 # ---------------------------------------------------------------------------
58 # jaccard_similarity
59 # ---------------------------------------------------------------------------
60
61
62 class TestJaccardSimilarity(unittest.TestCase):
63
64 def test_identical_sets(self):
65 self.assertAlmostEqual(dedupe.jaccard_similarity({"a", "b"}, {"a", "b"}), 1.0)
66
67 def test_disjoint_sets(self):
68 self.assertAlmostEqual(dedupe.jaccard_similarity({"a"}, {"b"}), 0.0)
69
70 def test_partial_overlap(self):
71 result = dedupe.jaccard_similarity({"a", "b", "c"}, {"b", "c", "d"})
72 self.assertAlmostEqual(result, 2.0 / 4.0)
73
74 def test_empty_left(self):
75 self.assertAlmostEqual(dedupe.jaccard_similarity(set(), {"a"}), 0.0)
76
77 def test_both_empty(self):
78 self.assertAlmostEqual(dedupe.jaccard_similarity(set(), set()), 0.0)
79
80 # ---------------------------------------------------------------------------
81 # token_jaccard
82 # ---------------------------------------------------------------------------
83
84
85 class TestTokenJaccard(unittest.TestCase):
86
87 def test_identical_texts(self):
88 self.assertAlmostEqual(dedupe.token_jaccard("hello world", "hello world"), 1.0)
89
90 def test_completely_different(self):
91 self.assertAlmostEqual(dedupe.token_jaccard("alpha beta", "gamma delta"), 0.0)
92
93 def test_filters_stopwords(self):
94 # "the" and "a" are stopwords, so "big cat" vs "big dog" should compare on {big, cat} vs {big, dog}
95 result = dedupe.token_jaccard("the big cat", "a big dog")
96 self.assertAlmostEqual(result, 1.0 / 3.0) # {big} / {big, cat, dog}
97
98 def test_filters_single_char_tokens(self):
99 # Single char tokens like "I" are filtered (len > 1)
100 result = dedupe.token_jaccard("I am great", "I am terrible")
101 # "am" is len 2, "great"/"terrible" are content
102 self.assertGreater(result, 0.0)
103
104 # ---------------------------------------------------------------------------
105 # hybrid_similarity
106 # ---------------------------------------------------------------------------
107
108
109 class TestHybridSimilarity(unittest.TestCase):
110
111 def test_identical_texts(self):
112 self.assertAlmostEqual(dedupe.hybrid_similarity("same text", "same text"), 1.0)
113
114 def test_completely_different(self):
115 result = dedupe.hybrid_similarity("aaaaaa", "zzzzzz")
116 self.assertLess(result, 0.1)
117
118 def test_takes_max_of_both_methods(self):
119 text_a = "OpenClaw security issues discussion"
120 text_b = "OpenClaw security issues thread"
121 ngram_sim = dedupe.jaccard_similarity(
122 dedupe.get_ngrams(text_a), dedupe.get_ngrams(text_b)
123 )
124 token_sim = dedupe.token_jaccard(text_a, text_b)
125 self.assertAlmostEqual(
126 dedupe.hybrid_similarity(text_a, text_b),
127 max(ngram_sim, token_sim),
128 )
129
130 # ---------------------------------------------------------------------------
131 # item_text
132 # ---------------------------------------------------------------------------
133
134
135 class TestItemText(unittest.TestCase):
136
137 def test_combines_fields(self):
138 item = _item("My Title", "My Body")
139 text = dedupe.item_text(item)
140 self.assertIn("My Title", text)
141 self.assertIn("My Body", text)
142
143 def test_skips_none_fields(self):
144 item = _item("Title", "")
145 item.author = None
146 item.container = None
147 text = dedupe.item_text(item)
148 self.assertEqual(text, "Title")
149
150 def test_includes_author_and_container(self):
151 item = _item("Title", "Body")
152 item.author = "john"
153 item.container = "r/python"
154 text = dedupe.item_text(item)
155 self.assertIn("john", text)
156 self.assertIn("r/python", text)
157
158 # ---------------------------------------------------------------------------
159 # dedupe_items
160 # ---------------------------------------------------------------------------
161
162
163 class TestDedupeItems(unittest.TestCase):
164
165 def test_keeps_unique_items(self):
166 items = [
167 _item("OpenClaw is amazing", item_id="a"),
168 _item("NanoClaw security review", item_id="b"),
169 _item("IronClaw Rust architecture", item_id="c"),
170 ]
171 result = dedupe.dedupe_items(items)
172 self.assertEqual(len(result), 3)
173
174 def test_removes_near_duplicates(self):
175 items = [
176 _item("OpenClaw vs NanoClaw comparison review", item_id="a"),
177 _item("OpenClaw vs NanoClaw comparison review thread", item_id="b"),
178 ]
179 result = dedupe.dedupe_items(items)
180 self.assertEqual(len(result), 1)
181 self.assertEqual(result[0].item_id, "a") # keeps first
182
183 def test_keeps_first_of_duplicates(self):
184 items = [
185 _item("Same title here", item_id="first"),
186 _item("Same title here", item_id="second"),
187 ]
188 result = dedupe.dedupe_items(items)
189 self.assertEqual(result[0].item_id, "first")
190
191 def test_empty_body_items_kept(self):
192 item = SourceItem(
193 item_id="empty", source="reddit", title="", body="",
194 url="", engagement={}, metadata={},
195 )
196 result = dedupe.dedupe_items([item])
197 self.assertEqual(len(result), 1)
198
199 def test_threshold_respected(self):
200 items = [
201 _item("OpenClaw security analysis", item_id="a"),
202 _item("OpenClaw security review", item_id="b"),
203 ]
204 # With threshold=1.0, only exact matches are removed
205 result = dedupe.dedupe_items(items, threshold=1.0)
206 self.assertEqual(len(result), 2)
207 # With threshold=0.3, these similar items collapse
208 result_loose = dedupe.dedupe_items(items, threshold=0.3)
209 self.assertEqual(len(result_loose), 1)
210
211 if __name__ == "__main__":
212 unittest.main()
213
213 lines PYTHON