v3.6.0 同步发版

- 交互契约改对话式询问(禁点选工具、一次1~2问、先铺垫再问),规则库 3.6
- 盘面图默认生成+chart_shot 截图;品牌区、大运/流年小卡信息补全、交流群二维码卡
- day_boundary 子正换日;memory_save --update-chart 换盘留痕;share_export 事件简介+城市级地域
- share_push 失败诊断+代理引导;_runtime_guard;修 STAR_ORDER 三奇贵人 KeyError
- README 规则计数 541→542;manifest 重建(1275 文件);测试 142 项全绿
This commit is contained in:
2026-10-10 13:13:41 +08:00
parent f725df8ff6
commit 433c9b4a41
32 changed files with 1468 additions and 389 deletions
+113
View File
@@ -0,0 +1,113 @@
"""chart_shot.py 与 cast_chart --shot:HTML → PNG 截图的分级回退与优雅失败。"""
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SKILL_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SKILL_ROOT / "scripts"))
import chart_shot # noqa: E402
CHROME_OK = Path(chart_shot.CHROME).is_file()
try:
import playwright # noqa: F401
PLAYWRIGHT_OK = True
except ImportError:
PLAYWRIGHT_OK = False
SHOT_OK = CHROME_OK or PLAYWRIGHT_OK
HTML = "<!DOCTYPE html><html><body><h1>命盘</h1></body></html>"
class ShootUnitTest(unittest.TestCase):
def test_missing_html_exit_2(self):
r = subprocess.run(
[sys.executable, str(SKILL_ROOT / "scripts" / "chart_shot.py"),
"/nonexistent/x.html"], capture_output=True, text=True, timeout=30)
self.assertEqual(r.returncode, 2)
self.assertIn("ERROR", r.stderr)
def test_no_tools_fails_gracefully(self):
"""playwright 与 Chrome 都不可用时:shoot 返回 None,CLI 退出码 2 并给提示。"""
with tempfile.TemporaryDirectory() as td:
html = Path(td) / "p.html"
html.write_text(HTML, encoding="utf-8")
with mock.patch.object(chart_shot, "_shot_playwright",
return_value=False), \
mock.patch.object(chart_shot, "_shot_chrome",
return_value=False):
self.assertIsNone(chart_shot.shoot(html))
self.assertFalse(html.with_suffix(".png").exists())
with mock.patch.object(sys, "argv",
["chart_shot.py", str(html)]):
with mock.patch("sys.stderr") as err:
self.assertEqual(chart_shot.main(), 2)
err.write.assert_called()
def test_content_height_bounds(self):
"""高度估算不越界:空页夹到下限,超长页夹到上限。"""
with tempfile.TemporaryDirectory() as td:
empty = Path(td) / "e.html"
empty.write_text(HTML, encoding="utf-8")
self.assertEqual(chart_shot._content_height(empty), 1300)
big = Path(td) / "b.html"
big.write_text(
'<div class="strip">' + '<div class="cell"></div>' * 5000
+ "</div>", encoding="utf-8")
self.assertEqual(chart_shot._content_height(big), 4200)
@unittest.skipUnless(SHOT_OK, "环境无 playwright 与 Chrome,跳过真实截图")
class ShootRealTest(unittest.TestCase):
def test_real_png(self):
"""真实截图:PNG 非空、宽 2200(1100 窗口 × 2 倍分辨率)。"""
import struct
with tempfile.TemporaryDirectory() as td:
html = Path(td) / "p.html"
html.write_text(HTML, encoding="utf-8")
png = chart_shot.shoot(html)
self.assertIsNotNone(png)
data = png.read_bytes()
self.assertGreater(len(data), 1000)
self.assertEqual(data[:8], b"\x89PNG\r\n\x1a\n")
w, _h = struct.unpack(">II", data[16:24])
self.assertEqual(w, 2200)
def test_cli_success(self):
with tempfile.TemporaryDirectory() as td:
html = Path(td) / "p.html"
html.write_text(HTML, encoding="utf-8")
out = Path(td) / "o.png"
r = subprocess.run(
[sys.executable, str(SKILL_ROOT / "scripts" / "chart_shot.py"),
str(html), "-o", str(out)],
capture_output=True, text=True, timeout=120)
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn(str(out), r.stdout)
self.assertGreater(out.stat().st_size, 0)
@unittest.skipUnless(SHOT_OK, "环境无 playwright 与 Chrome,跳过 --shot 端到端")
class CastShotE2ETest(unittest.TestCase):
def test_shot_flag_outputs_paths(self):
with tempfile.TemporaryDirectory() as td:
html = Path(td) / "p.html"
r = subprocess.run(
[sys.executable, str(SKILL_ROOT / "scripts" / "cast_chart.py"),
"--name", "张三", "--gender", "男", "--birth", "1990-05-14 08:30",
"--place", "四川省成都市", "--html", str(html), "--shot"],
capture_output=True, text=True, timeout=120,
cwd=str(SKILL_ROOT / "scripts"))
self.assertEqual(r.returncode, 0, r.stderr)
self.assertTrue(r.stdout.startswith("# 命盘:张三"))
self.assertIn(f"HTML 盘面:{html.resolve()}", r.stdout)
png = html.with_suffix(".png")
self.assertIn(f"盘面截图:{png.resolve()}", r.stdout)
self.assertGreater(png.stat().st_size, 0)
if __name__ == "__main__":
unittest.main()