Files
ai-kefu-admin/tests/test_cc_completion.py
T

239 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""cc_completion 判定树 + 状态机测试矩阵(spec §3/§4)。
纯函数离线确定性测试:无 LLM、无 DB、无网络——phonenumberslite 全离线。
样本值均经 phonenumbers 9.0.40 实测钉死(勿凭直觉改断言,先跑库验证)。
"""
from app.engine.services import ip_service
from app.engine.services.cc_completion import (
HAS_CC,
NEED_CC,
NAME_TO_REGION,
cc_due,
cc_settled,
classify,
format_intl,
name_to_region,
transit,
)
def _sa_state():
"""标准等待态样本:沙特 IP + 沙特本地格式号(c 分支 ip_proposed)。"""
return classify("0501234567", "SA")[1]
# ---------------------------------------------------------------- 判定树 §3
def test_classify_tree():
# a: 显式国际前缀(+/00)→ 现有流程直走,无确认态
assert classify("+966501234567", "SA")[0] == HAS_CC
assert classify("00966501234567", "SA")[0] == HAS_CC
assert classify("+966501234567", "SA")[1] is None
# b: 号恰以 IP 国码开头 + 剩余段对该国 is_valid → 静默规范化(confirmed=true)
r = classify("966501234567", "SA")
assert r[0] == HAS_CC and r[1]["e164"] == "+966501234567"
assert r[1]["source"] == "prefix_normalized" and r[1]["confirmed"] is True
assert r[1]["canon"] == "966501234567"
# b 多形态:尼日利亚式裸写国码(234 + 本地号)
r = classify("2348012345678", "NG")
assert r[0] == HAS_CC and r[1]["source"] == "prefix_normalized"
assert r[1]["e164"] == "+2348012345678"
# c: region=IP 国解析合法 → 高置信建议(confirmed=false,待回显确认)
r = classify("0501234567", "SA")
assert r[0] == NEED_CC and r[1]["source"] == "ip_proposed"
assert r[1]["e164"] == "+966501234567" and r[1]["confirmed"] is False
assert r[1]["attempts"] == 0 and r[1]["canon"] == "0501234567"
assert r[1]["intl"] == "+966 50 123 4567"
# c: 中国号裸写
r = classify("13800138000", "CN")
assert r[0] == NEED_CC and r[1]["e164"] == "+8613800138000"
# d: 均不匹配 → naive(去前导 0 + IP 国码;回显靠客户纠错)。
# 注意:0501234567 在 KE 被 phonenumbers 判为合法固话段(走 c 高置信),
# 真正的 d 样本必须是 region 解析不合法的号——如沙特号配肯尼亚 IP。
r = classify("966501234567", "KE")
assert r[0] == NEED_CC and r[1]["source"] == "naive"
assert r[1]["e164"] == "+254966501234567"
r = classify("0501234567", "NG") # NG 解析不合法(固话 01/02 段为主)
assert r[0] == NEED_CC and r[1]["source"] == "naive"
assert r[1]["e164"] == "+234501234567" and r[1]["confirmed"] is False
# d 的对照证明:同一号在 KE 下实走 c,e164 与 naive 拼接恰好一致
r = classify("0501234567", "KE")
assert r[0] == NEED_CC and r[1]["source"] == "ip_proposed"
assert r[1]["e164"] == "+254501234567"
# e: 无 IP → 无建议国(no_ip),只问一次"方便发带区号完整号吗"
r = classify("0501234567", None)
assert r[0] == NEED_CC and r[1]["source"] == "no_ip"
assert r[1].get("suggest_ask_full") is True and "e164" not in r[1]
# 输入防御:空号/垃圾号不炸(调用方契约外场景)
assert classify("", "SA")[0] == NEED_CC and classify("", "SA")[1] is None
# ---------------------------------------------------------------- 状态机 §4
def test_transit_affirm():
st = _sa_state()
st2 = transit(st, {"affirm": True, "country": None})
assert st2["confirmed"] is True and st2["source"] == "ip_confirm"
assert st2["e164"] == "+966501234567" # e164 不变
assert cc_settled(st2) is True and cc_due(st2) is False
# 纯函数:入参不被变异
assert st["confirmed"] is False
def test_transit_country_recalc():
st = classify("0501234567", "KE")[1]
assert st["source"] == "ip_proposed"
st2 = transit(st, {"affirm": False, "country": "Egypt"})
assert st2["e164"] == "+20501234567" # 按客户报国(EG=+20)重算
assert st2["intl"] == "+20 50 1234567"
assert st2["source"] == "customer_stated"
assert st2["confirmed"] is False and st2["attempts"] == 1
assert cc_due(st2) is True and cc_settled(st2) is False # 再等待(回显新号)
# 中文国名同样命中
st3 = transit(_sa_state(), {"affirm": False, "country": "肯尼亚"})
assert st3["e164"] == "+254501234567" and st3["source"] == "customer_stated"
# 报国后确认:source 保持 customer_stated(号源如实标注)
st4 = transit(st2, {"affirm": True, "country": None})
assert st4["confirmed"] is True and st4["source"] == "customer_stated"
# 未知国名:不重算不撒谎,等同无回应(attempts 递增走兜底,spec §9)
st5 = transit(_sa_state(), {"affirm": False, "country": "亚特兰蒂斯"})
assert st5["e164"] == "+966501234567" and st5["source"] == "ip_proposed"
assert st5["attempts"] == 1
def test_transit_canon_change_reset():
st = _sa_state()
# 换号(canon 变化)→ RESET(返回 None,调用方重走判定树)
assert transit(st, {"affirm": True}, new_canon="0712345678") is None
assert transit(st, None, new_canon="0712345678") is None
# 同 canon 正常推进
st2 = transit(st, {"affirm": True}, new_canon="0501234567")
assert st2 is not None and st2["confirmed"] is True
# 未提供 new_canon(本轮无比对号)→ 按同 canon 推进
assert transit(st, None)["attempts"] == 1
def test_transit_attempts_exhaust():
st = _sa_state()
st2 = transit(st, None)
assert st2["attempts"] == 1 and cc_due(st2) is True # 第 1 轮仍等待
st3 = transit(st2, None)
assert st3["attempts"] == 2
assert st3["confirmed"] is False and st3["source"] == "fallback"
assert st3["e164"] == "+966501234567" # 落定但保持现有建议(不无限纠缠)
assert cc_settled(st3) is True and cc_due(st3) is False
def test_transit_no_reply():
st = _sa_state()
st2 = transit(st, None)
assert st2["attempts"] == 1 and st2["confirmed"] is False
assert st2["source"] == "ip_proposed" and cc_due(st2) is True
# extractor 漏填兜底:有 reply 但无 affirm 无 country → 等同无回应
st3 = transit(st, {"affirm": False, "country": None})
assert st3["attempts"] == 1 and cc_due(st3) is True
def test_transit_country_repeat_not_reset():
st = classify("0501234567", "KE")[1]
st2 = transit(st, {"affirm": False, "country": "Egypt"})
assert st2["attempts"] == 1
st3 = transit(st2, {"affirm": False, "country": "Egypt"}) # 国名重述
assert st3["attempts"] == 2 # 照加不重置(attempts 只被 canon 变化重置)
assert st3["source"] == "fallback" # 达上限落定,防"无限换国名"
assert cc_settled(st3) is True
def test_cc_due_and_settled():
# 无态:不等待 + 已落定(带+号客户不进流程,邮箱轻补不回归)
assert cc_due(None) is False and cc_settled(None) is True
assert cc_due({}) is False # 空字典防御同无态
# 等待态
waiting = _sa_state()
assert cc_due(waiting) is True and cc_settled(waiting) is False
# 落定:confirmed(ip_confirm)
assert cc_settled(transit(waiting, {"affirm": True})) is True
# 落定:attempts 耗尽(fallback)
assert cc_settled(transit(transit(waiting, None), None)) is True
# 落定:prefix_normalized 即便 confirmed 字段缺失也视为落定(码是客户自己写的)
prefix = {"source": "prefix_normalized", "confirmed": False,
"attempts": 0, "canon": "966501234567"}
assert cc_due(prefix) is False and cc_settled(prefix) is True
# b 分支产物天然落定
b = classify("966501234567", "SA")[1]
assert cc_due(b) is False and cc_settled(b) is True
# no_ip 新鲜态:等 1 轮(未落定)
no_ip = classify("0501234567", None)[1]
assert cc_due(no_ip) is True and cc_settled(no_ip) is False
def test_no_ip_attempt_cap_one():
# no_ip 路径上限 1 轮(spec §4 统一口径)——问一次"方便发带区号完整号吗"
st = classify("0501234567", None)[1]
assert st["source"] == "no_ip" and cc_due(st) is True
st2 = transit(st, None) # 一轮无回应即落定
assert st2["attempts"] == 1
assert cc_settled(st2) is True and cc_due(st2) is False
assert st2["source"] == "no_ip" # 无 e164 不改写 fallback(邮件标"缺国家区号")
# 对照:常规路径一轮不落定
st3 = transit(_sa_state(), None)
assert cc_due(st3) is True and cc_settled(st3) is False
# no_ip 态的肯定答语无可确认对象 → 按一轮消耗处理(非确认)
st4 = transit(classify("0501234567", None)[1], {"affirm": True, "country": None})
assert st4["confirmed"] is False and cc_settled(st4) is True
# no_ip 态客户主动报国名 → 重算出 e164,转常规确认流(cap 升为 2)
st5 = transit(classify("0501234567", None)[1],
{"affirm": False, "country": "Saudi Arabia"})
assert st5["e164"] == "+966501234567" and st5["source"] == "customer_stated"
assert cc_due(st5) is True # attempts=1 < cap 2,回显确认继续
# ------------------------------------------------------------ 国名映射 §4
def test_name_to_region():
assert NAME_TO_REGION["沙特"] == "SA" and NAME_TO_REGION["Saudi Arabia"] == "SA"
assert NAME_TO_REGION["埃及"] == "EG" and NAME_TO_REGION["Egypt"] == "EG"
assert NAME_TO_REGION["肯尼亚"] == "KE" and NAME_TO_REGION["Kenya"] == "KE"
# COUNTRY_NAMES 全集形态(中文全称)必须可直查
assert NAME_TO_REGION["沙特阿拉伯"] == "SA"
def test_name_to_region_covers_country_names():
# ip_service.COUNTRY_NAMES 全集中文名必须可直查——新增国家时强制同步本映射
for iso, zh in ip_service.COUNTRY_NAMES.items():
assert NAME_TO_REGION.get(zh) == iso, (
f"COUNTRY_NAMES 新增 {iso}/{zh} 未同步 NAME_TO_REGION")
def test_name_to_region_lookup_tolerant():
# 大小写/空白/弯撇号/别称容错(extractor 回传原样字符串)
assert name_to_region(" egypt ") == "EG"
assert name_to_region("SAUDI ARABIA") == "SA"
assert name_to_region("Cote d'Ivoire") == "CI" # 弯撇号归一
assert name_to_region("迪拜") == "AE" and name_to_region("Dubai") == "AE"
assert name_to_region("UK") == "GB" and name_to_region("USA") == "US"
assert name_to_region("不存在的国家") is None and name_to_region(None) is None
# ------------------------------------------------------------ 展示格式化
def test_format_intl():
# 按该国官方分组(phonenumbers INTERNATIONAL;勿手工 3-3-3 拼组)
assert format_intl("+254712345678") == "+254 712 345678"
assert format_intl("+966501234567") == "+966 50 123 4567"
assert format_intl("+8613800138000") == "+86 138 0013 8000"
# 异常防御:非法/空输入原样返回(parse 抛错不外溢)
assert format_intl("+999999") == "+999999"
assert format_intl("") == ""
assert format_intl("not-a-number") == "not-a-number"