From a62f2a7d748ea1c4aa71265e51fe616949ff5aca Mon Sep 17 00:00:00 2001 From: moxiaonian Date: Fri, 9 Oct 2026 22:23:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20HTML=20=E7=9B=98=E9=9D=A2?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=EF=BC=88cast=5Fchart=20--html=EF=BC=8C?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=20scripts/render/templates/paipan.html?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SKILL.md | 14 ++ manifest.json | 7 +- scripts/cast_chart.py | 13 ++ scripts/render/html.py | 289 +++++++++++++++++++++++++++ scripts/render/templates/paipan.html | 121 +++++++++++ tests/test_html.py | 96 +++++++++ 6 files changed, 538 insertions(+), 2 deletions(-) create mode 100644 scripts/render/html.py create mode 100644 scripts/render/templates/paipan.html create mode 100644 tests/test_html.py diff --git a/SKILL.md b/SKILL.md index 94d3928..0a67210 100644 --- a/SKILL.md +++ b/SKILL.md @@ -18,6 +18,13 @@ python scripts/cast_chart.py --name 张三 --gender 男 \ 成功时命盘 Markdown 打印到 stdout,退出码 0。诊断信息只走 stderr。 +可选:加 `--html` 同步生成 HTML 盘面图(`--html 路径.html` 指定输出,省略路径则写 `./命盘_<姓名>.html`;生成路径打在 stderr): + +```bash +python scripts/cast_chart.py --name 张三 --gender 男 \ + --birth "1990-05-14 08:30" --place "四川省成都市" --html +``` + 另配岁运神煞查询工具(本命神煞已列于命盘 `## 神煞`,此工具查某流年/流月/大运/流日干支相对本命所逢之神煞): ```bash @@ -198,6 +205,13 @@ stdout 的 Markdown 依次包含以下章节。标题是解释规则的定位锚 - **更新提示(缓存制、不打扰排盘)**:每次排盘成功后,若本地缓存的检查结果已过期(24 小时)则后台线程拉一次远端 VERSION 刷新缓存(超时 3 秒、失败静默,主流程不等);缓存显示有新版本时,stderr 提示一行「技能有新版本 X.Y.Z,运行 `python scripts/self_update.py --apply` 可更新」。 - **应用更新(须用户同意)**:用户说「更新技能」时再运行 `python scripts/self_update.py --apply`——从 GitHub 拉取(失败转 Gitea),**先按 manifest.json 逐文件校验 SHA256、任一不符即中止不动本地**,通过后覆盖更新;`data/.license.json`、`memory/archives/`、`share/pending/`、`share/pushed/`、`.git` 保留不动。不得替用户自动执行 --apply。 +## 盘面图(HTML) + +排盘后同步生成 HTML 盘面图(`cast_chart.py --html`):四柱主表(当前大运与流年并列对照)、大运/小运条、全量流年、当年流月,五行配色、空亡标虚线、节气边界与晚子时出警示条。模板在 `scripts/render/templates/paipan.html`,样式可直接改。 + +- **给用户看图**:agent 环境具备浏览器/发图能力时,排盘后生成 HTML 并直接打开,或截图发送给用户;纯文本环境则告知文件路径。 +- HTML 只是 Markdown 命盘的图形化重渲染,内容以 stdout 命盘为准;解释时引用的字段名仍以 Markdown 章节为准。 + ## 文风与反 AI 腔(强制) 命理解读是「一个人对着一个人说话」,不是报告。以下写法一律禁止: diff --git a/manifest.json b/manifest.json index ae1093b..eaecac6 100644 --- a/manifest.json +++ b/manifest.json @@ -3,7 +3,7 @@ ".gitignore": "1e4671dee2d3123a45609ed82a4329531e0d41bf1b6895ab169ecd8813cfb8f5", "LICENSE.md": "6b839540e0fd4ab75530ec31fc5c3cbc3c2d5de5fe85068ef1c0901e54be9315", "README.md": "1e11e5d19856fbd9bb15068c74e184617a10b2e0b18c7e42d9b617cd0d8f534a", - "SKILL.md": "d3d6791d2278ee7cb6e8b50bc5758de2406f89ef629f23941632804b2dcec428", + "SKILL.md": "eaff2bc3cc1221417caaf40cf86741c7fe2e43f7538fd9efc0f436c13c030a0b", "VERSION": "76120c1f2c4311c2154c22a6b58e00e411e0ebe76afae0daf608d9196c61f114", "data/.public_edition.json": "e8692c45d512c78903fc89a924d0e6d4dc9b95a78ca6b359bb7a5ef5c946ffee", "data/cities.json": "00f17905c8843218162e25238a78aeb4c4a3bb5424b611cc30ed9e436db31854", @@ -570,7 +570,7 @@ "scripts/astro/equation_of_time.py": "ee9331022b4ed35d407664e97b5ce9ce99167a06ce7cb8c9ffba5497a437da8e", "scripts/astro/solar_time.py": "35b45edc365083a6ce90afe076176c6f3e798509e76d3f03819fd1845491b153", "scripts/astro/timezone_resolve.py": "3a287a64bd50736ce91ac15876e88edeb98745eb6d0459cf18e7e6ee3c026aba", - "scripts/cast_chart.py": "bc08b53ab50f7d9e51b4d7e84deb3c2660040c025dd03c578f7fd51aeba6f315", + "scripts/cast_chart.py": "16e5e9f0df1bcb0c0eef1febd1ae2395a84c1676e0c629058821056861daa05e", "scripts/chart/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "scripts/chart/luck.py": "d930f3f402cf814eaaa10fdcaa0a8be4a47411e6961dd0324ac38a8f898b4299", "scripts/chart/model.py": "8279145da10901cfb1a7b4823762074169dfd554bbe8ce39ae15862334b001f6", @@ -582,7 +582,9 @@ "scripts/memory_save.py": "42638841cceb586783b8764eacff91a8518d8df7dfe99b3919b3caa5a432e0ec", "scripts/query_shensha.py": "1d77205c8781676f3536bbeb2f36d397b825ff3065a4b968344fb95e5b8859dd", "scripts/render/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "scripts/render/html.py": "9896ffc7c1f29cdce0d2ce9c21ce78ec6035b77851249466776945de918de49d", "scripts/render/markdown.py": "4b2bcc3fc6581728d74bbaa357bf1db2e8aec1d3ec54c2c27ca645b6c5179977", + "scripts/render/templates/paipan.html": "9cdeed44834ef0de7adf0d775486b8ece7c81cf228107295e51bc31847a81688", "scripts/self_update.py": "95dc0302ca2b78d71b2893ebaced7e233dbc00944a5e3c5b06081b5c9a749f8a", "scripts/share_export.py": "21396143ad5cc2d244c7cc060a3ece25702594465689a9f12438dfe92de3296f", "scripts/share_push.py": "42683c27a93f12208c20b0a5cd2feb7cd81f9be3efb76b46d4789ad4d2aab4ca", @@ -594,6 +596,7 @@ "tests/test_day_boundary.py": "0e6c9ee32177a178f21a7812f83032abe4d1f585de48d07d21e2b432bdbcbd04", "tests/test_e2e.py": "e324930fad582dc38618fa3599425bb48ec718beaf426efcb76531911c9464fa", "tests/test_equation_of_time.py": "8a3a1baf7e61c42015405cc80fc0bdf4fd9bc732d440bc156f9c92c94e30f790", + "tests/test_html.py": "0972e938f37e7f2f70569cc3abbd9bf313a2610cd227537dcde6add010d46737", "tests/test_license_gate.py": "ee44226b75620b3965ba52cc2a0cc5404fbff37822fd96a6faf2aed89d07d47e", "tests/test_luck.py": "821f98bc19ab1e920353de58c6ffacac5d757c887c690a2d596f51c1485b21ea", "tests/test_memory.py": "d3decfdc8d7c5bbfc4af5e3aed93bb3c53ab09071b682a0f4808614ac2cd3c6f", diff --git a/scripts/cast_chart.py b/scripts/cast_chart.py index 72886ca..b28596c 100644 --- a/scripts/cast_chart.py +++ b/scripts/cast_chart.py @@ -37,6 +37,8 @@ def main() -> int: help='出生时刻(当地钟表时间),格式 "YYYY-MM-DD HH:MM",时刻必需') ap.add_argument("--place", required=True, help="出生地,精确到地级市一级,如「四川省成都市」") + ap.add_argument("--html", nargs="?", const="", default=None, + help="可选:同时生成 HTML 盘面图(给路径参数,或省略则写 ./命盘_<姓名>.html)") a = ap.parse_args() # 软授权锁:定制副本(含 data/.license.json)按授权次数/期限放行;母版无状态文件直接放行 @@ -130,6 +132,17 @@ def main() -> int: print(render(chart)) _gate.consume(Path(__file__).resolve().parents[1]) + # 可选:同步生成 HTML 盘面图(渲染失败不影响 stdout 契约) + if a.html is not None: + try: + from render.html import build_html + html_path = (Path(a.html) if a.html + else Path(f"命盘_{a.name}.html")) + html_path.write_text(build_html(chart), encoding="utf-8") + print(f"HTML 盘面:{html_path.resolve()}", file=sys.stderr) + except Exception: + print("WARN: HTML 盘面生成失败(命盘不受影响)", file=sys.stderr) + # 自更新提示:只读本地缓存往 stderr 打一行;缓存过期则后台线程刷新, # 主流程不等它(用当次缓存)。任何异常静默,绝不影响 stdout 命盘契约。 try: diff --git a/scripts/render/html.py b/scripts/render/html.py new file mode 100644 index 0000000..ebff5c9 --- /dev/null +++ b/scripts/render/html.py @@ -0,0 +1,289 @@ +# scripts/render/html.py +"""ChartData → 单文件 HTML 盘面(模板 render/templates/paipan.html)。 + +与 Markdown 输出同为纯计算渲染:十神/藏干/空亡/纳音/十二长生/月令旺衰 +均为固定查表,不含任何命理判断(P1)。 +""" +from datetime import datetime +from pathlib import Path + +from chart.luck import luck_label_for_year + +GAN = "甲乙丙丁戊己庚辛壬癸" +ZHI = "子丑寅卯辰巳午未申酉戌亥" + +_WX_GAN = {"甲": "木", "乙": "木", "丙": "火", "丁": "火", "戊": "土", + "己": "土", "庚": "金", "辛": "金", "壬": "水", "癸": "水"} +_WX_ZHI = {"子": "水", "丑": "土", "寅": "木", "卯": "木", "辰": "土", + "巳": "火", "午": "火", "未": "土", "申": "金", "酉": "金", + "戌": "土", "亥": "水"} +_YY = {"甲": 1, "丙": 1, "戊": 1, "庚": 1, "壬": 1, + "乙": -1, "丁": -1, "己": -1, "辛": -1, "癸": -1} +_SHENG = {"木": "火", "火": "土", "土": "金", "金": "水", "水": "木"} +_KE = {"木": "土", "土": "水", "水": "火", "火": "金", "金": "木"} + +_HIDDEN = {"子": ["癸"], "丑": ["己", "癸", "辛"], "寅": ["甲", "丙", "戊"], + "卯": ["乙"], "辰": ["戊", "乙", "癸"], "巳": ["丙", "戊", "庚"], + "午": ["丁", "己"], "未": ["己", "丁", "乙"], + "申": ["庚", "壬", "戊"], "酉": ["辛"], "戌": ["戊", "辛", "丁"], + "亥": ["壬", "甲"]} + +_NAYIN = {"甲子": "海中金", "乙丑": "海中金", "丙寅": "炉中火", "丁卯": "炉中火", + "戊辰": "大林木", "己巳": "大林木", "庚午": "路旁土", "辛未": "路旁土", + "壬申": "剑锋金", "癸酉": "剑锋金", "甲戌": "山头火", "乙亥": "山头火", + "丙子": "涧下水", "丁丑": "涧下水", "戊寅": "城头土", "己卯": "城头土", + "庚辰": "白蜡金", "辛巳": "白蜡金", "壬午": "杨柳木", "癸未": "杨柳木", + "甲申": "泉中水", "乙酉": "泉中水", "丙戌": "屋上土", "丁亥": "屋上土", + "戊子": "霹雳火", "己丑": "霹雳火", "庚寅": "松柏木", "辛卯": "松柏木", + "壬辰": "长流水", "癸巳": "长流水", "甲午": "沙中金", "乙未": "沙中金", + "丙申": "山下火", "丁酉": "山下火", "戊戌": "平地木", "己亥": "平地木", + "庚子": "壁上土", "辛丑": "壁上土", "壬寅": "金箔金", "癸卯": "金箔金", + "甲辰": "覆灯火", "乙巳": "覆灯火", "丙午": "天河水", "丁未": "天河水", + "戊申": "大驿土", "己酉": "大驿土", "庚戌": "钗钏金", "辛亥": "钗钏金", + "壬子": "桑柘木", "癸丑": "桑柘木", "甲寅": "大溪水", "乙卯": "大溪水", + "丙辰": "沙中土", "丁巳": "沙中土", "戊午": "天上火", "己未": "天上火", + "庚申": "石榴木", "辛酉": "石榴木", "壬戌": "大海水", "癸亥": "大海水"} + +_TERRAIN_ORDER = ["长生", "沐浴", "冠带", "临官", "帝旺", "衰", + "病", "死", "墓", "绝", "胎", "养"] +_TERRAIN_START = {"甲": "亥", "乙": "午", "丙": "寅", "戊": "寅", "庚": "巳", + "壬": "申", "丁": "酉", "己": "酉", "辛": "子", "癸": "卯"} + +_SEASON = {"寅": ("木", "火", "水", "金", "土"), "卯": ("木", "火", "水", "金", "土"), + "巳": ("火", "土", "木", "水", "金"), "午": ("火", "土", "木", "水", "金"), + "申": ("金", "水", "土", "火", "木"), "酉": ("金", "水", "土", "火", "木"), + "亥": ("水", "木", "金", "土", "火"), "子": ("水", "木", "金", "土", "火"), + "辰": ("土", "金", "火", "木", "水"), "戌": ("土", "金", "火", "木", "水"), + "丑": ("土", "金", "火", "木", "水"), "未": ("土", "金", "火", "木", "水")} +_SEASON_LABEL = ("旺", "相", "休", "囚", "死") + + +def _wx(ch: str) -> str: + wx = _WX_GAN.get(ch) or _WX_ZHI.get(ch) + return {"木": "wx-mu", "火": "wx-huo", "土": "wx-tu", + "金": "wx-jin", "水": "wx-shui"}.get(wx, "") + + +def _ten_god(me: str, other: str) -> str: + wx_m, wx_o = _WX_GAN[me], _WX_GAN[other] + same = _YY[me] == _YY[other] + if wx_m == wx_o: + return "比肩" if same else "劫财" + if _SHENG[wx_m] == wx_o: + return "食神" if same else "伤官" + if _KE[wx_m] == wx_o: + return "偏财" if same else "正财" + if _KE[wx_o] == wx_m: + return "七杀" if same else "正官" + return "偏印" if same else "正印" + + +def _terrain(me: str, zhi: str) -> str: + """日干对支的十二长生(阳顺阴逆)。""" + start = _ZHI_IDX(_TERRAIN_START[me]) + zi = _ZHI_IDX(zhi) + off = (zi - start) % 12 if _YY[me] == 1 else (start - zi) % 12 + return _TERRAIN_ORDER[off] + + +def _ZHI_IDX(z: str) -> int: + return ZHI.index(z) + + +def _gz_void(gz: str) -> str: + """一柱干支的旬空两支。""" + g, z = GAN.index(gz[0]), ZHI.index(gz[1]) + return ZHI[(z - g + 10) % 12] + ZHI[(z - g + 11) % 12] + + +def _z(zhi: str, voids: str) -> str: + cls = "voidz" if zhi in voids else "" + return f'{zhi}' + + +def _pillar_col(chart, key: str, label: str, cur: bool = False) -> dict: + """四柱之一 → 各行的 HTML 单元格内容。""" + p = getattr(chart, key) + voids = chart.void.get("day", "") + chart.void.get("hour", "") + return { + "label": label, "cur": cur, + "god": p.gan_ten_god or ("日主" if key == "day" else ""), + "gan": p.gan, "zhi": p.zhi, + "hidden": list(zip(p.hidden_stems, p.hidden_ten_gods)), + "terrain": chart.terrain.get(key, ""), + "void": _gz_void(p.gan + p.zhi), + "nayin": p.na_yin or "", + "shensha": chart.shen_sha.get(key, []), + "voids": voids, + } + + +def _extra_col(chart, gz: str, label: str, cur: bool) -> dict: + """流年/大运列(仅干支行星级信息,神煞留空)。""" + me = chart.day_master + voids = chart.void.get("day", "") + chart.void.get("hour", "") + return { + "label": label, "cur": cur, + "god": _ten_god(me, gz[0]), + "gan": gz[0], "zhi": gz[1], + "hidden": [(h, _ten_god(me, h)) for h in _HIDDEN[gz[1]]], + "terrain": _terrain(me, gz[1]), + "void": _gz_void(gz), + "nayin": _NAYIN[gz], + "shensha": [], + "voids": voids, + } + + +def _main_table(chart) -> str: + this_year = datetime.now().year + ly = next(((y, gz) for y, gz, _ in chart.flow_years if y == this_year), None) + cur_luck = None + for lp in chart.luck_pillars: + if lp.start_year <= this_year: + cur_luck = lp + cols = [] + if ly: + cols.append(_extra_col(chart, ly[1], f"流年 {ly[0]}", cur=True)) + if cur_luck: + cols.append(_extra_col(chart, cur_luck.gan + cur_luck.zhi, "大运", cur=True)) + for key, label in (("year", "年柱"), ("month", "月柱"), + ("day", "日柱"), ("hour", "时柱")): + cols.append(_pillar_col(chart, key, label, cur=(key == "day"))) + + def th(c): + tag = '当前' if c["cur"] else "" + return f'{c["label"]}{tag}' + + def row(label, fn, cls=""): + tds = "".join( + f'{fn(c)}' for c in cols) + return f'{label}{tds}' + + rows = [ + "" + '' + + "".join(th(c) for c in cols) + "", + row("主星", lambda c: f'{c["god"]}'), + row("天干", lambda c: f'{c["gan"]}'), + row("地支", lambda c: f'{_z(c["zhi"], c["voids"])}'), + row("藏干", lambda c: '"), + row("星运", lambda c: f'{c["terrain"]}'), + row("空亡", lambda c: f'{c["void"]}'), + row("纳音", lambda c: f'{c["nayin"]}'), + row("神煞", lambda c: '
' + + ("
".join(c["shensha"]) if c["shensha"] else "—") + "
"), + ] + return '' + "".join(rows) + "
" + + +def _luck_strip(chart) -> str: + this_year = datetime.now().year + cells = [] + for lp in chart.luck_pillars: + gz = lp.gan + lp.zhi + cur = lp.start_year <= this_year < lp.start_year + 10 + cells.append( + f'
' + f'
{lp.start_year} · {lp.start_age:.0f} 岁起
' + f'
{lp.gan}' + f'{lp.zhi}
' + f'
{_ten_god(chart.day_master, lp.gan)}' + f' · {_terrain(chart.day_master, lp.zhi)}
') + return '
' + "".join(cells) + "
" + + +def _childhood_block(chart) -> str: + if not chart.childhood_luck: + return "" + cells = "".join( + f'
{age} 岁 · {year}
' + f'
{gz[0]}' + f'{gz[1]}
' + for age, year, gz in chart.childhood_luck) + return ('
小运' + '童限(未交大运)各虚岁
' + f'
{cells}
') + + +def _flow_years(chart) -> str: + this_year = datetime.now().year + cells = [] + for year, gz, label in chart.flow_years: + cls = "cell" + if year == this_year: + cls += " cur" + if label.startswith("交运年"): + cls += " jiao" + cells.append(f'
{year}
' + f'
{gz[0]}' + f'{gz[1]}
') + return '
' + "".join(cells) + "
" + + +def _flow_months(chart) -> str: + cells = [] + for label, gz in chart.flow_months: + cells.append(f'
{label}
' + f'
{gz[0]}' + f'{gz[1]}
') + return '
' + "".join(cells) + "
" + + +def _season_bar(chart) -> str: + seq = _SEASON.get(chart.month_ling) + if not seq: + return "" + parts = " ".join(f"{wx}{lab}" for wx, lab in zip(seq, _SEASON_LABEL)) + return f"月令 {chart.month_ling}:{parts}" + + +def build_html(chart) -> str: + """ChartData → 完整 HTML 文档。""" + tpl = (Path(__file__).resolve().parent + / "templates" / "paipan.html").read_text(encoding="utf-8") + + warns = [] + if chart.is_late_zi: + warns.append("⚠️ 晚子时:本造按子初换日归入次日;子正换日一派日柱、时柱不同。") + if chart.jie_gap_warning: + warns.append(chart.jie_gap_warning) + warnings = "".join(f'
{w}
' for w in warns) + + qiyun = "" + if chart.luck_pillars: + qiyun = (f"起运:{chart.start_age_text}" + f"({'顺行' if chart.luck_is_forward else '逆行'})") + + audit = (f"时区 {chart.tz_used}(夏令时:{'是' if chart.is_dst else '否'})· " + f"经度修正 {chart.lon_correction_min:+.2f} 分 · 均时差 {chart.eot_minutes:+.2f} 分 · " + f"日界 子初换日(23:00)· tyme4py {chart.library_version} · " + f"schema {chart.schema_version}") + + mapping = { + "TITLE": f"命盘:{chart.name}", + "NAME": chart.name, + "GENDER_LABEL": "乾造" if chart.gender == "男" else "坤造", + "DAY_MASTER": f"{chart.day_master}{_WX_GAN[chart.day_master]}", + "BIRTHPLACE": chart.birthplace, + "BIRTH_CLOCK": chart.birth_clock, + "TRUE_SOLAR": chart.true_solar_time, + "MONTH_LING": chart.month_ling, + "TAI_YUAN": chart.tai_yuan, + "MING_GONG": chart.ming_gong, + "QIYUN": qiyun, + "WARNINGS": warnings, + "MAIN_TABLE": _main_table(chart), + "LUCK_NOTE": f"{chart.start_age_text}起运,当前一步高亮", + "LUCK_STRIP": _luck_strip(chart), + "CHILDHOOD_BLOCK": _childhood_block(chart), + "FLOW_YEARS": _flow_years(chart), + "FLOW_MONTHS": _flow_months(chart), + "SEASON_BAR": _season_bar(chart), + "AUDIT": audit, + } + out = tpl + for k, v in mapping.items(): + out = out.replace("{{" + k + "}}", str(v)) + return out diff --git a/scripts/render/templates/paipan.html b/scripts/render/templates/paipan.html new file mode 100644 index 0000000..1bacc43 --- /dev/null +++ b/scripts/render/templates/paipan.html @@ -0,0 +1,121 @@ + + + + + +{{TITLE}} + + + +
+ +
+
+ {{NAME}} + {{GENDER_LABEL}} + 日主 {{DAY_MASTER}} +
+
+ 出生地:{{BIRTHPLACE}} 钟表时间:{{BIRTH_CLOCK}} 真太阳时:{{TRUE_SOLAR}}
+ 月令:{{MONTH_LING}} 胎元:{{TAI_YUAN}} 命宫:{{MING_GONG}} {{QIYUN}} +
+ {{WARNINGS}} +
+ +
+
排盘四柱 · 藏干 · 空亡 · 纳音 · 神煞(当前大运与流年并列对照)
+ {{MAIN_TABLE}} +
+ +
+
大运{{LUCK_NOTE}}
+ {{LUCK_STRIP}} + {{CHILDHOOD_BLOCK}} +
+ +
+
流年出生年至今后十年,今年高亮,红字年份为交运年
+ {{FLOW_YEARS}} +
+ +
+
流月本年节气月
+ {{FLOW_MONTHS}} +
+ +
+ {{SEASON_BAR}}
+ {{AUDIT}} +
灵爻妙解 · 传承易学智慧 · 本盘面由 AI 生成,仅供娱乐参考
+
+ +
+ + diff --git a/tests/test_html.py b/tests/test_html.py new file mode 100644 index 0000000..41ffef8 --- /dev/null +++ b/tests/test_html.py @@ -0,0 +1,96 @@ +"""HTML 盘面渲染测试(render/html.py + cast_chart --html)。""" +import re +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +SKILL_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SKILL_ROOT / "scripts")) + + +def _chart(): + """用真实排盘管线造一份 ChartData(成都 1990 男盘)。""" + from datetime import datetime + from astro.solar_time import to_true_solar_time + from astro.timezone_resolve import to_utc + from astro.day_boundary import resolve_calendar_day + from chart.tyme_adapter import cast + from chart.luck import (childhood_luck, compute_luck, decade_pillars, + flow_months, flow_years, is_forward, + luck_label_for_year) + from chart.model import build_chart_data + from geo.cities import resolve_location + + loc = resolve_location("四川省成都市") + clock = datetime(1990, 5, 14, 8, 30) + utc, tz_audit = to_utc(clock, loc) + tst, st_audit = to_true_solar_time(utc, loc.lon) + day, bd_audit = resolve_calendar_day(tst) + cast_dt = day.replace(hour=tst.hour, minute=tst.minute, second=tst.second) + raw = cast(cast_dt, "男") + forward = is_forward("男", raw["year"]["gan"]) + luck = compute_luck(birth_tst=tst, prev_jie_time=raw["prev_jie"]["time"], + next_jie_time=raw["next_jie"]["time"], forward=forward) + this_year = datetime.now().year + decade = decade_pillars(raw["month"]["gan"], raw["month"]["zhi"], forward, + luck["start_age_years"], cast_dt.year) + years = [(y, gz, luck_label_for_year(y, decade)) + for y, gz in flow_years(cast_dt.year, this_year, 10)] + return build_chart_data( + name="张三", gender="男", place="四川省成都市", + clock="1990-05-14 08:30", loc=loc, tz_audit=tz_audit, + st_audit=st_audit, bd_audit=bd_audit, raw=raw, luck=luck, + luck_pillars=decade, flow_years=years, + flow_months=flow_months(this_year), + childhood_luck=childhood_luck(raw["hour"]["gan"], raw["hour"]["zhi"], + forward, luck["start_age_years"], + cast_dt.year), + ) + + +class HtmlRenderTest(unittest.TestCase): + def test_render_complete(self): + from render.html import build_html + html = build_html(_chart()) + self.assertFalse(re.findall(r"\{\{[A-Z_]+\}\}", html), "存在未填充占位符") + for ch in ("庚", "午", "辛", "巳", "己", "卯", "丁"): + self.assertIn(ch, html) + for sect in ("排盘", "大运", "流年", "流月", "空亡", "神煞", "纳音"): + self.assertIn(sect, html) + + 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("庚", "壬"), "食神") + + 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("庚", "子"), "死") + + +class CastHtmlE2ETest(unittest.TestCase): + def test_html_flag_writes_file(self): + with tempfile.TemporaryDirectory() as td: + out = 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(out)], + 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("HTML 盘面:", r.stderr) + html = out.read_text(encoding="utf-8") + self.assertIn("