test-article-errors.mjs 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. import assert from 'node:assert/strict'
  2. import { readFile } from 'node:fs/promises'
  3. import ts from 'typescript'
  4. const sourceUrl = new URL('../src/views/Editor/EnglishSpeaking/services/articleErrors.ts', import.meta.url)
  5. let source = await readFile(sourceUrl, 'utf8')
  6. // `lang` 是 PPT 运行时的响应式对象;测试里换成 identity 桩,直接断言「返回的是哪个 key」,
  7. // 而不是断言译文 —— 译文归 test-article-reading-i18n.mjs 管,这里只管映射对不对。
  8. source = source
  9. // 桩返回 key 本身,于是断言的是「映射到哪个 key」而不是译文。带占位符的那条例外:
  10. // 它多挂一个 `{n}`,好让「段号有没有被填进去」也能被断言到。
  11. .replace(
  12. "import { lang } from '@/main'",
  13. "const lang = new Proxy({}, { get: (_, k) => k === 'ssArticleErrDemoAudio' ? 'ssArticleErrDemoAudio:{n}' : String(k) })",
  14. )
  15. .replace(/import \{[^}]*\} from '\.\/articleReading'/, 'class ArticleApiError extends Error { constructor(m, s) { super(m); this.status = s } }')
  16. const compiled = ts.transpileModule(source, {
  17. compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
  18. }).outputText
  19. const { friendlyArticleError, articleActiveCountFromError } = await import(`data:text/javascript,${encodeURIComponent(compiled)}`)
  20. const cases = [
  21. ['Config not found', 'ssArticleErrConfigNotFound'],
  22. ['Config is not an article config', 'ssArticleErrNotArticleConfig'],
  23. ['Article content is empty', 'ssArticleErrContentEmpty'],
  24. ['Session not found', 'ssArticleErrSessionNotFound'],
  25. ['Paragraph not found', 'ssArticleErrParagraphNotFound'],
  26. ['Session is abandoned', 'ssArticleSessionExpired'],
  27. ['Session has ended', 'ssArticleErrSessionEnded'],
  28. ['Report already generated', 'ssArticleErrReportGenerated'],
  29. ['Segmentation failed', 'ssArticleSegmentFailed'],
  30. ['Segmentation returned empty output', 'ssArticleSegmentFailed'],
  31. ['content is required', 'ssArticleErrBadRequest'],
  32. ['configId is required', 'ssArticleErrBadRequest'],
  33. ['userId is required', 'ssArticleErrBadRequest'],
  34. ['users is required', 'ssArticleErrBadRequest'],
  35. ['users capped at 100', 'ssArticleErrBadRequest'],
  36. ]
  37. for (const [detail, key] of cases) {
  38. assert.equal(friendlyArticleError(new Error(detail)), key, `bare detail: ${detail}`)
  39. }
  40. // request() 万一没拆干净、或者别的 service 抛了带包装的 message,也要吃得下
  41. assert.equal(
  42. friendlyArticleError(new Error('{"detail":"Session not found"}')),
  43. 'ssArticleErrSessionNotFound',
  44. 'raw JSON body',
  45. )
  46. assert.equal(
  47. friendlyArticleError(new Error('[404] {"detail":"Config not found"}')),
  48. 'ssArticleErrConfigNotFound',
  49. '@/services/speaking parse() 的 `[404] {...}` 形状',
  50. )
  51. // 409「有人在练」:人数照旧要能摘出来(弹框靠它),但它**不该**被当成错误文案 ——
  52. // 2026-07-24 起那是问句:handleApply 截住它去弹确认框(spec §4.3 B)。真漏到 toast 只能是
  53. // 别处误用,那就走通用兜底,绝不出现「暂时无法修改」这种已经不成立的话。
  54. assert.equal(
  55. friendlyArticleError(new Error('Students are practising: 3')),
  56. 'ssArticleErrGeneric',
  57. '409 不再映射成一句「无法修改」',
  58. )
  59. assert.equal(articleActiveCountFromError(new Error('Students are practising: 3')), 3)
  60. assert.equal(articleActiveCountFromError(new Error('Students are practising: 0')), 0)
  61. assert.equal(articleActiveCountFromError(new Error('Session not found')), null)
  62. assert.equal(articleActiveCountFromError(null), null)
  63. // 保存配置时整篇示范音频同步合成,失败的 detail 带着段号(`[2]` / `[1, 3]`)。
  64. // 段号是老师唯一能据以行动的信息,必须填进文案,不能只回一句笼统的失败。
  65. assert.equal(
  66. friendlyArticleError(new Error('Demo audio synthesis failed: [2]')),
  67. 'ssArticleErrDemoAudio:2',
  68. 'single failing paragraph',
  69. )
  70. assert.equal(
  71. friendlyArticleError(new Error('Demo audio synthesis failed: [1, 3]')),
  72. 'ssArticleErrDemoAudio:1、3',
  73. 'several failing paragraphs',
  74. )
  75. assert.equal(
  76. friendlyArticleError(new Error('{"detail":"Demo audio synthesis failed: [4]"}')),
  77. 'ssArticleErrDemoAudio:4',
  78. 'wrapped in a JSON body',
  79. )
  80. // 兜底:绝不把原始内容漏出去
  81. const leaks = [
  82. new Error('{"detail":"some brand new backend error"}'),
  83. new Error('TypeError: Failed to fetch'),
  84. new Error(''),
  85. 'a bare string',
  86. null,
  87. undefined,
  88. { nope: true },
  89. ]
  90. for (const cause of leaks) {
  91. const out = friendlyArticleError(cause)
  92. assert.equal(out, 'ssArticleErrGeneric', `fallback for ${JSON.stringify(String(cause))}`)
  93. assert.ok(!out.includes('detail'), 'never leak the response body')
  94. assert.ok(!out.includes('{'), 'never leak JSON')
  95. }
  96. console.log(`article errors: ${cases.length + 2} mappings + ${leaks.length} fallbacks OK`)