feat: rebuild birth time rectification workflow
This commit is contained in:
@@ -78,47 +78,46 @@ test("ordinary product drafts keep the public question and clear hidden routing
|
||||
assert.match(source, /setDraft\(pending\.question\);[\s\S]*?setDraftTheme\(pending\.theme\);[\s\S]*?setDraftEntrypoint\(pending\.entrypoint\);/);
|
||||
});
|
||||
|
||||
test("homepage birth-time card opens the v3 surface instead of ordinary consultation", () => {
|
||||
test("homepage birth-time card opens the v4 evidence surface instead of ordinary consultation", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /function openBirthTimeRectification/);
|
||||
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /<ConversationalBirthTimeRectification/);
|
||||
assert.match(source, /sendConversationalRectificationCommand/);
|
||||
assert.match(source, /rectificationPriceCredits/);
|
||||
assert.doesNotMatch(source, /\/api\/birth-rectification/);
|
||||
assert.doesNotMatch(source, /\/api\/birth-time-journey/);
|
||||
assert.match(component, /<RectificationV4Panel/);
|
||||
assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
|
||||
assert.doesNotMatch(source.slice(
|
||||
source.indexOf("async function openBirthTimeRectification"),
|
||||
source.indexOf("function handleConversationalRectificationTurn"),
|
||||
), /sendConversationalRectificationCommand/);
|
||||
assert.doesNotMatch(source, /chooseSuggestedQuestion\([\s\S]{0,180}"birth_time_rectification"/);
|
||||
assert.doesNotMatch(source, /draftBirthTimeRectificationQuestion/);
|
||||
});
|
||||
|
||||
test("homepage birth-time card starts the first rectification turn without a second confirmation card", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const start = source.indexOf("async function openBirthTimeRectification");
|
||||
const end = source.indexOf("function handleConversationalRectificationTurn", start);
|
||||
const handler = source.slice(start, end);
|
||||
test("homepage opens the v4 panel without invoking the retired v3 start command", () => {
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
|
||||
const start = page.indexOf("async function openBirthTimeRectification");
|
||||
const end = page.indexOf("function handleConversationalRectificationTurn", start);
|
||||
const handler = page.slice(start, end);
|
||||
|
||||
assert.match(handler, /sendConversationalRectificationCommand\(\{[\s\S]*?type:\s*"start"/);
|
||||
assert.match(
|
||||
handler,
|
||||
/sendConversationalRectificationCommand\(\{[\s\S]*?type:\s*"start"[\s\S]*?modelId:\s*rectificationSession\.modelId/,
|
||||
);
|
||||
assert.doesNotMatch(handler, /sendConversationalRectificationCommand/);
|
||||
const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
|
||||
assert.match(page, /<ConversationalBirthTimeRectification/);
|
||||
assert.match(component, /<RectificationV4Panel/);
|
||||
assert.match(hook, /await loadRectificationV4Handoff\(\)/);
|
||||
assert.match(hook, /await createRectificationV4\(\)/);
|
||||
});
|
||||
|
||||
test("a stale start snapshot refreshes and resumes the existing unfinished case after a 409", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const start = source.indexOf("async function openBirthTimeRectification");
|
||||
const end = source.indexOf("function handleConversationalRectificationTurn", start);
|
||||
const handler = source.slice(start, end);
|
||||
test("a stale v4 mutation refreshes the same case after a 409", () => {
|
||||
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(handler, /error instanceof ConversationalRectificationRequestError/);
|
||||
assert.match(handler, /error\.status !== 409/);
|
||||
assert.match(handler, /const latest = await fetchAccount\(\)/);
|
||||
assert.match(handler, /if \(!latest\.rectificationCase\) throw error/);
|
||||
assert.match(handler, /type: "resume"[\s\S]*?latest\.rectificationCase\.caseId/);
|
||||
assert.match(handler, /latest\.rectificationCase\.turnVersion/);
|
||||
assert.match(hook, /caught instanceof RectificationV4RequestError && caught\.status === 409 && data/);
|
||||
assert.match(hook, /await refresh\(data\.case\.id\)/);
|
||||
assert.match(hook, /loadRectificationV4\(caseId\)/);
|
||||
});
|
||||
|
||||
test("homepage birth-time card opens its dedicated session before the first turn resolves", () => {
|
||||
test("homepage birth-time card opens its dedicated session before v4 data loads", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const start = source.indexOf("async function openBirthTimeRectification");
|
||||
const end = source.indexOf("function handleConversationalRectificationTurn", start);
|
||||
@@ -134,36 +133,29 @@ test("homepage birth-time card opens its dedicated session before the first turn
|
||||
assert.match(handler, /rectificationOpenInFlight\.current/);
|
||||
assert.match(handler, /rectificationOpenInFlight\.current = true;[\s\S]*?finally \{[\s\S]*?rectificationOpenInFlight\.current = false;/);
|
||||
assert.match(handler, /setRectificationReturnSessionId\(sourceSession\.id\)/);
|
||||
assert.match(handler, /type: "start",[\s\S]*?onNarrativeDelta\(text\)[\s\S]*?setRectificationOpeningAssistantText/);
|
||||
assert.match(handler, /type: "resume",[\s\S]*?onNarrativeDelta\(text\)[\s\S]*?setRectificationOpeningAssistantText/);
|
||||
assert.doesNotMatch(handler, /onNarrativeDelta|sendConversationalRectificationCommand/);
|
||||
assert.match(source, /const rectificationSurfaceOpen = activeRectificationSession\s*&& activeSession\.id === rectificationSessionId/);
|
||||
assert.match(source, /rectificationSurfaceOpen && \(!visibleRectificationTurn && rectificationError \? \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?initialTurn=\{visibleRectificationTurn\}[\s\S]*?openingAssistantText=\{rectificationOpeningAssistantText\}/);
|
||||
assert.match(source, /rectificationSurfaceOpen && \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
|
||||
});
|
||||
|
||||
test("the first rectification turn becomes interactive before session persistence finishes", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const start = source.indexOf("async function openBirthTimeRectification");
|
||||
const end = source.indexOf("function handleConversationalRectificationTurn", start);
|
||||
const handler = source.slice(start, end);
|
||||
const turnVisible = handler.indexOf("setRectificationInitialTurn(turn)");
|
||||
const backgroundPersist = handler.indexOf("void rectificationPersistence.current.enqueue(");
|
||||
test("the v4 panel owns case recovery while the page persists only the dedicated session shell", () => {
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
|
||||
const start = page.indexOf("async function openBirthTimeRectification");
|
||||
const end = page.indexOf("function handleConversationalRectificationTurn", start);
|
||||
const handler = page.slice(start, end);
|
||||
|
||||
assert.ok(turnVisible >= 0);
|
||||
assert.ok(backgroundPersist > turnVisible);
|
||||
assert.match(handler, /void rectificationPersistence\.current\.enqueue\([\s\S]*?\(\) => persistSession\([\s\S]*?\.catch\(\(\) => \{[\s\S]*?校正已经开始,但会话关联暂时未同步到云端。/);
|
||||
assert.match(handler, /persistSession\(rectificationSession, "create"\)/);
|
||||
assert.match(hook, /const existingHandoff = await loadRectificationV4Handoff\(\)/);
|
||||
assert.match(hook, /existingHandoff[\s\S]*?loadRectificationV4\(existingHandoff\.caseId\)[\s\S]*?createRectificationV4\(\)/);
|
||||
});
|
||||
|
||||
test("a direct homepage start skips the durable handoff read when no question was handed off", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const start = source.indexOf("async function openBirthTimeRectification");
|
||||
const end = source.indexOf("function handleConversationalRectificationTurn", start);
|
||||
const handler = source.slice(start, end);
|
||||
test("a direct homepage start restores any active v4 case before creating another", () => {
|
||||
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(handler, /const localHandoff = rectificationQuestionHandoff\.current\.peek\(\)/);
|
||||
assert.match(
|
||||
handler,
|
||||
/const durable = requestedQuestion !== null \|\| localHandoff !== null\s*\? await durableRectificationQuestionHandoff\.current\.load\(\)\s*:\s*null/,
|
||||
);
|
||||
assert.match(hook, /const existingHandoff = await loadRectificationV4Handoff\(\)/);
|
||||
assert.match(hook, /existingHandoff\s*\? await loadRectificationV4\(existingHandoff\.caseId\)\s*:\s*await createRectificationV4\(\)/);
|
||||
assert.doesNotMatch(hook, /sendConversationalRectificationCommand/);
|
||||
});
|
||||
|
||||
test("rectification cards render only inside the active rectification session", () => {
|
||||
@@ -186,32 +178,31 @@ test("selecting a rectification session resumes it without an intermediate confi
|
||||
assert.match(selectSession, /resumeRectificationSession\.current\(nextSession\)/);
|
||||
assert.match(source, /resumeRectificationSession\.current\(activeSession\)/);
|
||||
assert.doesNotMatch(source, /RectificationLoadingState|重试恢复/);
|
||||
assert.match(source, /setComposerNotice\(message\)/);
|
||||
assert.match(source, /<ConversationalBirthTimeRectification/);
|
||||
});
|
||||
|
||||
test("homepage reuses the session bound to an unfinished rectification case", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const start = source.indexOf("async function openBirthTimeRectification");
|
||||
const end = source.indexOf("function handleConversationalRectificationTurn", start);
|
||||
const handler = source.slice(start, end);
|
||||
test("homepage reuses the dedicated rectification session while v4 restores the active case", () => {
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
|
||||
const start = page.indexOf("async function openBirthTimeRectification");
|
||||
const end = page.indexOf("function handleConversationalRectificationTurn", start);
|
||||
const handler = page.slice(start, end);
|
||||
|
||||
assert.match(handler, /const accountResumeCase = action === "resume" \? account\.rectificationCase : null/);
|
||||
assert.match(handler, /session\.rectificationCaseId === accountResumeCase\.caseId/);
|
||||
assert.match(handler, /resumableSession \?\? createSession/);
|
||||
assert.match(handler, /sessions\.find\(\(session\) => session\.sessionType === "birth_time_rectification"\)/);
|
||||
assert.match(handler, /existing \?\? createSession/);
|
||||
assert.match(hook, /loadRectificationV4Handoff|createRectificationV4/);
|
||||
});
|
||||
|
||||
test("a bound rectification session resumes its own case while a homepage restart stays dedicated", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const start = source.indexOf("async function openBirthTimeRectification");
|
||||
const end = source.indexOf("function handleConversationalRectificationTurn", start);
|
||||
const handler = source.slice(start, end);
|
||||
test("a bound rectification session and a homepage restart share the v4 active-case loader", () => {
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
|
||||
const start = page.indexOf("async function openBirthTimeRectification");
|
||||
const end = page.indexOf("function handleConversationalRectificationTurn", start);
|
||||
const handler = page.slice(start, end);
|
||||
|
||||
assert.match(handler, /const sourceBoundCaseId = sourceSession\.sessionType === "birth_time_rectification"/);
|
||||
assert.match(handler, /const resumeTarget = sourceBoundCaseId/);
|
||||
assert.match(handler, /caseId: sourceBoundCaseId/);
|
||||
assert.match(handler, /if \(!resumeTarget\) \{[\s\S]*?type: "start"/);
|
||||
assert.match(handler, /const rectificationSession = canReuseSourceRectificationSession[\s\S]*?: resumableSession \?\? createSession/);
|
||||
assert.match(handler, /type: "resume",[\s\S]*?caseId: current\.caseId/);
|
||||
assert.match(handler, /sourceSession\.sessionType === "birth_time_rectification"[\s\S]*?sourceSession[\s\S]*?sessions\.find/);
|
||||
assert.match(hook, /loadRectificationV4Handoff\(\)/);
|
||||
assert.match(hook, /loadRectificationV4\(existingHandoff\.caseId\)/);
|
||||
});
|
||||
|
||||
test("historical completed rectification does not replace the account's unfinished case", () => {
|
||||
@@ -244,7 +235,8 @@ test("completed handoffs return only after the user clicks and target the source
|
||||
assert.doesNotMatch(source, /automaticRectificationContinuation/);
|
||||
assert.match(source, /const returnSession = \(localHandoff/);
|
||||
assert.match(source, /session\.sessionType === "consultation"/);
|
||||
assert.match(source, /onContinueOriginalQuestion=\{\(question\) => void continueRectificationOriginalQuestion\(question\)\}/);
|
||||
assert.match(source, /onContinueOriginalQuestion=\{\(continuation\) => void continueRectificationOriginalQuestion\(continuation\)\}/);
|
||||
assert.match(source, /claimRectificationV4Handoff\(\{[\s\S]*?caseId: continuation\.caseId,[\s\S]*?caseVersion: continuation\.caseVersion,[\s\S]*?question/);
|
||||
assert.match(source, /sessionId: returnSession\.id/);
|
||||
assert.match(source, /setActiveSessionId\(context\.sessionId\)/);
|
||||
assert.match(source, /clearBirthTimeConsultationConsent\([\s\S]*?context\.sessionId/);
|
||||
|
||||
@@ -275,3 +275,51 @@ test("consult route constructs workflow input from the route service rather than
|
||||
const toolInput = route.slice(route.indexOf("const toolInput = consultationInputSchema.parse"));
|
||||
assert.doesNotMatch(toolInput.slice(0, toolInput.indexOf("const workflowContext")), /\.\.\.parsed\.data/);
|
||||
});
|
||||
|
||||
test("v4 continuation builds chart boundaries from the durable range without a reported minute", async () => {
|
||||
const order: string[] = [];
|
||||
const prepared = await prepareConsultationRoute({
|
||||
userId: "user-v4",
|
||||
mode: "general_no_birth_time",
|
||||
candidateRange: { start: "05:13", end: "05:15" },
|
||||
loadProfile: async () => {
|
||||
order.push("profile");
|
||||
return {
|
||||
...profile,
|
||||
reported_birth_time: null,
|
||||
active_birth_time: null,
|
||||
birth_time_source: "period_only",
|
||||
birth_time_status: "reported",
|
||||
};
|
||||
},
|
||||
async resolveTimezoneOffset(value, selectedTime) {
|
||||
order.push(`timezone:${selectedTime}`);
|
||||
return value;
|
||||
},
|
||||
async reserve() {
|
||||
order.push("reserve");
|
||||
return "reserved";
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(order, ["profile", "timezone:05:13", "reserve"]);
|
||||
assert.equal(prepared.consultationMode, "unverified_birth_time");
|
||||
assert.equal(prepared.serverChart?.toolInput.hour, 5);
|
||||
assert.equal(prepared.serverChart?.toolInput.minute, 13);
|
||||
assert.equal(prepared.serverChart?.truth.selectedTimeKind, "candidate_range_boundary");
|
||||
});
|
||||
|
||||
test("v4 continuation request has an independent schema and omits client chart minutes", () => {
|
||||
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const schemaStart = route.indexOf("const v4ContinuationRequestSchema");
|
||||
const schemaEnd = route.indexOf("const chartChatRequestSchema");
|
||||
const schema = route.slice(schemaStart, schemaEnd);
|
||||
|
||||
assert.ok(schemaStart >= 0 && schemaEnd > schemaStart);
|
||||
assert.match(schema, /rectificationHandoff:\s*rectificationV4HandoffSchema/);
|
||||
assert.doesNotMatch(schema, /consultationInputSchema/);
|
||||
assert.doesNotMatch(schema, /\bhour\b|\bminute\b|\blat\b|\blon\b|\btz\b/);
|
||||
assert.match(page, /rectificationHandoff\?\.protocol === "rectification-evidence-v4" \? \{\} : \{/);
|
||||
assert.match(route, /candidateRange:\s*handoffExecution\.acceptedRange/);
|
||||
});
|
||||
|
||||
@@ -263,79 +263,48 @@ test("an active correction target remains cancellable without rendering evidence
|
||||
assert.doesNotMatch(markup, /(已修订)|已记录的真实经历/);
|
||||
});
|
||||
|
||||
test("pending markup and responsive CSS expose accessibility contracts", () => {
|
||||
const pendingController = controller({
|
||||
pending: true,
|
||||
draft: "保留中的文字",
|
||||
getSnapshot: () => ({
|
||||
turn,
|
||||
draft: "保留中的文字",
|
||||
selectedDomain: "career",
|
||||
correctionTarget: null,
|
||||
pending: true,
|
||||
error: "",
|
||||
}),
|
||||
});
|
||||
const markup = renderToStaticMarkup(React.createElement(
|
||||
ConversationalRectificationSurface,
|
||||
surfaceProps(pendingController),
|
||||
));
|
||||
test("v4 markup and responsive CSS expose accessibility and uncertainty contracts", () => {
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const component = readFileSync(
|
||||
new URL("../src/components/rectification-v4-panel.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const wrapper = readFileSync(
|
||||
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(markup, /aria-busy="true"/);
|
||||
assert.match(markup, /Jyotisha 正在核对经历/);
|
||||
assert.doesNotMatch(markup, /正在核对这段经历|Enter 发送|已发送,2\.5 秒/);
|
||||
assert.match(markup, /Jyotisha 正在分析/);
|
||||
assert.match(markup, /<textarea[^>]+disabled=""[^>]*>保留中的文字<\/textarea>/);
|
||||
assert.match(markup, /aria-label="生时校正对话"/);
|
||||
assert.match(markup, /role="alert"|aria-live="polite"/);
|
||||
assert.match(css, /\.conversation\.is-rectification[^}]*padding-bottom:\s*0/);
|
||||
assert.match(css, /\.rectification-chat[^}]*height:\s*100%[^}]*display:\s*flex/);
|
||||
assert.match(css, /\.rectification-message-list[^}]*flex:\s*1[^}]*overflow-y:\s*auto/);
|
||||
assert.match(component, /conversationEnd\.current\?\.scrollIntoView/);
|
||||
assert.match(component, /<div ref=\{conversationEnd\} \/>/);
|
||||
assert.match(css, /\.composer:focus-within[^}]*border-color:/);
|
||||
assert.match(css, /\.composer textarea[^}]*border:\s*0/);
|
||||
assert.match(css, /\.rectification-composer-wrap[^}]*position:\s*static/);
|
||||
assert.match(page, /rectificationSurfaceOpen \? "is-rectification"/);
|
||||
assert.doesNotMatch(css, /\.conversational-domain-picker|\.conversational-event-date/);
|
||||
assert.doesNotMatch(css, /button\[aria-label\$="下一步建议"\]/);
|
||||
assert.match(css, /@media\s*\(prefers-reduced-motion:\s*reduce\)/);
|
||||
assert.doesNotMatch(component, /确认放弃且不应用候选|本轮技术回执/);
|
||||
assert.match(component, /controller\.answer\(undefined, text\)/);
|
||||
assert.match(component, /<section className="rectification-v4-panel" aria-busy=\{processing \|\| controller\.pending\}>/);
|
||||
assert.match(component, /id="rectification-v4-answer"/);
|
||||
assert.match(component, /disabled=\{controller\.pending\}/);
|
||||
assert.match(component, /aria-label="提交这段经历"/);
|
||||
assert.match(component, /event\.key === "Enter" && !event\.shiftKey/);
|
||||
assert.match(component, /onPendingChange/);
|
||||
assert.match(component, /onPendingChange:\s*props\.onPendingChange/);
|
||||
assert.match(component, /onContinueOriginalQuestion\?\./);
|
||||
assert.match(markup, /aria-label="赞"/);
|
||||
assert.match(markup, /aria-label="踩"/);
|
||||
assert.match(markup, /aria-label="复制回答"/);
|
||||
assert.match(markup, /aria-label="重跑回答"/);
|
||||
assert.match(component, /navigator\.clipboard\.writeText/);
|
||||
assert.match(component, /setRegeneratingMessageKey\(messageKey\)/);
|
||||
assert.match(component, /regeneratingMessageKey === message\.renderKey[\s\S]*?state: "thinking"/);
|
||||
assert.match(component, /controller\.pending && canAnswer && regeneratingMessageKey === null/);
|
||||
assert.match(component, /await controller\.regenerate\(\)/);
|
||||
assert.match(component, /message\.renderKey !== latestAssistantKey/);
|
||||
assert.match(css, /\.rectification-message-actions/);
|
||||
assert.match(component, /候选范围,不是已确认的出生分钟/);
|
||||
assert.match(component, /不会把峰值分钟当作真实出生时间/);
|
||||
assert.match(component, /保存这个范围/);
|
||||
assert.match(component, /protocol: "rectification-evidence-v4"[\s\S]*?caseId: caseValue\.id,[\s\S]*?caseVersion: caseValue\.version,[\s\S]*?acceptedRange: accepted/);
|
||||
assert.match(wrapper, /<RectificationV4Panel[\s\S]*?onPendingChange=\{props\.onPendingChange\}/);
|
||||
assert.match(css, /\.rectification-v4-panel \{[^}]*width: min\(860px, 100%\)[^}]*overflow-y: auto/);
|
||||
assert.match(css, /\.rectification-v4-composer textarea \{[^}]*min-height: 112px/);
|
||||
assert.match(css, /@media\s*\(max-width:\s*680px\)[\s\S]*?\.rectification-v4-ranges, \.rectification-v4-evidence-grid \{ grid-template-columns: 1fr; \}/);
|
||||
assert.match(css, /@media\s*\(prefers-reduced-motion:\s*reduce\)/);
|
||||
});
|
||||
|
||||
test("the page persists and rehydrates the full rectification transcript", () => {
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const component = readFileSync(
|
||||
test("the v4 panel rehydrates the active case and event revisions instead of session transcript state", () => {
|
||||
const wrapper = readFileSync(
|
||||
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
|
||||
const client = readFileSync(new URL("../src/lib/rectification-v4/client.ts", import.meta.url), "utf8");
|
||||
const v4Export = wrapper.slice(wrapper.indexOf("export function ConversationalBirthTimeRectification"));
|
||||
|
||||
assert.match(page, /firstSessionMessages[\s\S]*?role: "assistant"[\s\S]*?turn\.narrative\.trim\(\)/);
|
||||
assert.match(page, /messages:\s*durableRectificationMessages\(messages\)/);
|
||||
assert.match(page, /initialMessages=\{activeSession\?\.messages \?\? \[\]\}/);
|
||||
assert.match(component, /initialMessages:\s*props\.initialMessages/);
|
||||
assert.match(v4Export, /return \([\s\S]*?<RectificationV4Panel/);
|
||||
assert.doesNotMatch(v4Export, /initialTurn|initialMessages|useConversationalRectification/);
|
||||
assert.match(hook, /await loadRectificationV4Handoff\(\)/);
|
||||
assert.match(hook, /await loadRectificationV4\(existingHandoff\.caseId\)/);
|
||||
assert.match(hook, /await createRectificationV4\(\)/);
|
||||
assert.match(client, /rectificationV4ApiResponseSchema/);
|
||||
});
|
||||
|
||||
type CdpResponse = Readonly<{
|
||||
@@ -686,107 +655,117 @@ test("Chromium harness keeps the browser sandbox and bounds every external wait"
|
||||
assert.match(source, /fetchJsonWithDeadline/);
|
||||
});
|
||||
|
||||
test("real Chromium at 390px verifies layout, keyboard focus, streamlined controls, and live hook inputs", {
|
||||
test("real Chromium at 390px verifies the v4 range surface, keyboard focus, and no horizontal overflow", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const frontendRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
const directory = mkdtempSync(join(tmpdir(), "rectification-browser-"));
|
||||
const directory = mkdtempSync(join(tmpdir(), "rectification-v4-browser-"));
|
||||
const entryPath = join(directory, "fixture.tsx");
|
||||
const bundlePath = join(directory, "fixture.js");
|
||||
const htmlPath = join(directory, "fixture.html");
|
||||
const userDataDirectory = join(directory, "chrome-profile");
|
||||
const componentPath = join(frontendRoot, "src/components/conversational-birth-time-rectification.tsx");
|
||||
const hookPath = join(frontendRoot, "src/hooks/use-conversational-rectification.ts");
|
||||
const componentPath = join(frontendRoot, "src/components/rectification-v4-panel.tsx");
|
||||
const css = readFileSync(join(frontendRoot, "src/app/globals.css"), "utf8")
|
||||
.replace(/^@import[^;]+;\s*/gm, "");
|
||||
const fixture = `
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ConversationalRectificationSurface } from ${JSON.stringify(componentPath)};
|
||||
import { useConversationalRectification } from ${JSON.stringify(hookPath)};
|
||||
import { RectificationV4Panel } from ${JSON.stringify(componentPath)};
|
||||
|
||||
const caseA = "00000000-0000-4000-8000-000000000821";
|
||||
const caseB = "00000000-0000-4000-8000-000000000829";
|
||||
const longWord = "D9SENSITIVEREFERENCE".repeat(70);
|
||||
const makeTurn = (caseId, turnVersion, status = "active") => ({
|
||||
caseId,
|
||||
journeyProtocol: "conversational-evidence-v3",
|
||||
status,
|
||||
turnVersion,
|
||||
narrative: "## 当前判断\\n\\n**05:18** 只是待验证候选。" + longWord,
|
||||
candidate: {
|
||||
status: "pending_validation",
|
||||
representativeTime: "05:18",
|
||||
rangeStart: "05:10",
|
||||
rangeEnd: "05:26",
|
||||
const eventId = "00000000-0000-4000-8000-000000000822";
|
||||
const now = "2026-07-27T00:00:00.000Z";
|
||||
const data = {
|
||||
case: {
|
||||
id: "00000000-0000-4000-8000-000000000821",
|
||||
userId: "00000000-0000-4000-8000-000000000823",
|
||||
protocol: "rectification-evidence-v4",
|
||||
version: 4,
|
||||
status: "range_ready",
|
||||
phase: "complete",
|
||||
calculationSpec: {
|
||||
version: "rectification-calculation-spec-v4",
|
||||
birthDate: "1990-01-01",
|
||||
candidateRange: { start: "05:00", end: "06:00" },
|
||||
latitude: 25.03,
|
||||
longitude: 121.56,
|
||||
timezoneOffsetHours: 8,
|
||||
ayanamsa: "lahiri",
|
||||
nodeMode: "mean",
|
||||
minuteStep: 1,
|
||||
},
|
||||
calculationSpecHash: "a".repeat(64),
|
||||
evidenceSetHash: "b".repeat(64),
|
||||
currentQuestion: {
|
||||
id: "00000000-0000-4000-8000-000000000824",
|
||||
domain: "career",
|
||||
targetEventId: eventId,
|
||||
prompt: "如果要修订这段经历,请直接写出正确年月和发生了什么。",
|
||||
recallCost: "low",
|
||||
reason: "核对日期敏感性",
|
||||
},
|
||||
latestSnapshot: {
|
||||
id: "00000000-0000-4000-8000-000000000825",
|
||||
caseId: "00000000-0000-4000-8000-000000000821",
|
||||
caseVersion: 4,
|
||||
evidenceSetHash: "b".repeat(64),
|
||||
calculationSpecHash: "a".repeat(64),
|
||||
algorithmVersion: "rectification-v4-range-scoring-1",
|
||||
candidates: [{
|
||||
time: "05:18",
|
||||
score: 9.5,
|
||||
supportingEventIds: [eventId],
|
||||
conflictingEventIds: [],
|
||||
}],
|
||||
clusters: [{
|
||||
rank: 1,
|
||||
startTime: "05:16",
|
||||
endTime: "05:20",
|
||||
representativeTime: "05:18",
|
||||
widthMinutes: 5,
|
||||
peakScore: 9.5,
|
||||
scoreMass: 9.5,
|
||||
}],
|
||||
robustness: {
|
||||
neighborSupportMinutes: 5,
|
||||
leaveOneOutRetentionRate: 0.8,
|
||||
dateSensitivityRetentionRate: 0.9,
|
||||
calculationSpecHashMatched: true,
|
||||
},
|
||||
canConfirmExactMinute: false,
|
||||
canAcceptRange: true,
|
||||
gateReasons: [],
|
||||
createdAt: now,
|
||||
},
|
||||
acceptedRange: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
technicalReceipt: {
|
||||
calculationVersion: "rectification-technical-v1",
|
||||
stableLayers: ["D1"],
|
||||
sensitiveLayers: ["D9", "D10"],
|
||||
candidateDifferenceRefs: ["consult-d9", "consult-d10"],
|
||||
},
|
||||
evidenceRequest: status === "abandoned" ? null : {
|
||||
domains: ["career", "education", "relocation"],
|
||||
datePrecision: "month_preferred",
|
||||
freeTextAllowed: true,
|
||||
},
|
||||
evidenceRecap: status === "abandoned" ? [] : [{
|
||||
id: "00000000-0000-4000-8000-000000000822",
|
||||
summary: "开始第一份长期工作",
|
||||
dateLabel: "2021-07",
|
||||
isCorrection: false,
|
||||
job: null,
|
||||
events: [{
|
||||
id: "00000000-0000-4000-8000-000000000826",
|
||||
eventId,
|
||||
revision: 2,
|
||||
domain: "career",
|
||||
eventKind: "career_change",
|
||||
summary: "2021 年开始第一份长期工作",
|
||||
rawText: "2021 年 7 月开始第一份长期工作",
|
||||
dateRange: { start: "2021-07-01", end: "2021-07-31", precision: "month", label: "2021-07" },
|
||||
scoreability: "scoreable",
|
||||
supersedesRevisionId: null,
|
||||
createdAt: now,
|
||||
}],
|
||||
actions: status === "abandoned" ? [] : status === "paused"
|
||||
? ["answer", "abandon"]
|
||||
: ["answer", "pause", "abandon"],
|
||||
pendingConsultationQuestion: null,
|
||||
});
|
||||
const turns = {
|
||||
activeA1: makeTurn(caseA, 1),
|
||||
activeA3: makeTurn(caseA, 3),
|
||||
activeB1: makeTurn(caseB, 1),
|
||||
abandonedB2: makeTurn(caseB, 2, "abandoned"),
|
||||
};
|
||||
const events = [];
|
||||
|
||||
function Harness() {
|
||||
const [initialTurn, setInitialTurn] = useState(null);
|
||||
const [transportLabel, setTransportLabel] = useState("first");
|
||||
const [callbackLabel, setCallbackLabel] = useState("first");
|
||||
const send = async (command) => {
|
||||
events.push("send:" + transportLabel + ":" + command.type);
|
||||
await new Promise((resolveSend) => setTimeout(resolveSend, 20));
|
||||
if (command.type === "pause") {
|
||||
return makeTurn(command.caseId, command.turnVersion + 1, "paused");
|
||||
}
|
||||
if (command.type === "abandon") {
|
||||
return makeTurn(command.caseId, command.turnVersion + 1, "abandoned");
|
||||
}
|
||||
return makeTurn(command.caseId ?? caseA, (command.turnVersion ?? 0) + 1);
|
||||
};
|
||||
const controller = useConversationalRectification({
|
||||
initialTurn,
|
||||
send,
|
||||
onTurn: (next) => events.push("turn:" + callbackLabel + ":" + next.status),
|
||||
});
|
||||
useEffect(() => {
|
||||
globalThis.__rectificationHarness = {
|
||||
events,
|
||||
setCallbackLabel,
|
||||
setTransportLabel,
|
||||
setTurn(name) { setInitialTurn(name === "none" ? null : turns[name]); },
|
||||
};
|
||||
globalThis.__rectificationReady = true;
|
||||
});
|
||||
return <ConversationalRectificationSurface
|
||||
controller={controller}
|
||||
models={[{ id: "deepseek-chat", label: "DeepSeek", description: "", creditCost: 1, isDefault: true }]}
|
||||
selectedModelId="deepseek-chat"
|
||||
onSelectModel={() => undefined}
|
||||
/>;
|
||||
}
|
||||
createRoot(document.getElementById("root")).render(<Harness />);
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/rectification/v4/handoff" && !init?.method) return new Response(null, { status: 204 });
|
||||
if (path === "/api/rectification/v4/cases" && init?.method === "POST") {
|
||||
return Response.json(data);
|
||||
}
|
||||
throw new Error("unexpected fetch " + path);
|
||||
};
|
||||
createRoot(document.getElementById("root")).render(<RectificationV4Panel />);
|
||||
globalThis.__rectificationReady = true;
|
||||
`;
|
||||
let browser: ChildProcess | null = null;
|
||||
let cdp: CdpSession | null = null;
|
||||
@@ -819,73 +798,40 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro
|
||||
() => cdp?.evaluate<boolean>("globalThis.__rectificationReady === true") ?? Promise.resolve(false),
|
||||
"React fixture readiness",
|
||||
);
|
||||
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA1')");
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>(`document.body.textContent.includes('2021-07 · 开始第一份长期工作')
|
||||
&& !document.body.textContent.includes('已记录这段经历')`) ?? Promise.resolve(false),
|
||||
"streamlined async initial turn",
|
||||
() => cdp?.evaluate<boolean>(`document.body.textContent.includes('05:16–05:20')
|
||||
&& document.body.textContent.includes('保存这个范围')
|
||||
&& document.getElementById('rectification-v4-answer') !== null`) ?? Promise.resolve(false),
|
||||
"v4 range surface",
|
||||
);
|
||||
|
||||
const layout = await cdp.evaluate<{
|
||||
viewport: number;
|
||||
scrollWidth: number;
|
||||
surfaceWidth: number;
|
||||
shortestButton: number;
|
||||
selectCount: number;
|
||||
domainChoiceCount: number;
|
||||
panelWidth: number;
|
||||
rangeColumns: string;
|
||||
exactMinuteClaimVisible: boolean;
|
||||
}>(`(() => {
|
||||
const buttons = [...document.querySelectorAll('.rectification-chat button')]
|
||||
.filter((button) => button.getBoundingClientRect().height > 0);
|
||||
const panel = document.querySelector('.rectification-v4-panel');
|
||||
return {
|
||||
viewport: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
surfaceWidth: document.querySelector('.rectification-chat').getBoundingClientRect().width,
|
||||
shortestButton: Math.min(...buttons.map((button) => button.getBoundingClientRect().height)),
|
||||
selectCount: document.querySelectorAll('.rectification-chat select').length,
|
||||
domainChoiceCount: document.querySelectorAll('[data-evidence-domain]').length,
|
||||
panelWidth: panel.getBoundingClientRect().width,
|
||||
rangeColumns: getComputedStyle(document.querySelector('.rectification-v4-ranges')).gridTemplateColumns,
|
||||
exactMinuteClaimVisible: document.body.textContent.includes('已确认出生分钟'),
|
||||
};
|
||||
})()`);
|
||||
assert.equal(layout.viewport, 390);
|
||||
assert.ok(layout.scrollWidth <= 390, `page overflowed: ${layout.scrollWidth}px`);
|
||||
assert.ok(layout.surfaceWidth <= 366, `surface overflowed padded viewport: ${layout.surfaceWidth}px`);
|
||||
assert.ok(layout.shortestButton >= 44, `shortest button was ${layout.shortestButton}px`);
|
||||
assert.equal(layout.selectCount, 0, "language-first flow should not render date selects");
|
||||
assert.equal(layout.domainChoiceCount, 0, "language-first flow should not render domain buttons");
|
||||
assert.ok(layout.panelWidth <= 366, `panel overflowed padded viewport: ${layout.panelWidth}px`);
|
||||
assert.equal(layout.rangeColumns.trim().split(/\s+/).length, 1);
|
||||
assert.equal(layout.exactMinuteClaimVisible, false);
|
||||
|
||||
const mistakenAnswer = "2020年9月离职写错了";
|
||||
await cdp.evaluate(`(() => {
|
||||
const textarea = document.getElementById('conversational-rectification-answer');
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;
|
||||
setter.call(textarea, ${JSON.stringify(mistakenAnswer)});
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
document.querySelector('[aria-label="发送"]').click();
|
||||
})()`);
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>(`document.querySelector('[aria-label="撤回发送,本次不计入校正"]') !== null`) ?? Promise.resolve(false),
|
||||
"rectification undo window",
|
||||
await cdp.evaluate("document.getElementById('rectification-v4-answer').focus()");
|
||||
assert.equal(
|
||||
await cdp.evaluate<boolean>("document.activeElement?.id === 'rectification-v4-answer'"),
|
||||
true,
|
||||
);
|
||||
await cdp.evaluate("document.querySelector('[aria-label=\"撤回发送,本次不计入校正\"]').click()");
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>(`document.getElementById('conversational-rectification-answer').value === ${JSON.stringify(mistakenAnswer)}`) ?? Promise.resolve(false),
|
||||
"mistaken answer restored to draft",
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_700));
|
||||
assert.equal(await cdp.evaluate<boolean>("globalThis.__rectificationHarness.events.some((event) => event.endsWith(':answer'))"), false);
|
||||
|
||||
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA3')");
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>(`(() => {
|
||||
const text = document.body.textContent;
|
||||
return !text.includes('当前候选')
|
||||
&& !text.includes('候选时间')
|
||||
&& !text.includes('本轮技术回执')
|
||||
&& !text.includes('暂停,稍后继续')
|
||||
&& !text.includes('放弃本次校正')
|
||||
&& !document.querySelector('[role=alertdialog]');
|
||||
})()`) ?? Promise.resolve(false),
|
||||
"streamlined rectification controls",
|
||||
);
|
||||
|
||||
} finally {
|
||||
try {
|
||||
cdp?.close();
|
||||
|
||||
@@ -26,7 +26,6 @@ import type {
|
||||
LoadedConversationalRectificationCase,
|
||||
StoredConversationalRectificationCase,
|
||||
} from "../src/lib/conversational-rectification/store.ts";
|
||||
import { createRectificationQuestionHandoffCoordinator } from "../src/lib/rectification-question-handoff.ts";
|
||||
import type { ConversationalRectificationTelemetryPayload } from "../src/lib/birth-time-journey-telemetry.ts";
|
||||
import { createConversationalRectificationTelemetry } from "../src/lib/birth-time-journey-telemetry.ts";
|
||||
import { conversationalRectificationCreationPolicy } from "../src/lib/conversational-rectification/creation-policy.ts";
|
||||
@@ -35,6 +34,8 @@ const userId = "00000000-0000-4000-8000-000000009001";
|
||||
const caseId = "00000000-0000-4000-8000-000000009002";
|
||||
const originalQuestion = "我下一次适合换工作的时间是什么时候?";
|
||||
const deploymentSha = "0123456789abcdef0123456789abcdef01234567";
|
||||
const openingNarrative = "根据你填写的出生时间信息,当前先核对 05:00–06:00。这只是待核对范围,还不能把其中某一分钟当作已确认出生时间。你可以按自己的节奏讲已经发生的人生经历,一次说一件或连续说多件都可以;记得的年月可以自然地带上,不确定也没关系。";
|
||||
const intermediateFallbackNarrative = "我听到了。你愿意接着说说这件事之后发生了什么吗?";
|
||||
|
||||
const declaredBirthInput = {
|
||||
source: "approximate" as const,
|
||||
@@ -169,6 +170,7 @@ function createSyntheticBackend(options: {
|
||||
legacy?: boolean;
|
||||
allowNewCaseCreation?: boolean;
|
||||
packetFailure?: boolean;
|
||||
createFailure?: boolean;
|
||||
initialReady?: boolean;
|
||||
packetEvidenceCalls?: string[][];
|
||||
} = {}) {
|
||||
@@ -250,6 +252,7 @@ function createSyntheticBackend(options: {
|
||||
return row?.userId === input.userId ? structuredClone(row) : null;
|
||||
},
|
||||
async createCaseWithFirstTurn(input) {
|
||||
if (options.createFailure) throw new Error("synthetic create failure");
|
||||
return save({
|
||||
userId: input.userId,
|
||||
caseId: input.caseId,
|
||||
@@ -430,7 +433,7 @@ async function post(
|
||||
return payload as ConversationalRectificationTurn;
|
||||
}
|
||||
|
||||
test("authenticated synthetic flow covers soft entry, rich evidence, resume, atomic confirmation, and handoff", async () => {
|
||||
test("authenticated synthetic flow covers soft entry, rich evidence, resume, and safety gates", async () => {
|
||||
assert.equal(isDeclaredBirthProfileComplete(onboardingDraft), true, "onboarding may finish without rectification");
|
||||
|
||||
let consent = createBirthTimeConsultationConsentState();
|
||||
@@ -460,16 +463,11 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
|
||||
assert.equal(turn.status, "active");
|
||||
assert.equal(
|
||||
turn.narrative,
|
||||
"当前仍在核对 05:00–06:00 的候选范围,不能视为已经确认的出生分钟。先说一件已经发生的重要经历好吗?请注明哪一年、哪一月以及发生了什么。",
|
||||
"the first visible reply must remain the narrator's authored answer rather than receive a deterministic prefix or suffix",
|
||||
openingNarrative,
|
||||
"the first visible reply must use the deterministic fast opening",
|
||||
);
|
||||
assert.doesNotMatch(turn.narrative, /\bD\d+\b/);
|
||||
assert.deepEqual(
|
||||
turn.evidenceRequest?.domains,
|
||||
["career"],
|
||||
"the narrator chooses the next useful domain instead of receiving a program-authored domain list",
|
||||
);
|
||||
assert.equal(turn.evidenceRequest?.freeTextAllowed, true);
|
||||
assert.equal(turn.evidenceRequest, null);
|
||||
assert.equal(JSON.stringify(turn).includes("candidateWeights"), false);
|
||||
assert.equal(JSON.stringify(turn).includes("private-synthetic-partition"), false);
|
||||
assert.deepEqual(backend.billing(), { reserveCount: 1, chargeCount: 1, releaseCount: 0, state: "charged" });
|
||||
@@ -483,7 +481,7 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
|
||||
assert.equal(turn.status, "active");
|
||||
assert.equal(
|
||||
turn.narrative,
|
||||
"当前仍在核对 05:00–06:00 的候选范围,不能视为已经确认的出生分钟。先说一件已经发生的重要经历好吗?请注明哪一年、哪一月以及发生了什么。",
|
||||
intermediateFallbackNarrative,
|
||||
"a direction change must show the model reply instead of a program-authored redirect template",
|
||||
);
|
||||
assert.doesNotMatch(turn.narrative, /不沿用不符合|自由描述另一件已经发生/);
|
||||
@@ -495,7 +493,7 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
|
||||
});
|
||||
assert.equal(
|
||||
turn.narrative,
|
||||
"当前仍在核对 05:00–06:00 的候选范围,不能视为已经确认的出生分钟。先说一件已经发生的重要经历好吗?请注明哪一年、哪一月以及发生了什么。",
|
||||
intermediateFallbackNarrative,
|
||||
);
|
||||
assert.doesNotMatch(turn.narrative, /我先按你的原话记下|我已保存你的原话|还差时间定位/);
|
||||
assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "日期待补充");
|
||||
@@ -507,7 +505,7 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
|
||||
});
|
||||
assert.equal(
|
||||
turn.narrative,
|
||||
"当前仍在核对 05:00–06:00 的候选范围,不能视为已经确认的出生分钟。先说一件已经发生的重要经历好吗?请注明哪一年、哪一月以及发生了什么。",
|
||||
intermediateFallbackNarrative,
|
||||
"future evidence must remain non-scoreable without replacing the model's visible answer",
|
||||
);
|
||||
assert.doesNotMatch(turn.narrative, /未来事件只能作为背景|不能用于校正评分/);
|
||||
@@ -551,63 +549,19 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
|
||||
turnVersion: turn.turnVersion, domain: "relocation", answer: "2018年9月搬到外地生活",
|
||||
});
|
||||
historicalEvidenceIds.push(turn.evidenceRecap.at(-1)!.id);
|
||||
assert.equal(turn.status, "confirming");
|
||||
assert.equal(turn.candidate.representativeTime, "05:18");
|
||||
assert.equal(backend.activeTime(), "04:58");
|
||||
assert.equal(turn.status, "active");
|
||||
assert.equal(turn.candidate.status, "pending_validation");
|
||||
assert.doesNotMatch(turn.actions.join(","), /confirm/);
|
||||
assert.equal(backend.activeTime(), "04:58", "evidence collection must not replace the active minute");
|
||||
assert.equal(packetEvidenceCalls.some((ids) => ids.includes(futureEvidenceId)), false);
|
||||
assert.deepEqual(packetEvidenceCalls.at(-1), historicalEvidenceIds);
|
||||
|
||||
const wrong = await handler(new Request("https://example.invalid/api/birth-time-conversation", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "confirm", caseId, actionId: "00000000-0000-4000-8000-000000009010",
|
||||
turnVersion: turn.turnVersion, time: "05:17",
|
||||
}),
|
||||
}));
|
||||
assert.equal(wrong.status, 409);
|
||||
assert.equal(backend.activeTime(), "04:58", "a failed confirmation must be atomic");
|
||||
assert.equal(telemetry.at(-1)?.phase, "confirming");
|
||||
assert.equal(telemetry.at(-1)?.billingState, "unchanged");
|
||||
|
||||
turn = await post(secondDevice, {
|
||||
type: "confirm", caseId, actionId: "00000000-0000-4000-8000-000000009011",
|
||||
turnVersion: turn.turnVersion, time: "05:18",
|
||||
});
|
||||
assert.equal(turn.status, "completed");
|
||||
assert.deepEqual(turn.actions, ["continue_original_question"]);
|
||||
assert.equal(backend.activeTime(), "05:18");
|
||||
assert.equal(backend.billing().chargeCount, 1);
|
||||
|
||||
let ordinaryReservations = 0;
|
||||
let ordinaryAnswers = 0;
|
||||
const handoff = createRectificationQuestionHandoffCoordinator<"timing">();
|
||||
const continued = await handoff.continueOriginalQuestion(
|
||||
turn.pendingConsultationQuestion ?? "",
|
||||
{ sessionId: "new-device-chat", theme: "timing" },
|
||||
async (context) => {
|
||||
ordinaryReservations += 1;
|
||||
ordinaryAnswers += 1;
|
||||
assert.equal(context.question, originalQuestion);
|
||||
assert.equal(backend.activeTime(), "05:18");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(continued, true);
|
||||
assert.equal(ordinaryReservations, 1);
|
||||
assert.equal(ordinaryAnswers, 1);
|
||||
|
||||
const chats = new Set(["new-device-chat"]);
|
||||
chats.delete("new-device-chat");
|
||||
assert.equal(chats.size, 0);
|
||||
const caseAfterChatDeletion = backend.cases.get(caseId);
|
||||
assert.equal(caseAfterChatDeletion?.status, "completed", "chat deletion must not cascade to the account case");
|
||||
|
||||
const allowedTelemetryKeys = [
|
||||
"protocol", "phase", "actionKind", "resultCategory", "latencyBucket",
|
||||
"billingState", "errorCategory", "deploymentSha",
|
||||
].sort();
|
||||
assert.ok(telemetry.length >= 10);
|
||||
assert.ok(telemetry.length >= 8);
|
||||
for (const payload of telemetry) {
|
||||
assert.deepEqual(Object.keys(payload).sort(), allowedTelemetryKeys);
|
||||
assert.equal(payload.protocol, "conversational-evidence-v3");
|
||||
@@ -664,7 +618,7 @@ test("v3 telemetry drops invalid payloads without affecting the product request"
|
||||
});
|
||||
|
||||
test("telemetry reports released reservations and authentication rejects before service creation", async () => {
|
||||
const backend = createSyntheticBackend({ packetFailure: true });
|
||||
const backend = createSyntheticBackend({ createFailure: true });
|
||||
const telemetry: ConversationalRectificationTelemetryPayload[] = [];
|
||||
const handler = createBirthTimeConversationPostHandler({
|
||||
authenticate: async () => ({ userId, context: {} }),
|
||||
@@ -933,13 +887,9 @@ test("rollback flag stops only new cases while existing v3 resume stays readable
|
||||
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009055",
|
||||
turnVersion: existingTurn.turnVersion, domain: "relocation", answer: "2018年9月搬到外地生活",
|
||||
});
|
||||
assert.equal(existingTurn.status, "confirming");
|
||||
existingTurn = await post(handler, {
|
||||
type: "confirm", caseId, actionId: "00000000-0000-4000-8000-000000009056",
|
||||
turnVersion: existingTurn.turnVersion, time: "05:18",
|
||||
});
|
||||
assert.equal(existingTurn.status, "completed");
|
||||
assert.equal(backend.activeTime(), "05:18");
|
||||
assert.equal(existingTurn.status, "active");
|
||||
assert.equal(existingTurn.caseId, caseId);
|
||||
assert.equal(backend.activeTime(), "04:58");
|
||||
});
|
||||
|
||||
test("a throwing injected telemetry sink cannot turn a committed request into failure", async () => {
|
||||
|
||||
@@ -85,6 +85,16 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
|
||||
"birth_time_rectification_question_handoffs",
|
||||
"birth_time_rectification_scoring_jobs",
|
||||
"birth_time_rectification_turns",
|
||||
"birth_time_rectification_v4_actions",
|
||||
"birth_time_rectification_v4_candidate_snapshots",
|
||||
"birth_time_rectification_v4_cases",
|
||||
"birth_time_rectification_v4_event_revisions",
|
||||
"birth_time_rectification_v4_events",
|
||||
"birth_time_rectification_v4_handoff_attach_receipts",
|
||||
"birth_time_rectification_v4_handoff_settlements",
|
||||
"birth_time_rectification_v4_handoffs",
|
||||
"birth_time_rectification_v4_jobs",
|
||||
"birth_time_rectification_v4_turns",
|
||||
"chart_profiles",
|
||||
"chat_sessions",
|
||||
"consultation_requests",
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { buildCandidateClusters } from "../src/lib/rectification-v4/candidate-clusters.ts";
|
||||
import { dateRangeFromDeclared, sampledDates } from "../src/lib/rectification-v4/date-range.ts";
|
||||
import { evaluateDecisionGate } from "../src/lib/rectification-v4/decision-gate.ts";
|
||||
import { appendEventRevision, latestEventRevisions } from "../src/lib/rectification-v4/evidence-ledger.ts";
|
||||
import { extractV4EventRevisions } from "../src/lib/rectification-v4/extraction.ts";
|
||||
import { planNextQuestion } from "../src/lib/rectification-v4/question-planner.ts";
|
||||
|
||||
const now = new Date("2026-07-26T00:00:00.000Z");
|
||||
|
||||
test("declared month, quarter and year retain real boundaries instead of invented midpoints", () => {
|
||||
assert.deepEqual(dateRangeFromDeclared("2024-02", "month"), {
|
||||
start: "2024-02-01", end: "2024-02-29", precision: "month", label: "2024-02",
|
||||
});
|
||||
assert.deepEqual(dateRangeFromDeclared("2024-Q2", "quarter"), {
|
||||
start: "2024-04-01", end: "2024-06-30", precision: "quarter", label: "2024-Q2",
|
||||
});
|
||||
assert.deepEqual(dateRangeFromDeclared("2024", "year"), {
|
||||
start: "2024-01-01", end: "2024-12-31", precision: "year", label: "2024",
|
||||
});
|
||||
assert.equal(sampledDates(dateRangeFromDeclared("2024-02", "month")).includes("2024-02-15"), false);
|
||||
});
|
||||
|
||||
test("relationship start and end remain separate immutable events", () => {
|
||||
const startId = randomUUID();
|
||||
const endId = randomUUID();
|
||||
const start = appendEventRevision([], {
|
||||
eventId: startId, domain: "relationship", eventKind: "relationship_start", summary: "关系开始",
|
||||
rawText: "2024年5月开始", dateRange: dateRangeFromDeclared("2024-05", "month"),
|
||||
}, { id: randomUUID(), now });
|
||||
const end = appendEventRevision([start], {
|
||||
eventId: endId, domain: "relationship", eventKind: "relationship_end", summary: "关系结束",
|
||||
rawText: "2024年8月结束", dateRange: dateRangeFromDeclared("2024-08", "month"),
|
||||
}, { id: randomUUID(), now });
|
||||
assert.notEqual(start.eventId, end.eventId);
|
||||
assert.equal(start.eventKind, "relationship_start");
|
||||
assert.equal(end.eventKind, "relationship_end");
|
||||
});
|
||||
|
||||
test("family evidence is retained explicitly as context only", () => {
|
||||
const revision = appendEventRevision([], {
|
||||
eventId: randomUUID(), domain: "family", eventKind: "family_event", summary: "家庭变化",
|
||||
rawText: "家庭发生变化", dateRange: dateRangeFromDeclared("2020", "year"),
|
||||
}, { id: randomUUID(), now });
|
||||
assert.equal(revision.scoreability, "context_only");
|
||||
});
|
||||
|
||||
test("candidate minutes merge into ranked contiguous clusters", () => {
|
||||
const id = randomUUID();
|
||||
const clusters = buildCandidateClusters([
|
||||
{ time: "05:13", score: 100, supportingEventIds: [id], conflictingEventIds: [] },
|
||||
{ time: "05:14", score: 99, supportingEventIds: [id], conflictingEventIds: [] },
|
||||
{ time: "05:15", score: 98, supportingEventIds: [id], conflictingEventIds: [] },
|
||||
{ time: "05:16", score: 70, supportingEventIds: [], conflictingEventIds: [id] },
|
||||
{ time: "05:17", score: 97, supportingEventIds: [id], conflictingEventIds: [] },
|
||||
{ time: "05:18", score: 97, supportingEventIds: [id], conflictingEventIds: [] },
|
||||
]);
|
||||
assert.deepEqual(clusters.map((cluster) => [cluster.rank, cluster.startTime, cluster.endTime]), [
|
||||
[1, "05:13", "05:15"], [2, "05:17", "05:18"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("decision gate can accept a stable range but never an exact minute", () => {
|
||||
const result = evaluateDecisionGate({
|
||||
clusters: [{ rank: 1, startTime: "05:13", endTime: "05:15", representativeTime: "05:13", widthMinutes: 3, peakScore: 10, scoreMass: 29 }],
|
||||
robustness: { neighborSupportMinutes: 3, leaveOneOutRetentionRate: 1, dateSensitivityRetentionRate: 0.9, calculationSpecHashMatched: true },
|
||||
scoreableEventCount: 10,
|
||||
scoreableDomainCount: 5,
|
||||
});
|
||||
assert.equal(result.canAcceptRange, true);
|
||||
assert.equal(result.canConfirmExactMinute, false);
|
||||
});
|
||||
|
||||
test("question planner asks one deterministic low-recall question at a time", () => {
|
||||
const question = planNextQuestion({
|
||||
askedDomains: ["education"], coveredDomains: ["education"],
|
||||
candidateSplitByDomain: { relocation: 0.8, relationship: 0.2 }, id: randomUUID(),
|
||||
});
|
||||
assert.equal(question.domain, "relocation");
|
||||
assert.equal(question.targetEventId, null);
|
||||
assert.equal(question.prompt.includes("搬家"), true);
|
||||
});
|
||||
|
||||
test("question planner refines imprecise scoreable events after domain coverage", () => {
|
||||
const eventId = randomUUID();
|
||||
const event = appendEventRevision([], {
|
||||
eventId, domain: "education", eventKind: "education_milestone", summary: "高中毕业",
|
||||
rawText: "2016年高中毕业", dateRange: dateRangeFromDeclared("2016", "year"),
|
||||
}, { id: randomUUID(), now });
|
||||
const question = planNextQuestion({
|
||||
askedDomains: ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family"],
|
||||
coveredDomains: ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family"],
|
||||
events: [event],
|
||||
attemptedRefinementEventIds: [],
|
||||
id: randomUUID(),
|
||||
});
|
||||
assert.equal(question.targetEventId, eventId);
|
||||
assert.equal(question.prompt.includes("高中毕业"), true);
|
||||
|
||||
const fallback = planNextQuestion({
|
||||
askedDomains: ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family"],
|
||||
coveredDomains: ["education", "relocation", "relationship", "career", "finance", "health_pressure", "family"],
|
||||
events: [event],
|
||||
attemptedRefinementEventIds: [eventId],
|
||||
id: randomUUID(),
|
||||
});
|
||||
assert.equal(fallback.targetEventId, null);
|
||||
assert.equal(fallback.domain, "other");
|
||||
assert.equal(fallback.prompt.includes("暂停"), true);
|
||||
});
|
||||
|
||||
test("targeted date answer appends a revision without duplicating the scoreable event", () => {
|
||||
const eventId = randomUUID();
|
||||
const original = appendEventRevision([], {
|
||||
eventId, domain: "education", eventKind: "education_milestone", summary: "高中毕业",
|
||||
rawText: "2016年高中毕业", dateRange: dateRangeFromDeclared("2016", "year"),
|
||||
}, { id: randomUUID(), now });
|
||||
const revisions = extractV4EventRevisions({
|
||||
answer: "2016年6月8日",
|
||||
sourceTurnId: randomUUID(),
|
||||
asOfDate: "2026-07-26",
|
||||
existing: [original],
|
||||
targetEventId: eventId,
|
||||
now,
|
||||
});
|
||||
assert.equal(revisions.length, 1);
|
||||
assert.equal(revisions[0]?.eventId, eventId);
|
||||
assert.equal(revisions[0]?.revision, 2);
|
||||
assert.equal(revisions[0]?.dateRange.precision, "day");
|
||||
assert.equal(revisions[0]?.dateRange.start, "2016-06-08");
|
||||
assert.equal(latestEventRevisions([original, ...revisions]).length, 1);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { mock } from "node:test";
|
||||
|
||||
import {
|
||||
createRectificationV4HandoffService,
|
||||
rectificationQuestionFingerprint,
|
||||
} from "../src/lib/rectification-handoff-service.ts";
|
||||
import { claimRectificationV4Handoff } from "../src/lib/rectification-v4/client.ts";
|
||||
|
||||
const userId = "00000000-0000-4000-8000-000000004001";
|
||||
const caseId = "00000000-0000-4000-8000-000000004002";
|
||||
const actionId = "00000000-0000-4000-8000-000000004003";
|
||||
const requestId = "00000000-0000-4000-8000-000000004004";
|
||||
const question = "未来半年是否适合换工作?";
|
||||
const acceptedRange = { start: "05:13", end: "05:15" } as const;
|
||||
|
||||
function projection(status: "pending" | "claimed" | "in_progress" | "consumed") {
|
||||
return {
|
||||
protocol: "rectification-evidence-v4" as const,
|
||||
caseId,
|
||||
caseVersion: 7,
|
||||
question,
|
||||
questionFingerprint: rectificationQuestionFingerprint(question),
|
||||
requestId,
|
||||
status,
|
||||
acceptedRange,
|
||||
};
|
||||
}
|
||||
|
||||
test("v4 handoff adapter binds all RPCs to case, version, action, request, and database range", async () => {
|
||||
const calls: Array<{ name: string; args: Readonly<Record<string, unknown>> }> = [];
|
||||
const service = createRectificationV4HandoffService({
|
||||
async rpc(name, args) {
|
||||
calls.push({ name, args });
|
||||
if (name === "load_birth_time_rectification_v4_handoff") return { data: projection("pending"), error: null };
|
||||
if (name === "begin_birth_time_rectification_v4_handoff_execution") {
|
||||
return { data: { status: "ready", requestId, billingReused: false, credits: 8, acceptedRange }, error: null };
|
||||
}
|
||||
if (name === "settle_birth_time_rectification_v4_handoff") {
|
||||
return { data: { status: "consumed", requestId, credits: 8 }, error: null };
|
||||
}
|
||||
return { data: projection(name.startsWith("claim_") ? "claimed" : "pending"), error: null };
|
||||
},
|
||||
});
|
||||
|
||||
await service.attach({ userId, caseId, caseVersion: 7, actionId, question: ` ${question} ` });
|
||||
await service.load({ userId, caseId });
|
||||
await service.claim({ userId, caseId, caseVersion: 7, actionId, question });
|
||||
const execution = await service.beginExecution({ userId, caseId, caseVersion: 7, claimActionId: actionId, requestId, question });
|
||||
await service.settle({ userId, caseId, claimActionId: actionId, requestId, emitted: true });
|
||||
|
||||
assert.deepEqual(calls.map(({ name }) => name), [
|
||||
"attach_birth_time_rectification_v4_question",
|
||||
"load_birth_time_rectification_v4_handoff",
|
||||
"claim_birth_time_rectification_v4_handoff",
|
||||
"begin_birth_time_rectification_v4_handoff_execution",
|
||||
"settle_birth_time_rectification_v4_handoff",
|
||||
]);
|
||||
assert.deepEqual(calls[0]?.args, {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_expected_version: 7,
|
||||
p_action_id: actionId,
|
||||
p_question: question,
|
||||
p_question_fingerprint: rectificationQuestionFingerprint(question),
|
||||
});
|
||||
assert.deepEqual(calls[2]?.args, {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_expected_version: 7,
|
||||
p_action_id: actionId,
|
||||
p_question_fingerprint: rectificationQuestionFingerprint(question),
|
||||
});
|
||||
assert.deepEqual(calls[3]?.args, {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_expected_version: 7,
|
||||
p_claim_action_id: actionId,
|
||||
p_request_id: requestId,
|
||||
p_question_fingerprint: rectificationQuestionFingerprint(question),
|
||||
});
|
||||
assert.deepEqual(calls[4]?.args, {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_claim_action_id: actionId,
|
||||
p_request_id: requestId,
|
||||
p_emitted: true,
|
||||
});
|
||||
assert.deepEqual(execution.acceptedRange, acceptedRange);
|
||||
});
|
||||
|
||||
test("lost v4 claim response retries with the same action id", async () => {
|
||||
const bodies: Array<Record<string, unknown>> = [];
|
||||
let attempts = 0;
|
||||
mock.method(globalThis, "fetch", async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new TypeError("lost response");
|
||||
return Response.json(projection("claimed"));
|
||||
});
|
||||
try {
|
||||
const claimed = await claimRectificationV4Handoff({ caseId, caseVersion: 7, question });
|
||||
assert.equal(claimed.status, "claimed");
|
||||
assert.equal(bodies.length, 2);
|
||||
assert.equal(bodies[0]?.actionId, bodies[1]?.actionId);
|
||||
assert.equal(claimed.claimActionId, bodies[1]?.actionId);
|
||||
} finally {
|
||||
mock.restoreAll();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const sql = readFileSync(new URL("../supabase/migrations/20260726020000_birth_time_rectification_v4.sql", import.meta.url), "utf8");
|
||||
|
||||
test("v4 migration creates canonical append-only storage and leased jobs", () => {
|
||||
for (const table of [
|
||||
"birth_time_rectification_v4_cases", "birth_time_rectification_v4_turns",
|
||||
"birth_time_rectification_v4_events", "birth_time_rectification_v4_event_revisions",
|
||||
"birth_time_rectification_v4_candidate_snapshots", "birth_time_rectification_v4_jobs",
|
||||
]) assert.match(sql, new RegExp(`create table public\\.${table}`));
|
||||
assert.match(sql, /for update skip locked/);
|
||||
assert.match(sql, /lease_expires_at/);
|
||||
assert.match(sql, /stale_rectification_v4_job/);
|
||||
assert.match(sql, /can_confirm_exact_minute = false/);
|
||||
assert.match(sql, /question_target_event_id uuid/);
|
||||
assert.match(sql, /foreign key \(case_id, question_target_event_id\)/);
|
||||
assert.match(sql, /domain not in \('family', 'other'\) or scoreability = 'context_only'/);
|
||||
});
|
||||
|
||||
test("v4 handoff SQL enforces range acceptance, leases, idempotent settlement, and no profile time write", () => {
|
||||
for (const functionName of [
|
||||
"attach_birth_time_rectification_v4_question",
|
||||
"load_birth_time_rectification_v4_handoff",
|
||||
"claim_birth_time_rectification_v4_handoff",
|
||||
"begin_birth_time_rectification_v4_handoff_execution",
|
||||
"settle_birth_time_rectification_v4_handoff",
|
||||
]) {
|
||||
assert.match(sql, new RegExp(`create function public\\.${functionName}\\(`, "i"));
|
||||
assert.match(sql, new RegExp(`grant execute on function public\\.${functionName}\\([\\s\\S]*?to service_role`, "i"));
|
||||
}
|
||||
assert.match(sql, /accepted_range_start is null[\s\S]*accepted_range_end is null[\s\S]*rectification_v4_handoff_conflict/i);
|
||||
assert.match(sql, /lease_expires_at > pg_catalog\.now\(\)[\s\S]*'in_progress'/i);
|
||||
assert.match(sql, /state in \('claimed', 'executing'\)[\s\S]*lease_expires_at > pg_catalog\.now\(\)/i);
|
||||
assert.match(sql, /birth_time_rectification_v4_handoff_settlements/i);
|
||||
assert.doesNotMatch(sql, /update\s+public\.profiles[\s\S]*active_birth_time/i);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import test from "node:test";
|
||||
|
||||
import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts";
|
||||
import type { CalculationSpec } from "../src/lib/rectification-v4/contracts.ts";
|
||||
import { calculationSpecHash } from "../src/lib/rectification-v4/fingerprints.ts";
|
||||
import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts";
|
||||
import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts";
|
||||
|
||||
const now = () => new Date("2026-07-26T08:00:00.000Z");
|
||||
const spec: CalculationSpec = {
|
||||
version: "rectification-calculation-spec-v4",
|
||||
birthDate: "1997-08-08",
|
||||
candidateRange: { start: "04:30", end: "05:30" },
|
||||
latitude: 36.419,
|
||||
longitude: 114.213,
|
||||
timezoneOffsetHours: 8,
|
||||
ayanamsa: "lahiri",
|
||||
nodeMode: "mean",
|
||||
minuteStep: 1,
|
||||
};
|
||||
|
||||
async function answerAndRun(
|
||||
service: ReturnType<typeof createRectificationV4CaseService>,
|
||||
worker: ReturnType<typeof createRectificationV4Worker>,
|
||||
userId: string,
|
||||
caseId: string,
|
||||
version: number,
|
||||
answer: string,
|
||||
) {
|
||||
const queued = await service.answer({
|
||||
userId,
|
||||
caseId,
|
||||
actionId: randomUUID(),
|
||||
expectedCaseVersion: version,
|
||||
answer,
|
||||
});
|
||||
assert.ok(queued?.job);
|
||||
assert.equal(await worker.runOnce(), true);
|
||||
const loaded = await service.loadCase(userId, caseId);
|
||||
assert.ok(loaded);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
test("fixture replay returns ranges only and never mutates the profile birth minute", async () => {
|
||||
const profile = { active_birth_time: "05:00:00" };
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createRectificationV4CaseService(store, { now });
|
||||
const worker = createRectificationV4Worker({
|
||||
store,
|
||||
now,
|
||||
engine: {
|
||||
async score({ calculationSpec, events }) {
|
||||
const ids = events.map((event) => event.eventId);
|
||||
return {
|
||||
resultId: randomUUID(),
|
||||
calculationSpecHash: calculationSpecHash(calculationSpec),
|
||||
candidates: [
|
||||
{ time: "05:13", score: 100, supportingEventIds: ids, conflictingEventIds: [] },
|
||||
{ time: "05:14", score: 99, supportingEventIds: ids, conflictingEventIds: [] },
|
||||
{ time: "05:15", score: 98, supportingEventIds: ids, conflictingEventIds: [] },
|
||||
{ time: "05:16", score: 60, supportingEventIds: [], conflictingEventIds: ids },
|
||||
{ time: "05:17", score: 97.8, supportingEventIds: ids, conflictingEventIds: [] },
|
||||
{ time: "05:18", score: 97.7, supportingEventIds: ids, conflictingEventIds: [] },
|
||||
{ time: "05:19", score: 97.6, supportingEventIds: ids, conflictingEventIds: [] },
|
||||
],
|
||||
robustness: {
|
||||
neighborSupportMinutes: 3,
|
||||
leaveOneOutRetentionRate: 1,
|
||||
dateSensitivityRetentionRate: 0.9,
|
||||
},
|
||||
missingLayers: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const userId = randomUUID();
|
||||
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
let loaded = await answerAndRun(
|
||||
service,
|
||||
worker,
|
||||
userId,
|
||||
created.case.id,
|
||||
created.case.version,
|
||||
"2015年高中毕业后复读一年,2016年再次高中毕业",
|
||||
);
|
||||
assert.deepEqual(loaded.events.map((event) => [event.dateRange.start, event.dateRange.end]), [
|
||||
["2015-01-01", "2015-12-31"],
|
||||
["2016-01-01", "2016-12-31"],
|
||||
]);
|
||||
loaded = await answerAndRun(service, worker, userId, created.case.id, loaded.case.version, "2018年8月搬家到北京");
|
||||
loaded = await answerAndRun(
|
||||
service,
|
||||
worker,
|
||||
userId,
|
||||
created.case.id,
|
||||
loaded.case.version,
|
||||
"2020年5月开始恋爱,2022年3月分手",
|
||||
);
|
||||
|
||||
const snapshot = loaded.case.latestSnapshot;
|
||||
assert.ok(snapshot);
|
||||
assert.equal(snapshot.canConfirmExactMinute, false);
|
||||
assert.equal(snapshot.canAcceptRange, true);
|
||||
assert.deepEqual(snapshot.clusters.map((cluster) => [cluster.startTime, cluster.endTime]), [
|
||||
["05:13", "05:15"],
|
||||
["05:17", "05:19"],
|
||||
]);
|
||||
assert.equal(snapshot.clusters[0]?.representativeTime, "05:13");
|
||||
assert.equal(loaded.case.acceptedRange, null);
|
||||
|
||||
const accepted = await service.acceptRange({
|
||||
userId,
|
||||
caseId: created.case.id,
|
||||
actionId: randomUUID(),
|
||||
expectedCaseVersion: loaded.case.version,
|
||||
startTime: "05:13",
|
||||
endTime: "05:15",
|
||||
});
|
||||
assert.deepEqual(accepted?.case.acceptedRange, { start: "05:13", end: "05:15" });
|
||||
assert.equal(accepted?.case.latestSnapshot?.canConfirmExactMinute, false);
|
||||
assert.equal(profile.active_birth_time, "05:00:00");
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts";
|
||||
import type { CalculationSpec } from "../src/lib/rectification-v4/contracts.ts";
|
||||
import { calculationSpecHash } from "../src/lib/rectification-v4/fingerprints.ts";
|
||||
import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts";
|
||||
import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts";
|
||||
|
||||
const fixedNow = () => new Date("2026-07-26T12:00:00.000Z");
|
||||
const spec: CalculationSpec = {
|
||||
version: "rectification-calculation-spec-v4",
|
||||
birthDate: "1997-08-08",
|
||||
candidateRange: { start: "04:30", end: "05:30" },
|
||||
latitude: 36.419,
|
||||
longitude: 114.213,
|
||||
timezoneOffsetHours: 8,
|
||||
ayanamsa: "lahiri",
|
||||
nodeMode: "mean",
|
||||
minuteStep: 1,
|
||||
};
|
||||
|
||||
|
||||
|
||||
test("same calculation spec resumes the unfinished case", async () => {
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
||||
const userId = randomUUID();
|
||||
const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
const resumed = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: { ...spec } });
|
||||
|
||||
assert.equal(resumed.case.id, first.case.id);
|
||||
assert.equal(store.cases.size, 1);
|
||||
});
|
||||
|
||||
test("changed calculation spec atomically abandons the old case and stales its job", async () => {
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
||||
const userId = randomUUID();
|
||||
const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
const queued = await service.answer({
|
||||
userId, caseId: first.case.id, actionId: randomUUID(), expectedCaseVersion: 0, answer: "2016年9月上大学",
|
||||
});
|
||||
assert.ok(queued?.job);
|
||||
|
||||
const replacement = await service.createCase({
|
||||
userId,
|
||||
actionId: randomUUID(),
|
||||
calculationSpec: { ...spec, candidateRange: { start: "04:45", end: "05:30" } },
|
||||
});
|
||||
|
||||
assert.notEqual(replacement.case.id, first.case.id);
|
||||
assert.equal(store.cases.get(first.case.id)?.status, "abandoned");
|
||||
assert.equal(store.cases.get(first.case.id)?.currentQuestion, null);
|
||||
assert.equal(store.jobs.get(queued.job.id)?.status, "stale");
|
||||
assert.equal((await service.loadActive(userId))?.case.id, replacement.case.id);
|
||||
});
|
||||
|
||||
test("an accepted range closes the active lifecycle and allows a new case", async () => {
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
||||
const userId = randomUUID();
|
||||
const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
await store.transitionCase({
|
||||
userId,
|
||||
caseId: first.case.id,
|
||||
actionId: randomUUID(),
|
||||
expectedCaseVersion: first.case.version,
|
||||
status: "range_ready",
|
||||
phase: "complete",
|
||||
acceptedRange: { start: "05:13", end: "05:15" },
|
||||
now: fixedNow().toISOString(),
|
||||
});
|
||||
|
||||
assert.equal(await service.loadActive(userId), null);
|
||||
const next = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
assert.notEqual(next.case.id, first.case.id);
|
||||
assert.equal(store.cases.size, 2);
|
||||
});
|
||||
|
||||
test("answer is durably queued and poll remains read only", async () => {
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
||||
const userId = randomUUID();
|
||||
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
assert.equal(created.case.status, "awaiting_answer");
|
||||
assert.equal(created.case.currentQuestion?.domain, "education");
|
||||
const queued = await service.answer({
|
||||
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
|
||||
answer: "2015年7月高中毕业后复读一年,2016年6月再次毕业",
|
||||
});
|
||||
assert.equal(queued?.case.status, "processing");
|
||||
assert.equal(queued?.job?.status, "pending");
|
||||
const before = JSON.stringify([...store.jobs.values()]);
|
||||
const polled = await service.loadCase(userId, created.case.id);
|
||||
assert.equal(polled?.job, null);
|
||||
assert.equal(JSON.stringify([...store.jobs.values()]), before);
|
||||
});
|
||||
|
||||
test("worker extracts dated events, keeps one question and never confirms an exact minute", async () => {
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
||||
const userId = randomUUID();
|
||||
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
const queued = await service.answer({
|
||||
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
|
||||
answer: "2015年7月高中毕业后复读一年,2016年6月再次毕业",
|
||||
});
|
||||
const worker = createRectificationV4Worker({
|
||||
store,
|
||||
now: fixedNow,
|
||||
engine: { async score() { throw new Error("engine must not run before enough events"); } },
|
||||
});
|
||||
assert.equal(await worker.runOnce(), true);
|
||||
const done = await service.loadCase(userId, created.case.id);
|
||||
assert.equal(done?.case.status, "awaiting_answer");
|
||||
assert.equal(done?.events.length, 2);
|
||||
assert.equal(done?.case.currentQuestion?.domain, "relocation");
|
||||
assert.equal(done?.case.latestSnapshot, null);
|
||||
assert.equal(queued?.job?.status, "pending");
|
||||
});
|
||||
|
||||
test("completed job rejects stale case or calculation hashes", async () => {
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
||||
const userId = randomUUID();
|
||||
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
await service.answer({ userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0, answer: "2016年9月上大学" });
|
||||
const claimed = await store.claimNextJob("worker", fixedNow().toISOString());
|
||||
assert.ok(claimed);
|
||||
await assert.rejects(() => store.completeJob({
|
||||
workerId: "worker", jobId: claimed.job.id, expectedCaseVersion: claimed.case.version,
|
||||
inputEvidenceSetHash: "0".repeat(64), outputEvidenceSetHash: claimed.case.evidenceSetHash,
|
||||
calculationSpecHash: claimed.case.calculationSpecHash, newEventRevisions: [], snapshot: null,
|
||||
nextQuestion: claimed.case.currentQuestion, status: "awaiting_answer", phase: "collecting_evidence",
|
||||
}, fixedNow().toISOString()), /stale_job/);
|
||||
});
|
||||
|
||||
test("worker moves from domain coverage to targeted date refinement without a null-question dead state", async () => {
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
||||
const userId = randomUUID();
|
||||
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
const scoreableCounts: number[] = [];
|
||||
const worker = createRectificationV4Worker({
|
||||
store,
|
||||
now: fixedNow,
|
||||
engine: {
|
||||
async score({ calculationSpec, events }) {
|
||||
scoreableCounts.push(events.length);
|
||||
const ids = events.map((event) => event.eventId);
|
||||
return {
|
||||
resultId: randomUUID(),
|
||||
calculationSpecHash: calculationSpecHash(calculationSpec),
|
||||
candidates: [
|
||||
{ time: "05:13", score: 100, supportingEventIds: ids, conflictingEventIds: [] },
|
||||
{ time: "05:14", score: 99, supportingEventIds: ids, conflictingEventIds: [] },
|
||||
{ time: "05:15", score: 98, supportingEventIds: ids, conflictingEventIds: [] },
|
||||
],
|
||||
robustness: {
|
||||
neighborSupportMinutes: 3,
|
||||
leaveOneOutRetentionRate: 1,
|
||||
dateSensitivityRetentionRate: 0.5,
|
||||
},
|
||||
missingLayers: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let current = created;
|
||||
for (const answer of [
|
||||
"2016年高中毕业",
|
||||
"2018年8月搬家到北京",
|
||||
"2020年5月开始恋爱",
|
||||
"2021年3月入职公司",
|
||||
"2022年4月收入明显变化",
|
||||
"2023年5月住院",
|
||||
"2024年6月家庭发生变化",
|
||||
]) {
|
||||
const queued = await service.answer({
|
||||
userId,
|
||||
caseId: created.case.id,
|
||||
actionId: randomUUID(),
|
||||
expectedCaseVersion: current.case.version,
|
||||
answer,
|
||||
});
|
||||
assert.ok(queued?.job);
|
||||
assert.equal(await worker.runOnce(), true);
|
||||
current = (await service.loadCase(userId, created.case.id))!;
|
||||
assert.equal(current.case.status === "awaiting_answer" && current.case.currentQuestion === null, false);
|
||||
}
|
||||
|
||||
const targetEventId = current.case.currentQuestion?.targetEventId;
|
||||
assert.ok(targetEventId);
|
||||
assert.equal(current.case.status, "awaiting_answer");
|
||||
assert.equal(current.case.currentQuestion?.prompt.includes("更具体的日期"), true);
|
||||
|
||||
const queued = await service.answer({
|
||||
userId,
|
||||
caseId: created.case.id,
|
||||
actionId: randomUUID(),
|
||||
expectedCaseVersion: current.case.version,
|
||||
answer: "2016年6月8日",
|
||||
});
|
||||
assert.ok(queued?.job);
|
||||
assert.equal(await worker.runOnce(), true);
|
||||
current = (await service.loadCase(userId, created.case.id))!;
|
||||
|
||||
const targetedRevisions = current.events.filter((event) => event.eventId === targetEventId);
|
||||
assert.deepEqual(targetedRevisions.map((event) => event.revision), [1, 2]);
|
||||
assert.equal(targetedRevisions[1]?.dateRange.precision, "day");
|
||||
assert.equal(scoreableCounts.at(-1), 6);
|
||||
assert.equal(current.case.status === "awaiting_answer" && current.case.currentQuestion === null, false);
|
||||
assert.notEqual(current.case.currentQuestion?.targetEventId, targetEventId);
|
||||
});
|
||||
@@ -30,6 +30,10 @@ const syncScript = new URL(
|
||||
"../../deploy/sync-staging-tree.sh",
|
||||
import.meta.url,
|
||||
);
|
||||
const stagingCompose = new URL(
|
||||
"../../deploy/docker-compose.staging.yml",
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
function read(url: URL): string {
|
||||
return readFileSync(url, "utf8");
|
||||
@@ -270,7 +274,7 @@ test("first immutable deployment rolls back to validated local image IDs", () =>
|
||||
"if [ \"$1\" = compose ]; then",
|
||||
" if [[ \" $* \" == *\" up -d --no-build --remove-orphans \"* ]]; then",
|
||||
` printf '%s|%s|%s|%s\\n' \"\${API_IMAGE:-}\" \"\${WEB_IMAGE:-}\" \"\${GITHUB_SHA:-}\" \"$*\" >>'${rollbackLog}'`,
|
||||
" [[ \"$*\" == *\" api web caddy\" ]] && exit 0",
|
||||
" [[ \"$*\" == *\" api web rectification-v4-worker caddy\" ]] && exit 0",
|
||||
" exit 42",
|
||||
" fi",
|
||||
" exit 0",
|
||||
@@ -308,7 +312,7 @@ test("first immutable deployment rolls back to validated local image IDs", () =>
|
||||
attempts[1],
|
||||
new RegExp(`^${previousApiId}\\|${previousWebId}\\|${previousSha}\\|`),
|
||||
);
|
||||
assert.match(attempts[1], /api web caddy$/);
|
||||
assert.match(attempts[1], /api web rectification-v4-worker caddy$/);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -328,10 +332,19 @@ test("normal deployment checks migrations but never applies them", () => {
|
||||
assert.doesNotMatch(runner, /--profile migration run --rm migrator/);
|
||||
assert.doesNotMatch(runner, /npm\s+run\s+db:migrate(?!:check)/);
|
||||
assert.doesNotMatch(runner, /pull api web postgres/);
|
||||
assert.match(runner, /verify_container_image rectification-v4-worker \"\$WEB_IMAGE\"/);
|
||||
assert.match(runner, /adminRoot\.status !== 302/);
|
||||
assert.match(runner, /adminRoot\.headers\.get\("location"\) !== "\/admin\/codes"/);
|
||||
});
|
||||
|
||||
test("staging runs the rectification V4 worker from the immutable web image", () => {
|
||||
const compose = read(stagingCompose);
|
||||
assert.match(compose, /rectification-v4-worker:/);
|
||||
assert.match(compose, /image: \$\{WEB_IMAGE:-jyotisha-web:local\}/);
|
||||
assert.match(compose, /command: \["npm", "run", "worker:rectification-v4"\]/);
|
||||
assert.ok(compose.includes("JYOTISH_API_BASE: http://api:5200"));
|
||||
});
|
||||
|
||||
test("manual migration uses only PostgreSQL and the digest-pinned migrator", () => {
|
||||
const workflow = read(migrationWorkflow);
|
||||
const runner = read(migrationScript);
|
||||
|
||||
Reference in New Issue
Block a user