feat: add click-first birth time questions

This commit is contained in:
Jesse_Chen
2026-07-19 10:39:47 +08:00
parent db5c12f67f
commit c74c0bd70f
13 changed files with 664 additions and 105 deletions
@@ -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, /<fieldset/);
assert.match(source, /<legend/);
assert.match(source, /onSelect\(intent\.optionId\)/);
assert.match(source, /maxLength=\{240\}/);
assert.doesNotMatch(source, /整理为经历草稿|记得的精度|发生时间|第.*\/.*轮/);
assert.doesNotMatch(source, /required/);
});
test("preview choices expose real local interaction states without authentication", () => {
const start = dynamicBirthTimePreview();
const scoring = advanceDynamicBirthTimePreview(start, { kind: "select", optionId: "earlier" });
const clarification = advanceDynamicBirthTimePreview(start, { kind: "select", optionId: "unmatched" });
const reframed = advanceDynamicBirthTimePreview(clarification, { kind: "reframe" });
const terminal = advanceDynamicBirthTimePreview(reframed, { kind: "finish" });
assert.equal(scoring.nextAction.kind, "score_pending");
assert.equal(clarification.nextAction.kind, "clarify_unmatched_answer");
assert.equal(reframed.nextAction.kind, "ask_dynamic_choice");
assert.equal(terminal.nextAction.kind, "present_low_result");
});
test("dynamic previews cover every visible result and recovery state", () => {
const expected = new Map([
["generating", "generate_dynamic_question"],
["question-retry", "retry_question_generation"],
["question", "ask_dynamic_choice"],
["clarification", "clarify_unmatched_answer"],
["scoring", "score_pending"],
["scoring-retry", "retry_scoring"],
["low", "present_low_result"],
["medium", "present_medium_result"],
["confirmation", "request_candidate_confirmation"],
["ready", "ready"],
["paused", "paused"],
] as const);
for (const [state, action] of expected) {
assert.equal(dynamicBirthTimePreview(state).nextAction.kind, action);
}
});
+12 -1
View File
@@ -5,6 +5,8 @@ import { guidedTurnIdentity } from "../src/lib/birth-time-guided-turn-identity.t
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
const rectificationSource = read("../src/components/birth-time-rectification.tsx");
const legacyRectificationSource = read("../src/components/birth-time-legacy-rectification.tsx");
const choiceSource = read("../src/components/birth-time-choice-question.tsx");
const turnSource = read("../src/components/birth-time-guide-turn.tsx");
const draftSource = read("../src/components/birth-time-evidence-draft-card.tsx");
const candidateSource = read("../src/components/birth-time-candidate-result.tsx");
@@ -24,7 +26,8 @@ test("each persisted question has a stable remount identity so skipped input can
guidedTurnIdentity(3, "education_entry"),
guidedTurnIdentity(4, "relationship_entry"),
);
assert.match(rectificationSource, /key=\{guidedTurnIdentity\(props\.journey\.turnVersion, action\.question\.questionId\)\}/);
assert.match(legacyRectificationSource, /key=\{guidedTurnIdentity\(props\.journey\.turnVersion, action\.question\.questionId\)\}/);
assert.match(rectificationSource, /key=\{`\$\{props\.journey\.turnVersion\}:\$\{action\.question\.questionId\}`\}/);
});
test("draft review is explicit, domain locked, and incomplete confirmation stays disabled", () => {
@@ -64,4 +67,12 @@ test("guided controls expose live status and minimum target classes", () => {
assert.match(draftSource, /role=["']alert/);
assert.match(turnSource, /birth-time-guided-action/);
assert.match(candidateSource, /birth-time-guided-action/);
assert.match(choiceSource, /aria-busy=\{props\.pending\}/);
assert.match(choiceSource, /birth-time-guided-action/);
});
test("the active v2 surface cannot render the legacy prose and date draft flow", () => {
assert.doesNotMatch(rectificationSource, /BirthTimeGuideTurn|BirthTimeEvidenceDraftCard|<textarea/);
assert.match(rectificationSource, /<BirthTimeChoiceQuestion/);
assert.match(rectificationSource, /<BirthTimeUnmatchedClarification/);
});
@@ -10,6 +10,10 @@ const source = readFileSync(
new URL("../src/components/birth-time-rectification.tsx", import.meta.url),
"utf8",
);
const legacySource = readFileSync(
new URL("../src/components/birth-time-legacy-rectification.tsx", import.meta.url),
"utf8",
);
const pageSource = readFileSync(
new URL("../src/app/page.tsx", import.meta.url),
"utf8",
@@ -35,7 +39,7 @@ test("rectification UI is driven only by the persisted guided action", () => {
test("development previews cover every guided state with legal persisted actions", () => {
const expectedActions = new Map([
["birth-time-rectification", "ask_baseline_evidence"],
["birth-time-rectification", "ask_dynamic_choice"],
["birth-time-rectification-draft", "review_evidence_draft"],
["birth-time-rectification-score-pending", "score_pending"],
["birth-time-rectification-retry", "retry_scoring"],
@@ -50,6 +54,7 @@ test("development previews cover every guided state with legal persisted actions
assert.equal(isGuidedBirthTimePreview(mode), true);
assert.equal(guidedBirthTimePreview(mode).nextAction.kind, action);
}
assert.equal(guidedBirthTimePreview("birth-time-rectification").journeyProtocol, "dynamic-choice-v2");
assert.match(pageSource, /guidedBirthTimePreview\(previewMode\)/);
});
@@ -90,8 +95,10 @@ test("declared-time edits preserve the current journey until the revised profile
});
test("rectification renders draft review and candidates only from nextAction", () => {
assert.match(source, /action\.kind === "review_evidence_draft"/);
assert.match(source, /<BirthTimeEvidenceDraftCard/);
assert.match(legacySource, /action\.kind === "review_evidence_draft"/);
assert.match(legacySource, /<BirthTimeEvidenceDraftCard/);
assert.doesNotMatch(source, /BirthTimeEvidenceDraftCard|BirthTimeGuideTurn/);
assert.match(source, /<BirthTimeChoiceQuestion/);
assert.match(source, /<BirthTimeCandidateResult/);
assert.match(pageSource, /controller=\{birthTimeGuided\}/);
assert.doesNotMatch(pageSource, /submitBirthTimeLifeEvents/);