diff --git a/app/services/cc_completion.py b/app/services/cc_completion.py new file mode 100644 index 0000000..ca3d3a7 --- /dev/null +++ b/app/services/cc_completion.py @@ -0,0 +1,303 @@ +"""号码区号补全核心:判定树 + 确认状态机(纯函数模块)。 + +设计依据:docs/superpowers/specs/2026-10-08-cc-ip-country-confirm-design.md §3/§4。 +分工:本模块只做确定性计算(phonenumberslite 全离线),不 import +conversation/DB/LLM——IO、存储与提示词注入归调用方(轮前/轮末钩子)。 + +状态字典 schema(存 raw_info["_cc"][渠道],spec §4):: + + { + "e164": "+966501234567", # 补全号;no_ip 态无此键 + "intl": "+966 50 123 4567", # 分组展示(回显/邮件正文用) + "confirmed": false, + "attempts": 0, # 确认轮计数(只被 canon 变化重置) + "source": "ip_proposed", # ip_proposed|naive|ip_confirm| + # prefix_normalized|customer_stated|no_ip|fallback + "canon": "0501234567", # 客户原始号的纯数字形态(换号=RESET 的判据) + } +""" + +from __future__ import annotations + +import re + +import phonenumbers +from phonenumbers import PhoneNumberFormat + +# 判定树两类结局: +# HAS_CC —— 已带国家码,现有流程直走。含两个子形态:显式 +/00 前缀(dict=None, +# 调用方无需建状态);裸写 IP 国码且剩余段合法(prefix_normalized 态, +# confirmed=true,首封无"未经确认"标注)。 +# NEED_CC —— 缺国家码,进入确认流(ip_proposed/naive/no_ip 态)。 +HAS_CC = "has_cc" +NEED_CC = "need_cc" + +# 确认轮上限(spec §4 统一口径):常规路径每渠道 2 轮; +# no_ip(无建议国,只问一次"方便发带区号完整号吗")1 轮。 +ATTEMPT_CAP_DEFAULT = 2 +ATTEMPT_CAP_NO_IP = 1 + +# transit 返回 None 即 RESET:canon 变化(换号),调用方对号码重走判定树重建状态。 +RESET = None + + +def _digits(text: str | None) -> str: + """纯数字提取(canon 口径:客户原始号去一切非数字字符)。""" + return re.sub(r"\D", "", text or "") + + +def _safe_country_code(iso: str | None) -> str | None: + """region ISO → 拨号码字符串(如 "SA"→"966");未知 region 返回 None。""" + if not iso: + return None + try: + cc = phonenumbers.country_code_for_region(iso) + except Exception: + return None + # 未知 region phonenumbers 返回 0(不抛),与 None 同义 + return str(cc) if cc else None + + +def _try_parse(text: str, region: str): + """parse 防御包装:抛错(含空串/垃圾/非法 region)归 None。""" + try: + return phonenumbers.parse(text, region=region) + except Exception: + return None + + +def format_intl(e164: str) -> str: + """E.164 → 该国官方分组的国际展示格式(回显/邮件正文用)。 + + 非法/空输入原样返回(防御:naive 拼出的号可能不被 parse 接受)。 + """ + if not e164: + return e164 or "" + try: + parsed = phonenumbers.parse(e164) + return phonenumbers.format_number(parsed, PhoneNumberFormat.INTERNATIONAL) + except Exception: + return e164 + + +def _new_state(canon: str, source: str, e164: str | None = None, + confirmed: bool = False) -> dict: + st = {"confirmed": confirmed, "attempts": 0, "source": source, "canon": canon} + if e164: + st["e164"] = e164 + st["intl"] = format_intl(e164) + return st + + +def _complete_with_region(digits: str, iso: str) -> dict | None: + """按给定 region 把裸号补全为 E.164——判定树 c/d 与状态机重算共用。 + + 先严格解析(c:region=该国 parse 且 is_valid → 高置信), + 失败退 naive(d:去前导 0 + 该国拨号码拼接);region 不可用返回 None。 + """ + if not digits or not iso: + return None + parsed = _try_parse(digits, iso) + if parsed is not None and phonenumbers.is_valid_number(parsed): + e164 = phonenumbers.format_number(parsed, PhoneNumberFormat.E164) + return {"e164": e164, "intl": format_intl(e164), "strict": True} + cc = _safe_country_code(iso) + if not cc: + return None + national = digits.lstrip("0") or digits + e164 = f"+{cc}{national}" + return {"e164": e164, "intl": format_intl(e164), "strict": False} + + +def classify(number: str | None, ip_iso: str | None) -> tuple[str, dict | None]: + """判定树(spec §3):(kind, state)。 + + a. +/00 开头 → (HAS_CC, None),现有流程直走; + b. 以 IP 国拨号码开头且剩余段对该国 is_valid → (HAS_CC, prefix_normalized + 态 confirmed=true)——静默规范化,无对话动作; + c. region=IP 国解析合法 → (NEED_CC, ip_proposed 态)——高置信建议; + d. 均不匹配 → (NEED_CC, naive 态)——去前导 0 + IP 码,回显靠客户纠错; + e. IP 无效/未知(或 region 不被 phonenumbers 认识)→ (NEED_CC, no_ip 态, + 无 e164,suggest_ask_full=true,只问一次"方便发带区号完整号吗")。 + + 防御:number 为空/全非数字 → (NEED_CC, None)(调用方契约外场景,不建状态)。 + """ + raw = (number or "").strip() + digits = _digits(raw) + # a: 显式国际前缀 + if raw.startswith("+") or raw.startswith("00"): + return HAS_CC, None + if not digits: + return NEED_CC, None + if ip_iso: + # b: 裸写 IP 国码 + 剩余段合法(无确认防线,靠 is_valid 严格校验兜底) + cc = _safe_country_code(ip_iso) + if cc and digits.startswith(cc) and len(digits) > len(cc): + rest = digits[len(cc):] + parsed = _try_parse(rest, ip_iso) + if parsed is not None and phonenumbers.is_valid_number(parsed): + e164 = phonenumbers.format_number(parsed, PhoneNumberFormat.E164) + return HAS_CC, _new_state(digits, "prefix_normalized", + e164=e164, confirmed=True) + # c/d: region 严格解析,失败退 naive + comp = _complete_with_region(digits, ip_iso) + if comp: + source = "ip_proposed" if comp["strict"] else "naive" + return NEED_CC, _new_state(digits, source, e164=comp["e164"]) + # e: 无建议国 + st = _new_state(digits, "no_ip") + st["suggest_ask_full"] = True + return NEED_CC, st + + +def attempt_cap(source: str | None) -> int: + """确认轮上限:no_ip 1 轮,其余 2 轮(spec §4 统一口径)。""" + return ATTEMPT_CAP_NO_IP if source == "no_ip" else ATTEMPT_CAP_DEFAULT + + +def _consume_round(state: dict) -> dict: + """消耗一轮(无回应/不可解析/报国重算后调用):attempts+1,达上限落定。 + + 落定规则:有 e164 的路径 source 改写 fallback(标注"未经客户确认"); + no_ip 无 e164 保持原 source(邮件标"缺国家区号"),仅停止追问。 + """ + state["attempts"] = int(state.get("attempts", 0)) + 1 + if not state.get("confirmed") and state["attempts"] >= attempt_cap(state.get("source")): + if state.get("e164"): + state["source"] = "fallback" + return state + + +def transit(state: dict | None, reply: dict | None, + new_canon: str | None = None) -> dict | None: + """消费一轮 cc_reply 推进状态机(纯函数,不变异入参;spec §4 答语树)。 + + 参数: + state —— classify 产物或上轮 transit 结果; + reply —— None(无回应/extractor 漏填兜底)或 {"affirm": bool, + "country": str|None}(extractor 单轮分类,消费后由调用方剥离); + new_canon —— 本轮最新号码的纯数字形态;与 state["canon"] 不同 → 换号。 + + 返回: + None —— RESET(换号或无态):调用方对号码重走 classify 重建状态 + (结构性解决"改号残留":旧补全永不跟随新号); + dict —— 新状态: + - affirm=true → confirmed=true(source=ip_confirm;报国重算后的确认 + 保持 customer_stated,号源如实标注)→ 落定; + - affirm=false + country → NAME_TO_REGION 转 ISO 重算 e164 + (source=customer_stated)→ 再回显,attempts+1; + - affirm=false 无 country / 无回应 / 国名不识别 → attempts+1, + 达上限(常规 2 轮)落定 confirmed=false、source=fallback。 + """ + if not state: + return RESET + canon = state.get("canon") + if new_canon is not None and canon is not None and new_canon != canon: + return RESET + new = dict(state) + if reply is None: + return _consume_round(new) + affirm = bool(reply.get("affirm")) + country = (reply.get("country") or "").strip() or None + if affirm: + if not new.get("e164"): + # no_ip 态没有可确认对象——肯定答语无意义,按一轮消耗处理 + return _consume_round(new) + new["confirmed"] = True + new["source"] = ("customer_stated" if new.get("source") == "customer_stated" + else "ip_confirm") + return new + if country: + iso = name_to_region(country) + recalc = _complete_with_region(canon or "", iso) if iso else None + if recalc: + new["e164"] = recalc["e164"] + new["intl"] = recalc["intl"] + new["source"] = "customer_stated" + # 国名不识别:不重算不撒谎,等同无回应走兜底(spec §9) + return _consume_round(new) + # affirm=false 无 country → 请重发完整号(新号到达=canon 变化自然 RESET) + return _consume_round(new) + + +def _settled(state: dict) -> bool: + """确认流是否落定(不再追问):confirmed / attempts 耗尽 / + prefix_normalized 与 fallback 天然终态。""" + if state.get("confirmed"): + return True + if state.get("source") in ("prefix_normalized", "fallback"): + return True + return int(state.get("attempts", 0)) >= attempt_cap(state.get("source")) + + +def cc_due(state: dict | None) -> bool: + """是否处于等待 cc_reply 的未落定态——消费答语/注入确认句的门控。 + + None → False:无状态即无等待(带 + 号客户不进流程)。 + """ + if not state: + return False + return not _settled(state) + + +def cc_settled(state: dict | None) -> bool: + """确认流是否已落定(或根本无流)。None → True:无态视为天然落定, + 邮箱轻补解锁判断不因缺状态而回归(带 + 号客户照走现有轻补路径)。""" + if not state: + return True + return _settled(state) + + +# 国名 → ISO 3166-1 alpha-2。中文全集与 ip_service.COUNTRY_NAMES 对齐 +# (tests/test_cc_completion.py 强制同步:COUNTRY_NAMES 新增国必须同步本表), +# 英文为对应常见英文国名(双语两键同值)+ 高频别称。 +NAME_TO_REGION: dict[str, str] = { + # —— 中文(全集对齐 ip_service.COUNTRY_NAMES)—— + "中国": "CN", "美国": "US", "尼日利亚": "NG", "加纳": "GH", + "肯尼亚": "KE", "坦桑尼亚": "TZ", "南非": "ZA", "埃及": "EG", + "阿联酋": "AE", "沙特阿拉伯": "SA", "伊拉克": "IQ", "约旦": "JO", + "巴基斯坦": "PK", "孟加拉国": "BD", "印度": "IN", "泰国": "TH", + "越南": "VN", "印尼": "ID", "菲律宾": "PH", "马来西亚": "MY", + "日本": "JP", "韩国": "KR", "英国": "GB", "德国": "DE", + "法国": "FR", "俄罗斯": "RU", "巴西": "BR", "墨西哥": "MX", + "澳大利亚": "AU", "加拿大": "CA", "土耳其": "TR", "伊朗": "IR", + "缅甸": "MM", "柬埔寨": "KH", "老挝": "LA", "斯里兰卡": "LK", + "尼泊尔": "NP", "乌干达": "UG", "埃塞俄比亚": "ET", "塞内加尔": "SN", + "科特迪瓦": "CI", "喀麦隆": "CM", "刚果(金)": "CD", "莫桑比克": "MZ", + # —— 中文高频简称/别称 —— + "沙特": "SA", "迪拜": "AE", "刚果金": "CD", + # —— 英文国名 —— + "China": "CN", "United States": "US", "Nigeria": "NG", "Ghana": "GH", + "Kenya": "KE", "Tanzania": "TZ", "South Africa": "ZA", "Egypt": "EG", + "United Arab Emirates": "AE", "Saudi Arabia": "SA", "Iraq": "IQ", + "Jordan": "JO", "Pakistan": "PK", "Bangladesh": "BD", "India": "IN", + "Thailand": "TH", "Vietnam": "VN", "Indonesia": "ID", "Philippines": "PH", + "Malaysia": "MY", "Japan": "JP", "South Korea": "KR", "United Kingdom": "GB", + "Germany": "DE", "France": "FR", "Russia": "RU", "Brazil": "BR", + "Mexico": "MX", "Australia": "AU", "Canada": "CA", "Turkey": "TR", + "Iran": "IR", "Myanmar": "MM", "Cambodia": "KH", "Laos": "LA", + "Sri Lanka": "LK", "Nepal": "NP", "Uganda": "UG", "Ethiopia": "ET", + "Senegal": "SN", "Ivory Coast": "CI", "Cameroon": "CM", "DR Congo": "CD", + "Mozambique": "MZ", + # —— 英文高频简称/别称 —— + "USA": "US", "UAE": "AE", "KSA": "SA", "UK": "GB", "Korea": "KR", + "Dubai": "AE", "Cote d'Ivoire": "CI", "Democratic Republic of the Congo": "CD", + # —— 超出 COUNTRY_NAMES 的高频市场补充(西语场景为本项目多语言重点)—— + "西班牙": "ES", "Spain": "ES", +} + +# 大小写容错影子表(中文 casefold 为恒等,不受影响) +_NAME_LOOKUP = {k.casefold(): v for k, v in NAME_TO_REGION.items()} + + +def name_to_region(name: str | None) -> str | None: + """国名(extractor 回传原样字符串)→ ISO;未命中返回 None。 + + 容错:去首尾空白、大小写不敏感、弯撇号(U+2019)归一为 ASCII。 + """ + if not name: + return None + key = name.strip().replace("’", "'") + if key in NAME_TO_REGION: + return NAME_TO_REGION[key] + return _NAME_LOOKUP.get(key.casefold()) diff --git a/tests/test_cc_completion.py b/tests/test_cc_completion.py new file mode 100644 index 0000000..e4e80c4 --- /dev/null +++ b/tests/test_cc_completion.py @@ -0,0 +1,238 @@ +"""cc_completion 判定树 + 状态机测试矩阵(spec §3/§4)。 + +纯函数离线确定性测试:无 LLM、无 DB、无网络——phonenumberslite 全离线。 +样本值均经 phonenumbers 9.0.40 实测钉死(勿凭直觉改断言,先跑库验证)。 +""" + +from app.services import ip_service +from app.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"