All files result.js

100% Statements 217/217
98.55% Branches 136/138
100% Functions 12/12
100% Lines 217/217

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 21810x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 60x 60x 10x 10x 10x 1679x 1679x 1257x 1679x 1679x 10x 10x 10x 10x 10x 10x 140x 140x 140x 281x 281x 13x 13x 13x 8x 7x 5x 5x 5x 13x 10x 10x 13x 13x 175x 140x 140x 10x 10x 10x 10x 139x 139x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 139x 10x 10x 139x 139x 1x 1x 1x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 10x 10x 10x 10x 10x 10x 10x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 139x 10x 10x 1428x 1428x 1325x 1428x 1428x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 476x 476x 476x 476x 476x 442x 10137x 10137x 10137x 10137x 442x 476x 476x 476x 476x 476x 476x 476x 476x 476x 476x 10x 10x 10x 11x 9x 9x 11x 11x 10x 10x 10x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x  
// Copyright 2026 优谷雅言 open.shengzhiai.com. Licensed under the Apache License, Version 2.0.
// Result parsing for native REST, compat REST, native WS and compat WS (bare result JSON, no envelope),
// and the progress frames of both WS channels.
 
import { WARNING_TABLE } from './error-table.js';
 
/** Native coreType values (CONTRACT 1). */
export const NATIVE_CORE_TYPES = Object.freeze(['word', 'sentence', 'passage', 'connected', 'open', 'alpha', 'pinyin']);
 
/** Shengtong compatible coreType paths (CONTRACT 3.2). */
export const COMPAT_CORE_TYPES = Object.freeze([
  'word.eval',
  'word.eval.pro',
  'sent.eval',
  'sent.eval.pro',
  'para.eval',
  'alpha.eval',
  'word.eval.cn',
  'sent.eval.cn',
  'para.eval.cn',
  'pinyin',
]);
 
/** Warning code to English server message, kept from 1.x. */
export const WARNING_MESSAGES = Object.freeze(
  Object.values(WARNING_TABLE).reduce((acc, e) => {
    acc[e.code] = e.message;
    return acc;
  }, {}),
);
 
function num(v) {
  if (v === null || v === undefined || v === '' || typeof v === 'boolean') return null;
  const n = Number(v);
  return Number.isFinite(n) ? n : null;
}
 
const arr = (v) => (Array.isArray(v) ? v : []);
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);
 
/** Server warnings as [{code, message}], from numbers, numeric strings or {code, message} objects. */
export function normalizeWarnings(...sources) {
  const out = [];
  const seen = new Set();
  for (const src of sources) {
    if (src == null) continue;
    for (const item of Array.isArray(src) ? src : [src]) {
      let code = null;
      let message = null;
      if (typeof item === 'number') code = item;
      else if (typeof item === 'string' && /^\d+$/.test(item)) code = Number(item);
      else if (item && typeof item === 'object' && item.code != null && Number.isFinite(Number(item.code))) {
        code = Number(item.code);
        if (typeof item.message === 'string') message = item.message;
      }
      if (code == null || seen.has(code)) continue;
      seen.add(code);
      const entry = WARNING_TABLE[code];
      out.push(Object.freeze({ code, message: message != null ? message : entry ? entry.message : '' }));
    }
  }
  return out;
}
 
const str = (v) => (typeof v === 'string' ? v : null);
 
/** Connected speech mode: no result.overall, the total is result.connected_overall. */
function connectedPart(r) {
  if (r.connected_overall == null && r.linking == null && !Array.isArray(r.boundaries)) return null;
  return {
    overall: num(r.connected_overall),
    linking: num(r.linking),
    rhythm: num(r.rhythm),
    elision: num(r.elision),
    reduction: num(r.reduction),
    nBoundaries: num(r.n_boundaries),
    boundaries: arr(r.boundaries),
    coverage: obj(r.coverage),
    metrics: obj(r.raw),
    report: obj(r.report),
  };
}
 
/** Open speaking mode: content, languageUse, delivery and feedback sub-objects. */
function openPart(r) {
  if (!obj(r.content) && !obj(r.delivery) && !obj(r.languageUse) && r.transcript == null) return null;
  return {
    taskType: str(r.taskType),
    transcript: str(r.transcript),
    hasSpeech: typeof r.hasSpeech === 'boolean' ? r.hasSpeech : null,
    content: obj(r.content),
    languageUse: obj(r.languageUse),
    delivery: obj(r.delivery),
    feedback: obj(r.feedback),
    audioQuality: obj(r.audioQuality),
    openTaskAudit: obj(r.openTaskAudit),
    aggregation: obj(r.aggregation),
    rubricVersion: str(r.rubricVersion),
  };
}
 
/**
 * Unified result. Works for native and compat responses and for WS result frames.
 * meta: {mode, coreType, idempotencyKey, replayed, localWarnings}
 * Every mode keeps its own fields in raw; overall falls back to connected_overall.
 */
