/** * Pure reducer for evaluation-run session state. * * Extracted from `useRunSession` so the event-handling logic is: * - a pure function (no refs, no API calls, no WebSocket manipulation) * - trivially unit-testable * - free of the stale-closure hazards that bit the previous * `useCallback` + `useState` implementation. * * Side effects (closing the WebSocket, refreshing the Run object) are * handled by the hook via a `useEffect` that watches `completed`. */ import type { CaseSnapshot } from '../api' import type { CaseError, CaseState, RunError, RunProgress, RuleResultState, TurnState, } from './useRunSession' export interface SessionState { cases: CaseState[] progress: RunProgress | null scenarioSnapshot: Record isLive: boolean completed: boolean finalStatus: 'completed' | 'failed' | 'cancelled' | null errorInfo: RunError | null } export const initialSessionState: SessionState = { cases: [], progress: null, scenarioSnapshot: {}, isLive: false, completed: false, finalStatus: null, errorInfo: null, } export type SessionAction = | { type: 'RESET' } | { type: 'SET_LIVE'; live: boolean } | { type: 'SET_SNAPSHOT'; snapshot: Record } | { type: 'LOAD_HISTORY'; cases: CaseState[]; progress: RunProgress; snapshot: Record } | { type: 'WS_EVENT'; event: WsEvent } | { type: 'FINALIZE_FROM_RUN'; status: string; summary: any } | { type: 'SET_RUN'; run: { id: string } | null } /** * Shape of a WebSocket event payload emitted by the backend. Typed loosely * because the backend sends a different shape per event name. */ export interface WsEvent { event: string [key: string]: any } // ── helpers ────────────────────────────────────────────────────────────── function findOrCreateCase(cases: CaseState[], caseId: string, isDynamic = false): { cases: CaseState[] index: number } { const idx = cases.findIndex((c) => c.caseId === caseId) if (idx !== -1) return { cases, index: idx } const newCase: CaseState = { caseId, status: 'waiting', turns: [], ruleResults: [], errors: [], isDynamic, } return { cases: [...cases, newCase], index: cases.length } } function updateCase(cases: CaseState[], index: number, patch: Partial): CaseState[] { const next = [...cases] next[index] = { ...next[index], ...patch } return next } function upsertTurn(cases: CaseState[], caseIndex: number, turn: TurnState): CaseState[] { const cs = cases[caseIndex] const turns = [...cs.turns] const existing = turns.findIndex((t) => t.roundIndex === turn.roundIndex) if (existing >= 0) turns[existing] = turn else turns.push(turn) return updateCase(cases, caseIndex, { turns, status: 'running' }) } // ── reducer ────────────────────────────────────────────────────────────── export function sessionReducer(state: SessionState, action: SessionAction): SessionState { switch (action.type) { case 'RESET': return initialSessionState case 'SET_LIVE': return { ...state, isLive: action.live } case 'SET_SNAPSHOT': return { ...state, scenarioSnapshot: action.snapshot } case 'LOAD_HISTORY': return { ...state, cases: action.cases, progress: action.progress, scenarioSnapshot: action.snapshot, } case 'SET_RUN': // The Run object itself lives in a separate useState; this action // only exists to piggy-back a reset of transient session state when // the user selects a different run. return state case 'FINALIZE_FROM_RUN': { const { status, summary } = action if (status === 'completed') { return { ...state, completed: true, isLive: false, finalStatus: 'completed', errorInfo: null } } if (status === 'failed') { const err = summary?.error let final: 'failed' | 'cancelled' = 'failed' let errInfo: RunError | null = null if (err && typeof err === 'object' && err.code === 'cancelled_by_user') { final = 'cancelled' errInfo = err } else if (typeof err === 'string') { if (err === 'cancelled by user') { final = 'cancelled' errInfo = { code: 'cancelled_by_user', message: '评测已手动停止' } } else { errInfo = { message: err } } } else if (err && typeof err === 'object') { errInfo = err } return { ...state, completed: true, isLive: false, finalStatus: final, errorInfo: errInfo } } return state } case 'WS_EVENT': { const ev = action.event switch (ev.event) { case 'case_start': { const { cases, index } = findOrCreateCase(state.cases, ev.case_id) return { ...state, cases: updateCase(cases, index, { status: 'running' }), progress: { done: state.progress?.done ?? 0, total: ev.total ?? state.progress?.total ?? 0, currentIndex: ev.index ?? 0, }, } } case 'case_end': { const { cases, index } = findOrCreateCase(state.cases, ev.case_id) return { ...state, cases: updateCase(cases, index, { status: 'done', passed: !!ev.passed }), progress: { done: (state.progress?.done ?? 0) + 1, total: ev.total ?? state.progress?.total ?? 0, currentIndex: ev.index ?? state.progress?.currentIndex ?? 0, }, } } case 'messages_generated': { const { cases, index } = findOrCreateCase(state.cases, ev.case_id, true) return { ...state, cases: updateCase(cases, index, { generatedMessages: ev.messages, isDynamic: true, }), } } case 'turn_start': { const { cases, index } = findOrCreateCase(state.cases, ev.case_id) const turn: TurnState = { roundIndex: ev.round, message: ev.message, pending: true, } return { ...state, cases: upsertTurn(cases, index, turn) } } case 'turn_end': { const { cases, index } = findOrCreateCase(state.cases, ev.case_id) const existing = cases[index].turns.findIndex((t) => t.roundIndex === ev.round) const merged: TurnState = { roundIndex: ev.round, message: existing >= 0 ? cases[index].turns[existing].message : '', reply: ev.reply_text, latency_ms: ev.latency_ms, pending: false, } return { ...state, cases: upsertTurn(cases, index, merged) } } case 'turn_error': { const { cases, index } = findOrCreateCase(state.cases, ev.case_id ?? 'unknown') const cs = cases[index] const errors: CaseError[] = [...cs.errors, { round: ev.round, message: ev.error ?? '未知错误' }] const turns = [...cs.turns] const existing = turns.findIndex((t) => t.roundIndex === ev.round) if (existing >= 0) { turns[existing] = { ...turns[existing], pending: false, error: ev.error } } return { ...state, cases: updateCase(cases, index, { errors, turns }) } } case 'rule_result': { const { cases, index } = findOrCreateCase(state.cases, ev.case_id) const cs = cases[index] const ruleResults: RuleResultState[] = [ ...cs.ruleResults, { rule_type: ev.rule_type, passed: !!ev.passed, score: ev.score, reason: ev.reason, }, ] return { ...state, cases: updateCase(cases, index, { ruleResults }) } } case 'error': { if (ev.case_id) { const { cases, index } = findOrCreateCase(state.cases, ev.case_id) const cs = cases[index] const errors: CaseError[] = [...cs.errors, { message: ev.error ?? '未知错误' }] return { ...state, cases: updateCase(cases, index, { errors }) } } return { ...state, errorInfo: { message: ev.error ?? '未知错误' }, } } case 'run_completed': { const status: string = ev.status ?? 'completed' const reason: string | undefined = ev.reason const errorPayload = ev.error let final: 'completed' | 'failed' | 'cancelled' = 'completed' let errInfo: RunError | null = null if (status === 'completed') { final = 'completed' } else if (reason === 'cancelled') { final = 'cancelled' } else { final = 'failed' } if (typeof errorPayload === 'string') { errInfo = { message: errorPayload } } else if (errorPayload && typeof errorPayload === 'object') { errInfo = { code: errorPayload.code, message: errorPayload.message ?? '评测失败' } } // Mark any still-running cases as done so the UI doesn't show // spinners forever after the run ends. const cases = state.cases.map((c) => c.status === 'running' ? { ...c, status: 'done' as const } : c, ) return { ...state, cases, completed: true, isLive: false, finalStatus: final, errorInfo: errInfo, } } default: return state } } default: return state } }