import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; const component = readFileSync( new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8", ); const chat = readFileSync( new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8", ); const messageRow = readFileSync( new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8", ); const messageActions = readFileSync( new URL("../src/components/chat-message-actions.tsx", import.meta.url), "utf8", ); const route = readFileSync( new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8", ); const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const agent = readFileSync( new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8", ); const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const board = readFileSync( new URL("../src/components/rectification-board.tsx", import.meta.url), "utf8", ); const boardModel = readFileSync( new URL("../src/lib/rectification-board-model.ts", import.meta.url), "utf8", ); const houseTable = readFileSync( new URL("../src/components/rectification-house-table.tsx", import.meta.url), "utf8", ); const progressLabels = readFileSync( new URL("../src/lib/rectification-activity-labels.ts", import.meta.url), "utf8", ); const completedActivityReceipt = readFileSync( new URL("../src/components/completed-activity-receipt.tsx", import.meta.url), "utf8", ); const choiceCardComponent = readFileSync( new URL("../src/components/rectification-choice-card.tsx", import.meta.url), "utf8", ); const caseRoute = readFileSync( new URL("../src/app/api/rectification/cases/[caseId]/route.ts", import.meta.url), "utf8", ); const adoptSkillRoute = readFileSync( new URL("../src/app/api/rectification/cases/[caseId]/adopt-skill/route.ts", import.meta.url), "utf8", ); const upgradeSkillRoute = readFileSync( new URL("../src/app/api/rectification/cases/[caseId]/upgrade-skill/route.ts", import.meta.url), "utf8", ); const regenerateRoute = readFileSync( new URL( "../src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts", import.meta.url, ), "utf8", ); test("birth-time rectification entry mounts the V9 case-ref chat", () => { assert.match(component, /return /); assert.match(component, /caseId: string;/); assert.match(component, /readonly: boolean;/); assert.match(component, /shouldStartOpening: boolean;/); assert.match(component, /initialTurns:/); }); test("persisted rectification turns hydrate after the async Case refresh", () => { assert.match(chat, /function messagesFromTurns\(initialTurns:/); assert.match(chat, /useState\(\(\) => messagesFromTurns\(initialTurns\)\)/); assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.at\(-1\)\?\.id \?\? "loading"\}`\}/); assert.match(page, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/); assert.match(component, /methods\?: readonly string\[\]/); }); test("opening is server-owned: shouldStartOpening drives the first turn, never client history", () => { assert.doesNotMatch(chat, /initialMessages\.length > 0 \|\| openingStarted/); assert.doesNotMatch(chat, /agenticOpeningInstruction|用户刚进入生时校正会话/); assert.match(chat, /shouldStartOpening/); assert.match(chat, /if \(readonly \|\| openingStarted\.current \|\| !shouldStartOpening\) return/); assert.match(chat, /void send\("opening", ""\)/); assert.match(route, /action: z\.enum\(\["opening", "message", "read_only"\]\)/); assert.match(page, /shouldStartOpening=\{rectificationShouldStartOpening\}/); assert.match(page, /setRectificationShouldStartOpening\(opened\.shouldStartOpening\)/); }); test("incomplete profiles stay in the shared onboarding flow before any open request", () => { const opening = page.slice( page.indexOf("async function openRectificationCase"), page.indexOf("async function openRectificationFromHomepage"), ); assert.match(opening, /const missingStep = missingProfileStep\(profile\)/); assert.match(opening, /setOnboardingStep\(missingStep\)/); assert.ok( opening.indexOf("const missingStep = missingProfileStep(profile)") < opening.indexOf("/api/rectification/cases/open"), ); assert.match(chat, /payload\?\.code === "profile_incomplete"/); }); test("server profile failures pause automatic rectification resume", () => { const resumeEffect = page.slice( page.indexOf('useEffect(() => {\n if (!hydrated'), page.indexOf('useEffect(() => {\n if (!hydrated || !accountId'), ); const incompleteHandler = page.slice( page.indexOf("function handleRectificationProfileIncomplete"), page.indexOf("async function draftSynastryQuestionFromChart"), ); assert.match(resumeEffect, /\|\| rectificationError\) return/); assert.match(incompleteHandler, /setRectificationError\("profile_incomplete"\)/); assert.ok( incompleteHandler.indexOf('setRectificationError("profile_incomplete")') < incompleteHandler.indexOf("setRectificationSessionId(null)"), ); }); test("homepage rectification open failures always surface a visible recovery message", () => { const opening = page.slice( page.indexOf("async function openRectificationCase"), page.indexOf("async function openRectificationFromHomepage"), ); assert.match( opening, /code === "profile_incomplete"[\s\S]*?handleRectificationProfileIncomplete\(\)/, ); assert.match( opening, /code === "invalid_open_request"[\s\S]*?setRectificationError\(/, ); assert.match( opening, /catch \{[\s\S]*?setRectificationError\("生时校正会话暂时无法打开,请稍后重试。"\)/, ); assert.match( page, /rectificationError && !rectificationSurfaceOpen && \([\s\S]*?role="alert"[\s\S]*?rectificationErrorMessage/, ); }); test("account rehydration normalizes persisted ISO birth dates before completeness checks", () => { const profileReader = page.slice( page.indexOf("function readProfile"), page.indexOf("function readSessions"), ); assert.match(profileReader, /const date = normalizePersistedBirthDate\(/); }); test("candidate acceptance refreshes the profile result without overwriting an open draft", () => { const refresh = page.slice( page.indexOf("async function refreshAccount"), page.indexOf("function updateSession"), ); assert.match(refresh, /const nextProfile = readProfile\(latest\.profile\)/); assert.match( refresh, /setProfile\(\(current\) => preserveShallowEqual\(current, nextProfile\)\)/, ); assert.doesNotMatch(refresh, /setProfileDraft/); }); test("agent tool calls never end silently; the runner owns completion and failure", () => { assert.doesNotMatch(route, /agenticRectificationMaxSteps = 8/); assert.match(agent, /RECTIFICATION_AGENT_STEP_BUDGETS/); assert.match(agent, /RECTIFICATION_AGENT_HARD_STEP_LIMIT/); assert.match(route, /send\(\{ type: "done", emitted: true \}\)/); assert.doesNotMatch(route, /send\(\{ type: "done", emitted: false \}\)/); }); test("persisted turns survive remounts; duplicate openings are suppressed by the server", () => { assert.match(chat, /initialTurns/); assert.match(chat, /const openingStarted = useRef\(false\)/); assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.at\(-1\)\?\.id \?\? "loading"\}`\}/); assert.match(page, /initialTurns=\{rectificationTurns\}/); assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/); }); test("the agent route verifies the exact Case/Session binding before any turn", () => { assert.match(route, /caseId: z\.string\(\)\.uuid\(\)/); assert.match(route, /sessionId: z\.string\(\)\.uuid\(\)/); assert.match(route, /requestId: z\.string\(\)\.uuid\(\)/); assert.match(route, /boundSessionId !== sessionId/); assert.match(route, /chatSession\.agentic_rectification_case_id !== caseId/); assert.doesNotMatch(route, /conversation\.action === "opening" && persistedMessages\.length > 0/); assert.doesNotMatch(route, /\.update\(\{ messages: nextMessages, updated_at:/); }); test("candidate results restore through the durable Candidate Snapshot API, never agent text", () => { assert.match(chat, /\/api\/rectification\/cases\/\$\{encodeURIComponent\(caseId\)\}\?sessionId=/); assert.doesNotMatch(chat, /savedSentinel|AYANAM_RECTIFICATION_SAVED|hidden block/); assert.doesNotMatch(chat, /