"""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("

kos putra

") == "kos putra" def test_nested_tags(self): assert strip_html("

kos putra

") == "kos putra" def test_strip_with_attributes(self): assert strip_html('kos 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("") == ""