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