fix(qizheng): pass school options into the vendored chart and stabilize gates
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:
@@ -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 的环境验证。
|
||||
|
||||
@@ -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`。
|
||||
@@ -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+ | 待领取 | — |
|
||||
|
||||
## 命名与归档
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user