Files
t/tools/check-article-flow.js
T
root 25307a755e 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通过
2026-09-12 01:33:35 +08:00

170 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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,
cmsLstToDelta,
createVoteId,
getArticleText,
getArticleImages,
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() {
console.log('文章内容适配契约(a–g):')
// —— 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, '改后')
})
// —— 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 === '')
})
// —— 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()
module.exports = { run }