diff --git a/BLOCKED.md b/BLOCKED.md index 2adcd2c8..e632765f 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -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 为准,未追上不得写成已部署。 + ## 七政原生排盘:镜像构建与上游 checkout(2026-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 的环境验证。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 024e599e..a5d36fac 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-readonly-pages-fix-20260916.md b/docs/tasks/PROGRESS-readonly-pages-fix-20260916.md new file mode 100644 index 00000000..0fcd4fad --- /dev/null +++ b/docs/tasks/PROGRESS-readonly-pages-fix-20260916.md @@ -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`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index b35f0767..41425978 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -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.0(Apache-2.0)归档 + API 镜像 Node runtime + 七政适配器(计都派别与宿度坐标系参数化、boundary 按实测重写)+ 三个只读端点 `/api/qizheng`、`/api/western`、`/api/ephemeris_events`。实证三条:四柱时柱按 UTC 算(BUG-700,本轮不修不调用)、`ketuMode` 写死未暴露(BUG-701)、boundary 把空神煞与未闭合庙旺说成已生成(BUG-702)。BUG 段 700–703 | 已推 `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 段 707–709 | 已验收(带修复单) | `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.0(Apache-2.0)归档 + API 镜像 Node runtime + 七政适配器(计都派别与宿度坐标系参数化、boundary 按实测重写)+ 三个只读端点 `/api/qizheng`、`/api/western`、`/api/ephemeris_events`。实证三条:四柱时柱按 UTC 算(BUG-700,本轮不修不调用)、`ketuMode` 写死未暴露(BUG-701)、boundary 把空神煞与未闭合庙旺说成已生成(BUG-702)。BUG 段 700–703 | 已验收(带修复单) | `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 段 707–709 | 已验收(带修复单) | `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+ | 待领取 | — | ## 命名与归档 diff --git a/frontend/src/lib/chart-view-mapper.ts b/frontend/src/lib/chart-view-mapper.ts index 10a26dd0..41bde112 100644 --- a/frontend/src/lib/chart-view-mapper.ts +++ b/frontend/src/lib/chart-view-mapper.ts @@ -599,7 +599,10 @@ function mapQizheng(raw: Record | 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, diff --git a/frontend/tests/chart-view-route.test.ts b/frontend/tests/chart-view-route.test.ts index fa4a2a55..a54784db 100644 --- a/frontend/tests/chart-view-route.test.ts +++ b/frontend/tests/chart-view-route.test.ts @@ -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", diff --git a/frontend/tests/ephemeris-page.test.tsx b/frontend/tests/ephemeris-page.test.tsx index 968749a3..122fe02f 100644 --- a/frontend/tests/ephemeris-page.test.tsx +++ b/frontend/tests/ephemeris-page.test.tsx @@ -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", () => { diff --git a/scripts/generate_ephemeris_events_golden.py b/scripts/generate_ephemeris_events_golden.py new file mode 100644 index 00000000..96095332 --- /dev/null +++ b/scripts/generate_ephemeris_events_golden.py @@ -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() diff --git a/scripts/qizheng_chart_engine.py b/scripts/qizheng_chart_engine.py index b1502057..5af30c4f 100644 --- a/scripts/qizheng_chart_engine.py +++ b/scripts/qizheng_chart_engine.py @@ -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) diff --git a/scripts/qizheng_seven_governors.js b/scripts/qizheng_seven_governors.js new file mode 100644 index 00000000..a3847d59 --- /dev/null +++ b/scripts/qizheng_seven_governors.js @@ -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 })); diff --git a/tests/test_ephemeris_events.py b/tests/test_ephemeris_events.py index 29a325af..0cbb833d 100644 --- a/tests/test_ephemeris_events.py +++ b/tests/test_ephemeris_events.py @@ -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"}) diff --git a/tests/test_qizheng_chart_engine.py b/tests/test_qizheng_chart_engine.py index 25c99b85..ae215267 100644 --- a/tests/test_qizheng_chart_engine.py +++ b/tests/test_qizheng_chart_engine.py @@ -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