239 lines
11 KiB
Python
239 lines
11 KiB
Python
|
|
"""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"
|