Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a94edf200 | ||
|
|
ad1dc89664 | ||
|
|
0a4ba23658 | ||
|
|
85055cf9fa | ||
|
|
6350943152 | ||
|
|
ad8b24bf9b | ||
|
|
66cd60081f | ||
|
|
6dd1ff7d0c | ||
|
|
aae3fea011 | ||
|
|
4cdb997800 | ||
|
|
2748e43871 | ||
|
|
d32ad7b7b1 |
@@ -1,9 +1,20 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-14 — 回答生成时侧栏不再跟着闪动 / 卡顿(待合入 staging)
|
||||
|
||||
- 回答生成时侧栏不再跟着闪动 / 卡顿。点会话、改名、换人仍是当前这一下的操作。Skill 版本不变(BUG-1329)。
|
||||
|
||||
## 2026-10-14 — 校正每轮重算更快(待合入 staging)
|
||||
|
||||
- 校正每轮重算更快(2 小时窗口约快一倍以上),结果不变。一次要算的分钟数超过 241 会直接拒绝,不再占着计算空位排盘。Skill 版本不 bump(BUG-1323~1325、BUG-1345)。
|
||||
|
||||
## 2026-10-14 — 婚恋评级改成证据档位,KP 判宫和大运年长对齐(待合入 staging)
|
||||
|
||||
- 婚恋结构指数不再写成婚姻稳定、离婚、再婚或建议婚前咨询。中文报告用中文档位,英文报告用英文档位。配偶星分数按 0 到 1 显示,不再写成百分制。
|
||||
- KP 按宫头到下一宫头判宫,报告写明口径。本命宫位表的判宫不变。
|
||||
- 接口里兜底大运的一年改成和推运脚本一样的 365.25 天。
|
||||
- 星历路径只核查,没改。Skill 版本不变(BUG-1318~1321;BUG-1322 仍在核查)。
|
||||
|
||||
## 2026-10-13 — 明说要做生时校正时,第一步就能打开(待合入 staging)
|
||||
|
||||
- 新对话第一句就说「帮我做生时校正 / 生时纠正 / 核对出生时间」,会直接打开校正,只留一句过渡,不先写一段解读。
|
||||
|
||||
@@ -17720,6 +17720,81 @@
|
||||
- 复发自:BUG-1306。
|
||||
- 修复版本:分支 `codex/rectification-step0-entry-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1318 | 婚恋评级仍写成稳定或婚前咨询,配偶星分数被写成百分制
|
||||
|
||||
- 状态:fixed-pending-verify
|
||||
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
|
||||
- 来源:issue #56。上游 `f891a2c1`。任务书 `docs/tasks/TASK-upstream-sync7-20261014.md` T1。
|
||||
- 现象:D9 综合评级仍写「婚姻稳定」「强烈建议婚前咨询」。这份文字进全读导出包,模型能读到。配偶星证据行把 0 到 1 的指数写成「婚姻质量 x/100」。
|
||||
- 触发条件:算出婚恋几何指数,或关系时机证据带出配偶星。
|
||||
- 根因:同步第六轮按标题筛提交时,漏了 10-02 到 10-03 的这条措辞修正。分数行把 0 到 1 当成了百分制。
|
||||
- 修复:五档改成证据档位,并写明这是结构指数,不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示婚前咨询。中文报告用中文档位,英文报告用英文档位。证据行改成「婚姻质量指数 x(0到1)」,不乘 100。
|
||||
- 验证:`tests/test_d9_evidence_bands.py` 3 通过。公开盘中英文各一条快照。配偶星行不再出现 `/100`。
|
||||
- 防复发:五档文字不得含「稳定、离婚、再婚、婚前咨询、几段」;快照锁住公开盘的两句。
|
||||
- 相关记录:BUG-1183。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
|
||||
|
||||
## BUG-1319 | KP 落宫沿用本命中点判宫,传了宫头也不声明口径
|
||||
|
||||
- 状态:fixed-pending-verify
|
||||
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
|
||||
- 来源:issue #56。上游 `0337b77f`。任务书 T2。
|
||||
- 现象:KP 用的行星落宫和本命 Bhava Chalit 表一样,按相邻宫头的中点判宫。宫头传进 KP 分析时,结果不写明判宫口径。
|
||||
- 触发条件:打开 KP。本命 Bhava Chalit 表不走这条。
|
||||
- 根因:同步第六轮漏了夹在上游隐私提交里的判宫修正。本命表和 KP 共用了中点函数。
|
||||
- 修复:KP 改成宫头到下一宫头。本命表默认仍是中点,宫位数字不变。传了宫头却不声明口径就拒绝;没传宫头记整宫代理。KP 报告写出判宫口径。
|
||||
- 验证:`tests/test_kp_cusp_house_assignment.py` 3 通过。同一颗星在两种口径下落入不同宫;默认中点与改前一致。
|
||||
- 防复发:未知口径直接报错;报告正文必须出现 House occupancy basis。
|
||||
- 相关记录:BUG-1318。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
|
||||
|
||||
## BUG-1320 | 瑜伽角宫主和三方主的顺序随进程哈希变化
|
||||
|
||||
- 状态:fixed-pending-verify
|
||||
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
|
||||
- 来源:issue #56。上游 `0337b77f`。任务书 T3。
|
||||
- 现象:同一张盘的角宫主、三方主列表用了集合去重。集合顺序会跟着进程哈希变,瑜伽参与者顺序不稳定。
|
||||
- 触发条件:规则表达式读取这两份宫主列表。
|
||||
- 根因:去重用了不保证顺序的集合。同步第六轮漏了上游把它们改成排序的那一处。
|
||||
- 修复:两处都改成先去重再按名字排序。
|
||||
- 验证:`tests/test_yoga_lord_order_stable.py` 1 通过。哈希种子 0 和 1 两个进程算出的参与者顺序相同。
|
||||
- 防复发:两个子进程的名单必须逐项相同,并且等于排好序的名单。
|
||||
- 相关记录:BUG-1319。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
|
||||
|
||||
## BUG-1321 | 接口里的大运年长和推运脚本差了一点,起止会对不齐
|
||||
|
||||
- 状态:fixed-pending-verify
|
||||
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
|
||||
- 来源:issue #56。上游 `4d543209`。任务书 T4。
|
||||
- 现象:接口的兜底大运和「进入本运几年」用 365.25636 天为一年。同一条时间线的推运脚本用 365.25 天。年限一长,日期会差一天。
|
||||
- 触发条件:走兜底大运时间线,或计算进入当前大运的年数。
|
||||
- 根因:两处常量没有跟着推运脚本改。同步第六轮漏了上游 10-03 的这一处。
|
||||
- 修复:这两处改成 365.25,与推运脚本相同。其它大运系统的年长不动。
|
||||
- 验证:`tests/test_dasha_year_length_alignment.py` 2 通过。同一大运起止相差 0 天。158 年的旧常量会落到下一天,新常量不会。
|
||||
- 防复发:兜底时间线必须和推运脚本的起止是同一天;进入本运的年数按 365.25 除。
|
||||
- 相关记录:BUG-1320。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/upstream-sync7-20261014`,尚未合入 staging。
|
||||
|
||||
## BUG-1322 | 星历路径被设成空字符串后,计算退回低精度星历
|
||||
|
||||
- 状态:investigating
|
||||
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
|
||||
- 来源:issue #56。上游 `e1909724` 点出的同类问题。任务书 T5,本轮只核查、不改代码。
|
||||
- 现象:排盘和过境入口都把星历路径设成空字符串。本机随后计算太阳位置时,返回标记含低精度星历位,不含星历文件位。
|
||||
- 触发条件:调用排盘或过境计算。本轮没有改这条路径。
|
||||
- 根因:尚未在生产镜像里核实。本机仓库里的星历文件只在参考资料目录,不在计算进程会去找的路径上。空路径是进程全局的,后一次调用会盖掉前面设过的路径。
|
||||
- 修复:未改。建议另开一单:镜像里放一份星历文件,路径显式设上,并断言返回标记不再是低精度星历。
|
||||
- 验证:本机一次探针。生产镜像未打开,不能当作已确认。
|
||||
- 防复发:未做。等修复单再加断言。
|
||||
- 相关记录:BUG-1321。
|
||||
- 复发自:无。
|
||||
- 修复版本:未修复。
|
||||
|
||||
## BUG-1323 | 校正引导窗口按候选两两比较,窗口一长就平方变慢
|
||||
|
||||
- 状态:fixed-pending-verify(尚未合入 staging)
|
||||
@@ -17765,6 +17840,21 @@
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/rect-scoring-perf-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1329 | 流式回答期间首页侧栏每帧整栏重渲染
|
||||
|
||||
- 状态:fixed-pending-verify
|
||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-14
|
||||
- 来源:issue #59。任务书 `docs/tasks/TASK-home-sidebar-rerender-20261013.md`。
|
||||
- 现象:回答逐字出来时,左侧会话列表跟着每一帧重画。会话越多越重。外观、顺序和点击结果没有变。
|
||||
- 触发条件:首页已登录,回答按帧写入。每一帧都会让首页再渲染一次。
|
||||
- 根因:BUG-971 切断了「首页订阅自己的注册」这条反馈,但注册仍然每次渲染都发布一份新对象。会话回调和可见列表每次渲染都是新的,外壳就跟着提交一次。不是回归,是当时留下的开销。
|
||||
- 修复:外壳拿到的是稳定包装,点下去仍执行首页最新一次渲染的回调。注册只在会话、当前项、账户或开关这些数据变了才发布;列表逐条对象没变就不重建行。可见列表按当前人物记忆。侧栏上下文对象也不再每次新建。没有运行时开关。
|
||||
- 验证:同一套计数测试。改前 60 帧注册 60 次、侧栏提交 60 次;改后注册 0 次、提交 0 次。换当前项、改标题、换人各发布 1 次。包装函数身份不变,调用记到的是最新一次渲染。详见 `docs/tasks/PROGRESS-home-sidebar-rerender-20261013.md`。2026-10-14 补了一条旧检查:账户菜单里的充值仍须打开 billing,来源仍是 account-menu,只是改认稳定包装的写法。
|
||||
- 防复发:外壳注册须按数据变化发布。任何流式会话面要有侧栏提交次数的按帧断言。`frontend/tests/home-sidebar-rerender.test.tsx` 在 60 帧内要求注册 0 次、侧栏提交 0 次。
|
||||
- 相关记录:BUG-971、BUG-725、BUG-473、BUG-744、BUG-745、BUG-746。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/home-sidebar-rerender-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1345 | 打分接口没有候选数上限,超宽窗口会占着重计算槽
|
||||
|
||||
- 状态:fixed-pending-verify(尚未合入 staging)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# PROGRESS · 流式回答时侧栏不再跟着每帧重画(issue #59)
|
||||
|
||||
- 分支:`codex/home-sidebar-rerender-20261013`
|
||||
- 实际基线:`origin/staging` `66cd60081f37a57680274633fcb3fb942d01bb2f`
|
||||
- 任务书基线写成 `77057a819`,计划评论里的 base 是 `2994588ad4d858dead4835943e5883c5aa2b03f8`。开工时远端 staging 已是上面的 `66cd6008`。A/B 就跑在这个提交上,没有另开只读工作树(本轮不能离开当前工作树)。方向没有改,不需要拍板。
|
||||
- `docs/BUG_HISTORY.md` 在这个基线上停在 BUG-1322。BUG-1323~1328、1345、1346 属于还没合入的别的分支,本单只用 BUG-1329。
|
||||
- 未改 `page.tsx`。没有运行时开关。没有启用 React Compiler。Skill 版本不变。浏览器 A/B 未跑。
|
||||
|
||||
## 做了什么
|
||||
|
||||
外壳拿到的点击函数不再每次渲染换新,点下去仍执行首页刚渲染的那一版。注册只在数据变了才发给侧栏:会话行要逐条还是同一个对象才算没变,没变就不重建行。可见会话按当前人物记忆。侧栏自己的上下文对象也不再每次新建。卸载首页时仍会清掉注册。
|
||||
|
||||
## 断言三栏
|
||||
|
||||
| 位置 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| `frontend/tests/session-list-lifecycle.test.tsx` 外壳「打开资料」函数 | `assert.notEqual(onOpenProfile, initialCallback)` | `assert.equal(...)`;再改一版后仍是同一个函数,调用记录从 `[1]` 加强到 `[1, 2]` | 包装函数身份稳定。调用必须落到最新一次渲染,不能只删掉「不相等」 |
|
||||
|
||||
`HomeProbe` 仍是每次渲染新回调、新数组,没有改成常量。
|
||||
|
||||
## 基线失败(改代码之前,同一测试,3 次,退出码都是 1)
|
||||
|
||||
失败停在 `assert.equal(duringFrames.registers, 0)`,实际是 60。60 帧里侧栏提交也是 60。换当前项、改标题、换人各注册 1 次;只改回调版本也会再注册 1 次。剔除函数后的内容摘要:
|
||||
|
||||
| 档 | 60 帧注册 | 60 帧提交 | 60 帧耗时三次(毫秒) | 中位 | 内容摘要 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 30 条虚构会话 | 60 | 60 | 2774.964 / 2778.961 / 4202.085 | 2778.961 | `b18f05a3d83be5b41584787b7c0495f1fcec8ef7b8ecfbdbb3c083c40796c103` |
|
||||
| 100 条虚构会话 | 60 | 60 | 10530.828 / 11799.253 / 12284.103 | 11799.253 | `cef0a63d6f3fe72e541f9ac882b8eaf305b6933fdd07ca3002e8cd03bb139d25` |
|
||||
|
||||
更早一次探针把注册函数也做成每次渲染新建,60 帧数到 120 次注册、60 次提交。那是清理函数跟着新函数又清了一次,不是产品路径。交付用的测试和生产一样,用提供者给出的稳定注册函数,所以改前是 60 而不是 120。
|
||||
|
||||
## 改后(同一测试,3 次,退出码都是 0)
|
||||
|
||||
| 档 | 60 帧注册 | 60 帧提交 | 60 帧耗时中位 | 换当前项 | 改标题 | 只改回调版本 | 换人 | 内容摘要 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 30 | 0 | 0 | 0 | 注册 1、提交 1 | 注册 1,标题更新 | 注册 0,调用记到第 4 版 | 注册 1,列表换成另一人 | 与改前相同 |
|
||||
| 100 | 0 | 0 | 0 | 注册 1、提交 1 | 注册 1,标题更新 | 注册 0,调用记到第 4 版 | 注册 1,列表换成另一人 | 与改前相同 |
|
||||
|
||||
三次摘要都与上表相同。门槛达到:60 帧内注册 0、侧栏提交 0,内容摘要逐字节一致。
|
||||
|
||||
## 自测
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 类型 | `node frontend/node_modules/typescript/bin/tsc --noEmit -p frontend` | 退出码 0 |
|
||||
| 规范 | `npm run lint --prefix frontend` | 0 error,126 条原有 warning。本单文件没有新增 warning,没有 `eslint-disable` |
|
||||
| 定向测试 | `tsx --test` 下表 10 个文件 | tests 94,pass 94,fail 0,cancelled 0 |
|
||||
| 数据库 | 未跑 | 本单不碰数据库 |
|
||||
| 浏览器 | 未跑 | 浏览器 A/B 未跑。步骤在 `docs/testing/home-sidebar-rerender-20261013.md` |
|
||||
| 隐私标记 | `.venv\Scripts\python.exe -m pytest tests/test_repo_privacy_markers.py -q` | 通过 |
|
||||
|
||||
定向文件:`home-sidebar-rerender`、`session-list-lifecycle`、`session-list-provider`、`sidebar-contract`、`sidebar-state`、`secondary-page-entry`、`account-dialog-inert-20260918`、`home-shell-growth-contract`、`home-streaming-render-split`、`chat-profile-picker`。
|
||||
|
||||
全量 `npm test`、`next build`、Python 快速门未跑,留给验收。
|
||||
|
||||
## 2026-10-14 修复单
|
||||
|
||||
按修复单合并了最新 `origin/staging`。只改了 `frontend/tests/chat-navigation-a11y-contract.test.ts` 里账户菜单「充值」那一条源码断言,改成认稳定包装的写法,仍然要求打开 billing 且来源是 `account-menu`。业务代码没有改。
|
||||
|
||||
又搜了一遍 `onOpenProfile`、`onOpenGeneral`、`onOpenLogout`、`onNewChat`、`onSelectSession`、`onLoadMore`。测试里其余命中的是侧栏组件自己的入口,或校正点数不足时打开充值,不是这条旧写法,没有改。
|
||||
|
||||
和上一版 `63509431` 比,网页业务目录里多出来的只有合并测试线带入的校正提速(`engine-client.ts`),不是这单写的。这单自己的提交不碰 `frontend/src`。
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 类型 | `tsc --noEmit -p frontend` | 通过,退出码 0 |
|
||||
| 规范 | `npm run lint`(在 frontend) | 0 error,125 条原有 warning。本单改的测试文件没有新增 warning |
|
||||
| 这条旧检查 | `tsx --test tests/chat-navigation-a11y-contract.test.ts` | 13 通过,0 失败,0 取消 |
|
||||
|
||||
全量 `npm test` 仍留给验收。
|
||||
@@ -0,0 +1,41 @@
|
||||
# PROGRESS · 上游同步第七轮(2026-10-14)
|
||||
|
||||
- 分支:`codex/upstream-sync7-20261014`
|
||||
- 基线:任务书写的是 `77057a81`。开工时 `origin/staging` 已到 `99ef1bbd`,本分支从这里建。
|
||||
- 结论:T1 到 T4 已做完并推这个分支。T5 只核查,代码没改。星历在这台电脑上会退回低精度,生产镜像没打开,记 BUG-1322,状态是核查中。
|
||||
|
||||
## 做了什么
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| T1 婚恋断语 | 五档改成证据档位,中文给中文报告,英文给英文报告。写明不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示婚前咨询。配偶星那一行改成 0 到 1 的指数,不再写 `/100`。BUG-1318,上游 `f891a2c1`。 |
|
||||
| T2 KP 判宫 | KP 按宫头到下一宫头。本命宫位表仍按中点,默认宫位不变。传了宫头却不说口径就拒绝;没传记整宫代理。报告多一行判宫口径。BUG-1319,上游 `0337b77f`。 |
|
||||
| T3 瑜伽排序 | 角宫主、三方主先去重再按名字排序。BUG-1320,上游 `0337b77f`。 |
|
||||
| T4 大运年长 | 接口里两处 365.25636 改成 365.25,和推运脚本同一天。别的大运系统没动。BUG-1321,上游 `4d543209`。 |
|
||||
| T5 星历 | 见下。不改代码。BUG-1322,核查中。 |
|
||||
|
||||
## 检查
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 婚恋档位 | `pytest tests/test_d9_evidence_bands.py` | 3 通过 |
|
||||
| KP 判宫 | `pytest tests/test_kp_cusp_house_assignment.py` | 3 通过 |
|
||||
| 本命宫位表 | `pytest tests/test_bhava_chalit.py` | 通过(与上面一批共 20 通过、0 失败) |
|
||||
| 几何指数旧测试 | `pytest tests/test_marriage_counting_geometry_only.py` | 通过(同上) |
|
||||
| 瑜伽顺序 | `pytest tests/test_yoga_lord_order_stable.py` | 1 通过 |
|
||||
| 大运年长 | `pytest tests/test_dasha_year_length_alignment.py` | 2 通过 |
|
||||
| 接口增长合同 | `pytest tests/test_api_server_growth_contract.py` | 通过 |
|
||||
| KP 报告标题回归 | `pytest tests/test_report_longform_parity.py` 里 KP 那一条 | 通过 |
|
||||
| 快速门、前端全量 | 未跑 | 按执行分工,留给验收方 |
|
||||
| 数据库 | 未跑 | 没动表 |
|
||||
|
||||
## T5 星历核查
|
||||
|
||||
本机把路径设成空字符串后,算太阳位置的返回标记带低精度星历,不带星历文件。仓库里的星历文件在参考资料目录,计算时用不到。排盘和过境两处都设成同一个空路径,所以这两处彼此一致,但都会退回低精度。路径是整个进程共用的,谁后设谁生效。
|
||||
|
||||
生产镜像这台电脑打不开,不能说线上也是这样。健康检查只看星历库能不能导入,看不出用的是哪一套。建议另开一单再改。
|
||||
|
||||
## 偏离
|
||||
|
||||
- 关系时机里还有一处用 65 和 45 去比这个 0 到 1 的指数。任务书只允许改配偶星那一行文字,所以没动。它现在加不上那一档分数。建议另开小单。
|
||||
- 生时校正打分文件没有改。
|
||||
@@ -331,7 +331,7 @@
|
||||
| `TASK-rectification-probe-supply-research-20260913.md` | — | 研究单:六题后引擎在剩余候选上再出带年月题的四种放宽规则,20 例公开 AA 数据离线量收益,有收益才立实现单 | 已合入(离线测量,不改线上) | `b063c668` |
|
||||
| `TASK-rectification-refresh-r3-r4-20260913.md` | `PROGRESS-rectification-refresh-r3-r4-20260913.md` | 刷新阶段 R3(MIN_BOUNDARY_DAYS 45→30)+ R4(pratyantar 与 D9/D10 上升 Narayana);首轮出题不变。即使放宽仍有 0 题例子,定向补事另线保留(BUG-664/665) | 已实现 `ab1ade59`,已部署;2026-09-13 验收通过(实现单由执行方自拟,无产品决策记录段;收益口径见研究文档) | `codex/rectification-refresh-r3-r4-20260913` |
|
||||
| `TASK-upstream-sync6-20261007.md` | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) | **上游同步第六轮(`23be1807`)**:10-05 五个计算修正一行未合——D5/D6/D8/D11 映射(乔布斯盘四张分盘星座全不同)、Vimsopaka 权重、Yogini 顺序与出生余额、Panchadhikari 宫位参照、Narayana 并列、特殊上升、出生秒数、Neecha Bhanga 两条件、首段大运子运、岁差被覆盖;逐项对照原书核实;最先做 | 已验收(门禁补修 `2df6ee72`,已部署 staging) | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) |
|
||||
| `TASK-upstream-sync7-20261014.md` | — | **上游同步第七轮(BUG-1318 起)**:婚恋评级断语改证据档位、DK「婚姻质量 X/100」改对;KP 宫头到宫头判宫;瑜伽宫主排序去重;api_server 大运年长 365.25;星历路径只核查。不动校正打分。Grok 执行(agent 循环) | 待执行 | — |
|
||||
| `TASK-upstream-sync7-20261014.md` | [PROGRESS-upstream-sync7-20261014.md](PROGRESS-upstream-sync7-20261014.md) | **上游同步第七轮(BUG-1318 起)**:婚恋评级断语改证据档位、DK「婚姻质量 X/100」改对;KP 宫头到宫头判宫;瑜伽宫主排序去重;api_server 大运年长 365.25;星历路径只核查。不动校正打分。Grok 执行(agent 循环) | 待验收 | `codex/upstream-sync7-20261014`(BUG-1318~1321 已改;BUG-1322 星历路径核查中,未改代码) |
|
||||
| `TASK-beta-main-path-20261014.md` | — | **内测主路径**:BUG-1063 他人报告 worker 读档案(逐列只读授权 + test:db);对话与报告「AI 生成,仅供参考」标识与免责;BUG-1080/283 状态;状态板按事实清账;冒烟清单 `docs/testing/beta-smoke-20261014.md`。与发布单并行 | 待执行 | — |
|
||||
| `TASK-release-path-production-20261014.md` | — | **发布通路 + 生产首发**:产品 10-14 授权改生产工作流(发布 SHA = 最近门禁成功提交 + 其后纯文档);63 个迁移清单与演练脚本、首发 runbook;生产拒绝测试验证码。Grok 不 dispatch 生产工作流 | 待执行 | — |
|
||||
| `TASK-beta-safety-net-20261014.md` | — | **内测安全网**:每日备份 + 恢复演练、探活告警、本地错误汇总、日志轮转、按 requestId 查日志。**等发布通路单合入、#58 合入后开工** | 待前置 | — |
|
||||
@@ -469,7 +469,9 @@
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `TASK-rect-scoring-perf-20261013.md` | `PROGRESS-rect-scoring-perf-20261013.md` | 校正打分提速:引导窗口平方扫描、候选不变量重复算、响应带无读取点字段、候选数上限 241(BUG-1323~1325、1345) | 待验收 | `codex/rect-scoring-perf-20261013` |
|
||||
| `TASK-node-runtime-resources-20261013.md` | — | 报告 worker abort 监听泄漏、本地 PG 适配层往返与元数据缓存、无上限内存表、用户端会话读缓存(BUG-1326~1328、1346,PG 与缓存部分 A/B) | 待领取 | `codex/node-runtime-resources-20261013` |
|
||||
| `TASK-home-sidebar-rerender-20261013.md` | — | 流式回答时侧栏每帧整栏重渲染(BUG-1329,A/B) | 待领取 | `codex/home-sidebar-rerender-20261013` |
|
||||
| `TASK-node-runtime-resources-fix-20261014.md` | — | **#58 第 1 次打回**:`4c3485bc` 其余全过;压测脚本放在 `frontend/scripts/` 却从 `frontend/tests/` 导入,镜像构建会失败(BUG-1140 形状,任务书指定的路径有误)。挪到 `frontend/tests/` 下非 `.test` 文件名 | 待领取 | `codex/node-runtime-resources-20261013` |
|
||||
| `TASK-home-sidebar-rerender-20261013.md` | `PROGRESS-home-sidebar-rerender-20261013.md` | 流式回答时侧栏每帧整栏重渲染(BUG-1329,A/B) | 待验收 | `codex/home-sidebar-rerender-20261013` |
|
||||
| `TASK-home-sidebar-rerender-fix-20261014.md` | `PROGRESS-home-sidebar-rerender-20261013.md` | **#59 第 1 次打回**:`63509431` 其余全过;`chat-navigation-a11y-contract` 按源码文本断言 `onOpenBilling` 旧写法未跟改(§7.8) | 待验收 | `codex/home-sidebar-rerender-20261013` |
|
||||
| `TASK-api-disk-cache-retention-20261013.md` | — | API 磁盘缓存只进不出、VedAstro 审计文件留存出生资料、失败快照久缓存、缓存键规范化(BUG-1330~1332,A/B) | 待领取 | `codex/api-disk-cache-retention-20261013` |
|
||||
| `TASK-report-reference-perf-20261013.md` | — | 长报告出稿提速:术语替换命中预检、哈希单次遍历、同盘复用(BUG-1335~1336,A/B) | 待领取 | `codex/report-reference-perf-20261013` |
|
||||
| `TASK-consult-prep-parallel-20261013.md` | — | 普通对话发问前准备步骤并行、模型版本短缓存、多领域并发开关(BUG-1337~1339,A/B) | 待领取 | `codex/consult-prep-parallel-20261013` |
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# TASK · 侧栏重渲染修复单(issue #59,第 1 次打回)
|
||||
|
||||
- 日期:2026-10-14
|
||||
- 原任务书:`docs/tasks/TASK-home-sidebar-rerender-20261013.md`
|
||||
- 被验 SHA:`63509431529d212c7847371e7717660d823b40e6`(`codex/home-sidebar-rerender-20261013`)
|
||||
- 执行方:Grok,同一分支续做(落后 staging 时用 merge,不改写已推历史)
|
||||
- BUG 编号:沿用 BUG-1329
|
||||
|
||||
## 1. 验收结论:一项未通过,其余全部通过
|
||||
|
||||
| 检查 | 结果 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 前端全量 `npm test`(与 staging 基线逐名比对) | **未通过:新增 1 条失败** | `frontend/tests/chat-navigation-a11y-contract.test.ts`「in-app destinations navigate client-side so the chat survives the round trip」:`The input did not match the regular expression /onOpenBilling: \(\) => openAccountDialog\("billing", \{ source: "account-menu" \}\)/` |
|
||||
| T1 稳定包装 + 比较后发布 | 通过 | 逐行看过:`latestRef` 在无依赖 effect 里写,发布 effect 在其后;未冻结首次闭包;`HomeProbe` 的故意不稳定输入未改 |
|
||||
| T2 `visibleSessions` 记忆化 | 通过 | — |
|
||||
| T3 `SidebarProvider` context 记忆化 | 通过 | — |
|
||||
| 改既有断言 | 通过 | `session-list-lifecycle` 三栏齐全,且加强了(再改一版后仍调到最新) |
|
||||
| tsc / lint | 通过 | 0 错 / 0 error |
|
||||
|
||||
**根因**:`use-home-shell-registration.ts` 把 `onOpenBilling` 从 `() => openAccountDialog("billing", { source: "account-menu" })` 改成了 `() => { latestRef.current?.openAccountDialog("billing", { source: "account-menu" }); }`,而 `chat-navigation-a11y-contract.test.ts` 第 42 行按源码文本断言旧写法(AGENTS.md §7.8:改写语句前要 grep 按源码文本断言的合同测试,同一轮跟着改)。
|
||||
|
||||
## 2. 修法要求
|
||||
|
||||
1. 只改 `frontend/tests/chat-navigation-a11y-contract.test.ts` 第 42 行这条断言,按 §7.3 写「原值 / 新值 / 原因」三栏。新正则要**仍然锁住意图**:账户菜单里的「充值」打开 billing 且 `source: "account-menu"`。例如匹配 `onOpenBilling: \(\) => \{ latestRef\.current\?\.openAccountDialog\("billing", \{ source: "account-menu" \}\); \}`;不得放宽成只查 `"billing"`。
|
||||
2. 再 grep 一遍:`grep -rn "onOpenProfile\|onOpenGeneral\|onOpenLogout\|onNewChat\|onSelectSession\|onLoadMore" frontend/tests`,有按旧写法断言源码文本的同样处理(本次全量里只红了这一条,但要确认)。
|
||||
3. 业务代码不改。先把最新 `origin/staging` 合并进分支(用 merge)。
|
||||
4. PROGRESS 末尾记一行。
|
||||
|
||||
## 3. 验收标准
|
||||
|
||||
- 前端全量 `npm test` 失败清单与 staging 基线逐名相同;
|
||||
- `git diff 63509431..<新 SHA> -- frontend/src` 为空。
|
||||
|
||||
## 4. 让步顺序
|
||||
|
||||
无。第 1 次打回;再打回会停下等产品负责人。
|
||||
@@ -0,0 +1,39 @@
|
||||
# TASK · 服务器内存与数据库往返修复单(issue #58,第 1 次打回)
|
||||
|
||||
- 日期:2026-10-14
|
||||
- 原任务书:`docs/tasks/TASK-node-runtime-resources-20261013.md`
|
||||
- 被验 SHA:`4c3485bc2352e8f9de097d81d9979092ee2bb280`(`codex/node-runtime-resources-20261013`)
|
||||
- 执行方:Grok,同一分支续做(落后 staging 时用 merge,不改写已推历史)
|
||||
- BUG 编号:沿用 BUG-1326~1328、1346
|
||||
|
||||
## 1. 验收结论:一项未通过,其余全部通过
|
||||
|
||||
| 检查 | 结果 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 前端全量 `npm test`(与 staging 基线逐名比对) | **未通过:新增 1 条真实失败** | `runtime-sources-no-test-imports.test.ts`「files copied into the web image never import from frontend/tests」:`actual: ['scripts/bench-local-pg-roundtrips.ts']` |
|
||||
| 同上,另一条新增 | 环境缺口(不算未通过) | `database-local-postgres-session-setup.test.ts` 报 `spawnSync docker ENOENT`,本机无 Docker;以 Grok 的 test:db 回报为准 |
|
||||
| T1 报告 worker 摘监听 / T1b | 通过 | 代码与新测试逐行看过 |
|
||||
| T2 元数据缓存 + 合并身份设置 | 通过 | 角色仍只来自构造函数白名单;只缓存表结构;`off` 走原语句顺序;A/B 记录 49→32 往返、中位数降 51.9%、逐字节一致 |
|
||||
| T3 三张内存表 | 通过 | — |
|
||||
| T6 登录读缓存 | 通过(默认关,理由成立) | 改密码 / 注销 / 后台三处已强制绕过;默认关是因为没有身份库真实耗时对照,符合原任务书门槛 |
|
||||
| tsc / lint / next build | 通过 | 0 错 / 0 error / `/` 仍 Static |
|
||||
| 越界 / 隐私 | 通过 | — |
|
||||
|
||||
**根因**:`frontend/scripts/bench-local-pg-roundtrips.ts` 第 16 行 `import { startPostgresFixture } from "../tests/helpers/postgres-fixture.ts";`。web 镜像只复制 `frontend/src` 与 `frontend/scripts`,不复制 `frontend/tests`,而 `next build` 会类型检查 `frontend/` 下所有 `.ts`——推上 staging 后镜像构建会失败(BUG-1140,同一形状)。**这条路径是原任务书 T4 指定的,错在任务书,不是执行偏差。**
|
||||
|
||||
## 2. 修法要求
|
||||
|
||||
1. 把脚本挪到 `frontend/tests/bench-local-pg-roundtrips.ts`(文件名**不得**以 `.test.ts` 结尾,避免被 `npm test` 自动收进去),导入改成 `./helpers/postgres-fixture.ts`;脚本头注释里的运行命令改成 `npx tsx tests/bench-local-pg-roundtrips.ts`。
|
||||
2. 同步改引用:`docs/tasks/PROGRESS-node-runtime-resources-20261013.md`、`docs/testing/node-runtime-resources-ab-20261013.md`。原任务书不改(历史记录),在 PROGRESS 里写一行「按修复单挪位置」。
|
||||
3. 不改任何业务代码;A/B 数字不用重跑。
|
||||
4. 先把最新 `origin/staging` 合并进分支(用 merge)。
|
||||
|
||||
## 3. 验收标准
|
||||
|
||||
- `frontend/tests/runtime-sources-no-test-imports.test.ts` 通过;
|
||||
- 前端全量 `npm test` 的失败清单只剩 staging 基线那 24 条 + 本单新增的 Docker 测试(无 Docker 时);
|
||||
- `git diff 4c3485bc2..<新 SHA> -- frontend/src` 为空(业务代码一字未动)。
|
||||
|
||||
## 4. 让步顺序
|
||||
|
||||
无。这是第 1 次打回;再打回会停下等产品负责人。
|
||||
@@ -0,0 +1,12 @@
|
||||
# 真机走查 · 回答生成时侧栏不再跟着闪(BUG-1329,issue #59)
|
||||
|
||||
浏览器 A/B 未跑。部署到 staging 后,在 `https://staging.jyotisha.chat` 登录一个没有真实会话内容需要露出的账号,按顺序看:
|
||||
|
||||
1. 电脑浏览器打开首页。左侧会话列表的样子、顺序、分组和以前一样。
|
||||
2. 发一个普通问题,等回答一个字一个字出来。这段时间左侧列表不跟着闪、不跟着整栏重画。回答本身仍是逐字出现。
|
||||
3. 回答过程中点另一条会话,能打开那一条。点当前这条的菜单,改名、置顶、分享、删除仍可用,改的是刚刚点的那一条。
|
||||
4. 右上角账户菜单仍能打开资料、通用、账单和退出。
|
||||
5. 换一个人物后,左侧列表换成那个人的会话,不串到别人的。
|
||||
6. 手机宽度再看一遍第 2 步。侧栏抽屉的开关和以前一样。
|
||||
|
||||
任何一步不对,在 issue #59 里写是哪一步、哪台设备,并把标签改回 `agent/待规划`。不要贴出真实会话内容。
|
||||
@@ -0,0 +1,18 @@
|
||||
# A/B · 回答生成时侧栏重画(BUG-1329,issue #59)
|
||||
|
||||
同一测试 `frontend/tests/home-sidebar-rerender.test.tsx`,同一输入。A 是改代码前的 `66cd60081f37a57680274633fcb3fb942d01bb2f`(任务书写的 `77057a819` 已被后续提交盖过)。B 是分支 `codex/home-sidebar-rerender-20261013`。各跑 3 次。会话都是虚构的「Synthetic session N」。没有真实会话内容。
|
||||
|
||||
度量是 60 帧里的注册次数、侧栏提交次数、这 60 帧的 Profiler 耗时合计。内容用剔除函数字段后的 SHA-256 比较。
|
||||
|
||||
| 档 | A 注册 / 提交 / 耗时中位 | B 注册 / 提交 / 耗时中位 | 内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| 30 条 | 60 / 60 / 2778.961 毫秒 | 0 / 0 / 0 | 两边都是 `b18f05a3d83be5b41584787b7c0495f1fcec8ef7b8ecfbdbb3c083c40796c103` |
|
||||
| 100 条 | 60 / 60 / 11799.253 毫秒 | 0 / 0 / 0 | 两边都是 `cef0a63d6f3fe72e541f9ac882b8eaf305b6933fdd07ca3002e8cd03bb139d25` |
|
||||
|
||||
A 的三次 30 条耗时:2774.964、2778.961、4202.085。100 条:10530.828、11799.253、12284.103。B 的三次都是 0。
|
||||
|
||||
正确性:换当前项后 B 注册 1 次,外壳看到新的当前项。改一行标题后标题更新。换成另一人的会话后列表换掉。只改回调版本不再注册,点「打开会话」和「改名」记到的都是第 4 版。A 在只改回调版本时还会再注册 1 次;B 不注册,但调用结果相同。
|
||||
|
||||
判定:B 在 60 帧内注册 0、侧栏提交 0,内容摘要一致,`session-list-lifecycle` 全绿。可以交付。
|
||||
|
||||
浏览器 A/B 未跑。
|
||||
@@ -551,6 +551,7 @@ one skeleton, none of them carrying the sidebar.
|
||||
`children` through unchanged. Registering fresh controls/rows must not render
|
||||
their producer again: that feedback loop can starve client navigation.
|
||||
Business changes still publish current callbacks; Home unmount clears them.
|
||||
流式期间侧栏不随回答帧重渲染(BUG-1329)。
|
||||
- **One sidebar component, two modes.** `AppSidebar` is the only sidebar in the
|
||||
product. `/` passes `controls`; the secondary layout does not, and without it
|
||||
the same markup renders read-only. There used to be a second component for
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
@@ -169,7 +170,13 @@ export function SidebarProvider({
|
||||
else setOpen(!open);
|
||||
}, [isMobile, open, openMobile, setOpen, setOpenMobile]);
|
||||
|
||||
const context: SidebarProviderContextValue = {
|
||||
const setInsetTrigger = useCallback((element: HTMLButtonElement | null) => {
|
||||
insetTriggerRef.current = element;
|
||||
}, []);
|
||||
const setSidebarSurface = useCallback((element: HTMLElement | null) => {
|
||||
sidebarSurfaceRef.current = element;
|
||||
}, []);
|
||||
const context = useMemo<SidebarProviderContextValue>(() => ({
|
||||
state: open ? "expanded" : "collapsed",
|
||||
open,
|
||||
setOpen,
|
||||
@@ -179,9 +186,12 @@ export function SidebarProvider({
|
||||
ready,
|
||||
isMobile,
|
||||
toggleSidebar,
|
||||
setInsetTrigger: (element) => { insetTriggerRef.current = element; },
|
||||
setSidebarSurface: (element) => { sidebarSurfaceRef.current = element; },
|
||||
};
|
||||
setInsetTrigger,
|
||||
setSidebarSurface,
|
||||
}), [
|
||||
open, openMobile, viewport, ready, isMobile, setOpen, setOpenMobile, toggleSidebar,
|
||||
setInsetTrigger, setSidebarSurface,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={context}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type Dispatch, type RefObject, type SetStateAction } from "react";
|
||||
import { useEffect, useMemo, useRef, type Dispatch, type RefObject, type SetStateAction } from "react";
|
||||
|
||||
import type { ChatSession } from "@/lib/home-types";
|
||||
import type { Account } from "@/lib/home-types";
|
||||
@@ -43,6 +43,68 @@ type HomeShellRegistrationParams = {
|
||||
visibleSessions: readonly ChatSession[];
|
||||
};
|
||||
|
||||
type LatestShellActions = {
|
||||
sessions: readonly ChatSession[];
|
||||
loadMoreSessions: () => void;
|
||||
renameSession: (session: ChatSession) => void | Promise<void>;
|
||||
selectSession: (sessionId: string) => void;
|
||||
shareSession: (session: ChatSession) => void | Promise<void>;
|
||||
startNewChat: () => void | Promise<unknown>;
|
||||
togglePinnedSession: (sessionId: string) => void;
|
||||
openAccountDialog: (dialog: AccountDialogName, options?: { source?: string }) => void;
|
||||
setSessionMenuId: Dispatch<SetStateAction<string | null>>;
|
||||
setAccountMenuOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setPendingSessionDeletion: Dispatch<SetStateAction<ChatSession | null>>;
|
||||
};
|
||||
|
||||
type PublishedShellInputs = {
|
||||
hydrated: boolean;
|
||||
account: Account;
|
||||
profileName: string;
|
||||
accountMenuOpen: boolean;
|
||||
modalOpen: boolean;
|
||||
activeSessionId: string | undefined;
|
||||
rectificationOpeningSessionId: string | null;
|
||||
rectificationSurfaceOpen: boolean;
|
||||
creatingSession: boolean;
|
||||
pendingSessionId: string | null;
|
||||
cancellationPending: boolean;
|
||||
modelCatalog: PublicLanguageModelCatalog | null;
|
||||
sessionMenuId: string | null;
|
||||
sessionsCursor: string | null;
|
||||
accountTrigger: RefObject<HTMLButtonElement | null>;
|
||||
chartLibrary: readonly ChartLibraryRecord[];
|
||||
visibleSessions: readonly ChatSession[];
|
||||
};
|
||||
|
||||
function sameVisibleSessions(left: readonly ChatSession[], right: readonly ChatSession[]) {
|
||||
if (left.length !== right.length) return false;
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function samePublishedInputs(previous: PublishedShellInputs, next: PublishedShellInputs) {
|
||||
return previous.hydrated === next.hydrated
|
||||
&& previous.account === next.account
|
||||
&& previous.profileName === next.profileName
|
||||
&& previous.accountMenuOpen === next.accountMenuOpen
|
||||
&& previous.modalOpen === next.modalOpen
|
||||
&& previous.activeSessionId === next.activeSessionId
|
||||
&& previous.rectificationOpeningSessionId === next.rectificationOpeningSessionId
|
||||
&& previous.rectificationSurfaceOpen === next.rectificationSurfaceOpen
|
||||
&& previous.creatingSession === next.creatingSession
|
||||
&& previous.pendingSessionId === next.pendingSessionId
|
||||
&& previous.cancellationPending === next.cancellationPending
|
||||
&& previous.modelCatalog === next.modelCatalog
|
||||
&& previous.sessionMenuId === next.sessionMenuId
|
||||
&& previous.sessionsCursor === next.sessionsCursor
|
||||
&& previous.accountTrigger === next.accountTrigger
|
||||
&& previous.chartLibrary === next.chartLibrary
|
||||
&& sameVisibleSessions(previous.visibleSessions, next.visibleSessions);
|
||||
}
|
||||
|
||||
export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
|
||||
const {
|
||||
account,
|
||||
@@ -75,6 +137,27 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
|
||||
togglePinnedSession,
|
||||
visibleSessions,
|
||||
} = params;
|
||||
const profileName = profile.name;
|
||||
const latestRef = useRef<LatestShellActions | null>(null);
|
||||
const publishedRef = useRef<PublishedShellInputs | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// No dependency list: this has to land after every render and before the
|
||||
// publish effect, so a later click runs this render's callbacks (BUG-725).
|
||||
latestRef.current = {
|
||||
sessions,
|
||||
loadMoreSessions,
|
||||
renameSession,
|
||||
selectSession,
|
||||
shareSession,
|
||||
startNewChat,
|
||||
togglePinnedSession,
|
||||
openAccountDialog,
|
||||
setSessionMenuId,
|
||||
setAccountMenuOpen,
|
||||
setPendingSessionDeletion,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// The inline dead-screen script stands down only after the app has revealed.
|
||||
@@ -84,12 +167,59 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
|
||||
document.documentElement.dataset.hydrated = "1";
|
||||
}, [hydrated]);
|
||||
|
||||
const stableControls = useMemo(() => ({
|
||||
onLoadMore: () => { latestRef.current?.loadMoreSessions(); },
|
||||
onMenuSessionChange: (sessionId: string | null) => { latestRef.current?.setSessionMenuId(sessionId); },
|
||||
onTogglePinned: (sessionId: string) => { latestRef.current?.togglePinnedSession(sessionId); },
|
||||
onRename: (sessionId: string) => {
|
||||
const session = latestRef.current?.sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void latestRef.current?.renameSession(session);
|
||||
},
|
||||
onShare: (sessionId: string) => {
|
||||
const session = latestRef.current?.sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void latestRef.current?.shareSession(session);
|
||||
},
|
||||
onDelete: (sessionId: string) => {
|
||||
const session = latestRef.current?.sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) latestRef.current?.setPendingSessionDeletion(session);
|
||||
},
|
||||
onAccountMenuOpenChange: (open: boolean) => { latestRef.current?.setAccountMenuOpen(open); },
|
||||
onNewChat: () => { void latestRef.current?.startNewChat(); },
|
||||
onSelectSession: (sessionId: string) => { latestRef.current?.selectSession(sessionId); },
|
||||
onOpenProfile: () => { latestRef.current?.openAccountDialog("profile"); },
|
||||
onOpenGeneral: () => { latestRef.current?.openAccountDialog("general"); },
|
||||
onOpenBilling: () => { latestRef.current?.openAccountDialog("billing", { source: "account-menu" }); },
|
||||
onOpenLogout: () => { latestRef.current?.openAccountDialog("logout"); },
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !account) {
|
||||
publishedRef.current = null;
|
||||
registerShellControls(null);
|
||||
return;
|
||||
}
|
||||
const name = profile.name.trim();
|
||||
const next: PublishedShellInputs = {
|
||||
hydrated,
|
||||
account,
|
||||
profileName,
|
||||
accountMenuOpen,
|
||||
modalOpen,
|
||||
activeSessionId,
|
||||
rectificationOpeningSessionId,
|
||||
rectificationSurfaceOpen,
|
||||
creatingSession,
|
||||
pendingSessionId,
|
||||
cancellationPending,
|
||||
modelCatalog,
|
||||
sessionMenuId,
|
||||
sessionsCursor,
|
||||
accountTrigger,
|
||||
chartLibrary,
|
||||
visibleSessions,
|
||||
};
|
||||
if (publishedRef.current && samePublishedInputs(publishedRef.current, next)) return;
|
||||
publishedRef.current = next;
|
||||
const name = profileName.trim();
|
||||
const email = account.user.email || "尚未读取邮箱";
|
||||
registerShellControls({
|
||||
escapeBlocked: accountMenuOpen || modalOpen,
|
||||
@@ -112,42 +242,34 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
|
||||
accountTriggerRef: accountTrigger,
|
||||
sessionControls: {
|
||||
hasMore: Boolean(sessionsCursor),
|
||||
onLoadMore: loadMoreSessions,
|
||||
onLoadMore: stableControls.onLoadMore,
|
||||
menuSessionId: sessionMenuId,
|
||||
disabled: Boolean(pendingSessionId) || cancellationPending,
|
||||
onMenuSessionChange: setSessionMenuId,
|
||||
onTogglePinned: togglePinnedSession,
|
||||
onRename: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void renameSession(session);
|
||||
},
|
||||
onShare: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void shareSession(session);
|
||||
},
|
||||
onDelete: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) setPendingSessionDeletion(session);
|
||||
},
|
||||
onMenuSessionChange: stableControls.onMenuSessionChange,
|
||||
onTogglePinned: stableControls.onTogglePinned,
|
||||
onRename: stableControls.onRename,
|
||||
onShare: stableControls.onShare,
|
||||
onDelete: stableControls.onDelete,
|
||||
},
|
||||
onAccountMenuOpenChange: setAccountMenuOpen,
|
||||
onNewChat: () => { void startNewChat(); },
|
||||
onSelectSession: selectSession,
|
||||
onOpenProfile: () => openAccountDialog("profile"),
|
||||
onOpenGeneral: () => openAccountDialog("general"),
|
||||
onOpenBilling: () => openAccountDialog("billing", { source: "account-menu" }),
|
||||
onOpenLogout: () => openAccountDialog("logout"),
|
||||
onAccountMenuOpenChange: stableControls.onAccountMenuOpenChange,
|
||||
onNewChat: stableControls.onNewChat,
|
||||
onSelectSession: stableControls.onSelectSession,
|
||||
onOpenProfile: stableControls.onOpenProfile,
|
||||
onOpenGeneral: stableControls.onOpenGeneral,
|
||||
onOpenBilling: stableControls.onOpenBilling,
|
||||
onOpenLogout: stableControls.onOpenLogout,
|
||||
},
|
||||
});
|
||||
}, [
|
||||
account, accountMenuOpen, accountTrigger, activeSessionId, cancellationPending,
|
||||
chartLibrary, creatingSession, hydrated, loadMoreSessions, modalOpen, modelCatalog,
|
||||
openAccountDialog, pendingSessionId, profile.name,
|
||||
chartLibrary, creatingSession, hydrated, modalOpen, modelCatalog,
|
||||
pendingSessionId, profileName,
|
||||
rectificationOpeningSessionId, rectificationSurfaceOpen,
|
||||
registerShellControls, renameSession, selectSession, sessionMenuId, sessions,
|
||||
sessionsCursor, setAccountMenuOpen, setPendingSessionDeletion, setSessionMenuId,
|
||||
shareSession, startNewChat, togglePinnedSession, visibleSessions,
|
||||
registerShellControls, sessionMenuId, sessionsCursor, stableControls, visibleSessions,
|
||||
]);
|
||||
|
||||
useEffect(() => () => registerShellControls(null), [registerShellControls]);
|
||||
useEffect(() => () => {
|
||||
publishedRef.current = null;
|
||||
registerShellControls(null);
|
||||
}, [registerShellControls]);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
|
||||
import { writeChatSession } from "@/lib/chat-session-write-contract";
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
writeSessionUrl,
|
||||
} from "@/lib/chat-session-url";
|
||||
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
|
||||
import { currentSubjectCatalogReady, readCurrentSubjectId, readCurrentSubjectScope, subscribeCurrentSubject, subscribeSubjectCatalog } from "@/lib/current-subject";
|
||||
import { currentSubjectCatalogReady, readCurrentSubjectId, readCurrentSubjectScope, subscribeCurrentSubject, subscribeSubjectCatalog, useCurrentSubject } from "@/lib/current-subject";
|
||||
import { mergeSubjectSessionRows } from "@/lib/subject-session-merge";
|
||||
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
|
||||
import { fallbackSessionId } from "@/lib/rectification-session-composer-guard";
|
||||
@@ -181,8 +181,9 @@ export function useSessionManagement(params: SessionManagementParams) {
|
||||
if (!isUnsavedEmptyConsultation(session)) cloudCreatedIds.current.add(session.id);
|
||||
}
|
||||
|
||||
const visibleSessions = sortSessions(sessions.filter((session) => isListedSidebarSession(session)
|
||||
&& sessionMatchesSubject(session, readCurrentSubjectId())));
|
||||
const subjectId = useCurrentSubject();
|
||||
const visibleSessions = useMemo(() => sortSessions(sessions.filter((session) => isListedSidebarSession(session)
|
||||
&& sessionMatchesSubject(session, subjectId))), [sessions, subjectId]);
|
||||
|
||||
function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) {
|
||||
setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session)));
|
||||
|
||||
@@ -39,7 +39,10 @@ test("in-app destinations navigate client-side so the chat survives the round tr
|
||||
assert.match(sidebarSource, /<SidebarMenuLink\n\s*className="report-nav-button"/);
|
||||
assert.doesNotMatch(sidebarSource, /window\.location/);
|
||||
|
||||
assert.match(pageSource, /onOpenBilling: \(\) => openAccountDialog\("billing", \{ source: "account-menu" \}\)/);
|
||||
// 原值: /onOpenBilling: \(\) => openAccountDialog\("billing", \{ source: "account-menu" \}\)/
|
||||
// 新值: /onOpenBilling: \(\) => \{ latestRef\.current\?\.openAccountDialog\("billing", \{ source: "account-menu" \}\); \}/
|
||||
// 原因: 侧栏按钮改成稳定包装,点下去仍打开充值,来源仍是账户菜单。不得放宽成只查 "billing"。
|
||||
assert.match(pageSource, /onOpenBilling: \(\) => \{ latestRef\.current\?\.openAccountDialog\("billing", \{ source: "account-menu" \}\); \}/);
|
||||
assert.match(pageSource, /openAccountDialog\("billing", \{ returnTarget: event\.currentTarget, source: "credits" \}\)/);
|
||||
assert.match(pageSource, /openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/);
|
||||
// 原值: /if \(response\.status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import test from "node:test";
|
||||
import { Profiler, useCallback, useEffect, useState, type ProfilerOnRenderCallback, type ReactNode } from "react";
|
||||
|
||||
import { AppSidebar } from "../src/components/app-sidebar.tsx";
|
||||
import { SidebarInset, SidebarProvider } from "../src/components/ui/sidebar.tsx";
|
||||
import { useHomeShellRegistration } from "../src/hooks/use-home-shell-registration.ts";
|
||||
import { resetSubjectCatalogForTests } from "../src/lib/current-subject.ts";
|
||||
import { emptyProfile, type Account, type ChatSession } from "../src/lib/home-types.ts";
|
||||
import {
|
||||
SessionListProvider,
|
||||
sessionListSidebarModel,
|
||||
useSessionList,
|
||||
useShellRegistration,
|
||||
type ShellRegistration,
|
||||
} from "../src/lib/session-list-context.tsx";
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
|
||||
|
||||
const account: Account = {
|
||||
user: { id: "11111111-1111-4111-8111-111111111111", email: null },
|
||||
profile: { ...emptyProfile, name: "Synthetic" },
|
||||
avatar: null, credits: 10, isAdmin: false, adminUrl: null,
|
||||
rectificationPriceCredits: 0, activeSubscription: null,
|
||||
hasConfirmedBirthTime: false, hasUsableBirthTime: false,
|
||||
};
|
||||
const trigger = { current: null };
|
||||
const charts: never[] = [];
|
||||
|
||||
function syntheticSession(index: number, title = `Synthetic session ${index}`): ChatSession {
|
||||
return {
|
||||
id: `session-${index}`,
|
||||
title,
|
||||
theme: "general",
|
||||
modelId: "test",
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1_700_000_000_000 + index,
|
||||
sessionType: "consultation",
|
||||
rectificationCaseId: null,
|
||||
chartProfileId: null,
|
||||
chartProfileName: null,
|
||||
chartProfileRole: null,
|
||||
pinned: false,
|
||||
archivedAt: null,
|
||||
messagesHydrated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function registrationData(registration: ShellRegistration | null) {
|
||||
if (!registration) return null;
|
||||
return {
|
||||
activeSessionId: registration.activeSessionId,
|
||||
openingSessionId: registration.openingSessionId,
|
||||
escapeBlocked: registration.escapeBlocked,
|
||||
insetClassName: registration.insetClassName,
|
||||
insetInert: registration.insetInert,
|
||||
account: registration.sidebarAccount ?? null,
|
||||
sessions: registration.sidebarSessions ?? [],
|
||||
newChatDisabled: registration.controls.newChatDisabled,
|
||||
creatingSession: registration.controls.creatingSession,
|
||||
accountMenuOpen: registration.controls.accountMenuOpen,
|
||||
hasMore: registration.controls.sessionControls.hasMore ?? false,
|
||||
menuSessionId: registration.controls.sessionControls.menuSessionId,
|
||||
disabled: registration.controls.sessionControls.disabled,
|
||||
};
|
||||
}
|
||||
|
||||
function payloadSha(data: ReturnType<typeof registrationData>) {
|
||||
return createHash("sha256").update(JSON.stringify(data)).digest("hex");
|
||||
}
|
||||
|
||||
async function runProbe(sessionCount: number) {
|
||||
const harness = createClientLifecycleHarness();
|
||||
resetSubjectCatalogForTests();
|
||||
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
const storageValues = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storageValues.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storageValues.set(key, String(value)); },
|
||||
removeItem: (key: string) => { storageValues.delete(key); },
|
||||
clear: () => { storageValues.clear(); },
|
||||
key: (index: number) => Array.from(storageValues.keys())[index] ?? null,
|
||||
get length() { return storageValues.size; },
|
||||
} satisfies Storage,
|
||||
});
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalIntersection = Object.getOwnPropertyDescriptor(globalThis, "IntersectionObserver");
|
||||
class FakeIntersectionObserver {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
}
|
||||
Object.defineProperty(globalThis, "IntersectionObserver", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: FakeIntersectionObserver,
|
||||
});
|
||||
globalThis.fetch = (async (input: unknown) => {
|
||||
const url = String(input);
|
||||
const payload = url === "/api/account" ? account
|
||||
: url === "/api/chart-profiles" ? { profiles: [] }
|
||||
: { sessions: [], nextCursor: null };
|
||||
return new Response(JSON.stringify(payload), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const metrics = { registers: 0, commits: 0, actualDuration: 0, recording: false };
|
||||
const selectRevisions: number[] = [];
|
||||
const renameRevisions: number[] = [];
|
||||
const driver: {
|
||||
setFrame: (frame: number) => void;
|
||||
setActive: (id: string) => void;
|
||||
retitle: () => void;
|
||||
switchPerson: () => void;
|
||||
setRevision: (revision: number) => void;
|
||||
} = {
|
||||
setFrame: () => {},
|
||||
setActive: () => {},
|
||||
retitle: () => {},
|
||||
switchPerson: () => {},
|
||||
setRevision: () => {},
|
||||
};
|
||||
let latestShell: ShellRegistration | null = null;
|
||||
const currentShell = (): ShellRegistration | null => latestShell;
|
||||
const initialSessions = Array.from({ length: sessionCount }, (_, index) => syntheticSession(index));
|
||||
const otherPerson = [syntheticSession(sessionCount + 1, "Other person session")];
|
||||
|
||||
function HomeProbe() {
|
||||
const list = useSessionList();
|
||||
const [frame, setFrame] = useState(0);
|
||||
const [activeSessionId, setActive] = useState(initialSessions[0]?.id ?? null);
|
||||
const [sessions, setSessions] = useState(initialSessions);
|
||||
const [revision, setRevision] = useState(0);
|
||||
// Production passes SessionListProvider's stable registrar. A fresh wrapper
|
||||
// every render would rerun the unmount cleanup and publish twice per frame.
|
||||
const registerShellControls = useCallback((registration: ShellRegistration | null) => {
|
||||
if (metrics.recording) metrics.registers += 1;
|
||||
list.registerShellControls(registration);
|
||||
}, [list]);
|
||||
useHomeShellRegistration({
|
||||
account: list.account,
|
||||
accountMenuOpen: false,
|
||||
accountTrigger: trigger,
|
||||
activeSessionId: activeSessionId ?? undefined,
|
||||
cancellationPending: false,
|
||||
chartLibrary: charts,
|
||||
creatingSession: false,
|
||||
hydrated: list.settled,
|
||||
loadMoreSessions: () => {},
|
||||
modalOpen: false,
|
||||
modelCatalog: null,
|
||||
openAccountDialog: () => {},
|
||||
pendingSessionId: null,
|
||||
profile: { ...emptyProfile, name: "Synthetic" },
|
||||
rectificationOpeningSessionId: null,
|
||||
rectificationSurfaceOpen: false,
|
||||
registerShellControls,
|
||||
renameSession: () => { renameRevisions.push(revision); },
|
||||
selectSession: () => { selectRevisions.push(revision); },
|
||||
sessionMenuId: null,
|
||||
sessions,
|
||||
sessionsCursor: null,
|
||||
setAccountMenuOpen: () => {},
|
||||
setPendingSessionDeletion: () => {},
|
||||
setSessionMenuId: () => {},
|
||||
shareSession: () => {},
|
||||
startNewChat: () => {},
|
||||
togglePinnedSession: () => {},
|
||||
// New array every render. Element identity stays until a real edit.
|
||||
visibleSessions: sessions.filter(() => true),
|
||||
});
|
||||
void frame;
|
||||
useEffect(() => {
|
||||
driver.setFrame = setFrame;
|
||||
driver.setActive = setActive;
|
||||
driver.retitle = () => setSessions((current) => current.map((session, index) => (
|
||||
index === 0 ? { ...session, title: "Renamed synthetic" } : session
|
||||
)));
|
||||
driver.switchPerson = () => setSessions(otherPerson);
|
||||
driver.setRevision = setRevision;
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
|
||||
const onSidebarRender: ProfilerOnRenderCallback = (_id, phase, actualDuration) => {
|
||||
if (!metrics.recording) return;
|
||||
if (phase !== "mount" && phase !== "update") return;
|
||||
metrics.commits += 1;
|
||||
metrics.actualDuration += actualDuration;
|
||||
};
|
||||
|
||||
function Shell({ children }: { children: ReactNode }) {
|
||||
const list = useSessionList();
|
||||
const registration = useShellRegistration();
|
||||
const model = sessionListSidebarModel(list, registration);
|
||||
useEffect(() => { latestShell = registration; });
|
||||
return (
|
||||
<SidebarProvider escapeBlocked={registration?.escapeBlocked ?? false}>
|
||||
<Profiler id="sidebar" onRender={onSidebarRender}>
|
||||
<AppSidebar
|
||||
sessions={model.sessions}
|
||||
activeSessionId={registration?.activeSessionId ?? null}
|
||||
openingSessionId={registration?.openingSessionId ?? null}
|
||||
account={model.account}
|
||||
settled={list.settled}
|
||||
signedOut={list.signedOut}
|
||||
controls={registration?.controls}
|
||||
/>
|
||||
</Profiler>
|
||||
<SidebarInset inert={registration?.insetInert}>{children}</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
resetSubjectCatalogForTests();
|
||||
if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
if (originalIntersection) Object.defineProperty(globalThis, "IntersectionObserver", originalIntersection);
|
||||
else Reflect.deleteProperty(globalThis, "IntersectionObserver");
|
||||
};
|
||||
|
||||
try {
|
||||
await harness.render(
|
||||
<SessionListProvider>
|
||||
<Shell><HomeProbe /></Shell>
|
||||
</SessionListProvider>,
|
||||
);
|
||||
await harness.idle();
|
||||
assert.equal(harness.errors.length, 0);
|
||||
assert.ok(latestShell, "hydrated home registers once before the frame run");
|
||||
metrics.recording = true;
|
||||
metrics.registers = 0;
|
||||
metrics.commits = 0;
|
||||
metrics.actualDuration = 0;
|
||||
for (let frame = 1; frame <= 60; frame += 1) {
|
||||
await harness.update(() => driver.setFrame(frame));
|
||||
}
|
||||
const duringFrames = {
|
||||
registers: metrics.registers,
|
||||
commits: metrics.commits,
|
||||
actualDuration: Number(metrics.actualDuration.toFixed(3)),
|
||||
};
|
||||
metrics.registers = 0;
|
||||
metrics.commits = 0;
|
||||
await harness.update(() => driver.setActive("session-active"));
|
||||
const afterActiveRegisters = metrics.registers;
|
||||
const afterActiveCommits = metrics.commits;
|
||||
const afterActive = registrationData(currentShell());
|
||||
metrics.registers = 0;
|
||||
await harness.update(() => driver.retitle());
|
||||
const afterTitleRegisters = metrics.registers;
|
||||
const renamedTitle = currentShell()?.sidebarSessions?.[0]?.title ?? null;
|
||||
metrics.registers = 0;
|
||||
await harness.update(() => driver.setRevision(4));
|
||||
const afterRevisionRegisters = metrics.registers;
|
||||
currentShell()?.controls.onSelectSession("session-0");
|
||||
currentShell()?.controls.sessionControls.onRename("session-0");
|
||||
metrics.registers = 0;
|
||||
await harness.update(() => driver.switchPerson());
|
||||
const afterSwitchRegisters = metrics.registers;
|
||||
const otherTitle = currentShell()?.sidebarSessions?.[0]?.title ?? null;
|
||||
const report = {
|
||||
sessions: sessionCount,
|
||||
duringFrames,
|
||||
afterActiveRegisters,
|
||||
afterActiveCommits,
|
||||
afterActiveSha: payloadSha(afterActive),
|
||||
afterActive,
|
||||
afterTitleRegisters,
|
||||
renamedTitle,
|
||||
afterRevisionRegisters,
|
||||
selectRevisions,
|
||||
renameRevisions,
|
||||
afterSwitchRegisters,
|
||||
otherTitle,
|
||||
};
|
||||
console.log(`SIDEBAR_AB ${JSON.stringify(report)}`);
|
||||
assert.equal(duringFrames.registers, 0);
|
||||
assert.equal(duringFrames.commits, 0);
|
||||
assert.equal(afterActiveRegisters, 1);
|
||||
assert.equal(afterActive?.activeSessionId, "session-active");
|
||||
assert.equal(afterTitleRegisters, 1);
|
||||
assert.equal(renamedTitle, "Renamed synthetic");
|
||||
assert.equal(afterRevisionRegisters, 0);
|
||||
assert.deepEqual(selectRevisions, [4]);
|
||||
assert.deepEqual(renameRevisions, [4]);
|
||||
assert.equal(afterSwitchRegisters, 1);
|
||||
assert.equal(otherTitle, "Other person session");
|
||||
return report;
|
||||
} finally {
|
||||
await harness.close();
|
||||
restore();
|
||||
}
|
||||
}
|
||||
|
||||
for (const sessionCount of [30, 100]) {
|
||||
test(`streaming frames do not republish the sidebar (${sessionCount} sessions)`, async () => {
|
||||
await runProbe(sessionCount);
|
||||
});
|
||||
}
|
||||
@@ -182,9 +182,16 @@ test("shell receives current callbacks, account and session state without freezi
|
||||
assert.equal(app.shell?.activeSessionId, "revision-1");
|
||||
assert.equal(app.shell?.insetInert, true);
|
||||
assert.equal(app.shell?.escapeBlocked, true);
|
||||
assert.notEqual(app.shell?.controls.onOpenProfile, initialCallback);
|
||||
// 原值:assert.notEqual(onOpenProfile, initialCallback),每次注册都是新函数。
|
||||
// 新值:包装函数身份不变;再改一版后仍是同一个函数,调用记到的是当前 revision。
|
||||
// 原因:外壳拿稳定包装,点下去执行 Home 最新一次渲染的回调(BUG-971 / BUG-1329)。
|
||||
assert.equal(app.shell?.controls.onOpenProfile, initialCallback);
|
||||
app.shell?.controls.onOpenProfile();
|
||||
assert.deepEqual(app.callbackRevisions, [1]);
|
||||
await app.revise(2);
|
||||
assert.equal(app.shell?.controls.onOpenProfile, initialCallback);
|
||||
app.shell?.controls.onOpenProfile();
|
||||
assert.deepEqual(app.callbackRevisions, [1, 2]);
|
||||
await app.harness.update(() => {
|
||||
app.list.setAccount({ ...account, credits: 27 });
|
||||
app.list.setSessions([row]);
|
||||
|
||||
+23
-2
@@ -272,6 +272,20 @@ class BhavaChalitCalculator:
|
||||
# fallback: 最近的宫
|
||||
return 1
|
||||
|
||||
def _planet_bhava_from_cusps(self, planet_lon: float, cusps: List[float]) -> int:
|
||||
"""KP 宫头到宫头:宫 i(0 起)的范围是 cusp[i] → cusp[(i+1)%12]。
|
||||
|
||||
落在下一宫头上的点归下一宫。本命 Bhava Chalit 表不用这条。
|
||||
"""
|
||||
for i in range(12):
|
||||
start = cusps[i]
|
||||
end = cusps[(i + 1) % 12]
|
||||
arc = _angular_dist(start, end)
|
||||
pos = _angular_dist(start, planet_lon)
|
||||
if arc > 0 and pos < arc:
|
||||
return i + 1
|
||||
return 1
|
||||
|
||||
def _planet_rashi_house(self, planet_lon: float, asc_lon: float) -> int:
|
||||
"""整宫制宫位 (Rashi house)。"""
|
||||
p_si = _sign_idx(planet_lon)
|
||||
@@ -330,7 +344,8 @@ class BhavaChalitCalculator:
|
||||
asc_lon: float, mc_lon: float,
|
||||
house_system: str = 'sripati',
|
||||
jd: float = None, lat: float = None,
|
||||
lon: float = None) -> Dict:
|
||||
lon: float = None,
|
||||
house_assignment: str = 'sandhi') -> Dict:
|
||||
"""根据 Bhava 边界重新分配行星宫位。
|
||||
|
||||
Parameters
|
||||
@@ -346,13 +361,18 @@ class BhavaChalitCalculator:
|
||||
dict with keys:
|
||||
house_system, boundaries, planets
|
||||
"""
|
||||
if house_assignment not in ('sandhi', 'cusp_to_cusp'):
|
||||
raise ValueError("house_assignment must be 'sandhi' or 'cusp_to_cusp'")
|
||||
cusps = self.calculate_cusps(asc_lon, mc_lon, house_system,
|
||||
jd, lat, lon)
|
||||
sandhis = self.calculate_sandhis(cusps)
|
||||
|
||||
planets = {}
|
||||
for pname, plon in planet_lons.items():
|
||||
bhava = self._planet_bhava(plon, sandhis)
|
||||
if house_assignment == 'cusp_to_cusp':
|
||||
bhava = self._planet_bhava_from_cusps(plon, cusps)
|
||||
else:
|
||||
bhava = self._planet_bhava(plon, sandhis)
|
||||
rashi = self._planet_rashi_house(plon, asc_lon)
|
||||
si = _sign_idx(plon)
|
||||
deg_in_sign = plon - si * 30
|
||||
@@ -380,6 +400,7 @@ class BhavaChalitCalculator:
|
||||
return {
|
||||
'house_system': house_system,
|
||||
'house_system_desc': self.HOUSE_SYSTEMS.get(house_system, ''),
|
||||
'house_assignment': house_assignment,
|
||||
'ascendant_lon': round(asc_lon, 4),
|
||||
'mc_lon': round(mc_lon, 4),
|
||||
'planets': planets,
|
||||
|
||||
@@ -6378,7 +6378,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
|
||||
periods = []
|
||||
current = birth_dt
|
||||
for lord in DASHA_ORDER:
|
||||
end_date = current + timedelta(days=period_years * 365.25636)
|
||||
end_date = current + timedelta(days=period_years * 365.25)
|
||||
periods.append({
|
||||
'lord': lord,
|
||||
'years': period_years,
|
||||
@@ -7398,7 +7398,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
|
||||
current_idx, current_md = analyzer.find_current(timeline, today)
|
||||
antardashas = analyzer.build_antardasha(current_md)
|
||||
current_ad = analyzer.find_current_sub(antardashas, today)
|
||||
years_into_md = max(0.0, (today - current_md['start']).days / 365.25636)
|
||||
years_into_md = max(0.0, (today - current_md['start']).days / 365.25)
|
||||
five_levels = enhanced.calculate_five_level_dasha(current_md['lord'], years_into_md)
|
||||
formatted_timeline = [self._format_datetime_period(period) for period in timeline]
|
||||
formatted_antardashas = [self._format_datetime_period(period) for period in antardashas]
|
||||
@@ -7551,7 +7551,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
|
||||
evidence.append({
|
||||
'label': 'Darakaraka (DK)',
|
||||
'value': f"{dk_planet} · H{darakaraka.get('dk_house', '-')}",
|
||||
'note': f"{darakaraka.get('core_profile') or '配偶象征星'};婚姻质量 {darakaraka.get('marriage_quality_score', '-')}/100",
|
||||
'note': f"{darakaraka.get('core_profile') or '配偶象征星'};婚姻质量指数 {darakaraka.get('marriage_quality_score', '-')}(0到1)",
|
||||
})
|
||||
except Exception as exc:
|
||||
darakaraka = {'error': str(exc), 'dk_planet': dk_planet}
|
||||
|
||||
@@ -13963,6 +13963,7 @@ def cmd_kp(args):
|
||||
)
|
||||
bhava_chart = kp_house_calculator.get_bhava_chalit_chart(
|
||||
planet_lons, float(asc_lon), 0.0, 'placidus', jd, args.lat, args.lon,
|
||||
house_assignment='cusp_to_cusp',
|
||||
)
|
||||
bhava_planets = bhava_chart.get('planets') if isinstance(bhava_chart.get('planets'), dict) else {}
|
||||
|
||||
@@ -13977,7 +13978,12 @@ def cmd_kp(args):
|
||||
'house': bhava_row.get('bhava_house', pdata.get('house', 1)),
|
||||
}
|
||||
|
||||
result = calc_kp_analysis(planet_positions, asc_sign, house_cusps=boundaries.get('cusps'))
|
||||
result = calc_kp_analysis(
|
||||
planet_positions,
|
||||
asc_sign,
|
||||
house_cusps=boundaries.get('cusps'),
|
||||
house_occupancy_basis='cusp_to_cusp',
|
||||
)
|
||||
moon_lon = planets.get('Moon', {}).get('degree_raw', planets.get('Moon', {}).get('degree', 0.0)) if isinstance(planets.get('Moon'), dict) else 0.0
|
||||
try:
|
||||
birth_moment = datetime(int(args.year), int(args.month), int(args.day), int(args.hour), int(getattr(args, 'minute', 0) or 0))
|
||||
|
||||
@@ -371,6 +371,7 @@ def calc_kp_analysis(
|
||||
planet_positions: Dict,
|
||||
asc_sign: str = 'Aries',
|
||||
house_cusps: Optional[List[float]] = None,
|
||||
house_occupancy_basis: Optional[str] = None,
|
||||
) -> Dict:
|
||||
"""
|
||||
完整KP分析(基于 diliprk/VedicAstro MIT 算法)。
|
||||
@@ -379,6 +380,7 @@ def calc_kp_analysis(
|
||||
planet_positions: 行星位置 {planet: {'sign': str, 'degree': float, 'house': int}}
|
||||
asc_sign: 上升星座名称(无显式宫头时用于 whole-sign 代理)
|
||||
house_cusps: 可选的 12 个实际宫头黄经。提供时优先用于 KP 宫头与显著星。
|
||||
house_occupancy_basis: 传了宫头就必须声明判宫口径;没传宫头时记 whole_sign_proxy。
|
||||
|
||||
Returns:
|
||||
完整KP分析结果
|
||||
@@ -405,8 +407,11 @@ def calc_kp_analysis(
|
||||
}
|
||||
|
||||
explicit_cusps = house_cusps is not None
|
||||
if explicit_cusps and not house_occupancy_basis:
|
||||
raise ValueError('house_occupancy_basis is required when house_cusps are provided')
|
||||
if explicit_cusps and len(house_cusps) != 12:
|
||||
raise ValueError('house_cusps must contain exactly 12 longitudes')
|
||||
occupancy_basis = house_occupancy_basis if explicit_cusps else 'whole_sign_proxy'
|
||||
|
||||
# 2. 构建宫位信息(含 KP lords)。无实际宫头时保留历史 whole-sign 中点代理。
|
||||
houses = []
|
||||
@@ -438,6 +443,7 @@ def calc_kp_analysis(
|
||||
'source': 'diliprk/VedicAstro MIT License',
|
||||
'maturity_profile': kp_maturity_profile(),
|
||||
'house_basis': 'explicit_cusps' if explicit_cusps else 'whole_sign_proxy',
|
||||
'house_occupancy_basis': occupancy_basis,
|
||||
'planets': {pname: {'kp_lords': data['kp_lords'], 'significators': planet_sig.get(pname, {})}
|
||||
for pname, data in kp_planets.items()},
|
||||
'houses': {h['house']: {'sign': h['sign'], 'cusp_longitude': h['cusp_longitude'], 'kp_lords': h['kp_lords'], 'significators': house_sig.get(h['house'], {})}
|
||||
@@ -494,6 +500,7 @@ def build_kp_report_pack(
|
||||
f"- claim_status: `{maturity.get('claim_status')}`",
|
||||
f"- truth_matrix_allowed: `{maturity.get('truth_matrix_allowed')}`",
|
||||
f"- closed_oracle_parity_assets: `{', '.join(closed_parity_assets.keys())}`",
|
||||
f"- House occupancy basis: `{kp_analysis.get('house_occupancy_basis') or 'whole_sign_proxy'}`",
|
||||
f"- planet_count: `{len(planets)}`",
|
||||
f"- house_count: `{len(houses)}`",
|
||||
f"- dba_period_count: `{len(periods)}`",
|
||||
|
||||
@@ -21,6 +21,23 @@ from typing import Dict, Optional, List
|
||||
SIGN_CN = ['白羊座','金牛座','双子座','巨蟹座','狮子座','处女座',
|
||||
'天秤座','天蝎座','射手座','摩羯座','水瓶座','双鱼座']
|
||||
|
||||
# D9 结构证据档位(BUG-1318)。中文给报告正文,英文键与英文短句给英文版。
|
||||
# 不写婚姻稳定、离婚、再婚、段数或婚前咨询。
|
||||
D9_CLAIM_BOUNDARY = (
|
||||
'D9 结构证据指数,不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示需要婚前咨询'
|
||||
)
|
||||
D9_CLAIM_BOUNDARY_EN = (
|
||||
'D9 structural evidence index. It does not predict marriage count, stability, '
|
||||
'divorce, or partner quality, and it does not suggest premarital counseling.'
|
||||
)
|
||||
D9_EVIDENCE_BANDS = (
|
||||
('supportive_evidence_stronger', '支持证据较强', 'Stronger supportive evidence'),
|
||||
('supportive_evidence_mixed_positive', '支持证据偏正', 'Mixed supportive evidence'),
|
||||
('mixed_evidence', '证据混杂', 'Mixed evidence'),
|
||||
('pressure_evidence_mixed', '压力证据偏杂', 'Mixed pressure evidence'),
|
||||
('pressure_evidence_stronger', '压力证据较强', 'Stronger pressure evidence'),
|
||||
)
|
||||
|
||||
|
||||
def sign_of(lon: float) -> int:
|
||||
"""从黄道经度计算星座序号 (0-11)"""
|
||||
@@ -348,25 +365,42 @@ def _assess_d9_marriage_quality(
|
||||
quality_points -= 1
|
||||
factors.append("D9 Ketu在7宫 → 关系中的分离倾向")
|
||||
|
||||
# 综合评级
|
||||
# 证据档位,不是婚姻结局(BUG-1318,上游 f891a2c1)。
|
||||
if quality_points >= 4:
|
||||
rating = "高(关系质量好,伴侣支持强,婚姻稳定)"
|
||||
band = D9_EVIDENCE_BANDS[0]
|
||||
elif quality_points >= 1:
|
||||
rating = "中上(关系质量尚可,需经营但基础好)"
|
||||
band = D9_EVIDENCE_BANDS[1]
|
||||
elif quality_points >= 0:
|
||||
rating = "中(关系质量一般,需用心经营)"
|
||||
band = D9_EVIDENCE_BANDS[2]
|
||||
elif quality_points >= -2:
|
||||
rating = "中下(关系有挑战,需提前沟通和调整期望)"
|
||||
band = D9_EVIDENCE_BANDS[3]
|
||||
else:
|
||||
rating = "低(关系质量有重大挑战,强烈建议婚前咨询)"
|
||||
band = D9_EVIDENCE_BANDS[4]
|
||||
|
||||
return {
|
||||
'quality_rating': rating,
|
||||
'quality_band': band[0],
|
||||
'quality_rating': band[1],
|
||||
'quality_rating_en': band[2],
|
||||
'quality_points': quality_points,
|
||||
'factors': factors,
|
||||
'claim_boundary': D9_CLAIM_BOUNDARY,
|
||||
'claim_boundary_en': D9_CLAIM_BOUNDARY_EN,
|
||||
}
|
||||
|
||||
|
||||
def render_d9_evidence_report(quality: Dict, language: str = 'zh') -> str:
|
||||
"""中文报告用证据档位短句;英文版用英文短句。两边都带同一条边界。"""
|
||||
if not isinstance(quality, dict):
|
||||
quality = {}
|
||||
if language == 'en':
|
||||
band = quality.get('quality_rating_en') or quality.get('quality_band') or ''
|
||||
boundary = quality.get('claim_boundary_en') or D9_CLAIM_BOUNDARY_EN
|
||||
return f"D9 evidence band: {band}\n{boundary}"
|
||||
band = quality.get('quality_rating') or ''
|
||||
boundary = quality.get('claim_boundary') or D9_CLAIM_BOUNDARY
|
||||
return f"D9 证据档位:{band}\n{boundary}"
|
||||
|
||||
|
||||
def _marriage_recommendations(base: Dict, d9_quality: Dict) -> List[str]:
|
||||
"""几何指数与伴侣数量、事件断语分开(BUG-1183);不随指数大小给不同结论。"""
|
||||
return [
|
||||
|
||||
@@ -2140,6 +2140,7 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
'### p123 KP Chart / Cusps',
|
||||
'',
|
||||
f"- House basis:{_cell(kp.get('house_basis'))}",
|
||||
f"- House occupancy basis:{_cell(kp.get('house_occupancy_basis'))}",
|
||||
'',
|
||||
'| House | Sign | Cusp longitude | KP lord chain |',
|
||||
'|-------|------|----------------|---------------|',
|
||||
|
||||
@@ -1687,11 +1687,13 @@ class YogaEngine:
|
||||
# 辅助:获取角宫主星列表
|
||||
def _kendra_lords_list():
|
||||
ai = SIGNS.index(ctx.ascendant) if ctx.ascendant in SIGNS else 0
|
||||
return list(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 4, 7, 10]]))
|
||||
# set 顺序随进程哈希变化;排序后同一张盘的角宫主参与者顺序才稳定。
|
||||
return sorted(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 4, 7, 10]]))
|
||||
# 辅助:获取三方宫主星列表
|
||||
def _trikona_lords_list():
|
||||
ai = SIGNS.index(ctx.ascendant) if ctx.ascendant in SIGNS else 0
|
||||
return list(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 5, 9]]))
|
||||
# set 顺序随进程哈希变化;排序后同一张盘的三方主参与者顺序才稳定。
|
||||
return sorted(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 5, 9]]))
|
||||
|
||||
safe_globals = {
|
||||
# 安全内置函数:只开放规则表达式需要的纯函数,避免 custom 规则因 all/any/len/set 缺失而静默失效。
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""BUG-1318: D9 marriage rating is an evidence band, not an outcome claim.
|
||||
|
||||
Upstream f891a2c1. The five bands must not say the relationship is stable,
|
||||
ending, repeated, or in need of premarital counseling. The DK evidence line
|
||||
keeps the 0-1 index and does not print a fake /100 score.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(__file__), "..")
|
||||
SCRIPTS = os.path.join(ROOT, "scripts")
|
||||
if SCRIPTS not in sys.path:
|
||||
sys.path.insert(0, SCRIPTS)
|
||||
|
||||
from jyotish_engine import compute_chart_data # noqa: E402
|
||||
|
||||
|
||||
def _api_handler():
|
||||
"""Reuse the existing unbound handler helper. Do not add another forgery site."""
|
||||
path = os.path.join(os.path.dirname(__file__), "test_api_server_security.py")
|
||||
spec = importlib.util.spec_from_file_location("_api_security_helpers", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module._handler()
|
||||
from marriage_counting import ( # noqa: E402
|
||||
D9_CLAIM_BOUNDARY,
|
||||
D9_EVIDENCE_BANDS,
|
||||
_assess_d9_marriage_quality,
|
||||
marriage_counting_full_analysis,
|
||||
render_d9_evidence_report,
|
||||
)
|
||||
from varga import calc_varga # noqa: E402
|
||||
|
||||
BANNED = re.compile(r"稳定|离婚|再婚|婚前咨询|几段")
|
||||
|
||||
|
||||
def _quality(points_chart: dict) -> dict:
|
||||
return _assess_d9_marriage_quality(points_chart, None)
|
||||
|
||||
|
||||
def test_five_bands_have_no_outcome_claims_and_keep_the_boundary() -> None:
|
||||
samples = {
|
||||
"supportive_evidence_stronger": {"Venus": 331.0, "Mars": 271.0, "Jupiter": 91.0},
|
||||
"supportive_evidence_mixed_positive": {"Venus": 31.0},
|
||||
"mixed_evidence": {"Venus": 10.0},
|
||||
"pressure_evidence_mixed": {"Venus": 151.0},
|
||||
"pressure_evidence_stronger": {"Venus": 151.0, "Mars": 91.0, "Jupiter": 271.0},
|
||||
}
|
||||
seen = []
|
||||
for key, chart in samples.items():
|
||||
quality = _quality(chart)
|
||||
assert quality["quality_band"] == key
|
||||
assert quality["claim_boundary"] == D9_CLAIM_BOUNDARY
|
||||
assert quality["claim_boundary_en"]
|
||||
assert not BANNED.search(quality["quality_rating"])
|
||||
assert not BANNED.search(quality["quality_rating_en"])
|
||||
seen.append(quality["quality_band"])
|
||||
assert seen == [band[0] for band in D9_EVIDENCE_BANDS]
|
||||
|
||||
|
||||
def test_public_chart_report_snapshots_in_both_languages() -> None:
|
||||
cases = json.loads(
|
||||
Path(ROOT, "references/real_case_calibration/minute_rectification_development_v1.json").read_text(encoding="utf-8")
|
||||
)["cases"]
|
||||
birth = next(case for case in cases if case["case_id"] == "steve_jobs_1955_development")["birth"]
|
||||
assert birth["source"]["rodden_rating"] == "AA"
|
||||
year, month, day = (int(part) for part in birth["date"].split("-"))
|
||||
hour, minute = (int(part) for part in birth["time"].split(":"))
|
||||
chart, *_ = compute_chart_data(
|
||||
year, month, day, hour, minute,
|
||||
birth["latitude"], birth["longitude"], birth["timezone_offset"],
|
||||
ayanamsa_name="raman",
|
||||
)
|
||||
longitudes = {name: row["degree_raw"] for name, row in chart["planets"].items()}
|
||||
d9 = {}
|
||||
for name, lon in longitudes.items():
|
||||
divisional = calc_varga(lon, 9)
|
||||
d9[name] = divisional["sign_idx"] * 30 + divisional["degree_in_sign"]
|
||||
result = marriage_counting_full_analysis(chart["houses"]["house_7"]["lord"], longitudes, d9)
|
||||
quality = result["d9_marriage_quality"]
|
||||
assert quality["quality_band"] == "mixed_evidence"
|
||||
assert quality["quality_points"] == 0
|
||||
chinese = render_d9_evidence_report(quality, "zh")
|
||||
english = render_d9_evidence_report(quality, "en")
|
||||
assert chinese == (
|
||||
"D9 证据档位:证据混杂\n"
|
||||
"D9 结构证据指数,不预测婚姻次数、稳定、离婚、伴侣好坏,也不提示需要婚前咨询"
|
||||
)
|
||||
assert english == (
|
||||
"D9 evidence band: Mixed evidence\n"
|
||||
"D9 structural evidence index. It does not predict marriage count, stability, "
|
||||
"divorce, or partner quality, and it does not suggest premarital counseling."
|
||||
)
|
||||
assert "/100" not in chinese
|
||||
assert "/100" not in english
|
||||
|
||||
|
||||
def test_dk_evidence_line_prints_the_zero_to_one_index() -> None:
|
||||
cases = json.loads(
|
||||
Path(ROOT, "references/real_case_calibration/minute_rectification_development_v1.json").read_text(encoding="utf-8")
|
||||
)["cases"]
|
||||
birth = next(case for case in cases if case["case_id"] == "steve_jobs_1955_development")["birth"]
|
||||
year, month, day = (int(part) for part in birth["date"].split("-"))
|
||||
hour, minute = (int(part) for part in birth["time"].split(":"))
|
||||
chart, asc_idx, *_ = compute_chart_data(
|
||||
year, month, day, hour, minute,
|
||||
birth["latitude"], birth["longitude"], birth["timezone_offset"],
|
||||
ayanamsa_name="raman",
|
||||
)
|
||||
longitudes = {name: row["degree_raw"] for name, row in chart["planets"].items()}
|
||||
handler = _api_handler()
|
||||
evidence = handler._compute_relationship_timing_evidence(
|
||||
{},
|
||||
chart["planets"],
|
||||
longitudes,
|
||||
asc_idx,
|
||||
chart["ascendant"]["sign"],
|
||||
)
|
||||
notes = [row["note"] for row in evidence["evidence"] if row.get("label") == "Darakaraka (DK)"]
|
||||
assert len(notes) == 1
|
||||
assert "/100" not in notes[0]
|
||||
assert "婚姻质量指数" in notes[0]
|
||||
assert "(0到1)" in notes[0]
|
||||
score = evidence["darakaraka"]["marriage_quality_score"]
|
||||
assert isinstance(score, float)
|
||||
assert 0.0 <= score <= 1.0
|
||||
assert str(score) in notes[0]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""BUG-1321: the API fallback dasha year matches dasha_analyzer's 365.25 days.
|
||||
|
||||
Upstream 4d543209. The two 365.25636 constants in the API server made the
|
||||
fallback timeline and the years-into-mahadasha figure drift from the analyzer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
||||
if SCRIPTS not in sys.path:
|
||||
sys.path.insert(0, SCRIPTS)
|
||||
|
||||
from dasha_analyzer import build_dasha_timeline, lon_to_nakshatra # noqa: E402
|
||||
|
||||
|
||||
def _api_handler():
|
||||
"""Reuse the existing unbound handler helper. Do not add another forgery site."""
|
||||
path = os.path.join(os.path.dirname(__file__), "test_api_server_security.py")
|
||||
spec = importlib.util.spec_from_file_location("_api_security_helpers_dasha", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module._handler()
|
||||
|
||||
|
||||
def test_fallback_timeline_matches_analyzer_to_the_day() -> None:
|
||||
birth = datetime(2000, 1, 1)
|
||||
nak_info, progress, _pada = lon_to_nakshatra(0.0)
|
||||
timeline, _elapsed, _remaining, _lord = build_dasha_timeline("2000-01-01", nak_info, progress)
|
||||
handler = _api_handler()
|
||||
years = timeline[0]["years"]
|
||||
periods = handler._fallback_dasha_periods(birth, "generic", {"years": years * 9})
|
||||
assert periods[0]["lord"] == timeline[0]["lord"]
|
||||
assert periods[0]["start"] == timeline[0]["start"].strftime("%Y-%m-%d")
|
||||
assert periods[0]["end"] == timeline[0]["end"].strftime("%Y-%m-%d")
|
||||
fallback_days = (
|
||||
datetime.strptime(periods[0]["end"], "%Y-%m-%d")
|
||||
- datetime.strptime(periods[0]["start"], "%Y-%m-%d")
|
||||
).days
|
||||
analyzer_days = (timeline[0]["end"].date() - timeline[0]["start"].date()).days
|
||||
assert fallback_days - analyzer_days == 0
|
||||
|
||||
long_periods = handler._fallback_dasha_periods(birth, "generic", {"years": 1422})
|
||||
expected = (birth + timedelta(days=158 * 365.25)).strftime("%Y-%m-%d")
|
||||
stale = (birth + timedelta(days=158 * 365.25636)).strftime("%Y-%m-%d")
|
||||
assert expected != stale
|
||||
assert long_periods[0]["end"] == expected
|
||||
|
||||
|
||||
def test_years_into_mahadasha_uses_the_analyzer_year() -> None:
|
||||
birth = datetime(2000, 1, 1)
|
||||
today = datetime(2003, 1, 1)
|
||||
handler = _api_handler()
|
||||
layer = handler._compute_vimshottari_analysis_layer(birth, 0.0, current_date="2003-01-01")
|
||||
days = (today - birth).days
|
||||
years_into = layer["five_levels"]["mahadasha"]["years_into_period"]
|
||||
assert years_into == days / 365.25
|
||||
assert years_into != days / 365.25636
|
||||
@@ -0,0 +1,58 @@
|
||||
"""BUG-1319: KP house occupancy is cusp-to-cusp and must be declared.
|
||||
|
||||
Upstream 0337b77f. Natal Bhava Chalit stays on the sandhi midpoint rule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
||||
if SCRIPTS not in sys.path:
|
||||
sys.path.insert(0, SCRIPTS)
|
||||
|
||||
from bhava_chalit import BhavaChalitCalculator # noqa: E402
|
||||
from kp_system import build_kp_report_pack, calc_kp_analysis # noqa: E402
|
||||
|
||||
|
||||
def test_cusp_to_cusp_differs_from_sandhi_and_default_houses_stay() -> None:
|
||||
calc = BhavaChalitCalculator()
|
||||
planets = {"Sun": 40.0}
|
||||
sandhi = calc.get_bhava_chalit_chart(planets, 15.0, 270.0, "equal")
|
||||
explicit = calc.get_bhava_chalit_chart(
|
||||
planets, 15.0, 270.0, "equal", house_assignment="sandhi",
|
||||
)
|
||||
cusp = calc.get_bhava_chalit_chart(
|
||||
planets, 15.0, 270.0, "equal", house_assignment="cusp_to_cusp",
|
||||
)
|
||||
assert sandhi["house_assignment"] == "sandhi"
|
||||
assert explicit["planets"]["Sun"]["bhava_house"] == sandhi["planets"]["Sun"]["bhava_house"] == 2
|
||||
assert cusp["house_assignment"] == "cusp_to_cusp"
|
||||
assert cusp["planets"]["Sun"]["bhava_house"] == 1
|
||||
assert cusp["planets"]["Sun"]["bhava_house"] != sandhi["planets"]["Sun"]["bhava_house"]
|
||||
|
||||
|
||||
def test_unknown_house_assignment_is_rejected() -> None:
|
||||
calc = BhavaChalitCalculator()
|
||||
with pytest.raises(ValueError, match="house_assignment"):
|
||||
calc.get_bhava_chalit_chart({"Sun": 10.0}, 15.0, 270.0, "equal", house_assignment="whole")
|
||||
|
||||
|
||||
def test_kp_cusps_require_an_occupancy_basis_and_the_report_names_it() -> None:
|
||||
planets = {"Sun": {"sign": "Taurus", "degree": 10.0, "house": 2}}
|
||||
cusps = [15.0 + 30.0 * index for index in range(12)]
|
||||
with pytest.raises(ValueError, match="house_occupancy_basis"):
|
||||
calc_kp_analysis(planets, "Aries", house_cusps=cusps)
|
||||
with pytest.raises(ValueError, match="house_occupancy_basis"):
|
||||
calc_kp_analysis(planets, "Aries", house_cusps=cusps, house_occupancy_basis="")
|
||||
declared = calc_kp_analysis(
|
||||
planets, "Aries", house_cusps=cusps, house_occupancy_basis="cusp_to_cusp",
|
||||
)
|
||||
assert declared["house_occupancy_basis"] == "cusp_to_cusp"
|
||||
proxy = calc_kp_analysis(planets, "Aries")
|
||||
assert proxy["house_occupancy_basis"] == "whole_sign_proxy"
|
||||
markdown = build_kp_report_pack(declared)["exports"]["markdown"]
|
||||
assert "House occupancy basis: `cusp_to_cusp`" in markdown
|
||||
@@ -0,0 +1,72 @@
|
||||
"""BUG-1320: kendra and trikona lord lists do not follow PYTHONHASHSEED.
|
||||
|
||||
Upstream 0337b77f. Two processes with different hash seeds must list the
|
||||
same yoga participants in the same order.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
_PROBE = r"""
|
||||
import json
|
||||
import sys
|
||||
sys.path.insert(0, "scripts")
|
||||
from yoga_engine import YogaContext, YogaEngine, detect_yogas
|
||||
|
||||
planets = {
|
||||
"Sun": {"sign": "Leo", "house": 5, "degree": 10},
|
||||
"Moon": {"sign": "Cancer", "house": 4, "degree": 5},
|
||||
"Mars": {"sign": "Aries", "house": 1, "degree": 8},
|
||||
"Mercury": {"sign": "Virgo", "house": 6, "degree": 12},
|
||||
"Jupiter": {"sign": "Sagittarius", "house": 9, "degree": 15},
|
||||
"Venus": {"sign": "Libra", "house": 7, "degree": 3},
|
||||
"Saturn": {"sign": "Capricorn", "house": 10, "degree": 20},
|
||||
"Rahu": {"sign": "Taurus", "house": 2, "degree": 9},
|
||||
"Ketu": {"sign": "Scorpio", "house": 8, "degree": 9},
|
||||
}
|
||||
rules = "references/yoga_rules.json"
|
||||
engine = YogaEngine(rules)
|
||||
ctx = YogaContext(planets, "Aries")
|
||||
namespace = engine._build_custom_exec_globals(ctx, {}, {})
|
||||
rows = detect_yogas(planets, "Aries", rules)
|
||||
print(json.dumps({
|
||||
"kendra": namespace["kendra_lords_list"](),
|
||||
"trikona": namespace["trikona_lords_list"](),
|
||||
"participants": [
|
||||
[row.get("rule_id"), str(row.get("combination"))]
|
||||
for row in rows
|
||||
],
|
||||
}, ensure_ascii=False))
|
||||
"""
|
||||
|
||||
|
||||
def _run(seed: str) -> dict:
|
||||
env = os.environ.copy()
|
||||
env["PYTHONHASHSEED"] = seed
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", _PROBE],
|
||||
cwd=ROOT,
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_yoga_participants_keep_the_same_order_across_hash_seeds() -> None:
|
||||
first = _run("0")
|
||||
second = _run("1")
|
||||
assert first == second
|
||||
assert first["kendra"] == sorted(first["kendra"])
|
||||
assert first["trikona"] == sorted(first["trikona"])
|
||||
assert first["kendra"] == ["Mars", "Moon", "Saturn", "Venus"]
|
||||
assert first["trikona"] == ["Jupiter", "Mars", "Sun"]
|
||||
Reference in New Issue
Block a user