"""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 "![]()