diff --git a/frontend/src/app/birth-time-choice.css b/frontend/src/app/birth-time-choice.css new file mode 100644 index 00000000..7d1626e3 --- /dev/null +++ b/frontend/src/app/birth-time-choice.css @@ -0,0 +1,26 @@ +.birth-time-question-progress { flex-wrap: wrap; gap: var(--space-2) var(--space-4); line-height: 1.5; } +.birth-time-question-progress b { color: var(--color-ink); font-weight: 600; } +.birth-time-choice-surface { min-width: 0; display: grid; gap: var(--space-4); } +.birth-time-choice-question { min-width: 0; display: grid; gap: var(--space-3); margin: 0; padding: 0; border: 0; } +.birth-time-choice-question legend { width: 100%; margin: 0 0 var(--space-1); color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; line-height: 1.55; text-wrap: pretty; word-break: auto-phrase; } +.birth-time-primary-choices { display: grid; gap: var(--space-2); } +.birth-time-special-choices, .birth-time-reframe-actions, .birth-time-journey-controls { display: flex; flex-wrap: wrap; gap: var(--space-2); } +.birth-time-choice-option { min-height: 52px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); cursor: pointer; font-size: var(--type-body-sm); line-height: 1.5; text-align: left; text-wrap: pretty; word-break: auto-phrase; transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out; } +.birth-time-choice-option.is-primary { width: 100%; padding: var(--space-3) var(--space-4); } +.birth-time-choice-option.is-secondary { min-height: 44px; flex: 1 1 0; padding: var(--space-2) var(--space-4); color: var(--color-ink-secondary); text-align: center; } +.birth-time-choice-option:hover:not(:disabled) { border-color: var(--color-border-strong); background: var(--color-canvas-soft); } +.birth-time-choice-option:active:not(:disabled) { transform: translateY(1px); } +.birth-time-choice-option[data-selected="true"] { border-color: var(--color-action); background: var(--color-action-soft); color: var(--color-ink); } +.birth-time-choice-option:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; } +.birth-time-choice-question:disabled .birth-time-choice-option { cursor: wait; opacity: .62; } +.birth-time-choice-pending { margin: 0; color: var(--color-action); font-size: var(--type-caption); } +.birth-time-unmatched-note { display: grid; gap: var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 600; } +.birth-time-unmatched-note textarea { min-height: var(--space-24); resize: vertical; color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 400; line-height: 1.55; } +.birth-time-reframe-actions, .birth-time-journey-controls { justify-content: flex-end; } +.birth-time-new-assessment { display: grid; justify-items: start; gap: var(--space-2); } +.birth-time-new-assessment small { color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.5; } + +@media (max-width: 767px) { + .birth-time-special-choices, .birth-time-reframe-actions, .birth-time-journey-controls { display: grid; grid-template-columns: 1fr; } + .birth-time-choice-option.is-secondary, .birth-time-reframe-actions > button, .birth-time-journey-controls > button { width: 100%; } +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 67e6e9ac..fae44c11 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Script from "next/script"; import "./globals.css"; +import "./birth-time-choice.css"; export const metadata: Metadata = { title: "Jyotisha · 印度占星", diff --git a/frontend/src/components/birth-time-candidate-result.tsx b/frontend/src/components/birth-time-candidate-result.tsx index eebfa190..036e3eb2 100644 --- a/frontend/src/components/birth-time-candidate-result.tsx +++ b/frontend/src/components/birth-time-candidate-result.tsx @@ -18,13 +18,14 @@ const confidenceLabels = { export function BirthTimeCandidateResult({ journey, controller }: CandidateResultProps) { const result = journey.candidateResult; const action = journey.nextAction; + const dynamic = journey.journeyProtocol === "dynamic-choice-v2"; const terminalPath = guidedTerminalPath(journey); if (!result && action.kind === "present_low_result") { return (
目前证据不足,只能保留已填报的时间范围 {journey.snapshot.reportedRange.label}。
+目前没有足够的新信息继续稳定缩小范围,本次评估已结束并保存当前候选范围。
系统不会选择或应用未经证据支持的具体分钟,当前排盘使用时间保持不变。
- {terminalPath && } + {terminalPath &&候选仍然并列或缺少必要计算层,系统不会选择具体分钟。
@@ -51,11 +52,17 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul {action.kind === "present_low_result" && (本轮校正已安全结束,只保留候选范围,当前排盘使用时间保持不变。
- {terminalPath && } +{dynamic ? "目前没有足够的新信息继续稳定缩小范围,本次评估已结束并保存当前候选范围。" : "本轮校正已安全结束,只保留候选范围,当前排盘使用时间保持不变。"}
+ {terminalPath &&已形成较窄的候选范围,本次评估已结束;它不会自动改动当前排盘使用时间。
+可以保存候选时间范围,代表时间不会用于排盘。
正在缩小候选范围…
} +正在换一道更合适的问题…
} +{props.error}
} + > + ); +} diff --git a/frontend/src/components/birth-time-legacy-rectification.tsx b/frontend/src/components/birth-time-legacy-rectification.tsx new file mode 100644 index 00000000..e6e1d56d --- /dev/null +++ b/frontend/src/components/birth-time-legacy-rectification.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { BirthTimeCandidateResult } from "@/components/birth-time-candidate-result"; +import { BirthTimeEvidenceDraftCard } from "@/components/birth-time-evidence-draft-card"; +import { BirthTimeGuideTurn } from "@/components/birth-time-guide-turn"; +import type { BirthTimeGuidedController } from "@/hooks/use-birth-time-guided-journey"; +import { assistantIntentCopy } from "@/lib/birth-time-intake-model"; +import type { LegacyJourneyClientResponse } from "@/lib/birth-time-journey-response-schema"; +import { guidedTurnIdentity } from "@/lib/birth-time-guided-turn-identity"; +import type { NextAction } from "@/lib/birth-time-journey-turn"; + +type LegacyRectificationProps = { + readonly journey: LegacyJourneyClientResponse; + readonly controller: BirthTimeGuidedController; + readonly externalError: string; +}; + +function actionHeading(action: NextAction): { readonly title: string; readonly badge: string } { + switch (action.kind) { + case "ask_baseline_evidence": return { title: "回想一条关键经历", badge: "基础证据" }; + case "ask_adaptive_evidence": return { title: "继续缩小候选范围", badge: "自适应校正" }; + case "review_evidence_draft": return { title: "确认经历草稿", badge: "待确认" }; + case "score_pending": return { title: "正在比较候选时间", badge: "评分中" }; + case "retry_scoring": return { title: "评分需要重试", badge: "可恢复" }; + case "present_low_result": return { title: "候选范围已保存", badge: "证据不足" }; + case "present_medium_result": return { title: "候选范围已形成", badge: "中等置信" }; + case "candidate_saved": return { title: "候选范围已保存", badge: "已保存" }; + case "request_candidate_confirmation": return { title: "确认候选时间", badge: "待确认" }; + case "ready": return { title: "排盘时间已更新", badge: "已完成" }; + case "paused": return { title: "校正已暂停", badge: "已保存" }; + } +} + +export function BirthTimeLegacyRectification(props: LegacyRectificationProps) { + const action = props.journey.nextAction; + const heading = actionHeading(action); + const asksQuestion = action.kind === "ask_baseline_evidence" || action.kind === "ask_adaptive_evidence"; + const showsCandidate = action.kind === "present_low_result" + || action.kind === "present_medium_result" + || action.kind === "candidate_saved" + || action.kind === "request_candidate_confirmation" + || action.kind === "ready"; + const error = props.controller.error || props.externalError; + + return ( +{assistantIntentCopy(props.journey.snapshot.assistantIntent)}
+ {asksQuestion && ( +正在使用已确认的关键经历评分…
} + {action.kind === "retry_scoring" &&{error}
} +{dynamicStatus(props.journey.nextAction)}
- {generationFailed &&{props.controller.error || props.externalError}
} -{assistantIntentCopy(props.journey.snapshot.assistantIntent)}
- - {asksQuestion && ( -{statusCopy(action)}
+ {action.kind === "ask_dynamic_choice" && ( +完成后会自动显示下一题或候选结果,无需再点击比较按钮。
- {props.controller.pollRecoverable &&已确认的关键经历仍然安全保存,可以重试同一评分任务。
-当前问题和证据已保存,可以现在继续,也可以稍后回来。
-{error}
} + {error && action.kind !== "ask_dynamic_choice" && action.kind !== "clarify_unmatched_answer" &&{error}
} ); } diff --git a/frontend/src/hooks/use-birth-time-guided-journey.ts b/frontend/src/hooks/use-birth-time-guided-journey.ts index f78a4463..211ddcf3 100644 --- a/frontend/src/hooks/use-birth-time-guided-journey.ts +++ b/frontend/src/hooks/use-birth-time-guided-journey.ts @@ -27,6 +27,8 @@ import { } from "@/lib/birth-time-guided-effect-coordinator"; import type { EvidenceDatePrecision } from "@/lib/birth-time-question-planner"; import { useBirthTimeAutomaticJourneyEffects } from "@/hooks/use-birth-time-automatic-journey-effects"; +import { advanceDynamicBirthTimePreview } from "@/lib/birth-time-dynamic-preview"; +import type { DynamicPreviewCommand } from "@/lib/birth-time-dynamic-preview"; type GuidedJourneyInput = { readonly journey: JourneyClientResponse | null; @@ -57,6 +59,12 @@ export type BirthTimeGuidedController = { readonly retryQuestionGeneration: () => void; }; +function previewAction(turn: JourneyClientResponse, command: DynamicPreviewCommand) { + return turn.journeyProtocol === "dynamic-choice-v2" + ? advanceDynamicBirthTimePreview(turn, command) + : turn; +} + export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeGuidedController { const { journey, onJourney, onReady, onEditBirthTimeDetails, preview } = input; const latest = useRef(journey); @@ -165,11 +173,11 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG turn, "skip_evidence_question", [], (actionId) => skipBirthTimeEvidenceQuestion(turn.caseId, actionId, turn.turnVersion), )); - const pause = () => operate((turn) => preview ? Promise.resolve(turn) : stableAction( + const pause = () => operate((turn) => preview ? Promise.resolve(previewAction(turn, { kind: "pause" })) : stableAction( turn, "pause_rectification", [], (actionId) => pauseBirthTimeRectification(turn.caseId, actionId, turn.turnVersion), )); - const resume = () => operate((turn) => preview ? Promise.resolve(turn) : resumeBirthTimeJourney(turn.caseId)); + const resume = () => operate((turn) => preview ? Promise.resolve(previewAction(turn, { kind: "resume" })) : resumeBirthTimeJourney(turn.caseId)); const acknowledgeReady = () => { if (journey?.nextAction.kind === "ready") onReady(journey); }; @@ -193,8 +201,9 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG (actionId) => confirmGuidedBirthTimeCandidate({ caseId: turn.caseId, actionId, turnVersion: turn.turnVersion, resultId, time }), )); const selectOption = (optionId: string) => operate((turn) => { - if (preview || turn.journeyProtocol !== "dynamic-choice-v2" + if (turn.journeyProtocol !== "dynamic-choice-v2" || turn.nextAction.kind !== "ask_dynamic_choice") return Promise.resolve(turn); + if (preview) return Promise.resolve(previewAction(turn, { kind: "select", optionId })); const questionId = turn.nextAction.question.questionId; return stableAction(turn, "answer_dynamic_choice", [questionId, optionId], (actionId) => ( answerDynamicBirthTimeChoice({ @@ -203,8 +212,9 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG )); }); const submitUnmatchedContext = (note: string) => operate((turn) => { - if (preview || turn.journeyProtocol !== "dynamic-choice-v2" + if (turn.journeyProtocol !== "dynamic-choice-v2" || turn.nextAction.kind !== "clarify_unmatched_answer") return Promise.resolve(turn); + if (preview) return Promise.resolve(previewAction(turn, { kind: "reframe" })); const questionId = turn.nextAction.questionId; return stableAction(turn, "reframe_unmatched", [questionId, note.trim()], (actionId) => ( reframeUnmatchedBirthTimeAnswer({ @@ -213,7 +223,7 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG )); }); const finish = () => operate((turn) => preview - ? Promise.resolve(turn) + ? Promise.resolve(previewAction(turn, { kind: "finish" })) : stableAction(turn, "finish_rectification", [], (actionId) => ( finishBirthTimeRectification(turn.caseId, actionId, turn.turnVersion) ))); diff --git a/frontend/src/lib/birth-time-choice-question-model.ts b/frontend/src/lib/birth-time-choice-question-model.ts new file mode 100644 index 00000000..29355147 --- /dev/null +++ b/frontend/src/lib/birth-time-choice-question-model.ts @@ -0,0 +1,50 @@ +import type { + PublicDynamicChoiceQuestion, + TimeRange, +} from "./birth-time-dynamic-choice.ts"; + +type ChoiceOption = PublicDynamicChoiceQuestion["options"][number]; + +export type ChoiceQuestionGroups = { + readonly primary: readonly ChoiceOption[]; + readonly unknown: ChoiceOption; + readonly unmatched: ChoiceOption; +}; + +export type ChoiceSelectionIntent = { + readonly kind: "submit"; + readonly optionId: string; + readonly effective: boolean; +}; + +export class ChoiceQuestionModelError extends Error { + readonly name = "ChoiceQuestionModelError"; +} + +export function choiceQuestionGroups( + question: PublicDynamicChoiceQuestion, +): ChoiceQuestionGroups { + const primary = question.options.filter((option) => option.kind === "primary"); + const unknown = question.options.find((option) => option.kind === "unknown"); + const unmatched = question.options.find((option) => option.kind === "unmatched"); + if (!unknown || !unmatched) { + throw new ChoiceQuestionModelError("Dynamic question is missing a reserved choice"); + } + return { primary, unknown, unmatched }; +} + +export function choiceSelectionIntent(option: ChoiceOption): ChoiceSelectionIntent { + return { + kind: "submit", + optionId: option.optionId, + effective: option.kind === "primary", + }; +} + +export function normalizeUnmatchedNote(note: string): string { + return Array.from(note.trim()).slice(0, 240).join(""); +} + +export function rangeLabel(range: TimeRange): string { + return `${range.startTime}—${range.endTime}`; +} diff --git a/frontend/src/lib/birth-time-dynamic-preview.ts b/frontend/src/lib/birth-time-dynamic-preview.ts new file mode 100644 index 00000000..16787090 --- /dev/null +++ b/frontend/src/lib/birth-time-dynamic-preview.ts @@ -0,0 +1,162 @@ +import { parseJourneyResponse } from "./birth-time-journey-client.ts"; +import type { DynamicJourneyClientResponse } from "./birth-time-journey-response-schema.ts"; +import type { DynamicNextAction } from "./birth-time-journey-turn-protocol.ts"; +import type { CandidateResult } from "./birth-time-evidence.ts"; + +const caseId = "7299894c-10a8-4b45-91d1-339007282c50"; +const resultId = "345087cc-7e7f-4b37-90e5-f0c0a6e5b7a7"; +const jobId = "5da741ba-4713-4c27-9cf4-a85e52dc4658"; + +class DynamicPreviewInvariantError extends Error { + readonly name = "DynamicPreviewInvariantError"; +} + +const questions = [ + { + questionId: "education-shift", + prompt: "哪段时间更接近一次明显的升学、转学或学习方向变化?", + options: [ + { optionId: "earlier", label: "更接近 2007—2009 年", kind: "primary" }, + { optionId: "later", label: "更接近 2010—2012 年", kind: "primary" }, + { optionId: "unknown", label: "不记得", kind: "unknown" }, + { optionId: "unmatched", label: "都不符合", kind: "unmatched" }, + ], + }, + { + questionId: "relocation-shift", + prompt: "第一次明显离开熟悉环境,更接近下面哪个阶段?", + options: [ + { optionId: "school", label: "求学阶段", kind: "primary" }, + { optionId: "work", label: "开始工作之后", kind: "primary" }, + { optionId: "unknown-2", label: "不记得", kind: "unknown" }, + { optionId: "unmatched-2", label: "都不符合", kind: "unmatched" }, + ], + }, +] as const; + +const candidate = { + resultId, + confidence: "medium", + canApply: false, + winningSegment: { + startTime: "05:38", + endTime: "05:49", + representativeTime: "05:43", + widthMinutes: 12, + }, + eventCount: 3, + domainCount: 0, + topScore: 14.4, + secondScore: 12.1, + marginPercent: 15.97, + reasons: [], + evidence: [], + algorithmVersion: "birth-time-dynamic-scoring-v2", +} satisfies CandidateResult; + +const highCandidate = { + ...candidate, + confidence: "high", + canApply: true, + winningSegment: { ...candidate.winningSegment, widthMinutes: 5 }, + eventCount: 4, + domainCount: 3, + marginPercent: 24, +} satisfies CandidateResult; + +function response(input: { + readonly nextAction: DynamicNextAction; + readonly phase: "question" | "clarification" | "scoring" | "result" | "ready" | "paused"; + readonly turnVersion?: number; + readonly answeredCount?: number; + readonly effectiveAnswerCount?: number; + readonly candidateResult?: CandidateResult | null; + readonly snapshotState?: "rectifying" | "candidate" | "confirming" | "ready"; + readonly activeTime?: string | null; +}): DynamicJourneyClientResponse { + const parsed = parseJourneyResponse({ + caseId, + snapshot: { + state: input.snapshotState ?? "rectifying", + assistantIntent: "start_standard_rectification", + input: input.phase === "ready" ? "none" : input.phase === "result" ? "candidate_actions" : "rectification_questions", + route: input.phase === "ready" ? "direct_chart" : "rectification", + confidence: input.candidateResult?.confidence ?? null, + canApply: input.nextAction.kind === "request_candidate_confirmation", + activeTime: input.activeTime ?? null, + reportedRange: { label: "04:00—07:59", startTime: "04:00", endTime: "07:59" }, + }, + questionnaire: null, + scoring: null, + answers: {}, + lifeEvents: [], + candidateResult: input.candidateResult ?? null, + journeyProtocol: "dynamic-choice-v2", + turnVersion: input.turnVersion ?? 1, + nextAction: input.nextAction, + progress: { + phase: input.phase, + answeredCount: input.answeredCount ?? 0, + effectiveAnswerCount: input.effectiveAnswerCount ?? 0, + currentRange: { startTime: "04:00", endTime: "07:59" }, + previousRange: null, + plateauCount: 0, + }, + permissions: { canConfirmCandidate: input.nextAction.kind === "request_candidate_confirmation" }, + evidenceDraft: null, + }); + if (parsed.journeyProtocol !== "dynamic-choice-v2") { + throw new DynamicPreviewInvariantError("Dynamic preview parsed as legacy journey"); + } + return parsed; +} + +export function dynamicBirthTimePreview( + state: "generating" | "question-retry" | "question" | "clarification" | "scoring" | "scoring-retry" | "low" | "medium" | "confirmation" | "ready" | "paused" = "question", +): DynamicJourneyClientResponse { + switch (state) { + case "generating": return response({ nextAction: { kind: "generate_dynamic_question" }, phase: "question" }); + case "question-retry": return response({ nextAction: { kind: "retry_question_generation" }, phase: "question" }); + case "question": return response({ nextAction: { kind: "ask_dynamic_choice", question: questions[0] }, phase: "question" }); + case "clarification": return response({ nextAction: { kind: "clarify_unmatched_answer", questionId: questions[0].questionId }, phase: "clarification", answeredCount: 1, turnVersion: 2 }); + case "scoring": return response({ nextAction: { kind: "score_pending", jobId }, phase: "scoring", answeredCount: 1, effectiveAnswerCount: 1, turnVersion: 2 }); + case "scoring-retry": return response({ nextAction: { kind: "retry_scoring", jobId }, phase: "scoring", answeredCount: 1, effectiveAnswerCount: 1, turnVersion: 2 }); + case "low": return response({ nextAction: { kind: "present_low_result", resultId: null }, phase: "result", answeredCount: 2, effectiveAnswerCount: 1, snapshotState: "candidate", turnVersion: 3 }); + case "medium": return response({ nextAction: { kind: "present_medium_result", resultId }, phase: "result", answeredCount: 3, effectiveAnswerCount: 2, candidateResult: candidate, snapshotState: "candidate", turnVersion: 4 }); + case "confirmation": return response({ nextAction: { kind: "request_candidate_confirmation", resultId }, phase: "result", answeredCount: 4, effectiveAnswerCount: 4, candidateResult: highCandidate, snapshotState: "confirming", turnVersion: 5 }); + case "ready": return response({ nextAction: { kind: "ready", activeTime: "05:43" }, phase: "ready", answeredCount: 4, effectiveAnswerCount: 4, candidateResult: highCandidate, snapshotState: "ready", activeTime: "05:43", turnVersion: 6 }); + case "paused": return response({ nextAction: { kind: "paused" }, phase: "paused", turnVersion: 2 }); + } +} + +export type DynamicPreviewCommand = + | { readonly kind: "select"; readonly optionId: string } + | { readonly kind: "reframe" } + | { readonly kind: "finish" } + | { readonly kind: "pause" } + | { readonly kind: "resume" } + | { readonly kind: "retry_scoring" }; + +export function advanceDynamicBirthTimePreview( + turn: DynamicJourneyClientResponse, + command: DynamicPreviewCommand, +): DynamicJourneyClientResponse { + switch (command.kind) { + case "select": { + const action = turn.nextAction; + if (action.kind !== "ask_dynamic_choice") return turn; + const option = action.question.options.find((item) => item.optionId === command.optionId); + if (!option) return turn; + switch (option.kind) { + case "unmatched": return dynamicBirthTimePreview("clarification"); + case "primary": return dynamicBirthTimePreview("scoring"); + case "unknown": return response({ nextAction: { kind: "ask_dynamic_choice", question: questions[1] }, phase: "question", answeredCount: 1, turnVersion: 2 }); + } + } + case "reframe": return response({ nextAction: { kind: "ask_dynamic_choice", question: questions[1] }, phase: "question", answeredCount: 1, turnVersion: 3 }); + case "finish": return dynamicBirthTimePreview("low"); + case "pause": return dynamicBirthTimePreview("paused"); + case "resume": return dynamicBirthTimePreview("question"); + case "retry_scoring": return dynamicBirthTimePreview("medium"); + } +} diff --git a/frontend/src/lib/birth-time-guided-preview.ts b/frontend/src/lib/birth-time-guided-preview.ts index c7c227a7..86e453fc 100644 --- a/frontend/src/lib/birth-time-guided-preview.ts +++ b/frontend/src/lib/birth-time-guided-preview.ts @@ -1,5 +1,6 @@ import { parseJourneyResponse } from "./birth-time-journey-client.ts"; import type { JourneyClientResponse } from "./birth-time-journey-client.ts"; +import { dynamicBirthTimePreview } from "./birth-time-dynamic-preview.ts"; const caseId = "7299894c-10a8-4b45-91d1-339007282c50"; const resultId = "345087cc-7e7f-4b37-90e5-f0c0a6e5b7a7"; @@ -89,6 +90,7 @@ export function isGuidedBirthTimePreview(mode: string): boolean { } export function guidedBirthTimePreview(mode: string): JourneyClientResponse { + if (mode === "birth-time-rectification") return dynamicBirthTimePreview(); if (mode === "birth-time-rectification-draft" || mode === "birth-time-rectification-events") { return response({ snapshot: { ...response().snapshot, input: "life_events", assistantIntent: "collect_dated_life_events" }, diff --git a/frontend/tests/birth-time-choice-question.test.ts b/frontend/tests/birth-time-choice-question.test.ts new file mode 100644 index 00000000..907ac2b2 --- /dev/null +++ b/frontend/tests/birth-time-choice-question.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { + choiceQuestionGroups, + choiceSelectionIntent, + normalizeUnmatchedNote, +} from "../src/lib/birth-time-choice-question-model.ts"; +import type { PublicDynamicChoiceQuestion } from "../src/lib/birth-time-dynamic-choice.ts"; +import { + advanceDynamicBirthTimePreview, + dynamicBirthTimePreview, +} from "../src/lib/birth-time-dynamic-preview.ts"; + +const question: PublicDynamicChoiceQuestion = { + questionId: "education-shift", + prompt: "哪段时间更接近一次明显的学习方向变化?", + options: [ + { optionId: "early", label: "更接近 2007—2009 年", kind: "primary" }, + { optionId: "late", label: "更接近 2010—2012 年", kind: "primary" }, + { optionId: "unknown", label: "不记得", kind: "unknown" }, + { optionId: "unmatched", label: "都不符合", kind: "unmatched" }, + ], +}; + +test("click intent submits primary and unknown options immediately", () => { + const groups = choiceQuestionGroups(question); + + assert.deepEqual(groups.primary.map((option) => option.optionId), ["early", "late"]); + assert.deepEqual(choiceSelectionIntent(groups.primary[0]), { + kind: "submit", optionId: "early", effective: true, + }); + assert.deepEqual(choiceSelectionIntent(groups.unknown), { + kind: "submit", optionId: "unknown", effective: false, + }); +}); + +test("unmatched requests clarification without creating scoring evidence", () => { + const groups = choiceQuestionGroups(question); + + assert.deepEqual(choiceSelectionIntent(groups.unmatched), { + kind: "submit", optionId: "unmatched", effective: false, + }); + assert.equal(normalizeUnmatchedNote(" 大约是在搬家之后 "), "大约是在搬家之后"); + assert.equal(normalizeUnmatchedNote("甲".repeat(260)).length, 240); +}); + +test("the rendered component keeps one-click and optional-note semantics", () => { + const source = readFileSync( + new URL("../src/components/birth-time-choice-question.tsx", import.meta.url), + "utf8", + ); + + assert.match(source, /