test-article-auto-format-flow.mjs 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import assert from 'node:assert/strict'
  2. import { readFile } from 'node:fs/promises'
  3. import ts from 'typescript'
  4. const root = new URL('../', import.meta.url)
  5. const read = p => readFile(new URL(p, root), 'utf8')
  6. const transpile = source => ts.transpileModule(source, {
  7. compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
  8. }).outputText
  9. const base = 'src/views/Editor/EnglishSpeaking/composables/'
  10. // 纯规则模块先转译成 data URL,供 composable 原样 import(相对路径在 data: 里无法解析)
  11. const rulesUrl = `data:text/javascript,${encodeURIComponent(
  12. transpile(await read(`${base}articleAutoFormatRules.ts`)),
  13. )}`
  14. let source = await read(`${base}useArticleAutoFormat.ts`)
  15. source = source
  16. .replace(
  17. "import { onScopeDispose, ref, type Ref } from 'vue'",
  18. // onScopeDispose 桩:把清理回调存到 globalThis,供测试手动模拟"组件已卸载"
  19. 'const ref = value => ({ value }); const onScopeDispose = fn => { globalThis.__onDispose = fn }',
  20. )
  21. .replace(
  22. "import { lang } from '@/main'",
  23. "const lang = { ssArticleSegmentFailed: 'segment-failed' }",
  24. )
  25. .replace(
  26. "import message from '@/utils/message'",
  27. 'const message = { error: text => globalThis.__errors.push(text) }',
  28. )
  29. .replace(
  30. "import { segmentArticle } from '../services/articleReading'",
  31. 'const segmentArticle = (...args) => globalThis.__segment(...args)',
  32. )
  33. // 双引号做外层定界符:rulesUrl 是 encodeURIComponent 输出,单引号/反引号在其字符集里不转义,
  34. // 若仍用单引号包裹会被规则文件注释里的反引号/`.join(' ')` 里的单引号提前截断字符串。
  35. .replace("from './articleAutoFormatRules'", `from "${rulesUrl}"`)
  36. const { useArticleAutoFormat } = await import(
  37. `data:text/javascript,${encodeURIComponent(transpile(source))}`
  38. )
  39. const words = n => Array.from({ length: n }, (_, i) => `w${i}`).join(' ')
  40. function setup({ segment } = {}) {
  41. globalThis.__errors = []
  42. globalThis.__segment = segment ?? (async () => ({ content: 'P1.\n\nP2.' }))
  43. const content = { value: '' }
  44. return { content, api: useArticleAutoFormat(content), errors: globalThis.__errors }
  45. }
  46. // 失败用例是故意的,composable 会如实 console.error。但它的栈里带着整个 data: URL
  47. // (被转译进去的源码),一条就是好几 KB,会把真正的测试输出淹掉。捕获掉,让通过时的
  48. // 输出保持干净;断言仍然验证 message.error 收到了什么。
  49. async function withSilencedConsoleError(fn) {
  50. const original = console.error
  51. console.error = () => {}
  52. try {
  53. await fn()
  54. }
  55. finally {
  56. console.error = original
  57. }
  58. }
  59. // ---------------- 粘贴触发 ----------------
  60. {
  61. const { content, api } = setup()
  62. api.onContentInput(words(60))
  63. assert.equal(content.value, words(60), '内容恒写入(取消时保留原始粘贴)')
  64. assert.equal(api.dialogVisible.value, true)
  65. assert.equal(api.dialogMode.value, 'paste')
  66. }
  67. {
  68. const { api } = setup()
  69. api.onContentInput(words(40))
  70. assert.equal(api.dialogVisible.value, false, '短文不弹')
  71. }
  72. {
  73. // 已有长文再敲一个字符 —— 绝不能弹(prev 取自 content.value,不是陈旧变量)
  74. const { content, api } = setup()
  75. content.value = words(300)
  76. api.onContentInput(`${words(300)} x`)
  77. assert.equal(api.dialogVisible.value, false)
  78. }
  79. // ---------------- 粘贴 → 取消 → 内容不变(spec §3.2 step1「取消 → 保留原始粘贴」)----------------
  80. {
  81. const { content, api } = setup()
  82. api.onContentInput(words(60))
  83. assert.equal(api.dialogVisible.value, true)
  84. api.cancelDialog()
  85. assert.equal(api.dialogVisible.value, false, '取消关框')
  86. assert.equal(content.value, words(60), '取消不清空/不改写老师刚粘贴的内容')
  87. }
  88. // ---------------- 按钮触发 ----------------
  89. {
  90. const { content, api } = setup()
  91. content.value = words(80)
  92. api.openOverwrite()
  93. assert.equal(api.dialogMode.value, 'overwrite')
  94. assert.equal(api.dialogVisible.value, true)
  95. }
  96. {
  97. const { api } = setup() // 空文本
  98. api.openOverwrite()
  99. assert.equal(api.dialogVisible.value, false, '空文本不开框')
  100. }
  101. // ---------------- 应用配置守卫 ----------------
  102. {
  103. const { content, api } = setup()
  104. content.value = 'A one.\nB two.\n\n\nC three.'
  105. assert.equal(api.guardBeforeApply(), true, '无超长段 → 放行')
  106. assert.equal(content.value, 'A one.\n\nB two.\n\nC three.', '归一化回写 textarea')
  107. assert.equal(api.dialogVisible.value, false)
  108. }
  109. {
  110. // 拦截路径也要归一化回写(脏空白不能留在 textarea)——通行路径已断言,这里补上拦截路径
  111. const { content, api } = setup()
  112. const messyOverlong = words(301).replace(/ /g, ' ') // 词间三倍空格
  113. content.value = `A one.\n\n\n\n${messyOverlong}`
  114. assert.equal(api.guardBeforeApply(), false, '超长段 → 拦截')
  115. assert.equal(content.value, `A one.\n\n${words(301)}`, '拦截也归一化回写')
  116. assert.equal(api.dialogMode.value, 'guard')
  117. assert.equal(api.dialogVisible.value, true)
  118. }
  119. {
  120. // 请求进行中点「应用配置」:guardBeforeApply 必须早退,程序化调用也绕不过去(不止按钮 disabled)
  121. let release
  122. const pending = new Promise(resolve => { release = resolve })
  123. const { content, api } = setup({
  124. segment: async () => { await pending; return { content: 'Done.' } },
  125. })
  126. content.value = words(60)
  127. api.openOverwrite()
  128. const confirming = api.confirmDialog() // isFormatting → true
  129. assert.equal(api.guardBeforeApply(), false, '格式化进行中,guardBeforeApply 必须早退')
  130. assert.equal(content.value, words(60), '早退不应触碰内容(不归一化、不误判超长)')
  131. assert.equal(api.dialogMode.value, 'overwrite', 'dialogMode 不应被早退的 guardBeforeApply 改成 guard')
  132. release()
  133. await confirming
  134. }
  135. // ---------------- 确认键:文本清空后点确认 ----------------
  136. {
  137. // 可达路径(非 UI-unreachable 早退):老师清空文本框再点确认,弹框不能卡住
  138. const { content, api } = setup()
  139. content.value = words(60)
  140. api.openOverwrite()
  141. content.value = ' '
  142. await api.confirmDialog()
  143. assert.equal(api.dialogVisible.value, false, '空文本点确认 —— 关框而非静默无反应')
  144. assert.equal(api.isFormatting.value, false)
  145. }
  146. // ---------------- 分段成功 ----------------
  147. {
  148. const calls = []
  149. const { content, api } = setup({
  150. segment: async text => { calls.push(text); return { content: 'One.\n\n\nTwo.' } },
  151. })
  152. content.value = words(60)
  153. api.openOverwrite()
  154. await api.confirmDialog()
  155. assert.deepEqual(calls, [words(60)], '整篇送后端')
  156. assert.equal(content.value, 'One.\n\nTwo.', '回填并归一化')
  157. assert.equal(api.dialogVisible.value, false)
  158. assert.equal(api.isFormatting.value, false)
  159. }
  160. // ---------------- 分段失败:非守卫框关闭、守卫框保持打开 ----------------
  161. {
  162. const { content, api, errors } = setup({ segment: async () => { throw new Error('down') } })
  163. content.value = words(60)
  164. api.openOverwrite()
  165. await withSilencedConsoleError(() => api.confirmDialog())
  166. assert.equal(content.value, words(60), '失败保留原文本')
  167. assert.equal(api.dialogVisible.value, false)
  168. assert.deepEqual(errors, ['segment-failed'])
  169. }
  170. {
  171. const { content, api } = setup({ segment: async () => { throw new Error('down') } })
  172. content.value = words(400)
  173. api.guardBeforeApply()
  174. await withSilencedConsoleError(() => api.confirmDialog())
  175. assert.equal(api.dialogVisible.value, true, '守卫框失败后保持打开,可原地重试')
  176. assert.equal(api.isFormatting.value, false)
  177. assert.equal(content.value, words(400), '守卫框失败同样保留内容(非守卫失败已断言,这里补上守卫路径)')
  178. }
  179. // ---------------- 防重复点击 ----------------
  180. {
  181. let calls = 0
  182. let release
  183. const pending = new Promise(resolve => { release = resolve })
  184. const { content, api } = setup({
  185. segment: async () => { calls += 1; await pending; return { content: 'Done.' } },
  186. })
  187. content.value = words(60)
  188. api.openOverwrite()
  189. const first = api.confirmDialog()
  190. await api.confirmDialog() // 进行中再点 —— 必须早退
  191. assert.equal(calls, 1)
  192. api.cancelDialog() // 进行中取消 —— 必须无效
  193. assert.equal(api.dialogVisible.value, true)
  194. release()
  195. await first
  196. assert.equal(calls, 1)
  197. assert.equal(api.dialogVisible.value, false)
  198. }
  199. // ---------------- 陈旧性守卫:等待期间内容被改,响应作废不回写 ----------------
  200. {
  201. let release
  202. const pending = new Promise(resolve => { release = resolve })
  203. const { content, api } = setup({
  204. segment: async () => { await pending; return { content: 'Stale.' } },
  205. })
  206. content.value = words(60)
  207. api.openOverwrite()
  208. const confirming = api.confirmDialog()
  209. content.value = 'teacher typed something newer' // 30 秒等待期间老师又改了文本
  210. release()
  211. await confirming
  212. assert.equal(content.value, 'teacher typed something newer', '内容中途被改 → 响应作废,绝不覆盖')
  213. assert.equal(api.dialogVisible.value, false, '仍然关框,不让弹框卡住')
  214. }
  215. // ---------------- 组件卸载(effect scope 销毁)后到达的响应是 no-op ----------------
  216. {
  217. let release
  218. const pending = new Promise(resolve => { release = resolve })
  219. const { content, api } = setup({
  220. segment: async () => { await pending; return { content: 'Late.' } },
  221. })
  222. content.value = words(60)
  223. api.openOverwrite()
  224. const confirming = api.confirmDialog()
  225. globalThis.__onDispose() // 模拟宿主组件在响应落地前已卸载
  226. release()
  227. await confirming
  228. assert.equal(content.value, words(60), '销毁后到达的响应不得写 store')
  229. assert.equal(api.dialogVisible.value, true, '销毁后不再触碰任何本地状态')
  230. }
  231. console.log('article auto-format flow OK')