feat: P0-2抽纯函数+补a-g断言13组+P1-2编辑器空覆盖守卫

P0-2:
- content-adapter.js: 解耦Node crypto(客户端直引),
  新增mergeCmsItem/normalizeThumbnail/buildPublishPayload
- buildPublishPayload补normalizeCmsList归一(抓到vote_id漏生成真bug)
- check-article-flow.js: 4组->13组断言,覆盖a-g

P1-2:
- editText.vue/smallTitle.vue getContents成功回调加空覆盖守卫
- editSetting封面已统一字符串数组,无需改动

验证: verify.js全绿,h5构建90 JS通过
This commit is contained in:
2026-09-12 01:33:35 +08:00
parent 3117146281
commit 25307a755e
4 changed files with 262 additions and 29 deletions
+149 -24
View File
@@ -1,6 +1,21 @@
#!/usr/bin/env node
'use strict'
/**
* 文章内容适配契约测试(P0-2)。
*
* 覆盖 NEXT-STEPS.md B/P0-2 的 a–g(共 13 组断言组):
* a. 编辑首个文字块(index=0)不被清空 → mergeCmsItem
* b. 视频改说明后 src/poster/duration 仍在 → normalizeCmsList
* c. 小标题能写入 cmsLst(type:'title') → normalizeCmsList/cmsLstToDelta
* d. 封面始终是字符串数组 → normalizeThumbnail
* e. mobile 发布 payload 同时含 cmsLst + content → buildPublishPayload
* f. 5 种块在预览/详情分流后都有渲染目标 → cmsLstToDelta/getArticleText/getArticleImages
* g. hasRenderableContent 对空/纯空块的判定 → hasRenderableContent
*
* 运行:node tools/check-article-flow.js
*/
const assert = require('assert')
const {
normalizeCmsList,
@@ -8,36 +23,146 @@ const {
createVoteId,
getArticleText,
getArticleImages,
hasRenderableContent
hasRenderableContent,
mergeCmsItem,
normalizeThumbnail,
buildPublishPayload
} = require('../uniCloud-alipay/cloudfunctions/uni-cms-articles/content-adapter')
let groups = 0
function group(name, fn) {
groups++
fn()
console.log(` [${groups}] ${name}`)
}
function run() {
const list = normalizeCmsList([
{ type: 'text', text: '首段', delta: { ops: [{ insert: '首段' }, { insert: '\n' }] } },
{ type: 'title', text: '小标题', html: '<p>小标题</p>', delta: { ops: [{ insert: '小标题' }] } },
{ type: 'image', image: { src: 'cloud://image-a' } },
{ type: 'video', text: '视频说明', video: { src: 'cloud://video-a', poster: 'cloud://poster-a', duration: 12 } },
{ type: 'vote', vote: { voteTitle: '投票', voteLst: [{ value: 'A' }, { value: 'B' }] } },
{ type: 'text', text: '' },
{ type: 'image', image: { src: '' } }
])
console.log('文章内容适配契约(a–g):')
assert.strictEqual(list.length, 5)
assert.strictEqual(typeof list[2].image.src, 'string')
assert.strictEqual(typeof list[3].video.src, 'string')
assert.match(list[4].vote.vote_id, /^vote_/)
// —— a. 首块 index=0 不被清空(editText.vue:97-108 同源语义)——
group('a1 首块原文写回保留', () => {
const oldItem = { type: 'text', text: '首段', html: '<p>首段</p>', delta: { ops: [{ insert: '首段' }] } }
const merged = mergeCmsItem(oldItem, { text: '首段', html: '<p>首段</p>', delta: { ops: [{ insert: '首段' }] } })
assert.ok(merged, '同内容写回应合并成功')
assert.strictEqual(merged.text, '首段')
assert.strictEqual(merged.type, 'text')
})
group('a2 首块改后新文落盘', () => {
const oldItem = { type: 'text', text: '首段', html: '<p>首段</p>', delta: { ops: [{ insert: '首段' }] } }
const merged = mergeCmsItem(oldItem, { text: '改后', html: '<p>改后</p>', delta: { ops: [{ insert: '改后' }] } })
assert.ok(merged)
assert.strictEqual(merged.text, '改后')
})
const delta = cmsLstToDelta(list)
assert.ok(Array.isArray(delta.ops))
assert.ok(delta.ops.some(op => op.insert && op.insert.image === 'cloud://image-a'))
assert.ok(delta.ops.some(op => op.insert && op.insert.video === 'cloud://video-a'))
assert.ok(hasRenderableContent(delta, list))
assert.match(getArticleText(delta, list), /首段/)
assert.match(getArticleText(delta, list), /小标题/)
assert.deepStrictEqual(getArticleImages(delta, list).sort(), ['cloud://image-a', 'cloud://poster-a'])
assert.notStrictEqual(createVoteId(), createVoteId())
// —— P1-2 空覆盖守卫(editText/smallTitle getContents 成功回调同源语义)——
group('P1-2 有旧内容+空新内容→拒绝覆盖(null)', () => {
const oldItem = { type: 'text', text: '旧文', html: '<p>旧文</p>', delta: { ops: [{ insert: '旧文' }] } }
assert.strictEqual(mergeCmsItem(oldItem, { text: '', html: '', delta: { ops: [] } }), null)
assert.strictEqual(mergeCmsItem(oldItem, { text: '', html: '' }), null)
})
group('P1-2 新建空块允许落盘(无旧内容不拦截)', () => {
const merged = mergeCmsItem(undefined, { text: '', html: '', delta: { ops: [] } })
assert.ok(merged && merged.text === '')
})
console.log('✓ 文章内容适配自测通过(cmsLst / Delta / 媒体 / 投票)')
// —— b. 视频改说明保留媒体字段(editCms.vue video 分支同源语义)——
group('b 视频改说明后 src/poster/duration 仍在', () => {
const list = normalizeCmsList([
{ type: 'video', text: '新说明', video: { src: 'cloud://video-a', poster: 'cloud://poster-a', duration: 12 } }
])
assert.strictEqual(list.length, 1)
assert.strictEqual(list[0].video.src, 'cloud://video-a')
assert.strictEqual(list[0].video.poster, 'cloud://poster-a')
assert.strictEqual(list[0].video.duration, 12)
assert.strictEqual(list[0].text, '新说明')
})
// —— c. 小标题写入(smallTitle.vue:120-140 同源语义)——
group('c 小标题 type:title 进 cmsLst 且进 Delta', () => {
const list = normalizeCmsList([
{ type: 'title', text: '小标题', html: '<p>小标题</p>', delta: { ops: [{ insert: '小标题' }] } }
])
assert.strictEqual(list.length, 1)
assert.strictEqual(list[0].type, 'title')
const delta = cmsLstToDelta(list)
assert.ok(delta.ops.some(op => op.insert === '小标题'), 'Delta 含小标题文本')
})
// —— d. 封面字符串数组(editSetting.vue 同源语义)——
group('d1 三图/单图/无封面归一', () => {
assert.deepStrictEqual(normalizeThumbnail(['a', 'b', 'c'], 3), ['a', 'b', 'c'])
assert.deepStrictEqual(normalizeThumbnail(['only'], 1), ['only'])
assert.deepStrictEqual(normalizeThumbnail(['a', 'b'], 0), [])
})
group('d2 空串与对象形态被过滤/抽取', () => {
assert.deepStrictEqual(normalizeThumbnail(['', 'b', ''], 3), ['b'])
assert.deepStrictEqual(normalizeThumbnail([{ src: 'x' }, { src: '' }], 3), ['x'])
assert.deepStrictEqual(normalizeThumbnail([''], 1), [])
})
// —— e. 发布 payload(preview.vue:404-420 同源语义)——
group('e1 发布同时含 cmsLst+content 且 vote_id 非空', () => {
const cmsLst = [
{ type: 'text', text: '首段', html: '', delta: { ops: [{ insert: '首段' }] } },
{ type: 'vote', vote: { voteTitle: '投票', voteLst: [{ value: 'A' }, { value: 'B' }] } }
]
const r = buildPublishPayload({ title: '标题', cmsLst }, { articleStatus: 1 })
assert.ok(!r.error, r.error || '发布应成功')
assert.ok(Array.isArray(r.query.cmsLst) && r.query.cmsLst.length === 2)
assert.ok(r.query.content && Array.isArray(r.query.content.ops) && r.query.content.ops.length > 0)
assert.match(r.query.cmsLst[1].vote.vote_id, /^vote_/)
assert.strictEqual(r.query.edit_type, 'mobile')
assert.strictEqual(r.query.article_status, 1)
})
group('e2 无标题/空正文发布被拦,草稿只拦标题', () => {
const cmsLst = [{ type: 'text', text: '首段', html: '', delta: { ops: [{ insert: '首段' }] } }]
assert.strictEqual(buildPublishPayload({ title: ' ', cmsLst }, { articleStatus: 1 }).error, '请先填写标题')
assert.strictEqual(buildPublishPayload({ title: '标题', cmsLst: [] }, { articleStatus: 1 }).error, '正文不能为空')
const draft = buildPublishPayload({ title: '标题', cmsLst: [] }, { articleStatus: 0 })
assert.ok(!draft.error, '草稿允许空正文')
assert.strictEqual(draft.query.article_status, 0)
})
// —— f. 5 种块渲染分流 ——
group('f 5种块各有 Delta/文本/图片渲染目标', () => {
const list = normalizeCmsList([
{ type: 'text', text: '首段', delta: { ops: [{ insert: '首段' }, { insert: '\n' }] } },
{ type: 'title', text: '小标题', html: '<p>小标题</p>', delta: { ops: [{ insert: '小标题' }] } },
{ type: 'image', image: { src: 'cloud://image-a' } },
{ type: 'video', text: '视频说明', video: { src: 'cloud://video-a', poster: 'cloud://poster-a', duration: 12 } },
{ type: 'vote', vote: { voteTitle: '投票', voteLst: [{ value: 'A' }, { value: 'B' }] } },
{ type: 'text', text: '' },
{ type: 'image', image: { src: '' } }
])
assert.strictEqual(list.length, 5, '空块被过滤后剩 5 块')
const delta = cmsLstToDelta(list)
assert.ok(delta.ops.some(op => op.insert && op.insert.image === 'cloud://image-a'), '图片有渲染目标')
assert.ok(delta.ops.some(op => op.insert && op.insert.video === 'cloud://video-a'), '视频有渲染目标')
assert.ok(delta.ops.some(op => op.insert === '投票\n'), '投票标题有渲染目标')
const text = getArticleText(delta, list)
assert.match(text, /首段/)
assert.match(text, /小标题/)
assert.match(text, /视频说明/)
assert.deepStrictEqual(getArticleImages(delta, list).sort(), ['cloud://image-a', 'cloud://poster-a'])
})
// —— g. hasRenderableContent 空判定 ——
group('g 空与纯空块判 false,有内容判 true', () => {
assert.strictEqual(hasRenderableContent({ ops: [] }, []), false)
assert.strictEqual(hasRenderableContent(null, [{ type: 'text', text: '' }]), false)
assert.strictEqual(hasRenderableContent(null, [{ type: 'image', image: { src: '' } }]), false)
assert.strictEqual(hasRenderableContent({ ops: [{ insert: 'x' }] }, []), true)
assert.strictEqual(hasRenderableContent(null, [{ type: 'text', text: '有' }]), true)
assert.strictEqual(hasRenderableContent(null, [{ type: 'vote', vote: { voteTitle: '投', voteLst: [{ value: 'A' }, { value: 'B' }] } }]), true)
})
group('基础 vote_id 唯一且前缀正确', () => {
assert.match(createVoteId(), /^vote_/)
assert.notStrictEqual(createVoteId(), createVoteId())
})
assert.ok(groups >= 10, `断言组数 ${groups} < 10`)
console.log(`✓ 文章内容适配自测通过(${groups} 组断言,覆盖 a–g)`)
}
if (require.main === module) run()