| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250 |
- 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')
|