fix: guard unconfirmed birth-time candidates
fix: guard unconfirmed birth-time candidates
This commit is contained in:
@@ -1,9 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { candidateWorkingTime } from "@/lib/birth-time-candidate-completion";
|
|
||||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
|
||||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
|
||||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -13,57 +9,12 @@ const requestSchema = z.object({
|
|||||||
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
|
time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
|
/** Compatibility endpoint for stale clients; unconfirmed candidates never write profiles. */
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
|
||||||
const supabase = await createServerSupabaseClient();
|
if (!parsed.success) return NextResponse.json({ error: "候选时间格式不正确" }, { status: 400 });
|
||||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
return NextResponse.json(
|
||||||
if (authError || !user) {
|
{ error: "候选时间不能直接采用", message: "候选范围已保留;请补充资料,或在高置信结果出现后通过正式确认继续。" },
|
||||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
{ status: 409 },
|
||||||
}
|
);
|
||||||
|
|
||||||
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json({ error: "候选时间格式不正确" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const admin = createAdminSupabaseClient();
|
|
||||||
const { data: stored, error: caseError } = await admin
|
|
||||||
.from("birth_time_rectification_cases")
|
|
||||||
.select("id,user_id,status,candidate_result_id,candidate_result,turn_state")
|
|
||||||
.eq("id", parsed.data.caseId)
|
|
||||||
.eq("user_id", user.id)
|
|
||||||
.maybeSingle();
|
|
||||||
const time = candidateWorkingTime(stored, { ...parsed.data, userId: user.id });
|
|
||||||
if (caseError || !time) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "候选结果已变化", message: "请使用当前评估结果继续。" },
|
|
||||||
{ status: 409 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: profile, error: profileError } = await admin
|
|
||||||
.from("profiles")
|
|
||||||
.update({
|
|
||||||
active_birth_time: time,
|
|
||||||
birth_time_status: "candidate",
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq("id", user.id)
|
|
||||||
.eq("rectification_case_id", parsed.data.caseId)
|
|
||||||
.select("id")
|
|
||||||
.maybeSingle();
|
|
||||||
if (profileError || !profile) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "候选时间暂时无法保存", message: "当前评估结果仍已保留,请稍后重试。" },
|
|
||||||
{ status: 503 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, activeTime: time, birthTimeStatus: "candidate" });
|
|
||||||
} catch (error) {
|
|
||||||
if (isSupabaseConfigurationError(error)) {
|
|
||||||
return NextResponse.json({ error: "Supabase 尚未配置" }, { status: 503 });
|
|
||||||
}
|
|
||||||
return NextResponse.json({ error: "候选时间暂时无法保存" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -747,7 +747,6 @@ export default function Home() {
|
|||||||
preview: process.env.NODE_ENV === "development" && uiPreview.current,
|
preview: process.env.NODE_ENV === "development" && uiPreview.current,
|
||||||
onJourney: setBirthTimeJourney,
|
onJourney: setBirthTimeJourney,
|
||||||
onReady: completeGuidedBirthTime,
|
onReady: completeGuidedBirthTime,
|
||||||
onCandidateComplete: completeCandidateBirthTime,
|
|
||||||
onEditBirthTimeDetails: editDeclaredBirthTimeDetails,
|
onEditBirthTimeDetails: editDeclaredBirthTimeDetails,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1019,7 +1018,7 @@ export default function Home() {
|
|||||||
setSessions(nextSessions);
|
setSessions(nextSessions);
|
||||||
setActiveSessionId(nextSessions[0].id);
|
setActiveSessionId(nextSessions[0].id);
|
||||||
if ((nextProfile.birthTimeStatus === "rectifying"
|
if ((nextProfile.birthTimeStatus === "rectifying"
|
||||||
|| (nextProfile.birthTimeStatus === "candidate" && !nextProfile.time))
|
|| nextProfile.birthTimeStatus === "candidate")
|
||||||
&& nextProfile.rectificationCaseId) {
|
&& nextProfile.rectificationCaseId) {
|
||||||
try {
|
try {
|
||||||
const resumed = await resumeBirthTimeJourney(nextProfile.rectificationCaseId);
|
const resumed = await resumeBirthTimeJourney(nextProfile.rectificationCaseId);
|
||||||
@@ -1657,21 +1656,6 @@ export default function Home() {
|
|||||||
setOnboardingJustCompleted(true);
|
setOnboardingJustCompleted(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeCandidateBirthTime(result: JourneyClientResponse, time: string) {
|
|
||||||
const candidateProfile: Profile = {
|
|
||||||
...profileDraft,
|
|
||||||
time,
|
|
||||||
birthTimeStatus: "candidate",
|
|
||||||
rectificationCaseId: result.caseId,
|
|
||||||
};
|
|
||||||
setProfile(candidateProfile);
|
|
||||||
setProfileDraft(candidateProfile);
|
|
||||||
setBirthTimeJourney(null);
|
|
||||||
setPresetMessageLength(0);
|
|
||||||
setStartGreeting(createStartGreeting(candidateProfile.name));
|
|
||||||
setOnboardingJustCompleted(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function retryBirthTimeAssessment() {
|
async function retryBirthTimeAssessment() {
|
||||||
if (!account || profileSaving) return;
|
if (!account || profileSaving) return;
|
||||||
setProfileSaving(true);
|
setProfileSaving(true);
|
||||||
|
|||||||
@@ -121,20 +121,10 @@ function TerminalAction({ controller, error, path }: {
|
|||||||
readonly error: string;
|
readonly error: string;
|
||||||
readonly path: NonNullable<ReturnType<typeof guidedTerminalPath>>;
|
readonly path: NonNullable<ReturnType<typeof guidedTerminalPath>>;
|
||||||
}) {
|
}) {
|
||||||
if (path.kind === "complete_with_candidate") {
|
|
||||||
return (
|
|
||||||
<div className="birth-time-next-step">
|
|
||||||
<b>评估已完成,下一步</b>
|
|
||||||
<p>点击后将使用 {path.time} 作为<span className="phrase-nowrap">当前排盘时间</span>并进入对话;<span className="phrase-nowrap">原始填报</span>和本次<span className="phrase-nowrap">候选结果</span><span className="phrase-nowrap">仍会保留</span>。</p>
|
|
||||||
<button className="button-primary birth-time-guided-action" disabled={controller.pending} onClick={() => controller.completeCandidate(path.time)} type="button">
|
|
||||||
{controller.pending ? `正在采用 ${path.time}…` : `采用 ${path.time} 并进入对话`}
|
|
||||||
</button>
|
|
||||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="birth-time-new-assessment">
|
<div className="birth-time-new-assessment">
|
||||||
|
<b>尚未达到采用条件</b>
|
||||||
|
<p>候选范围已保留,但当前证据不足以将具体分钟写入当前排盘时间。补充经历后可重新评估。</p>
|
||||||
<button className="button-secondary birth-time-guided-action" disabled={controller.pending} onClick={controller.editBirthTimeDetails} type="button">开始新的评估</button>
|
<button className="button-secondary birth-time-guided-action" disabled={controller.pending} onClick={controller.editBirthTimeDetails} type="button">开始新的评估</button>
|
||||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||||
<small>会建立新的记录,当前结果仍会保留。</small>
|
<small>会建立新的记录,当前结果仍会保留。</small>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
} from "@/lib/birth-time-journey-client";
|
} from "@/lib/birth-time-journey-client";
|
||||||
import type { JourneyClientResponse } from "@/lib/birth-time-journey-client";
|
import type { JourneyClientResponse } from "@/lib/birth-time-journey-client";
|
||||||
import {
|
import {
|
||||||
completeGuidedBirthTimeCandidate,
|
|
||||||
confirmGuidedBirthTimeCandidate,
|
confirmGuidedBirthTimeCandidate,
|
||||||
reviseBirthTimeEvidenceDraft,
|
reviseBirthTimeEvidenceDraft,
|
||||||
saveGuidedBirthTimeCandidate,
|
saveGuidedBirthTimeCandidate,
|
||||||
@@ -37,7 +36,6 @@ type GuidedJourneyInput = {
|
|||||||
readonly preview: boolean;
|
readonly preview: boolean;
|
||||||
readonly onJourney: (journey: JourneyClientResponse) => void;
|
readonly onJourney: (journey: JourneyClientResponse) => void;
|
||||||
readonly onReady: (journey: JourneyClientResponse) => void;
|
readonly onReady: (journey: JourneyClientResponse) => void;
|
||||||
readonly onCandidateComplete: (journey: JourneyClientResponse, time: string) => void;
|
|
||||||
readonly onEditBirthTimeDetails: () => void;
|
readonly onEditBirthTimeDetails: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,7 +51,6 @@ export type BirthTimeGuidedController = {
|
|||||||
readonly resume: () => void;
|
readonly resume: () => void;
|
||||||
readonly editBirthTimeDetails: () => void;
|
readonly editBirthTimeDetails: () => void;
|
||||||
readonly acknowledgeReady: () => void;
|
readonly acknowledgeReady: () => void;
|
||||||
readonly completeCandidate: (time: string) => void;
|
|
||||||
readonly retryScoring: () => void;
|
readonly retryScoring: () => void;
|
||||||
readonly saveCandidate: (resultId: string) => void;
|
readonly saveCandidate: (resultId: string) => void;
|
||||||
readonly confirmCandidate: (resultId: string, time: string) => void;
|
readonly confirmCandidate: (resultId: string, time: string) => void;
|
||||||
@@ -70,7 +67,7 @@ function previewAction(turn: JourneyClientResponse, command: DynamicPreviewComma
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeGuidedController {
|
export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeGuidedController {
|
||||||
const { journey, onJourney, onReady, onCandidateComplete, onEditBirthTimeDetails, preview } = input;
|
const { journey, onJourney, onReady, onEditBirthTimeDetails, preview } = input;
|
||||||
const latest = useRef(journey);
|
const latest = useRef(journey);
|
||||||
const busy = useRef(false);
|
const busy = useRef(false);
|
||||||
const [actionRegistry] = useState(() => createStableActionIdentityRegistry());
|
const [actionRegistry] = useState(() => createStableActionIdentityRegistry());
|
||||||
@@ -185,26 +182,6 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG
|
|||||||
const acknowledgeReady = () => {
|
const acknowledgeReady = () => {
|
||||||
if (journey?.nextAction.kind === "ready") onReady(journey);
|
if (journey?.nextAction.kind === "ready") onReady(journey);
|
||||||
};
|
};
|
||||||
const completeCandidate = (time: string) => {
|
|
||||||
const turn = journey;
|
|
||||||
const resultId = turn?.candidateResult?.resultId;
|
|
||||||
const winner = turn?.candidateResult?.winningSegment;
|
|
||||||
if (!turn || !resultId || winner?.representativeTime !== time) return;
|
|
||||||
const release = claimMutation(busy);
|
|
||||||
if (release === null) return;
|
|
||||||
setPending(true);
|
|
||||||
setError("");
|
|
||||||
const completion = preview
|
|
||||||
? Promise.resolve()
|
|
||||||
: completeGuidedBirthTimeCandidate({ caseId: turn.caseId, resultId, time });
|
|
||||||
void completion
|
|
||||||
.then(() => onCandidateComplete(turn, time))
|
|
||||||
.catch((caught) => setError(birthTimeUserError(caught)))
|
|
||||||
.finally(() => {
|
|
||||||
release();
|
|
||||||
setPending(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const retryScoring = () => {
|
const retryScoring = () => {
|
||||||
const turn = journey;
|
const turn = journey;
|
||||||
if (preview && turn?.journeyProtocol === "dynamic-choice-v2"
|
if (preview && turn?.journeyProtocol === "dynamic-choice-v2"
|
||||||
@@ -277,7 +254,6 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG
|
|||||||
resume,
|
resume,
|
||||||
editBirthTimeDetails: onEditBirthTimeDetails,
|
editBirthTimeDetails: onEditBirthTimeDetails,
|
||||||
acknowledgeReady,
|
acknowledgeReady,
|
||||||
completeCandidate,
|
|
||||||
retryScoring,
|
retryScoring,
|
||||||
saveCandidate,
|
saveCandidate,
|
||||||
confirmCandidate,
|
confirmCandidate,
|
||||||
|
|||||||
@@ -5,37 +5,10 @@ type CandidateCompletionRequest = {
|
|||||||
readonly time: string;
|
readonly time: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function record(value: unknown): Record<string, unknown> | null {
|
/** Direct adoption was superseded by versioned high-confidence confirmation. */
|
||||||
return value !== null && typeof value === "object"
|
|
||||||
? value as Record<string, unknown>
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function candidateWorkingTime(
|
export function candidateWorkingTime(
|
||||||
stored: unknown,
|
_stored: unknown,
|
||||||
request: CandidateCompletionRequest,
|
_request: CandidateCompletionRequest,
|
||||||
): string | null {
|
): string | null {
|
||||||
const assessment = record(stored);
|
return null;
|
||||||
const candidate = record(assessment?.candidate_result);
|
|
||||||
const winner = record(candidate?.winningSegment);
|
|
||||||
const turn = record(assessment?.turn_state);
|
|
||||||
const action = record(turn?.nextAction);
|
|
||||||
const actionKind = action?.kind;
|
|
||||||
const terminal = actionKind === "present_low_result"
|
|
||||||
|| actionKind === "present_medium_result"
|
|
||||||
|| actionKind === "candidate_saved";
|
|
||||||
const terminalStatusMatches = actionKind === "present_low_result"
|
|
||||||
? assessment?.status === "rectifying"
|
|
||||||
: (actionKind === "present_medium_result" || actionKind === "candidate_saved")
|
|
||||||
&& assessment?.status === "candidate";
|
|
||||||
|
|
||||||
return assessment?.id === request.caseId
|
|
||||||
&& assessment?.user_id === request.userId
|
|
||||||
&& terminalStatusMatches
|
|
||||||
&& assessment.candidate_result_id === request.resultId
|
|
||||||
&& action?.resultId === request.resultId
|
|
||||||
&& terminal
|
|
||||||
&& winner?.representativeTime === request.time
|
|
||||||
? request.time
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ type DraftRevision = GuidedMutation & {
|
|||||||
};
|
};
|
||||||
type CandidateSave = GuidedMutation & { readonly resultId: string };
|
type CandidateSave = GuidedMutation & { readonly resultId: string };
|
||||||
type CandidateConfirmation = CandidateSave & { readonly time: string };
|
type CandidateConfirmation = CandidateSave & { readonly time: string };
|
||||||
type CandidateCompletion = Pick<CandidateSave, "caseId" | "resultId"> & { readonly time: string };
|
|
||||||
|
|
||||||
const errorPayloadSchema = z.object({
|
const errorPayloadSchema = z.object({
|
||||||
message: z.string().optional(),
|
message: z.string().optional(),
|
||||||
@@ -69,22 +68,3 @@ export function confirmGuidedBirthTimeCandidate(
|
|||||||
) {
|
) {
|
||||||
return send({ type: "confirm_guided_candidate", ...input });
|
return send({ type: "confirm_guided_candidate", ...input });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function completeGuidedBirthTimeCandidate(
|
|
||||||
input: CandidateCompletion,
|
|
||||||
) {
|
|
||||||
const { response, payload } = await postJson({
|
|
||||||
url: "/api/birth-time-candidate-completion",
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
retryLostResponse: false,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const parsed = errorPayloadSchema.safeParse(payload);
|
|
||||||
throw new GuidedBirthTimeRequestError(
|
|
||||||
response.status,
|
|
||||||
parsed.success
|
|
||||||
? parsed.data.message ?? parsed.data.error ?? "候选时间暂时无法保存"
|
|
||||||
: "候选时间暂时无法保存",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,32 +1,14 @@
|
|||||||
import type { JourneyClientResponse } from "./birth-time-journey-response-schema.ts";
|
import type { JourneyClientResponse } from "./birth-time-journey-response-schema.ts";
|
||||||
|
|
||||||
export type GuidedTerminalPath =
|
export type GuidedTerminalPath = {
|
||||||
| {
|
readonly kind: "edit_birth_time_details";
|
||||||
readonly kind: "edit_birth_time_details";
|
readonly preservesCase: true;
|
||||||
readonly preservesCase: true;
|
readonly appliesCandidateTime: false;
|
||||||
readonly appliesCandidateTime: false;
|
};
|
||||||
}
|
|
||||||
| {
|
|
||||||
readonly kind: "complete_with_candidate";
|
|
||||||
readonly time: string;
|
|
||||||
readonly preservesCase: true;
|
|
||||||
readonly appliesCandidateTime: true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function guidedTerminalPath(journey: JourneyClientResponse): GuidedTerminalPath | null {
|
export function guidedTerminalPath(journey: JourneyClientResponse): GuidedTerminalPath | null {
|
||||||
const kind = journey.nextAction.kind;
|
const kind = journey.nextAction.kind;
|
||||||
const winner = journey.candidateResult?.winningSegment;
|
return kind === "present_low_result" || kind === "present_medium_result" || kind === "candidate_saved"
|
||||||
if (journey.journeyProtocol === "dynamic-choice-v2"
|
|
||||||
&& winner
|
|
||||||
&& (kind === "present_low_result" || kind === "present_medium_result" || kind === "candidate_saved")) {
|
|
||||||
return {
|
|
||||||
kind: "complete_with_candidate",
|
|
||||||
time: winner.representativeTime,
|
|
||||||
preservesCase: true,
|
|
||||||
appliesCandidateTime: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return kind === "present_low_result" || kind === "candidate_saved"
|
|
||||||
? { kind: "edit_birth_time_details", preservesCase: true, appliesCandidateTime: false }
|
? { kind: "edit_birth_time_details", preservesCase: true, appliesCandidateTime: false }
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export function isBirthTimeDraftReady(draft: BirthTimeDraft) {
|
|||||||
|
|
||||||
export function isBirthTimeReadyForConsultation(draft: BirthTimeDraft) {
|
export function isBirthTimeReadyForConsultation(draft: BirthTimeDraft) {
|
||||||
return Boolean(draft.time)
|
return Boolean(draft.time)
|
||||||
&& (draft.birthTimeStatus === "candidate" || draft.birthTimeStatus === "confirmed");
|
&& draft.birthTimeStatus === "confirmed";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function birthTimePersistenceValues(draft: BirthTimeDraft) {
|
export function birthTimePersistenceValues(draft: BirthTimeDraft) {
|
||||||
|
|||||||
@@ -2,204 +2,11 @@ import assert from "node:assert/strict";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { candidateWorkingTime } from "../src/lib/birth-time-candidate-completion.ts";
|
import { candidateWorkingTime } from "../src/lib/birth-time-candidate-completion.ts";
|
||||||
|
|
||||||
const terminalCase = {
|
test("unconfirmed candidate results cannot directly become the active consultation time", () => {
|
||||||
id: "5425f9e7-3d45-491d-aab3-24cfd4261d51",
|
assert.equal(candidateWorkingTime({}, {
|
||||||
user_id: "07e583fc-90b9-4fcb-a9d3-8de654eeac9a",
|
userId: "07e583fc-90b9-4fcb-a9d3-8de654eeac9a",
|
||||||
status: "candidate",
|
caseId: "5425f9e7-3d45-491d-aab3-24cfd4261d51",
|
||||||
candidate_result_id: "d9133ba2-afcf-56da-b40b-ace3d7124a7d",
|
resultId: "d9133ba2-afcf-56da-b40b-ace3d7124a7d",
|
||||||
candidate_result: {
|
time: "04:53",
|
||||||
confidence: "medium",
|
|
||||||
winningSegment: { representativeTime: "04:53" },
|
|
||||||
},
|
|
||||||
turn_state: {
|
|
||||||
nextAction: {
|
|
||||||
kind: "present_medium_result",
|
|
||||||
resultId: "d9133ba2-afcf-56da-b40b-ace3d7124a7d",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const lowTerminalCase = {
|
|
||||||
...terminalCase,
|
|
||||||
status: "rectifying",
|
|
||||||
candidate_result: {
|
|
||||||
...terminalCase.candidate_result,
|
|
||||||
confidence: "low",
|
|
||||||
},
|
|
||||||
turn_state: {
|
|
||||||
...terminalCase.turn_state,
|
|
||||||
nextAction: {
|
|
||||||
kind: "present_low_result",
|
|
||||||
resultId: terminalCase.candidate_result_id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const completionRequest = {
|
|
||||||
userId: terminalCase.user_id,
|
|
||||||
caseId: terminalCase.id,
|
|
||||||
resultId: terminalCase.candidate_result_id,
|
|
||||||
time: "04:53",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
test("candidate completion only accepts the persisted terminal representative time", () => {
|
|
||||||
assert.equal(candidateWorkingTime(terminalCase, {
|
|
||||||
...completionRequest,
|
|
||||||
}), "04:53");
|
|
||||||
|
|
||||||
assert.equal(candidateWorkingTime(terminalCase, {
|
|
||||||
...completionRequest,
|
|
||||||
time: "04:54",
|
|
||||||
}), null);
|
}), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("accepts a matching low-confidence result from the rectifying state", () => {
|
|
||||||
assert.equal(candidateWorkingTime(lowTerminalCase, {
|
|
||||||
...completionRequest,
|
|
||||||
}), "04:53");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts a persisted candidate-saved compatibility action", () => {
|
|
||||||
assert.equal(candidateWorkingTime({
|
|
||||||
...terminalCase,
|
|
||||||
turn_state: {
|
|
||||||
nextAction: {
|
|
||||||
kind: "candidate_saved",
|
|
||||||
resultId: terminalCase.candidate_result_id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, completionRequest), "04:53");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not accept a medium terminal action from the rectifying state", () => {
|
|
||||||
assert.equal(candidateWorkingTime({
|
|
||||||
...lowTerminalCase,
|
|
||||||
turn_state: {
|
|
||||||
...lowTerminalCase.turn_state,
|
|
||||||
nextAction: {
|
|
||||||
kind: "present_medium_result",
|
|
||||||
resultId: terminalCase.candidate_result_id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, {
|
|
||||||
...completionRequest,
|
|
||||||
}), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("non-terminal cases cannot be adopted for consultation", () => {
|
|
||||||
assert.equal(candidateWorkingTime({
|
|
||||||
...terminalCase,
|
|
||||||
turn_state: { nextAction: { kind: "ask_dynamic_choice" } },
|
|
||||||
}, {
|
|
||||||
...completionRequest,
|
|
||||||
}), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
const rejectedCompletions = [
|
|
||||||
{
|
|
||||||
name: "case owned by another user",
|
|
||||||
stored: { ...terminalCase, user_id: "f6cf99a5-9af7-4980-93ea-0298ee1dc95e" },
|
|
||||||
request: completionRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "request from another user",
|
|
||||||
stored: terminalCase,
|
|
||||||
request: { ...completionRequest, userId: "f6cf99a5-9af7-4980-93ea-0298ee1dc95e" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing case owner",
|
|
||||||
stored: { ...terminalCase, user_id: null },
|
|
||||||
request: completionRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty case owner",
|
|
||||||
stored: { ...terminalCase, user_id: "" },
|
|
||||||
request: completionRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wrong case ID",
|
|
||||||
stored: terminalCase,
|
|
||||||
request: { ...completionRequest, caseId: "c84052ca-bcea-40a8-a32a-56980bbf7b22" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wrong persisted result ID",
|
|
||||||
stored: { ...terminalCase, candidate_result_id: "a3e41512-9fa0-4866-a187-e3b3aa07aee0" },
|
|
||||||
request: completionRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wrong action result ID",
|
|
||||||
stored: {
|
|
||||||
...terminalCase,
|
|
||||||
turn_state: {
|
|
||||||
nextAction: {
|
|
||||||
...terminalCase.turn_state.nextAction,
|
|
||||||
resultId: "a3e41512-9fa0-4866-a187-e3b3aa07aee0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
request: completionRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wrong requested result ID",
|
|
||||||
stored: terminalCase,
|
|
||||||
request: { ...completionRequest, resultId: "a3e41512-9fa0-4866-a187-e3b3aa07aee0" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing winning segment",
|
|
||||||
stored: { ...terminalCase, candidate_result: { confidence: "medium" } },
|
|
||||||
request: completionRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing representative time",
|
|
||||||
stored: {
|
|
||||||
...terminalCase,
|
|
||||||
candidate_result: { confidence: "medium", winningSegment: {} },
|
|
||||||
},
|
|
||||||
request: completionRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "low-result action paired with candidate status",
|
|
||||||
stored: {
|
|
||||||
...terminalCase,
|
|
||||||
turn_state: {
|
|
||||||
nextAction: {
|
|
||||||
kind: "present_low_result",
|
|
||||||
resultId: terminalCase.candidate_result_id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
request: completionRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "medium-result action paired with rectifying status",
|
|
||||||
stored: {
|
|
||||||
...lowTerminalCase,
|
|
||||||
turn_state: {
|
|
||||||
nextAction: {
|
|
||||||
kind: "present_medium_result",
|
|
||||||
resultId: terminalCase.candidate_result_id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
request: completionRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "candidate-saved action paired with rectifying status",
|
|
||||||
stored: {
|
|
||||||
...lowTerminalCase,
|
|
||||||
turn_state: {
|
|
||||||
nextAction: {
|
|
||||||
kind: "candidate_saved",
|
|
||||||
resultId: terminalCase.candidate_result_id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
request: completionRequest,
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
for (const scenario of rejectedCompletions) {
|
|
||||||
test(`rejects candidate completion with ${scenario.name}`, () => {
|
|
||||||
assert.equal(candidateWorkingTime(scenario.stored, scenario.request), null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -56,14 +56,13 @@ test("low without a result and saved medium both return to declared-time editing
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("dynamic medium terminal completes with its candidate working time", () => {
|
test("dynamic medium terminal preserves its candidate range without direct adoption", () => {
|
||||||
const medium = dynamicBirthTimePreview("medium");
|
const medium = dynamicBirthTimePreview("medium");
|
||||||
|
|
||||||
assert.deepEqual(guidedTerminalPath(medium), {
|
assert.deepEqual(guidedTerminalPath(medium), {
|
||||||
kind: "complete_with_candidate",
|
kind: "edit_birth_time_details",
|
||||||
time: "05:43",
|
|
||||||
preservesCase: true,
|
preservesCase: true,
|
||||||
appliesCandidateTime: true,
|
appliesCandidateTime: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,33 +118,33 @@ test("ready completion is explicit and terminal low has no finish mutation", ()
|
|||||||
assert.doesNotMatch(hookSource, /turn\.nextAction\.kind === "ready"\) onReady/);
|
assert.doesNotMatch(hookSource, /turn\.nextAction\.kind === "ready"\) onReady/);
|
||||||
assert.match(candidateSource, /acknowledgeReady/);
|
assert.match(candidateSource, /acknowledgeReady/);
|
||||||
assert.doesNotMatch(candidateSource, /controller\.finish/);
|
assert.doesNotMatch(candidateSource, /controller\.finish/);
|
||||||
|
assert.doesNotMatch(hookSource, /completeGuidedBirthTimeCandidate/);
|
||||||
|
assert.doesNotMatch(hookSource, /completeCandidate:/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("terminal candidate owns one explicit next step and its completion error", () => {
|
test("unconfirmed terminal candidates preserve the range without offering direct adoption", () => {
|
||||||
const candidateResultSource = readFileSync(new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url), "utf8");
|
const candidateResultSource = readFileSync(new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url), "utf8");
|
||||||
const choiceQuestionSource = readFileSync(new URL("../src/components/birth-time-choice-question.tsx", import.meta.url), "utf8");
|
const choiceQuestionSource = readFileSync(new URL("../src/components/birth-time-choice-question.tsx", import.meta.url), "utf8");
|
||||||
const rectificationSource = readFileSync(new URL("../src/components/birth-time-rectification.tsx", import.meta.url), "utf8");
|
const rectificationSource = readFileSync(new URL("../src/components/birth-time-rectification.tsx", import.meta.url), "utf8");
|
||||||
const legacyRectificationSource = readFileSync(new URL("../src/components/birth-time-legacy-rectification.tsx", import.meta.url), "utf8");
|
const legacyRectificationSource = readFileSync(new URL("../src/components/birth-time-legacy-rectification.tsx", import.meta.url), "utf8");
|
||||||
const globalCssSource = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
|
||||||
|
|
||||||
assert.match(candidateResultSource, /评估已完成,下一步/);
|
assert.match(candidateResultSource, /尚未达到采用条件/);
|
||||||
assert.match(candidateResultSource, /采用 \$\{path\.time\} 并进入对话/);
|
assert.match(candidateResultSource, /补充资料并重新评估/);
|
||||||
assert.match(candidateResultSource, /正在采用 \$\{path\.time\}…/);
|
assert.doesNotMatch(candidateResultSource, /采用 \$\{path\.time\} 并进入对话/);
|
||||||
assert.match(candidateResultSource, /birth-time-next-step/);
|
assert.doesNotMatch(candidateResultSource, /birth-time-next-step/);
|
||||||
assert.match(rectificationSource, /error=\{error\}/);
|
assert.match(rectificationSource, /error=\{error\}/);
|
||||||
assert.match(rectificationSource, /const childOwnsError = action\.kind === "ask_dynamic_choice"\s*\|\| action\.kind === "clarify_unmatched_answer"/);
|
assert.match(rectificationSource, /const childOwnsError = action\.kind === "ask_dynamic_choice"\s*\|\| action\.kind === "clarify_unmatched_answer"/);
|
||||||
assert.match(rectificationSource, /error && !showsCandidate && !childOwnsError/);
|
assert.match(rectificationSource, /error && !showsCandidate && !childOwnsError/);
|
||||||
assert.equal(choiceQuestionSource.match(/role="alert"/g)?.length, 1);
|
assert.equal(choiceQuestionSource.match(/role="alert"/g)?.length, 1);
|
||||||
assert.match(legacyRectificationSource, /error=\{error\}/);
|
assert.match(legacyRectificationSource, /error=\{error\}/);
|
||||||
assert.match(legacyRectificationSource, /error && !showsCandidate/);
|
assert.match(legacyRectificationSource, /error && !showsCandidate/);
|
||||||
assert.match(globalCssSource, /\.birth-time-next-step/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("terminal and entrypoint CJK phrases stay intact at narrow widths", () => {
|
test("terminal and entrypoint CJK phrases stay intact at narrow widths", () => {
|
||||||
const candidateResultSource = readFileSync(new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url), "utf8");
|
const candidateResultSource = readFileSync(new URL("../src/components/birth-time-candidate-result.tsx", import.meta.url), "utf8");
|
||||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||||
|
|
||||||
assert.match(candidateResultSource, /作为<span className="phrase-nowrap">当前排盘时间<\/span>并进入对话;<span className="phrase-nowrap">原始填报<\/span>和本次<span className="phrase-nowrap">候选结果<\/span><span className="phrase-nowrap">仍会保留<\/span>。/);
|
assert.match(candidateResultSource, /候选范围已保留,但当前证据不足以将具体分钟写入当前排盘时间。补充经历后可重新评估。/);
|
||||||
assert.match(pageSource, /当前使用候选时间排盘;<span className="phrase-nowrap">原始填报范围<\/span>仍保留。/);
|
assert.match(pageSource, /当前使用候选时间排盘;<span className="phrase-nowrap">原始填报范围<\/span>仍保留。/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -47,16 +47,17 @@ test("birth time intake requires only the fields selected by the source", () =>
|
|||||||
assert.equal(isBirthTimeDraftReady({ ...emptyDraft, birthTimeSource: "unknown" }), true);
|
assert.equal(isBirthTimeDraftReady({ ...emptyDraft, birthTimeSource: "unknown" }), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a persisted candidate working time can leave rectification onboarding", () => {
|
test("an unconfirmed candidate working time remains in rectification onboarding", () => {
|
||||||
const candidate = {
|
const candidate = {
|
||||||
...emptyDraft,
|
...emptyDraft,
|
||||||
time: "04:53",
|
time: "04:53",
|
||||||
birthTimeStatus: "candidate",
|
birthTimeStatus: "candidate",
|
||||||
} satisfies BirthTimeDraft;
|
} satisfies BirthTimeDraft;
|
||||||
|
|
||||||
assert.equal(isBirthTimeReadyForConsultation(candidate), true);
|
assert.equal(isBirthTimeReadyForConsultation(candidate), false);
|
||||||
assert.equal(isBirthTimeReadyForConsultation({ ...candidate, time: "" }), false);
|
assert.equal(isBirthTimeReadyForConsultation({ ...candidate, time: "" }), false);
|
||||||
assert.equal(isBirthTimeReadyForConsultation({ ...candidate, birthTimeStatus: "rectifying" }), false);
|
assert.equal(isBirthTimeReadyForConsultation({ ...candidate, birthTimeStatus: "rectifying" }), false);
|
||||||
|
assert.equal(isBirthTimeReadyForConsultation({ ...candidate, birthTimeStatus: "confirmed" }), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a persisted candidate working time takes precedence over the reported range", () => {
|
test("a persisted candidate working time takes precedence over the reported range", () => {
|
||||||
|
|||||||
@@ -75,9 +75,8 @@ test("low-confidence preview mirrors the persisted dynamic terminal state", () =
|
|||||||
assert.equal(low.candidateResult.winningSegment?.representativeTime, "05:21");
|
assert.equal(low.candidateResult.winningSegment?.representativeTime, "05:21");
|
||||||
assert.equal(low.nextAction.resultId, low.candidateResult.resultId);
|
assert.equal(low.nextAction.resultId, low.candidateResult.resultId);
|
||||||
assert.deepEqual(guidedTerminalPath(low), {
|
assert.deepEqual(guidedTerminalPath(low), {
|
||||||
kind: "complete_with_candidate",
|
kind: "edit_birth_time_details",
|
||||||
time: low.candidateResult.winningSegment?.representativeTime,
|
|
||||||
preservesCase: true,
|
preservesCase: true,
|
||||||
appliesCandidateTime: true,
|
appliesCandidateTime: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user