fix: preserve focus across rectification turns
This commit is contained in:
@@ -23,7 +23,6 @@ export function BirthTimeCandidateResult({ journey, controller }: CandidateResul
|
||||
if (!result && action.kind === "present_low_result") {
|
||||
return (
|
||||
<div className="birth-time-candidate-result" aria-live="polite">
|
||||
<p className="birth-time-assessment-unavailable">目前没有足够的新信息继续稳定缩小范围,本次评估已结束并保存当前候选范围。</p>
|
||||
<p className="birth-time-evidence-boundary">系统不会选择或应用未经证据支持的具体分钟,<span className="phrase-nowrap">当前排盘使用时间</span>保持不变。</p>
|
||||
{terminalPath && <NewAssessmentAction controller={controller} />}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
choiceQuestionGroups,
|
||||
choiceSelectionIntent,
|
||||
@@ -43,6 +43,12 @@ export function BirthTimeQuestionProgress({ progress }: {
|
||||
export function BirthTimeChoiceQuestion(props: ChoiceQuestionProps) {
|
||||
const groups = choiceQuestionGroups(props.question);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const firstChoiceRef = useRef<HTMLButtonElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!props.pending && (props.progress.answeredCount > 0 || props.error)) {
|
||||
firstChoiceRef.current?.focus();
|
||||
}
|
||||
}, [props.error, props.pending, props.progress.answeredCount, props.question.questionId]);
|
||||
const select = (option: PublicDynamicChoiceQuestion["options"][number]) => {
|
||||
const intent = choiceSelectionIntent(option);
|
||||
setSelectedId(intent.optionId);
|
||||
@@ -55,12 +61,13 @@ export function BirthTimeChoiceQuestion(props: ChoiceQuestionProps) {
|
||||
<fieldset className="birth-time-choice-question" disabled={props.pending}>
|
||||
<legend>{props.question.prompt}</legend>
|
||||
<div className="birth-time-primary-choices">
|
||||
{groups.primary.map((option) => (
|
||||
{groups.primary.map((option, index) => (
|
||||
<button
|
||||
className="birth-time-choice-option is-primary"
|
||||
data-selected={selectedId === option.optionId}
|
||||
key={option.optionId}
|
||||
onClick={() => select(option)}
|
||||
ref={index === 0 ? firstChoiceRef : undefined}
|
||||
type="button"
|
||||
>
|
||||
{option.label}
|
||||
@@ -89,6 +96,12 @@ export function BirthTimeChoiceQuestion(props: ChoiceQuestionProps) {
|
||||
|
||||
export function BirthTimeUnmatchedClarification(props: ClarificationProps) {
|
||||
const [note, setNote] = useState("");
|
||||
const reframeRef = useRef<HTMLButtonElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!props.pending && (props.progress.answeredCount > 0 || props.error)) {
|
||||
reframeRef.current?.focus();
|
||||
}
|
||||
}, [props.error, props.pending, props.progress.answeredCount]);
|
||||
return (
|
||||
<div className="birth-time-choice-surface" aria-busy={props.pending}>
|
||||
<BirthTimeQuestionProgress progress={props.progress} />
|
||||
@@ -104,7 +117,7 @@ export function BirthTimeUnmatchedClarification(props: ClarificationProps) {
|
||||
/>
|
||||
</label>
|
||||
<div className="birth-time-reframe-actions">
|
||||
<button className="button-secondary birth-time-guided-action" onClick={() => props.onReframe("")} type="button">换一道题</button>
|
||||
<button className="button-secondary birth-time-guided-action" onClick={() => props.onReframe("")} ref={reframeRef} type="button">换一道题</button>
|
||||
<button className="button-primary birth-time-guided-action" onClick={() => props.onReframe(normalizeUnmatchedNote(note))} type="button">提交补充并换题</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { BirthTimeCandidateResult } from "@/components/birth-time-candidate-result";
|
||||
import {
|
||||
BirthTimeChoiceQuestion,
|
||||
@@ -50,6 +51,17 @@ function statusCopy(action: DynamicNextAction): string {
|
||||
}
|
||||
|
||||
export function BirthTimeRectification(props: BirthTimeRectificationProps) {
|
||||
const assessmentHeadingRef = useRef<HTMLHeadingElement | null>(null);
|
||||
const previousTurnVersion = useRef(props.journey.turnVersion);
|
||||
useEffect(() => {
|
||||
const changed = previousTurnVersion.current !== props.journey.turnVersion;
|
||||
previousTurnVersion.current = props.journey.turnVersion;
|
||||
if (!changed || props.journey.journeyProtocol !== "dynamic-choice-v2") return;
|
||||
const action = props.journey.nextAction;
|
||||
if (action.kind !== "ask_dynamic_choice" && action.kind !== "clarify_unmatched_answer") {
|
||||
assessmentHeadingRef.current?.focus();
|
||||
}
|
||||
}, [props.journey]);
|
||||
if (props.journey.journeyProtocol !== "dynamic-choice-v2") {
|
||||
return <BirthTimeLegacyRectification {...props} journey={props.journey} />;
|
||||
}
|
||||
@@ -65,7 +77,7 @@ export function BirthTimeRectification(props: BirthTimeRectificationProps) {
|
||||
return (
|
||||
<section className="birth-time-rectification onboarding-card" aria-labelledby="birth-time-assessment-title">
|
||||
<div className="birth-time-assessment-heading">
|
||||
<div><span>出生时间评估</span><h2 id="birth-time-assessment-title">{heading.title}</h2></div>
|
||||
<div><span>出生时间评估</span><h2 id="birth-time-assessment-title" ref={assessmentHeadingRef} tabIndex={-1}>{heading.title}</h2></div>
|
||||
<span className="birth-time-status-badge">{heading.badge}</span>
|
||||
</div>
|
||||
{showsProgress && <BirthTimeQuestionProgress progress={props.journey.progress} />}
|
||||
|
||||
@@ -1,78 +1,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { guidedTurnIdentity } from "../src/lib/birth-time-guided-turn-identity.ts";
|
||||
|
||||
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");
|
||||
const hookSource = read("../src/hooks/use-birth-time-guided-journey.ts");
|
||||
const automaticEffectsSource = read("../src/hooks/use-birth-time-automatic-journey-effects.ts");
|
||||
|
||||
test("guided rectification renders exactly the persisted action, never a questionnaire slice", () => {
|
||||
assert.match(rectificationSource, /journey\.nextAction/);
|
||||
assert.doesNotMatch(rectificationSource, /questions\.slice\(0, 3\)/);
|
||||
assert.doesNotMatch(rectificationSource, /nextRoundQuestions/);
|
||||
assert.match(turnSource, /说出大概年份也可以/);
|
||||
});
|
||||
|
||||
test("each persisted question has a stable remount identity so skipped input cannot leak", () => {
|
||||
test("persisted questions receive stable identities without leaking prior input", () => {
|
||||
assert.equal(guidedTurnIdentity(3, "education_entry"), "3:education_entry");
|
||||
assert.notEqual(
|
||||
guidedTurnIdentity(3, "education_entry"),
|
||||
guidedTurnIdentity(4, "relationship_entry"),
|
||||
);
|
||||
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", () => {
|
||||
assert.match(draftSource, /确认并用于校正/);
|
||||
assert.match(draftSource, /disabled=\{props\.pending \|\| !isValid\}/);
|
||||
assert.match(draftSource, /domainLabels\[props\.draft\.domain\]/);
|
||||
assert.doesNotMatch(draftSource, /name=["']domain/);
|
||||
assert.match(hookSource, /reviseBirthTimeEvidenceDraft/);
|
||||
assert.match(hookSource, /confirmBirthTimeEvidenceDraft/);
|
||||
});
|
||||
|
||||
test("guided orchestration owns fallback copy, polling, and retry", () => {
|
||||
assert.match(automaticEffectsSource, /fallbackQuestionCopy/);
|
||||
assert.match(automaticEffectsSource, /requestBirthTimeGuidePrompt/);
|
||||
assert.match(automaticEffectsSource, /runBirthTimeScoringPoll/);
|
||||
assert.match(hookSource, /retry_scoring/);
|
||||
assert.match(automaticEffectsSource, /AbortController/);
|
||||
});
|
||||
|
||||
test("candidate UI is nextAction-gated and keeps application boundary explicit", () => {
|
||||
assert.match(candidateSource, /present_medium_result/);
|
||||
assert.match(candidateSource, /request_candidate_confirmation/);
|
||||
assert.match(candidateSource, /ready/);
|
||||
assert.match(candidateSource, /候选时间/);
|
||||
assert.match(candidateSource, /当前排盘使用时间/);
|
||||
assert.doesNotMatch(candidateSource, /真实出生分钟/);
|
||||
});
|
||||
|
||||
test("candidate confirmation and ready copy keep semantic time phrases intact", () => {
|
||||
assert.match(candidateSource, /<span className="phrase-nowrap">当前排盘使用时间<\/span>已更新为/);
|
||||
assert.match(candidateSource, /<span className="phrase-nowrap">原始填报时间<\/span>仍已保留/);
|
||||
assert.match(candidateSource, /<span className="phrase-nowrap">原始填报时间<\/span>仍会保留/);
|
||||
});
|
||||
|
||||
test("guided controls expose live status and minimum target classes", () => {
|
||||
assert.match(turnSource, /aria-live=["']polite/);
|
||||
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/);
|
||||
});
|
||||
|
||||
@@ -1,42 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import {
|
||||
guidedBirthTimePreview,
|
||||
isGuidedBirthTimePreview,
|
||||
} from "../src/lib/birth-time-guided-preview.ts";
|
||||
|
||||
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",
|
||||
);
|
||||
const storeSource = readFileSync(
|
||||
new URL("../src/lib/birth-time-journey-store.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const routeSource = readFileSync(
|
||||
new URL("../src/app/api/birth-time-journey/route.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const stylesSource = readFileSync(
|
||||
new URL("../src/app/globals.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("rectification UI is driven only by the persisted guided action", () => {
|
||||
assert.match(source, /journey\.nextAction/);
|
||||
assert.doesNotMatch(source, /questions\.slice\(0, 3\)/);
|
||||
assert.doesNotMatch(source, /nextRoundQuestions/);
|
||||
});
|
||||
|
||||
test("development previews cover every guided state with legal persisted actions", () => {
|
||||
const expectedActions = new Map([
|
||||
["birth-time-rectification", "ask_dynamic_choice"],
|
||||
@@ -55,60 +23,4 @@ test("development previews cover every guided state with legal persisted actions
|
||||
assert.equal(guidedBirthTimePreview(mode).nextAction.kind, action);
|
||||
}
|
||||
assert.equal(guidedBirthTimePreview("birth-time-rectification").journeyProtocol, "dynamic-choice-v2");
|
||||
assert.match(pageSource, /guidedBirthTimePreview\(previewMode\)/);
|
||||
});
|
||||
|
||||
test("journey store keeps scored candidates server-owned until confirmation", () => {
|
||||
assert.match(storeSource, /async saveCandidateResult\(value\)/);
|
||||
assert.match(storeSource, /life_events: value\.lifeEvents/);
|
||||
assert.match(storeSource, /candidate_result: value\.candidateResult/);
|
||||
assert.match(storeSource, /async confirmCandidate\(value\)/);
|
||||
assert.match(storeSource, /confirm_birth_time_candidate/);
|
||||
});
|
||||
|
||||
test("journey route exposes only structured evidence and guarded candidate actions", () => {
|
||||
assert.match(routeSource, /birthTimeJourneyRequestSchema\.safeParse/);
|
||||
assert.match(
|
||||
routeSource,
|
||||
/if \(!parsed\.success\) \{[\s\S]*?status: 400/,
|
||||
"invalid structured input must stop at the route boundary before service dispatch",
|
||||
);
|
||||
assert.match(routeSource, /service\.submitLifeEvents/);
|
||||
assert.match(routeSource, /service\.confirmCandidate/);
|
||||
assert.match(routeSource, /service\.confirmEvidenceDraft/);
|
||||
assert.match(routeSource, /service\.skipEvidenceQuestion/);
|
||||
assert.match(routeSource, /service\.pause/);
|
||||
assert.match(routeSource, /service\.finishWithCurrentRange/);
|
||||
assert.match(routeSource, /GuidedCandidateActionError && error\.reason === "case_not_found"\)[\s\S]*status: 404/);
|
||||
});
|
||||
|
||||
test("declared-time edits preserve the current journey until the revised profile is submitted", () => {
|
||||
assert.match(pageSource, /birthTimeRevisionPending\.current = true/);
|
||||
assert.match(
|
||||
pageSource,
|
||||
/await persistProfile\(profileDraft\);[\s\S]*?if \(birthTimeRevisionPending\.current\) \{[\s\S]*?await assessSavedBirthTime\(profileDraft\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
pageSource.match(/function editDeclaredBirthTimeDetails\(\) \{[\s\S]*?\n \}/)?.[0] ?? "",
|
||||
/setBirthTimeJourney\(null\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rectification renders draft review and candidates only from nextAction", () => {
|
||||
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/);
|
||||
assert.doesNotMatch(pageSource, /confirmBirthTimeCandidate/);
|
||||
});
|
||||
|
||||
test("rectification keeps short Chinese evidence phrases intact on mobile", () => {
|
||||
assert.match(pageSource, /phraseSafe=\{onboardingStep === "rectification"\}/);
|
||||
assert.match(stylesSource, /\.onboarding-message\.is-phrase-safe/);
|
||||
assert.match(stylesSource, /\.onboarding-message \.message-markdown p[\s\S]*?word-break: auto-phrase/);
|
||||
assert.match(stylesSource, /\.birth-time-evidence-note[\s\S]*?word-break: keep-all/);
|
||||
assert.match(stylesSource, /\.birth-time-assistant-intent[\s\S]*?word-break: keep-all/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user