fix(qizheng): pass school options into the vendored chart and stabilize gates
Independent Staging Quality Gate / validate (push) Failing after 8m1s
Independent Staging Quality Gate / publish (push) Skipped

BUG-710: call getSevenGovernorsChart with ketuMode/siderealMode so
calculation.ketu_mode is the engine school, not the request echo.
BUG-711: stop forbidding /ephemeris in the sidebar contract.
BUG-712: compare ephemeris event longitudes at 6 decimals and fail
closed when the golden is missing.
This commit is contained in:
jesse-ux
2026-09-15 17:54:20 +08:00
parent 6bb892b826
commit a38a941522
12 changed files with 314 additions and 31 deletions
+6
View File
@@ -1,5 +1,11 @@
# BLOCKED
## 只读页验收修复:镜像与浏览器(2026-09-16,分支 `codex/readonly-pages-fix-20260916`
- **无 Docker** 无法验证 API 镜像里 `COPY vendor` 与容器内 `node --version`。不得写成通过。
- **无登录态 / 无 Chrome** `/chart``/ephemeris` 浏览器走查仍留给 `docs/testing/`
- **staging 部署:** 推送前 `/api/health``deployment.gitCommit` 仍可能停在 `2d7698ea`。本单修的是门禁红测试,部署是否追上以推送后的 health 为准,未追上不得写成已部署。
## 七政原生排盘:镜像构建与上游 checkout2026-09-15,分支 `codex/qizheng-native-chart-20260915`
- **无 Docker** 本机 `docker` 不在 PATH。无法构建 `deploy/railway-api.Dockerfile`,也无法在容器内跑 `node --version` 或断言 `Path('/app/vendor/stem-branch/dist/cli.cjs').exists()`。**不得把镜像构建或容器内验证写成通过。** Dockerfile 已按任务加上 `nodejs` 运行时与 `COPY vendor ./vendor`,待有 Docker 的环境验证。
+48
View File
@@ -11029,3 +11029,51 @@
- 相关记录:BUG-706、BUG-686
- 复发自:无
- 修复版本:待发布
## BUG-710 | 七政 `ketu_mode` / `sidereal_mode` 收了请求却不传给引擎
- 状态:resolved
- 首次发现:2026-09-15
- 最近更新:2026-09-16
- 影响面:`scripts/qizheng_chart_engine.py``POST /api/qizheng`、星盘页七政 Tab 的计都派别文案
- 用户现象:请求 `ketu_mode: "descending-node"` 仍得到月孛派计都位置,响应 `calculation.ketu_mode` 却写成 descending-node,没有 warning。
- 触发条件:`POST /api/qizheng` 带非默认 `ketu_mode``sidereal_mode`
- 根因:vendored CLI 的 `getSevenGovernorsChart(date, location)` 不传 options。适配器只调 CLI 旗标,旗标里没有派别。`_normalize` 把请求值写进 `calculation.ketu_mode`,把 CLI 实际值另放 `engine_ketu_mode`
- 修复:走任务书方案 A。`scripts/qizheng_seven_governors.js` 在内存里取出库函数 `getSevenGovernorsChart` 并传入 `ketuMode` / `siderealMode``calculation.ketu_mode``engine_ketu_mode` 都只表示实际生效派别。前端 `chart-view-mapper``calculation.engine_ketu_mode`
- 验证:`tests/test_qizheng_chart_engine.py` 新增非默认派别 / 岁差用例;`frontend/tests/chart-view-route.test.ts` 锁 mapper 用引擎字段。
- 防复发:成功响应里 `calculation.ketu_mode` 必须等于 `engine_ketu_mode`。不得再把请求值回写成已生效派别。
- 相关记录:BUG-701、TASK-qizheng-native-chart-20260915、TASK-readonly-pages-fix-20260916
- 复发自:BUG-701(当时只把默认值暴露成参数,没有把参数送进引擎)
- 修复版本:待发布
## BUG-711 | 星历页合同断言禁止 sidebar 出现 ephemeris,与星盘页入口冲突
- 状态:resolved
- 首次发现:2026-09-15
- 最近更新:2026-09-16
- 影响面:`frontend/tests/ephemeris-page.test.tsx``frontend/src/components/app-sidebar.tsx`
- 用户现象:两份只读页单合入后 `npx tsx --test tests/ephemeris-page.test.tsx` 1 红,staging 门禁红。
- 触发条件:chart-page 单按任务书在 sidebar 同时加「星盘」「星历」入口;星历单合同断言 `doesNotMatch(app-sidebar.tsx, /ephemeris/)`
- 根因:星历单把「本单不改 sidebar」写成了「sidebar 里不得出现 ephemeris」。前者是文件归属,后者不是产品事实。
- 修复:删掉对 sidebar 的禁止断言,改为只约束本单文件归属(独立 `/ephemeris` route、不碰 `page.tsx`)。**不删** sidebar 星历入口。
- 验证:`npx tsx --test tests/ephemeris-page.test.tsx` 全绿。
- 防复发:文件归属断言不得写成产品入口禁止。改既有断言必须写原值 / 新值 / 原因。
- 相关记录:TASK-chart-page-20260915、TASK-ephemeris-page-20260915、TASK-readonly-pages-fix-20260916
- 复发自:无
- 修复版本:待发布
## BUG-712 | `ephemeris_events` golden 存全精度浮点且缺失时自动重建
- 状态:resolved
- 首次发现:2026-09-15
- 最近更新:2026-09-16
- 影响面:`tests/test_ephemeris_events.py``tests/golden/ephemeris_events_raman_20260915_90d.json`
- 用户现象:同一窗口、同一岁差,事件种类 / 日期 / 星体 / 星座一致,只有 `speed_longitude` 尾数差一位,验收机红。
- 触发条件:golden 在 Windows / Anaconda 3.11.7 生成,Linux / Python 3.13 / 仓库 `.venv``assert result["events"] == golden`
- 根因:全精度浮点整体相等跨 pyswisseph / 星历数据会抖。golden 不存在时测试自己写出文件,等于没有 golden。
- 修复:`longitude` / `speed_longitude` 量化到 6 位再比;`kind` / `date` / `body` / 星座字段保持严格相等。缺失 golden 失败,并提示用 `scripts/generate_ephemeris_events_golden.py` 重建。
- 验证:`pytest tests/test_ephemeris_events.py`;缺失 golden 的定向用例确认不会写文件。
- 防复发:不得对全精度浮点做整体 `==`。测试不得写 golden。
- 相关记录:TASK-ephemeris-page-20260915、TASK-readonly-pages-fix-20260916
- 复发自:无
- 修复版本:待发布
@@ -0,0 +1,49 @@
# PROGRESS · 三份只读页单验收修复(2026-09-16)
- 执行分支:`codex/readonly-pages-fix-20260916`
- 工作树:`.worktrees/readonly-pages-fix-20260916`
- 基线:`origin/staging` @ `6bb892b8`(含 `fadb64fb` 任务书与 `page.tsx` 1951 冻结修复)
## 方案选择
**BUG-710 走任务书方案 A。** vendored `cli.cjs` 里的库函数 `getSevenGovernorsChart(date, location, options)` 接受 `ketuMode` / `siderealMode`CLI `main()` 调用时不传 options。本单用 `scripts/qizheng_seven_governors.js` 在内存编译该 bundle、取出库函数并传入 options,不改 `vendor/**` 字节、不加 CLI 旗标。
## 做了什么
- **BUG-710:** 请求派别真正进入引擎。`calculation.ketu_mode` / `sidereal_mode` 只写实际生效值。mapper 读 `calculation.engine_ketu_mode`
- **BUG-711** 星历合同不再禁止 sidebar 出现 `ephemeris`。侧栏「星历」入口保留。
- **BUG-712** 浮点量化到 6 位;golden 缺失失败,不自动重建。
## 测试
| 项 | 结果 |
| --- | --- |
| `node --version` | v22.23.2 |
| `pytest tests/test_qizheng_chart_engine.py tests/test_ephemeris_events.py tests/test_qizheng_api_productization.py` | **21 passed** |
| `pytest tests/test_api_server_growth_contract.py` | 通过(未改 `jyotish_api_server.py` |
| `npx tsx --test tests/ephemeris-page.test.tsx` | **6/6** |
| `npx tsx --test tests/chart-view-route.test.ts` | **8/8**(含冻结上限与 mapper 引擎字段) |
### 既有断言变更(AGENTS.md §7.3
`frontend/tests/ephemeris-page.test.tsx`「the ephemeris page is its own route and never grows page.tsx」:
| | |
| --- | --- |
| 原值 | `assert.doesNotMatch(app-sidebar.tsx, /ephemeris/)` |
| 新值 | 不读 sidebar;本测试文件不得 import `app-sidebar` |
| 原因 | chart-page 单按任务书在 sidebar 加了 `/ephemeris` 入口;文件归属不是产品入口禁止(BUG-711) |
`tests/test_qizheng_chart_engine.py::test_ketu_mode_request_is_echoed_and_engine_mode_is_visible`
| | |
| --- | --- |
| 原值 | 请求 descending-node 时 `ketu_mode == descending-node``engine_ketu_mode == apogee` |
| 新值 | 请求 descending-node 时两者都是 descending-node,且计都位置相对默认派别改变 |
| 原因 | 原断言把 BUG-710 的错误行为锁成合同 |
未改 `jyotish_api_server.py` 行数契约。未删 sidebar 星历入口。未放宽 `dignities` / boundary。
## 合入
`git push origin HEAD:staging`,核对远端 SHA。部署是否追上见推送后的 `/api/health`
+4 -4
View File
@@ -223,10 +223,10 @@
| `TASK-rectification-house-lord-gochara-research-20260913.md` | `PROGRESS-rectification-house-lord-gochara-research-20260913.md` | 研究单:宫主触发与木星/土星过运(合冲本命宫主、罗睺紧密合、年精度、用于 block 选上升)四种放宽,20 例公开 AA 离线量 block 层与 minute 层两组指标;引擎里已有宫主/功能吉凶/受控过运,只量缺的四条 | 待验收(无收益,关闭;不立实现单) | `codex/rectification-house-lord-gochara-research-20260913` |
| `TASK-qizheng-native-chart-20260915.md` | `PROGRESS-qizheng-native-chart-20260915.md` | **后端单(独占 `scripts/jyotish_api_server.py`**vendored `stem-branch` 0.8.0Apache-2.0)归档 + API 镜像 Node runtime + 七政适配器(计都派别与宿度坐标系参数化、boundary 按实测重写)+ 三个只读端点 `/api/qizheng`、`/api/western`、`/api/ephemeris_events`。实证三条:四柱时柱按 UTC 算(BUG-700,本轮不修不调用)、`ketuMode` 写死未暴露(BUG-701)、boundary 把空神煞与未闭合庙旺说成已生成(BUG-702)。BUG 段 700703 | 已推 `origin/staging` `e776cf9d` | 已验收(带修复单) |
| `TASK-chart-page-20260915.md` | `PROGRESS-chart-page-20260915.md` | **前端单(独占 `app-sidebar.tsx`,同时加星盘与星历两个入口)**:P0 只读星盘页,五个 Tab(星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余),中宫排盘参数卡,三套坐标系各自标注且禁止互相换算。不扣点不调模型不出 spinner;不碰 `page.tsx`1951/2000);**不搬 `vedic-chart-svg.tsx`**rectification-board 也在用)。任务书预占 704–706,Bug 历史未写入;校正 P0 落地占用了 704–706 / 708–709 | 已验收(带修复单) | `codex/chart-page-20260915` |
| `TASK-ephemeris-page-20260915.md` | `PROGRESS-ephemeris-page-20260915.md` | **前端单**:P1 星历页,今日五要素 + 当日行运(相对本命宫位)+ 未来九十天换座与停滞,底部「带这天去提问」出口。页面不得出现任何运势判断。含实证缺陷:panchanga 写死 Lahiri 与账户 Raman 分裂(关联 BUG-703;本单标注为 BUG-707)。侧边栏入口由 chart-page 单交付。BUG 段 707709 | 已验收(带修复单) | `codex/ephemeris-page-20260915` |
| `TASK-readonly-pages-fix-20260916.md` | `PROGRESS-readonly-pages-fix-20260916.md` | 三份只读页单的验收修复:**BUG-710** 七政 `ketu_mode`/`sidereal_mode` 收了请求却从不传给引擎,`calculation.ketu_mode` 回写请求值而非实际值(实测请求 descending-node 仍返回 apogee 盘,无警告);**BUG-711** 星历单断言 sidebar 不得含 `/ephemeris`,与星盘单按任务书添加的入口直接冲突,staging 现在是红的;**BUG-712** `ephemeris_events` golden 存全精度浮点跨机不稳,且 golden 缺失时自动重建。另附部署缺口:`deployment.gitCommit` 仍是 `2d7698ea`。BUG 段 710+ | 待领取 | |
| `TASK-qizheng-native-chart-20260915.md` | `PROGRESS-qizheng-native-chart-20260915.md` | **后端单(独占 `scripts/jyotish_api_server.py`**vendored `stem-branch` 0.8.0Apache-2.0)归档 + API 镜像 Node runtime + 七政适配器(计都派别与宿度坐标系参数化、boundary 按实测重写)+ 三个只读端点 `/api/qizheng`、`/api/western`、`/api/ephemeris_events`。实证三条:四柱时柱按 UTC 算(BUG-700,本轮不修不调用)、`ketuMode` 写死未暴露(BUG-701)、boundary 把空神煞与未闭合庙旺说成已生成(BUG-702)。BUG 段 700703 | 已验收(带修复单) | `e776cf9d` / `f7386e07` |
| `TASK-chart-page-20260915.md` | `PROGRESS-chart-page-20260915.md` | **前端单(独占 `app-sidebar.tsx`,同时加星盘与星历两个入口)**:P0 只读星盘页,五个 Tab(星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余),中宫排盘参数卡,三套坐标系各自标注且禁止互相换算。不扣点不调模型不出 spinner;不碰 `page.tsx`1951/2000);**不搬 `vedic-chart-svg.tsx`**rectification-board 也在用)。任务书预占 704–706,Bug 历史未写入;校正 P0 落地占用了 704–706 / 708–709 | 已验收(带修复单) | `830799fa` |
| `TASK-ephemeris-page-20260915.md` | `PROGRESS-ephemeris-page-20260915.md` | **前端单**:P1 星历页,今日五要素 + 当日行运(相对本命宫位)+ 未来九十天换座与停滞,底部「带这天去提问」出口。页面不得出现任何运势判断。含实证缺陷:panchanga 写死 Lahiri 与账户 Raman 分裂(关联 BUG-703;本单标注为 BUG-707)。侧边栏入口由 chart-page 单交付。BUG 段 707709 | 已验收(带修复单) | `d3a2c48b` |
| `TASK-readonly-pages-fix-20260916.md` | `PROGRESS-readonly-pages-fix-20260916.md` | 三份只读页单的验收修复:**BUG-710** 七政 `ketu_mode`/`sidereal_mode` 收了请求却从不传给引擎,`calculation.ketu_mode` 回写请求值而非实际值(实测请求 descending-node 仍返回 apogee 盘,无警告);**BUG-711** 星历单断言 sidebar 不得含 `/ephemeris`,与星盘单按任务书添加的入口直接冲突,staging 现在是红的;**BUG-712** `ephemeris_events` golden 存全精度浮点跨机不稳,且 golden 缺失时自动重建。另附部署缺口:`deployment.gitCommit` 仍是 `2d7698ea`。BUG 段 710+ | 待验收 | `codex/readonly-pages-fix-20260916` |
| `TASK-api-server-decomposition-20260916.md` | `PROGRESS-api-server-decomposition-20260916.md` | **重构单(串行在 qizheng 单之后)**:把业务逻辑搬出 `JyotishAPIHandler`。核心不是行数,是全仓 3 处靠 `JyotishAPIHandler.__new__` 伪造空壳 handler 借方法(`consultation_workflow_service` ×2、`capture_report_blocked_repairs_golden`、`local_accuracy_report`,MCP 也走这条),依赖方向反了、handler 没有 `headers`/`wfile` 随时可炸。四阶段:拆 `__new__` 后门 → 抽 ≥150 行业务方法 → `do_POST`/`do_GET` 改路由表 → 重新冻结行数 baseline(余量 300→50)。纯搬运不改行为,`test_api_server_security.py` 3841 行断言一条不许改。预计 11,314 → 约 9,230 行。BUG 段 710+ | 待领取 | — |
## 命名与归档
+4 -1
View File
@@ -599,7 +599,10 @@ function mapQizheng(raw: Record<string, unknown> | null): ChartViewQizhengOk | C
return {
status: "ok",
coordinateSystem: text(raw.coordinate_system) ?? "qizheng_mansion_degrees_from_jiao",
ketuMode: text(raw.ketu_mode) ?? text(raw.ketuMode) ?? "unspecified",
ketuMode: text(record(raw.calculation)?.engine_ketu_mode)
?? text(record(raw.chart)?.ketu_mode)
?? text(raw.ketuMode)
?? "unspecified",
boundary: COORDINATE_BOUNDARY.qizheng,
palaces,
bodies,
+20
View File
@@ -135,6 +135,26 @@ test("a real western engine packet lights the tropical tab without mixing sidere
assert.equal(body.qizheng.status, "unavailable");
});
test("qizheng ketuMode follows the engine field, not a request echo", async () => {
const qizheng = {
coordinate_system: "qizheng_mansion_degrees_from_jiao",
calculation: { ketu_mode: "descending-node", engine_ketu_mode: "apogee" },
palaces: [{ name: "午", branch: "午", lifePalace: "命宫", mansions: ["星"], occupants: [] }],
bodies: { sun: { mansion: "奎", siderealLon: 177.94, palace: "亥宮", dignity: "陷" } },
};
const { postEngine } = engine({ "/api/qizheng": qizheng });
const result = await assembleChartView({
userId: "user-1",
profile,
postEngine,
asOf: "2026-09-15",
});
const body = chartViewOkSchema.parse(result.body);
assert.equal(body.qizheng.status, "ok");
if (body.qizheng.status !== "ok") throw new Error("expected qizheng ok");
assert.equal(body.qizheng.ketuMode, "apogee");
});
test("the golden chart-view envelope stays inside the page contract", () => {
chartViewResponseSchema.parse({
status: "birth_profile_incomplete",
+5 -1
View File
@@ -67,7 +67,11 @@ function render(payload: EphemerisOkResponse | null, date = payload?.date ?? "20
test("the ephemeris page is its own route and never grows page.tsx", () => {
assert.match(routePage, /from "@\/components\/ephemeris\/ephemeris-page"/);
assert.doesNotMatch(readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"), /ephemeris-page/);
assert.doesNotMatch(readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8"), /ephemeris/);
// 原值: assert.doesNotMatch(app-sidebar.tsx, /ephemeris/)
// 新值: 本单文件归属只锁 page.tsx / 本 route,不再锁 sidebar
// 原因: TASK-chart-page-20260915 任务 3 要求 sidebar 同时加「星盘」「星历」入口;文件归属「本单不改 sidebar」不是产品事实「sidebar 不得出现 ephemeris」(BUG-711
const thisTest = readFileSync(new URL(import.meta.url), "utf8");
assert.doesNotMatch(thisTest, /from "@\/components\/app-sidebar"/);
});
test("date switching replaces panchanga and transits", () => {
@@ -0,0 +1,31 @@
"""Rebuild tests/golden/ephemeris_events_raman_20260915_90d.json from a live scan.
The test suite will not write this file. Run this script only when the
event list (kind / date / body / signs) has genuinely changed.
"""
from __future__ import annotations
import json
from pathlib import Path
from scripts.ephemeris_events import build_ephemeris_events
ROOT = Path(__file__).resolve().parents[1]
GOLDEN = ROOT / "tests" / "golden" / "ephemeris_events_raman_20260915_90d.json"
WINDOW = {
"start_date": "2026-09-15",
"end_date": "2026-12-14",
"ayanamsa": "raman",
"node_mode": "mean",
}
def main() -> None:
result = build_ephemeris_events(WINDOW)
GOLDEN.write_text(json.dumps(result["events"], ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"wrote {GOLDEN} ({len(result['events'])} events)")
if __name__ == "__main__":
main()
+35 -14
View File
@@ -169,21 +169,34 @@ def _node_bin() -> str:
return found
def _run_cli(iso: str, lat: float, lon: float, timeout_seconds: int) -> dict[str, Any]:
EVAL_HELPER = REPO_ROOT / "scripts" / "qizheng_seven_governors.js"
def _run_cli(
iso: str,
lat: float,
lon: float,
timeout_seconds: int,
*,
ketu_mode: str,
sidereal_mode: dict[str, Any],
) -> dict[str, Any]:
command = [
_node_bin(),
str(VENDORED_CLI),
"--date",
iso,
"--lat",
str(lat),
"--lng",
str(lon),
"--seven-governors",
"--json",
str(EVAL_HELPER),
]
if FORBIDDEN_CLI_FLAGS.intersection(command):
raise QizhengChartError("forbidden engine subcommand blocked", error_code="ERR_QIZHENG_ENGINE")
payload = json.dumps(
{
"date": iso,
"lat": lat,
"lng": lon,
"ketuMode": ketu_mode,
"siderealMode": sidereal_mode,
},
ensure_ascii=False,
)
try:
completed = subprocess.run(
command,
@@ -192,6 +205,7 @@ def _run_cli(iso: str, lat: float, lon: float, timeout_seconds: int) -> dict[str
capture_output=True,
text=True,
encoding="utf-8",
input=payload,
timeout=timeout_seconds,
)
except FileNotFoundError as exc:
@@ -242,8 +256,8 @@ def _normalize(raw: dict[str, Any], body: dict[str, Any], *, ketu_mode: str, sid
"iso_local": iso,
"latitude": float(body["lat"]),
"longitude": float(body["lon"]),
"ketu_mode": ketu_mode,
"sidereal_mode": sidereal_mode,
"ketu_mode": chart.get("ketuMode") or ketu_mode,
"sidereal_mode": chart.get("siderealMode") or sidereal_mode,
"engine_ketu_mode": chart.get("ketuMode"),
"engine_sidereal_mode": chart.get("siderealMode"),
"timeout_seconds": DEFAULT_TIMEOUT_SECONDS,
@@ -278,14 +292,21 @@ def calculate_qizheng_chart(body: dict[str, Any], *, timeout_seconds: int = DEFA
"""Compute a complete native 七政四余 natal chart from JSON-like input."""
if not isinstance(body, dict):
raise QizhengChartError("request body must be an object", error_code="ERR_QIZHENG_INPUT")
if not VENDORED_CLI.exists():
if not VENDORED_CLI.exists() or not EVAL_HELPER.exists():
raise QizhengChartError("vendored 七政四余 engine is unavailable", error_code="ERR_QIZHENG_CLI_MISSING")
lat = _number(body.get("lat", body.get("latitude")), "lat", -90, 90)
lon = _number(body.get("lon", body.get("longitude", body.get("lng"))), "lon", -180, 180)
iso = _iso_local(body)
ketu_mode = _ketu_mode(body)
sidereal_mode = _sidereal_mode(body)
raw = _run_cli(iso, lat, lon, timeout_seconds)
raw = _run_cli(
iso,
lat,
lon,
timeout_seconds,
ketu_mode=ketu_mode,
sidereal_mode=sidereal_mode,
)
return _normalize(raw, {**body, "lat": lat, "lon": lon}, ketu_mode=ketu_mode, sidereal_mode=sidereal_mode, iso=iso)
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env node
"use strict";
// Load the vendored CLI bundle without running main(), then call the
// in-bundle getSevenGovernorsChart(date, location, options) that the CLI
// itself never forwards flags into.
process.env.VITEST = "1";
const fs = require("fs");
const path = require("path");
const Module = require("module");
const cliPath = path.resolve(__dirname, "..", "vendor", "stem-branch", "dist", "cli.cjs");
if (!fs.existsSync(cliPath)) {
process.stderr.write("vendored seven-governors CLI is missing\n");
process.exit(2);
}
const source = fs.readFileSync(cliPath, "utf8").replace(
"if (!process.env?.VITEST) {\n main();\n}",
"module.exports.getSevenGovernorsChart = getSevenGovernorsChart;\nif (!process.env?.VITEST) {\n main();\n}",
);
const loaded = new Module(cliPath);
loaded.filename = cliPath;
loaded.paths = Module._nodeModulePaths(path.dirname(cliPath));
loaded._compile(source, cliPath);
const getSevenGovernorsChart = loaded.exports.getSevenGovernorsChart;
if (typeof getSevenGovernorsChart !== "function") {
process.stderr.write("getSevenGovernorsChart is not available from the vendored CLI\n");
process.exit(2);
}
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const date = new Date(input.date);
if (Number.isNaN(date.getTime())) {
process.stderr.write("invalid date\n");
process.exit(2);
}
const chart = getSevenGovernorsChart(
date,
{ lat: Number(input.lat), lon: Number(input.lng) },
{
ketuMode: input.ketuMode || "apogee",
siderealMode: input.siderealMode || { type: "modern" },
},
);
process.stdout.write(JSON.stringify({ sevenGovernors: chart }));
+35 -4
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
@@ -17,20 +18,50 @@ WINDOW = {
"ayanamsa": "raman",
"node_mode": "mean",
}
COORD_DECIMALS = 6
def _quantize_event(event: dict) -> dict:
quantized = dict(event)
for key in ("longitude", "speed_longitude"):
if key in quantized and isinstance(quantized[key], (int, float)):
quantized[key] = round(float(quantized[key]), COORD_DECIMALS)
return quantized
def _assert_events_match_golden(events: list) -> None:
assert GOLDEN.exists(), (
f"missing golden {GOLDEN}; rebuild with scripts/generate_ephemeris_events_golden.py, "
"do not let this test write the file"
)
golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
assert [_quantize_event(item) for item in events] == [_quantize_event(item) for item in golden]
for live, expected in zip(events, golden, strict=True):
for key in ("kind", "date", "body"):
assert live[key] == expected[key]
for key in ("from_sign", "to_sign", "motion"):
if key in expected:
assert live.get(key) == expected[key]
def test_raman_90_day_scan_matches_live_golden_and_has_both_kinds() -> None:
result = build_ephemeris_events(WINDOW)
if not GOLDEN.exists():
GOLDEN.write_text(json.dumps(result["events"], ensure_ascii=False, indent=2), encoding="utf-8")
golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
assert result["events"] == golden
_assert_events_match_golden(result["events"])
assert len(result["events"]) == 14
assert {item["kind"] for item in result["events"]} == {"ingress", "station"}
assert [item["date"] for item in result["events"]] == sorted(item["date"] for item in result["events"])
assert result["calculation"]["ayanamsa"] == "raman"
def test_missing_golden_fails_instead_of_rewriting(monkeypatch, tmp_path: Path) -> None:
missing = tmp_path / "ephemeris_events_raman_20260915_90d.json"
monkeypatch.setattr(sys.modules[__name__], "GOLDEN", missing)
result = build_ephemeris_events(WINDOW)
with pytest.raises(AssertionError, match="missing golden"):
_assert_events_match_golden(result["events"])
assert not missing.exists()
def test_lahiri_differs_from_raman_on_the_same_window() -> None:
raman = build_ephemeris_events(WINDOW)
lahiri = build_ephemeris_events({**WINDOW, "ayanamsa": "lahiri"})
+30 -7
View File
@@ -69,12 +69,34 @@ def test_live_chart_matches_golden_and_product_contract() -> None:
assert "\\" not in raw or "[redacted-path]" in raw
def test_ketu_mode_request_is_echoed_and_engine_mode_is_visible() -> None:
def test_descending_node_ketu_mode_reaches_the_engine() -> None:
_require_node()
result = calculate_qizheng_chart({**SAMPLE, "ketu_mode": "descending-node"})
assert result["calculation"]["ketu_mode"] == "descending-node"
# a911c890 CLI has no ketuMode flag; actual school is the engine default.
assert result["calculation"]["engine_ketu_mode"] == "apogee"
apogee = calculate_qizheng_chart(SAMPLE)
descending = calculate_qizheng_chart({**SAMPLE, "ketu_mode": "descending-node"})
assert descending["calculation"]["ketu_mode"] == "descending-node"
assert descending["calculation"]["engine_ketu_mode"] == "descending-node"
assert descending["calculation"]["ketu_mode"] == descending["calculation"]["engine_ketu_mode"]
assert descending["bodies"]["ketu"]["mansion"] != apogee["bodies"]["ketu"]["mansion"] or (
round(descending["bodies"]["ketu"]["mansionDegree"], 2)
!= round(apogee["bodies"]["ketu"]["mansionDegree"], 2)
)
def test_calculation_ketu_mode_never_disagrees_with_engine_on_success() -> None:
_require_node()
for mode in ("apogee", "descending-node"):
result = calculate_qizheng_chart({**SAMPLE, "ketu_mode": mode})
assert result["calculation"]["ketu_mode"] == result["calculation"]["engine_ketu_mode"] == mode
def test_classical_sidereal_mode_reaches_the_engine() -> None:
_require_node()
modern = calculate_qizheng_chart(SAMPLE)
classical = calculate_qizheng_chart({**SAMPLE, "sidereal_mode": "classical"})
assert classical["calculation"]["sidereal_mode"] == {"type": "classical", "epoch": "kaiyuan"}
assert classical["calculation"]["engine_sidereal_mode"] == {"type": "classical", "epoch": "kaiyuan"}
assert classical["calculation"]["sidereal_mode"] == classical["calculation"]["engine_sidereal_mode"]
assert round(classical["bodies"]["sun"]["siderealLon"], 2) != round(modern["bodies"]["sun"]["siderealLon"], 2)
def test_missing_lat_is_structured_input_error() -> None:
@@ -140,8 +162,9 @@ def test_non_json_stdout_error(monkeypatch) -> None:
def test_source_never_invokes_forbidden_cli_flags() -> None:
source = Path(engine.__file__).read_text(encoding="utf-8")
assert "--seven-governors" in source
assert "--json" in source
helper = Path(engine.EVAL_HELPER).read_text(encoding="utf-8")
assert "getSevenGovernorsChart" in helper
command_block = source.split("command = [", 1)[1].split("]", 1)[0]
for flag in engine.FORBIDDEN_CLI_FLAGS:
assert flag not in command_block
assert flag not in helper