"""Direct characterization tests for src/core/engine.py. These pin the engine's current behaviour so refactors (and the two front ends) have a net. Wording asserted verbatim here is client-validated — see CLAUDE.md ("the Streamlit wording is the client-validated string"). These tests must run with no UI toolkit installed: the engine is UI-agnostic and nothing here may pull in a front-end framework. """ import pytest from core import engine from data.loader import load_majors def _major(id=1, name_ar="الحقوق", faculty_ar="كلية الحقوق", triplet="ESC", tracks=None, langs=None, govs=None): return { "id": id, "name_ar": name_ar, "name_en": "Law", "faculty_ar": faculty_ar, "holland_codes": {"primary": triplet[0], "secondary": triplet[1], "tertiary": triplet[2], "triplet": triplet}, "allowed_tracks": tracks if tracks is not None else ["اقتصاد واجتماع"], "teaching_languages": langs if langs is not None else ["العربية"], "locations": [{"governorate": g, "district": "", "branch": g} for g in (govs or ["بيروت"])], "description": "وصف", "career_opportunities": "فرص", "admission_requirements": "شروط", "entry_mechanism": "مباراة", } def _result(major, score=80.0): return {"major": major, "holland_score": score, "match_reasons": []} def _results(n, score=80.0): return [_result(_major(id=i, name_ar=f"تخصص{i}"), score) for i in range(1, n + 1)] class StubLLM: """Records prompts and returns a canned reply (or raises).""" def __init__(self, reply="هذا رد عربي مفيد عن التخصص الجامعي في لبنان.", raises=False): self.reply = reply self.raises = raises self.prompts = [] def generate(self, prompt): self.prompts.append(prompt) if self.raises: raise RuntimeError("provider exploded") return self.reply # --- results_intro / results_table ---------------------------------------- def test_results_intro_empty(): assert engine.results_intro([], ["R"]) == \ "لم أجد تخصصات تطابق معاييرك. جرّب تغيير الفلاتر من الشريط الجانبي." def test_results_intro_interactive_mentions_filters_and_count(): intro = engine.results_intro(_results(3), ["R", "I"], interactive_cards=True) assert "3 تخصصاً" in intro assert "يمكنك تصفية النتائج أدناه" in intro assert "R - I" in intro def test_results_intro_card_mode_invites_clicking_or_typing(): """The Gradio build renders clickable cards below the chat, so its intro offers both routes to details.""" intro = engine.results_intro(_results(3), ["R"], interactive_cards=False) assert "اضغط على أي تخصص" in intro assert "اكتب رقمه أو اسمه" in intro assert "يمكنك تصفية النتائج أدناه" not in intro def test_results_intro_flags_an_expanded_search(): expanded = [{**r, "expanded": True} for r in _results(3)] assert "وسّعت البحث" in engine.results_intro(expanded, ["C", "E", "R"]) assert "وسّعت البحث" not in engine.results_intro(_results(3), ["C"]) def test_results_intro_mentions_page_size_when_more_results(): intro = engine.results_intro(_results(25), ["R"]) assert f"أفضل {engine.PAGE_SIZE}" in intro def test_results_table_numbering_offset(): table = engine.results_table(_results(2), start=11) lines = [l for l in table.splitlines() if l.startswith("| 1")] assert lines[0].startswith("| 11 |") assert "| 12 |" in table def test_results_table_has_header_and_fields(): table = engine.results_table([_result(_major(name_ar="الطب", triplet="SIR"), 91.2)]) assert "| # | التخصص | رمز هولاند | النسبة |" in table assert "الطب" in table and "SIR" in table and "91.2%" in table # --- kalim_recommendation / compare_top3 ---------------------------------- def test_kalim_recommendation_empty(): assert engine.kalim_recommendation([], ["R"], None, []) == "" def test_kalim_recommendation_names_top_major_and_score(): rec = engine.kalim_recommendation( [_result(_major(name_ar="الطب"), 88.0)], ["S", "I"], "علوم حياة", ["بيروت"]) assert "الطب" in rec and "88.0%" in rec assert "توصية كليم" in rec def test_recommendation_omits_track_claim_when_no_track_declared(): rec = engine.kalim_recommendation( [_result(_major(name_ar="الطب"), 88.0)], ["S"], None, []) assert "يقبل فرعك الثانوي" not in rec def test_recommendation_names_the_declared_track(): rec = engine.kalim_recommendation( [_result(_major(name_ar="الطب", tracks=["علوم عامة"]), 88.0)], ["S"], "علوم عامة", []) assert "يقبل فرعك الثانوي (علوم عامة)" in rec def test_recommendation_claims_only_governorates_the_major_is_in(): """The location filter is a union — the major need not be in all of them.""" rec = engine.kalim_recommendation( [_result(_major(name_ar="الطب", govs=["النبطية"]), 88.0)], ["S"], None, ["بيروت", "النبطية"]) assert "متوفر في النبطية" in rec assert "متوفر في بيروت" not in rec def test_recommendation_omits_location_claim_without_selection(): rec = engine.kalim_recommendation( [_result(_major(name_ar="الطب"), 88.0)], ["S"], None, []) assert "متوفر في" not in rec def test_compare_top3_caps_at_three(): text = engine.compare_top3(_results(5)) assert text.count("- المطابقة:") == 3 def test_compare_top3_empty(): assert engine.compare_top3([]) == "" # --- major formatting ------------------------------------------------------ def test_format_major_details_includes_core_fields(): text = engine.format_major_details(_major(name_ar="الطب", faculty_ar="كلية الطب")) assert "الطب" in text and "كلية الطب" in text assert "متطلبات القبول" in text def test_major_context_lines_includes_score_when_given(): assert "77%" in engine.major_context_lines(_major(), score=77) assert "نسبة المطابقة" not in engine.major_context_lines(_major()) # --- profile_summary ------------------------------------------------------- def test_profile_summary_defaults_when_empty(): text = engine.profile_summary([], None, [], []) assert "لم يحدده" in text assert "كل اللغات" in text and "كل المحافظات" in text def test_profile_summary_renders_values(): text = engine.profile_summary(["R"], "علوم عامة", ["العربية"], ["بيروت"]) assert "علوم عامة" in text and "العربية" in text and "بيروت" in text assert "الواقعي" in text # --- majors_mentioned ------------------------------------------------------ def test_mentioned_by_number(): visible = _results(3) found = engine.majors_mentioned("اشرح لي رقم 2", visible) assert [m.get("name_ar") for m, _ in found] == ["تخصص2"] assert found[0][1] is visible[1] def test_mentioned_number_ten_not_shadowed_by_one(): visible = _results(10) found = engine.majors_mentioned("رقم 10", visible) assert [m.get("name_ar") for m, _ in found] == ["تخصص10"] def test_bare_number_is_not_a_card_reference(): """"أريد 5 تخصصات" must not answer about result #5.""" assert engine.majors_mentioned("أريد 5 تخصصات", _results(8)) == [] def test_arabic_indic_digits_resolve(): found = engine.majors_mentioned("اشرح لي رقم ٣", _results(5)) assert [m.get("name_ar") for m, _ in found] == ["تخصص3"] def test_eastern_arabic_indic_digits_resolve(): found = engine.majors_mentioned("رقم ۲", _results(5)) assert [m.get("name_ar") for m, _ in found] == ["تخصص2"] def test_number_only_message_resolves(): assert [m.get("name_ar") for m, _ in engine.majors_mentioned("3", _results(5))] == ["تخصص3"] assert [m.get("name_ar") for m, _ in engine.majors_mentioned("٣؟", _results(5))] == ["تخصص3"] def test_out_of_range_number_resolves_nothing(): assert engine.majors_mentioned("رقم 99", _results(5)) == [] def test_mentioned_by_name_out_of_shortlist(): real = load_majors()[0]["name_ar"] found = engine.majors_mentioned(f"احكيلي عن {real}", []) assert found and found[0][0]["name_ar"] == real assert found[0][1] is None def test_mentioned_by_name_in_shortlist_carries_result(): real_major = load_majors()[0] visible = [_result(real_major, 70.0)] found = engine.majors_mentioned(f"ما رأيك في {real_major['name_ar']}؟", visible) assert found[0][1] is visible[0] def test_mentioned_caps_at_three(): names = [m["name_ar"] for m in load_majors()[:6]] found = engine.majors_mentioned(" و ".join(names), []) assert len(found) <= 3 # --- why_not_in_results ---------------------------------------------------- def test_why_not_track_mismatch(): reasons = engine.why_not_in_results( _major(tracks=["اقتصاد واجتماع"]), ["E"], "علوم عامة", [], []) assert any("اقتصاد واجتماع" in r for r in reasons) def test_why_not_language_mismatch(): reasons = engine.why_not_in_results( _major(langs=["العربية"]), ["E"], None, ["الفرنسية"], []) assert any("لغة التدريس" in r for r in reasons) def test_why_not_location_mismatch(): reasons = engine.why_not_in_results( _major(govs=["النبطية"]), ["E"], None, [], ["بيروت"]) assert any("غير متوفر في" in r for r in reasons) def test_why_not_low_holland_score(): reasons = engine.why_not_in_results( _major(triplet="RIA"), ["S", "E", "C"], None, [], []) assert any("ضعيف" in r for r in reasons) def test_why_not_returns_nothing_when_criteria_met(): major = _major(tracks=["علوم عامة"], langs=["العربية"], govs=["بيروت"], triplet="ESC") assert engine.why_not_in_results(major, ["E", "S", "C"], "علوم عامة", ["العربية"], ["بيروت"]) == [] # --- answer_without_llm ---------------------------------------------------- def test_answer_without_llm_lists_shortlist(): text = engine.answer_without_llm([], _results(8), ["R", "I"], None, [], []) assert text.count("% توافق") == 5 assert "R - I" in text def test_answer_without_llm_no_results(): text = engine.answer_without_llm([], None, ["R"], None, [], []) assert "لم أجد نتائج بعد" in text def test_shortlisted_major_answer_leads_with_the_verdict_and_reasons(): """The fallback has to read like an answer, not like a record: dumping every field is what students saw as a non-answer.""" major = _major(name_ar="الطب") result = _result(major, 82.0) result["match_reasons"] = ["توافق عالٍ مع شخصيتك المهنية (ESC)", "يقبل فرعك الثانوي (علوم حياة)"] text = engine.answer_without_llm([(major, result)], [], ["S"], None, [], []) assert "الطب" in text and "82.0%" in text assert "لماذا يناسبك" in text assert "يقبل فرعك الثانوي (علوم حياة)" in text assert "خارج نتائجك" not in text def test_answer_targets_the_field_the_question_named(): major = _major(name_ar="الطب") major["career_opportunities"] = "طبيب في المستشفيات والعيادات" text = engine.answer_without_llm([(major, _result(major))], [], ["S"], None, [], [], message="شو فرص العمل بعد التخرج؟") assert "فرص العمل" in text assert "طبيب في المستشفيات" in text def test_answer_without_llm_explains_out_of_shortlist(): major = _major(name_ar="الطب", tracks=["علوم حياة"]) text = engine.answer_without_llm([(major, None)], [], ["S"], "فلسفة وانسانيات", [], []) assert "خارج نتائجك" in text assert "لا يقبل فرع فلسفة وانسانيات" in text def test_out_of_shortlist_answer_offers_the_closest_alternative(): major = _major(name_ar="الطب", tracks=["علوم حياة"]) shortlist = [_result(_major(id=9, name_ar="التمريض"), 74.0)] text = engine.answer_without_llm([(major, None)], shortlist, ["S"], "فلسفة وانسانيات", [], []) assert "أقرب بديل" in text and "التمريض" in text def test_answer_does_not_dump_every_field(): major = _major(name_ar="الطب") text = engine.answer_without_llm([(major, _result(major))], [], ["S"], None, [], []) # admission requirements are only surfaced when the student asks for them assert "متطلبات القبول" not in text # --- free_chat_reply ------------------------------------------------------- def test_free_chat_without_llm_uses_fallback(): turn = engine.free_chat_reply("ما التخصص المناسب لي؟", _results(6), _results(6), ["R"], None, [], [], llm=None) assert "أقرب التخصصات إليك" in turn.reply def test_free_chat_uses_llm_reply_and_passes_question(): stub = StubLLM() turn = engine.free_chat_reply("هل يناسبني تخصص الحقوق؟", _results(6), _results(6), ["R"], None, [], [], llm=stub) assert turn.reply == stub.reply assert "هل يناسبني تخصص الحقوق؟" in stub.prompts[0] def test_free_chat_falls_back_when_llm_raises(): turn = engine.free_chat_reply("ما التخصص المناسب لي؟", _results(6), _results(6), ["R"], None, [], [], llm=StubLLM(raises=True)) assert "أقرب التخصصات إليك" in turn.reply def test_free_chat_more_lists_next_page_without_llm(): stub = StubLLM() visible, everything = _results(10), _results(25) turn = engine.free_chat_reply("المزيد", visible, everything, ["R"], None, [], [], llm=stub) assert "تخصصاً إضافياً" in turn.reply assert "| 11 |" in turn.reply assert stub.prompts == [] # The listed page is now on screen. assert len(turn.visible) == 20 assert [r["major"]["name_ar"] for r in turn.visible[10:12]] == ["تخصص11", "تخصص12"] def test_free_chat_table_keyword_returns_table(): stub = StubLLM() visible = _results(3) turn = engine.free_chat_reply("اعطني قائمة", visible, visible, ["R"], None, [], [], llm=stub) assert turn.reply.startswith("**جدول التخصصات المقترحة:**") assert stub.prompts == [] assert turn.visible == visible def test_comparative_question_is_answered_not_paginated(): """"أكثر" is the ordinary Arabic comparative — it must not trigger paging.""" stub = StubLLM() turn = engine.free_chat_reply("أي تخصص أكثر طلباً في سوق العمل؟", _results(10), _results(25), ["R"], None, [], [], llm=stub) assert "تخصصاً إضافياً" not in turn.reply assert turn.reply == stub.reply assert len(stub.prompts) == 1 def test_named_major_wins_over_the_paging_branch(): stub = StubLLM() real = load_majors()[0]["name_ar"] turn = engine.free_chat_reply(f"احكيلي عن {real} والمزيد", _results(10), _results(25), ["R"], None, [], [], llm=stub) assert "تخصصاً إضافياً" not in turn.reply assert len(stub.prompts) == 1 def test_llm_html_survives_the_sanitizer_so_renderers_must_escape(): """sanitize_reply is not an HTML filter — it strips foreign scripts and chain-of-thought, nothing else. The render sites must therefore never use unsafe_allow_html on model output (app.py assistant bubbles).""" reply = 'انظر هذا مفيد جداً للطلاب في الجامعة اللبنانية' assert "