File size: 11,475 Bytes
03b34b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c96e917
 
 
 
 
 
 
 
 
 
 
03b34b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
"""Unit tests untuk preprocessing pipeline + atomic functions.

Test atomic (normalizer, tokenizer, jargon, spelling) — no Sastrawi needed.
Test pipeline/stopword/stemmer — requires Sastrawi (skip kalau gak install).

Run: pytest backend/tests/test_preprocessing.py -v
"""

from __future__ import annotations

import pytest

from app.preprocessing.jargon import KOS_JARGON_DICT, jargon_count, MIN_REQUIRED
from app.preprocessing.normalizer import (
    extract_prices_inline,
    lowercase,
    normalize_whitespace,
    strip_html,
)
from app.preprocessing.spelling import correct_spelling
from app.preprocessing.tokenizer import simple_tokenize


# =============================================================================
# Strip HTML
# =============================================================================
class TestStripHTML:
    def test_basic_tags(self):
        assert strip_html("<p>kos putra</p>") == "kos putra"

    def test_nested_tags(self):
        assert strip_html("<div><p>kos <b>putra</b></p></div>") == "kos putra"

    def test_strip_with_attributes(self):
        assert strip_html('<a href="x">kos</a> putra') == "kos putra"

    def test_empty(self):
        assert strip_html("") == ""

    def test_no_tags(self):
        assert strip_html("kos putra dekat unila") == "kos putra dekat unila"


# =============================================================================
# Whitespace normalize
# =============================================================================
class TestNormalizeWhitespace:
    def test_multiple_spaces(self):
        assert normalize_whitespace("kos   putra    dekat") == "kos putra dekat"

    def test_newlines(self):
        assert normalize_whitespace("kos\nputra\n\ndekat") == "kos putra dekat"

    def test_tabs(self):
        assert normalize_whitespace("kos\tputra") == "kos putra"

    def test_strip_leading_trailing(self):
        assert normalize_whitespace("  kos putra  ") == "kos putra"

    def test_empty(self):
        assert normalize_whitespace("") == ""


# =============================================================================
# Lowercase
# =============================================================================
class TestLowercase:
    def test_basic(self):
        assert lowercase("Kos PUTRA AC") == "kos putra ac"

    def test_unicode(self):
        assert lowercase("KOS DEKAT UNILA") == "kos dekat unila"


# =============================================================================
# Price extraction (inline, all matches)
# =============================================================================
class TestExtractPricesInline:
    def test_single_rupiah(self):
        assert extract_prices_inline("Sewa Rp 850.000 per bulan") == [850000]

    def test_no_space_rupiah(self):
        assert extract_prices_inline("Rp1.250.000") == [1250000]

    def test_multiple_prices(self):
        prices = extract_prices_inline("Mulai Rp 500.000 sampai Rp 1.500.000")
        assert prices == [500000, 1500000]

    def test_juta(self):
        assert extract_prices_inline("Harga 1.5jt all in") == [1500000]

    def test_juta_kata_penuh(self):
        # Bug lama: cuma `jt\b`, "1,5 juta" lolos dan fragmen "Rp 1,5"
        # terbaca 15 rupiah
        assert extract_prices_inline("maksimal 1,5 juta per bulan") == [1500000]
        assert extract_prices_inline("Rp 1,5 juta") == [1500000]
        assert extract_prices_inline("sekitar 2 juta") == [2000000]

    def test_rupiah_fragment_noise_dibuang(self):
        # Nilai rupiah < 10rb itu fragmen, bukan harga kos
        assert extract_prices_inline("Rp 1,5") == []

    def test_ribu_k(self):
        assert extract_prices_inline("Murah 350k aja") == [350000]

    def test_ribu_rb(self):
        assert extract_prices_inline("500rb/bulan") == [500000]

    def test_dedup(self):
        # Sama-sama 500.000, hanya muncul sekali
        assert extract_prices_inline("Rp 500.000 atau 500rb") == [500000]

    def test_anti_pattern_lowercase_first(self):
        # Walaupun lowercase, regex tetap match (insensitive)
        assert extract_prices_inline("rp 500.000") == [500000]

    def test_empty(self):
        assert extract_prices_inline("") == []


# =============================================================================
# Tokenizer
# =============================================================================
class TestTokenizer:
    def test_simple(self):
        assert simple_tokenize("kos putra dekat unila") == [
            "kos", "putra", "dekat", "unila",
        ]

    def test_punctuation_excluded(self):
        assert simple_tokenize("kos, putra! dekat? unila.") == [
            "kos", "putra", "dekat", "unila",
        ]

    def test_numbers_kept(self):
        tokens = simple_tokenize("kos 500k murah")
        assert "kos" in tokens
        assert "500k" in tokens
        assert "murah" in tokens

    def test_empty(self):
        assert simple_tokenize("") == []


# =============================================================================
# Spelling correction
# =============================================================================
class TestSpelling:
    def test_fix_fasiltas(self):
        assert "fasilitas" in correct_spelling("banyak fasiltas").lower()

    def test_fix_exclusive(self):
        assert "eksklusif" in correct_spelling("kos ekslusive").lower()

    def test_no_change_correct(self):
        # "kos putra" gak ada di typo dict, return as-is
        assert correct_spelling("kos putra") == "kos putra"

    def test_empty(self):
        assert correct_spelling("") == ""

    def test_word_boundary(self):
        # "rapih" → "rapi" tapi "rapihkan" jangan ke-replace
        result = correct_spelling("rapih dan rapihkan")
        assert "rapi" in result
        # "rapihkan" gak di-replace karena \b boundary
        assert "rapihkan" in result or "rapih" not in result.replace("rapi", "")


