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:
@@ -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()
|
||||
+107
-10
@@ -60,19 +60,116 @@ class HtmlRenderTest(unittest.TestCase):
|
||||
for sect in ("排盘", "大运", "流年", "流月", "空亡", "神煞", "纳音"):
|
||||
self.assertIn(sect, html)
|
||||
|
||||
def test_brand_head_embedded(self):
|
||||
"""顶部品牌区:logo 与 LogoFont 均 base64 内嵌,口号在。"""
|
||||
from render.html import build_html
|
||||
html = build_html(_chart())
|
||||
self.assertIn("灵爻妙解", html)
|
||||
self.assertIn("传承易学智慧", html)
|
||||
self.assertIn('class="brand-head"', html)
|
||||
self.assertIn("data:image/png;base64,", html)
|
||||
self.assertIn("@font-face", html)
|
||||
self.assertIn("data:font/ttf;base64,", html)
|
||||
|
||||
def test_qrcode_card(self):
|
||||
"""尾部交流群卡:二维码 jpeg base64 内嵌,标题与正文文案各出现一次。"""
|
||||
from render.html import build_html
|
||||
html = build_html(_chart())
|
||||
self.assertIn('class="card qrcard"', html)
|
||||
self.assertIn("data:image/jpeg;base64,", html)
|
||||
self.assertEqual(html.count("灵爻妙解 · 传统文化交流群"), 1)
|
||||
self.assertEqual(
|
||||
html.count("扫码加入微信群:反馈解读偏差、交流文化心得、"
|
||||
"参与档案共建,版本更新动态第一时间同步。"), 1)
|
||||
|
||||
def test_audit_versions_only_in_comment(self):
|
||||
"""技术信息并入用户信息卡:展示行删版本段,完整原文留在 HTML 注释。"""
|
||||
from render.html import build_html
|
||||
html = build_html(_chart())
|
||||
self.assertEqual(html.count("tyme4py"), 1)
|
||||
self.assertEqual(html.count("schema"), 1)
|
||||
m = re.search(r"<!--[^>]*tyme4py[^>]*-->", html)
|
||||
self.assertIsNotNone(m, "注释中应保留含 tyme4py/schema 的完整审计原文")
|
||||
self.assertIn("schema", m.group(0))
|
||||
self.assertIn("时区", m.group(0))
|
||||
self.assertIn("月令", html)
|
||||
self.assertIn("日界 子初换日(23:00)", html)
|
||||
|
||||
def test_shensha_notes_stripped(self):
|
||||
"""神煞括号注释在装配层剔除(全角/半角),Markdown 侧不受影响。"""
|
||||
from render.html import _strip_sha_note, build_html
|
||||
self.assertEqual(_strip_sha_note("将星(以年支子查,见于月支子)"), "将星")
|
||||
self.assertEqual(_strip_sha_note("天乙贵人(以日干查)"), "天乙贵人")
|
||||
self.assertEqual(_strip_sha_note("驿马"), "驿马")
|
||||
html = build_html(_chart())
|
||||
self.assertNotIn("(以", html)
|
||||
|
||||
def test_luck_flow_card_removed(self):
|
||||
"""「大运 · 流年」对照卡已整张删除;排盘卡仍只含四柱。"""
|
||||
from render.html import build_html
|
||||
html = build_html(_chart())
|
||||
self.assertNotIn("大运 · 流年", html)
|
||||
self.assertNotIn("当前一步大运与本年流年对照", html)
|
||||
self.assertNotIn("LUCK_FLOW_TABLE", html)
|
||||
pan_seg = html[html.find("排盘<span"):html.find('class="sect">大运')]
|
||||
self.assertIn("年柱", pan_seg)
|
||||
self.assertNotIn("大运", pan_seg)
|
||||
self.assertNotIn("流年", pan_seg)
|
||||
|
||||
def test_luck_strip_enriched(self):
|
||||
"""大运格:起运年+干支之外带十神·星运/空亡/纳音/神煞(2018 甲申运对拍)。"""
|
||||
from render.html import build_html
|
||||
html = build_html(_chart())
|
||||
seg = html[html.find('class="sect">大运'):html.find('class="sect">流年')]
|
||||
self.assertIn("2018 · 28 岁起", seg)
|
||||
self.assertIn("正官 · 沐浴", seg) # 甲对日干己 / 己对申
|
||||
self.assertIn("午未", seg) # 甲申旬空
|
||||
self.assertIn("泉中水", seg) # 甲申纳音
|
||||
self.assertIn("天乙贵人 · 金舆 · 驿马 · 劫煞 · 孤辰", seg) # 甲申岁运神煞
|
||||
self.assertIn('class="cell cur"', seg) # 当前一步高亮
|
||||
|
||||
def test_flow_year_strip_enriched(self):
|
||||
"""流年格:年份+干支之外带主星/星运/空亡/纳音/神煞(2026 丙午对拍)。"""
|
||||
from render.html import build_html
|
||||
html = build_html(_chart())
|
||||
seg = html[html.find('class="sect">流年'):html.find('class="sect">流月')]
|
||||
self.assertIn("正印 · 临官", seg) # 丙对日干己 / 己对午
|
||||
self.assertIn("寅卯", seg) # 丙午旬空(甲辰旬)
|
||||
self.assertIn("天河水", seg) # 丙午纳音
|
||||
self.assertIn("天德合 · 禄神 · 将星", seg) # 丙午岁运神煞
|
||||
self.assertIn('class="cell cur"', seg) # 本年高亮
|
||||
|
||||
def test_strips_have_no_field_labels(self):
|
||||
"""条带小卡只排值:不出现「主星/星运/空亡/纳音/神煞」字段标签。"""
|
||||
from render.html import build_html
|
||||
html = build_html(_chart())
|
||||
segs = [html[html.find('class="sect">大运'):html.find('class="sect">流年')],
|
||||
html[html.find('class="sect">流年'):html.find('class="sect">流月')]]
|
||||
for seg in segs:
|
||||
for label in ("主星", "星运", "空亡", "纳音", "神煞"):
|
||||
self.assertNotIn(label, seg)
|
||||
|
||||
def test_ten_god_table(self):
|
||||
from render.html import _ten_god
|
||||
self.assertEqual(_ten_god("庚", "丁"), "正官")
|
||||
self.assertEqual(_ten_god("庚", "丙"), "七杀")
|
||||
self.assertEqual(_ten_god("庚", "辛"), "劫财")
|
||||
self.assertEqual(_ten_god("庚", "壬"), "食神")
|
||||
from chart.ganzhi import ten_god
|
||||
self.assertEqual(ten_god("庚", "丁"), "正官")
|
||||
self.assertEqual(ten_god("庚", "丙"), "七杀")
|
||||
self.assertEqual(ten_god("庚", "辛"), "劫财")
|
||||
self.assertEqual(ten_god("庚", "壬"), "食神")
|
||||
|
||||
def test_void_and_terrain(self):
|
||||
from render.html import _gz_void, _terrain
|
||||
self.assertEqual(_gz_void("庚子"), "辰巳")
|
||||
self.assertEqual(_gz_void("甲子"), "戌亥")
|
||||
self.assertEqual(_terrain("庚", "巳"), "长生")
|
||||
self.assertEqual(_terrain("庚", "子"), "死")
|
||||
from chart.ganzhi import gz_void, terrain
|
||||
self.assertEqual(gz_void("庚子"), "辰巳")
|
||||
self.assertEqual(gz_void("甲子"), "戌亥")
|
||||
self.assertEqual(terrain("庚", "巳"), "长生")
|
||||
self.assertEqual(terrain("庚", "子"), "死")
|
||||
|
||||
def test_nayin_shared_with_shensha_module(self):
|
||||
"""纳音表集中于 chart/ganzhi.py,chart/shensha.py 复用同一份。"""
|
||||
from chart.ganzhi import NAYIN
|
||||
from chart.shensha import NAYIN as SHA_NAYIN
|
||||
self.assertIs(NAYIN, SHA_NAYIN)
|
||||
self.assertEqual(NAYIN["甲辰"], "覆灯火")
|
||||
self.assertEqual(NAYIN["丙午"], "天河水")
|
||||
|
||||
|
||||
class CastHtmlE2ETest(unittest.TestCase):
|
||||
|
||||
@@ -16,6 +16,10 @@ LOOKUP = SCRIPTS_DIR / "memory_lookup.py"
|
||||
ZHANGSAN = ["--name", "张三", "--gender", "男",
|
||||
"--birth", "1990-05-14 08:30", "--place", "四川省成都市"]
|
||||
|
||||
# 晚子时案例:真太阳时落于 23:00–24:00,子初/子正换日四柱不同
|
||||
WANZI = ["--name", "李四", "--gender", "女",
|
||||
"--birth", "1996-12-08 00:30", "--place", "四川省乐山市"]
|
||||
|
||||
|
||||
def run(script, mem_root, *args, use_env=False):
|
||||
env = dict(os.environ)
|
||||
@@ -117,3 +121,97 @@ class TestLookupNoMatch:
|
||||
def test_no_condition_exit_2(self, tmp_path):
|
||||
r = run(LOOKUP, tmp_path)
|
||||
assert r.returncode == 2
|
||||
|
||||
|
||||
class TestUpdateChart:
|
||||
"""换盘(--update-chart):工作盘唯一——盘面段整体替换为新盘,frontmatter
|
||||
与文件名同步,其余小节保留;旧盘信息由使用方记入反馈与澄清。"""
|
||||
|
||||
def test_zizheng_replaces_chart_and_renames(self, tmp_path):
|
||||
r1 = run(SAVE, tmp_path, *WANZI)
|
||||
assert r1.returncode == 0, f"stderr:\n{r1.stderr}"
|
||||
path = Path(r1.stdout.strip())
|
||||
assert "丙子庚子庚辰丙子" in path.name # 默认子初换日盘
|
||||
|
||||
# 模拟核对后的回写 + 固定 created_at
|
||||
text = path.read_text(encoding="utf-8")
|
||||
text = text.replace("(无)", "- 【已核实】2016 年离乡赴厦门读大学。")
|
||||
text = re.sub(r'^created_at:.*$', 'created_at: "2000-01-01 00:00"',
|
||||
text, count=1, flags=re.M)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
# 用户确认子正换日:重排替换盘面段,日柱/时柱变 → 改新主键名
|
||||
r2 = run(SAVE, tmp_path, "--update-chart", str(path),
|
||||
"--day-boundary", "子正")
|
||||
assert r2.returncode == 0, f"stderr:\n{r2.stderr}"
|
||||
assert "日柱 庚辰→己卯" in r2.stderr and "时柱 丙子→甲子" in r2.stderr
|
||||
assert "待复核" in r2.stderr # 变动柱影响条目提示复核
|
||||
new_path = Path(r2.stdout.strip())
|
||||
assert new_path != path and not path.exists()
|
||||
assert "丙子庚子己卯甲子" in new_path.name
|
||||
assert list((tmp_path / "archives").glob("*.md")) == [new_path]
|
||||
|
||||
text = new_path.read_text(encoding="utf-8")
|
||||
meta = read_meta(text)
|
||||
assert meta["pillars"] == '["丙子", "庚子", "己卯", "甲子"]'
|
||||
assert meta["day_master"] == "己"
|
||||
assert meta["created_at"] == "2000-01-01 00:00" # 保留
|
||||
# 盘面段只剩新盘:日界审计与晚子时警示均为子正口径,无旧盘残留
|
||||
assert "- 日界规则:子正换日(真太阳时 00:00)" in text
|
||||
assert "已按「子正换日」定盘" in text
|
||||
assert text.count("# 命盘") == 1
|
||||
chart_section = text.split("## 盘面")[1].split("## 主要关注方向")[0]
|
||||
assert "日柱:己卯" in chart_section and "日柱:庚辰" not in chart_section
|
||||
# 其余小节保留
|
||||
assert "- 【已核实】2016 年离乡赴厦门读大学。" in text
|
||||
|
||||
def test_birth_correction_updates_meta(self, tmp_path):
|
||||
r1 = run(SAVE, tmp_path, *ZHANGSAN)
|
||||
assert r1.returncode == 0, f"stderr:\n{r1.stderr}"
|
||||
path = Path(r1.stdout.strip())
|
||||
|
||||
r2 = run(SAVE, tmp_path, "--update-chart", str(path),
|
||||
"--birth", "1990-05-14 10:30")
|
||||
assert r2.returncode == 0, f"stderr:\n{r2.stderr}"
|
||||
assert "时柱" in r2.stderr and "待复核" in r2.stderr
|
||||
new_path = Path(r2.stdout.strip())
|
||||
assert new_path != path and not path.exists() # hash6 随 birth 变
|
||||
meta = read_meta(new_path.read_text(encoding="utf-8"))
|
||||
assert meta["birth_clock"] == "1990-05-14 10:30"
|
||||
|
||||
def test_same_chart_keeps_filename(self, tmp_path):
|
||||
r1 = run(SAVE, tmp_path, *ZHANGSAN)
|
||||
assert r1.returncode == 0, f"stderr:\n{r1.stderr}"
|
||||
path = Path(r1.stdout.strip())
|
||||
r2 = run(SAVE, tmp_path, "--update-chart", str(path))
|
||||
assert r2.returncode == 0, f"stderr:\n{r2.stderr}"
|
||||
assert Path(r2.stdout.strip()) == path # 四柱未变,文件名不动
|
||||
assert "待复核" not in r2.stderr
|
||||
|
||||
def test_conflict_refuses_overwrite(self, tmp_path):
|
||||
run(SAVE, tmp_path, *WANZI)
|
||||
olds = list((tmp_path / "archives").glob("*.md"))
|
||||
assert len(olds) == 1
|
||||
old = olds[0]
|
||||
r = run(SAVE, tmp_path, "--update-chart", str(old),
|
||||
"--day-boundary", "子正")
|
||||
assert r.returncode == 0, f"stderr:\n{r.stderr}"
|
||||
# 重新建子初盘档案,再换子正 → 目标已存在,拒绝覆盖
|
||||
run(SAVE, tmp_path, *WANZI)
|
||||
r = run(SAVE, tmp_path, "--update-chart", str(old),
|
||||
"--day-boundary", "子正")
|
||||
assert r.returncode == 2
|
||||
assert "已存在" in r.stderr
|
||||
assert len(list((tmp_path / "archives").glob("*.md"))) == 2
|
||||
|
||||
def test_missing_archive_exit_2(self, tmp_path):
|
||||
r = run(SAVE, tmp_path, "--update-chart",
|
||||
str(tmp_path / "archives" / "nope.md"))
|
||||
assert r.returncode == 2
|
||||
|
||||
def test_rejects_name_gender_overrides(self, tmp_path):
|
||||
r1 = run(SAVE, tmp_path, *ZHANGSAN)
|
||||
assert r1.returncode == 0, f"stderr:\n{r1.stderr}"
|
||||
path = r1.stdout.strip()
|
||||
r = run(SAVE, tmp_path, "--update-chart", path, "--name", "王五")
|
||||
assert r.returncode == 2
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""运行环境守卫(scripts/_runtime_guard.py):版本判定文案、import 时拦截、入口脚本接线。
|
||||
|
||||
import 时拦截用子进程模拟:伪造 sys.version_info = (3, 9, 6) 后再 import,
|
||||
守卫应打印中文报错并以退出码 3 结束进程。
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS = ROOT / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import _runtime_guard as guard
|
||||
|
||||
ENTRY_SCRIPTS = ("cast_chart.py", "chart_shot.py", "memory_lookup.py",
|
||||
"memory_save.py", "query_shensha.py", "self_update.py",
|
||||
"share_export.py", "share_push.py")
|
||||
|
||||
|
||||
def run_with_fake_py39(code: str) -> subprocess.CompletedProcess:
|
||||
"""在子进程中把 sys.version_info 伪造成 3.9.6 后执行 code。"""
|
||||
prefix = "import sys, runpy; sys.version_info = (3, 9, 6); "
|
||||
return subprocess.run([sys.executable, "-c", prefix + code],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
|
||||
|
||||
class TestVersionError:
|
||||
def test_current_interpreter_passes(self):
|
||||
assert guard.version_error() is None
|
||||
|
||||
def test_py39_denied_with_chinese_message(self):
|
||||
msg = guard.version_error((3, 9, 6))
|
||||
assert msg is not None
|
||||
assert "Python 3.10" in msg
|
||||
assert "3.9.6" in msg
|
||||
assert sys.executable in msg
|
||||
|
||||
def test_boundary_versions(self):
|
||||
assert guard.version_error((3, 10, 0)) is None
|
||||
assert guard.version_error((4, 0, 0)) is None
|
||||
assert guard.version_error((3, 8, 10)) is not None
|
||||
assert guard.version_error((2, 7, 18)) is not None
|
||||
|
||||
|
||||
class TestImportTimeGuard:
|
||||
def test_import_denied_exits_3(self):
|
||||
cp = run_with_fake_py39(
|
||||
f"sys.path.insert(0, {str(SCRIPTS)!r}); import _runtime_guard")
|
||||
assert cp.returncode == 3
|
||||
assert "Python 3.10" in cp.stderr
|
||||
|
||||
def test_import_allowed_is_silent(self):
|
||||
cp = subprocess.run(
|
||||
[sys.executable, "-c",
|
||||
f"import sys; sys.path.insert(0, {str(SCRIPTS)!r}); import _runtime_guard"],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
assert cp.returncode == 0
|
||||
assert cp.stderr == ""
|
||||
|
||||
def test_entry_script_intercepts_before_deep_error(self):
|
||||
"""chart_shot.py 在 3.9 下原本 def 期即 TypeError;接线后守卫先拦截,报错清晰。"""
|
||||
cp = run_with_fake_py39(
|
||||
f"sys.argv = ['chart_shot.py', 'x.html']; "
|
||||
f"runpy.run_path({str(SCRIPTS / 'chart_shot.py')!r}, run_name='__main__')")
|
||||
assert cp.returncode == 3
|
||||
assert "Python 3.10" in cp.stderr
|
||||
assert "TypeError" not in cp.stderr
|
||||
|
||||
|
||||
class TestEntryScriptsWired:
|
||||
def test_every_entry_script_imports_guard(self):
|
||||
for name in ENTRY_SCRIPTS:
|
||||
src = (SCRIPTS / name).read_text(encoding="utf-8")
|
||||
assert "import _runtime_guard" in src, f"{name} 未接入 _runtime_guard"
|
||||
+85
-9
@@ -19,7 +19,7 @@ import _memory_store as store
|
||||
|
||||
PILLARS = ["庚午", "辛巳", "己卯", "戊辰"]
|
||||
BIRTH = "1990-05-14 08:30"
|
||||
PLACE = "四川省成都市"
|
||||
PLACE = "四川省成都市武侯区"
|
||||
|
||||
git = pytest.mark.skipif(shutil.which("git") is None, reason="无 git")
|
||||
|
||||
@@ -31,11 +31,14 @@ def run(script, *args):
|
||||
capture_output=True, text=True, timeout=120, env=env)
|
||||
|
||||
|
||||
def make_archive(mem_root: Path, feedback: str | None = None) -> Path:
|
||||
"""造一份手工档案:四柱/大运/关注方向齐全,反馈小节可定制。"""
|
||||
def make_archive(mem_root: Path, feedback: str | None = None,
|
||||
events: str | None = None,
|
||||
pillars: list[str] | None = None) -> Path:
|
||||
"""造一份手工档案:四柱/大运/关注方向齐全,流年大事与反馈小节可定制。"""
|
||||
pillars = pillars or PILLARS
|
||||
meta = {"name": "张三", "gender": "男", "birth_clock": BIRTH,
|
||||
"birthplace": PLACE, "true_solar_time": "1990-05-14 08:23",
|
||||
"pillars": PILLARS, "day_master": "己",
|
||||
"pillars": pillars, "day_master": "己",
|
||||
"created_at": "2026-10-01 10:00", "updated_at": "2026-10-08 15:00"}
|
||||
body = (
|
||||
"# 档案:张三(男)\n\n"
|
||||
@@ -49,15 +52,19 @@ def make_archive(mem_root: Path, feedback: str | None = None) -> Path:
|
||||
"## 小运\n\n(无)\n\n"
|
||||
"## 主要关注方向\n\n- 婚姻与事业走向\n\n"
|
||||
"## 归纳\n\n- 性格:未断\n- 身体:未断\n- 事业:未断\n- 感情:未断\n- 婚姻:未断\n\n"
|
||||
"## 流年大事\n\n(未记录)\n\n"
|
||||
"## 反馈与澄清\n\n"
|
||||
"## 流年大事\n\n"
|
||||
+ (events if events is not None else
|
||||
"- 2018(戊戌):【已核实】升职,自成都赴北京工作。\n"
|
||||
"- 2021(辛丑):【已核实】张三搬家,新居在幸福小区3栋2单元501室,"
|
||||
"电话 13812345678。\n")
|
||||
+ "\n## 反馈与澄清\n\n"
|
||||
+ (feedback if feedback is not None else
|
||||
"- 【已核实】2018 年升职,跳槽到外企\n- 【未核实】2020 年感情波动\n")
|
||||
)
|
||||
arch_dir = mem_root / "archives"
|
||||
arch_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = arch_dir / store.archive_name(
|
||||
PILLARS, "男", store.key_hash(BIRTH, PLACE, "男"))
|
||||
pillars, "男", store.key_hash(BIRTH, PLACE, "男"))
|
||||
path.write_text(store.dump_frontmatter(meta) + body, encoding="utf-8")
|
||||
return path
|
||||
|
||||
@@ -77,11 +84,13 @@ class TestExport:
|
||||
text = out.read_text("utf-8")
|
||||
pkg = json.loads(text)
|
||||
|
||||
# 脱敏:不含姓名、出生分钟/钟表时间、省以下地域
|
||||
# 脱敏:不含姓名、出生分钟/钟表时间、区县及以下地域、直接定位信息
|
||||
assert "name" not in pkg
|
||||
assert "张三" not in text
|
||||
assert "08:30" not in text and "08:23" not in text
|
||||
assert "成都市" not in text
|
||||
assert "武侯区" not in text
|
||||
assert "幸福小区" not in text and "501" not in text
|
||||
assert "13812345678" not in text
|
||||
assert "birth_clock" not in pkg and "birthplace" not in pkg
|
||||
|
||||
# 结构字段
|
||||
@@ -90,17 +99,50 @@ class TestExport:
|
||||
assert pkg["pillars"] == PILLARS
|
||||
assert pkg["birth_date"] == "1990-05-14"
|
||||
assert pkg["birth_shichen"] == "辰"
|
||||
# 地域放宽到城市级:省+地级市保留
|
||||
assert pkg["region_province"] == "四川省"
|
||||
assert pkg["region_city"] == "成都市"
|
||||
assert [l["pillar"] for l in pkg["luck_pillars"]] == ["壬午", "癸未", "甲申"]
|
||||
assert pkg["luck_pillars"][0]["start_year"] == 1998
|
||||
assert pkg["focus_categories"] == ["婚姻", "事业"]
|
||||
assert {f["year"] for f in pkg["feedback"]} == {2018, 2020}
|
||||
assert {f["status"] for f in pkg["feedback"]} == {"已核实", "未核实"}
|
||||
assert all(f["category"] for f in pkg["feedback"])
|
||||
# 反馈带事件简介(状态标签已剥离)
|
||||
fb = {f["year"]: f for f in pkg["feedback"]}
|
||||
assert fb[2018]["note"] == "2018 年升职,跳槽到外企"
|
||||
assert fb[2020]["note"] == "2020 年感情波动"
|
||||
# 盘 hash = sha1(四柱+出生地) 前 8,文件名即 hash
|
||||
assert out.stem == pkg["chart_hash"]
|
||||
assert len(pkg["chart_hash"]) == 8
|
||||
|
||||
def test_events_carry_notes_with_city_kept(self, tmp_path, share_root):
|
||||
arch = make_archive(tmp_path / "memory")
|
||||
r = run(EXPORT, "--archive", str(arch), "--share-root", str(share_root))
|
||||
assert r.returncode == 0, r.stderr
|
||||
pkg = json.loads(Path(r.stdout.strip()).read_text("utf-8"))
|
||||
|
||||
ev = pkg["events"]
|
||||
assert len(ev) == 2
|
||||
# 目标格式:2018(戊戌):升职,自成都赴北京工作。——城市级地名保留
|
||||
assert ev[0]["year"] == 2018 and ev[0]["ganzhi"] == "戊戌"
|
||||
assert ev[0]["label"] == "戊戌"
|
||||
assert ev[0]["note"] == "升职,自成都赴北京工作。"
|
||||
assert ev[0]["status"] == "已核实" and ev[0]["category"] == "事业"
|
||||
# 姓名替换为「命主」,详细住址与电话删除,事件内容保留
|
||||
assert ev[1]["year"] == 2021 and ev[1]["ganzhi"] == "辛丑"
|
||||
assert "搬家" in ev[1]["note"] and "命主" in ev[1]["note"]
|
||||
assert "(已删)" in ev[1]["note"]
|
||||
|
||||
def test_events_missing_still_exports(self, tmp_path, share_root):
|
||||
# 流年大事为空不挡导出,events 如实为空
|
||||
arch = make_archive(tmp_path / "memory", events="(未记录)\n")
|
||||
r = run(EXPORT, "--archive", str(arch), "--share-root", str(share_root))
|
||||
assert r.returncode == 0, r.stderr
|
||||
pkg = json.loads(Path(r.stdout.strip()).read_text("utf-8"))
|
||||
assert pkg["events"] == []
|
||||
assert len(pkg["feedback"]) == 2
|
||||
|
||||
def test_lookup_by_birth_place_gender(self, tmp_path, share_root):
|
||||
make_archive(tmp_path / "memory")
|
||||
r = run(EXPORT, "--birth", BIRTH, "--place", PLACE, "--gender", "男",
|
||||
@@ -121,6 +163,20 @@ class TestExport:
|
||||
"--share-root", str(share_root))
|
||||
assert r.returncode == 2
|
||||
|
||||
def test_export_warns_orphan_pending_after_recast(self, tmp_path, share_root):
|
||||
# 先导出原盘,再导出同出生日同性别但时柱不同的换盘 → WARN 提示残留,不自动删
|
||||
arch = make_archive(tmp_path / "memory")
|
||||
r = run(EXPORT, "--archive", str(arch), "--share-root", str(share_root))
|
||||
assert r.returncode == 0, r.stderr
|
||||
arch2 = make_archive(tmp_path / "memory2",
|
||||
pillars=["庚午", "辛巳", "己卯", "己巳"])
|
||||
r2 = run(EXPORT, "--archive", str(arch2), "--share-root", str(share_root))
|
||||
assert r2.returncode == 0, r2.stderr
|
||||
assert r.stdout.strip() != r2.stdout.strip() # hash 确实变了
|
||||
assert "WARN" in r2.stderr and "残留" in r2.stderr
|
||||
assert r.stdout.strip().split("/")[-1] in r2.stderr # 列出了旧包文件名
|
||||
assert len(list((share_root / "pending").glob("*.json"))) == 2 # 不自动删
|
||||
|
||||
|
||||
@git
|
||||
class TestPush:
|
||||
@@ -190,6 +246,26 @@ class TestPush:
|
||||
assert pkg.is_file() # 失败保留 pending
|
||||
st = json.loads((skill / "data" / ".license.json").read_text("utf-8"))
|
||||
assert st["remaining_uses"] == 5 # 未计励
|
||||
# 失败诊断:带该远端 git 报错末行;GitHub 线附 HTTPS_PROXY 提示
|
||||
assert "FAIL" in r.stderr and "no-such-repo" in r.stderr
|
||||
assert "HTTPS_PROXY" in r.stderr
|
||||
|
||||
def test_push_partial_failure_warns_but_succeeds(self, skill, tmp_path):
|
||||
# github 坏、gitea 好:整体成功,但 stderr 带 github 线的失败摘要
|
||||
(skill / "share" / "config.json").write_text(json.dumps(
|
||||
{"github_remote": str(skill / "no-such-repo.git"),
|
||||
"gitea_remote": str(tmp_path / "gitea.git")}, ensure_ascii=False),
|
||||
"utf-8")
|
||||
pkg = skill / "share" / "pending" / "baddcafe.json"
|
||||
pkg.write_text(json.dumps({"schema": "share/v1",
|
||||
"chart_hash": "baddcafe"}), "utf-8")
|
||||
r = run(PUSH, "--root", str(skill))
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert (skill / "share" / "pushed" / "baddcafe.json").is_file()
|
||||
assert "WARN" in r.stderr and "github_remote" in r.stderr
|
||||
assert "no-such-repo" in r.stderr and "HTTPS_PROXY" in r.stderr
|
||||
st = json.loads((skill / "data" / ".license.json").read_text("utf-8"))
|
||||
assert st["remaining_uses"] == 15 # gitea 成功,正常计励
|
||||
|
||||
def test_no_remote_configured(self, tmp_path):
|
||||
root = tmp_path / "skill"
|
||||
|
||||
+66
-1
@@ -9,7 +9,16 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
|
||||
from chart.shensha import evaluate_natal, evaluate_target # noqa: E402
|
||||
from chart.ganzhi import GAN, ZHI # noqa: E402
|
||||
from chart.shensha import STAR_ORDER, evaluate_natal, evaluate_target # noqa: E402
|
||||
|
||||
_VENDOR = Path(__file__).resolve().parents[1] / "vendor"
|
||||
if _VENDOR.is_dir():
|
||||
sys.path.insert(0, str(_VENDOR))
|
||||
try: # 排盘适配层(含排序逻辑);无 tyme4py 的环境跳过其回归测试
|
||||
from chart.tyme_adapter import _shen_sha
|
||||
except ImportError: # pragma: no cover
|
||||
_shen_sha = None
|
||||
|
||||
|
||||
def natal(year, month, day, hour, gender="男"):
|
||||
@@ -267,5 +276,61 @@ class TargetQueryTest(unittest.TestCase):
|
||||
self.assertEqual(own, {"十恶大败"})
|
||||
|
||||
|
||||
def _jiazi60() -> list[str]:
|
||||
return [GAN[i % 10] + ZHI[i % 12] for i in range(60)]
|
||||
|
||||
|
||||
# 甲子…乙亥:12 个干支覆盖全部 10 天干与 12 地支(月、时柱扫面用)
|
||||
_SPREAD12 = _jiazi60()[:12]
|
||||
|
||||
|
||||
class StarOrderExhaustiveTest(unittest.TestCase):
|
||||
"""根治校验:求值器一切可能产出的神煞名都必须落在 STAR_ORDER 键集内。
|
||||
|
||||
3.6.0 事故:三奇贵人漏录排序表,排盘排序 KeyError。此处穷举求值器
|
||||
全部输入维度(年柱 60 × 月柱 12 全干支行 × 日柱 60 × 时柱 12 全干支行
|
||||
× 男女),收集实际产出的星名集合,断言与 STAR_ORDER 完全一致——
|
||||
新增神煞忘补排序表、或排序表残留死项,都会被本测试当场抓住。
|
||||
"""
|
||||
|
||||
def test_star_order_no_duplicates(self):
|
||||
self.assertEqual(len(STAR_ORDER), len(set(STAR_ORDER)))
|
||||
|
||||
def test_natal_outputs_exactly_star_order(self):
|
||||
produced: set[str] = set()
|
||||
for year in _jiazi60():
|
||||
for month in _SPREAD12:
|
||||
for day in _jiazi60():
|
||||
for hour in _SPREAD12:
|
||||
for gender in ("男", "女"):
|
||||
r = evaluate_natal(
|
||||
{"year": year, "month": month,
|
||||
"day": day, "hour": hour}, gender)
|
||||
for entries in r.values():
|
||||
produced.update(s for s, _ in entries)
|
||||
self.assertEqual(produced, set(STAR_ORDER))
|
||||
|
||||
def test_target_outputs_subset_of_star_order(self):
|
||||
"""岁运查询同理:年 60 × 月 4(四季各一)× 日 60 × 目标干支 60。"""
|
||||
produced: set[str] = set()
|
||||
for year in _jiazi60():
|
||||
for month in ("甲寅", "乙巳", "丙申", "丁亥"):
|
||||
for day in _jiazi60():
|
||||
p = {"year": year, "month": month, "day": day, "hour": "甲子"}
|
||||
for target in _jiazi60():
|
||||
r = evaluate_target(p, "男", target)
|
||||
produced.update(s for s, _ in r["anchored"])
|
||||
produced.update(s for s, _ in r["own"])
|
||||
missing = produced - set(STAR_ORDER)
|
||||
self.assertEqual(missing, set())
|
||||
|
||||
@unittest.skipIf(_shen_sha is None, "tyme4py 不可用")
|
||||
def test_sanqi_combo_survives_adapter_sort(self):
|
||||
"""回归:combo 组三奇贵人经 tyme_adapter._shen_sha 排序不再 KeyError。"""
|
||||
out = _shen_sha({"year": "甲辰", "month": "戊辰",
|
||||
"day": "庚子", "hour": "辛巳"}, "男")
|
||||
self.assertTrue(any(s.startswith("三奇贵人") for s in out["combo"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user