""" Unit tests for IntentClassifier. Covers: - train() with adequate data → returns accuracy float - train() with small dataset (< min_per_class threshold) → trains on full set, returns {"accuracy": None, ...} - train() with only 1 class → raises ValueError - train() with mismatched texts/labels length → raises ValueError - predict() on untrained classifier → returns keyword_only fallback dict - predict() on trained classifier → returns dict with intent/confidence/method keys """ import warnings import pytest from app.models.intent_classifier.model import IntentClassifier # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_dataset(n_per_class: int, classes=("find_hotel", "find_flight")): """Generate a synthetic balanced dataset.""" texts, labels = [], [] class_texts = { "find_hotel": [ "I need a hotel in Hanoi", "book a room for me", "looking for accommodation", "need a place to stay tonight", "where can I find a hostel", "cheap resort near the beach", "hotel with breakfast included", "book homestay", "rent a villa please", "want to book a lodge", ], "find_flight": [ "I want to book a flight", "find me cheap airfare", "flights from Hanoi to HCMC", "looking for airline tickets", "when is the next plane", "book a ticket to Paris", "search for flights", "best airline for this route", "I need to fly tomorrow", "airfare prices to Tokyo", ], "plan_trip": [ "help me plan a trip", "create an itinerary for 7 days", "plan vacation to Europe", "I want to organize my holiday", "can you make a travel plan", "schedule a trip for me", "arrange my travel", "I need a week-long travel plan", "draft a tour plan", "plan a 3-day trip to Da Nang", ], } for cls in classes: pool = class_texts.get(cls, [f"sample {cls} {i}" for i in range(20)]) for i in range(n_per_class): texts.append(pool[i % len(pool)]) labels.append(cls) return texts, labels # --------------------------------------------------------------------------- # train() — normal path # --------------------------------------------------------------------------- class TestIntentClassifierTrain: def test_train_adequate_dataset_returns_accuracy_float(self): """Normal sized dataset: train_test_split fires, accuracy is a float.""" clf = IntentClassifier() texts, labels = _make_dataset(n_per_class=8) result = clf.train(texts, labels) assert clf.is_trained is True assert "accuracy" in result assert isinstance(result["accuracy"], float) assert 0.0 <= result["accuracy"] <= 1.0 def test_train_returns_report_dict(self): """Result dict should also contain a 'report' key.""" clf = IntentClassifier() texts, labels = _make_dataset(n_per_class=8) result = clf.train(texts, labels) assert "report" in result # ---- _can_split guard ---- def test_train_small_dataset_no_split_accuracy_is_none(self): """Dataset too small for stratified split → trains on full set, accuracy == None with a UserWarning.""" clf = IntentClassifier() # Only 1 sample per class → can_split=False texts, labels = _make_dataset(n_per_class=1) with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") result = clf.train(texts, labels) assert clf.is_trained is True assert result["accuracy"] is None warning_msgs = [str(w.message) for w in caught if issubclass(w.category, UserWarning)] assert any("full set" in m.lower() or "too small" in m.lower() for m in warning_msgs), \ f"Expected UserWarning about dataset size, got: {warning_msgs}" def test_train_exactly_threshold_triggers_split(self): """When dataset just meets the minimum threshold, split should be attempted.""" clf = IntentClassifier() # 2 classes, min_total_needed = max(7, 2*2) = 7 → 8 samples with 4 each = can_split texts, labels = _make_dataset(n_per_class=4) result = clf.train(texts, labels) # Either accuracy is a float (split succeeded) or None (edge case allowed) assert result["accuracy"] is None or isinstance(result["accuracy"], float) # ---- validation errors ---- def test_train_single_class_raises_value_error(self): """Only 1 class → ValueError (need at least 2).""" clf = IntentClassifier() texts = ["book a hotel"] * 5 labels = ["find_hotel"] * 5 with pytest.raises(ValueError, match="at least 2 intent classes"): clf.train(texts, labels) def test_train_length_mismatch_raises_value_error(self): """texts and labels length mismatch → ValueError.""" clf = IntentClassifier() with pytest.raises(ValueError, match="length mismatch"): clf.train(["text1", "text2"], ["label1"]) # --------------------------------------------------------------------------- # predict() — untrained classifier # --------------------------------------------------------------------------- class TestIntentClassifierPredict: def test_predict_untrained_returns_keyword_only(self): """Untrained classifier falls back to keyword_only method.""" clf = IntentClassifier() result = clf.predict("I want to book a hotel") assert "intent" in result assert "confidence" in result assert "method" in result assert result["method"] == "keyword_only" def test_predict_result_has_required_keys(self): """predict() always returns a dict with intent / confidence / method.""" clf = IntentClassifier() # Train minimally so the ML path is exercised texts, labels = _make_dataset(n_per_class=8) clf.train(texts, labels) result = clf.predict("I need to find a flight to Paris") assert set(result.keys()) >= {"intent", "confidence", "method"} def test_predict_confidence_between_0_and_1(self): """confidence is always in [0, 1].""" clf = IntentClassifier() texts, labels = _make_dataset(n_per_class=8) clf.train(texts, labels) result = clf.predict("book a room please") assert 0.0 <= result["confidence"] <= 1.0 def test_predict_intent_is_string(self): """intent field is always a non-empty string.""" clf = IntentClassifier() result = clf.predict("hello") assert isinstance(result["intent"], str) assert len(result["intent"]) > 0 def test_predict_with_language_hint(self): """Passing language hint doesn't crash the classifier.""" clf = IntentClassifier() result = clf.predict("khách sạn Hà Nội", language="vi") assert "intent" in result def test_predict_empty_string_returns_fallback(self): """Empty text should return a fallback dict without raising.""" clf = IntentClassifier() result = clf.predict("") assert "intent" in result # Should gracefully produce some result (keyword_only or fallback) assert result["method"] in ("keyword_only", "ml_model", "keyword_fallback", "ood_rejector")