🔍 核心机关与修改指南
⏱️ 虚拟快进时钟 Clock 600秒防死循环
把 time.sleep() 变成瞬间加法!要是谁写了死循环超过虚拟 600 秒,当场抛出断言报错抓现行。
🤖 严苛替身 Robot 专抓违规操作
上一句还没播完就发下一句?报错!动作保持时间没等够就说话?报错!Q1 敢调 T1 接口或用错动作白名单?统统报错!
📦 三大测试军团 Skill / Toolkit / Artifact
同时覆盖技能全分支测试、Chrome/Edge/Firefox/MSIX 提 Token 测试,以及 4 个 .rpk 压缩包结构与 Python 3.10 语法树校验!
💻 完整源码与配置内容(共 341 行 · 支持行号定位与一键复制)
| 1 | """本机模拟只验证代码流程;不能证明真实播音、动作执行或安全。""" |
| 2 | |
| 3 | import ast |
| 4 | import contextlib |
| 5 | import importlib |
| 6 | import io |
| 7 | import json |
| 8 | import os |
| 9 | import sqlite3 |
| 10 | import sys |
| 11 | import tempfile |
| 12 | import types |
| 13 | import unittest |
| 14 | import zipfile |
| 15 | from pathlib import Path |
| 16 | from unittest.mock import patch |
| 17 | |
| 18 | ROOT = Path(__file__).resolve().parents[1] |
| 19 | WORKSPACE = ROOT.parent |
| 20 | DEV_KIT = ROOT / "primehub_dev_kit" |
| 21 | sys.path.insert(0, str(DEV_KIT)) |
| 22 | extract_token = importlib.import_module("extract_token") |
| 23 | primehub_publish = importlib.import_module("primehub_publish") |
| 24 | SOURCE = (WORKSPACE / "Example/energy_station/src/main.py").read_text(encoding="utf-8") |
| 25 | UNIVERSAL_SOURCE = (WORKSPACE / "Example/universal_skill/src/main.py").read_text(encoding="utf-8") |
| 26 | |
| 27 | |
| 28 | class Clock(types.ModuleType): |
| 29 | def __init__(self): |
| 30 | super().__init__("time") |
| 31 | self.now = 0.0 |
| 32 | |
| 33 | def monotonic(self): |
| 34 | return self.now |
| 35 | |
| 36 | def sleep(self, seconds): |
| 37 | self.now += seconds |
| 38 | if self.now > 600: |
| 39 | raise AssertionError("模拟超过600秒,疑似无界等待") |
| 40 | |
| 41 | |
| 42 | class Robot: |
| 43 | def __init__(self, clock, model, mode="normal", form="quadruped"): |
| 44 | self.clock = clock |
| 45 | self.model = model |
| 46 | self.mode = mode |
| 47 | self.form = form |
| 48 | self.started = None |
| 49 | self.duration = 0.0 |
| 50 | self.last_animation_end = 0.0 |
| 51 | self.lines = [] |
| 52 | self.animations = [] |
| 53 | self.lights = [] |
| 54 | |
| 55 | def play_tts(self, text): |
| 56 | if self.started is not None and self.clock.now < self.started + self.duration: |
| 57 | raise AssertionError("上一句尚未播完就发出了下一句") |
| 58 | if self.clock.now < self.last_animation_end: |
| 59 | raise AssertionError("未等动作保持时长结束就开始下一句") |
| 60 | self.lines.append(text) |
| 61 | self.started = self.clock.now + 0.5 |
| 62 | self.duration = max(len(text) * 0.12, 1.0) |
| 63 | |
| 64 | def get_audio_play_state(self): |
| 65 | if self.mode == "invalid": |
| 66 | return {"state": None} |
| 67 | elapsed = self.clock.now - self.started |
| 68 | playing = 0 <= elapsed < self.duration |
| 69 | if self.mode == "never_start": |
| 70 | playing = False |
| 71 | elif self.mode == "never_finish": |
| 72 | playing = elapsed >= 0 |
| 73 | elif self.mode == "jitter" and 1.0 < elapsed < 1.3: |
| 74 | playing = False |
| 75 | return {"pkg_name": "tts", "state": {"value": 1 if playing else 2}} |
| 76 | |
| 77 | def play_animation(self, animation_id, interrupt): |
| 78 | if self.clock.now < self.started + self.duration: |
| 79 | raise AssertionError("台词未播完就触发了动作") |
| 80 | if self.model == "q1": |
| 81 | allowed = {3001, 3008, 3007} |
| 82 | elif self.form == "quadruped": |
| 83 | allowed = {1, 7, 26, 43} |
| 84 | else: |
| 85 | allowed = {26, 43} |
| 86 | if animation_id not in allowed or interrupt: |
| 87 | raise AssertionError("动作白名单或不打断约束被破坏") |
| 88 | self.animations.append(animation_id) |
| 89 | self.last_animation_end = self.clock.now + (6.5 if self.model == "q1" else 16.0) |
| 90 | return self.mode != "reject_animation" |
| 91 | |
| 92 | def play_light(self, color, mode): |
| 93 | self.lights.append((color, mode)) |
| 94 | |
| 95 | def get_t1_robot_form(self): |
| 96 | if self.model != "t1": |
| 97 | raise AssertionError("Q1 不应调用 T1 专用接口") |
| 98 | return self.form |
| 99 | |
| 100 | |
| 101 | def perform(model, mode="normal", form="quadruped", source_code=SOURCE): |
| 102 | clock = Clock() |
| 103 | robot = Robot(clock, model, mode, form) |
| 104 | output = io.StringIO() |
| 105 | script = source_code.replace('ROBOT_MODEL = "q1"', 'ROBOT_MODEL = "%s"' % model) |
| 106 | with patch.dict(sys.modules, {"time": clock}), contextlib.redirect_stdout(output): |
| 107 | exec(compile(script, "skill/src/main.py", "exec"), {"robot": robot}) |
| 108 | return robot, output.getvalue(), clock.now |
| 109 | |
| 110 | |
| 111 | class SkillTests(unittest.TestCase): |
| 112 | def test_both_models_and_t1_forms_finish_twice(self): |
| 113 | for model, form in (("q1", "quadruped"), ("t1", "quadruped"), ("t1", "biped")): |
| 114 | for run in range(2): |
| 115 | with self.subTest(model=model, form=form, run=run): |
| 116 | robot, output, elapsed = perform(model, form=form) |
| 117 | self.assertEqual(len(robot.lines), 5) |
| 118 | self.assertEqual(len(robot.animations), 3) |
| 119 | self.assertIn("ENERGY_STATION_DONE steps=3", output) |
| 120 | self.assertLess(elapsed, 120) |
| 121 | |
| 122 | def test_universal_skill_both_models_and_forms(self): |
| 123 | for model, form in (("q1", "quadruped"), ("t1", "quadruped"), ("t1", "biped")): |
| 124 | with self.subTest(model=model, form=form): |
| 125 | robot, output, elapsed = perform(model, form=form, source_code=UNIVERSAL_SOURCE) |
| 126 | self.assertEqual(len(robot.lines), 5) |
| 127 | self.assertEqual(len(robot.animations), 3) |
| 128 | self.assertIn("UNIVERSAL_SKILL_DONE steps=3", output) |
| 129 | self.assertLess(elapsed, 120) |
| 130 | |
| 131 | def test_universal_skill_network_fallback_when_offline(self): |
| 132 | online_script = UNIVERSAL_SOURCE.replace("ENABLE_NET_QUOTE = False", "ENABLE_NET_QUOTE = True") |
| 133 | with patch("urllib.request.urlopen", side_effect=OSError("network unreachable")): |
| 134 | robot, output, _ = perform("q1", source_code=online_script) |
| 135 | self.assertIn("NET_FALLBACK", output) |
| 136 | self.assertIn("UNIVERSAL_SKILL_DONE steps=3", output) |
| 137 | self.assertEqual(len(robot.lines), 5) |
| 138 | |
| 139 | def test_short_idle_glitch_does_not_cut_speech(self): |
| 140 | for model in ("q1", "t1"): |
| 141 | with self.subTest(model=model): |
| 142 | robot, output, _ = perform(model, "jitter") |
| 143 | self.assertEqual(len(robot.lines), 5) |
| 144 | self.assertIn("ENERGY_STATION_DONE", output) |
| 145 | |
| 146 | def test_audio_failures_stop_instead_of_claiming_success(self): |
| 147 | for mode, error in (("never_start", "TTS_NOT_STARTED"), |
| 148 | ("never_finish", "TTS_NOT_FINISHED"), |
| 149 | ("invalid", "AUDIO_STATE_INVALID")): |
| 150 | with self.subTest(mode=mode), self.assertRaisesRegex(RuntimeError, error): |
| 151 | perform("q1", mode) |
| 152 | |
| 153 | def test_rejected_animation_stops(self): |
| 154 | with self.assertRaisesRegex(RuntimeError, "ANIMATION_REJECTED"): |
| 155 | perform("q1", "reject_animation") |
| 156 | |
| 157 | def test_unknown_t1_form_stops(self): |
| 158 | with self.assertRaisesRegex(RuntimeError, "T1_FORM_UNKNOWN"): |
| 159 | perform("t1", form="unknown") |
| 160 | |
| 161 | def test_python_310_and_stdlib_contract(self): |
| 162 | for name, code, expected_imports in ( |
| 163 | ("energy_station", SOURCE, ["time"]), |
| 164 | ("universal_skill", UNIVERSAL_SOURCE, ["json", "random", "time", "urllib.request"]), |
| 165 | ): |
| 166 | with self.subTest(skill=name): |
| 167 | tree = ast.parse(code, feature_version=(3, 10)) |
| 168 | imports = sorted(node.names[0].name for node in ast.walk(tree) if isinstance(node, ast.Import)) |
| 169 | self.assertEqual(imports, expected_imports) |
| 170 | self.assertFalse(any(isinstance(node, ast.ImportFrom) for node in ast.walk(tree))) |
| 171 | |
| 172 | |
| 173 | class ToolkitTests(unittest.TestCase): |
| 174 | def test_anonymous_search_does_not_read_token(self): |
| 175 | with patch.object(sys, "argv", ["primehub_publish.py", "search", "点歌"]), \ |
| 176 | patch.object(primehub_publish, "search", return_value=[]) as search, \ |
| 177 | patch.object(Path, "read_text", side_effect=AssertionError("不应读凭证")): |
| 178 | primehub_publish.main() |
| 179 | search.assert_called_once_with("点歌") |
| 180 | |
| 181 | def test_search_api_error_is_not_reported_as_empty_success(self): |
| 182 | with patch.object(primehub_publish, "call", return_value={"code": 9999, "msg": "synthetic"}): |
| 183 | with self.assertRaisesRegex(RuntimeError, "9999"): |
| 184 | primehub_publish.search("点歌") |
| 185 | |
| 186 | def test_my_skills_queries_authenticated_account_endpoint(self): |
| 187 | response = {"code": 1000, "data": {"records": [{"id": 1}]}} |
| 188 | with patch.object(primehub_publish, "call", return_value=response) as call: |
| 189 | self.assertEqual(primehub_publish.my_skills("synthetic-token"), [{"id": 1}]) |
| 190 | call.assert_called_once_with( |
| 191 | "skillCenter/queryPageByUserCode", {"page": 1, "rows": 50}, "synthetic-token" |
| 192 | ) |
| 193 | |
| 194 | def test_my_skills_rejects_expired_login(self): |
| 195 | with patch.object(primehub_publish, "call", return_value={"code": 1002, "msg": "synthetic"}): |
| 196 | with self.assertRaisesRegex(RuntimeError, "1002"): |
| 197 | primehub_publish.my_skills("synthetic-token") |
| 198 | |
| 199 | def test_shared_token_path(self): |
| 200 | self.assertEqual(primehub_publish.TOKEN_FILE, extract_token.DEFAULT_TOKEN_FILE) |
| 201 | self.assertTrue(Path(extract_token.DEFAULT_TOKEN_FILE).is_absolute()) |
| 202 | |
| 203 | def test_msix_firefox_profile_discovery(self): |
| 204 | with tempfile.TemporaryDirectory(prefix="qiyuan_msix_test_") as folder: |
| 205 | database = ( |
| 206 | Path(folder) / "Packages/Mozilla.MozillaFirefox_synthetic/LocalCache" |
| 207 | / "Roaming/Mozilla/Firefox/Profiles/default.synthetic/storage/default" |
| 208 | / "https+++primehub.primebot.com/ls/data.sqlite" |
| 209 | ) |
| 210 | database.parent.mkdir(parents=True) |
| 211 | database.touch() |
| 212 | with patch.dict(os.environ, { |
| 213 | "LOCALAPPDATA": folder, "APPDATA": folder, "PRIMEHUB_FIREFOX_PROFILE": "" |
| 214 | }): |
| 215 | self.assertIn(database, extract_token.firefox_databases()) |
| 216 | |
| 217 | def test_publish_accepts_json_argument_and_metadata_file(self): |
| 218 | with tempfile.TemporaryDirectory(prefix="qiyuan_metadata_test_") as folder: |
| 219 | metadata_path = Path(folder) / "publish.json" |
| 220 | metadata_path.write_text('{"skillName":"synthetic"}', encoding="utf-8") |
| 221 | cases = [ |
| 222 | ["publish", "synthetic.rpk", '{"skillName":"synthetic"}'], |
| 223 | ["publish", "synthetic.rpk", "--metadata-file", str(metadata_path)], |
| 224 | ] |
| 225 | for arguments in cases: |
| 226 | with self.subTest(arguments=arguments), \ |
| 227 | patch.object(sys, "argv", ["primehub_publish.py"] + arguments), \ |
| 228 | patch.object(Path, "read_text", autospec=True) as read_text, \ |
| 229 | patch.object(primehub_publish, "upload", return_value="synthetic-url") as upload, \ |
| 230 | patch.object(primehub_publish, "call", return_value={"code": 1000}) as call, \ |
| 231 | contextlib.redirect_stdout(io.StringIO()): |
| 232 | read_text.side_effect = lambda path, **kwargs: ( |
| 233 | '{"skillName":"synthetic"}' if path == metadata_path else "synthetic-token" |
| 234 | ) |
| 235 | primehub_publish.main() |
| 236 | upload.assert_called_once_with("synthetic.rpk", "synthetic-token") |
| 237 | call.assert_called_once_with( |
| 238 | "skillCenter/publishSkill", |
| 239 | {"skillName": "synthetic", "packageUrl": "synthetic-url"}, |
| 240 | "synthetic-token", |
| 241 | ) |
| 242 | |
| 243 | def test_sqlite_backup_reads_wal_and_extraction_writes_selected_path(self): |
| 244 | with tempfile.TemporaryDirectory(prefix="qiyuan_token_test_") as folder: |
| 245 | profile = Path(folder) / "profile[synthetic]" |
| 246 | database = profile / "storage/default/https+++primehub.primebot.com/ls/data.sqlite" |
| 247 | database.parent.mkdir(parents=True) |
| 248 | synthetic = b'"synthetic.test.token"' |
| 249 | compressed = bytes([len(synthetic), (len(synthetic) - 1) << 2]) + synthetic |
| 250 | connection = sqlite3.connect(database) |
| 251 | try: |
| 252 | connection.execute("PRAGMA journal_mode=WAL") |
| 253 | connection.execute("CREATE TABLE data (key TEXT, value BLOB)") |
| 254 | connection.execute("INSERT INTO data VALUES (?, ?)", ("skillhub_token", compressed)) |
| 255 | connection.commit() |
| 256 | output_path = Path(folder) / "output/ph_token.txt" |
| 257 | with patch.dict(os.environ, {"PRIMEHUB_FIREFOX_PROFILE": str(profile), "PRIMEHUB_TOKEN": ""}): |
| 258 | self.assertEqual(extract_token.firefox_databases(), [database]) |
| 259 | self.assertEqual(extract_token.read_storage(database)["skillhub_token"], compressed) |
| 260 | with patch.object(extract_token, "firefox_databases", return_value=[database]), \ |
| 261 | patch.object(sys, "argv", ["extract_token.py", str(output_path)]), \ |
| 262 | contextlib.redirect_stdout(io.StringIO()) as output: |
| 263 | extract_token.main() |
| 264 | self.assertEqual(output_path.read_text(encoding="utf-8"), "synthetic.test.token") |
| 265 | self.assertNotIn("synthetic.test.token", output.getvalue()) |
| 266 | finally: |
| 267 | connection.close() |
| 268 | database.unlink() # Windows 下也应已释放读取连接。 |
| 269 | |
| 270 | def test_chromium_leveldb_and_direct_token_extraction(self): |
| 271 | fake_jwt = "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE5OTk5OTk5OTl9.signature_part_12345" |
| 272 | with tempfile.TemporaryDirectory(prefix="qiyuan_chrome_test_") as folder: |
| 273 | ldb_dir = Path(folder) / "Default/Local Storage/leveldb" |
| 274 | ldb_dir.mkdir(parents=True) |
| 275 | # 1. 测试 UTF-8 编码的 LevelDB .log 记录 |
| 276 | record_utf8 = b"_https://primehub.primebot.com\x00\x01skillhub_token\x01\"" + fake_jwt.encode("ascii") + b"\"" |
| 277 | (ldb_dir / "000003.log").write_bytes(record_utf8) |
| 278 | output_path = Path(folder) / "out_chrome.txt" |
| 279 | with patch.dict(os.environ, {"PRIMEHUB_CHROME_PROFILE": folder, "PRIMEHUB_TOKEN": ""}), \ |
| 280 | patch.object(extract_token, "firefox_databases", return_value=[]), \ |
| 281 | patch.object(sys, "argv", ["extract_token.py", "--browser", "chrome", str(output_path)]), \ |
| 282 | contextlib.redirect_stdout(io.StringIO()) as out: |
| 283 | extract_token.main() |
| 284 | self.assertEqual(output_path.read_text(encoding="utf-8"), fake_jwt) |
| 285 | self.assertNotIn(fake_jwt, out.getvalue()) |
| 286 | |
| 287 | # 2. 测试 UTF-16LE 编码的 LevelDB .ldb 记录 |
| 288 | (ldb_dir / "000003.log").unlink() |
| 289 | record_utf16 = ("_https://primehub.primebot.com\x00skillhub_token\x00" + fake_jwt).encode("utf-16le") |
| 290 | (ldb_dir / "000005.ldb").write_bytes(record_utf16) |
| 291 | output_utf16 = Path(folder) / "out_edge.txt" |
| 292 | with patch.dict(os.environ, {"PRIMEHUB_CHROME_PROFILE": folder, "PRIMEHUB_TOKEN": ""}), \ |
| 293 | patch.object(extract_token, "firefox_databases", return_value=[]), \ |
| 294 | patch.object(sys, "argv", ["extract_token.py", "--browser", "edge", str(output_utf16)]), \ |
| 295 | contextlib.redirect_stdout(io.StringIO()): |
| 296 | extract_token.main() |
| 297 | self.assertEqual(output_utf16.read_text(encoding="utf-8"), fake_jwt) |
| 298 | |
| 299 | # 3. 测试命令行 --token 直接传入 |
| 300 | output_direct = Path(folder) / "out_direct.txt" |
| 301 | with patch.object(sys, "argv", ["extract_token.py", "--token", f'"{fake_jwt}"', str(output_direct)]), \ |
| 302 | contextlib.redirect_stdout(io.StringIO()): |
| 303 | extract_token.main() |
| 304 | self.assertEqual(output_direct.read_text(encoding="utf-8"), fake_jwt) |
| 305 | |
| 306 | def test_missing_firefox_has_actionable_failure(self): |
| 307 | with patch.dict(os.environ, {"PRIMEHUB_TOKEN": ""}), \ |
| 308 | patch.object(extract_token, "firefox_databases", return_value=[]), \ |
| 309 | patch.object(extract_token, "extract_from_chromium", return_value=None), \ |
| 310 | patch.object(sys, "argv", ["extract_token.py"]), \ |
| 311 | self.assertRaisesRegex(SystemExit, "Firefox"): |
| 312 | extract_token.main() |
| 313 | |
| 314 | |
| 315 | class ArtifactTests(unittest.TestCase): |
| 316 | def test_packages_and_metadata_match_each_model(self): |
| 317 | for folder, pkg_prefix, pub_prefix in ( |
| 318 | ("energy_station", "cans518_energy_station", "publish"), |
| 319 | ("universal_skill", "cans518_universal_skill", "publish_universal"), |
| 320 | ): |
| 321 | for model, model_id, model_code in (("q1", 100, "robot_q"), ("t1", 101, "robot_t")): |
| 322 | with self.subTest(skill=folder, model=model): |
| 323 | package = ROOT / "dist" / ("%s_%s.rpk" % (folder, model)) |
| 324 | self.assertLess(package.stat().st_size, 200 * 1024 * 1024) |
| 325 | with zipfile.ZipFile(package) as archive: |
| 326 | self.assertIsNone(archive.testzip()) |
| 327 | self.assertEqual(set(archive.namelist()), {"cfg/pkg.yaml", "src/main.py", "readme.md"}) |
| 328 | config = archive.read("cfg/pkg.yaml").decode("utf-8") |
| 329 | script = archive.read("src/main.py").decode("utf-8") |
| 330 | self.assertIn("robot_model: " + model, config) |
| 331 | self.assertIn("package_name: %s_%s_20261024" % (pkg_prefix, model), config) |
| 332 | self.assertIn('ROBOT_MODEL = "%s"' % model, script) |
| 333 | ast.parse(script, feature_version=(3, 10)) |
| 334 | metadata = json.loads((ROOT / "dist" / ("%s_%s.json" % (pub_prefix, model))).read_text(encoding="utf-8")) |
| 335 | self.assertEqual(metadata["robotModelIds"], [model_id]) |
| 336 | self.assertEqual(metadata["robotModelCodeList"], [model_code]) |
| 337 | self.assertEqual(metadata["version"], "0.1.0") |
| 338 | |
| 339 | |
| 340 | if __name__ == "__main__": |
| 341 | unittest.main(verbosity=2) |