diff --git a/BLOCKED.md b/BLOCKED.md index 66994f12..d06c2f4f 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,5 +1,13 @@ # BLOCKED +## TASK-chart-surface-polish:受控登录、实体手机与基线全量失败(2026-09-29) + +- 本轮 Chrome、Edge、Docker 均可用,不套用历史“无 Chrome / 无 Docker”。真实 Chrome + golden 的本地组件验收及骨架高度修复后复验已完成(70+8 项通过);没有受控线上登录账号、实体手机和读屏实测;不能代替完整账户/人物/请求链路。清单见 `docs/testing/chart-surface-polish-20260929.md`。 +- Linux 保真基线全量前端 4369 项,4340 通过 / 29 失败 / 0 cancelled / 0 skipped,exit 1;两个既有生命周期测试文件均报 `tailwind-merge/dist/bundle-mjs.mjs:99:50 Parse error`,定向及关闭 tsx 缓存仍复现。final-r2 为 4386 项、4357 通过 / 29 失败 / 0 cancelled / 0 skipped,exit 1;逐名逐原因核对无新增失败、无诊断变化。根因未定,直接 import 同包正常,不能称包损坏;不改依赖或测试消红,不声称全量通过。证据见本轮 PROGRESS 和 `artifacts/chart-surface-polish-validation-20260929/baseline-test-summary.json`。 +- 本轮 pre_work_check exit 1:远端 verified、适配器成功,但碎片扫描 90 秒、focused tests 45 秒超时。未伪造历史镜像、未清其他会话目录。 +- 真实 Chrome 前后对照发现既有印度盘多星同宫的个别字形边缘裁切,基线/实现坐标相同、可见中心仍能触发正确说明。本轮没有引入或修复该布局欠账;不将“无新增裁切”说成“全部字形无裁切”。证据见本轮 PROGRESS 的 `vedicVisibleAudit`。 +- 前次任务书文档推送认证失败尚未解除;本轮只实施与本地验收,未提交、未推送、未部署,不借用任何凭据。 + ## ~~TASK-birth-sky-cover T5:报告封面没有可靠的出生资料绑定(2026-09-28)~~ → 已解除:产品 2026-09-28 选 A → 2026-09-29 产品决定去掉报告封面(TASK-mobile-chart-and-confirmed-edit-20260929 M5;生成时写入的 `subject.birthFingerprint` 保留) - ~~任务书要求「只有报告生成时的出生资料与现在一致时才显示封面」。逐项查过,报告侧**没有任何出生资料指纹**:~~ diff --git a/CHANGELOG.md b/CHANGELOG.md index 14513953..8a6dd329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 印度占星 Skill 更新日志 +## 2026-09-29 — 星盘展示去引擎、能力列收口与图标说明(本地实施完成,未发布) + +- 星盘参数不再展示引擎品牌;岁差、交点与准确性边界保留。 +- 分盘庙旺保留入旺 / 自宫 / 落陷,同位只在 D9 显示;上升庙旺写「不适用」。宫位表头与数值统一左对齐。 +- 盘内符号增加鼠标、键盘与轻点说明,不增加运势解读,不改变西洋盘点选相位。 +- 桌面印度绘图区上限 440px、西洋 480px;手机结果盘保持原自适应,报告与校正盘不缩。等待盘沿用相同宽度,并预留图例高度,避免结果出现时盘框跳动。 +- Skill 版本不 bump;不改算法、数据库、许可证或依赖版本。验收状态见本轮 PROGRESS。 + ## 2026-09-29 — 年运返照时刻改为真正的本地时间;PL9 导出文件减半(待验收) - 专业报告里「Varshapravesha(太阳返照时刻)」此前打印的是世界时(北京盘差 8 小时,跨日时日期也错),现在按出生地时区显示;有夏令时的地区按返照那一刻的实际偏移(BUG-1095)。Sahams 昼夜判定也改用真实返照时刻。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 2c80170c..1d744257 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2604,6 +2604,7 @@ - 验证:个人报告聚焦测试覆盖 answer-first 顺序、十二宫坐标/裁切/密集数据、独立滚动边界、打印表格与分页、原生附录 disclosure、PDF 操作能力保护;浏览器在 1363×936 CSS viewport 实测报告容器 `clientHeight=936`、`scrollHeight=3466`,滚动后 `scrollTop=980` 并可到达底部,附录展开后高度增至 5219;参考图与实现图完成同画布视觉比对。聚焦 ESLint、生产构建和 `git diff --check` 作为最终门禁重新执行。 - 防复发:聊天根滚动锁与报告阅读滚动必须分层;报告回归测试不得删除 `100dvh/overflow-y:auto` 合同。任何命盘 occupants 改动必须验证至少两个不同宫位 transform、clipPath 与 12 项密集输入。打印回归必须覆盖长摘要、长主题、最小宽度表格和无滚动容器的 A4 输出;PDF 入口的隐藏/恢复必须同步更新明确的能力与 ready-state 合同。 - 2026-09-24 产品修订:`TASK-report-reader-actions-20260924` D2 明确撤下打印按钮与专用调用,不是此 Bug 复发;A4、壳层解锁、表格分页以及 beforeprint/afterprint 展开/恢复保留,浏览器 Ctrl+P 仍可用。本地候选只更新入口合同,不把受阻完整测试、真机打印或部署记为通过。 +- 2026-09-29 关联观察(investigating,未修复):星盘展示单真实 Chrome 在 390px 的既有 D1 golden 发现个别边缘 glyph 的 bbox 超出 SVG;基线/实现坐标相同,可见中心均能触发正确说明。本轮未改布局坐标/clipPath,不能认定为本轮新增,也未证实与本记录原根因相同。旧 transform/密集裁切合同不等于所有真实字体边界都不裁切;后续布局单应补真实字体 bbox 与可见区域检查。证据见 `PROGRESS-chart-surface-polish-20260929.md` 的 `vedicVisibleAudit`;本轮不扩改报告/校正布局算法。 - 相关记录:BUG-126 - 复发自:无 - 修复版本:本地候选(未 push / deploy) @@ -14730,3 +14731,31 @@ - 相关记录:BUG-1095、ERR-111 - 复发自:无 - 修复版本:分支 `codex/upstream-sync3-20260929`,未合入 + +## BUG-1098 | 星盘行星表宫位标题左对齐,内容误用居中样式 + +- 状态:investigating(2026-09-29 本地修复及独立浏览器验证通过;最终全量无新增失败,但保留 29 项既有加载失败,未部署、线上待验) +- 首次发现 / 最近更新:2026-09-29 / 2026-09-29 +- 影响面:`chart-vedic-tab.tsx` 分盘、本命/月亮、Bhava、行运表格与对应 CSS。 +- 现象 / 触发:打开上述盘型,宫位标题与每行宫位内容不在同一左边线。 +- 根因:标题继承默认左对齐,宫位 td 误用 `chart-page-planet-mid`。旧测试验证数据、列数与 D1 字节冻结,没有检查宫位 computed alignment;分盘之外同类写法也存在。 +- 修复:分盘、Bhava、行运仅移除宫位格 mid;本命/月亮保留原八列 markup,通过八列表头限定 CSS 第四列左对齐。宿主、pada、速度、换宫等刻意居中列不变,不全局改 mid。 +- 验证:执行方前端定向与 D1 字节冻结通过;新增源码/样式合同。独立真实 Chrome 六盘型×六视口确认宫位 computed text-align left、标题和值 padding 12px、宿主/pada仍居中,窄屏无整页横滚;独立全量结果见 `PROGRESS-chart-surface-polish-20260929.md`。未部署,不提前宣布线上解决。 +- 防复发:各盘宫位标题/值同左对齐和 padding;保留 BUG-1083 外层可收缩与表内横滚合同。 +- 相关记录:BUG-1076、BUG-1083;本地诊断任务书记录延续,非重新编号。 +- 复发自:无旧对齐修复证据;属于既有表格未覆盖的展示问题。 +- 修复版本:`codex/chart-surface-polish-20260929` 工作区,未提交、未推送。 + +## BUG-1099 | 星盘分盘表把全部 Dn 同座比较统一展示为原生同位能力 + +- 状态:investigating(2026-09-29 D9 能力边界本地验证通过;最终全量无新增失败,但保留 29 项既有加载失败,未部署、线上待验) +- 首次发现 / 最近更新:2026-09-29 / 2026-09-29 +- 影响面:`chart-view-mapper.ts::buildVargas`、`chartViewVargaSchema`、`chart-vedic-tab.tsx::VargaTable`。 +- 现象 / 触发:非 D9 分盘也显示同位,没有能力声明,false 横杠无法区分不支持与未命中。 +- 根因:09-28 产品约定将任意 Dn 与 D1 同座泛化为 vargottama;原生 `_calc_vargottama` 只处理 D9。算术比较本身没错,旧测试按旧产品定义验证,并非计算回归漏检。 +- 修复:服务端可选能力声明,两列独立严格 true 才展示;D9 有有效来源时支持同位,其他 Dn 隐藏;三档庙旺保留,上升标不适用。支持性由有效来源而非结果真值判断。旧模块快照缺声明保守隐藏,不改原始引擎缓存或身份隔离。 +- 验证:能力组合、全 false/null、坏类型、缺失/无效源、缓存直读与分层合并均增加 golden 回归;Python 合同直接将真实 TS mapper 与 `_calc_vargottama` 行星和 `build_d9_summary` 上升分别对照。执行方及独立定向通过,最终 87 项受影响前端测试、5 项 Python 合同全部通过;全量新增 17 项、删除 0,29 项基线加载失败逐名逐原因一致。完整证据见 PROGRESS。 +- 防复发:禁止用 `some(vargottama)` 判断支持,禁止将未知补 false;缺能力整列不渲染,分盘行不能退回 D1。 +- 相关记录:BUG-1076、`TASK-chart-types-and-report-buttons-20260928.md` T3;本任务书明确有限修订旧约定。 +- 复发自:无;产品术语能力范围的收口,不是 D1 冒充分盘复发。 +- 修复版本:`codex/chart-surface-polish-20260929` 工作区,未提交、未推送。 diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index f8106661..e5f13be7 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -2,6 +2,19 @@ Purpose: read this file before substantial project work. It exists to stop repeat mistakes caused by multiple Codex windows, WorkBuddy mirrors, local drafts, backup folders, and partial cloud-git visibility. +## 2026-09-29 · 星盘展示获准推送后的预检 + +- 产品在获知本轮全量 29 项既有失败后明确授权推送 staging。重新 fetch,远端仍为已验基线 `78b9c5b65cda44650e8c7ca019931ce33a85a95d`;19 个业务/测试变动文件与 final-r2 快照哈希一致,隐私检查 63/63 通过。 +- 推送前预检这次不再超时:remote verified、碎片扫描与适配器成功;focused 22 通过 / 2 失败,整体 exit 1。复现历史同类机器目录假设:缺 `.workbuddy/skills/jyotish-vedic-astrology`,candidate_count=6 小于 workspace_residue_count=1937。未造镜像、未清其他会话目录、未修改测试;原日志 `artifacts/chart-surface-polish-validation-20260929/push-pre-work-check.log` 保留。 +- 此处只记录推送准备,写认证、远端 SHA 与门禁/部署另行核验;不把远端可读当作已写入。 + +## 2026-09-29 · 星盘展示实施预检与保真基线 + +- 独立工作树 `codex/chart-surface-polish-20260929`,fetch 后固定 `78b9c5b65cda44650e8c7ca019931ce33a85a95d`。Windows Python 3.11.7 加 UTF-8 环境变量运行预检;remote verified、适配器成功,碎片扫描 90 秒与 focused tests 45 秒分别超时,整体 exit 1。原日志在本轮 `artifacts/chart-surface-polish-validation-20260929/pre-work-check.log`。不套用此前单条镜像断言失败,不造目录、不清他人残留。 +- 本机 Docker 29.8.0、真实 Chrome 可用。Linux 独立 Git archive 基线保留 5485 tracked 文件及 8 个 symlink,mode 校验零差异;不以 Windows checkout 的 symlink/mode 替代 Linux 结果。Node 22.23.2 / Python 3.13.15,不更改依赖锁。 +- 基线 npm 全量 4369 项中 29 项失败,集中两个生命周期文件的 `tailwind-merge/dist/bundle-mjs.mjs:99:50 Parse error`,定向重跑与关闭 tsx 缓存仍复现;不是 Docker 不可用。保留失败原日志,最终逐名逐原因比较,不修改业务或断言迎合工具。tsc/lint/build 与 Python quick 基线成功,具体数字见本轮 PROGRESS。 +- 浏览器 harness 初稿误落主检出 artifact,随后复制到本轮实现树,后续产物只写独立工作树;未清理其他会话文件。前次纯文档写认证失败未解除,本轮无 push、无部署。 + ## 2026-09-26 · 校正时延单预检(BUG-1047) - `python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` 在 Linux / Python 3.13 上整体 exit 0:远端 verified,碎片扫描、外部引擎适配器诊断与 5 个 focused 目标全部通过(本机没有复现 09-24/09-25 记录的 `.workbuddy` 镜像断言失败)。 diff --git a/docs/tasks/PROGRESS-chart-surface-polish-20260929.md b/docs/tasks/PROGRESS-chart-surface-polish-20260929.md new file mode 100644 index 00000000..8ecdd543 --- /dev/null +++ b/docs/tasks/PROGRESS-chart-surface-polish-20260929.md @@ -0,0 +1,148 @@ +# PROGRESS · 星盘展示收口(2026-09-29) + +## 授权与基线 + +- 产品要求“请你执行任务书”,对应 `TASK-chart-surface-polish-20260929.md`;实施计划已批准。 +- fetch 后基线仍为 `78b9c5b65cda44650e8c7ca019931ce33a85a95d`,独立工作树 `.worktrees/chart-surface-polish-20260929`,分支 `codex/chart-surface-polish-20260929`。 +- 未 stash、未提交、未推送、未部署;前次文档写认证失败尚未解除,不尝试借用凭据。浏览器 harness 初稿曾误写主检出 artifact,已复制至本树,后续产物仅写本树,未清理其他会话文件。 +- 工作树清单与会话检查未见同日报告两单正在本机修改共享 SVG;保持本单共享 SVG 先于后续报告单串行接续的约定。 + +## 执行边界与补充决策 + +- 去掉用户可见引擎行及专用展示字段,不删内部计算元信息或许可证。 +- 庙旺保留三档同源派生,同位只 D9;能力声明与命中结果分离,旧缓存缺声明保守隐藏,不新造缓存系统。 +- 宫位对齐审计发现本命/月亮、Bhava、行运也使用相同居中类。按 T3 统一修复宫位类格;本命八列结构和数据字节冻结不动,以八列表头限定 CSS 第四列左对齐。宿主、pada 等仍居中。 +- 盘宽只在既有 ≥1024px 断点收紧;印度限内部绘图区,参数卡和表格不缩,报告/校正不受影响。 +- 图标说明由 `/chart` 页面交互包装层承接,共享绘图默认静态。固定说明区避免挡住相邻符号;静态导出白名单不扩。西洋固定文字放在透明行星命中圆之上,避免说明归属被截走。 + +## 进度 + +| 项 | 状态 | +| --- | --- | +| T1 去引擎 / T2 能力列 / T3 对齐 | 已实现;定向通过 | +| T4 图标说明 | 已实现;定向与真实浏览器通过 | +| T5 桌面盘宽与等待槽位 | 已实现;六视口真实浏览器通过 | +| 基线全量与最终验收 | final-r2 完成,无新增失败;全量仍有 29 项既有失败,独立只读审查闭环 | +| 文档与真人清单 | 已回填;受控登录、实体手机和读屏留待真人验收 | + +## 本轮环境与预检 + +| 项目 | 实测 | +| --- | --- | +| Windows Node / npm | 22.23.2 / 10.8.1 | +| Windows Python | 3.11.7(UTF-8 环境变量) | +| Docker Server | 29.8.0 | +| 实际浏览器 | Chrome 154.0.8037.57,独立临时配置,无个人登录态 | +| Linux Node / npm / Python | 22.23.2 / 10.9.8 / 3.13.15 | +| 实现树 npm ci --ignore-scripts | exit 0;未改 lockfile | +| pre_work_check | exit 1:remote verified、适配器成功;碎片扫描 90s 超时,focused tests 45s 超时 | + +预检原日志:`artifacts/chart-surface-polish-validation-20260929/pre-work-check.log`。本轮不是旧镜像路径断言的失败计数,不伪造历史目录、不清理别人的残留、不把整体 fail 写成通过。 + +Linux 基线使用本轮独立 Node/Python/DinD 容器和卷,Git archive 保留 5485 tracked 文件及 8 个 symlink,精确 index/mode 校验零差异。不挂宿主 Docker socket,不更改依赖锁。archive SHA256:`23f0b42bad804e59362959c64fa282ed4437faa301773f98b731e6ddfbcee76a`。最终另建快照,不覆盖基线。 + +## 既有断言变更账本 + +| 原值 | 新值 | 原因 | +| --- | --- | --- | +| 西洋所有视口 max-width 520px | 窄屏仍 520px,≥1024px 上限 480px;viewBox 520×520 保留 | 只收紧桌面绘图区,不改变手机和几何 | +| 完整 markup 中“逆”只出现一次 | 只统计可见 text,仍一次;另增说明与默认静态无交互断言 | title/aria 的事实说明可含逆行,不能误当成盘内重复标签 | +| 临时浏览器追加检查要求骨架 SVG 与结果 SVG 总高相等 | 要求绘图容器、stage 等高及说明起点相同;400 方盘主体同宽同高,内部空 SVG 不要求绘制图例带 | 以真实无跳动布局为合同,保留静态空 SVG;旧失败原日志保留,外框高度断言未放宽 | + +数据能力测试只新增断言,原 D9 六列、D1 八列字节冻结保留。没有删除用例来消红。 + +## 执行方与主会话定向验证 + +| 检查 | 结果 | +| --- | --- | +| 数据六文件前端定向 | 55 pass / 0 fail / 0 cancelled,exit 0 | +| Python 表格/原生同位合同 | 5 passed,exit 0 | +| 图标、静态报告、西洋布局与新交互 | 31 pass / 0 fail / 0 cancelled | +| 星盘页、西洋布局与新交互 | 36 pass / 0 fail / 0 cancelled | +| 主会话独立星盘页、尺寸、交互定向(初轮) | 32 pass / 0 fail / 0 cancelled / 0 skipped,exit 0 | +| 主会话 r2 定向(含高度合同及 Bhava/Transit 成功参数表) | 42 pass / 0 fail / 0 cancelled / 0 skipped,exit 0 | +| 主会话 Python 原生合同 / 隐私 | 5 + 63 passed,exit 0 | +| 主会话 tsc / 完整 lint | exit 0 / exit 0(0 error / 126 warning) | +| 主会话差异空白检查 | exit 0 | + +数据代理首轮新增缓存测试 54/55,因新测试缺少身份 pin 前置而失败;补齐测试前置后整组 55/55,未修改生产缓存逻辑。图标测试是实际 React handler/生命周期测试,不伪称浏览器布局仿真。主会话原日志:`artifacts/chart-surface-polish-validation-20260929/main-targeted.log`。 + +## Linux 同环境基线 / 最终对账 + +证据目录:`artifacts/chart-surface-polish-validation-20260929/`。 + +| 检查 | 基线 | final-r2 | +| --- | --- | --- | +| tsc | exit 0 | exit 0 | +| lint | 0 error / 126 warning,exit 0 | 0 error / 126 warning,exit 0 | +| npm test | 4369 项:4340 pass / 29 fail / 0 cancelled / 0 skipped,exit 1 | 4386 项:4357 pass / 29 fail / 0 cancelled / 0 skipped,exit 1 | +| 测试名多重集合 | 顶层 4339;含嵌套 4369 | 顶层 4356;含嵌套 4386;新增 17、删除 0 | +| build / 首页 | exit 0 / ○ Static | exit 0 / ○ Static | +| 完整首屏直链 JS/CSS gzip | 720027 B(32 资源) | 720933 B,+0.12583% | +| 相对父 layout 增量 JS gzip | 233663 B(8 块) | 234101 B,+0.18745% | +| 首页专属 JS gzip | 133914 B(4 块) | 134352 B,+0.32708% | +| Python 定向 / 隐私 | 3 + 63 passed,exit 0 | 5 + 63 passed,exit 0 | +| Python quick | 1000 passed / 2 skipped / 3 subtests passed,exit 0 | 1000 passed / 2 skipped / 3 subtests passed,exit 0 | +| 受影响前端定向 | — | 87/87 pass,fail/cancelled/skipped/todo 0,exit 0 | + +quick 使用 `--profile quick --skip-frontend-runtime`,前端另外完整执行 tsc/lint/npm test/build,不省 Python 核心。基线 29 项失败均在 `home-warm-return-lifecycle.test.tsx`(14)和 `new-chat-from-people-lifecycle.test.tsx`(15),报 `tailwind-merge/dist/bundle-mjs.mjs:99:50 Parse error`,定向及禁用 tsx 缓存重复同错。不属于 Docker 不可用,不改业务/测试/依赖迎合工具;final-r2 已逐名核对 name/error/code/location(仅归一快照目录),新增失败 0、诊断变化 0。主会话独立读取 summary、名称多重集合与失败诊断证据,未将 exit 1 写成成功。最终证据:`final-r2-summary.json`、`final-r2-versus-baseline.json`、`final-r2-failure-diagnostics-versus-baseline.json`、`final-r2-gzip-versus-baseline.json`。 + +## 真实浏览器验收 + +高度修复后最终稳定轮:`artifacts/chart-surface-polish-browser-20260929/run-2026-09-29T10-31-46-677Z/results.json`;触控/高度追加轮 `run-2026-09-29T10-31-31-386Z/results.json`。脚本 `run-browser.cjs`、日志 `acceptance-post-height-fix.log` 与 `touch-height-post-fix.log`。两轮使用同一源码哈希快照;旧 `run-2026-09-29T10-18-19-676Z` 初轮和其后高度失败证据保留。Chrome 真实组件 + 完整 CSS + 项目字体 + 真实引擎 golden,同一 harness 前后对照,源码前后哈希一致。 + +| 验证 | 实测 | +| --- | --- | +| 检查汇总 | 主套 70 PASS / 0 FAIL + 追加 8 PASS / 0 FAIL,两次 exit 0;pageerror 0(未监听全部 console.error) | +| 宫位对齐 | 6 盘型 × 6 视口,computed text-align left,标题/值 padding 12px;宿主/pada仍center | +| 西洋拥挤盘 | 64 golden × 6 视口 = 384 盘;17190 字符命中归属/nearest语义归属错误 0,字框碰撞 0 | +| 375px 西洋 | 最小实际字号 12.533px;命中直径 44.854px | +| 交互 | 实际 hover/focus/tap/Esc/leave/blur/切盘清理;西洋选择/取消/相位过滤、星座/宫号/角点说明通过 | +| 印度可见符号 | 10 个可见 glyph 中心均能触发正确说明;逆行线同对象 | +| 共享绘图 | 报告实际组件与校正共享宿主六视口尺寸同基线;导出静态、无脚本或交互、无 height auto | +| 页面宽度与等待 | 无整页横滚,表内可滚;六视口骨架与结果绘图区同宽 | + +| 视口 px | 基线印度 px | 实现印度 px | 基线西洋 px | 实现西洋 px | +| --- | ---: | ---: | ---: | ---: | +| 375 | 317 | 317 | 343 | 343 | +| 390 | 332 | 332 | 358 | 358 | +| 480 | 422 | 422 | 448 | 448 | +| 1023 | 949 | 949 | 520 | 520 | +| 1024 | 497.594 | 440 | 520 | 480 | +| 1280 | 565.797 | 440 | 520 | 480 | + +浏览器第一轮运行时实现方仍在调整西洋文字层,哈希不稳定,不算最终证据;harness 也修正了零高度 SVG 逆行线定位(改几何中心真实 mouse.move)和扩展 aria-label 前缀定位,没有放宽业务断言。最终稳定轮已重跑全套。 + +### 既有视觉欠账与未验边界 + +- 390px D1 个别多星同宫字形 bbox 越 SVG 边界,基线/实现坐标与可见中心相同,属于既有裁切,不是本轮新增;可见中心能正确说明,不把“无新增裁切”写成“全部字形无裁切”。证据含 `vedicVisibleAudit` 和前后截图。 +- 没有受控线上登录态、实体手机或真实读屏验收。校正只覆盖共享绘图宿主,不是完整 Case 流程;本地 golden harness 不能代替账户/人物/服务端请求链路。 +- 真人清单:`docs/testing/chart-surface-polish-20260929.md`;缺口保留在 `BLOCKED.md`。 + +## 独立审查与追加修复 + +修复单:`TASK-chart-surface-polish-fix-20260929.md`,与原任务同一授权/分支,不扩大发布范围。 + +- 未参与实现的审查方完成能力声明、缓存、静态 SVG、安全边界与西洋事件审查,最小 20 项测试通过、fail/cancelled 0、exit 0,未发现确定的新数据或交互回归。 +- 审查发现 T5 高度验收缺口:骨架空 SVG 为 400×400,结果有图例为 400×452。虽是既存比例差,本任务书要求揭幕后不跳尺寸,因此不缩窄解释为“只限宽度”。追加 Chrome 实测六视口外框增高 41.203 / 43.156 / 54.859 / 123.359 / 57.188 / 57.188px,六项失败、exit 1;日志 `touch-height.log` 和 `run-2026-09-29T10-23-27-737Z/results.json` 保留。 +- 最小修复只给 `/chart` 印度骨架容器 `.chart-page-skeleton.chart-page-vedic-plot` 添加 `aspect-ratio:400/452`,留出图例带;不改空 SVG/viewBox,不改报告/校正。新增合同;新稳定浏览器追加轮 8 PASS / 0 FAIL、exit 0,六视口绘图槽/外框高度及说明起点差均为 0,方盘主体尺寸差 <0.1px。证据 `run-2026-09-29T10-31-31-386Z/results.json`,pageerror 0、源码哈希稳定。内部两个 SVG 自身不要求等高,因为骨架不绘图例。 +- 触控追加可信原生事件序列为 pointerup(touch) → pointerout/leave(touch) → 兼容鼠标事件 → click(touch),印度/西洋在完整事件后 0/50/500/1500ms 说明均保留;没有复现触控立即消失。不扩推到所有移动浏览器。 +- 补强 Bhava/Transit 的真实成功 golden 参数表断言:参数表、岁差、交点存在且无引擎,两个文件 9/9 pass、fail/cancelled/skip 0、exit 0。不是仅验证失败占位不含引擎。 +- 29 项基线错误只能定性为既有生命周期测试加载失败,根因未定:同环境直接 import tailwind-merge 并执行正常,不能写成缺依赖或包损坏。 +- final-r1 为中间快照,不能替代上述改动;4385 项中 4356 pass / 29 fail / 0 cancelled / 0 skipped,新增 16 个测试名、删除 0,29 项失败 name/error/code/location 归一快照路径后逐项相同。tsc/lint/build/quick 成功,首页 Static。r1 最初 Python 与 Node 依赖复制并行导致新增两项 skip,依赖就绪后真实补跑 5 个定向 + 63 隐私全部通过,原日志不覆盖。 +- final-r2 新保真快照 SHA256 为 `7af1408accee383ade7906c638d032fc992cca8e06986f43526616a58c804a34`,5491 tracked、index/mode 零差异。主会话逐项核对 19 个变动业务/测试文件 source_sha256 与当前树一致,mismatch 0;文档最终回填另做隐私检查(主会话已独立 63/63 pass,exit 0)。 + +## 当前结论 + +本地实施完成:五项展示需求及独立审查发现的骨架高度缺口均已落实,冻结后真实浏览器 78/78、受影响前端定向 87/87、tsc/lint/build、Python 定向与 quick 通过;首页 Static,三种 gzip 变化均小于 2%。独立审查无未处理意见,主会话另外完成关键复验。 + +全量 npm 仍为 exit 1:29 项既有生命周期加载失败与基线逐名逐原因完全一致,新增失败 0;不能称全部测试全绿。预检超时、实体手机/读屏和受控线上链路未验、既有印度边缘裁切均明确保留。BUG-1098/1099 记录为本地验证通过但线上待验,不宣称生产已解决。 + +本地验收完成时,交付仅限工作树 `G:/Ferti/Jyotisha/.worktrees/chart-surface-polish-20260929`,尚未提交、推送或部署。算法、依赖锁、迁移、发布配置未改。本地证据不能替代远端 SHA/部署核验。 + +## 2026-09-29 · staging 推送授权 + +- 产品在获知上述 29 项既有全量失败及真人验收缺口后,明确要求“请你帮我推送到staging”。本轮授权包括提交已验收改动并快进推送 staging,不包括提升 main 或手动生产发布。 +- 推送前重新 fetch;`origin/staging` 与本轮基线仍同为 `78b9c5b65cda44650e8c7ca019931ce33a85a95d`,无需合并或重放代码。重新执行预检、源码快照一致性与隐私检查;只提交明确列出的业务/测试/文档,不纳入本地产物、依赖、截图或凭据。 +- 推送前源码核对 19 文件 mismatch 0,隐私 63/63 通过。预检 exit 1:remote verified、碎片与适配器成功;focused 22 pass / 2 fail,分别是历史镜像路径不存在与 candidate_count=6 小于 workspace_residue_count=1937,不再是开工时超时。原日志 `push-pre-work-check.log` 保留,未修改测试或清理其他会话目录。 +- 本节记录授权与推送准备,不预言写认证、远端门禁或部署成功。提交后须单独核对远端 SHA,自动门禁/部署以新提交的实际结果为准。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 3a528321..652e63e6 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -229,6 +229,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | +| [TASK-chart-surface-polish-20260929](TASK-chart-surface-polish-20260929.md)([验收修复](TASK-chart-surface-polish-fix-20260929.md)) | [PROGRESS](PROGRESS-chart-surface-polish-20260929.md) | 去引擎展示、庙旺三档保留、同位仅 D9、宫位左对齐、图标解释与桌面盘宽收紧(BUG-1098 / 1099) | 本地实施完成(定向/浏览器通过;全量保留 29 项基线失败,未推送) | `codex/chart-surface-polish-20260929`;不发布、不提升 main | | `TASK-docs-reconcile-20260926.md` | `PROGRESS-docs-reconcile-20260926.md` | **状态板 / BUG / BLOCKED 过期对账**(纯文档):约 35 行校正任务状态过期、BUG-085/086/743/981/984 记录过期、BLOCKED latency 条目过期;逐条附证据改,不把欠真机写成通过 | 待验收 | `codex/docs-reconcile-20260926`(本地提交,未推送) | | `TASK-people-ephemeris-ui-20260926.md` | `PROGRESS-people-ephemeris-ui-20260926.md` | **星盘档案页补样式 + 星历可读性改版**(原型 https://claude.ai/artifact/FmPy9xZ7dQw9u7yrW4WzTw ,产品 09-26 确认):/people 样式 0 条(BUG-1039)→ 手机列表→详情、桌面两栏;星历五要素先中文后梵文(附唯一对照表)、吉凶彩色标签、固定规则当日总评(不调模型)、标题栏大按钮移到底部提示卡。必须真实浏览器截图验收。与 BUG-1038 单并行、文件不交叉 | 已验收(Claude 09-26 直接执行模式:子代理实现,Claude 独立复验 tsc/lint 0、全量 3916 条失败名单与基线逐条一致、四路由 ○、gzip +0.00%、36 张浏览器截图核对与原型一致) | `0ab061b9`(已部署,run 2910,health 一致) | | `TASK-round-0925-followup-20260925.md` | `PROGRESS-round-0925-followup-20260925.md` | **0925 收尾单**:西洋盘标签偏移 ≤30° + 第三圈;删除后只在当前人物里回退;人物接口故障降级为本人;P3 删旧面板、点击按最近行星、删除年运死调用 | 已验收(Claude 09-26:偏移 ≤30° 达成;删后回退按人、人物接口降级、账户单次请求、面板与死代码删除均通过;残留 0.73% 盘标签重叠为 P3) | `d08ffdc0` + 合同修复 `e84d6eb8`(d08ffdc0 的门禁 run 2901 红于一条引用已删面板文案的 Python 合同,Claude 经产品授权直接修复);已部署 run 2906,health 一致 | diff --git a/docs/tasks/TASK-chart-surface-polish-20260929.md b/docs/tasks/TASK-chart-surface-polish-20260929.md new file mode 100644 index 00000000..5b426f31 --- /dev/null +++ b/docs/tasks/TASK-chart-surface-polish-20260929.md @@ -0,0 +1,154 @@ +# TASK · 星盘去引擎展示、能力列按支持显示、宫位对齐、图标解释与盘面收紧(2026-09-29) + +> 初稿为任务书模式。产品于 2026-09-29 明确要求“请你执行任务书”,已进入独立工作树实施;执行后独立验收,本轮不推送、不发布。 +> +> 执行补充:尺寸只在既有 ≥1024px 桌面两栏断点收紧,印度 440px 指绘图区而非含 padding 的外框。T3 同时修复本命、月亮、Bhava、行运的同类宫位对齐;本命/月亮保留原八列 markup,按八列表头精确限定 CSS 第四列,不全局改 `mid`。 + +## 1. 基线与串行依赖 + +- 调查基线:`origin/staging = 78b9c5b65cda44650e8c7ca019931ce33a85a95d`。执行开工重新 fetch,记录实际 SHA。 +- 实现分支:`codex/chart-surface-polish-20260929`;工作树:`.worktrees/chart-surface-polish-20260929`。 +- 进度:`docs/tasks/PROGRESS-chart-surface-polish-20260929.md`;真人清单:`docs/testing/chart-surface-polish-20260929.md`。 +- 必须接在 `codex/mobile-chart-confirmed-edit-20260929`(BUG-1083)合入之后;本基线已包含其修复。不得重新引入手机宽表撑开外层网格的问题。 +- 同日报告两单既有顺序不变:`codex/report-reader-polish-20260929` → `codex/report-english-edition-20260929`。本单优先限定 `/chart`;若要同时编辑共享 `vedic-chart-svg.tsx`、报告图盘导出或同一 CSS/文档段落,必须串行交接,不能各自在旧基线上覆盖。默认本单共享 SVG 改动先合入,英文版再接续;若对方已开工,先协调,不重置别人分支。 + +## 2. 事故实证与根因 + +本轮输入为产品截图和展示要求,不记录截图中的真实星盘数据。本轮没有登录态浏览器复现或全量业务验收。代码定位按符号为准,以下行号对应上述基线: + +| 现象 | 证据与根因 | +| --- | --- | +| 所有印度盘显示引擎来源 | `frontend/src/lib/chart-view-mapper.ts:822` 投影 `engineName`,`chart-view-contract.ts:261` 要求该字段;`components/chart-page/chart-param-table.tsx:28` 把它作为参数行无条件渲染。`chart-vedic-tab.tsx:312` 的共享 `VedicLayout` 让该行出现在全部 23 种印度盘型。西洋盘不走此表,不能声称其已有同一行。 | +| 分盘宫位标题左、内容居中(BUG-1098) | `chart-vedic-tab.tsx:148,163` 的 `VargaRow` / `VargaAscendantRow` 给宫位套了 `chart-page-planet-mid`。`globals.css:5210–5215` 本来统一左对齐,却被 `:5249` 的该类覆盖成居中。是单元格类使用不当,不是引擎数据错误。 | +| 盘内符号无悬停解释 | `components/personal-report/vedic-chart-svg.tsx:168–220` 的 `OccupantGlyph` 渲染行星、逆行短线、As,没有逐符号 ``;整盘读屏描述不能替代鼠标提示。西洋盘 `components/chart-page/western-wheel-svg.tsx` 的行星交互组已有键盘/选择语义,须在其上补解释而非换一套事件。 | +| 印度盘可能铺得过大 | `globals.css:5136–5142` 的 `.chart-page-vedic-stage` 未设最大宽度;桌面 `:5508–5519` 第一列为 `minmax(0,1.1fr)`,盘随列宽铺开。西洋盘 `.chart-page-western-wheel`(`:5417–5421`)已有 520px 上限。尺寸取舍不是计算缺陷。 | + +### 历史约束核对 + +已确认的产品契约冲突: + +- `frontend/DESIGN.md` §15「排盘参数表」原来明确规定八行,最后一行为「引擎」。因此移除是新的产品决定,不是丢字段。 +- 09-28 `TASK-chart-types-and-report-buttons-20260928.md` T3 规定分盘庙旺只取入旺 / 自宫 / 落陷,且同位是分盘与本命同星座;不能以空白多直接认定计算不支持,也不能把它包装成全面尊贵状态或外部精度认证。 +- 09-18 符号化改造让宫内只画行星符号;读屏信息、图例和鼠标悬停提示不是一回事,不能以已有 `aria-label` 代替可见解释。 + +### 数据能力核查(代码证据,不是外部占星准确率验证) + +| 范围 | 实际来源 | 本单处理 | +| --- | --- | --- | +| D1 状态 | `/api/chart` 的原生 `_get_dignity_level`;旧八列表 | 保留,不改其计算 | +| D2–D60 行星庙旺 | `chart-view-mapper.ts:216–253` 服务端 `vargaRows` 调用 `chart-view-varga.ts:19–23`;规则表由 `tests/test_chart_page_vedic_tables_contract.py` 与 Python 原生常量及九体×十二星座逐格对照 | 保留三档能力:入旺 / 自宫 / 落陷。不是 `/api/varga_full` 原生返回字段,但为有合同保护的同源派生,不能说引擎不支持。没有三档命中为 `null`;不代表未知或不支持 | +| D9 行星同位 | mapper 比较 D1/D9 星座;`scripts/jyotish_engine.py:300–313` `_calc_vargottama` 原生判定也是 D1↔D9 | 保留;必须加星盘投影与原生结果的直接对照测试 | +| D9 上升同位 | mapper 比较 D1/D9 上升;原生 `consultation_native_layers.build_d9_summary` 与 `satabdika_dasha.satabdika_applicability` 有同座判定路径 | 保留已有事实比较,补同源合同;不要声称 `_calc_vargottama` 本身处理上升 | +| 其他 Dn 同位 | mapper 对任意分盘都用 `sign === natalSign`,并统一命名 `vargottama`;原生 `_calc_vargottama` 并无各 Dn 的这套泛化 | 按产品“不支持不显示”的原则,本单收口:只在 D9 显示「同位」,其他 Dn 隐藏。不是断言通用同座比较算错,而是限制产品术语的能力范围 | +| 上升庙旺 | 无此字段,组件固定「—」 | 不对上升假造尊严计算;行星能力列仍在,上升此格标「不适用」并对照 VOICE | +| 月亮 / Bhava / 行运 / 西洋 | 独立表,无上述两列 | 不新增 | + +`/api/varga_full` 本身没有这两字段;既有契约的 `dignity` 为三枚举或 null、`vargottama` 为必填 boolean,**没有独立的 unsupported 状态**。缺分盘/本命星座时 mapper 会跳过该行。支持性应由服务端的盘型能力和有效数据来源表示,不能由真值多少推断,也不能让未知被 false 吞掉。本轮只读查到上述测试存在,尚未运行其回归,不以旧记录代替本轮结果。 + +## 3. 决策记录 + +| 项 | 产品授权 / 本单执行口径 | +| --- | --- | +| D1 去引擎 | 产品要求所有盘型不再显示「引擎」「Swiss Ephemeris(本站)」这类实现品牌。删除展示节点,不改为「自研引擎」「本站引擎」,不只用 CSS 藏起来。印度各盘型与西洋盘都查。 | +| D2 能力列 | 有实际计算依据才显示「庙旺 / 同位」;没有该能力时隐藏整列,不留一列横杠。支持但未命中不能当成不支持。此次不扩建算法、不虚构结果。 | +| D3 宫位对齐 | 宫位标题与每行内容统一左对齐,同列左右 padding 一致。不是把表头改成居中来迁就错误的单元格样式。 | +| D4 图标说明 | 鼠标停在图标上显示中文名称和已有事实;键盘能获得等价信息。手机没有 hover,提供可用的轻点说明且不与现有点选动作冲突。不加运势解释,不调用模型。 | +| D5 盘面收紧 | 适度缩小星盘页的盘面,优先收紧桌面最大宽度;手机不机械按比例缩小文字与点击区域。不改数据坐标,不重做绘图。 | + +**对旧约定的有限修订**:D1 推翻 DESIGN §15 参数表必须含引擎的旧约定;D2 将 09-28 T3 与 BUG-1076 中「所有分盘同位 = 与本命同星座」的泛化展示收口为 **D9 才显示同位**(基于此次“不支持则不显示”的授权,架构建议;不新造另名列),庙旺的同源三档派生保留;D3 只修宫位列,宿主 / pada 的居中不顺带改;D5 允许修改星盘页尺寸上限,保留 BUG-1083 的无溢出和西洋盘点击 / 避让规则。版权、许可证、第三方声明与内部诊断元数据不删除;隐藏展示不是让技术栈保密的安全措施。 + +## 4. 硬红线 + +1. 不改本命 / 分盘 / Bhava / 行运 / 西洋盘的计算结果、岁差、交点、宫制或时间精度提示;不拿 D1 数据补分盘(BUG-1076)。 +2. 不在浏览器补算庙旺或同位。数据不支持就不显示,未知不伪装为 false / 正常。 +3. 服务端计算元数据和审计来源保留;不得全仓字符串替换或删除许可证署名。此次只收口面向用户的盘面展示,不清洗既有存储报告或原始引擎 JSON。 +4. 星盘页的盘型条、按需取数、缓存身份、等待 / 失败 / 重试规则不变;不新增请求、模型调用或 loading 动画。 +5. 盘面文字与触控可用性优先于尺寸;西洋盘现有行星点选过滤相位、再次点选取消必须保留。多星同宫和拥挤盘不许通过缩字掩盖重叠。 +6. 共用 SVG 同时供校正右栏、报告与静态导出使用。不得直接把静态绘图组件整体改为依赖浏览器的客户端组件;交互只在需要的页面接入,导出仍是安全的静态 SVG。图表缩小样式只作用于 `/chart`。 +7. 不动数据库、依赖版本、workflow、DNS、`main`,不碰首页装配。遵守 AGENTS 增长合同,不另加净增零行约束。 +8. 改代码之前在 `frontend/tests/` 与 `tests/` 检索相关路径、符号、类名和文案;既有断言改动逐条写「原值 / 新值 / 原因」,不删测试消红。fixture 使用现有真实引擎 golden 或虚构输入生成的真实引擎响应。 + +## 5. 任务分解与验收 + +### T1 · 去除盘面引擎展示 + +- 删除参数表引擎行;`engineName` 在 chart-page view schema / mapper 中只为该行服务,删除这项无用展示投影并同步 fixture / 缓存兼容,不影响原始后端计算元数据。检查所有印度盘型和西洋盘的可见说明是否仍出现实现来源。 +- 不误删岁差 / 交点 / 宫制等影响结果的参数,不误删准确性边界。 +- **验收**:遍历全部盘型,渲染结果无引擎标签和供应商名称;西洋说明同样通过。后台/内部数据来源仍可追踪。若报告中共享盘块原有相同标签,同步去掉盘块标签,但不扩成报告正文清洗任务。 + +### T2 · 能力列按支持显示 + +- 开工先复核本文数据能力结论,分别记录庙旺与同位的有效来源、覆盖范围和缺值含义。使用最小的服务端列能力契约(如每盘 `supportsDignity` / `supportsVargottama`,或等价的可用性字段),无需新建全站能力注册系统。D9 保留两列,其他 Dn 保留庙旺、隐藏同位;上升庙旺标不适用。 +- 兼容旧浏览器模块缓存:契约新增字段不得导致旧缓存无声显示错误,按现有 schema/缓存换键方式处理;缺能力声明时保守隐藏对应列,不能假定支持。由 service mapper 输出列能力,组件只消费,不用分散的 JSX 魔法判断。 +- 支持性独立于结果值:`false` 是有效同位结果;未命中入旺 / 自宫 / 落陷也可能是正常有效结果。不能以 `rows.some(row => row.vargottama)` 或某列有非空字符串作为能力判断。 +- 不支持的列整列不渲染(含表头和上升行);单颗星不适用时可留「—」。两列分别控制,不能同开同关。 +- **验收**:真实 golden 的支持盘保留结果;缺失能力、只支持其中一列、两列都不支持、支持但全不命中分别覆盖。缺字段/无效字段的防御测试须从真实 golden 复制后做定向删除或破坏,不手造引擎响应形状。 + +### T3 · 宫位同列左对齐 + +- 精确收窄表格样式到宫位单元格和表头,审计本命、分盘、月亮、Bhava 和行运的同类宫位列。 +- **验收**:桌面与手机截图中宫位标题和内容同一左边线;自动化断言 computed style 和边距,而不只看 JSX;宿主/pada 等刻意居中列不变,横向滚动仍在表格内部。 + +### T4 · 图标解释 + +- 覆盖盘内行星、上升标记、逆行标记及存在的星座/角点缩写,明确每种图标含义。文案只报名称、已知位置与顺逆等事实,禁止编个人运势。 +- 复用已有 tooltip / 提示样式和现有数据,图例与读屏信息保留。可在 SVG 中补安全的 `<title>` 作为基础描述,但原生 title 不等于已完成 focus/触屏可见提示;页面级增强不要污染静态绘图层。没有度数等精细数据时只显示有依据的名称,不从粗略显示串造精度。 +- `frontend/src/lib/report-chart-export.tsx:10–25` 的 SVG serializer 当前不允许 `title`。本单导出的静态 SVG **不要求悬停交互**,允许该描述节点按现有规则剥离;不得为图标提示顺带放开脚本、事件属性或任意 SVG 标签白名单。已有图例与事实文本保留,测试明确静态导出不带交互。 +- hover 与 focus 可见;离开/失焦/Esc 可关闭;不挡相邻图标。触屏不得依赖原生 title 长按;西洋盘轻点仍完成原有选择,解释随选中显示或采用不冲突的等价方式。 +- **验收**:鼠标、键盘、触屏各一条交互测试;至少覆盖多星同宫、逆行、上升、切盘后无旧提示;报告/校正共用渲染和静态 SVG 安全合同仍过。 + +### T5 · 缩小盘面 + +- 推荐初值:`/chart` 印度盘**绘图区宽度上限 440px**,西洋盘 **480px**(不把 stage padding 算成盘宽),同时 `width:100%` 自适应并居中。印度盘相对 BUG-1083 在桌面记录的 480px 约收紧一成,西洋盘从 520px 小幅收紧;实际基线宽度小于该值时不能反而放大。此数值是设计目标,不是本轮已实测结论,按下方可读性验收可减小缩幅。 +- 参数区和下方表格不随盘面一起变窄。骨架盘与结果盘使用相同宽度容器,揭幕后不跳尺寸。 +- 手机保留自适应宽度和原可读性。缩小只改容器布局,不用 `transform: scale()` 留下大空白或压小命中区。 +- **验收**:至少 375、390、1024、1280 视口对照;无整页横滚,表格仍可横滚;在实际盘宽下验证文字和触控尺寸、西洋拥挤标签/命中归属。报告与校正盘尺寸不变。 + +### T6 · 文档、回归与交付 + +- UI 同提交更新 `frontend/DESIGN.md`,新文案核对并更新 `frontend/docs/VOICE.md`,用户变化入 `CHANGELOG.md`(Skill 不 bump)。 +- 更新 BUG 历史与 PROGRESS;不能自动化的项目进入 `docs/testing/chart-surface-polish-20260929.md`,实际环境缺口入 `BLOCKED.md`。 +- `tsc --noEmit` 零错、lint 零 error、相关套件 fail/cancelled 均为零;全量 `npm test` 与同环境基线比测试名称、失败名单和退出码。`npm run build` 后 `/` 仍 Static,首屏 gzip 同口径变化 ±2%。 +- 若修改 Python 或 Python 合同测试,定向与 `run_quality_gate.py --profile quick` 全量必跑;即使仅前端,本单也运行 quick 以防漏掉前端源码的 Python 合同。 +- 文档推送前运行 `tests/test_repo_privacy_markers.py`。代码完成先交 Claude 独立验收,不自行 push;用户授权推 staging 后才核对门禁、部署 SHA,不把文档已推送当作实现已上线。 + +### 已定位的回归文件与旧断言 + +| 文件 | 本轮重点 | +| --- | --- | +| `frontend/tests/chart-page-view.test.tsx` | 参数行无引擎、全盘型遍历;原西洋 `max-width:520px` 断言改为本单目标,写三栏理由 | +| `frontend/tests/chart-varga-table.test.tsx` | 真实分盘行/D1冻结仍保留;旧六列断言改为 D9 六列、其他 Dn 五列及能力缺失分支;宫位类回归 | +| `frontend/tests/chart-view-varga-dignity.test.ts` | 入旺/自宫/落陷规则子集与未命中含义不变 | +| `tests/test_chart_page_vedic_tables_contract.py` | 同源规则表合同保留;如增加 D9 同位对照,使用真实 golden 和原生函数,输入岁差明确 | +| `frontend/tests/chart-planet-glyphs.test.tsx` | 原 `markup.match(/逆/g)` 只允许一处“逆”,新增 title 后不可继续全 markup 数字数;改为验证可见文字与说明节点各自语义,三栏列明,不能直接删除测试 | +| `frontend/tests/chart-page-mobile-width-20260929.test.ts` | 保留 BUG-1083 的 minmax/overflow 合同;新增页面限定的盘宽约束 | +| `frontend/src/lib/report-chart-export.tsx` 的相关测试 | 共享 drawing 仍 hook-free、静态导出安全、数字 height,不能出现 SVG `height="auto"`(BUG-906) | + +西洋 hover 必须实测可见文字层而不只是透明命中层;现有 `western-wheel-svg.tsx` 先画 hit group、后画 caption group,上层文字可能截走指针。不得为了 tooltip 破坏字符命中归属和原键盘按钮语义。 + +## 6. 让步顺序 + +1. 可先不扩展到本单没有展示缺陷的报告正文;不得扩大为全站技术词清理。 +2. 若触屏复杂浮层增加冲突,改用现有选中详情承载等价说明,不新造复杂手势。 +3. 若缩小后破坏可读性,减小缩幅,不能牺牲图标文字或命中区域。 +4. 去引擎、真实能力边界、宫位左对齐、鼠标图标解释与手机不溢出不可让步。 + +## 7. 开工前置命令 + +每次 Git 操作前都先 `git status -sb`,确认在自己的工作树;禁止 stash。以下路径按实际环境调整,不在主检出写代码。 + +```bash +git status -sb +git fetch origin --prune +git status -sb +git worktree add -b codex/chart-surface-polish-20260929 .worktrees/chart-surface-polish-20260929 origin/staging +cd .worktrees/chart-surface-polish-20260929 +git status -sb +python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45 +# 必读 AGENTS、frontend/AGENTS、DESIGN、VOICE;检索 BUG_HISTORY 和全部相关源码合同。 +# 核对实际 Node/Python 环境,在同基线独立树保存全量测试名称/失败清单、构建与 gzip。 +``` + +## 8. BUG 编号起点 + +写作时 `docs/BUG_HISTORY.md` 最大号 **BUG-1097**。本单从 **BUG-1098** 起登记已确认的展示缺陷;实现前再次核对,若被占用按当前最大号连续顺延并同步任务书/进度引用。新增功能/产品取舍不为了凑编号全部记 Bug。 diff --git a/docs/tasks/TASK-chart-surface-polish-fix-20260929.md b/docs/tasks/TASK-chart-surface-polish-fix-20260929.md new file mode 100644 index 00000000..d824f6aa --- /dev/null +++ b/docs/tasks/TASK-chart-surface-polish-fix-20260929.md @@ -0,0 +1,24 @@ +# 星盘展示独立验收修复单(2026-09-29) + +## 基线与授权 + +原任务 `TASK-chart-surface-polish-20260929.md` 已获实施授权。本单是同一工作区验收发现的收尾,不扩大算法或发布范围;基线仍为 `78b9c5b65cda44650e8c7ca019931ce33a85a95d`,分支 `codex/chart-surface-polish-20260929`。不 commit/push/deploy,不改 workflow/数据库/依赖。 + +## 事故实证与根因 + +1. 独立审查发现印度骨架和结果已同宽,但图例带使高度不同。Chrome 六视口追加测量均失败:骨架 400×400,结果 400×452,外框增高随盘宽变化。证据 `artifacts/chart-surface-polish-browser-20260929/run-2026-09-29T10-23-27-737Z/results.json`。此比例差在基线已有,但原任务 T5“揭幕后不跳尺寸”不能仅按宽度通过。 +2. 全盘型去引擎测试中 Bhava 使用失败占位,Transit 复用本命响应;已有独立成功 golden 渲染未显式断言参数表和去引擎,存在否定断言覆盖不足。 +3. 触控 leave 怀疑项已用真实可信事件链验证:Chrome click 在 touch leave 之后,说明在 1500ms 后仍在,当前没有实测失败,不为假设改动生产事件链。实体手机仍待验。 + +## 决策与修复 + +- 仅 `/chart` 的 `.chart-page-skeleton.chart-page-vedic-plot` 设置 `aspect-ratio:400/452`,预留图例带。内部空 SVG 保持方盘,不改共享 SVG、viewBox、静态导出、报告/校正,不画虚假的骨架图例。 +- 在 Bhava/Transit 真实成功 golden 既有测试中新增参数表、岁差、交点存在及引擎不存在的断言,旧断言保留。 +- 串行顺序:初轮浏览器结束 → 主会话修改 CSS/尺寸合同、数据代理修改两成功态测试 → 全部冻结 → 浏览器重跑与 Linux final-r2 新快照全套。r1 不覆盖、不冒充最终证据。 + +## 验收与红线 + +- 六视口盘主体尺寸不变,骨架/结果容器与外框等高,说明区起点一致;不要求有无图例的两个 SVG 元素自身等高。 +- 重新执行完整浏览器交互/命中/静态回归;主会话定向与 Linux tsc/lint/npm test/build、Python 定向/quick。失败名、多重集合、cancelled、退出码与首页 gzip 同基线比较。 +- 旧失败日志保留,未新增回归不等于全量全绿;既有 29 项加载失败根因未定,不顺带改依赖或测试工具。 +- 更新 DESIGN/PROGRESS/真人清单;无受控登录/实体手机仍写 BLOCKED,不借凭据。结果汇总入主任务 PROGRESS,不重复创建 Bug 编号(本单是原 T5 验收缺口,不是新报线上事故)。 diff --git a/docs/testing/chart-surface-polish-20260929.md b/docs/testing/chart-surface-polish-20260929.md new file mode 100644 index 00000000..63cebc1c --- /dev/null +++ b/docs/testing/chart-surface-polish-20260929.md @@ -0,0 +1,41 @@ +# 星盘展示收口 · 真人验收清单(2026-09-29) + +对应任务书:`docs/tasks/TASK-chart-surface-polish-20260929.md`。自动化证据与最终结论以同名 PROGRESS 为准。本轮仅本地实现,未推送、未部署;以下线上项目均待部署后在受控测试账户完成,不借用个人登录态或他人凭据。 + +## 环境与隐私 + +- 使用明确虚构资料的测试人物,不录入或截图真实个人资料、账户标识、Cookie、JWT、密钥。 +- 桌面 Chrome / Edge 与实体手机各走一次;手机至少覆盖接近 375/390px 的窄屏。记录浏览器/系统版本和视口,不记录身份信息。 +- 本地 Chrome + golden harness 验证了真实组件、字体和样式,但不是完整登录、服务端请求或实体手机/读屏验收。 + +## 展示与表格 + +- [ ] 切换全部盘型:参数区没有“引擎”或“Swiss Ephemeris(本站)”,原岁差、交点和时间精度提醒仍在。 +- [ ] D9 保留“庙旺”“同位”;其他分盘保留三档“庙旺”,无“同位”表头或空白占位列。 +- [ ] 未命中庙旺/同位仍按既有空值呈现,不因为本盘全未命中而隐藏受支持列;分盘上升庙旺为“不适用”。 +- [ ] 旧页面内存快照若缺能力声明,整列保守隐藏;刷新得到新响应后按新能力显示,不串人物资料。 +- [ ] 本命、月亮、D9、D10、Bhava、行运:每个宫位标题与值左边线一致。宿主、pada、速度、换宫等原居中列保持原样。 +- [ ] 窄屏整页不横滚,宽表只在表内横滚,切盘条仍可横滑。 + +## 图标说明 + +- [ ] 鼠标停在印度行星符号、逆行短线、As、星座编号及拥挤宫位的 +N 上,盘下显示对应事实说明,离开清除。 +- [ ] Tab 能到每个可解释标记,焦点可见;focus、Enter/Space 与鼠标获得同文案,Esc/失焦关闭说明。 +- [ ] 手机轻点可读说明,不靠 hover;轻点盘内空白关闭。拥挤宫位不误触相邻星体,说明不盖住绘图。 +- [ ] 西洋行星字形、度数字段、逆行提示、透明可点区域的说明与选中对象一致;星座名、宫号、ASC/MC 解释对象正确。 +- [ ] 西洋点选仍筛选相位,再点同星仍取消;Esc 仅关闭说明,不清除已选相位对象。 +- [ ] 切盘、切人物、更新出生资料后旧说明不残留。返回页面、慢网或请求失败不显示前一人物盘面。 +- [ ] 读屏能读到名称/事实,不将整张 SVG 当单张图而吞掉可聚焦目标;连续操作的 live-region 播报不过度重复。 +- [ ] 默认提示为“停在或轻点盘内标记可查看说明。”,没有运势判断或新增推测度数。 + +## 尺寸、等待与共享绘图 + +- [ ] ≥1024px:印度实际绘图区不超过 440px,西洋不超过 480px;参数卡与下方表格没有一起缩小。 +- [ ] 375/390/480/1023px:与改动前同布局宽度,不额外缩盘;字号没有另行缩小。 +- [ ] 冷打开、切人物或慢网时,盘位骨架与结果宽度一致,印度盘框高度与说明句起点也一致,出现结果时不跳动;骨架下方为图例预留空位而不绘虚假图例。失败仍退出等待态,不新增参数卡/表格骨架。 +- [ ] 报告、校正里的共享盘大小及静态行为不变;报告导出图片仍正常,无新增交互属性依赖。 + +## 已知边界 + +- 本轮基线已有个别印度盘多星同宫的字形边缘超出 SVG 边界;本地同视口对照未新增裁切,不能把“无新增裁切”写成“全部字形无裁切”。这项既有视觉问题另行记录,不扩改本轮共享布局算法。 +- 实体触屏、读屏与受控登录链路尚未执行。验收时任何不通过项记录盘型和操作步骤,不附真实个人资料。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index ccb8731d..93af7cff 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1011,11 +1011,13 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 ### 排盘参数表 -参数是**一张键值表**(`.chart-page-params-table`),八行:出生时间(含校正状态)、出生地(含经纬)、岁差、交点、上升、月宿、当前大运、引擎。左列标签 caption 字号三级色,右列值主文字色。表下方紧跟 `view.vedic.boundary` 那一句准确性边界句——边界句限定的就是这张表,两者不能分开。 +参数是**一张键值表**(`.chart-page-params-table`),七行:出生时间(含校正状态)、出生地(含经纬)、岁差、交点、上升、月宿、当前大运。2026-09-29 产品决定移除引擎品牌行及专用展示投影;内部计算来源和许可证保留,不以别的品牌词替代。左列标签 caption 字号三级色,右列值主文字色。表下方紧跟 `view.vedic.boundary` 那一句准确性边界句——边界句限定的就是这张表,两者不能分开。 **不再按断点复制参数。** 原本同一组参数有三种画法:中宫叠一张 `.chart-page-center-card`(手机两行、桌面六行),盘下方再来一张 `.chart-page-params-below` 重复那六行,由 media query 三选一。同样的事实画三遍、靠断点开关,改一处要改三处。键值表让每个字段只出现一次,`.chart-page-center-card` / `-compact` / `-extra` / `.chart-page-params-below` / `.chart-page-boundary-desktop` 五个类连同它们的 767/768 开关块一并删除,由 `chart-page-view.test.tsx` 守着不许回来。 -桌面(≥1024)盘面与参数表左右并排,行星表是跨两列的整行——它的列宽需要完整版心。 +桌面(≥1024)盘面与参数表左右并排,行星表是跨两列的整行——它的列宽需要完整版心。2026-09-29 起,印度内部 `.chart-page-vedic-plot` 绘图区上限 440px 并居中,外框 padding 不计入盘宽;西洋桌面绘图区上限 480px。低于此断点的结果盘不收紧,参数区和表格不缩。等待盘与结果盘复用同一桌面列宽与绘图容器,不再使用独立 400px 等待盘上限;印度等待绘图容器以 400/452 比例预留结果图例带,内部空 SVG 仍为 400×400,避免揭幕时外框和说明区纵向跳动,不改共享绘图。只有盘位呼吸,失败后退出,参数表不新增骨架。 + +**符号说明(2026-09-29)。** `/chart` 开启共享 SVG 的可选交互模式:鼠标悬停、键盘聚焦与手机轻点都在盘下预留说明区显示同一事实说明,不覆盖邻近图标;Esc、离开、失焦可关闭,切盘/人物/数据身份变化清理旧说明。行星与其逆行短线共用一个目标;西洋 caption 和命中圆用一致的行星解析,原点击过滤相位及再次点击取消保留。报告、校正、静态导出默认不启用该模式;纯绘图层不持有 hooks,静态 SVG 白名单不扩。 盘内 SVG 文字用 CSS 提到 16px viewBox 单位,使约 318px 盘宽下实际像素不低于 12px。Tab、盘型 chip 均 44px。 @@ -1068,12 +1070,12 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 | 盘型 | 列 | 说明 | | --- | --- | --- | | 本命 | 上面八列 | 不变(与基线渲染逐字节相同) | -| D2–D60 | 行星 · 分盘星座(度°分′)· 宫位 · 本命星座 · 庙旺 · 同位 | 宫位从该分盘上升数;庙旺只写「入旺 / 自宫 / 落陷」,其余「—」;同位 = 与本命同星座(Vargottama)。**没有星宿 / 宿主 / 足**:那是本命经度的属性。逆行在行星格后标「逆」(分盘不改顺逆)。首行是分盘上升 | +| D2–D60 | 行星 · 分盘星座(度°分′)· 宫位 · 本命星座 · 庙旺;仅 D9 再有同位 | 宫位从该分盘上升数;庙旺只写「入旺 / 自宫 / 落陷」,其余「—」,上升此格「不适用」。2026-09-29 收口:仅 D9 的 D1↔D9 同座比较显示为同位,其他分盘不显示此列。两列各由服务端能力声明控制,缺声明保守隐藏,不以是否命中推断支持性。**没有星宿 / 宿主 / 足**:那是本命经度的属性。逆行在行星格后标「逆」(分盘不改顺逆)。首行是分盘上升 | | 月亮盘 | 本命八列 | 宫位从月亮所在星座数;表头「月亮盘 · 以月亮所在星座(X)为第 1 宫」;盘上「As」画在本命上升落的那一宫(`VedicChartSvg` 的 `ascendantHouse`,其它盘默认 1 宫不变) | | Bhava | 行星 · 黄道经度 · 星座宫 · Bhava 宫 · 换宫;另一张「宫 · 宫头 · 起点(sandhi)」12 行 | Sripati。换宫的行用 `--color-selected` 浅底。拿不到真实 MC 时这一栏走失败句,不画等宫(BUG-1077) | | 行运 | 行星 · 今天的位置 · 速度 · 落本命 · 本命同星 | 表头写计算时刻「行运 · YYYY-MM-DD 12:00(时区)」,上方一句「以本命上升为第 1 宫,放的是这一天中午的行星位置。岁差和交点与本命相同。」 | -说明句 + 表格包在 `.chart-page-varga-table-block` 里,作为一个 grid 项在 ≥1024 时跨两列。 +说明句 + 表格包在 `.chart-page-varga-table-block` 里,作为一个 grid 项在 ≥1024 时跨两列。宫位表头与对应数据格统一左对齐、相同 padding;包括本命/月亮、分盘、Bhava、行运。宿主、pada、速度、换宫等原本居中列不随之变化。本命/月亮八列 markup 冻结,通过限定八列表头的第四列 CSS 修正宫位,不能全局覆盖 `chart-page-planet-mid`。 ### 三套坐标系 @@ -1099,7 +1101,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 ### 西洋盘 Tab(2026-09-25) -盘 `viewBox` 是 `0 0 520 520`,容器 `width: 100%`、`max-width: 520px`、水平居中。三圈:外圈星座、中圈行星、内圈宫位号。骨架模式只画这三圈和 12 条星座分割线,不画星座名、行星、宫位号、相位。 +盘 `viewBox` 是 `0 0 520 520`,容器 `width: 100%`、窄屏 `max-width: 520px`、≥1024px 桌面上限 480px、水平居中(2026-09-29 仅桌面收紧)。三圈:外圈星座、中圈行星、内圈宫位号。骨架模式只画这三圈和 12 条星座分割线,不画星座名、行星、宫位号、相位。 星座环按元素填色,token 在 `:root` 与两套暗色里各一份: diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 71d323f3..0d4ab220 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -105,6 +105,13 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 这四句只在活动行里,是「流式生成中」的一部分:不进正文、不进历史、刷新后不出现;只往前走,不回头;不写秒数、不写「请稍候」,不承诺多久。 +## 星盘图标说明与能力列(2026-09-29) + +- 盘面不显示引擎品牌,也不替换成「自研引擎」「本站引擎」。岁差、交点等计算口径与准确性边界照旧。 +- 图标说明只写名称和已有事实:上升、行星、逆行、星座名称/编号、宫位、ASC/MC 等。没有可靠度数就不写度数,不加吉凶、性格或个人运势解释。 +- 鼠标悬停、键盘聚焦、手机轻点展示同一份说明;说明在盘外预留区域,不遮住旁边图标。关闭说明不改变西洋相位选择。默认提示为「停在或轻点盘内标记可查看说明。」 +- 分盘庙旺仅指入旺、自宫、落陷三档;未命中写「—」,上升写「不适用」。同位仅 D9 展示,其他分盘不留空列。缺能力声明不装作已支持。 + ## 星盘空状态、0 年大运、星历切日期 这三句只描述眼前缺的数据,不解释运势,也不许写成「正在加载」: diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 2bb91c98..f88eb2ad 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -5119,7 +5119,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class /* A body the Bhava boundaries move to another house. */ .chart-page-planet-table tr.is-shifted { background: var(--color-selected); } .chart-page-waiting-plot { - width: min(100%, 400px); + width: 100%; min-width: 0; } .chart-page-skeleton { @@ -5141,6 +5141,26 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class padding: var(--space-3); } .chart-page-vedic-stage .personal-report-chart-svg { margin-top: 0; } +.chart-page-vedic-plot, +.chart-page-symbol-help { + width: 100%; + min-width: 0; +} +.chart-page-vedic-plot { margin-inline: auto; } +/* Reserve the result's legend band without changing the shared empty drawing. */ +.chart-page-skeleton.chart-page-vedic-plot { aspect-ratio: 400 / 452; } +/* Keep the explanation outside the drawing so it cannot cover nearby symbols. */ +.chart-page-note.chart-page-symbol-note { + min-height: 2.8em; + margin: var(--space-2) 0 0; + line-height: 1.4; +} +.chart-page-symbol-hit { fill: transparent; stroke: none; } +.chart-page-symbol-target { cursor: help; } +.chart-page-symbol-target:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} /* Only the small type is lifted for the chart page's narrow stage. Raising every `text` would also resize the planet symbols, which are already sized for this width. */ @@ -5247,6 +5267,10 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .ephemeris-transit-glyph.is-node { color: var(--color-planet-node); } .chart-page-planet-glyph.is-ascendant { color: var(--color-planet-ascendant); font-weight: 700; } .chart-page-planet-table td.chart-page-planet-mid { text-align: center; } +/* The natal/Moon eight-column markup stays frozen; only its house column changes. */ +.chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > tbody > tr > td:nth-child(4) { + text-align: left; +} /* Figures line up column-wise instead of drifting with glyph width. */ .chart-page-planet-num { font-variant-numeric: tabular-nums; } .chart-page-boundary { @@ -5513,6 +5537,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class gap: var(--space-6); align-items: start; } + /* Limit the drawing, not its padded stage or the parameter/table column. */ + .chart-page-vedic-plot { max-width: 440px; } + .chart-page-western-wheel { max-width: 480px; } /* Chart and parameters sit side by side; the planet table is a full-width row under both, because its columns need the whole measure. */ .chart-page-planet-table-wrap, diff --git a/frontend/src/components/chart-page/chart-page-skeleton.tsx b/frontend/src/components/chart-page/chart-page-skeleton.tsx index ade8efed..cc8b2950 100644 --- a/frontend/src/components/chart-page/chart-page-skeleton.tsx +++ b/frontend/src/components/chart-page/chart-page-skeleton.tsx @@ -4,12 +4,14 @@ import { EMPTY_WESTERN_WHEEL, WesternWheelSvg } from "./western-wheel-svg"; /** Only chart positions opt in; the shared page shell remains static. */ export function ChartPageSkeleton({ copy, western = false }: { copy: string; western?: boolean }) { return ( - <div className="chart-page-waiting-plot" aria-busy="true" role="status"> - <div className="chart-page-skeleton" aria-hidden="true"> - {western ? <WesternWheelSvg western={EMPTY_WESTERN_WHEEL} skeleton /> - : <VedicChartSvg chart={EMPTY_NORTH_INDIAN_CHART} ariaLabel={copy} skeleton />} + <div className={western ? "chart-page-western-layout" : "chart-page-vedic-layout"}> + <div className={`chart-page-waiting-plot ${western ? "chart-page-western-wheel" : "chart-page-vedic-stage"}`} aria-busy="true" role="status"> + <div className={western ? "chart-page-skeleton" : "chart-page-skeleton chart-page-vedic-plot"} aria-hidden="true"> + {western ? <WesternWheelSvg western={EMPTY_WESTERN_WHEEL} skeleton /> + : <VedicChartSvg chart={EMPTY_NORTH_INDIAN_CHART} ariaLabel={copy} skeleton />} + </div> + <p className="chart-page-note chart-page-symbol-note">{copy}</p> </div> - <p className="chart-page-note">{copy}</p> </div> ); } diff --git a/frontend/src/components/chart-page/chart-param-table.tsx b/frontend/src/components/chart-page/chart-param-table.tsx index c1629e4d..7fc43254 100644 --- a/frontend/src/components/chart-page/chart-param-table.tsx +++ b/frontend/src/components/chart-page/chart-param-table.tsx @@ -25,7 +25,6 @@ export function ChartParamTable({ view }: { view: ChartViewOk }) { ["上升", profile.ascendantSignLabel], ["月宿", `${profile.moonNakshatra} 第${profile.moonPada}足`], ["当前大运", profile.currentDashaLabel], - ["引擎", profile.engineName], ]; return ( <dl className="chart-page-params-table"> diff --git a/frontend/src/components/chart-page/chart-symbol-help.tsx b/frontend/src/components/chart-page/chart-symbol-help.tsx new file mode 100644 index 00000000..c3396527 --- /dev/null +++ b/frontend/src/components/chart-page/chart-symbol-help.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useState, type ReactNode } from "react"; + +type Explain = (text: string | null) => void; +type Props = { + resetKey: string; + children: ReactNode | ((explain: Explain) => ReactNode); +}; + +/** The drawing only supplies facts. All transient help stays on the chart page. */ +export function ChartSymbolHelp({ resetKey, children }: Props) { + return <SymbolHelpSession key={resetKey}>{children}</SymbolHelpSession>; +} + +function helpTarget(target: EventTarget | null): Element | null { + let element = target instanceof Element ? target : null; + while (element) { + if (element.hasAttribute("data-chart-help")) return element; + element = element.parentNode instanceof Element ? element.parentNode : null; + } + return null; +} + +function SymbolHelpSession({ children }: Pick<Props, "children">) { + const [help, setHelp] = useState<string | null>(null); + const explainTarget = (target: EventTarget | null) => { + setHelp(helpTarget(target)?.getAttribute("data-chart-help") ?? null); + }; + return ( + <div + className="chart-page-symbol-help" + onPointerOver={(event) => explainTarget(event.target)} + onPointerMove={(event) => explainTarget(event.target)} + onPointerOut={(event) => { + if (helpTarget(event.target) !== helpTarget(event.relatedTarget)) setHelp(null); + }} + onPointerLeave={() => setHelp(null)} + onFocus={(event) => explainTarget(event.target)} + onBlur={() => setHelp(null)} + onClick={(event) => explainTarget(event.target)} + onKeyDown={(event) => { + if (event.key === "Escape") { + setHelp(null); + event.stopPropagation(); + } else if (event.key === "Enter" || event.key === " ") { + const target = helpTarget(event.target); + if (target) { + event.preventDefault(); + explainTarget(target); + } + } + }} + > + {typeof children === "function" ? children(setHelp) : children} + <p className="chart-page-note chart-page-symbol-note" role="status" aria-live="polite" aria-atomic="true"> + {help ?? "停在或轻点盘内标记可查看说明。"} + </p> + </div> + ); +} diff --git a/frontend/src/components/chart-page/chart-vedic-tab.tsx b/frontend/src/components/chart-page/chart-vedic-tab.tsx index 0b05a2eb..c074c134 100644 --- a/frontend/src/components/chart-page/chart-vedic-tab.tsx +++ b/frontend/src/components/chart-page/chart-vedic-tab.tsx @@ -30,6 +30,7 @@ import { ChartParamTable } from "./chart-param-table"; import { chartLayerFailureCopy } from "@/lib/chart-view-failure"; import { VARGA_DIGNITY_LABEL } from "@/lib/chart-view-varga"; import { ChartPageSkeleton } from "./chart-page-skeleton"; +import { ChartSymbolHelp } from "./chart-symbol-help"; /** The same coloured symbol the chart draws, so a row and a house read as one body. */ function BodyGlyph({ name }: { name: string }) { @@ -136,7 +137,9 @@ function NatalTable({ planets, ascendant, ascendantHouse, caption }: { ); } -function VargaRow({ row }: { row: ChartViewVargaRow }) { +type VargaColumns = { supportsDignity: boolean; supportsVargottama: boolean }; + +function VargaRow({ row, supportsDignity, supportsVargottama }: { row: ChartViewVargaRow } & VargaColumns) { const key = planetGlyphKey(row.name); return ( <tr> @@ -145,35 +148,39 @@ function VargaRow({ row }: { row: ChartViewVargaRow }) { {row.retrograde ? <span className="chart-page-planet-abbr"> 逆</span> : null} </th> <td className="chart-page-planet-num">{row.signLabel} {formatDegreeMinutes(row.degreeInSign)}</td> - <td className="chart-page-planet-mid">{row.house}宫</td> + <td>{row.house}宫</td> <td>{row.natalSignLabel}</td> - <td className="chart-page-planet-mid">{row.dignity ? VARGA_DIGNITY_LABEL[row.dignity] : "—"}</td> - <td className="chart-page-planet-mid">{row.vargottama ? "同位" : "—"}</td> + {supportsDignity && <td className="chart-page-planet-mid">{row.dignity ? VARGA_DIGNITY_LABEL[row.dignity] : "—"}</td>} + {supportsVargottama && <td className="chart-page-planet-mid">{row.vargottama ? "同位" : "—"}</td>} </tr> ); } -function VargaAscendantRow({ row }: { row: ChartViewVargaAscendantRow }) { +function VargaAscendantRow({ row, supportsDignity, supportsVargottama }: { row: ChartViewVargaAscendantRow } & VargaColumns) { return ( <tr> <th scope="row"> <PlanetCell name={null} label="上升" abbr="As" /> </th> <td className="chart-page-planet-num">{row.signLabel} {formatDegreeMinutes(row.degreeInSign)}</td> - <td className="chart-page-planet-mid">1宫</td> + <td>1宫</td> <td>{row.natalSignLabel}</td> - <td className="chart-page-planet-mid">—</td> - <td className="chart-page-planet-mid">{row.vargottama ? "同位" : "—"}</td> + {supportsDignity && <td className="chart-page-planet-mid">不适用</td>} + {supportsVargottama && <td className="chart-page-planet-mid">{row.vargottama ? "同位" : "—"}</td>} </tr> ); } /** - * A divisional chart's own table (BUG-1076). Six columns, not the natal eight: - * nakshatra, its lord and pada are properties of the D1 longitude and would be - * wrong data here, so they are not shown at all. + * A divisional chart's own table (BUG-1076). Capability columns are declared + * by the server, independently of their result values. Nakshatra, its lord and + * pada belong to the D1 longitude and would be wrong data here. */ export function VargaTable({ varga }: { varga: ChartViewVarga }) { + const columns: VargaColumns = { + supportsDignity: varga.supportsDignity === true, + supportsVargottama: varga.supportsVargottama === true, + }; return ( <div className="chart-page-planet-table-wrap"> <table className="chart-page-planet-table"> @@ -184,13 +191,13 @@ export function VargaTable({ varga }: { varga: ChartViewVarga }) { <th scope="col">分盘星座</th> <th scope="col">宫位</th> <th scope="col">本命星座</th> - <th scope="col">庙旺</th> - <th scope="col">同位</th> + {columns.supportsDignity && <th scope="col">庙旺</th>} + {columns.supportsVargottama && <th scope="col">同位</th>} </tr> </thead> <tbody> - {varga.ascendant ? <VargaAscendantRow row={varga.ascendant} /> : null} - {(varga.rows ?? []).map((row) => <VargaRow key={row.name} row={row} />)} + {varga.ascendant ? <VargaAscendantRow row={varga.ascendant} {...columns} /> : null} + {(varga.rows ?? []).map((row) => <VargaRow key={row.name} row={row} {...columns} />)} </tbody> </table> </div> @@ -226,8 +233,8 @@ function TransitTable({ transit }: { transit: ChartViewTransitOk }) { </th> <td className="chart-page-planet-num">{row.signLabel} {formatDegreeMinutes(row.degreeInSign)}</td> <td className="chart-page-planet-mid">{row.retrograde ? "逆" : "顺"}</td> - <td className="chart-page-planet-mid">{row.natalHouse}宫</td> - <td className="chart-page-planet-mid">{row.natalPlanetHouse === null ? "—" : `${row.natalPlanetHouse}宫`}</td> + <td>{row.natalHouse}宫</td> + <td>{row.natalPlanetHouse === null ? "—" : `${row.natalPlanetHouse}宫`}</td> </tr> ); })} @@ -261,8 +268,8 @@ function BhavaTables({ bhava }: { bhava: ChartViewBhavaOk }) { <PlanetCell name={row.name} label={row.label} abbr={key ? PLANET_ABBR[key] : "—"} /> </th> <td className="chart-page-planet-num">{row.signLabel} {formatDegreeMinutes(row.degreeInSign)}</td> - <td className="chart-page-planet-mid">{row.rashiHouse}宫</td> - <td className="chart-page-planet-mid">{row.bhavaHouse}宫</td> + <td>{row.rashiHouse}宫</td> + <td>{row.bhavaHouse}宫</td> <td className="chart-page-planet-mid">{row.shifted ? "是" : "否"}</td> </tr> ); @@ -306,7 +313,11 @@ function VedicLayout({ view, chart, ariaLabel, ascendantHouse, children }: { return ( <div className="chart-page-vedic-layout"> <div className="chart-page-vedic-stage"> - <VedicChartSvg chart={chart} ariaLabel={ariaLabel} ascendantHouse={ascendantHouse} /> + <ChartSymbolHelp resetKey={JSON.stringify([view.accountId, view.subjectId, view.profileFingerprint, ariaLabel, ascendantHouse, chart])}> + <div className="chart-page-vedic-plot"> + <VedicChartSvg chart={chart} ariaLabel={ariaLabel} ascendantHouse={ascendantHouse} interactive /> + </div> + </ChartSymbolHelp> </div> <div className="chart-page-params"> <ChartParamTable view={view} /> diff --git a/frontend/src/components/chart-page/chart-western-tab.tsx b/frontend/src/components/chart-page/chart-western-tab.tsx index 6d764e60..5b1e8123 100644 --- a/frontend/src/components/chart-page/chart-western-tab.tsx +++ b/frontend/src/components/chart-page/chart-western-tab.tsx @@ -6,6 +6,7 @@ import type { ChartViewOk, ChartViewWesternOk } from "@/lib/chart-view-contract" import { COORDINATE_BOUNDARY, SIGN_ORDER, signZh } from "@/lib/chart-view-labels"; import { formatDegreeMinutes } from "./chart-format"; import { WesternWheelSvg } from "./western-wheel-svg"; +import { ChartSymbolHelp } from "./chart-symbol-help"; const STELLIUM_COUNT = 3; @@ -50,6 +51,11 @@ export function ChartWesternTab({ view: ChartViewOk; initialSelectedId?: string | null; }) { + const resetKey = JSON.stringify([view.accountId, view.subjectId, view.profileFingerprint, view.western]); + return <WesternTabSession key={resetKey} view={view} initialSelectedId={initialSelectedId} />; +} + +function WesternTabSession({ view, initialSelectedId }: { view: ChartViewOk; initialSelectedId: string | null }) { const western = view.western; const [selectedId, setSelectedId] = useState<string | null>(initialSelectedId); if (western.status === "unavailable") { @@ -75,11 +81,16 @@ export function ChartWesternTab({ <div className="chart-page-western"> <div className="chart-page-western-layout"> <div className="chart-page-western-wheel"> - <WesternWheelSvg - western={western} - selectedLabel={selected?.label ?? null} - onSelectPlanet={toggle} - /> + <ChartSymbolHelp resetKey="western"> + {(explain) => ( + <WesternWheelSvg + western={western} + selectedLabel={selected?.label ?? null} + onSelectPlanet={toggle} + onExplain={explain} + /> + )} + </ChartSymbolHelp> </div> <div className="chart-page-western-side"> <ul className="chart-page-western-chips"> diff --git a/frontend/src/components/chart-page/western-wheel-svg.tsx b/frontend/src/components/chart-page/western-wheel-svg.tsx index 780e0e93..7fda4a65 100644 --- a/frontend/src/components/chart-page/western-wheel-svg.tsx +++ b/frontend/src/components/chart-page/western-wheel-svg.tsx @@ -1,4 +1,4 @@ -import type { MouseEvent } from "react"; +import type { MouseEvent, PointerEvent } from "react"; import type { ChartViewWesternOk } from "@/lib/chart-view-contract"; import { SIGN_ORDER, signZh, type SignName } from "@/lib/chart-view-labels"; @@ -79,11 +79,13 @@ export function WesternWheelSvg({ skeleton = false, selectedLabel = null, onSelectPlanet, + onExplain, }: { western: WesternWheelModel; skeleton?: boolean; selectedLabel?: string | null; onSelectPlanet?: (id: string) => void; + onExplain?: (text: string | null) => void; }) { const western = skeleton ? EMPTY_WESTERN_WHEEL : suppliedWestern; const asc = western.ascendantLongitude; @@ -115,15 +117,38 @@ export function WesternWheelSvg({ ); return { id: placement.id, x: display.x, y: display.y }; }); - function selectNearest(event: MouseEvent<SVGElement>, fallbackId: string) { + function nearestId(event: MouseEvent<SVGElement>, fallbackId: string): string { const svg = event.currentTarget.ownerSVGElement; const matrix = svg?.getScreenCTM(); - if (!svg || !matrix || !onSelectPlanet) return; + if (!svg || !matrix) return fallbackId; const point = svg.createSVGPoint(); point.x = event.clientX; point.y = event.clientY; const local = point.matrixTransform(matrix.inverse()); - onSelectPlanet(nearestWesternPlanetId(hitPoints, local.x, local.y) ?? fallbackId); + return nearestWesternPlanetId(hitPoints, local.x, local.y) ?? fallbackId; + } + function planetDescription(id: string): string | null { + const planet = western.planets.find((body) => body.id === id); + return planet ? `${planet.label} · ${planet.retrograde ? "逆行" : "顺行"}` : null; + } + function explainNearest(event: PointerEvent<SVGElement>, fallbackId: string) { + if (!onExplain) return; + event.stopPropagation(); + onExplain(planetDescription(nearestId(event, fallbackId))); + } + function selectNearest(event: MouseEvent<SVGElement>, fallbackId: string) { + const id = nearestId(event, fallbackId); + onSelectPlanet?.(id); + if (onExplain) { + event.stopPropagation(); + onExplain(planetDescription(id)); + } + } + function helpProps(text: string) { + return onExplain ? { + className: "chart-page-symbol-target", role: "button", tabIndex: 0, + "aria-label": text, "data-chart-help": text, + } : {}; } const longitudeByLabel = new Map(western.planets.map((planet) => [planet.label, planet.longitude])); longitudeByLabel.set("上升", asc); @@ -133,7 +158,7 @@ export function WesternWheelSvg({ <svg viewBox={`0 0 ${WESTERN_SIZE} ${WESTERN_SIZE}`} width="100%" - role="img" + role={skeleton ? "img" : "group"} aria-label={skeleton ? undefined : "回归黄道本命盘,上升在左,宫位按 Placidus"} aria-hidden={skeleton || undefined} data-skeleton={skeleton ? "true" : undefined} @@ -159,25 +184,12 @@ export function WesternWheelSvg({ const edge = westernScreenAngle(index * 30, asc); const inner = westernPolar(WESTERN_R_ZODIAC, edge); const outer = westernPolar(WESTERN_R_OUTER, edge); - const label = westernPolar((WESTERN_R_ZODIAC + WESTERN_R_OUTER) / 2, westernScreenAngle(index * 30 + 15, asc)); - return ( - <g key={`${sign}-spoke`}> - <line x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} className="chart-page-western-spoke" /> - {!skeleton && ( - <text x={label.x} y={label.y} textAnchor="middle" dominantBaseline="central" style={{ fontSize: WESTERN_FONT_SIZE }} className="chart-page-western-sign-label"> - {signZh(sign)} - </text> - )} - </g> - ); + return <line key={`${sign}-spoke`} x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} className="chart-page-western-spoke" />; })} - {!skeleton && western.houses.map((house, index) => { - const next = western.houses[(index + 1) % western.houses.length] ?? house; + {!skeleton && western.houses.map((house) => { const angle = westernScreenAngle(house.longitude, asc); const inner = westernPolar(WESTERN_R_ASPECT, angle); const outer = westernPolar(WESTERN_R_ZODIAC, angle); - const mid = houseMidLongitude(house.longitude, next.longitude); - const label = houseLabels[index]!; const axis = house.house === 1 || house.house === 4 || house.house === 7 || house.house === 10; return ( <g key={house.house}> @@ -188,17 +200,6 @@ export function WesternWheelSvg({ y2={outer.y} className={axis ? "chart-page-western-house is-axis" : "chart-page-western-house"} /> - <text - x={label.x} - y={label.y} - textAnchor="middle" - dominantBaseline="central" style={{ fontSize: WESTERN_FONT_SIZE }} - className="chart-page-western-house-label" - data-house={house.house} - data-longitude={fmt(mid)} - > - {house.house} - </text> </g> ); })} @@ -238,12 +239,21 @@ export function WesternWheelSvg({ role="button" tabIndex={0} aria-pressed={selected} - aria-label={planet.label} + aria-label={onExplain ? planetDescription(planet.id) ?? planet.label : planet.label} + onPointerOver={(event) => explainNearest(event, planet.id)} + onPointerMove={(event) => explainNearest(event, planet.id)} + onPointerLeave={() => onExplain?.(null)} + onFocus={(event) => { + if (!onExplain) return; + event.stopPropagation(); + onExplain(planetDescription(planet.id)); + }} onClick={(event) => selectNearest(event, planet.id)} onKeyDown={(event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); onSelectPlanet?.(planet.id); + onExplain?.(planetDescription(planet.id)); }} > <line @@ -281,6 +291,9 @@ export function WesternWheelSvg({ data-longitude={fmt(placement.longitude)} data-display-longitude={fmt(placement.displayLongitude)} aria-hidden="true" + onPointerOver={(event) => explainNearest(event, planet.id)} + onPointerMove={(event) => explainNearest(event, planet.id)} + onPointerLeave={() => onExplain?.(null)} onClick={(event) => selectNearest(event, planet.id)} > <text @@ -296,18 +309,52 @@ export function WesternWheelSvg({ </g> ); })} + {/* Fixed labels also sit above hit circles: their visible ink must keep + its own explanation rather than selecting a nearby planet. */} + {!skeleton && SIGN_ORDER.map((sign, index) => { + const label = westernPolar((WESTERN_R_ZODIAC + WESTERN_R_OUTER) / 2, westernScreenAngle(index * 30 + 15, asc)); + return ( + <g key={`${sign}-label`} {...helpProps(`${signZh(sign)}座`)}> + <text x={label.x} y={label.y} textAnchor="middle" dominantBaseline="central" style={{ fontSize: WESTERN_FONT_SIZE }} className="chart-page-western-sign-label" pointerEvents="bounding-box"> + {signZh(sign)} + </text> + </g> + ); + })} + {!skeleton && western.houses.map((house, index) => { + const next = western.houses[(index + 1) % western.houses.length] ?? house; + const label = houseLabels[index]!; + return ( + <g key={`house-${house.house}-label`} {...helpProps(`${house.house} · 第 ${house.house} 宫`)}> + <text + x={label.x} + y={label.y} + textAnchor="middle" + dominantBaseline="central" style={{ fontSize: WESTERN_FONT_SIZE }} + className="chart-page-western-house-label" + data-house={house.house} + data-longitude={fmt(houseMidLongitude(house.longitude, next.longitude))} + pointerEvents="bounding-box" + > + {house.house} + </text> + </g> + ); + })} {!skeleton && angles.map((angle) => ( - <text - key={angle.caption} - x={angle.x} - y={angle.y} - textAnchor="middle" - dominantBaseline="central" - style={{ fontSize: WESTERN_FONT_SIZE }} - className="chart-page-western-angle" - > - {angle.caption} - </text> + <g key={angle.caption} {...helpProps(`${angle.caption} · ${angle.caption === "ASC" ? "上升" : "中天"}`)}> + <text + x={angle.x} + y={angle.y} + textAnchor="middle" + dominantBaseline="central" + style={{ fontSize: WESTERN_FONT_SIZE }} + className="chart-page-western-angle" + pointerEvents="bounding-box" + > + {angle.caption} + </text> + </g> ))} </svg> ); diff --git a/frontend/src/components/personal-report/vedic-chart-svg.tsx b/frontend/src/components/personal-report/vedic-chart-svg.tsx index b00bea6f..e307d423 100644 --- a/frontend/src/components/personal-report/vedic-chart-svg.tsx +++ b/frontend/src/components/personal-report/vedic-chart-svg.tsx @@ -19,6 +19,7 @@ import { useId } from "react"; import type { ChartV1 } from "@/lib/personal-report-contract"; +import { SIGN_ORDER, signZh } from "@/lib/chart-view-labels"; import { CHART_SIZE, HOUSE_NUMBERS, @@ -30,6 +31,8 @@ import { import { ASCENDANT_COLOR_CLASS, ASCENDANT_MARK, + ASCENDANT_NAME_ZH, + PLANET_NAME_ZH, PLANET_COLOR_CLASS, PLANET_GLYPH, PLANET_GLYPH_ZH, @@ -70,6 +73,8 @@ export const EMPTY_NORTH_INDIAN_CHART: NorthIndianChartModel = { houses: [] }; interface VedicChartSvgProps { skeleton?: boolean; + /** Chart-page enhancement only; reports, rectification and exports stay static. */ + interactive?: boolean; chart: NorthIndianChartModel; ariaLabel: string; highlightHouseNumbers?: ReadonlySet<number>; @@ -165,7 +170,29 @@ function tokenKey(token: ChartToken, index: number): string { return `${token.label}-${index}`; } -function OccupantGlyph({ token, x, y }: { token: ChartToken; x: number; y: number }) { +function tokenDescription(token: ChartToken): string { + if (token.kind === "planet") { + return `${PLANET_NAME_ZH[token.key]} · ${token.retrograde ? "逆行(符号下的横线)" : "顺行"}`; + } + if (token.kind === "ascendant") return `${ASCENDANT_MARK} · ${ASCENDANT_NAME_ZH}`; + if (token.kind === "overflow") return `${token.label} · 这一宫还有 ${token.label.slice(1)} 个标记未展开,可查看下方表格。`; + return token.label; +} + +function OccupantGlyph({ token, x, y, interactive }: { token: ChartToken; x: number; y: number; interactive: boolean }) { + const drawing = <OccupantGlyphDrawing token={token} x={x} y={y} />; + if (!interactive) return drawing; + const description = tokenDescription(token); + return ( + <g className="chart-page-symbol-target" role="button" tabIndex={0} aria-label={description} data-chart-help={description}> + <title>{description} + + {drawing} + + ); +} + +function OccupantGlyphDrawing({ token, x, y }: { token: ChartToken; x: number; y: number }) { if (token.kind === "ascendant") { return ( @@ -285,7 +313,8 @@ export function VedicChartSvg(props: VedicChartSvgProps) { } /** Shared, hook-free drawing for the reader and offline Markdown export. */ -export function VedicChartSvgDrawing({ chart: suppliedChart, ariaLabel, highlightHouseNumbers, skeleton = false, chartId, ascendantHouse = 1 }: VedicChartSvgProps & { chartId: string }) { +export function VedicChartSvgDrawing({ chart: suppliedChart, ariaLabel, highlightHouseNumbers, skeleton = false, chartId, ascendantHouse = 1, interactive: enableHelp = false }: VedicChartSvgProps & { chartId: string }) { + const interactive = enableHelp && !skeleton; const chart = skeleton ? EMPTY_NORTH_INDIAN_CHART : suppliedChart; const retrograde = retrogradeKeys(chart); const tokensByHouse = new Map(); @@ -307,7 +336,7 @@ export function VedicChartSvgDrawing({ chart: suppliedChart, ariaLabel, highligh {ordinal !== null && ( - - {ordinal} - + {interactive && } + + {ordinal} + + )} - + ); diff --git a/frontend/src/lib/chart-view-contract.ts b/frontend/src/lib/chart-view-contract.ts index 0a6aeefe..2fcc81eb 100644 --- a/frontend/src/lib/chart-view-contract.ts +++ b/frontend/src/lib/chart-view-contract.ts @@ -105,6 +105,10 @@ export const chartViewVargaSchema = z.object({ // D2 and up only. D1 keeps the natal eight-column table (`vedic.planets`). ascendant: chartViewVargaAscendantRowSchema.optional(), rows: z.array(chartViewVargaRowSchema).optional(), + // Older module-cache snapshots have no declaration: consumers must hide the + // corresponding column unless its server-projected capability is exactly true. + supportsDignity: z.boolean().optional(), + supportsVargottama: z.boolean().optional(), }); /** @@ -258,7 +262,6 @@ export const chartViewProfileSchema = z.object({ nodeModeLabel: z.string().min(1), birthTimeStatus: z.string().min(1), birthTimeStatusLabel: z.string().min(1), - engineName: z.string().min(1), moonNakshatra: z.string().min(1), moonPada: z.number().int().min(1).max(4), ascendantSign: z.string().min(1), diff --git a/frontend/src/lib/chart-view-mapper.ts b/frontend/src/lib/chart-view-mapper.ts index 8982cf87..d8b8c67f 100644 --- a/frontend/src/lib/chart-view-mapper.ts +++ b/frontend/src/lib/chart-view-mapper.ts @@ -314,13 +314,20 @@ function buildVargas( const mapped = vargaToChart(id, `${id} ${text(raw.name) ?? ""}`.trim(), raw, natalRetro); if (mapped) { const ascSign = text(record(raw.ascendant)?.sign)!; + const table = division === 1 ? null : vargaRows(ascSign, raw, natalRetro, natal); vargas.push({ id, title: id, meaning: mapped.meaning, division: mapped.division, chart: mapped.chart, - ...(division === 1 ? {} : vargaRows(ascSign, raw, natalRetro, natal)), + ...(table ? { + ...table, + // A null dignity or false same-sign result is still a valid result. + // Availability follows validated source rows, never positive hits. + supportsDignity: table.rows.length > 0, + supportsVargottama: division === 9 && (table.rows.length > 0 || table.ascendant !== undefined), + } : {}), }); continue; } @@ -819,7 +826,6 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk { nodeModeLabel: nodeModeLabel(nodeMode), birthTimeStatus: profile.birthTimeStatus ?? "reported", birthTimeStatusLabel: birthTimeStatusLabel(profile.birthTimeStatus ?? "reported"), - engineName: "Swiss Ephemeris(本站)", moonNakshatra: text(moon.nakshatra) ?? "—", moonPada: Math.min(4, Math.max(1, Math.trunc(finite(moon.nakshatra_pada) ?? 1))), ascendantSign: text(ascendant?.sign) ?? "—", diff --git a/frontend/tests/chart-bhava.test.tsx b/frontend/tests/chart-bhava.test.tsx index 3e64e652..c90b870d 100644 --- a/frontend/tests/chart-bhava.test.tsx +++ b/frontend/tests/chart-bhava.test.tsx @@ -85,10 +85,25 @@ test("Sripati rows, cusps and the drawn houses follow the engine's Bhava houses" const markup = render(view); assert.ok(markup.includes(CHART_VIEW_COPY.bhavaNote)); + assert.match(markup, /
/); + assert.match(markup, /
岁差<\/dt>/); + assert.match(markup, /
交点<\/dt>/); + assert.doesNotMatch(markup, /Swiss Ephemeris|>引擎Bhava · Sripati<\/caption>/); assert.match(markup, /宫头与宫界<\/caption>/); assert.equal((markup.match(/class="is-shifted"/g) ?? []).length, shifted.length); assert.equal((markup.match(/是<\/td>/g) ?? []).length, shifted.length); + const body = markup.match(/([\s\S]*?)<\/tbody>/)![1]!; + const rows = [...body.matchAll(/]*>([\s\S]*?)<\/tr>/g)]; + assert.equal(rows.length, 9); + for (const [, cells] of rows) { + const tds = [...cells!.matchAll(/]*)>([\s\S]*?)<\/td>/g)]; + for (const index of [1, 2]) { + assert.equal(tds[index]![1], "", "both house columns inherit their header's left alignment"); + assert.match(tds[index]![2]!, /^\d+宫$/); + } + assert.match(tds[3]![1]!, /chart-page-planet-mid/, "the shifted column remains centered"); + } }); test("an equal-house placeholder MC or a house-system fallback is never drawn", async () => { diff --git a/frontend/tests/chart-moon-transit.test.tsx b/frontend/tests/chart-moon-transit.test.tsx index 61827d1c..79237cb0 100644 --- a/frontend/tests/chart-moon-transit.test.tsx +++ b/frontend/tests/chart-moon-transit.test.tsx @@ -127,6 +127,21 @@ test("the transit layer posts today's noon with the natal ayanamsa and node mode assert.match(markup, /行运 · 2026-09-15 12:00(Asia\/Shanghai)<\/caption>/); for (const column of ["行星", "今天的位置", "速度", "落本命", "本命同星"]) assert.match(markup, new RegExp(`${column}`)); assert.ok(markup.includes(CHART_VIEW_COPY.transitNote)); + assert.match(markup, /
/); + assert.match(markup, /
岁差<\/dt>/); + assert.match(markup, /
交点<\/dt>/); + assert.doesNotMatch(markup, /Swiss Ephemeris|>引擎([\s\S]*?)<\/tbody>/)![1]!; + const rows = [...body.matchAll(/([\s\S]*?)<\/tr>/g)]; + assert.equal(rows.length, 9); + for (const [, cells] of rows) { + const tds = [...cells!.matchAll(/]*)>([\s\S]*?)<\/td>/g)]; + for (const index of [2, 3]) { + assert.equal(tds[index]![1], "", "both house columns inherit their header's left alignment"); + assert.match(tds[index]![2]!, /^\d+宫$/); + } + assert.match(tds[1]![1]!, /chart-page-planet-mid/, "direction remains centered"); + } }); test("the transit tab waits, then fails with the layer copy, and is never requested for D1", async () => { diff --git a/frontend/tests/chart-page-hook.test.ts b/frontend/tests/chart-page-hook.test.ts index 8cbcadb0..2f967c90 100644 --- a/frontend/tests/chart-page-hook.test.ts +++ b/frontend/tests/chart-page-hook.test.ts @@ -2,6 +2,9 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import React, { useLayoutEffect } from "react"; import test from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { VargaTable } from "../src/components/chart-page/chart-vedic-tab.tsx"; +import { ChartParamTable } from "../src/components/chart-page/chart-param-table.tsx"; import { useChartPage } from "../src/hooks/use-chart-page.ts"; import { bindCurrentSubjectAccount, resetSubjectCatalogForTests } from "../src/lib/current-subject.ts"; import { assembleChartView } from "../src/lib/chart-view-load.ts"; @@ -163,6 +166,58 @@ test("the transit layer merges beside varga and survives a late warm D1 refresh" assert.ok(view.vedic.vargas.some((x) => x.id === "D60")); })); +test("old capability-less module cache stays hidden through direct reads and layer merges until refreshed", async () => harness(async (h, state) => { + const initial = await packet(); + const currentVargas = await packet("varga"); + const western = await packet("western"); + const legacy = structuredClone(currentVargas.body) as ChartViewOk; + Object.assign(legacy.profile, { engineName: "Swiss Ephemeris(本站)" }); + for (const varga of legacy.vedic.vargas) { + delete varga.supportsDignity; + delete varga.supportsVargottama; + } + pinChartSnapshotIdentity({ accountId: "synthetic", fingerprint: "fixture-v1" }); + writeChartPage({ kind: "view", view: legacy }); + const refresh = deferred(); + globalThis.fetch = async (url) => String(url).includes("layers=western") ? response(western) + : String(url).includes("layers=varga") ? response(currentVargas) : refresh.promise; + const assertLegacyHidden = () => { + const view = state().view as ChartViewOk; + const d9 = view.vedic.vargas.find((item) => item.id === "D9")!; + assert.equal(d9.supportsDignity, undefined); + assert.equal(d9.supportsVargottama, undefined); + assert.doesNotMatch(renderToStaticMarkup(React.createElement(VargaTable, { varga: d9 })), />庙旺<|>同位<|>不适用 state().requestLayer("western")); + assert.equal((state().view as ChartViewOk).western.status, "ok"); + assertLegacyHidden(); // Unrelated layer merge retains the old varga object. + await h.update(() => refresh.resolve(response(initial))); + assertLegacyHidden(); // Late D1 refresh retains already loaded vargas too. + await h.update(() => state().requestLayer("varga")); + const updated = state().view as ChartViewOk; + const d9 = updated.vedic.vargas.find((item) => item.id === "D9")!; + assert.equal(d9.supportsDignity, true); + assert.equal(d9.supportsVargottama, true); + assert.match(renderToStaticMarkup(React.createElement(VargaTable, { varga: d9 })), />庙旺<[\s\S]*>同位 harness(async (h, state) => { + const initial = await packet(); + const invalid = await packet("varga"); + const body = structuredClone(invalid.body) as ChartViewOk; + const d9 = body.vedic.vargas.find((item) => item.id === "D9")!; + Object.assign(d9, { supportsVargottama: "true" }); + globalThis.fetch = async (url) => String(url).includes("layers=") ? response({ ...invalid, body }) : response(initial); + await h.render(null); + await h.update(() => state().requestLayer("varga")); + assert.equal(state().layerFailures.get("varga"), "response_schema_failure"); + assert.deepEqual(state().view, initial.body); +})); + test("HTTP 429 without a valid body remains engine_busy", async () => harness(async (h, state) => { const initial = await packet(); globalThis.fetch = async (url) => String(url).includes("layers=") ? new Response("busy", { status: 429 }) : response(initial); await h.render(null); await h.update(() => state().requestLayer("western")); diff --git a/frontend/tests/chart-page-mobile-width-20260929.test.ts b/frontend/tests/chart-page-mobile-width-20260929.test.ts index 526b8968..984c9857 100644 --- a/frontend/tests/chart-page-mobile-width-20260929.test.ts +++ b/frontend/tests/chart-page-mobile-width-20260929.test.ts @@ -20,6 +20,33 @@ test("the secondary page's single column may shrink below its content (BUG-1083) assert.match(page, /grid-template-columns:\s*minmax\(0,\s*1fr\)/); }); +test("chart drawing limits are desktop-only and exclude the padded Vedic stage", () => { + const desktopStart = globalStyles.indexOf("@media (min-width: 1024px) {\n .chart-page-vedic-layout,"); + assert.ok(desktopStart >= 0); + const desktopEnd = globalStyles.indexOf("\n}", desktopStart); + const desktop = globalStyles.slice(desktopStart, desktopEnd + 2); + const outsideDesktop = globalStyles.slice(0, desktopStart) + globalStyles.slice(desktopEnd + 2); + assert.match(cssDeclarations(".chart-page-vedic-plot", desktop), /max-width:\s*440px/); + assert.match(cssDeclarations(".chart-page-western-wheel", desktop), /max-width:\s*480px/); + assert.doesNotMatch(cssDeclarations(".chart-page-vedic-plot", outsideDesktop), /max-width/); + assert.doesNotMatch(cssDeclarations(".chart-page-vedic-stage", globalStyles), /max-width/); + assert.match(cssDeclarations(".chart-page-western-wheel", outsideDesktop), /max-width:\s*520px/); + assert.match(cssDeclarations(".chart-page-waiting-plot", globalStyles), /width:\s*100%/); + const skeleton = readFileSync(new URL("../src/components/chart-page/chart-page-skeleton.tsx", import.meta.url), "utf8"); + assert.match(skeleton, /chart-page-western-layout/); + assert.match(skeleton, /chart-page-vedic-layout/); + assert.match(skeleton, /chart-page-vedic-plot/); +}); + +test("the Vedic waiting slot reserves the result legend band without changing the shared SVG", () => { + assert.match(cssDeclarations(".chart-page-skeleton.chart-page-vedic-plot", globalStyles), /aspect-ratio:\s*400\s*\/\s*452/); +}); + +test("only the house column in frozen natal and Moon tables overrides centering", () => { + assert.match(cssDeclarations(".chart-page-planet-table td.chart-page-planet-mid", globalStyles), /text-align:\s*center/); + assert.match(cssDeclarations(".chart-page-planet-table:has(> thead > tr > th:nth-child(8)) > tbody > tr > td:nth-child(4)", globalStyles), /text-align:\s*left/); +}); + test("wide chart-page content still scrolls inside its own box instead of widening the page", () => { assert.match(cssDeclarations(".chart-page-planet-table-wrap", globalStyles), /overflow-x:\s*auto/); assert.match(cssDeclarations(".chart-page-type-strip", globalStyles), /overflow-x:\s*auto/); diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index a2b9e62b..3f09cabe 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -86,6 +86,21 @@ function withSidebarProvider(element: React.ReactElement) { return renderToStaticMarkup(React.createElement(SidebarProvider, null, element)); } +test("all chart types omit the engine display without removing calculation parameters", async () => { + const view = await okView(golden.western); + for (const varga of view.vedic.vargas) { + const markup = renderToStaticMarkup( {}} />); + assert.doesNotMatch(markup, /Swiss Ephemeris|>引擎岁差交点 {}} />); + assert.doesNotMatch(markup, /Swiss Ephemeris|>引擎), /Swiss Ephemeris|>引擎 { const view = await okView(); for (const tab of CHART_VIEW_TABS) { @@ -404,7 +419,10 @@ test("the western tab lists every planet and filters aspects for the selected bo assert.match(markup, /太阳 /); assert.match(markup, /月亮 /); assert.match(markup, /星群 摩羯/); + // 原值:所有宽度均为 520px 上限;新值:桌面 480px、窄屏仍 520px。 + // 原因:TASK-chart-surface-polish 只收紧桌面盘宽,不修改 viewBox 或手机布局。 assert.match(cssDeclarations(".chart-page-western-wheel", globalStyles), /max-width:\s*520px/); + assert.match(cssDeclarations(".chart-page-western-wheel", globalStyles), /max-width:\s*480px/); assert.match(cssDeclarations(".chart-page-western-table-wrap", globalStyles), /overflow-x:\s*auto/); assert.match(cssDeclarations(".chart-page-western-table-wrap", globalStyles), /min-width:\s*0/); assert.doesNotMatch(cssDeclarations(".chart-page-western-table", globalStyles), /min-width/); diff --git a/frontend/tests/chart-planet-glyphs.test.tsx b/frontend/tests/chart-planet-glyphs.test.tsx index 80a83b6d..d27d710f 100644 --- a/frontend/tests/chart-planet-glyphs.test.tsx +++ b/frontend/tests/chart-planet-glyphs.test.tsx @@ -45,8 +45,11 @@ test("a house of display text renders symbols, and a retrograde body gets a bar assert.match(markup, /class="personal-report-chart-retrograde is-node"/, "罗逆 gets a bar"); assert.doesNotMatch(markup, /class="personal-report-chart-retrograde is-mercury"/, "水 is direct"); assert.doesNotMatch(markup, /°/, "degrees are gone from the chart"); - // 「逆」survives only in the legend line 横线=逆行. - assert.deepEqual(markup.match(/逆/g), ["逆"]); + // 原值:全 markup 的「逆」只有一处;新值:只计 SVG 可见 text,另测说明节点。 + // 原因:页面交互说明可包含逆行,但盘内不能重新塞回「逆」字标签。 + const visibleText = [...markup.matchAll(/]*>(.*?)<\/text>/g)].map((hit) => hit[1]).join(""); + assert.deepEqual(visibleText.match(/逆/g), ["逆"]); + assert.doesNotMatch(markup, /data-chart-help|tabindex|/, "shared default stays static"); }); test("the rectification board's full Chinese names map to the same symbols", () => { diff --git a/frontend/tests/chart-symbol-help.test.tsx b/frontend/tests/chart-symbol-help.test.tsx new file mode 100644 index 00000000..80e850bb --- /dev/null +++ b/frontend/tests/chart-symbol-help.test.tsx @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import test from "node:test"; +import { ChartSymbolHelp } from "../src/components/chart-page/chart-symbol-help.tsx"; +import { ChartWesternTab } from "../src/components/chart-page/chart-western-tab.tsx"; +import { VedicChartSvg, VedicChartSvgDrawing } from "../src/components/personal-report/vedic-chart-svg.tsx"; +import { assembleChartView } from "../src/lib/chart-view-load.ts"; +import type { ChartViewOk } from "../src/lib/chart-view-contract.ts"; +import { reportChartExportSvg } from "../src/lib/report-chart-export.tsx"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; + +Object.assign(globalThis, { React }); +const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8")); +async function view(): Promise<ChartViewOk> { + const result = await assembleChartView({ + userId: "synthetic", accountId: "synthetic", profileFingerprint: "fixture-v1", + profile: { name: "Synthetic", date: "1990-06-15", time: "12:00", placeLabel: "Synthetic", latitude: 39.9042, longitude: 116.4074, timezoneOffset: 8, timezoneId: "Asia/Shanghai", ayanamsa: "raman", birthTimeStatus: "confirmed" }, + asOf: "2026-09-15", layers: ["varga", "western"], + postEngine: async (path) => ({ status: "ok", payload: path === "/api/chart" ? golden.chart : path === "/api/varga_full" ? golden.varga_full : golden.western }), + }); + assert.equal(result.body.status, "ok"); + return result.body as ChartViewOk; +} +type Harness = ReturnType<typeof createClientLifecycleHarness>; +function wrapper(h: Harness) { return h.elements().find((node) => node.getAttribute("class") === "chart-page-symbol-help")!; } +function note(h: Harness) { return h.elements().find((node) => node.getAttribute("role") === "status")!; } +function target(h: Harness, fragment: string) { return h.elements().find((node) => node.getAttribute("data-chart-help")?.includes(fragment))!; } + +// These mount real React components and invoke their host handlers. Geometry, +// native pointer dispatch and touch target dimensions still need browser QA. +test("Vedic help supports hover, focus, touch click, leave, blur and Escape without changing glyphs", async () => { + const data = await view(); + const chart = data.vedic.vargas.find((varga) => varga.id === "D1")!.chart; + const h = createClientLifecycleHarness(); + try { + await h.render(<ChartSymbolHelp resetKey="D1"><VedicChartSvg chart={chart} ariaLabel="测试盘" interactive /></ChartSymbolHelp>); + const retro = target(h, "逆行"); + assert.ok(retro, "golden contains a retrograde planet"); + assert.equal(retro.getAttribute("role"), "button"); + assert.equal(retro.childNodes.filter((node) => node.getAttribute("tabindex") !== null).length, 0, "bar and glyph share one focus stop"); + await h.event(wrapper(h), "onPointerOver", { target: retro.childNodes.find((node) => node.tagName === "LINE") ?? retro, pointerType: "mouse" }); + assert.match(note(h).text, /逆行(符号下的横线)/); + await h.event(wrapper(h), "onPointerOut", { target: retro, relatedTarget: null }); + assert.doesNotMatch(note(h).text, /逆行/); + const asc = target(h, "As · 上升"); + await h.event(wrapper(h), "onFocus", { target: asc }); + assert.equal(note(h).text, "As · 上升"); + await h.event(wrapper(h), "onBlur", { target: asc }); + assert.doesNotMatch(note(h).text, /As/); + await h.event(wrapper(h), "onClick", { target: asc, pointerType: "touch" }); + assert.equal(note(h).text, "As · 上升", "touch uses a visible page note, not native title"); + await h.event(wrapper(h), "onKeyDown", { target: asc, key: "Escape" }); + assert.doesNotMatch(note(h).text, /As/); + const sign = target(h, "星座编号"); + await h.event(wrapper(h), "onKeyDown", { target: sign, key: " " }); + assert.match(note(h).text, /座,星座编号,不是宫位编号/); + await h.event(wrapper(h), "onPointerLeave"); + assert.doesNotMatch(note(h).text, /星座编号/); + assert.deepEqual(h.errors, []); + } finally { await h.close(); } +}); + +test("Vedic help resets on chart, person or data identity changes", async () => { + const data = await view(); + const chart = data.vedic.vargas.find((varga) => varga.id === "D1")!.chart; + const h = createClientLifecycleHarness(); + try { + for (const key of ["person-a:D1:v1", "person-a:D9:v1", "person-b:D9:v1", "person-b:D9:v2"]) { + await h.render(<ChartSymbolHelp resetKey={key}><VedicChartSvg chart={chart} ariaLabel="测试盘" interactive /></ChartSymbolHelp>); + assert.doesNotMatch(note(h).text, /As/); + await h.event(wrapper(h), "onClick", { target: target(h, "As · 上升") }); + assert.equal(note(h).text, "As · 上升"); + } + } finally { await h.close(); } +}); + +test("crowded Vedic help explains overflow and preserves static report/export boundaries", async () => { + const data = await view(); + const chart = structuredClone(data.vedic.vargas.find((varga) => varga.id === "D1")!.chart); + // Directed crowding mutation of the engine golden, not a made-up response. + const occupants = chart.houses.flatMap((house) => house.occupants); + const occupantGlyphs = chart.houses.flatMap((house) => house.occupantGlyphs ?? []); + chart.houses.forEach((house, index) => { + house.occupants = index === 0 ? occupants : []; + house.occupantGlyphs = index === 0 ? occupantGlyphs : []; + }); + const interactive = renderToStaticMarkup(<VedicChartSvg chart={chart} ariaLabel="测试盘" interactive />); + assert.match(interactive, /这一宫还有 \d+ 个标记未展开/); + assert.match(interactive, /role="group"/); + assert.match(interactive, /<title>[^<]*逆行/); + for (const title of interactive.matchAll(/<title>(.*?)<\/title>/g)) { + assert.doesNotMatch(title[1]!, /°|′/, "help does not reverse-engineer precise degrees from display strings"); + } + const skeleton = renderToStaticMarkup(<VedicChartSvg chart={chart} ariaLabel="测试盘" interactive skeleton />); + assert.doesNotMatch(skeleton, /data-chart-help|tabindex|role="button"|<title>/); + const visibleText = [...interactive.matchAll(/<text\b[^>]*>(.*?)<\/text>/g)].map((hit) => hit[1]).join(""); + assert.deepEqual(visibleText.match(/逆/g), ["逆"]); + const staticMarkup = renderToStaticMarkup(VedicChartSvgDrawing({ chart, ariaLabel: "测试盘", chartId: "static" })); + const exported = reportChartExportSvg(chart, "测试盘"); + for (const markup of [staticMarkup, exported]) { + assert.doesNotMatch(markup, /data-chart-help|tabindex|role="button"|<title>|<script|\son\w+=|height="auto"/); + assert.match(markup, /横线=逆行/); + } + assert.match(exported, /height="452"/); +}); + +test("Western visible caption and hit target explain the same nearest planet as selection; Escape only closes help", async () => { + const data = await view(); + assert.equal(data.western.status, "ok"); + if (data.western.status !== "ok") return; + const planets = data.western.planets; + const h = createClientLifecycleHarness(); + try { + await h.render(<ChartWesternTab view={data} />); + const hosts = h.elements(); + const lastHit = Math.max(...hosts.map((node, index) => node.getAttribute("class") === "chart-page-western-hit" ? index : -1)); + for (const className of ["chart-page-western-sign-label", "chart-page-western-house-label", "chart-page-western-angle"]) { + assert.ok(hosts.findIndex((node) => node.getAttribute("class") === className) > lastHit, `${className} stays above transparent hit circles`); + } + const caption = h.elements().find((node) => node.getAttribute("data-planet-caption") === planets[0]!.id)!; + const otherCaption = h.elements().find((node) => node.getAttribute("data-planet-caption") === planets[1]!.id)!; + const text = otherCaption.childNodes.find((node) => node.tagName === "TEXT")!; + const point = { x: Number(text.props.x), y: Number(text.props.y) }; + const ownerSVGElement = { getScreenCTM: () => ({ inverse: () => ({}) }), createSVGPoint: () => ({ x: 0, y: 0, matrixTransform: () => point }) }; + const event = { currentTarget: { ownerSVGElement }, clientX: point.x, clientY: point.y, pointerType: "mouse" }; + await h.event(caption, "onPointerMove", event); + assert.match(note(h).text, new RegExp(planets[1]!.label)); + await h.event(caption, "onClick", { ...event, pointerType: "touch" }); + assert.match(note(h).text, new RegExp(planets[1]!.label)); + const selected = () => h.elements().find((node) => node.getAttribute("role") === "button" && node.getAttribute("aria-pressed") === "true")!; + assert.match(selected().getAttribute("aria-label")!, new RegExp(planets[1]!.label)); + await h.event(wrapper(h), "onKeyDown", { key: "Escape" }); + assert.doesNotMatch(note(h).text, new RegExp(planets[1]!.label)); + assert.ok(selected(), "Escape does not clear the aspect filter"); + await h.event(caption, "onClick", event); + assert.equal(selected(), undefined, "a second click still cancels selection"); + const hit = h.elements().find((node) => node.getAttribute("role") === "button" && node.getAttribute("aria-label")?.startsWith(planets[0]!.label))!; + await h.event(hit, "onPointerOver", event); + assert.match(note(h).text, new RegExp(planets[1]!.label)); + await h.event(hit, "onFocus"); + assert.match(note(h).text, new RegExp(planets[0]!.label)); + await h.event(hit, "onKeyDown", { key: "Enter" }); + assert.ok(selected()); + await h.event(hit, "onKeyDown", { key: " " }); + assert.equal(selected(), undefined); + await h.event(hit, "onPointerLeave"); + assert.doesNotMatch(note(h).text, new RegExp(planets[0]!.label)); + const asc = target(h, "ASC · 上升"); + await h.event(wrapper(h), "onClick", { target: asc }); + assert.equal(note(h).text, "ASC · 上升"); + await h.render(<ChartWesternTab view={{ ...data, subjectId: "synthetic-other" }} />); + assert.doesNotMatch(note(h).text, /ASC/); + assert.deepEqual(h.errors, []); + } finally { await h.close(); } +}); diff --git a/frontend/tests/chart-varga-table.test.tsx b/frontend/tests/chart-varga-table.test.tsx index 3881230c..0f8b1294 100644 --- a/frontend/tests/chart-varga-table.test.tsx +++ b/frontend/tests/chart-varga-table.test.tsx @@ -4,9 +4,10 @@ import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import test from "node:test"; -import { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx"; +import { ChartVedicTab, VargaTable } from "../src/components/chart-page/chart-vedic-tab.tsx"; import { assembleChartView } from "../src/lib/chart-view-load.ts"; -import type { ChartViewOk } from "../src/lib/chart-view-contract.ts"; +import { chartViewOkSchema, chartViewVargaSchema, type ChartViewOk, type ChartViewVarga } from "../src/lib/chart-view-contract.ts"; +import { buildChartView } from "../src/lib/chart-view-mapper.ts"; Object.assign(globalThis, { React }); @@ -117,9 +118,132 @@ test("divisional rows carry engine dignity and vargottama, and drop nakshatra co assert.doesNotMatch(header, /星宿|宿主|pada/); assert.match(row(markup, "土星"), />自宫<[\s\S]*>同位</); assert.match(row(markup, "太阳"), />落陷</); + assert.match(row(markup, "上升"), />不适用</, "the ascendant has no sign dignity"); // Every divisional chart the engine returned has its own rows, D1 has none. for (const varga of view.vedic.vargas) { if (varga.id === "D1") assert.equal(varga.rows, undefined); else assert.equal(varga.rows?.length, 9, varga.id); } }); + +function tableMarkup(varga: ChartViewVarga): string { + return renderToStaticMarkup(React.createElement(VargaTable, { varga })); +} + +function assertColumns(markup: string, dignity: boolean, vargottama: boolean) { + const header = markup.match(/<thead>([\s\S]*?)<\/thead>/)?.[1] ?? ""; + assert.equal(header.includes(">庙旺<"), dignity); + assert.equal(header.includes(">同位<"), vargottama); + const count = 4 + Number(dignity) + Number(vargottama); + assert.equal((header.match(/<th /g) ?? []).length, count); + const body = markup.match(/<tbody>([\s\S]*?)<\/tbody>/)?.[1] ?? ""; + const rows = [...body.matchAll(/<tr>([\s\S]*?)<\/tr>/g)]; + assert.equal(rows.length, 10, "the ascendant and all nine planets remain"); + for (const [, cells] of rows) assert.equal((cells!.match(/<(?:th|td)\b/g) ?? []).length, count); +} + +test("server capabilities retain dignity for every Dn and vargottama only for D9", async () => { + const view = await vargaView(); + for (const varga of view.vedic.vargas) { + if (varga.id === "D1") { + assert.equal(varga.supportsDignity, undefined); + assert.equal(varga.supportsVargottama, undefined); + continue; + } + assert.equal(varga.supportsDignity, true, varga.id); + assert.equal(varga.supportsVargottama, varga.id === "D9", varga.id); + assertColumns(tableMarkup(varga), true, varga.id === "D9"); + } +}); + +test("capability columns are independent and absent declarations hide whole columns", async () => { + const view = await vargaView(); + const source = view.vedic.vargas.find((varga) => varga.id === "D9")!; + for (const dignity of [undefined, false, true]) { + for (const vargottama of [undefined, false, true]) { + const copy = structuredClone(source); + delete copy.supportsDignity; + delete copy.supportsVargottama; + if (dignity !== undefined) copy.supportsDignity = dignity; + if (vargottama !== undefined) copy.supportsVargottama = vargottama; + assertColumns(tableMarkup(chartViewVargaSchema.parse(copy)), dignity === true, vargottama === true); + } + } +}); + +test("valid false and null results never turn supported columns off", async () => { + const view = await vargaView(); + const copy = structuredClone(view.vedic.vargas.find((varga) => varga.id === "D9")!); + for (const item of copy.rows!) { + item.dignity = null; + item.vargottama = false; + } + copy.ascendant!.vargottama = false; + const markup = tableMarkup(chartViewVargaSchema.parse(copy)); + assertColumns(markup, true, true); + assert.doesNotMatch(markup.match(/<tbody>([\s\S]*?)<\/tbody>/)?.[1] ?? "", />同位<|>入旺<|>自宫<|>落陷</); + assert.match(row(markup, "上升"), />不适用</); +}); + +test("capability schema rejects wrong types and direct cache rendering requires strict true", async () => { + const view = await vargaView(); + const source = view.vedic.vargas.find((varga) => varga.id === "D9")!; + for (const field of ["supportsDignity", "supportsVargottama"] as const) { + for (const value of [null, "true", "false", 1, 0, [], {}]) { + const copy = structuredClone(source); + Object.assign(copy, { [field]: value }); + const parsed = chartViewVargaSchema.safeParse(copy); + assert.equal(parsed.success, false, `${field} must not coerce ${JSON.stringify(value)}`); + if (!parsed.success) assert.ok(parsed.error.issues.some((issue) => issue.path[0] === field && issue.code === "invalid_type")); + assertColumns(tableMarkup(copy), field !== "supportsDignity", field !== "supportsVargottama"); + } + } +}); + +test("missing or invalid source positions are not manufactured as negative same-sign results", () => { + const chart = structuredClone(golden.chart); + const varga = structuredClone(golden.varga_full); + const d9 = varga.result.D9_Navamsa!; + delete d9.planets.Sun!.sign; + d9.planets.Moon!.sign = "not-a-sign"; + const view = buildChartView({ chart, varga: varga as unknown as Record<string, unknown>, chara: null, western: null, profile, asOf: "2026-09-15" }); + const projected = view.vedic.vargas.find((item) => item.id === "D9")!; + assert.equal(projected.supportsDignity, true); + assert.equal(projected.supportsVargottama, true); + assert.equal(projected.rows!.some((item) => item.name === "Sun" || item.name === "Moon"), false); + // Destroy only the source positions on a golden copy, rather than making a + // response shape. With no valid result source no capability is advertised. + for (const planet of Object.values(d9.planets)) delete planet.sign; + delete (chart.ascendant as Record<string, unknown>).sign; + const empty = buildChartView({ chart, varga: varga as unknown as Record<string, unknown>, chara: null, western: null, profile, asOf: "2026-09-15" }); + const emptyD9 = empty.vedic.vargas.find((item) => item.id === "D9")!; + assert.deepEqual(emptyD9.rows, []); + assert.equal(emptyD9.ascendant, undefined); + assert.equal(emptyD9.supportsDignity, false); + assert.equal(emptyD9.supportsVargottama, false); +}); + +test("view projection omits engine branding but old profile fields still parse and raw metadata stays intact", async () => { + const before = structuredClone(golden); + const view = await vargaView(); + assert.equal("engineName" in view.profile, false); + const legacy = structuredClone(view); + Object.assign(legacy.profile, { engineName: "Swiss Ephemeris(本站)" }); + assert.equal("engineName" in chartViewOkSchema.parse(legacy).profile, false); + assert.deepEqual(golden, before, "the mapper never strips or changes the raw engine packet"); +}); + +test("divisional house cells do not override their header's left alignment", async () => { + const view = await vargaView(); + for (const varga of view.vedic.vargas.filter((item) => item.id !== "D1")) { + const markup = tableMarkup(varga); + const rows = [...markup.matchAll(/<tr>([\s\S]*?)<\/tr>/g)].slice(1); + assert.equal(rows.length, 10); + for (const [, cells] of rows) { + const tds = [...cells!.matchAll(/<td([^>]*)>([\s\S]*?)<\/td>/g)]; + assert.equal(tds[1]![1], "", "house cells inherit the same alignment and padding as the header"); + assert.match(tds[1]![2]!, /^\d+宫$/); + assert.match(tds[3]![1]!, /chart-page-planet-mid/, "the dignity column's deliberate centering remains"); + } + } +}); diff --git a/tests/test_chart_page_vedic_tables_contract.py b/tests/test_chart_page_vedic_tables_contract.py index a3473f2d..e49633c0 100644 --- a/tests/test_chart_page_vedic_tables_contract.py +++ b/tests/test_chart_page_vedic_tables_contract.py @@ -9,14 +9,19 @@ uses and to `divisional_charts_extended.VargaType`. from __future__ import annotations import json +import shutil +import subprocess import sys from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) import divisional_charts_extended # noqa: E402 import jyotish_engine # noqa: E402 +from consultation_native_layers import build_d9_summary # noqa: E402 TABLES = json.loads((ROOT / "frontend/src/lib/vedic-chart-tables.json").read_text(encoding="utf-8")) PLANETS = ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"] @@ -56,3 +61,73 @@ def test_varga_meanings_equal_engine_varga_types() -> None: assert TABLES["vargaMeaning"] == engine for division in ("40", "45", "60"): assert division in TABLES["vargaMeaning"] + + +@pytest.fixture(scope="module") +def d9_projection() -> tuple[dict, dict]: + """Run the real server mapper, not a Python imitation of its comparison. + + The checked-in golden is a fictional chart, Raman ayanamsa / mean node. + No ephemeris is recomputed under a different process-global ayanamsa. + """ + frontend = ROOT / "frontend" + node = shutil.which("node") + tsx = frontend / "node_modules/tsx/dist/cli.mjs" + if not node or not tsx.is_file(): + pytest.skip("D9 mapper/native comparison requires Node and frontend tsx dependencies") + golden = json.loads((frontend / "tests/fixtures/chart-view-golden.json").read_text(encoding="utf-8")) + assert golden["chart"]["birth"]["ayanamsa_name"] == "raman" + assert golden["chart"]["birth"]["node_mode"] == "mean" + script = """ + import { readFileSync } from 'node:fs'; + import { buildChartView } from './src/lib/chart-view-mapper.ts'; + const golden = JSON.parse(readFileSync('./tests/fixtures/chart-view-golden.json', 'utf8')); + const birth = golden.chart.birth; + const view = buildChartView({ + chart: golden.chart, varga: golden.varga_full, chara: null, western: null, + profile: { + name: 'Synthetic', date: birth.date, time: birth.time, + latitude: birth.lat, longitude: birth.lon, timezoneOffset: 8, + ayanamsa: birth.ayanamsa_name, birthTimeStatus: 'confirmed', + }, + asOf: '2026-09-15', + }); + process.stdout.write(JSON.stringify(view.vedic.vargas.find(varga => varga.id === 'D9'))); + """ + completed = subprocess.run( + [node, str(tsx), "--eval", script], + cwd=frontend, text=True, encoding="utf-8", capture_output=True, timeout=60, check=False, + ) + assert completed.returncode == 0, completed.stderr + return golden, json.loads(completed.stdout) + + +def test_d9_projected_planets_match_native_vargottama(d9_projection: tuple[dict, dict]) -> None: + golden, projected = d9_projection + natal = golden["chart"]["planets"] + d9 = golden["varga_full"]["result"]["D9_Navamsa"] + native = jyotish_engine._calc_vargottama(natal, {"D9_Navamsa": d9["planets"]}) + assert projected["supportsDignity"] is True + assert projected["supportsVargottama"] is True + assert len(projected["rows"]) == len(PLANETS) + results = set() + for row in projected["rows"]: + expected = native[row["name"]] + assert row["sign"] == expected["d9_sign"] + assert row["natalSign"] == expected["d1_sign"] + assert row["vargottama"] is expected["is_vargottama"] + results.add(row["vargottama"]) + assert results == {True, False}, "golden must exercise both valid outcomes" + + +def test_d9_projected_ascendant_matches_native_summary(d9_projection: tuple[dict, dict]) -> None: + # _calc_vargottama handles planets, NOT the ascendant; use the native lagna + # summary path for this separate contract instead of attributing it there. + golden, projected = d9_projection + native = build_d9_summary( + jyotish_engine, golden["chart"], {"varga_full": golden["varga_full"]["result"]}, + ) + assert native["status"] == "executed" + assert projected["ascendant"]["sign"] == native["lagna"] + assert projected["ascendant"]["natalSign"] == golden["chart"]["ascendant"]["sign"] + assert projected["ascendant"]["vargottama"] is native["lagna_vargottama"]