# =============================================================================
# Jargon dict
# =============================================================================
class TestJargonDict:
    def test_meets_rubric_minimum(self):
        count = jargon_count()
        assert count >= MIN_REQUIRED, (
            f"Hanya {count} entries, minimum {MIN_REQUIRED} untuk rubric "
            f"Preprocessing 15%. Tim Anggota B: tambah {MIN_REQUIRED - count}"
        )

    def test_common_abbreviations_present(self):
        assert "ac" in KOS_JARGON_DICT
        assert "kmd" in KOS_JARGON_DICT
        assert "wc dlm" in KOS_JARGON_DICT

    def test_location_variants(self):
        assert KOS_JARGON_DICT["gdg meneng"] == "gedong meneng"
        assert KOS_JARGON_DICT["sumbro"] == "sumantri brojonegoro"
        assert KOS_JARGON_DICT["unyila"] == "universitas lampung"

    def test_type_slang(self):
        assert KOS_JARGON_DICT["cowo"] == "putra"
        assert KOS_JARGON_DICT["cewe"] == "putri"


# =============================================================================
# Pipeline + Sastrawi (heavy — skip kalau Sastrawi belum install)
# =============================================================================
try:
    from app.preprocessing.pipeline import PipelineConfig, PreprocessingPipeline
    from app.preprocessing.stemmer import SastrawiStemmer
    from app.preprocessing.stopwords import StopwordRemover

    SASTRAWI_AVAILABLE = True
except ImportError:
    SASTRAWI_AVAILABLE = False


@pytest.mark.skipif(not SASTRAWI_AVAILABLE, reason="Sastrawi belum di-install")
class TestPipeline:
    def test_full_pipeline_basic(self):
        pipeline = PreprocessingPipeline()
        result = pipeline.process("Kos Putra AC WiFi Rp 850.000/bulan dekat unyila")
        # Price ke-extract
        assert 850000 in result.extracted_prices
        # Stages applied
        assert "stem" in result.stages_applied
        assert "apply_jargon_dict" in result.stages_applied
        # Tokens non-empty
        assert len(result.tokens) > 0

    def test_disable_stem(self):
        config = PipelineConfig(stem=False)
        pipeline = PreprocessingPipeline(config)
        result = pipeline.process("kos murah dekat kampus")
        assert "stem" not in result.stages_applied

    def test_disable_stopword(self):
        config = PipelineConfig(remove_stopwords=False, stem=False)
        pipeline = PreprocessingPipeline(config)
        result = pipeline.process("kos yang murah di lampung")
        # "yang" dan "di" gak ke-remove
        tokens_lower = [t.lower() for t in result.tokens]
        assert "yang" in tokens_lower or "di" in tokens_lower

    def test_jargon_substitution_gdg_meneng(self):
        config = PipelineConfig(stem=False, remove_stopwords=False)
        pipeline = PreprocessingPipeline(config)
        result = pipeline.process("kos di gdg meneng")
        # "gdg meneng" → "gedong meneng"
        assert "gedong" in result.processed
        assert "meneng" in result.processed

    def test_jargon_longest_first(self):
        # "km dlm" harus ke-match dulu sebelum "dlm"
        config = PipelineConfig(stem=False, remove_stopwords=False)
        pipeline = PreprocessingPipeline(config)
        result = pipeline.process("ada km dlm dan dapur")
        # "km dlm" → "kamar mandi dalam", bukan "km dalam"
        assert "kamar mandi dalam" in result.processed

    def test_price_preserved_before_lowercase(self):
        # Anti-pattern check
        pipeline = PreprocessingPipeline()
        result = pipeline.process("Sewa Rp 1.250.000")
        assert 1250000 in result.extracted_prices


@pytest.mark.skipif(not SASTRAWI_AVAILABLE, reason="Sastrawi belum di-install")
class TestStopwordRemover:
    def test_remove_sastrawi_default(self):
        remover = StopwordRemover()
        tokens = ["kos", "yang", "murah", "di", "lampung"]
        result = remover.remove(tokens)
        # "yang" dan "di" Sastrawi stopwords
        assert "yang" not in result
        assert "di" not in result
        # "kos" custom stopword
        assert "kos" not in result
        # Informative tokens kept
        assert "murah" in result
        assert "lampung" in result

    def test_custom_only(self):
        remover = StopwordRemover(custom=["spesifik"], use_sastrawi_default=False)
        assert remover.is_stopword("spesifik")
        assert not remover.is_stopword("yang")  # Sastrawi default off

    def test_count(self):
        remover = StopwordRemover()
        counts = remover.count()
        assert counts["sastrawi"] > 0
        assert counts["custom"] > 0


@pytest.mark.skipif(not SASTRAWI_AVAILABLE, reason="Sastrawi belum di-install")
class TestStemmer:
    def test_basic_stem(self):
        stemmer = SastrawiStemmer()
        assert stemmer.stem("berlari") == "lari"
        assert stemmer.stem("pergi") == "pergi"  # already stem

    def test_cache_hit(self):
        stemmer = SastrawiStemmer()
        # First call: cache miss
        r1 = stemmer.stem("memasak")
        # Second call: cache hit
        r2 = stemmer.stem("memasak")
        assert r1 == r2
        info = stemmer.cache_info()
        assert info.hits >= 1

    def test_stem_tokens_batch(self):
        stemmer = SastrawiStemmer()
        result = stemmer.stem_tokens(["berlari", "memasak", "menulis"])
        assert len(result) == 3
        assert "lari" in result

    def test_empty(self):
        stemmer = SastrawiStemmer()
        assert stemmer.stem("") == ""