import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import ts from 'typescript' const root = new URL('../', import.meta.url) const read = p => readFile(new URL(p, root), 'utf8') const transpile = source => ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, }).outputText const base = 'src/views/Editor/EnglishSpeaking/composables/' // 纯规则模块先转译成 data URL,供 composable 原样 import(相对路径在 data: 里无法解析) const rulesUrl = `data:text/javascript,${encodeURIComponent( transpile(await read(`${base}articleAutoFormatRules.ts`)), )}` let source = await read(`${base}useArticleAutoFormat.ts`) source = source .replace( "import { onScopeDispose, ref, type Ref } from 'vue'", // onScopeDispose 桩:把清理回调存到 globalThis,供测试手动模拟"组件已卸载" 'const ref = value => ({ value }); const onScopeDispose = fn => { globalThis.__onDispose = fn }', ) .replace( "import { lang } from '@/main'", "const lang = { ssArticleSegmentFailed: 'segment-failed' }", ) .replace( "import message from '@/utils/message'", 'const message = { error: text => globalThis.__errors.push(text) }', ) .replace( "import { segmentArticle } from '../services/articleReading'", 'const segmentArticle = (...args) => globalThis.__segment(...args)', ) // 双引号做外层定界符:rulesUrl 是 encodeURIComponent 输出,单引号/反引号在其字符集里不转义, // 若仍用单引号包裹会被规则文件注释里的反引号/`.join(' ')` 里的单引号提前截断字符串。 .replace("from './articleAutoFormatRules'", `from "${rulesUrl}"`) const { useArticleAutoFormat } = await import( `data:text/javascript,${encodeURIComponent(transpile(source))}` ) const words = n => Array.from({ length: n }, (_, i) => `w${i}`).join(' ') function setup({ segment } = {}) { globalThis.__errors = [] globalThis.__segment = segment ?? (async () => ({ content: 'P1.\n\nP2.' })) const content = { value: '' } return { content, api: useArticleAutoFormat(content), errors: globalThis.__errors } } // 失败用例是故意的,composable 会如实 console.error。但它的栈里带着整个 data: URL // (被转译进去的源码),一条就是好几 KB,会把真正的测试输出淹掉。捕获掉,让通过时的 // 输出保持干净;断言仍然验证 message.error 收到了什么。 async function withSilencedConsoleError(fn) { const original = console.error console.error = () => {} try { await fn() } finally { console.error = original } } // ---------------- 粘贴触发 ---------------- { const { content, api } = setup() api.onContentInput(words(60)) assert.equal(content.value, words(60), '内容恒写入(取消时保留原始粘贴)') assert.equal(api.dialogVisible.value, true) assert.equal(api.dialogMode.value, 'paste') } { const { api } = setup() api.onContentInput(words(40)) assert.equal(api.dialogVisible.value, false, '短文不弹') } { // 已有长文再敲一个字符 —— 绝不能弹(prev 取自 content.value,不是陈旧变量) const { content, api } = setup() content.value = words(300) api.onContentInput(`${words(300)} x`) assert.equal(api.dialogVisible.value, false) } // ---------------- 粘贴 → 取消 → 内容不变(spec §3.2 step1「取消 → 保留原始粘贴」)---------------- { const { content, api } = setup() api.onContentInput(words(60)) assert.equal(api.dialogVisible.value, true) api.cancelDialog() assert.equal(api.dialogVisible.value, false, '取消关框') assert.equal(content.value, words(60), '取消不清空/不改写老师刚粘贴的内容') } // ---------------- 按钮触发 ---------------- { const { content, api } = setup() content.value = words(80) api.openOverwrite() assert.equal(api.dialogMode.value, 'overwrite') assert.equal(api.dialogVisible.value, true) } { const { api } = setup() // 空文本 api.openOverwrite() assert.equal(api.dialogVisible.value, false, '空文本不开框') } // ---------------- 应用配置守卫 ---------------- { const { content, api } = setup() content.value = 'A one.\nB two.\n\n\nC three.' assert.equal(api.guardBeforeApply(), true, '无超长段 → 放行') assert.equal(content.value, 'A one.\n\nB two.\n\nC three.', '归一化回写 textarea') assert.equal(api.dialogVisible.value, false) } { // 拦截路径也要归一化回写(脏空白不能留在 textarea)——通行路径已断言,这里补上拦截路径 const { content, api } = setup() const messyOverlong = words(301).replace(/ /g, ' ') // 词间三倍空格 content.value = `A one.\n\n\n\n${messyOverlong}` assert.equal(api.guardBeforeApply(), false, '超长段 → 拦截') assert.equal(content.value, `A one.\n\n${words(301)}`, '拦截也归一化回写') assert.equal(api.dialogMode.value, 'guard') assert.equal(api.dialogVisible.value, true) } { // 请求进行中点「应用配置」:guardBeforeApply 必须早退,程序化调用也绕不过去(不止按钮 disabled) let release const pending = new Promise(resolve => { release = resolve }) const { content, api } = setup({ segment: async () => { await pending; return { content: 'Done.' } }, }) content.value = words(60) api.openOverwrite() const confirming = api.confirmDialog() // isFormatting → true assert.equal(api.guardBeforeApply(), false, '格式化进行中,guardBeforeApply 必须早退') assert.equal(content.value, words(60), '早退不应触碰内容(不归一化、不误判超长)') assert.equal(api.dialogMode.value, 'overwrite', 'dialogMode 不应被早退的 guardBeforeApply 改成 guard') release() await confirming } // ---------------- 确认键:文本清空后点确认 ---------------- { // 可达路径(非 UI-unreachable 早退):老师清空文本框再点确认,弹框不能卡住 const { content, api } = setup() content.value = words(60) api.openOverwrite() content.value = ' ' await api.confirmDialog() assert.equal(api.dialogVisible.value, false, '空文本点确认 —— 关框而非静默无反应') assert.equal(api.isFormatting.value, false) } // ---------------- 分段成功 ---------------- { const calls = [] const { content, api } = setup({ segment: async text => { calls.push(text); return { content: 'One.\n\n\nTwo.' } }, }) content.value = words(60) api.openOverwrite() await api.confirmDialog() assert.deepEqual(calls, [words(60)], '整篇送后端') assert.equal(content.value, 'One.\n\nTwo.', '回填并归一化') assert.equal(api.dialogVisible.value, false) assert.equal(api.isFormatting.value, false) } // ---------------- 分段失败:非守卫框关闭、守卫框保持打开 ---------------- { const { content, api, errors } = setup({ segment: async () => { throw new Error('down') } }) content.value = words(60) api.openOverwrite() await withSilencedConsoleError(() => api.confirmDialog()) assert.equal(content.value, words(60), '失败保留原文本') assert.equal(api.dialogVisible.value, false) assert.deepEqual(errors, ['segment-failed']) } { const { content, api } = setup({ segment: async () => { throw new Error('down') } }) content.value = words(400) api.guardBeforeApply() await withSilencedConsoleError(() => api.confirmDialog()) assert.equal(api.dialogVisible.value, true, '守卫框失败后保持打开,可原地重试') assert.equal(api.isFormatting.value, false) assert.equal(content.value, words(400), '守卫框失败同样保留内容(非守卫失败已断言,这里补上守卫路径)') } // ---------------- 防重复点击 ---------------- { let calls = 0 let release const pending = new Promise(resolve => { release = resolve }) const { content, api } = setup({ segment: async () => { calls += 1; await pending; return { content: 'Done.' } }, }) content.value = words(60) api.openOverwrite() const first = api.confirmDialog() await api.confirmDialog() // 进行中再点 —— 必须早退 assert.equal(calls, 1) api.cancelDialog() // 进行中取消 —— 必须无效 assert.equal(api.dialogVisible.value, true) release() await first assert.equal(calls, 1) assert.equal(api.dialogVisible.value, false) } // ---------------- 陈旧性守卫:等待期间内容被改,响应作废不回写 ---------------- { let release const pending = new Promise(resolve => { release = resolve }) const { content, api } = setup({ segment: async () => { await pending; return { content: 'Stale.' } }, }) content.value = words(60) api.openOverwrite() const confirming = api.confirmDialog() content.value = 'teacher typed something newer' // 30 秒等待期间老师又改了文本 release() await confirming assert.equal(content.value, 'teacher typed something newer', '内容中途被改 → 响应作废,绝不覆盖') assert.equal(api.dialogVisible.value, false, '仍然关框,不让弹框卡住') } // ---------------- 组件卸载(effect scope 销毁)后到达的响应是 no-op ---------------- { let release const pending = new Promise(resolve => { release = resolve }) const { content, api } = setup({ segment: async () => { await pending; return { content: 'Late.' } }, }) content.value = words(60) api.openOverwrite() const confirming = api.confirmDialog() globalThis.__onDispose() // 模拟宿主组件在响应落地前已卸载 release() await confirming assert.equal(content.value, words(60), '销毁后到达的响应不得写 store') assert.equal(api.dialogVisible.value, true, '销毁后不再触碰任何本地状态') } console.log('article auto-format flow OK')