/** * Fake dependencies for the rectification chat's parameter functions * (TASK-rectification-code-split-20260926). The split moved `send`, * `submitStructuredChoice` and `acceptCandidate` out of the component into * `runRectificationChatTurn`, `runRectificationStructuredChoice` and * `runRectificationCandidateAccept`; tests that used to slice those bodies out * of the component source now call the functions with these fakes and assert * on what they do (requests, abort wiring, transcript, error line). */ import type { SetStateAction } from "react"; import type { RenderMessage } from "../src/components/rectification-message-entry.tsx"; import { runRectificationChatTurn, type RectificationChatTurnDeps, type RectificationRunOutcome, type RectificationTurnAction, type RectificationTurnContinuation, } from "../src/lib/rectification-chat-turn-run.ts"; import { runRectificationStructuredChoice, type RectificationStructuredChoiceDeps, } from "../src/lib/rectification-chat-choice-run.ts"; import { runRectificationCandidateAccept, type RectificationCandidateAcceptDeps, } from "../src/lib/rectification-chat-accept-run.ts"; import type { CandidateResult, CurrentQuestionModel } from "../src/lib/rectification-chat-snapshot.ts"; import { parseRectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts"; import type { RectificationChatViewInput } from "../src/lib/rectification-chat-view.ts"; import type { RectificationChoiceCard } from "../src/lib/rectification-agentic/v9/choice-card.ts"; export const RUN_CASE_ID = "44444444-4444-4444-8444-444444444444"; export const RUN_SESSION_ID = "33333333-3333-4333-8333-333333333333"; export const RUN_FOCUS_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1"; export type RecordedState = Readonly<{ get: () => T; set: (next: SetStateAction) => void; }>; export function recordedState(initial: T): RecordedState { let value = initial; return { get: () => value, set: (next) => { value = typeof next === "function" ? (next as (current: T) => T)(value) : next; }, }; } export type FetchCall = Readonly<{ url: string; init: RequestInit | undefined; body: Record | null }>; /** * Swap `fetch` (and the `window` frame scheduler the stream buffer needs) for * the duration of `run`. `respond` sees each call in order. */ export async function withFakeFetch( respond: (call: FetchCall, index: number) => Response | Promise, run: (calls: FetchCall[]) => Promise, ): Promise { const calls: FetchCall[] = []; const originalFetch = globalThis.fetch; const globals = globalThis as { window?: unknown }; const originalWindow = globals.window; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const body = typeof init?.body === "string" ? JSON.parse(init.body) as Record : null; const call = { url: String(input), init, body }; calls.push(call); return respond(call, calls.length - 1); }) as typeof fetch; globals.window = { requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0), cancelAnimationFrame: (handle: ReturnType) => clearTimeout(handle), setTimeout, clearTimeout, }; try { return await run(calls); } finally { globalThis.fetch = originalFetch; globals.window = originalWindow; } } export function ndjsonResponse(events: readonly Record[], init: ResponseInit = {}): Response { return new Response(`${events.map((event) => JSON.stringify(event)).join("\n")}\n`, { status: 200, headers: { "content-type": "application/x-ndjson; charset=utf-8" }, ...init, }); } export function jsonResponse(payload: unknown, status = 200): Response { return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" }, }); } /** Everything the three run functions write, recorded for assertions. */ export function chatRunRecorder(initialMessages: RenderMessage[] = []) { const transcript = recordedState(initialMessages); /** Every transcript the run committed, in order (each setMessages call). */ const messageHistory: RenderMessage[][] = []; const messages: RecordedState = { get: transcript.get, set: (next) => { transcript.set(next); messageHistory.push(transcript.get()); }, }; const error = recordedState(""); const choiceNonce = recordedState(0); const nextUserActionId = recordedState(null); const acceptingCandidateId = recordedState(null); const candidateResult = recordedState(null); const savedTime = recordedState(null); const savedStatus = recordedState<"accepted" | "confirmed" | null>(null); const pending: boolean[] = []; const liveLabels: string[] = []; const snapshotLoads: Array<((turns: readonly unknown[]) => void) | undefined> = []; const pins: number[] = []; const sends: Array> = []; const completed: number[] = []; const profileIncomplete: number[] = []; const billing: Array<{ source?: string } | undefined> = []; const messageChanges: unknown[] = []; const saved: Array<[string, string]> = []; const refs = { keyCounter: { current: 0 }, currentQuestionRef: { current: null as CurrentQuestionModel | null }, runAbort: { current: null as AbortController | null }, lastRunOutcome: { current: null as RectificationRunOutcome }, }; const shared = { caseId: RUN_CASE_ID, sessionId: RUN_SESSION_ID, busy: false, readonly: false, ...refs, conversationAnchor: { pinLatestTurn: () => { pins.push(Date.now()); } }, setError: error.set, setMessages: messages.set, setPending: (value: boolean) => { pending.push(value); }, beginLiveRun: (label: string) => { liveLabels.push(label); }, loadCaseSnapshot: async (merge?: (turns: readonly unknown[]) => void) => { snapshotLoads.push(merge); merge?.([]); return undefined; }, send: async (action: RectificationTurnAction, messageText: string, continuation?: RectificationTurnContinuation) => { sends.push({ action, messageText, continuation }); }, onCompleted: () => { completed.push(1); }, onProfileIncomplete: () => { profileIncomplete.push(1); }, onMessagesChange: (next: unknown) => { messageChanges.push(next); }, }; function turnDeps(overrides: Partial = {}): RectificationChatTurnDeps { return { ...shared, selectedModelId: "model-a", conversationReadonly: false, rememberLiveActivity: (label: string) => { liveLabels.push(label); return label; }, onOpenBilling: (options?: { source?: string }) => { billing.push(options); }, ...overrides, }; } function choiceDeps( choiceCard: RectificationChoiceCard | null, overrides: Partial = {}, ): RectificationStructuredChoiceDeps { return { ...shared, choiceCard, currentQuestion: null, actionIdForChoice: () => globalThis.crypto.randomUUID(), setChoiceNonce: choiceNonce.set, setNextUserActionId: nextUserActionId.set, ...overrides, }; } function acceptDeps(overrides: Partial = {}): RectificationCandidateAcceptDeps { return { ...shared, candidateResult: candidateResult.get(), acceptingCandidateId: acceptingCandidateId.get(), setAcceptingCandidateId: acceptingCandidateId.set, setCandidateResult: candidateResult.set, setSavedTime: savedTime.set, setSavedStatus: savedStatus.set, onSaved: (time: string, status: string) => { saved.push([time, status]); }, ...overrides, }; } return { messages, messageHistory, error, choiceNonce, nextUserActionId, acceptingCandidateId, candidateResult, savedTime, savedStatus, pending, liveLabels, snapshotLoads, pins, sends, completed, profileIncomplete, billing, messageChanges, saved, refs, turnDeps, choiceDeps, acceptDeps, }; } export function fixtureChoiceCard(overrides: Partial = {}): RectificationChoiceCard { return { question_id: "distinguish:1", method_id: "candidate_discriminator", prompt: "2023 年前后,有没有换过工作或职责明显变化?", why: "", varga: null, choice_mode: "A/B/C/D", options: [ { key: "A", label: "明确发生且时间吻合" }, { key: "B", label: "发生过但程度较弱" }, { key: "C", label: "没有这回事" }, { key: "D", label: "这段记不清楚" }, ], stop_label: "先这样,先看当前范围", stop_message: "", scoring: true, probe_id: "probe:1", case_revision: 3, focus_id: RUN_FOCUS_ID, ...overrides, } as RectificationChoiceCard; } export function fixtureCandidateResult(): NonNullable { return { resultId: "result-1", candidates: [ { candidateId: "cand-1", time: "05:07" }, { candidateId: "cand-2", time: "05:15" }, ], selectedTime: null, } as unknown as NonNullable; } /** * Where the stop button's AbortController goes during a tapped choice or an * adoption: is the controller in `runAbort` the one whose signal the request * carries, and is it cleared once the run ends? */ export async function abortWiringOf(kind: "choice" | "accept") { const recorder = chatRunRecorder(); let signalMatchesRunAbort = false; let url = ""; await withFakeFetch((call) => { url = call.url; signalMatchesRunAbort = Boolean( recorder.refs.runAbort.current && call.init?.signal === recorder.refs.runAbort.current.signal, ); return jsonResponse({ ok: false }, 500); }, async () => { if (kind === "choice") { await runRectificationStructuredChoice(recorder.choiceDeps(fixtureChoiceCard()), "answer_choice", "A"); } else { recorder.candidateResult.set(fixtureCandidateResult()); await runRectificationCandidateAccept(recorder.acceptDeps(), "cand-1"); } }); return { url, signalMatchesRunAbort, runAbortCleared: recorder.refs.runAbort.current === null, }; } /** * A result the delivery card may offer: `completed_with_range` with selection * allowed, parsed by the real parser (same snapshot shape as * rectification-delivery-vs-collect-20260914.test.ts). */ export function offerableCandidateResult(overrides: Record = {}): NonNullable { const result = parseRectificationCandidateResult({ resultId: "11111111-1111-4111-8111-111111111111", candidates: [ { candidateId: "88888888-8888-4888-8888-888888888881", rank: 1, time: "04:53", relativeSupport: 16, tiedMinuteCount: 2 }, { candidateId: "88888888-8888-4888-8888-888888888882", rank: 2, time: "05:00", relativeSupport: 16, tiedMinuteCount: 2 }, ], overallConfidence: "medium", selectionAllowed: true, canAdopt: false, confirmationAllowed: false, representativeTime: "04:53", selectedTime: null, selectionKind: null, credibleRange: ["04:48", "05:07"], session_outcome: "completed_with_range", decisionReceipt: { display_allowed: true, selection_allowed: true, acceptance_allowed: true, propose_allowed: true, confirmation_allowed: false, accept_allowed: true, confirm_allowed: false, }, ...overrides, }); if (!result) throw new Error("offerableCandidateResult: parser rejected the fixture"); return result; } export function settledAssistant(renderKey: string, extra: Partial = {}): RenderMessage { return { role: "assistant", text: `回复 ${renderKey}`, renderKey, state: "settled", ...extra }; } /** Input for deriveRectificationChatView with a quiet, loaded, idle surface. */ export function chatViewInput(overrides: Partial = {}): RectificationChatViewInput { return { messages: [], busy: false, readonly: false, shouldStartOpening: false, openingRequested: false, acceptingCandidateId: null, selectionOfferLock: null, candidateResult: null, currentQuestion: null, questionSource: null, choiceCard: null, caseStatus: null, caseSnapshotLoaded: true, searchWindow: null, caseStage: null, nextUserActionId: null, interviewStopReason: null, interviewSessionOutcome: null, questionRetryAttempts: 0, savedTime: null, ...overrides, }; } export { runRectificationChatTurn, runRectificationStructuredChoice, runRectificationCandidateAccept };