export function normalizeEvalResult(raw, meta) {
  const m = meta || {};
  const o = obj(raw) || {};
  const r = obj(o.result) || {};
  const ys = obj(r.yuguScores);
  const ysReading = ys ? (obj(ys.reading_skill) ? ys.reading_skill.overall : ys.reading_skill) : null;
  const dims = {
    integrity: num(r.integrity),
    accuracy: num(r.accuracy != null ? r.accuracy : r.pronunciation),
    pronunciation: num(r.pronunciation),
    fluency: num(r.fluency),
    tone: num(r.tone),
    rhythm: num(r.rhythm),
    emotion: num(r.emotion != null ? r.emotion : ys ? ys.emotion : null),
    readingSkill: num(r.reading_skill != null ? r.reading_skill : ysReading),
  };
  const durationSource = r.numeric_duration != null ? r.numeric_duration : r.duration != null ? r.duration : r.duration_s;
  const asr = obj(o.asrText);
  const std = obj(o.standardAudio);
  return {
    recordId: o.recordId != null ? String(o.recordId) : null,
    eof: num(o.eof),
    overall: num(r.overall != null ? r.overall : r.connected_overall),
    dims,
    speed: num(r.speed),
    durationSeconds: num(durationSource),
    words: arr(r.words),
    sentences: arr(r.sentences),
    paragraphs: arr(r.paragraphs),
    connected: connectedPart(r),
    open: openPart(r),
    asrText: asr && typeof asr.text === 'string' ? asr : null,
    report: obj(o.report),
    standardAudio: std && typeof std.url === 'string' && std.url ? std : null,
    yuguScores: ys,
    compositeReport: obj(r.compositeReport),
    warnings: normalizeWarnings(r.warning, o.warnings),
    localWarnings: Array.isArray(m.localWarnings) ? m.localWarnings.slice() : [],
    coreType: m.coreType || str(r._coreType),
    language: str(r._language) || str(r.language),
    mode: m.mode || 'native',
    idempotencyKey: m.idempotencyKey || null,
    replayed: m.replayed === true || o.replayed === true,
    raw: o,
  };
}
 
/** Integer count of a progress frame; -1 when absent or not an integer. */
function count(v) {
  if (v === null || v === undefined || v === '' || typeof v === 'boolean') return -1;
  const n = Number(v);
  return Number.isInteger(n) ? n : -1;
}
 
/**
 * Partial delivered to onPartial from a progress frame {"eof":0,"result":{...}} of either streaming
 * channel (CONTRACT 5.5). bytes and raw are unchanged from 2.0.0. With reading progress the frame
 * has result.progress (every unit of the reference text, {word, read, matched}) and the three
 * counts; without it hasProgress is false, the counts are -1 and progress is empty. Compat frames
 * also carry the Shengtong-shaped incremental result.words, recordId "" and the kernel and resource
 * versions; they stay in raw and are never read here.
 */
export function normalizePartial(frame) {
  const f = obj(frame) || {};
  const r = obj(f.result) || {};
  const hasProgress = Array.isArray(r.progress);
  const progress = [];
  if (hasProgress) {
    for (const item of r.progress) {
      const u = obj(item);
      if (!u) continue;
      progress.push(Object.freeze({ word: u.word == null ? '' : String(u.word), read: u.read === true, matched: u.matched === true }));
    }
  }
  return {
    bytes: r.bytes != null && Number.isFinite(Number(r.bytes)) ? Number(r.bytes) : null,
    readUnits: count(r.readUnits),
    matchedUnits: count(r.matchedUnits),
    totalUnits: count(r.totalUnits),
    progress,
    hasProgress,
    raw: f,
  };
}
 
/** TTS audio URL as a playable URL. /audio/... maps to {baseUrl}/tts/audio/... (CONTRACT 2.1). */
export function resolveTtsUrl(baseUrl, audioUrl) {
  if (!audioUrl) return '';
  if (/^https?:\/\//i.test(audioUrl)) return audioUrl;
  if (audioUrl.startsWith('/audio/')) return `${baseUrl}/tts${audioUrl}`;
  return audioUrl.startsWith('/') ? `${baseUrl}${audioUrl}` : `${baseUrl}/${audioUrl}`;
}
 
/** TTS result from the {code:0, data:{...}} envelope. */
export function normalizeTtsResult(raw, meta) {
  const m = meta || {};
  const o = obj(raw) || {};
  const d = obj(o.data) || {};
  const audioUrl = typeof d.audioUrl === 'string' ? d.audioUrl : '';
  return {
    audioUrl,
    absoluteUrl: resolveTtsUrl(m.baseUrl || '', audioUrl),
    duration: d.duration != null ? String(d.duration) : null,
    durationSeconds: num(d.duration),
    format: typeof d.format === 'string' ? d.format : null,
    warnings: arr(d.warnings),
    idempotencyKey: m.idempotencyKey || null,
    replayed: m.replayed === true,
    raw: o,
  };
}