# -*- coding: utf-8 -*- r'''验「语音播报降级链」。 == 为什么要测 == 用户反馈:**Win7 上语音播报不能用**。 原来的实现只有一条路(PowerShell + System.Speech),而 Win7 上 PowerShell 2.0 + 中文 + PATH 三样都可能出问题。改成降级链之后, 必须保证: 1. **方案按"依赖越少越优先"排** —— cscript/VBScript 排最前, 因为它从 Windows 98 就有,SAPI5 是系统组件,两样都不用装。 2. **真的会降级** —— 前一条失败要接着试下一条,不是试一条就放弃。 3. **一条通了要记住** —— 不然每播一次都要把几条全试一遍。 4. **失败了要报错**,不能像原来那样 `Popen` 不等待、 明明没出声却返回"已播报"。 5. **VBScript 用 ANSI 编码写** —— 写成 UTF-8 的话 cscript 读出来 中文是乱码。这是 VBScript 的老规矩,很隐蔽。 == 怎么测"会降级" == 不能真把 cscript 弄坏。所以对方案列表做**结构断言**: 确认它列出了 >= 3 条、顺序正确;再单独构造一条必失败的链 验证"失败会继续往下试"这个逻辑(用一个假的 subprocess)。 真出声的部分只在最后一节做(本机有 cscript 的话)。 ''' from __future__ import annotations import re import sys import tempfile from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "windows")) FAILS: list = [] def check(name: str, ok: bool, why: str = "") -> None: print(" [%s] %s%s" % ("PASS" if ok else "FAIL", name, (" —— " + why) if why else "")) if not ok: FAILS.append(name) class FakeUi: """Actions 需要一个 ui,TTS 用不到它的任何东西。""" def log(self, *_a, **_k): pass def notify(self, *_a, **_k): pass def main() -> int: print("=" * 58) print(" 语音播报降级链") print("=" * 58) print() from lanremote.actions import Actions from lanremote.config import Store a = Actions(FakeUi(), Store(Path(tempfile.mkdtemp()))) # ---------------- 一、方法都在 ---------------- print("一、方法") for name in ("_powershell_path", "_write_vbs", "_tts_commands"): check("有 %s" % name, hasattr(a, name)) # ---------------- 二、方案顺序 ---------------- print() print("二、降级顺序(依赖越少越优先)") cmds = a._tts_commands("测试一二三", "", 0) names = [c[0] for c in cmds] print(" 顺序: %s" % " -> ".join(names)) check("至少 3 条方案", len(cmds) >= 3, "只有 %d 条" % len(cmds)) if names: check("★ 第一条是 cscript(不依赖 PowerShell / .NET)", names[0] == "cscript", "第一条是 %r —— cscript 最该排前面" % names[0]) if len(names) > 1: check("第二条是 wscript(cscript 被拦时的备选)", names[1] == "wscript", "第二条是 %r" % names[1]) check("★ 有 ps-sapi 那条(绕开 .NET 只用系统 SAPI5)", "ps-sapi" in names, "方案 %s" % names) check("★ 保留了原来的 System.Speech 那条(兜底)", "ps-speech" in names or "ps-path" in names, "把老办法删了的话,老机器上本来能用的反而不能用") check("cscript 那条带 //nologo(不然会弹横幅)", any("//nologo" in " ".join(c[1]) for c in cmds)) # ---------------- 三、VBScript 编码 ---------------- print() print("三、VBScript(中文必须用 ANSI 写)") p = a._write_vbs("中文测试", "", 0) raw = p.read_bytes() check("生成了 .vbs", p.is_file(), str(p)) check("★ 不是 UTF-8(UTF-8 的中文在 cscript 里是乱码)", not raw.startswith(b"\xef\xbb\xbf") and raw.count(b"\xe4\xb8") == 0, "文件开头 %r" % raw[:8]) check("★ 用 mbcs 能解回原文", "中文测试" in raw.decode("mbcs", errors="replace")) txt = raw.decode("mbcs", errors="replace") check("用的是 SAPI.SpVoice(系统原生,不需要 .NET)", "SAPI.SpVoice" in txt) check("CRLF 换行(VBScript 的老规矩)", "\r\n" in txt) # 音色 / 语速要能写进去 p2 = a._write_vbs("有音色和语速", "Huihui", 3) t2 = p2.read_bytes().decode("mbcs", errors="replace") check("音色写进去了", "Huihui" in t2) check("语速写进去了", "v.Rate = 3" in t2) # 长文本要分句(VBScript 单行有长度上限) long_text = "这是一句很长的话。" * 100 p3 = a._write_vbs(long_text, "", 0) t3 = p3.read_bytes().decode("mbcs", errors="replace") speak_lines = [ln for ln in t3.splitlines() if ln.strip().startswith("v.Speak")] check("★ 长文本被切成多句(VBScript 单行长度有限)", len(speak_lines) > 1, "只有 %d 行 v.Speak —— 太长会报错" % len(speak_lines)) check(" 每句不超过 400 字", all(len(ln) < 420 for ln in speak_lines), "最长 %d" % max((len(x) for x in speak_lines), default=0)) # 引号要转义(不然 VBScript 语法错) p4 = a._write_vbs('他说"你好"', "", 0) t4 = p4.read_bytes().decode("mbcs", errors="replace") check("双引号被转义成两个", '""' in t4 or '"你好"' not in t4) # ---------------- 四、会降级(不是试一条就放弃)---------------- print() print("四、降级行为") import lanremote.actions as A real_run = A.subprocess.run calls = [] class R: def __init__(self, code, err=""): self.returncode = code self.stdout = "" self.stderr = err def fake_run(cmd, **kw): calls.append(cmd[0]) # 让前两条"失败",第三条"成功" —— 验证它会继续往下试 if len(calls) <= 2: return R(1, "模拟失败") return R(0) A.subprocess.run = fake_run try: out = a.say("降级测试") check("★ 前两条失败后会继续试第三条", len(calls) >= 3, "只试了 %d 次:%s" % (len(calls), calls)) check("★ 成功了就返回成功", "已播报" in out, "拿到 %r" % out) finally: A.subprocess.run = real_run # 全失败要抛错(不能像原来那样骗人) calls.clear() def always_fail(cmd, **kw): calls.append(cmd[0]) return R(1, "全失败") A.subprocess.run = always_fail try: a.say("全失败测试") check("★ 全失败时必须抛错(原来会假报成功)", False, "它竟然返回了成功") except Exception as exc: # noqa: BLE001 check("★ 全失败时必须抛错(原来会假报成功)", True) check(" 错误里列出了试过哪些", "试过" in str(exc), "拿到 %r" % exc) finally: A.subprocess.run = real_run check(" 确实把每条都试过了", len(calls) >= 3, "只试了 %d 次" % len(calls)) # ---------------- 五、本机真播一次 ---------------- print() print("五、本机真播一次(有 cscript 的话)") import shutil if shutil.which("cscript"): try: out = a.say("语音播报测试") check("真的播报成功", "已播报" in out, "拿到 %r" % out) check(" 报告里说了用的是哪条", "用 " in out, "拿到 %r" % out) except Exception as exc: # noqa: BLE001 check("真的播报成功", False, str(exc)[:120]) else: print(" [SKIP] 这台没有 cscript") print() print("=" * 58) if FAILS: print(" 失败 %d 项:%s" % (len(FAILS), ", ".join(FAILS))) return 1 print(" 全部通过") return 0 if __name__ == "__main__": sys.exit(main())