#292 生时纠正暂停问卷和选边 #293
@@ -4,6 +4,10 @@
|
||||
|
||||
- 判断依据与证据比对时,宫位写法、中英文星体和星座名视为同一事实;修订时告诉写作具体哪个词没对上;合盘生成失败页标题改为「婚恋合盘」。占星 Skill 版本不变(BUG-1897)。
|
||||
|
||||
## 2026-11-15 — 生时纠正暂时停用问卷和选边
|
||||
|
||||
- 直接显示填写时间或范围中点,保留六盘变化、快速选范围和历史记录。占星 Skill 版本不变(BUG-1896)。
|
||||
|
||||
## 2026-11-15 — 合盘分开显示正式核对和研究扫描
|
||||
|
||||
- 报告和追问分别展示五类事件的正式核对、研究日期和冲突说明。附录列出两人的运限条件、相关点位及法律婚姻缺项。旧报告重新打开时使用已保存的依据。占星 Skill 版本不变(BUG-1895)。
|
||||
|
||||
@@ -25615,3 +25615,14 @@
|
||||
- 验证与防复发:`tests/test_synastry_gate_repair_20261115.py`(混排证据通过、错宫错星仍拒、中英互认、D1、星宿、数组仍拒);`frontend/tests/synastry-gate-repair-20261115.test.ts`(修订细节、截断上限、改法说明、提示词、失败页标题)。
|
||||
- 关联记录:#281、#289、BUG-1892、BUG-1895。
|
||||
- 修复版本:`claude/synastry-gate-repair-20261115`。
|
||||
|
||||
## BUG-1896 | 生时纠正答完问卷仍回到填写时间或范围中点
|
||||
|
||||
- 状态:fixed-pending-verify(待独立验收)。
|
||||
- 首次发现 / 最近更新:2026-11-15 / 2026-11-15。
|
||||
- 触发与现象:首次进入或重新纠正时出现年龄选项和年份步进。选项未覆盖用户经历,答完也不会改变采用的时间或范围。
|
||||
- 根因:在答题结果不得缩窄自报范围的规则下,入口仍沿用问卷和选边。回测显示按答题缩窄表现低于随机。
|
||||
- 修复:默认直接给出填写时间或原范围中点。保留六盘、快速范围选择及历史。共用开关暂停问卷、选边和续答请求。
|
||||
- 验证与防复发:原生引擎夹具覆盖决定窗口、无分界线、历史重新进入、快速范围写回和中点。API 回归核对禁用请求的错误码。详见 [#292 进度](tasks/PROGRESS-rect-pause-20261115.md)。
|
||||
- 关联记录:#260、#279、BUG-1649、BUG-1886、BUG-1890。
|
||||
- 修复版本:`codex/rect-pause-20261115`,待独立验收。
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# 生时纠正停用问卷和选边执行记录(2026-11-15)
|
||||
|
||||
- 工单 #292,BUG-1896,范围按 Claude 的任务书。基线为 `1c9017698cc9fd577aa87b4615fc13f067126abe`,任务书提交为 `655e07ad2c76f077305f582ab20c350b812b85cd`。
|
||||
|
||||
## 开工盘点
|
||||
|
||||
| 步骤、组件 | API 和既有参数 | 原入口 |
|
||||
| --- | --- | --- |
|
||||
| `chart_range → chart_question → final`,`QuestionnaireRange`、`ChartQuestion`、`FinalStep` | `/api/chart-choice/packet` 的 `adaptive`、`window`。采用端点用 `adaptive`、`chart_answers`、`stop`、`mode` | 首次进入、首页重新纠正、改变范围 |
|
||||
| `range → choose`,`RangeStep`、`RoundStep` | 采用端点用 `resolved`、`answers`、`event_answers`、`appearance`、`birthmarks`、`stop`。回到计算端点用非空 `resolved` 继续 | 旧 v2 结果和逐轮确认,返回一步继续原轮 |
|
||||
| `choose → result`,`ChooseStep`、`ResultStep` | 采用端点用 `mode=preview/choice`、`answers`、`overrideSide`、`readBothAll` | 旧 v1 选盘,结果页可改选另一侧 |
|
||||
| 续答兼容入口 | 两个端点的 `resumeRecordId`、`questionnaire` | 旧记录曾可继续确认。当前首页只有重新纠正,接口仍接收续答 |
|
||||
| `narrow`,`ChartChoiceFlow` 中的缩小范围选项 | 计算端点用 `window` 提交所选子段 | 输入范围超出单次扫描上限 |
|
||||
| 占星师问卷,`AstrologerStage` | `/api/chart-choice/astrologer` 的 `window` 和 `input`,含 `question_answers`、日期事件、胎记、外表、D9、D10 的选择 | 组件和端点保留,当前页面没有挂载该阶段 |
|
||||
|
||||
历史首页以 `GET /api/chart-choice` 读取当前记录与停用记录。`restore_default`、`certain`、快速范围选择与历史读取按原有语义保留。
|
||||
|
||||
## 实现与截图
|
||||
|
||||
- 计算入口直接取原生引擎的默认结果。较宽范围沿用恢复默认值时的有界扫描,六盘变化和保存采用整段自报范围。用户原先没填范围时,保留 ±15 分钟试算,明确选择的范围在保存时写回。
|
||||
- 计算和采用端点对旧答题、选边和续答请求返回 HTTP 400 `questionnaire_paused`。停用的占星师问卷端点共用此错误码。页面和端点由同一个开关控制。
|
||||
- [四张 390 × 844 截图](https://git.copse.top/attachments/4b93d08e-d206-477a-8e97-c86f7e40d528) 分别为填写时间和范围、只填时间、只填范围、已有记录。Chrome 使用实际组件、生产构建 CSS 和原生引擎的虚构样例响应。截图环境用本地夹具替代登录后的 API。附件 SHA-256 为 `1fa22b021d2974d40c27e8505c2656aaeb76deca470e7cf17e89884198c9d5a7`。
|
||||
|
||||
## 验证
|
||||
|
||||
- 停用开关时的生时纠正定向测试 33 项通过。启用开关的隔离 worktree 中,33 项原有生时纠正和记录测试通过。既有断言未修改。
|
||||
- `tsc --noEmit` 通过,前端 lint 0 错误。生产构建通过,`/` 和 `/rectification` 都保持静态。开工基线和候选的首页 39 个入口 JS/CSS 资产 gzip 合计均为 614 357 字节,变化 0%。
|
||||
- Windows 前端全量,开工基线 3 782 项、通过 3 699 项,候选 3 785 项、通过 3 702 项。两边均有 46 项失败(含 Bash、部署和数据库环境限制),失败用例逐项相同,37 项跳过,0 项取消。
|
||||
- 仓库隐私测试 62 项通过。
|
||||
- Windows Python 快速门执行到 pytest,1 497 项通过、2 项跳过、29 个子测试通过,3 项失败。对开工基线单独运行 `tests/test_upstream_technique_layers.py` 复现同三项失败。触发点为 Kaala 的 1955 年负 Unix 时间戳经 Windows `datetime.fromtimestamp` 转换时报 `OSError 22`,另两项缺少 `seed`。预检确认远端、运行时和适配器可用,碎片审计和预检的定向检查分别触及 90 秒和 45 秒时限。
|
||||
- staging 新增 `12684c8b8` 后,在隔离工作树按该提交与本单前端变更的组合复测。生时纠正与合盘修订定向用例合计前端 37 项、Python 56 项通过。组合生产构建保持 `/` 与 `/rectification` 静态,首页入口资产 gzip 仍为 614 357 字节。其后 `3b510608e` 仅调整个人报告测试,该文件 8 项通过。
|
||||
@@ -0,0 +1,87 @@
|
||||
# 生时纠正:先停用问卷和选边(2026-11-15,Claude)
|
||||
|
||||
Bug 编号:BUG-1896。
|
||||
|
||||
## 背景
|
||||
|
||||
产品负责人 11-15 在 staging 真机上走完一遍问卷(#279 上线后),反馈:
|
||||
|
||||
- **选项残缺:** 选项只有能区分候选段的年龄(例如事业盘只有「26 岁」),用户 30 岁创业就找不到对应选项;年份只能在该年龄覆盖的一两年里调。
|
||||
- **选项样式难看。**
|
||||
- **答完没有结果:** 结论永远是「按你填的 05:00」和「现有信息不足以确认更准确的出生时间」。
|
||||
|
||||
最后一条是根本问题。#260 的回测显示,按答题缩窄比随机还差;占星师 11-14 裁定不得缩窄,#279 把旧窄范围也撤回了。所以现在答题不可能改变结果,问卷只是让用户白答。
|
||||
|
||||
负责人 11-15 决定**先停用问卷**。等占星师下一轮(日期区间计分、新留出集)验证有效后再开,届时重做选项 UI。
|
||||
|
||||
## 改法
|
||||
|
||||
### 一、盘点(先做,写进 PROGRESS)
|
||||
|
||||
列出生时纠正里所有会向用户提问、或让用户选边、选段的入口,每条写清:
|
||||
|
||||
- step 名、组件名、对应 API 及参数;
|
||||
- 现在从哪里进入(首页「重新纠正」、首次进入、换范围后等)。
|
||||
|
||||
已知的入口:
|
||||
|
||||
- 自适应问卷:`chart_range` → `chart_question` → `final`;
|
||||
- v2 轮次:`choose` 里的 `RoundStep`;
|
||||
- v1:`ChooseStep` → `ResultStep`,含「换另一侧」;
|
||||
- `astrologer-stage.tsx`:「感情里你更像哪一种?」等。
|
||||
|
||||
盘点中发现的其他入口同样处理。
|
||||
|
||||
### 二、页面(只改 `frontend/src/components/chart-choice/` 和对应测试)
|
||||
|
||||
首次进入和「重新纠正」都直接显示结果页,不出任何题目。结果页即现在问卷答完后的结果页(真机截图第 4 张)去掉「你提到的事」,内容依次为:
|
||||
|
||||
1. 标题「按你填的 05:00」(没填时间:「按范围中点 HH:MM」)。
|
||||
2. 「范围 04:45–05:15」。没填范围时保留现有的范围选择(`RangeChips`)。
|
||||
3. 「现有信息不足以确认更准确的出生时间。」
|
||||
4. 范围条和参照时间。
|
||||
5. 「按 05:00 看六张盘」六行,文案沿用 #279 的规则:
|
||||
- 默认窗口:「前后 15 分钟试算里没有变化」;
|
||||
- 用户填了范围:「在你填的范围里没有换座」;
|
||||
- 会换座:「范围内会换座(hh:mm:ss 换)」。
|
||||
6. 唯一的主按钮「用 05:00」,保存 `reported_time_default`(没填时间时为 `window_midpoint_default`)。保存后回首页。
|
||||
|
||||
如果范围内没有分界线,`certain` 分支照旧。
|
||||
|
||||
还要去掉:
|
||||
|
||||
- 「开始答题」「先到这里」「下一题」「都没有」「记不清」;
|
||||
- 年龄选项、哪一年步进器;
|
||||
- 「换另一侧」「更像哪一种」;
|
||||
- 「你提到的事」。
|
||||
|
||||
问卷相关组件和代码**保留不删**,只是不再渲染。用一个常量开关(例如 `RECT_QUESTIONNAIRE_ENABLED = false`)集中控制,方便以后重开。
|
||||
|
||||
### 三、API
|
||||
|
||||
- `/api/chart-choice/adopt` 和 `/api/chart-choice/packet` 收到问卷或选边类请求时,返回 4xx 并带明确说明(code 例如 `questionnaire_paused`)。问卷或选边类请求包括 `chart_answers`、`questionnaire`、`answers`、`overrideSide`、`resolved` 非空、`resumeRecordId` 续答等,以盘点为准。
|
||||
- `adaptive` 只取默认结果(不出题),保存仍走现有 `reported_time_default` / `window_midpoint_default`。
|
||||
- `restore_default`、`certain`、范围选择保持可用。
|
||||
- 不改迁移、不改引擎评分、不删旧枚举。
|
||||
|
||||
### 四、历史和下游
|
||||
|
||||
- 已有记录照旧显示,#279 的「已停用」逻辑不动。
|
||||
- 已有的明确手动选边、#229 选边记录继续按 #279 的规则读取,本单不改。
|
||||
- 报告、年运、聊天、合盘不受影响,不碰 `scripts/report_render/`。
|
||||
|
||||
## 验收
|
||||
|
||||
- **测试:**
|
||||
- 首次进入与「重新纠正」直接渲染结果页,没有题目、没有「先到这里」「下一题」「更像」「换另一侧」「你提到的事」;
|
||||
- 唯一按钮「用 HH:MM」,保存 `reported_time_default`;没填时间的保存 `window_midpoint_default`;
|
||||
- 没填范围时可选范围,保存后范围写回(BUG-1649 行为不变);
|
||||
- API 拒绝问卷和选边请求,返回 4xx 并带 code;
|
||||
- 开关打开时现有问卷测试仍通过(证明代码保留可用)。
|
||||
- **截图(390 宽):** 填了时间和范围;只填时间(默认 ±15);没填时间;首页已有记录。
|
||||
- **门禁:** Python quick gate、chart-choice 相关前端测试、`tsc`、`npx eslint .` 0 errors。
|
||||
- **文档:**
|
||||
- `docs/BUG_HISTORY.md` 新增 BUG-1896(只追加这一节):现象、根因(问卷在不能缩窄的规则下没有产出)、修复、验证、防复发,关联 BUG-1886、BUG-1890;
|
||||
- CHANGELOG 一行;
|
||||
- 更新 `frontend/DESIGN.md` 和 `frontend/docs/VOICE.md` 里涉及问卷的段落;
|
||||
- 写 PROGRESS。
|
||||
+5
-3
@@ -358,10 +358,12 @@ The self-profile editor in 星盘资料 (`ChartLibraryPanel`) owns the ayanamsa
|
||||
|
||||
### Rectification surface
|
||||
|
||||
- **按盘选盘(#239,BUG-1873):** 范围、答题、结果和当前记录用平面布局,靠留白和细线分组。答题用 36px 时间带、22px 衬线标题和浅底选项,选项内的 8px 小带表示覆盖范围。事件选项原地展开年份和类别,展开 0.32 秒,遵循系统的减少动态效果设置。结果突出候选段,六盘用细带展示段内换座,并列出证据与冲突。首页保留星盘、重做和按未定盘续问。
|
||||
- **自适应生时纠正(#260,BUG-1881):** 题目随候选范围变化。细进度条对应已确认盘数,年龄旁附公历年份。选项以同色底、1.5px 内描边和强调色文字及细带表示选中。事件类别用细边框与实心选中态,年份加减器保持透明底,展开仍为 0.32 秒。深色模式的选项和细带使用反转表面 token。刻度标签接近时移到另一侧,腾出靠近的端点。当前记录用代表时刻作标题,紧随范围和六盘状态。
|
||||
- **问卷暂停(#292,BUG-1896)**首次进入和重新纠正直接打开原问卷的结果布局。先显示填写时间或范围中点,再显示范围和计算说明;没填范围时在范围下保留快速范围选项。原时间带、六盘列表和单个「用 HH:MM」主按钮沿用。历史首页保留「重新纠正」及停用记录。
|
||||
|
||||
- **按盘选盘(#239,BUG-1873,问卷暂停前):** 范围、答题、结果和当前记录用平面布局,靠留白和细线分组。答题用 36px 时间带、22px 衬线标题和浅底选项,选项内的 8px 小带表示覆盖范围。事件选项原地展开年份和类别,展开 0.32 秒,遵循系统的减少动态效果设置。结果突出候选段,六盘用细带展示段内换座,并列出证据与冲突。首页保留星盘、重做和按未定盘续问。
|
||||
- **自适应生时纠正(#260,BUG-1881,问卷暂停前):** 题目随候选范围变化。细进度条对应已确认盘数,年龄旁附公历年份。选项以同色底、1.5px 内描边和强调色文字及细带表示选中。事件类别用细边框与实心选中态,年份加减器保持透明底,展开仍为 0.32 秒。深色模式的选项和细带使用反转表面 token。刻度标签接近时移到另一侧,腾出靠近的端点。当前记录用代表时刻作标题,紧随范围和六盘状态。
|
||||
- **生时纠正停用的结果(#279,BUG-1890):** 首页先显示填写时间或原范围中点,再显示原范围。已停用的记录在历史里可展开查看当时的时间和范围。没有填误差时,六盘行写试算窗口内的变化,默认前后各 15 分钟。填写过范围时,只描述该范围内是否换座。
|
||||
- **问卷结果(#260,BUG-1886):** 标题下依次显示原范围和「现有信息不足以确认更准确的出生时间。」时间带只画原范围和参照时刻,不画答题段;只有一个主按钮「用 HH:MM」,不提供收窄或比较(FIX-7)。事件列表只列用户提供的事件。比较态的首页显示「你选的比较范围」、原填写时间或范围,以及描边恢复按钮。
|
||||
- **问卷结果(#260,BUG-1886,问卷暂停前):** 标题下依次显示原范围和「现有信息不足以确认更准确的出生时间。」时间带只画原范围和参照时刻,不画答题段;只有一个主按钮「用 HH:MM」,不提供收窄或比较(FIX-7)。事件列表只列用户提供的事件。比较态的首页显示「你选的比较范围」、原填写时间或范围,以及描边恢复按钮。
|
||||
|
||||
The birth-time rectification session is the consultation transcript plus a house board; questions live inside the assistant message that asked them. It shares every waiting vocabulary with the consultation surface; nothing here spins or says "loading" after the reveal.
|
||||
|
||||
|
||||
@@ -620,8 +620,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
星盘档案里问别人和你的关系,标题只用「TA 是你的」。选项只有:伴侣、孩子、父母、朋友、其他。不写「客户」「家人」来代替这五项。没选过的,名字后面的小标签写「其他」。本人的标签仍是「本人」,不问这一句。
|
||||
|
||||
## 生时纠正问卷(#260)
|
||||
## 生时纠正结果(#260、#292)
|
||||
|
||||
- 结果页在范围后写「现有信息不足以确认更准确的出生时间。」不提供收窄或比较按钮(FIX-7)。事件列表只写日期和类别。
|
||||
- 首次进入或点「重新纠正」后写「按你填的 HH:MM」,没填时间时写「按范围中点 HH:MM」。范围后写「现有信息不足以确认更准确的出生时间。」只有「用 HH:MM」一个主按钮。没填范围时给快速范围选项。
|
||||
- 旧答题或事件推得的范围停用后,首页写「你填的时间」或「原范围中点」,历史记录写「已停用」。未填误差时,盘未换座写「前后 15 分钟试算里没有变化」。用户填过范围时写「在你填的范围里没有换座」。
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isRectQuestionnaireRequest, RECT_QUESTIONNAIRE_ENABLED, RECT_QUESTIONNAIRE_PAUSED } from "@/components/chart-choice/questionnaire-switch";
|
||||
import { z } from "zod";
|
||||
|
||||
import { chartAnswersSchema, chartChoiceCaller, chartChoiceErrorResponse, evidenceInput, resolvedSchema, windowSchema } from "@/lib/chart-choice/route-support";
|
||||
import { adoptCertainTime, adoptChartChoice, previewAdaptiveChartChoice, previewChartChoice, restoreChartChoiceDefault } from "@/lib/chart-choice/server";
|
||||
import { adoptCertainTime, adoptChartChoice, adoptDefaultChartChoice, previewAdaptiveChartChoice, previewChartChoice, readDefaultChartChoice, restoreChartChoiceDefault } from "@/lib/chart-choice/server";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -44,9 +45,22 @@ export async function POST(request: Request) {
|
||||
code: "answer_window_disabled",
|
||||
}, { status: 400 });
|
||||
}
|
||||
if (!RECT_QUESTIONNAIRE_ENABLED && isRectQuestionnaireRequest(body)) {
|
||||
return NextResponse.json(RECT_QUESTIONNAIRE_PAUSED, { status: 400 });
|
||||
}
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "请求格式不对", code: "bad_request" }, { status: 400 });
|
||||
try {
|
||||
if (!RECT_QUESTIONNAIRE_ENABLED && (parsed.data.mode === "preview" || parsed.data.mode === "choice")) {
|
||||
const { subjectId, window, rangeChosen } = parsed.data;
|
||||
if (parsed.data.mode === "preview") {
|
||||
const { decision } = await readDefaultChartChoice(caller.supabase, caller.userId, { subjectId, window });
|
||||
return NextResponse.json({ decision });
|
||||
}
|
||||
return NextResponse.json(await adoptDefaultChartChoice(
|
||||
caller.supabase, createAdminSupabaseClient(), caller.userId, { subjectId, window, rangeChosen },
|
||||
));
|
||||
}
|
||||
if (parsed.data.mode === "restore_default") {
|
||||
return NextResponse.json(await restoreChartChoiceDefault(
|
||||
caller.supabase, createAdminSupabaseClient(), caller.userId, parsed.data,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { RECT_QUESTIONNAIRE_ENABLED, RECT_QUESTIONNAIRE_PAUSED } from "@/components/chart-choice/questionnaire-switch";
|
||||
import { z } from "zod";
|
||||
|
||||
import { chartChoiceCaller, chartChoiceErrorResponse, windowSchema } from "@/lib/chart-choice/route-support";
|
||||
@@ -28,6 +29,9 @@ const bodySchema = z.object({
|
||||
export async function POST(request: Request) {
|
||||
const caller = await chartChoiceCaller("POST /api/chart-choice/astrologer");
|
||||
if (!caller.ok) return caller.response;
|
||||
if (!RECT_QUESTIONNAIRE_ENABLED) {
|
||||
return NextResponse.json(RECT_QUESTIONNAIRE_PAUSED, { status: 400 });
|
||||
}
|
||||
const parsed = bodySchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ error: "请求格式不对", code: "bad_request" }, { status: 400 });
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isRectQuestionnaireRequest, RECT_QUESTIONNAIRE_ENABLED, RECT_QUESTIONNAIRE_PAUSED } from "@/components/chart-choice/questionnaire-switch";
|
||||
import { z } from "zod";
|
||||
|
||||
import { chartChoiceCaller, chartChoiceErrorResponse, resolvedSchema, windowSchema } from "@/lib/chart-choice/route-support";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { buildChartChoicePacket } from "@/lib/chart-choice/server";
|
||||
import { buildChartChoicePacket, readDefaultChartChoice } from "@/lib/chart-choice/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -20,9 +21,16 @@ const bodySchema = z.object({
|
||||
export async function POST(request: Request) {
|
||||
const caller = await chartChoiceCaller("POST /api/chart-choice/packet");
|
||||
if (!caller.ok) return caller.response;
|
||||
const parsed = bodySchema.safeParse(await request.json().catch(() => null));
|
||||
const body: unknown = await request.json().catch(() => null);
|
||||
if (!RECT_QUESTIONNAIRE_ENABLED && isRectQuestionnaireRequest(body)) {
|
||||
return NextResponse.json(RECT_QUESTIONNAIRE_PAUSED, { status: 400 });
|
||||
}
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "请求格式不对", code: "bad_request" }, { status: 400 });
|
||||
try {
|
||||
if (!RECT_QUESTIONNAIRE_ENABLED) {
|
||||
return NextResponse.json(await readDefaultChartChoice(caller.supabase, caller.userId, parsed.data));
|
||||
}
|
||||
return NextResponse.json(await buildChartChoicePacket(caller.supabase, caller.userId, parsed.data, undefined,
|
||||
parsed.data.resumeRecordId ? createAdminSupabaseClient() : undefined));
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Fragment, useCallback, useEffect, useRef, useState, type ReactNode } fr
|
||||
import { ChevronLeft, Plus, X } from "lucide-react";
|
||||
|
||||
import { ChartDrawLoader } from "@/components/chart-draw-loader";
|
||||
import { RECT_QUESTIONNAIRE_ENABLED } from "@/components/chart-choice/questionnaire-switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -30,7 +31,11 @@ import { cn } from "@/lib/utils";
|
||||
* 选择题、外表体质、胎记、带日期的大事(含六亲)、D9 / D10 特质,再按她的标准给吻合率和置信度。
|
||||
* 选盘页不变;这里只并排给出她的方法的结果和我们的回测说明。
|
||||
*/
|
||||
export function AstrologerStage({
|
||||
export function AstrologerStage(props: Parameters<typeof AstrologerQuestionnaire>[0]) {
|
||||
return RECT_QUESTIONNAIRE_ENABLED ? <AstrologerQuestionnaire {...props} /> : null;
|
||||
}
|
||||
|
||||
function AstrologerQuestionnaire({
|
||||
subjectId,
|
||||
window,
|
||||
chartChoiceTime,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { AppLink } from "@/components/app-link";
|
||||
import { ChartDrawLoader } from "@/components/chart-draw-loader";
|
||||
import { MiniVargaChart } from "@/components/chart-choice/mini-varga-chart";
|
||||
import { RECT_QUESTIONNAIRE_ENABLED } from "@/components/chart-choice/questionnaire-switch";
|
||||
import { SecondaryPageShell } from "@/components/secondary-page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -159,6 +160,19 @@ export function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) {
|
||||
...(request.resolved === undefined ? { adaptive: true } : {}),
|
||||
});
|
||||
if (ticket !== generation.current) return;
|
||||
if (!RECT_QUESTIONNAIRE_ENABLED) {
|
||||
if (packet.status === "certain") {
|
||||
setStep({ kind: "certain", packet });
|
||||
} else if (packet.status === "complete" && packet.decision.reported_choice) {
|
||||
setStep({ kind: "final", packet, decision: packet.decision,
|
||||
body: { subjectId, adaptive: true, mode: "choice", ...(request.window ? { window: request.window } : {}) },
|
||||
back: { kind: "home" },
|
||||
});
|
||||
} else {
|
||||
throw new Error("暂时没有可用的结果,请重试。");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (packet.status === "needs_narrowing") setStep({ kind: "narrow", packet });
|
||||
else if (packet.status === "questionnaire") {
|
||||
const range: Step = { kind: "chart_range", packet, request, previous };
|
||||
@@ -448,7 +462,10 @@ export function ChartChoiceFlow({ subjectId }: { readonly subjectId: string }) {
|
||||
);
|
||||
case "final":
|
||||
return <FinalStep packet={step.packet} decision={step.decision} pending={pending} trial={trial}
|
||||
onBack={() => restore(step.back)} onAccept={() => void saveFinal(step)} />;
|
||||
defaultOnly={!RECT_QUESTIONNAIRE_ENABLED}
|
||||
rangeChips={!RECT_QUESTIONNAIRE_ENABLED ? rangeChips(step.packet) : null}
|
||||
onBack={RECT_QUESTIONNAIRE_ENABLED ? () => restore(step.back) : hasHistory ? toHome : undefined}
|
||||
onAccept={() => void saveFinal(step)} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -974,28 +991,31 @@ function matchedEvidence(decision: FinalDecision): string | null {
|
||||
return `${labels.join("、")},和这段时间的大运对得上${count < 3 ? ",先作参考" : ""}。`;
|
||||
}
|
||||
|
||||
export function FinalStep({ packet, decision, pending, onBack, onAccept, trial = false }: {
|
||||
export function FinalStep({ packet, decision, pending, onBack, onAccept, trial = false, defaultOnly = false, rangeChips }: {
|
||||
readonly packet: FinalPacket;
|
||||
readonly decision: FinalDecision;
|
||||
readonly pending: boolean;
|
||||
readonly onBack: () => void;
|
||||
readonly onBack?: () => void;
|
||||
readonly onAccept: () => void;
|
||||
readonly trial?: boolean;
|
||||
readonly defaultOnly?: boolean;
|
||||
readonly rangeChips?: ReactNode;
|
||||
}) {
|
||||
const seconds = decision.saved_at.length === 19 && !decision.saved_at.endsWith(":00");
|
||||
const saved = clockLabel(decision.saved_at, packet.reported_at, seconds);
|
||||
if (packet.status === "questionnaire") {
|
||||
if (defaultOnly || packet.status === "questionnaire") {
|
||||
const reported = "reported_choice" in decision ? decision.reported_choice : undefined;
|
||||
const reference = reported?.saved_at ?? packet.reported_at;
|
||||
const defaultWindow = reported?.segment ?? packet.window;
|
||||
const hasReportedTime = reported?.reported_known ?? packet.reported_known;
|
||||
const clock = clockLabel(reference);
|
||||
const events = decision.events ?? [];
|
||||
const events = defaultOnly ? [] : decision.events ?? [];
|
||||
return <section className="chart-choice-flat chart-choice-result-flat">
|
||||
{!pending ? <BackLink onClick={onBack}>改答案</BackLink> : null}
|
||||
{!pending && onBack ? <BackLink onClick={onBack}>{defaultOnly ? "返回" : "改答案"}</BackLink> : null}
|
||||
<div className="chart-choice-flat-section">
|
||||
<h2 className="chart-choice-heading">{hasReportedTime ? `按你填的 ${clock}` : `按范围中点 ${clock}`}</h2>
|
||||
<p className="chart-choice-fine">范围 {segmentLabel(defaultWindow, reference)}</p>
|
||||
{rangeChips}
|
||||
<p className="chart-choice-note">现有信息不足以确认更准确的出生时间。</p>
|
||||
<ChoiceBand window={defaultWindow} reportedAt={reference} segments={[1]} />
|
||||
</div>
|
||||
@@ -1016,7 +1036,7 @@ export function FinalStep({ packet, decision, pending, onBack, onAccept, trial =
|
||||
const evidence = matchedEvidence(decision);
|
||||
return <>
|
||||
<Card>
|
||||
{!pending ? <BackLink onClick={onBack}>改答案</BackLink> : null}
|
||||
{!pending && onBack ? <BackLink onClick={onBack}>改答案</BackLink> : null}
|
||||
<p className="chart-choice-label">确认了 {decision.rounds?.length ?? 0} 处</p>
|
||||
<p className="chart-choice-fine">候选时间段</p>
|
||||
<h2 className="chart-choice-heading">{segmentLabel(decision.segment)}</h2>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Product pause. Switch on to restore both the page and the questionnaire endpoints. */
|
||||
export const RECT_QUESTIONNAIRE_ENABLED = false;
|
||||
|
||||
const QUESTIONNAIRE_FIELDS = [
|
||||
"questionnaire", "chart_answers", "answers", "overrideSide", "readBothAll", "resumeRecordId",
|
||||
"event_answers", "appearance", "birthmarks",
|
||||
] as const;
|
||||
|
||||
export function isRectQuestionnaireRequest(body: unknown): boolean {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return false;
|
||||
const fields = body as Record<string, unknown>;
|
||||
return QUESTIONNAIRE_FIELDS.some((key) => Object.hasOwn(fields, key))
|
||||
|| (Array.isArray(fields.resolved) && fields.resolved.length > 0);
|
||||
}
|
||||
|
||||
export const RECT_QUESTIONNAIRE_PAUSED = {
|
||||
error: "答题和选边暂已停用。现在可以保存填写的时间或范围中点。",
|
||||
code: "questionnaire_paused",
|
||||
} as const;
|
||||
@@ -441,6 +441,8 @@ function terminalChoice({ result, subject }: Awaited<ReturnType<typeof evaluateC
|
||||
throw new ChartChoiceError("no_choice", 409);
|
||||
}
|
||||
|
||||
function publicDecision(decision: ChartChoiceCompletion): ChartChoiceCompletion;
|
||||
function publicDecision(decision: ChartChoiceDecision | ChartChoiceCompletion): ChartChoiceDecision | ChartChoiceCompletion;
|
||||
function publicDecision(decision: ChartChoiceDecision | ChartChoiceCompletion): ChartChoiceDecision | ChartChoiceCompletion {
|
||||
const visible = { ...decision };
|
||||
delete visible.conflict;
|
||||
@@ -488,6 +490,60 @@ function assertAdoptionMode(request: ChartChoiceAdoptRequest): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the full declaration without asking for evidence, even when it spans more than one scan. */
|
||||
async function defaultChartChoiceOutcome(subject: Subject, declared: ChartChoiceWindow, fetchImpl?: typeof fetch) {
|
||||
const at = subject.declared && reportedAt(subject.declared);
|
||||
const reference = at ?? midpointAt(declared);
|
||||
const wide = minutesOf(declared.end_at) - minutesOf(declared.start_at) > MAX_RECORD_SPAN_MINUTES * 2;
|
||||
const around = wide ? windowAround(reference, MAX_RECORD_SPAN_MINUTES) : declared;
|
||||
const window = {
|
||||
start_at: around.start_at > declared.start_at ? around.start_at : declared.start_at,
|
||||
end_at: around.end_at < declared.end_at ? around.end_at : declared.end_at,
|
||||
};
|
||||
const packet = await engine<ChartChoiceEnginePacket>("/api/chart_choice/decide", {
|
||||
...engineBody(subject, window), adaptive: true, stop: true,
|
||||
...(wide ? { reported_at: reference, reported_default_window: declared } : {}),
|
||||
}, fetchImpl, wide ? RESTORE_WIDE_RANGE_TIMEOUT_MS : ENGINE_TIMEOUT_MS);
|
||||
if (packet.status !== "complete" || packet.engine_version !== ROUNDS_VERSION || !packet.decision.reported_choice) {
|
||||
throw new ChartChoiceError("no_choice", 409);
|
||||
}
|
||||
const reported = { ...packet.decision.reported_choice, reported_known: at !== null };
|
||||
return { subject, packet, decision: { ...packet.decision, reported_choice: reported }, answers: {}, reported, wide };
|
||||
}
|
||||
|
||||
type DefaultChoiceRequest = Pick<ChartChoiceAdoptRequest, "subjectId" | "window" | "rangeChosen">;
|
||||
|
||||
async function loadDefaultChartChoice(supabase: unknown, userId: string, request: DefaultChoiceRequest, fetchImpl?: typeof fetch) {
|
||||
const subject = await loadSubject(supabase, userId, request.subjectId);
|
||||
const requested = scanWindow(subject, request.window);
|
||||
const window = subject.declared && declaredNeedsRange(subject.declared) ? requested : scanWindow(subject, undefined);
|
||||
return defaultChartChoiceOutcome(subject, window, fetchImpl);
|
||||
}
|
||||
|
||||
export async function readDefaultChartChoice(
|
||||
supabase: unknown, userId: string, request: DefaultChoiceRequest, fetchImpl?: typeof fetch,
|
||||
) {
|
||||
const result = await loadDefaultChartChoice(supabase, userId, request, fetchImpl);
|
||||
const { packet, reported, decision, subject } = result;
|
||||
if (!result.wide && !reported.remaining_key_lines && !packet.minor_lines?.length) {
|
||||
// The existing certain flow checks legacy minor boundaries too.
|
||||
const legacy = await engine<ChartChoicePacket>("/api/chart_choice/packet", engineBody(subject, reported.segment), fetchImpl);
|
||||
if (legacy.status === "certain") return { ...legacy, decision: publicDecision(decision) };
|
||||
}
|
||||
return {
|
||||
engine_version: packet.engine_version, status: "complete" as const,
|
||||
window: reported.segment, reported_at: reported.saved_at, reported_known: reported.reported_known,
|
||||
saved_at: reported.saved_at, decision: publicDecision(decision),
|
||||
};
|
||||
}
|
||||
|
||||
export async function adoptDefaultChartChoice(
|
||||
supabase: unknown, admin: RpcClient, userId: string, request: DefaultChoiceRequest, fetchImpl?: typeof fetch,
|
||||
) {
|
||||
const choice = await loadDefaultChartChoice(supabase, userId, request, fetchImpl);
|
||||
return saveChartChoice(admin, userId, { ...request, adaptive: true }, choice);
|
||||
}
|
||||
|
||||
/** Restore the default for the current comparison using the declaration and no questionnaire answers. */
|
||||
export async function restoreChartChoiceDefault(
|
||||
supabase: unknown,
|
||||
@@ -503,34 +559,12 @@ export async function restoreChartChoiceDefault(
|
||||
|| current.detail.provenance?.source !== "user_choice_from_answers") {
|
||||
throw new ChartChoiceError("record_stale", 409);
|
||||
}
|
||||
const declared = scanWindow(subject, undefined);
|
||||
const at = subject.declared && reportedAt(subject.declared);
|
||||
const start = minutesOf(declared.start_at);
|
||||
const end = minutesOf(declared.end_at);
|
||||
let window = declared;
|
||||
let reference = at;
|
||||
if (end - start > MAX_RECORD_SPAN_MINUTES * 2) {
|
||||
// The engine accepts a short scan plus the full range for its default projection.
|
||||
reference ??= midpointAt(declared);
|
||||
const around = windowAround(reference, MAX_RECORD_SPAN_MINUTES);
|
||||
window = { start_at: around.start_at > declared.start_at ? around.start_at : declared.start_at,
|
||||
end_at: around.end_at < declared.end_at ? around.end_at : declared.end_at };
|
||||
}
|
||||
const packet = await engine<ChartChoiceEnginePacket>("/api/chart_choice/decide", {
|
||||
...engineBody(subject, window), adaptive: true, stop: true,
|
||||
...(window !== declared ? { reported_at: reference, reported_default_window: declared } : {}),
|
||||
}, fetchImpl, window === declared ? ENGINE_TIMEOUT_MS : RESTORE_WIDE_RANGE_TIMEOUT_MS);
|
||||
if (packet.status !== "complete" || packet.engine_version !== ROUNDS_VERSION || !packet.decision.reported_choice) {
|
||||
throw new ChartChoiceError("no_choice", 409);
|
||||
}
|
||||
const choice = await defaultChartChoiceOutcome(subject, scanWindow(subject, undefined), fetchImpl);
|
||||
const latest = await readHistory(admin, userId, subject.subjectId);
|
||||
if (latest.current_record_id !== current.id || !latest.records.some((record) => record.id === current.id && record.reusable)) {
|
||||
throw new ChartChoiceError("record_stale", 409);
|
||||
}
|
||||
const { decision } = await saveChartChoice(admin, userId, { adaptive: true }, {
|
||||
packet, subject, answers: {},
|
||||
decision: { ...packet.decision, reported_choice: { ...packet.decision.reported_choice, reported_known: at !== null } },
|
||||
});
|
||||
const { decision } = await saveChartChoice(admin, userId, { adaptive: true }, choice);
|
||||
return { saved_at: decision.saved_at };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import "./install-test-document";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { register } from "node:module";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
|
||||
import { ChartChoiceFlow } from "../src/components/chart-choice/chart-choice-page";
|
||||
import type { ChartChoiceEnginePacket, ChartChoiceRecord, ChartChoiceStart } from "../src/lib/chart-choice/contract";
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
register(new URL("./support/stub-server-only.mjs", import.meta.url).href, import.meta.url);
|
||||
|
||||
type Complete = Extract<ChartChoiceEnginePacket, { status: "complete" }>;
|
||||
type Certain = Extract<ChartChoiceEnginePacket, { status: "certain" }>;
|
||||
type Ready = Extract<ChartChoiceStart, { status: "ready" }>;
|
||||
|
||||
const fixtures = JSON.parse(readFileSync(new URL("./fixtures/chart-choice-pause-native-20261115.json", import.meta.url), "utf8")) as {
|
||||
defaultTrial: Complete; declared: Complete; midpoint: Complete; wideMidpoint: Complete;
|
||||
certain: { legacy: Certain; default: Complete };
|
||||
};
|
||||
|
||||
function startFor(packet: Complete, rangeAdjustable: boolean, record?: ChartChoiceRecord): Ready {
|
||||
const defaultChoice = packet.decision.reported_choice!;
|
||||
return {
|
||||
status: "ready", subjectId: "self", name: "虚构人物",
|
||||
window: defaultChoice.segment, rangeAdjustable,
|
||||
reportedAt: defaultChoice.reported_known ? defaultChoice.saved_at : null,
|
||||
precision: defaultChoice.reported_known ? "minutes" : "period",
|
||||
history: { current_record_id: record?.id ?? null, records: record ? [record] : [] },
|
||||
};
|
||||
}
|
||||
|
||||
function currentRecord(packet: Complete): ChartChoiceRecord {
|
||||
const saved = packet.decision.reported_choice!;
|
||||
return {
|
||||
id: "test-current", created_at: "2026-11-15T00:00:00Z", saved_date: saved.saved_at.slice(0, 10),
|
||||
saved_time: saved.saved_at.slice(11), chosen_side: "none", rule: "reported_time_default", reusable: true,
|
||||
detail: {
|
||||
window: saved.segment, reported_at: saved.saved_at, line: null, other_lines: saved.remaining_key_lines,
|
||||
questions: [], answers: {}, unchanged: [], charts: [],
|
||||
decision: {
|
||||
...packet.decision, ...saved, chosen_side: "none", votes: null,
|
||||
both_versions_domains: [], rule: "reported_time_default",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("first entry and restart open the default result, preserve trial language and save the chosen range", async () => {
|
||||
for (const scenario of [
|
||||
{ packet: fixtures.declared, adjustable: false, exported: "按你填的 05:00", restart: false },
|
||||
{ packet: fixtures.defaultTrial, adjustable: true, exported: "按你填的 05:00", restart: true },
|
||||
{ packet: fixtures.midpoint, adjustable: false, exported: "按范围中点 05:00", restart: false },
|
||||
]) {
|
||||
const h = createClientLifecycleHarness();
|
||||
const originalFetch = globalThis.fetch;
|
||||
let packets = 0;
|
||||
let saved = false;
|
||||
let submitted: Record<string, unknown> | undefined;
|
||||
let packet = scenario.packet;
|
||||
globalThis.fetch = async (url, request) => {
|
||||
const path = String(url);
|
||||
if (path.startsWith("/api/chart-choice?")) {
|
||||
return Response.json(startFor(packet, scenario.adjustable && !saved, (scenario.restart || saved) ? currentRecord(packet) : undefined));
|
||||
}
|
||||
const body = JSON.parse(String(request?.body));
|
||||
if (path === "/api/chart-choice/packet") {
|
||||
packets += 1;
|
||||
assert.deepEqual(Object.keys(body).sort(), body.window ? ["adaptive", "subjectId", "window"] : ["adaptive", "subjectId"]);
|
||||
if (body.window) {
|
||||
assert.deepEqual(body.window, fixtures.declared.window);
|
||||
packet = fixtures.declared;
|
||||
}
|
||||
return Response.json(packet);
|
||||
}
|
||||
assert.equal(path, "/api/chart-choice/adopt");
|
||||
saved = true;
|
||||
submitted = body;
|
||||
return Response.json({ decision: { saved_at: "1997-08-08T05:00" } });
|
||||
};
|
||||
try {
|
||||
await h.render(<ChartChoiceFlow subjectId="self" />);
|
||||
const waitFor = async (text: string) => {
|
||||
for (let i = 0; i < 10 && !h.elements().some((element) => element.text === text); i += 1) await h.idle();
|
||||
assert.ok(h.elements().some((element) => element.text === text), text);
|
||||
};
|
||||
if (scenario.restart) {
|
||||
await waitFor("重新纠正");
|
||||
assert.equal(packets, 0);
|
||||
await h.event(h.elements().find((element) => element.text === "重新纠正")!);
|
||||
}
|
||||
await waitFor(scenario.exported);
|
||||
const elements = h.elements();
|
||||
assert.equal(elements.filter((element) => element.text === "用 05:00" && element.tagName === "BUTTON").length, 1);
|
||||
assert.equal(elements.some((element) => /开始答题|先到这里|下一题|都没有|记不清|换另一侧|更像哪一种|你提到的事/.test(element.text)), false);
|
||||
assert.equal(elements.filter((element) => element.tagName === "DT").length, 6);
|
||||
assert.equal(elements.some((element) => element.getAttribute("role") === "radiogroup"), scenario.adjustable);
|
||||
if (scenario.adjustable) {
|
||||
assert.ok(elements.some((element) => element.text.includes("前后 15 分钟试算里没有变化")));
|
||||
await h.event(elements.find((element) => element.getAttribute("role") === "radio" && element.text === "30 分钟")!);
|
||||
await waitFor(scenario.exported);
|
||||
assert.ok(h.elements().some((element) => element.text.includes("在你填的范围里没有换座")));
|
||||
}
|
||||
await h.event(h.elements().find((element) => element.tagName === "BUTTON" && element.text === "用 05:00")!);
|
||||
await waitFor("当前使用");
|
||||
assert.deepEqual(submitted, { subjectId: "self", adaptive: true, mode: "choice",
|
||||
...(scenario.adjustable ? { window: fixtures.declared.window, rangeChosen: true } : {}),
|
||||
});
|
||||
assert.equal(packets, scenario.adjustable ? 2 : 1);
|
||||
assert.equal(h.errors.length, 0);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
await h.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("default packet and adoption use the native completion without evidence and preserve certain and wide ranges", async () => {
|
||||
const { readDefaultChartChoice, adoptDefaultChartChoice } = await import("../src/lib/chart-choice/server");
|
||||
const profile = {
|
||||
name: "虚构人物", birth_date: "1997-08-08", reported_birth_time: "05:00", birth_time_source: "approximate",
|
||||
birth_time_status: "reported", declared_window_start: "04:30", declared_window_end: "05:30",
|
||||
uncertainty_before_minutes: 30, uncertainty_after_minutes: 30, birth_time_uncertainty_known: true,
|
||||
latitude: 36.42, longitude: 114.21, timezone_offset: 8, timezone_id: "Asia/Shanghai", birth_place_label: "虚构市",
|
||||
};
|
||||
for (const { row, packet, rule, request, checkWide } of [
|
||||
{ row: profile, packet: fixtures.declared, rule: "reported_time_default", request: {}, checkWide: false },
|
||||
{ row: { ...profile, declared_window_start: null, declared_window_end: null, uncertainty_before_minutes: 0,
|
||||
uncertainty_after_minutes: 0, birth_time_uncertainty_known: false, birth_time_source: "family_exact" },
|
||||
packet: fixtures.declared, rule: "reported_time_default",
|
||||
request: { window: fixtures.declared.window, rangeChosen: true }, checkWide: false },
|
||||
{ row: { ...profile, reported_birth_time: null, birth_time_source: "period_only", birth_time_precision_kind: "period",
|
||||
declared_window_start: "02:00", declared_window_end: "08:00", uncertainty_before_minutes: null, uncertainty_after_minutes: null },
|
||||
packet: fixtures.wideMidpoint, rule: "window_midpoint_default", request: {}, checkWide: true },
|
||||
]) {
|
||||
const caller = { from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: row, error: null }) }) }) }) };
|
||||
const fetchEngine: typeof fetch = async (url, init) => {
|
||||
assert.match(String(url), /\/api\/chart_choice\/decide$/);
|
||||
const body = JSON.parse(String(init?.body));
|
||||
assert.equal(body.adaptive, true);
|
||||
assert.equal(body.stop, true);
|
||||
assert.equal(body.chart_answers ?? body.answers ?? body.resolved, undefined);
|
||||
if (checkWide) {
|
||||
assert.equal(body.start_at, "1997-08-08T04:00");
|
||||
assert.equal(body.end_at, "1997-08-08T06:00");
|
||||
assert.deepEqual(body.reported_default_window, packet.decision.reported_choice!.segment);
|
||||
}
|
||||
return Response.json(packet);
|
||||
};
|
||||
const result = await readDefaultChartChoice(caller, "fixture", request, fetchEngine);
|
||||
assert.equal(result.status, "complete");
|
||||
assert.equal(result.reported_known, !checkWide);
|
||||
assert.deepEqual(result.window, packet.decision.reported_choice!.segment);
|
||||
const admin = { rpc: async (fn: string, args: Record<string, unknown>) => {
|
||||
assert.equal(fn, "chart_choice_adopt_v4");
|
||||
assert.equal((args.p_provenance as { source: string }).source, rule);
|
||||
assert.equal(args.p_window_start, request.rangeChosen ? "04:30" : null);
|
||||
assert.equal(args.p_window_end, request.rangeChosen ? "05:30" : null);
|
||||
assert.equal(args.p_time, "05:00:00");
|
||||
return { data: {}, error: null };
|
||||
} };
|
||||
assert.equal((await adoptDefaultChartChoice(caller, admin, "fixture", request, fetchEngine)).decision.rule, rule);
|
||||
}
|
||||
const { legacy, default: native } = fixtures.certain;
|
||||
const caller = { from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: {
|
||||
...profile, reported_birth_time: legacy.reported_at.slice(11), declared_window_start: legacy.window.start_at.slice(11),
|
||||
declared_window_end: legacy.window.end_at.slice(11), uncertainty_before_minutes: 1, uncertainty_after_minutes: 1,
|
||||
}, error: null }) }) }) }) };
|
||||
const paths: string[] = [];
|
||||
const certain = await readDefaultChartChoice(caller, "fixture", {}, async (url) => {
|
||||
const path = String(url).split("/").at(-1)!;
|
||||
paths.push(path);
|
||||
return Response.json(path === "packet" ? legacy : native);
|
||||
});
|
||||
assert.equal(certain.status, "certain");
|
||||
assert.deepEqual(paths, ["decide", "packet"]);
|
||||
assert.equal(certain.saved_at, legacy.saved_at);
|
||||
});
|
||||
|
||||
test("chart choice API rejects questionnaire, resume, side and evidence inputs with an explicit 400", () => {
|
||||
const program = `
|
||||
import assert from "node:assert/strict";
|
||||
import { mock } from "node:test";
|
||||
mock.module("server-only", { namedExports: {} });
|
||||
mock.module("@/lib/supabase/server", { namedExports: {
|
||||
createServerSupabaseClient: async () => ({auth:{getUser:async () => ({data:{user:{id:"fixture"}}})}}),
|
||||
}});
|
||||
mock.module("@/lib/supabase/admin", { namedExports: { createAdminSupabaseClient: () => { throw new Error("adoption must not run"); } } });
|
||||
mock.module("@/lib/product-access", { namedExports: { isProductEnabled: async () => true } });
|
||||
mock.module("@/lib/request-rate-limit", { namedExports: { consumeUserRequestRateLimit: () => ({ok:true}) } });
|
||||
const packet = (await import("./src/app/api/chart-choice/packet/route.ts")).POST;
|
||||
const adopt = (await import("./src/app/api/chart-choice/adopt/route.ts")).POST;
|
||||
const astrologer = (await import("./src/app/api/chart-choice/astrologer/route.ts")).POST;
|
||||
const run = async (handler, body) => {
|
||||
const response = await handler(new Request("http://localhost/api/chart-choice", {method:"POST",body:JSON.stringify(body)}));
|
||||
assert.equal(response.status, 400, JSON.stringify(body));
|
||||
assert.equal((await response.json()).code, "questionnaire_paused");
|
||||
};
|
||||
for (const handler of [packet, adopt]) {
|
||||
for (const body of [{questionnaire:true}, {chart_answers:{}}, {answers:{}}, {overrideSide:"before"},
|
||||
{resolved:[{}]}, {resumeRecordId:"old-record"}, {event_answers:{}}, {appearance:"before"}, {birthmarks:[]},
|
||||
{readBothAll:true}]) await run(handler, body);
|
||||
}
|
||||
await run(astrologer, {input:{}});
|
||||
`;
|
||||
const run = spawnSync(process.execPath, ["--experimental-test-module-mocks", "--import", "tsx", "--input-type=module", "--eval", program], {
|
||||
cwd: new URL("..", import.meta.url), encoding: "utf8", timeout: 15_000,
|
||||
});
|
||||
assert.equal(run.status, 0, run.stderr || run.stdout);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user