fix(home): split starter fine print, show hints only when actionable (BUG-1041)
TASK-starter-home-polish-20260926: - D1 today's trend moves to a sub-line under the greeting (.starter-greeting-sub), read from the same dailyStarlanguage state the warm snapshot seeds; static sentence until ready, hidden without a minute. - D2 the line under the pills appears only for a resumable case or a non-self subject; the first-run and redo sentences are removed. - BUG-1041 entrySummaryFromResponse read snake_case keys while the entry-summary route returns camelCase, so every account parsed as a first run; the parser now reads the route's shape (snake_case still ok). - D3 daily entry icon MoonStar, credits icon Coins; D4 boundary line kept. - Records: CHANGELOG, DESIGN, VOICE, BUG_HISTORY, PROGRESS, testing list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6d371d82a7
commit
b5a02ca861
@@ -1,5 +1,13 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-26 — 首页开场小字拆开,今日星语和点数换图标(待验收)
|
||||
|
||||
- 今日趋势(今日星语卡片那一句,每天生成)挪到问候语下面单独一行,浅色、最多两行;还没生成时写「今天的星语还没写出来。」,没有出生分钟时不显示。
|
||||
- 入口按钮下面平时不再有字,只在需要动手时出一句:有没做完的校正写「上次那次校正还没完成,可以接着做。」;当前人物不是本人写「生时校正暂时只支持本人。」。删掉「不确定出生时间时…」「上次已经校正完…」两句。
|
||||
- 修好已校正过的账户仍被当成第一次的问题(BUG-1041):首页读校正状态时一直读错字段名,所以从没出现过「再次校正」和未完成提示。
|
||||
- 今日星语入口图标换成带星的月亮,右上角点数换成硬币,两处不再是同一个图标;读屏名称不变。
|
||||
- Skill 版本不 bump。不改接口、今日星语生成逻辑和数据库;登录态真机与部署待验收。
|
||||
|
||||
## 2026-09-26 — 从其他页面回首页不再重放加载动画(待验收)
|
||||
|
||||
- 同一次打开网页里,从星盘、星历、我的报告、星盘档案点「新建对话」、点侧栏里的历史对话、点左下角头像或按浏览器返回回到首页,不再出现「正在载入账户 / 正在准备对话」加载环,首页立刻可以输入。
|
||||
|
||||
@@ -13928,3 +13928,18 @@
|
||||
- 相关记录:BUG-966(次级页模块缓存先例)、BUG-479(一次等待一次揭幕)、BUG-1021(不半揭幕,快照缺项回冷启动)、BUG-1038(新建意图与存根规则,暖路径沿用)、BUG-936(首屏兜底,`data-hydrated` 不变)。
|
||||
- 复发自:无。
|
||||
- 修复版本:`codex/home-warm-return-20260926`,未部署。
|
||||
|
||||
## BUG-1041 | 首页校正入口摘要解析读错键名:已校正账户仍显示首次文案、未完成的校正从不提示
|
||||
|
||||
- 状态:investigating(已修复待部署;登录态真机未复测)
|
||||
- 首次发现 / 最近更新:2026-09-26 / 2026-09-26
|
||||
- 影响面:`frontend/src/lib/rectification-entry.ts` `entrySummaryFromResponse`(首页 `page.tsx` 启动 effect 与 `useRectificationSurface.refreshRectificationEntrySummary` 共用),进而影响首页生时校正入口的可访问名(`开始新的生时校正` / `再次校正`)与入口下方提示。
|
||||
- 用户现象:产品 09-26 真机截图:账户已经校正过,首页入口下方仍写首次文案「不确定出生时间时,用记得住的经历一步步缩小范围」;按钮可访问名也一直是「开始新的生时校正」,从未出现「再次校正」。
|
||||
- 触发条件:任何有未完成或已完成校正 Case 的账户打开首页。
|
||||
- 根因:`GET /api/rectification/cases/entry-summary` 返回 `getRectificationEntrySummary` 投影后的 **camelCase** 对象(`hasResumableCase` / `hasTerminalCaseWithTime` / `latestTerminal.caseId`…),只有底下的 RPC 行是 snake_case;而浏览器端 `entrySummaryFromResponse` 只读 snake_case 键(`has_resumable_case` 等),所以每一个真实响应都被解析成「什么都没有」。`resolveRectificationEntryAction` 恒为 `start`,`rectificationCardAction === "restart"` 与 `hasResumableCase` 分支从未命中。自 2026-08-11 `ca748466`(BUG-163 入口改造)起存在。测试没拦住:`rectification-v9-entry-routing.test.ts`、`home-warm-return-lifecycle.test.tsx`、`new-chat-from-people-lifecycle.test.tsx` 的 entry-summary mock 都是手写的 snake_case,不是路由真实输出;`rectification-v9-case-service.test.ts` 只测到服务端投影为止,两段之间没有往返合同。
|
||||
- 修复:`entrySummaryFromResponse` 以路由真实的 camelCase 形状为准,同时仍读 snake_case(RPC 形状不会再静默塌成空)。首页小字按 TASK-starter-home-polish-20260926 D2 改为只读 summary 的 `hasResumableCase` 与当前人物,不再依赖派生的 `restart`。
|
||||
- 验证:`frontend/tests/starter-home-polish-20260926.test.tsx`「BUG-1041: the homepage parser reads what the entry-summary route actually returns」:用 `getRectificationEntrySummary`(假 RPC)产出、`JSON` 往返后交给 `entrySummaryFromResponse`,断言 `hasTerminalCaseWithTime`、`latestTerminal`、`restart` 与未完成提示;修复前该条失败(解析结果全 false)。本地 `next start` + Chrome 151,`/api/*` 以 camelCase 形状回放:已校正账户可访问名「再次校正」、无小字;未完成账户显示「上次那次校正还没完成,可以接着做。」。全量 3960 条 / 61 失败,失败名单与基线逐条一致。
|
||||
- 防复发:往返合同测试锁住「路由输出 → 首页解析」一段;新的首页测试 fixture 用路由真实的 camelCase 形状。旧的 snake_case mock 仍能被解析,未改动。
|
||||
- 相关记录:BUG-163(入口摘要由服务端 Case 驱动)、BUG-1040(暖快照缓存的正是解析后的 summary,快照结构不变)。
|
||||
- 复发自:无。
|
||||
- 修复版本:`codex/starter-home-polish-20260926`,未部署。
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# PROGRESS · 首页开场小字拆分 + 今日星语 / 点数图标(2026-09-26)
|
||||
|
||||
任务书:`TASK-starter-home-polish-20260926.md`。执行方:Claude 子代理(直接执行模式)。分支 `codex/starter-home-polish-20260926`,worktree `.worktrees/starter-home-polish-20260926`,本地提交未推送。
|
||||
|
||||
## 基线
|
||||
|
||||
- `origin/staging` = `7475eb6e`(BUG-1038、BUG-1040 已合入)。
|
||||
- 基线全量前端测试(Node 20.19.2):3953 条,pass 3865 / fail 61 / skipped 27(61 条均为环境缺口:Node 20 `mock.module` 的 `ERR_MODULE_NOT_FOUND @/…`、无 Docker)。
|
||||
- 基线 `next build --webpack`:`/` ○ Static;rootMainFiles gzip 130933 B;`index.html` 引用的全部 js/css gzip 合计 656646 B。
|
||||
- BUG 编号:开工时 `docs/BUG_HISTORY.md` 最大 BUG-1040,远端各分支无更大号 → 本单用 BUG-1041。
|
||||
|
||||
## 改动
|
||||
|
||||
| 任务 | 文件 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| T1 / D1 | `components/starter-home.tsx`、`app/(app)/page.tsx`、`globals.css` | `StarterGreeting` 加可选 `dailyTrend` / `dailyTrendSettled`,渲染 `.starter-greeting-sub`(`--color-ink-secondary`、`--type-body-sm`、两行截断)。`page.tsx` 传 `natalMinuteAvailable ? dailyStarlanguageTrend : null`,读的就是 BUG-1040 暖快照初始化的同一个 `dailyStarlanguage` state;未就绪沿用静态句「今天的星语还没写出来。」+ `role="status"`,无 `aria-busy`、无新等待态。 |
|
||||
| T2 / D2 | `components/starter-home.tsx` | 新增纯函数 `rectificationEntryHint(forSelf, summary)`:非本人 →「生时校正暂时只支持本人。」;`summary.hasResumableCase` →「上次那次校正还没完成,可以接着做。」;其余 `null`,不渲染 `<p>`。删「不确定出生时间时…」「上次已经校正完…」「未完成的那次可以从左侧继续」「看看今天的整体节奏」四句与「·」拼接。`StarterEntries` 去掉不再用的 `dailyStarlanguage` / `dailyStarlanguageTrend` / `rectificationCardAction` 三个 prop。 |
|
||||
| T2 / 分支误判 | `lib/rectification-entry.ts` | **真 bug(BUG-1041)**:`/api/rectification/cases/entry-summary` 返回 camelCase(`getRectificationEntrySummary` 的投影),`entrySummaryFromResponse` 只读 snake_case,每个真实响应都解析成全 false。改为优先读 camelCase、仍兼容 snake_case。结果:已校正账户可访问名变回「再次校正」,未完成提示能出现。 |
|
||||
| T2 / D4 | — | 边界句「出生时间不足以支持的判断我会说明,不会补造具体分钟。」原位保留。 |
|
||||
| T3 / D3 | `components/starter-home.tsx`、`app/(app)/page.tsx` | 今日星语入口 `Sparkles` → `MoonStar`;右上角点数 `Sparkles` → `Coins`(`className="credit-icon"`、`aria-hidden`、按钮 `aria-label` 不变)。已确认 lucide-react 1.24.0 同时导出 `MoonStar`、`Coins`。 |
|
||||
| T4 | `CHANGELOG.md`、`frontend/DESIGN.md`(Starter home + §9)、`frontend/docs/VOICE.md`、`docs/BUG_HISTORY.md`(BUG-1041, investigating)、`docs/testing/starter-home-polish-20260926.md`、`docs/tasks/README.md` | 记录。 |
|
||||
|
||||
红线核对:`Home()` 未新增 hook(只改 JSX 表达式);`page.tsx` 1373 → 1370 行;无语句压行;未改今日星语接口与生成逻辑;未改依赖、迁移、workflow。
|
||||
|
||||
## 测试
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| `npm run lint` | 0 error(126 warning,均为既有文件的既有 warning,本单文件无新增) |
|
||||
| 全量 `npm test` | 3960 条,pass 3872 / fail 61 / skipped 27 |
|
||||
| 失败名单 vs 基线 | 逐条一致(`diff` 为空),新增失败 0 |
|
||||
| 测试名单 vs 基线 | +7 条新名(下文),消失 0 条 |
|
||||
| Python 源码合同 | `tests/test_daily_and_rectification_entrypoints.py`、`test_session_management_entrypoints.py`、`test_birth_time_journey_contract.py`、`test_supabase_user_data_contract.py` 全过 |
|
||||
| `npm run build -- --webpack` | 通过;`/` ○ Static,`/chart` `/ephemeris` `/people` ○ |
|
||||
| gzip | rootMainFiles 130933 → 130933 B(0);首屏引用全部 js/css 656646 → 656729 B(+83 B,+0.013%) |
|
||||
|
||||
新增 `frontend/tests/starter-home-polish-20260926.test.tsx`(7 条):D2 首次 / 已校正无提示、未完成一句;D2 非本人(纯函数 + 真实 subject store 渲染,按钮禁用);D4 边界句;D1 副行三态(就绪 / 静态句 + status / 无分钟不渲染);D1 page.tsx 读暖快照同源 state;D3 图标与可访问名;BUG-1041 路由输出 JSON 往返后解析(修复前此条失败,已实测)。
|
||||
|
||||
### 改动既有断言(三栏同时写在测试文件内)
|
||||
|
||||
| 文件 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| `frontend/tests/icon-motion-audit.test.ts` | `import { Clock3, Sparkles }`、`<Sparkles aria-hidden="true" />`、doesNotMatch `/Moon\|MoonStar\|thinking-orbs/` | `import { Clock3, MoonStar }`、`<MoonStar aria-hidden="true" />`、doesNotMatch `/Sparkles\|thinking-orbs/` | D3 产品拍板换 MoonStar;「无循环动画、无 thinking-orbs」原样保留,并反向锁住旧图标 |
|
||||
| `frontend/tests/rectification-agentic-entry.test.ts` | match `/rectificationCardAction === "restart"/` | match `rectificationEntryHint(rectificationForSelf, rectificationEntrySummary)` 与 `summary?.hasResumableCase`;doesNotMatch `=== "restart"` | D2 删掉 restart 那句;BUG-1041 查明该分支从未命中。「由服务端 entry summary 驱动、不读会话列表」的意图不变 |
|
||||
|
||||
## 浏览器验证(本地 `next start` + Chrome 151 headless,CDP 拦截 `/api/*` 回放虚构数据,entry-summary 用路由真实的 camelCase 形状)
|
||||
|
||||
| 场景 | 副行 | 入口下方 | 其他 |
|
||||
| --- | --- | --- | --- |
|
||||
| 375 / 1280 × 明 / 暗,已校正账户 | 趋势句两行,明 `rgb(95,95,89)` / 暗 `rgb(179,175,164)`,14px | 无 | 生时校正可访问名「再次校正」;图标 moon-star / coins;无横向溢出 |
|
||||
| 375 明,未完成校正 | 趋势句 | 「上次那次校正还没完成,可以接着做。」 | 可访问名「开始新的生时校正」 |
|
||||
| 375 明,首次 | 趋势句 | 无 | — |
|
||||
| 1280 明,今日星语 unavailable | 「今天的星语还没写出来。」`role="status"` | 无 | — |
|
||||
| 375 明,无出生分钟 | 不渲染 | 仅边界句 | 入口「查看今日运势」 |
|
||||
| 暖返回:首页 → /chart → 新建对话(今日星语接口压 2.5 s) | 首帧即有趋势句(点击后 17–20 ms) | — | 可交互 23–27 ms,加载环 0 次;控制台 0 error |
|
||||
|
||||
截图(仓库内):`docs/testing/starter-home-polish-20260926/starter-375-light-resumable.png`、`starter-1280-dark.png`。其余截图在会话 scratchpad `shp/`(375/1280 明暗、静态句、无分钟、暖返回),未入库。
|
||||
|
||||
## 环境缺口
|
||||
|
||||
- 无登录态真机:真实账户的 entry-summary、真实今日星语文本长度、VoiceOver 朗读 → `docs/testing/starter-home-polish-20260926.md`。
|
||||
- Node 20:61 条 `mock.module` 类失败为既有环境缺口,名单与基线一致。
|
||||
- 无 Docker:DB 套件未跑(本单不动表)。
|
||||
|
||||
## 观察(未处理,留给产品 / Claude 定夺)
|
||||
|
||||
- 首页「生时校正」按钮走 `intent=homepage`,服务端总是**新建** Case;未完成的那次要从侧栏继续。产品指定的新文案「上次那次校正还没完成,可以接着做。」没有说「从左侧」,用户可能以为点按钮就会接着做。按任务书原文实现,未改。
|
||||
- `useRectificationSurface.rectificationLimitedToSelf` 点击时的错误文案仍是无句号的「生时校正暂时只支持本人」(非本人时按钮已禁用,正常点不到),不在本单范围。
|
||||
@@ -119,7 +119,7 @@
|
||||
|
||||
| 任务书 | 进度 | 主题 | 状态 | 落点 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `TASK-starter-home-polish-20260926.md` | — | **首页开场小字与图标**:今日趋势(每日模型生成,非写死)移到问候下方副行;入口下方提示只在有未完成校正或非本人时出现,删两句固定文案并修正已校正仍显示首次文案的分支;今日星语图标 MoonStar、点数图标 Coins。排在 BUG-1038、1040 之后 | 待领取 | — |
|
||||
| `TASK-starter-home-polish-20260926.md` | `PROGRESS-starter-home-polish-20260926.md` | **首页开场小字与图标**:今日趋势(每日模型生成,非写死)移到问候下方副行;入口下方提示只在有未完成校正或非本人时出现,删两句固定文案并修正已校正仍显示首次文案的分支;今日星语图标 MoonStar、点数图标 Coins。排在 BUG-1038、1040 之后。分支误判实为入口摘要解析读错键名(BUG-1041) | 待验收(Claude 09-26 直接执行:子代理实现,tsc/lint 0、全量 3960 条 / 61 失败与基线逐条一致、`/` ○、首屏 gzip +0.01%;CDP 375/1280 明暗截图) | `codex/starter-home-polish-20260926`(本地未推) |
|
||||
| `TASK-home-warm-return-20260926.md` | — | **从其他页面回首页不再放加载动画**:首页按组件挂载计揭幕,客户端返回会重跑模型列表 / 后台状态 / 入口摘要 / 今日星语(3–5 次往返 + 最多 4 秒)。产品定:每次打开网页只揭幕一次;模块级暖快照按账户隔离、只存内存,暖返回首帧即就绪、后台静默刷新;缺项回落冷启动,不许半揭幕。**排在 BUG-1038 合入后**。BUG-1040 | 已验收(Claude 09-26 直接执行:子代理实现,Claude 独立复验 tsc/lint 0、全量 3953 条失败名单与基线逐条一致、四路由 ○、gzip 不变;CDP 实测回首页 5.5 s → ~20 ms、无加载环) | `638b6a60`(已部署 `7475eb6e`,health 一致) |
|
||||
| `TASK-new-chat-from-people-20260926.md` | `PROGRESS-new-chat-from-people-20260926.md` | **从星盘档案页点新建对话落回上一次生时校正**(产品 09-26 真机):标题显示旧校正会话、输入框「正在打开生时校正…」,中间却是新对话开场;另有两套新建入口(`?new=1` 与 `?newChat=1`)违反 BUG-1015 一处解析红线。先复现写失败测试、定位跨页残留状态再修,合并入口。BUG-1038 | 已验收(Claude 09-26 直接执行:子代理复现并定位 sessionStorage 登录返回存根;Claude 独立复验 tsc/lint 0、全量 3928 条失败名单与基线逐条一致、四路由 ○、gzip 不变、修复前后截图核对) | `0a8350cc`(已部署 `6a22626d`,health 一致) |
|
||||
| `TASK-people-archive-p1-fix-20260925.md` | `PROGRESS-people-archive-p1-fix-20260925.md` | **P0修复**:worker按持久化人物解析、真实删除预留退款、本人历史OR is、切人/消息时序及P2/P3;BUG-1031/1032,关联1030/990/1024。永久定向54/54、独立前端5探针与退款r4六场景通过;必须最终Node22 Linux全量+DB逐条基线对照 | 已验收(Claude 09-25:两 P0、两 P1、七 P2 通过;新 P2 删后跳错人、人物接口故障连累聊天 → 收尾单) | `4d801e53`(已部署,health 核对一致) |
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# 首页开场小字与图标 · 真机清单(2026-09-26)
|
||||
|
||||
本轮在本地 `next start` + 无头 Chrome 里用虚构接口数据截过图(`starter-home-polish-20260926/`),没有登录态真机。下面留给真人,用自己的账户在 staging 上照做:
|
||||
|
||||
1. 打开首页:问候语下面一行浅色小字是今天的趋势句,最多两行;入口按钮(今日星语、生时校正)下面没有字。
|
||||
2. 如果你有一次没做完的生时校正:入口按钮下面只有一句「上次那次校正还没完成,可以接着做。」。
|
||||
3. 如果你已经校正完、没有没做完的:按钮下面没有字;用读屏(VoiceOver)读生时校正按钮,应读「再次校正」。
|
||||
4. 在星盘档案里切到另一个人再回首页:按钮下面只有「生时校正暂时只支持本人。」,生时校正按钮是灰的。
|
||||
5. 今日星语按钮是带星的月亮图标,右上角点数是硬币图标,两处不一样。
|
||||
6. 从星盘页点「新建对话」回首页:没有加载环,问候语下面的趋势句一出来就在,不会先空着再跳出来。
|
||||
7. 375px 宽(手机)明暗两种主题下:趋势句不溢出、不遮挡输入框;深色下小字能看清。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
+28
-11
@@ -533,7 +533,7 @@ The empty state is the composer. It used to be a bordered hero card at
|
||||
sitting on top of the input, so the primary action was the last thing on the
|
||||
page. Three parts now, in reading order:
|
||||
|
||||
- **Greeting** (`.starter-greeting-block`): one line, no subtitle — a single h1
|
||||
- **Greeting** (`.starter-greeting-block`): one line — a single h1
|
||||
at `clamp(24px, 2.8vw, 30px)`, weight 500, `line-height: 1.25`, centred. The
|
||||
salutation line above the question is gone; so is the question, which is the
|
||||
composer placeholder's job now («想聊什么都可以»). The line is drawn per visit
|
||||
@@ -543,6 +543,14 @@ page. Three parts now, in reading order:
|
||||
`{名字}` entries dropped when no name is set. It lives in the conversation area
|
||||
with `align-content: end`, so it sits directly above the composer rather than
|
||||
floating in the middle of the row.
|
||||
- **Daily sub-line** (`.starter-greeting-sub`, 2026-09-26): today's trend sits
|
||||
under the greeting as one quiet paragraph — `--color-ink-secondary`,
|
||||
`--type-body-sm`, `line-height: 1.55`, `max-width: 560px`, clamped to two
|
||||
lines with an ellipsis. It reads the same `dailyStarlanguage` state the warm
|
||||
snapshot seeds (BUG-1040), so a warm return shows it on the first frame. Until
|
||||
the card is ready it shows the existing static sentence「今天的星语还没写出来。」
|
||||
with `role="status"` and no `aria-busy`, spinner or placeholder; the reveal
|
||||
rule does not change. Without a natal minute it is not rendered at all.
|
||||
- **Composer**: unchanged, and *in the same DOM position it occupies during a
|
||||
live session*. Nothing remounts when the first message lands, so an in-flight
|
||||
draft, focus, or queued card survives the transition. Its width is
|
||||
@@ -552,9 +560,14 @@ page. Three parts now, in reading order:
|
||||
- **Entry pills** (`.starter-entry`): the two product entrances, rendered inside
|
||||
`.composer-wrap` *below* the composer. The visual pill is 36px tall
|
||||
(`min-height: 36px`). The transparent hit area is the `::after` box with
|
||||
`inset: -4px`, so the touch target is 44px. One word each, accent icon 15px.
|
||||
Below them one line merges the daily trend with the rectification hint, and
|
||||
an accuracy-boundary line appears when no natal minute is available.
|
||||
`inset: -4px`, so the touch target is 44px. One word each, accent icon 15px:
|
||||
lucide `MoonStar` for 今日星语 (was `Sparkles`, the same glyph as the credits
|
||||
button) and `Clock3` for 生时校正. The header credits button uses lucide
|
||||
`Coins`; its accessible name is unchanged. Below the pills there is normally
|
||||
**nothing**: a hint line (`.starter-entry-hint`) appears only when the user has
|
||||
something to do, and an accuracy-boundary line appears when no natal minute is
|
||||
available. The daily trend and the rectification hint are no longer joined
|
||||
with「·」.
|
||||
|
||||
- **States, measured:**
|
||||
- Default border is `1px solid var(--starter-entry-border)`, and that token is
|
||||
@@ -585,15 +598,19 @@ page. Three parts now, in reading order:
|
||||
entrypoint identifier and expanded only on the server. There is no
|
||||
“从一个主题开始” grid; leftover `guided_topic` values in stored sessions still
|
||||
pin domains when replayed.
|
||||
- **Rectification hint** keeps three server-driven branches — an unfinished case,
|
||||
a finished one that can be redone (`rectificationCardAction === "restart"`),
|
||||
and a first run. The branch comes from the entry summary, never the session
|
||||
list.
|
||||
- **Rectification hint** (`rectificationEntryHint`, 2026-09-26) has two
|
||||
visible branches and reads the entry summary itself, never the session list
|
||||
and never the derived start/restart label: a non-self current person →
|
||||
「生时校正暂时只支持本人。」; otherwise `hasResumableCase` →
|
||||
「上次那次校正还没完成,可以接着做。」. First runs and finished cases show no
|
||||
hint. The old first-run and "redo" sentences are gone. (The summary parser had
|
||||
been reading the wrong key casing since 2026-08-11, so every account looked
|
||||
like a first run — BUG-1041.)
|
||||
- **Responsive:** the pill row wraps and stays centred; there is no
|
||||
phone-specific layout, because there is no fixed-height card to collapse.
|
||||
- **Accessibility:** each pill is one native button with a descriptive accessible
|
||||
name (the full action label, not the one-word face). The daily hint keeps
|
||||
`role="status"` while the trend is still arriving.
|
||||
name (the full action label, not the one-word face). The daily sub-line keeps
|
||||
`role="status"` while the trend has not arrived.
|
||||
|
||||
### Account popover
|
||||
|
||||
@@ -703,7 +720,7 @@ Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小
|
||||
|
||||
**每次打开网页只揭幕一次,客户端返回首页走暖快照(2026-09-26,BUG-1040)。** 整页加载(首次打开、刷新、登录后跳转)照旧放下面这一次加载屏。同一次打开里从星盘 / 星历 / 我的报告 / 星盘档案经侧栏「新建对话」、历史会话行、账户页脚或浏览器返回回到 `/`,不再出现加载环:第一次冷启动成功后,模型目录、校正入口摘要、会话分页游标和今日星语记进模块级暖快照(`lib/home-warm-snapshot.ts`,按账户隔离、只在内存、不落任何存储;账户、资料与会话列表本来就在布局层的会话列表 provider 里跨页存活)。首页重挂时若快照齐全,首帧即可用,落点按冷启动同一套规则同步算出(`?new=1` 新建本地空对话、`?c=` 打开该会话、登录返回存根与其人物范围同 BUG-1038),随后后台刷新模型目录、账户、后台回答恢复、入口摘要与今日星语,结果到了静默替换;消息没缓存的历史会话沿用首页内切换会话的留白方式。快照缺任何一项、落点需要去服务端查、或客户端导航没留下目标地址(Next 先渲染新页面、后写地址栏,`AppLink` 在点击时记下目标),一律回到冷启动,不半揭幕。账户变化、退出、任一 401 清空快照;今日星语跨日或换人按新键重取,卡片先用静态句。
|
||||
|
||||
首页只揭幕一次。揭幕前的加载屏先并行取账户 / 会话、模型目录和咨询状态,再等待 lookup、会话详情等依赖数据全部到齐;不设 8 秒硬超时,超过 `HOME_BOOTSTRAP_SLOW_MS = 20_000` 才进入现有全屏错误屏。错误屏的「重试」只取消旧请求、重载 provider 并局部重跑 bootstrap,不调用 `location.reload()`。揭幕后不得再出现任何阻塞等待或组件级 spinner:今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。登录后的空首页只剩问候语、今日星语卡、生时校正卡和输入框,没有主题卡。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
|
||||
首页只揭幕一次。揭幕前的加载屏先并行取账户 / 会话、模型目录和咨询状态,再等待 lookup、会话详情等依赖数据全部到齐;不设 8 秒硬超时,超过 `HOME_BOOTSTRAP_SLOW_MS = 20_000` 才进入现有全屏错误屏。错误屏的「重试」只取消旧请求、重载 provider 并局部重跑 bootstrap,不调用 `location.reload()`。揭幕后不得再出现任何阻塞等待或组件级 spinner:今日星语未到时问候下方副行显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。登录后的空首页只剩问候语、今日星语卡、生时校正卡和输入框,没有主题卡。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
|
||||
|
||||
### 首屏死屏兜底
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
首页开场语只有一行,不带追问句,不用星星、月亮、陪伴一类比喻。
|
||||
|
||||
首页开场语下面可以有一行今日趋势(今日星语卡片的 trend,每日生成);还没生成时写「今天的星语还没写出来。」,没有出生分钟时不写。入口按钮下面平时不写字,只在用户需要动手时写一句:有没做完的校正写「上次那次校正还没完成,可以接着做。」;当前人物不是本人写「生时校正暂时只支持本人。」。不再写「不确定出生时间时,用记得住的经历一步步缩小范围」「上次已经校正完,可以拿最新资料再来一次」,也不要用「·」把两句不相干的提示拼成一行。
|
||||
|
||||
## 星盘空状态、0 年大运、星历切日期
|
||||
|
||||
这三句只描述眼前缺的数据,不解释运势,也不许写成「正在加载」:
|
||||
@@ -177,7 +179,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
账户与点数的页签只用:会员套餐 / 点数包 / 兑换码 / 订单记录。购买中按钮写「跳转收银台…」,轮询中写「等待支付结果」,不要写「正在加载」。
|
||||
|
||||
星盘档案不写关系。人数到顶时只说「最多保存 5 个人的星盘」。当前人物不是本人时,生时校正入口禁用,提示「生时校正暂时只支持本人」。删除一个人之前必须写出「将同时删除 N 个对话和 M 份报告,无法恢复。」本人不能删除,不要给删除按钮配一句绕过去的确认。
|
||||
星盘档案不写关系。人数到顶时只说「最多保存 5 个人的星盘」。当前人物不是本人时,生时校正入口禁用,提示「生时校正暂时只支持本人。」删除一个人之前必须写出「将同时删除 N 个对话和 M 份报告,无法恢复。」本人不能删除,不要给删除按钮配一句绕过去的确认。
|
||||
|
||||
余额不足时输入框占位写「余额不足,可在账户与点数中补充」,不要叫用户去会员页。
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { loadBillingPanel } from "@/lib/billing-panel-loader";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { Coins } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FormEvent, KeyboardEvent } from "react";
|
||||
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay";
|
||||
@@ -1210,7 +1210,7 @@ export default function Home() {
|
||||
<div className="chat-header-actions">
|
||||
<div className="chat-header-rectification" data-rectification-header-slot="" ref={setRectificationHeaderSlot} />
|
||||
<button className="credit-button" type="button" onClick={(event) => openAccountDialog("billing", { returnTarget: event.currentTarget, source: "credits" })} aria-label={account ? `余额 ${account.credits} 点,会员与充值` : accountError || "读取余额中"}>
|
||||
<Sparkles className="credit-icon" aria-hidden="true" />
|
||||
<Coins className="credit-icon" aria-hidden="true" />
|
||||
<span>{account ? account.credits : "—"}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1244,7 +1244,7 @@ export default function Home() {
|
||||
) : null}
|
||||
|
||||
{profileComplete && presetMessageFinished && !rectificationSurfaceOpen && (
|
||||
<StarterGreeting starterGreeting={starterGreeting} />
|
||||
<StarterGreeting starterGreeting={starterGreeting} dailyTrend={natalMinuteAvailable ? dailyStarlanguageTrend : null} dailyTrendSettled={dailyStarlanguage.kind === "ready"} />
|
||||
)}
|
||||
</div>
|
||||
) : sessionMessagesLoading ? (
|
||||
@@ -1328,13 +1328,10 @@ export default function Home() {
|
||||
{starterHomeVisible && (
|
||||
<StarterEntries
|
||||
natalMinuteAvailable={natalMinuteAvailable}
|
||||
dailyStarlanguage={dailyStarlanguage}
|
||||
dailyStarlanguageQuestion={dailyStarlanguageQuestion}
|
||||
dailyStarlanguageTrend={dailyStarlanguageTrend}
|
||||
productEntrypointsDisabled={productEntrypointsDisabled}
|
||||
startDailyStarlanguageConsultation={startDailyStarlanguageConsultation}
|
||||
rectificationCardLabel={rectificationCardLabel}
|
||||
rectificationCardAction={rectificationCardAction}
|
||||
rectificationLoading={rectificationLoading}
|
||||
rectificationMutationPending={rectificationMutationPending}
|
||||
openRectificationFromHomepage={openRectificationFromHomepage}
|
||||
|
||||
@@ -2258,6 +2258,23 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Today's trend, one quiet line under the greeting (2026-09-26). It used to be
|
||||
glued to the rectification hint under the pills with a middle dot. Two lines
|
||||
at most: the trend is model-written and can run long on a phone. */
|
||||
.starter-greeting-sub {
|
||||
display: -webkit-box;
|
||||
max-width: 560px;
|
||||
margin: var(--space-2) auto 0;
|
||||
overflow: hidden;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.55;
|
||||
text-wrap: pretty;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
}
|
||||
|
||||
/* Entry pills, under the composer. The two product entrances keep their place
|
||||
in the product but not their weight: they were 132px cards competing with the
|
||||
input for the first look. */
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Clock3, Sparkles } from "lucide-react";
|
||||
import { Clock3, MoonStar } from "lucide-react";
|
||||
import { useCurrentSubject } from "@/lib/current-subject";
|
||||
import type { DailyStarlanguageState } from "@/lib/home-types";
|
||||
import type { RectificationEntrySummary } from "@/lib/rectification-entry";
|
||||
|
||||
/**
|
||||
@@ -21,27 +20,50 @@ import type { RectificationEntrySummary } from "@/lib/rectification-entry";
|
||||
|
||||
export type StarterGreetingProps = {
|
||||
readonly starterGreeting: { readonly text: string };
|
||||
/* Today's trend, one quiet line under the greeting. `null` hides it (no natal
|
||||
minute). Before the card is ready the caller passes the static sentence,
|
||||
never a placeholder or a spinner. */
|
||||
readonly dailyTrend?: string | null;
|
||||
readonly dailyTrendSettled?: boolean;
|
||||
};
|
||||
|
||||
/* One line. The salutation/question pair it replaces asked the user's question
|
||||
for them; the composer placeholder asks now. */
|
||||
export function StarterGreeting({ starterGreeting }: StarterGreetingProps) {
|
||||
for them; the composer placeholder asks now. The optional sub-line is today's
|
||||
trend, moved up from the fine print under the entry pills where it was glued
|
||||
to an unrelated rectification hint with a middle dot. */
|
||||
export function StarterGreeting({ starterGreeting, dailyTrend = null, dailyTrendSettled = true }: StarterGreetingProps) {
|
||||
return (
|
||||
<section className="starter-greeting-block" aria-labelledby="starter-heading">
|
||||
<h1 id="starter-heading">{starterGreeting.text}</h1>
|
||||
{dailyTrend ? (
|
||||
<p className="starter-greeting-sub" role={dailyTrendSettled ? undefined : "status"}>
|
||||
{dailyTrend}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* The line under the pills only appears when there is something to do about
|
||||
it. First runs and finished cases say nothing: the pill already names the
|
||||
action. The branch reads the entry summary itself, not the derived
|
||||
start/restart label, and a non-self subject wins because the case summary
|
||||
belongs to the account owner. */
|
||||
export function rectificationEntryHint(
|
||||
rectificationForSelf: boolean,
|
||||
summary: RectificationEntrySummary | null,
|
||||
): string | null {
|
||||
if (!rectificationForSelf) return "生时校正暂时只支持本人。";
|
||||
if (summary?.hasResumableCase) return "上次那次校正还没完成,可以接着做。";
|
||||
return null;
|
||||
}
|
||||
|
||||
export type StarterEntriesProps = {
|
||||
readonly dailyStarlanguage: DailyStarlanguageState;
|
||||
readonly natalMinuteAvailable: boolean;
|
||||
readonly dailyStarlanguageQuestion: string;
|
||||
readonly dailyStarlanguageTrend: string;
|
||||
readonly productEntrypointsDisabled: boolean;
|
||||
readonly startDailyStarlanguageConsultation: () => void;
|
||||
readonly rectificationCardLabel: string;
|
||||
readonly rectificationCardAction: string;
|
||||
readonly rectificationLoading: boolean;
|
||||
readonly rectificationMutationPending: boolean;
|
||||
readonly openRectificationFromHomepage: () => void;
|
||||
@@ -53,13 +75,10 @@ export type StarterEntriesProps = {
|
||||
|
||||
export function StarterEntries({
|
||||
natalMinuteAvailable,
|
||||
dailyStarlanguage,
|
||||
dailyStarlanguageQuestion,
|
||||
dailyStarlanguageTrend,
|
||||
productEntrypointsDisabled,
|
||||
startDailyStarlanguageConsultation,
|
||||
rectificationCardLabel,
|
||||
rectificationCardAction,
|
||||
rectificationLoading,
|
||||
rectificationMutationPending,
|
||||
openRectificationFromHomepage,
|
||||
@@ -71,20 +90,7 @@ export function StarterEntries({
|
||||
const subjectId = useCurrentSubject();
|
||||
const rectificationForSelf = subjectId === "self";
|
||||
const dailyLabel = natalMinuteAvailable ? "今日星语" : "每日运势";
|
||||
/* The trend line is the only part that arrives late. It stays a live region so
|
||||
a screen reader hears it settle, but it is a tooltip-rank hint now, not a
|
||||
card body — the pill is actionable from the first paint either way. */
|
||||
const dailyHint = natalMinuteAvailable ? dailyStarlanguageTrend : "看看今天的整体节奏";
|
||||
/* Three branches, same as the card it replaces: an unfinished case, a finished
|
||||
one that can be redone, and a first run. The label itself is server-driven
|
||||
from the entry summary (`rectificationCardAction`), not from the session list. */
|
||||
const rectificationHint = !rectificationForSelf
|
||||
? "生时校正暂时只支持本人"
|
||||
: rectificationEntrySummary?.hasResumableCase
|
||||
? "未完成的那次可以从左侧继续"
|
||||
: rectificationCardAction === "restart"
|
||||
? "上次已经校正完,可以拿最新资料再来一次"
|
||||
: "不确定出生时间时,用记得住的经历一步步缩小范围";
|
||||
const rectificationHint = rectificationEntryHint(rectificationForSelf, rectificationEntrySummary);
|
||||
|
||||
return (
|
||||
<div className="starter-entries">
|
||||
@@ -96,7 +102,7 @@ export function StarterEntries({
|
||||
disabled={productEntrypointsDisabled}
|
||||
onClick={startDailyStarlanguageConsultation}
|
||||
>
|
||||
<Sparkles aria-hidden="true" />
|
||||
<MoonStar aria-hidden="true" />
|
||||
<span>{dailyLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -111,12 +117,7 @@ export function StarterEntries({
|
||||
<span>生时校正</span>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
className="starter-entry-hint"
|
||||
role={natalMinuteAvailable && dailyStarlanguage.kind !== "ready" ? "status" : undefined}
|
||||
>
|
||||
{dailyHint} · {rectificationHint}
|
||||
</p>
|
||||
{rectificationHint && <p className="starter-entry-hint">{rectificationHint}</p>}
|
||||
{!natalMinuteAvailable && (
|
||||
<p className="starter-entry-boundary">出生时间不足以支持的判断我会说明,不会补造具体分钟。</p>
|
||||
)}
|
||||
|
||||
@@ -69,32 +69,45 @@ export function isTerminalRectificationStatus(status: string): boolean {
|
||||
return TERMINAL_STATUSES.has(status);
|
||||
}
|
||||
|
||||
/* `GET /api/rectification/cases/entry-summary` answers with the camelCase
|
||||
object `getRectificationEntrySummary` returns; only the RPC underneath speaks
|
||||
snake_case. This parser used to read the snake_case keys alone, so every real
|
||||
response parsed as "nothing on record" and the homepage never saw a resumable
|
||||
or finished case (BUG-1041). The route's camelCase shape wins; snake_case is
|
||||
still read so an RPC-shaped payload does not silently collapse either. */
|
||||
function field(row: Record<string, unknown>, camel: string, snake: string): unknown {
|
||||
return camel in row ? row[camel] : row[snake];
|
||||
}
|
||||
|
||||
function objectField(row: Record<string, unknown>, camel: string, snake: string): Record<string, unknown> | null {
|
||||
const value = field(row, camel, snake);
|
||||
return value && typeof value === "object" ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
export function entrySummaryFromResponse(value: unknown): RectificationEntrySummary {
|
||||
if (!value || typeof value !== "object") {
|
||||
return { hasResumableCase: false, hasTerminalCaseWithTime: false, latestResumable: null, latestTerminal: null };
|
||||
}
|
||||
const row = value as Record<string, unknown>;
|
||||
const latestResumable = row.latest_resumable && typeof row.latest_resumable === "object"
|
||||
? row.latest_resumable as Record<string, unknown>
|
||||
: null;
|
||||
const latestTerminal = row.latest_terminal && typeof row.latest_terminal === "object"
|
||||
? row.latest_terminal as Record<string, unknown>
|
||||
: null;
|
||||
const latestResumable = objectField(row, "latestResumable", "latest_resumable");
|
||||
const latestTerminal = objectField(row, "latestTerminal", "latest_terminal");
|
||||
const resumableCaseId = latestResumable ? field(latestResumable, "caseId", "case_id") : null;
|
||||
const terminalCaseId = latestTerminal ? field(latestTerminal, "caseId", "case_id") : null;
|
||||
return {
|
||||
hasResumableCase: row.has_resumable_case === true,
|
||||
hasTerminalCaseWithTime: row.has_terminal_case_with_time === true,
|
||||
latestResumable: latestResumable && typeof latestResumable.case_id === "string"
|
||||
hasResumableCase: field(row, "hasResumableCase", "has_resumable_case") === true,
|
||||
hasTerminalCaseWithTime: field(row, "hasTerminalCaseWithTime", "has_terminal_case_with_time") === true,
|
||||
latestResumable: latestResumable && typeof resumableCaseId === "string"
|
||||
? {
|
||||
caseId: latestResumable.case_id,
|
||||
caseId: resumableCaseId,
|
||||
status: String(latestResumable.status ?? ""),
|
||||
lastActivityAt: String(latestResumable.last_activity_at ?? ""),
|
||||
lastActivityAt: String(field(latestResumable, "lastActivityAt", "last_activity_at") ?? ""),
|
||||
}
|
||||
: null,
|
||||
latestTerminal: latestTerminal && typeof latestTerminal.case_id === "string"
|
||||
latestTerminal: latestTerminal && typeof terminalCaseId === "string"
|
||||
? {
|
||||
caseId: latestTerminal.case_id,
|
||||
caseId: terminalCaseId,
|
||||
status: String(latestTerminal.status ?? ""),
|
||||
hasUsableTime: latestTerminal.has_usable_time === true,
|
||||
hasUsableTime: field(latestTerminal, "hasUsableTime", "has_usable_time") === true,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
@@ -6,10 +6,14 @@ const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", i
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
test("the 2026-09-22 icon audit keeps the existing entry icons and adds no loop", () => {
|
||||
assert.match(starterHome, /import \{ Clock3, Sparkles \} from "lucide-react"/);
|
||||
assert.match(starterHome, /<Sparkles aria-hidden="true" \/>/);
|
||||
// 原值:`import { Clock3, Sparkles }`、`<Sparkles aria-hidden="true" />`,且 doesNotMatch /Moon|MoonStar|thinking-orbs/
|
||||
// 新值:`import { Clock3, MoonStar }`、`<MoonStar aria-hidden="true" />`,doesNotMatch 收窄为 /Sparkles|thinking-orbs/
|
||||
// 原因:TASK-starter-home-polish-20260926 D3 产品拍板今日星语入口改 lucide MoonStar(与右上角点数不再同图标);
|
||||
// 「不加循环动画、不引入 thinking-orbs」这条意图原样保留,并反向锁住旧图标不回来。
|
||||
assert.match(starterHome, /import \{ Clock3, MoonStar \} from "lucide-react"/);
|
||||
assert.match(starterHome, /<MoonStar aria-hidden="true" \/>/);
|
||||
assert.match(starterHome, /<Clock3 aria-hidden="true" \/>/);
|
||||
assert.doesNotMatch(starterHome, /Moon|MoonStar|thinking-orbs/);
|
||||
assert.doesNotMatch(starterHome, /Sparkles|thinking-orbs/);
|
||||
const blockStart = css.indexOf(".starter-entry {");
|
||||
const blockEnd = css.indexOf(".starter-entry > svg");
|
||||
const block = css.slice(blockStart, blockEnd);
|
||||
|
||||
@@ -919,7 +919,14 @@ test("homepage CTA is server-driven from the entry summary, not the session list
|
||||
assert.doesNotMatch(page, /hasRectificationSession/);
|
||||
assert.doesNotMatch(page, /sessions\.some\([\s\S]{0,120}birth_time_rectification/);
|
||||
assert.match(page, /resolveRectificationEntryAction/);
|
||||
assert.match(page, /rectificationCardAction === "restart"/);
|
||||
// 原值:assert.match(page, /rectificationCardAction === "restart"/)
|
||||
// 新值:首页小字改由 rectificationEntryHint 直接读 entry summary 的 hasResumableCase;
|
||||
// 「restart」分支对应的那句已删除,改锁 hint 读的是 summary 本身、不读会话列表
|
||||
// 原因:TASK-starter-home-polish-20260926 D2 删掉「上次已经校正完…」一句;BUG-1041 查明
|
||||
// `=== "restart"` 从未命中(解析器读错键名)。「CTA 与提示由服务端 entry summary 驱动」这条意图不变。
|
||||
assert.match(page, /rectificationEntryHint\(rectificationForSelf, rectificationEntrySummary\)/);
|
||||
assert.match(page, /summary\?\.hasResumableCase/);
|
||||
assert.doesNotMatch(page, /rectificationCardAction === "restart"/);
|
||||
assert.match(entryLib, /开始新的生时校正/);
|
||||
assert.match(entryLib, /再次校正/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import {
|
||||
StarterEntries,
|
||||
StarterGreeting,
|
||||
rectificationEntryHint,
|
||||
type StarterEntriesProps,
|
||||
} from "../src/components/starter-home.tsx";
|
||||
import { setCurrentSubject } from "../src/lib/current-subject.ts";
|
||||
import { getRectificationEntrySummary } from "../src/lib/rectification-agentic/v9/case-service.ts";
|
||||
import {
|
||||
entrySummaryFromResponse,
|
||||
resolveRectificationEntryAction,
|
||||
type RectificationEntrySummary,
|
||||
} from "../src/lib/rectification-entry.ts";
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const CASE_ID = "00000000-0000-4000-8000-000000000041";
|
||||
const RESUMABLE_HINT = "上次那次校正还没完成,可以接着做。";
|
||||
const NON_SELF_HINT = "生时校正暂时只支持本人。";
|
||||
const REMOVED_HINTS = /不确定出生时间时|上次已经校正完|未完成的那次可以从左侧继续|看看今天的整体节奏| · /;
|
||||
|
||||
const summaries = {
|
||||
firstRun: { hasResumableCase: false, hasTerminalCaseWithTime: false, latestResumable: null, latestTerminal: null },
|
||||
rectified: {
|
||||
hasResumableCase: false,
|
||||
hasTerminalCaseWithTime: true,
|
||||
latestResumable: null,
|
||||
latestTerminal: { caseId: CASE_ID, status: "confirmed", hasUsableTime: true },
|
||||
},
|
||||
resumable: {
|
||||
hasResumableCase: true,
|
||||
hasTerminalCaseWithTime: true,
|
||||
latestResumable: { caseId: CASE_ID, status: "collecting_evidence", lastActivityAt: "2026-09-26T00:00:00.000Z" },
|
||||
latestTerminal: null,
|
||||
},
|
||||
} satisfies Record<string, RectificationEntrySummary>;
|
||||
|
||||
function entriesProps(overrides: Partial<StarterEntriesProps> = {}): StarterEntriesProps {
|
||||
return {
|
||||
natalMinuteAvailable: true,
|
||||
dailyStarlanguageQuestion: "深入看今日",
|
||||
productEntrypointsDisabled: false,
|
||||
startDailyStarlanguageConsultation: () => undefined,
|
||||
rectificationCardLabel: "开始新的生时校正",
|
||||
rectificationLoading: false,
|
||||
rectificationMutationPending: false,
|
||||
openRectificationFromHomepage: () => undefined,
|
||||
rectificationEntrySummary: null,
|
||||
rectificationError: "",
|
||||
rectificationSurfaceOpen: false,
|
||||
rectificationErrorMessage: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function hintOf(markup: string): string | null {
|
||||
return markup.match(/<p class="starter-entry-hint">([^<]*)<\/p>/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
test("D2: the line under the pills only appears when the user has something to do", () => {
|
||||
// First run and an already-rectified account: no hint at all.
|
||||
for (const summary of [null, summaries.firstRun, summaries.rectified]) {
|
||||
const markup = renderToStaticMarkup(<StarterEntries {...entriesProps({ rectificationEntrySummary: summary })} />);
|
||||
assert.equal(hintOf(markup), null, JSON.stringify(summary));
|
||||
assert.doesNotMatch(markup, /starter-entry-hint/);
|
||||
assert.doesNotMatch(markup, REMOVED_HINTS);
|
||||
}
|
||||
|
||||
// An unfinished case: one sentence, the resumable one.
|
||||
const resumable = renderToStaticMarkup(<StarterEntries {...entriesProps({ rectificationEntrySummary: summaries.resumable })} />);
|
||||
assert.equal(hintOf(resumable), RESUMABLE_HINT);
|
||||
assert.doesNotMatch(resumable, REMOVED_HINTS);
|
||||
});
|
||||
|
||||
test("D2: a non-self subject gets the self-only sentence, even with an unfinished case on record", async () => {
|
||||
assert.equal(rectificationEntryHint(false, null), NON_SELF_HINT);
|
||||
assert.equal(rectificationEntryHint(false, summaries.resumable), NON_SELF_HINT);
|
||||
assert.equal(rectificationEntryHint(true, summaries.resumable), RESUMABLE_HINT);
|
||||
assert.equal(rectificationEntryHint(true, summaries.rectified), null);
|
||||
assert.equal(rectificationEntryHint(true, summaries.firstRun), null);
|
||||
assert.equal(rectificationEntryHint(true, null), null);
|
||||
|
||||
// Rendered through the real subject store, not just the helper.
|
||||
const harness = createClientLifecycleHarness();
|
||||
try {
|
||||
setCurrentSubject("person-fictional-1");
|
||||
await harness.render(<StarterEntries {...entriesProps({ rectificationEntrySummary: summaries.resumable })} />);
|
||||
const hints = harness.elements().filter((node) => node.getAttribute("class") === "starter-entry-hint");
|
||||
assert.equal(hints.length, 1);
|
||||
assert.equal(hints[0].text, NON_SELF_HINT);
|
||||
const rectificationPill = harness.elements().find((node) => node.getAttribute("aria-label") === "开始新的生时校正");
|
||||
assert.equal(rectificationPill?.disabled, true);
|
||||
} finally {
|
||||
setCurrentSubject("self");
|
||||
await harness.close();
|
||||
}
|
||||
assert.deepEqual(harness.errors, []);
|
||||
});
|
||||
|
||||
test("D4: the no-minute boundary sentence stays under the pills", () => {
|
||||
const markup = renderToStaticMarkup(<StarterEntries {...entriesProps({ natalMinuteAvailable: false })} />);
|
||||
assert.match(markup, /<p class="starter-entry-boundary">出生时间不足以支持的判断我会说明,不会补造具体分钟。<\/p>/);
|
||||
assert.doesNotMatch(markup, REMOVED_HINTS);
|
||||
const withMinute = renderToStaticMarkup(<StarterEntries {...entriesProps()} />);
|
||||
assert.doesNotMatch(withMinute, /starter-entry-boundary/);
|
||||
});
|
||||
|
||||
test("D1: today's trend is a sub-line under the greeting, static until ready, hidden without a minute", () => {
|
||||
const ready = renderToStaticMarkup(
|
||||
<StarterGreeting starterGreeting={{ text: "早上好" }} dailyTrend="虚构的今日趋势句。" dailyTrendSettled />,
|
||||
);
|
||||
assert.match(ready, /<h1 id="starter-heading">早上好<\/h1><p class="starter-greeting-sub">虚构的今日趋势句。<\/p>/);
|
||||
assert.doesNotMatch(ready, /role="status"|aria-busy/);
|
||||
|
||||
const pending = renderToStaticMarkup(
|
||||
<StarterGreeting starterGreeting={{ text: "早上好" }} dailyTrend="今天的星语还没写出来。" dailyTrendSettled={false} />,
|
||||
);
|
||||
assert.match(pending, /<p class="starter-greeting-sub" role="status">今天的星语还没写出来。<\/p>/);
|
||||
assert.doesNotMatch(pending, /aria-busy|spinner|inline-spin|正在加载/);
|
||||
|
||||
for (const markup of [
|
||||
renderToStaticMarkup(<StarterGreeting starterGreeting={{ text: "早上好" }} dailyTrend={null} />),
|
||||
renderToStaticMarkup(<StarterGreeting starterGreeting={{ text: "早上好" }} />),
|
||||
]) {
|
||||
assert.equal(markup, '<section class="starter-greeting-block" aria-labelledby="starter-heading"><h1 id="starter-heading">早上好</h1></section>');
|
||||
}
|
||||
|
||||
// The trend no longer rides the pills' fine print.
|
||||
const entries = renderToStaticMarkup(<StarterEntries {...entriesProps({ rectificationEntrySummary: summaries.resumable })} />);
|
||||
assert.doesNotMatch(entries, /今天的星语还没写出来/);
|
||||
});
|
||||
|
||||
test("D1: page.tsx feeds the sub-line from the same daily state the warm snapshot seeds", () => {
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
assert.match(page, /useState<DailyStarlanguageState>\(\(\) => warmStart\(\)\?\.daily \?\? \{ kind: "pending" \}\)/);
|
||||
assert.match(page, /<StarterGreeting starterGreeting=\{starterGreeting\} dailyTrend=\{natalMinuteAvailable \? dailyStarlanguageTrend : null\} dailyTrendSettled=\{dailyStarlanguage\.kind === "ready"\} \/>/);
|
||||
assert.doesNotMatch(page, /dailyStarlanguageTrend=\{/);
|
||||
});
|
||||
|
||||
test("D3: daily entry uses MoonStar, credits use Coins, accessible names unchanged", () => {
|
||||
const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
|
||||
assert.match(starterHome, /<MoonStar aria-hidden="true" \/>/);
|
||||
assert.doesNotMatch(starterHome, /Sparkles/);
|
||||
assert.match(page, /import \{ Coins \} from "lucide-react";/);
|
||||
assert.match(page, /<Coins className="credit-icon" aria-hidden="true" \/>/);
|
||||
assert.doesNotMatch(page, /Sparkles/);
|
||||
assert.match(page, /aria-label=\{account \? `余额 \$\{account\.credits\} 点,会员与充值` : accountError \|\| "读取余额中"\}/);
|
||||
|
||||
const markup = renderToStaticMarkup(<StarterEntries {...entriesProps()} />);
|
||||
assert.match(markup, /aria-label="深入看今日"/);
|
||||
assert.match(markup, /lucide-moon-star/);
|
||||
});
|
||||
|
||||
test("BUG-1041: the homepage parser reads what the entry-summary route actually returns", async () => {
|
||||
// The route returns NextResponse.json(await getRectificationEntrySummary(...)), i.e. the camelCase
|
||||
// projection of the snake_case RPC row. Round-trip it through JSON the way the browser receives it.
|
||||
const accounting = {
|
||||
rpc: async (fn: string) => {
|
||||
assert.equal(fn, "get_agentic_rectification_entry_summary");
|
||||
return {
|
||||
data: {
|
||||
has_resumable_case: false,
|
||||
has_terminal_case_with_time: true,
|
||||
latest_resumable: null,
|
||||
latest_terminal: { case_id: CASE_ID, status: "confirmed", has_usable_time: true },
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
} as unknown as Parameters<typeof getRectificationEntrySummary>[0];
|
||||
const wire = JSON.parse(JSON.stringify(await getRectificationEntrySummary(accounting, "user-fictional")));
|
||||
assert.equal("has_terminal_case_with_time" in wire, false, "the route speaks camelCase");
|
||||
|
||||
const summary = entrySummaryFromResponse(wire);
|
||||
assert.equal(summary.hasTerminalCaseWithTime, true);
|
||||
assert.equal(summary.latestTerminal?.caseId, CASE_ID);
|
||||
assert.equal(summary.latestTerminal?.hasUsableTime, true);
|
||||
assert.equal(resolveRectificationEntryAction(summary), "restart");
|
||||
|
||||
const resumableWire = JSON.parse(JSON.stringify({
|
||||
hasResumableCase: true,
|
||||
hasTerminalCaseWithTime: false,
|
||||
latestResumable: { caseId: CASE_ID, status: "collecting_evidence", lastActivityAt: "2026-09-26T00:00:00.000Z" },
|
||||
latestTerminal: null,
|
||||
}));
|
||||
const resumable = entrySummaryFromResponse(resumableWire);
|
||||
assert.equal(resumable.hasResumableCase, true);
|
||||
assert.equal(resumable.latestResumable?.lastActivityAt, "2026-09-26T00:00:00.000Z");
|
||||
assert.equal(rectificationEntryHint(true, resumable), RESUMABLE_HINT);
|
||||
});
|
||||
Reference in New Issue
Block a user