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
+10
View File
@@ -99,6 +99,16 @@
success: async (res) => { success: async (res) => {
let { html, text, delta } = res let { html, text, delta } = res
const oldItem = cms.cmsLst[index] const oldItem = cms.cmsLst[index]
// P1-2 空覆盖守卫(同源 content-adapter.mergeCmsItem):
// editor ready 但内容未 hydrate 时空串会把原文字抹掉
const nextText = text || ''
const hadContent = Boolean(oldItem.text || oldItem.html)
const gotContent = Boolean(nextText || html || (delta && delta.ops && delta.ops.length))
if (hadContent && !gotContent) {
console.warn('编辑器内容为空,跳过覆盖以防丢稿')
await back()
return
}
cms.cmsLst[index] = { cms.cmsLst[index] = {
...oldItem, ...oldItem,
text: text || '', text: text || '',
+11
View File
@@ -120,6 +120,17 @@
if (!cms || !Array.isArray(cms.cmsLst) || !editorCtx.value) return if (!cms || !Array.isArray(cms.cmsLst) || !editorCtx.value) return
editorCtx.value.getContents({ editorCtx.value.getContents({
success: async ({ html = '', text = '', delta = { ops: [] } }) => { success: async ({ html = '', text = '', delta = { ops: [] } }) => {
// P1-2 空覆盖守卫:编辑已有小标题时,空内容直接返回不覆写
if (editingIndex.value >= 0 && cms.cmsLst[editingIndex.value]?.type === 'title') {
const oldItem = cms.cmsLst[editingIndex.value]
const hadContent = Boolean(oldItem.text || oldItem.html)
const gotContent = Boolean(text || html || (delta && delta.ops && delta.ops.length))
if (hadContent && !gotContent) {
console.warn('小标题内容为空,跳过覆盖以防丢稿')
await back()
return
}
}
if (!text.trim() && !html.trim()) { if (!text.trim() && !html.trim()) {
uni.showToast({ title: '小标题不能为空', icon: 'none' }) uni.showToast({ title: '小标题不能为空', icon: 'none' })
return return
+149 -24
View File
@@ -1,6 +1,21 @@
#!/usr/bin/env node #!/usr/bin/env node
'use strict' '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 assert = require('assert')
const { const {
normalizeCmsList, normalizeCmsList,
@@ -8,36 +23,146 @@ const {
createVoteId, createVoteId,
getArticleText, getArticleText,
getArticleImages, getArticleImages,
hasRenderableContent hasRenderableContent,
mergeCmsItem,
normalizeThumbnail,
buildPublishPayload
} = require('../uniCloud-alipay/cloudfunctions/uni-cms-articles/content-adapter') } = require('../uniCloud-alipay/cloudfunctions/uni-cms-articles/content-adapter')
let groups = 0
function group(name, fn) {
groups++
fn()
console.log(` [${groups}] ${name}`)
}
function run() { function run() {
const list = normalizeCmsList([ console.log('文章内容适配契约(a–g):')
{ 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) // —— a. 首块 index=0 不被清空(editText.vue:97-108 同源语义)——
assert.strictEqual(typeof list[2].image.src, 'string') group('a1 首块原文写回保留', () => {
assert.strictEqual(typeof list[3].video.src, 'string') const oldItem = { type: 'text', text: '首段', html: '<p>首段</p>', delta: { ops: [{ insert: '首段' }] } }
assert.match(list[4].vote.vote_id, /^vote_/) 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) // —— P1-2 空覆盖守卫(editText/smallTitle getContents 成功回调同源语义)——
assert.ok(Array.isArray(delta.ops)) group('P1-2 有旧内容+空新内容→拒绝覆盖(null)', () => {
assert.ok(delta.ops.some(op => op.insert && op.insert.image === 'cloud://image-a')) const oldItem = { type: 'text', text: '旧文', html: '<p>旧文</p>', delta: { ops: [{ insert: '旧文' }] } }
assert.ok(delta.ops.some(op => op.insert && op.insert.video === 'cloud://video-a')) assert.strictEqual(mergeCmsItem(oldItem, { text: '', html: '', delta: { ops: [] } }), null)
assert.ok(hasRenderableContent(delta, list)) assert.strictEqual(mergeCmsItem(oldItem, { text: '', html: '' }), null)
assert.match(getArticleText(delta, list), /首段/) })
assert.match(getArticleText(delta, list), /小标题/) group('P1-2 新建空块允许落盘(无旧内容不拦截)', () => {
assert.deepStrictEqual(getArticleImages(delta, list).sort(), ['cloud://image-a', 'cloud://poster-a']) const merged = mergeCmsItem(undefined, { text: '', html: '', delta: { ops: [] } })
assert.notStrictEqual(createVoteId(), createVoteId()) 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() if (require.main === module) run()
@@ -1,4 +1,13 @@
const crypto = require('crypto') function randomHex(bytes) {
try {
if (typeof require === 'function') {
return require('crypto').randomBytes(bytes).toString('hex')
}
} catch (e) { /* fall through to Math.random */ }
let out = ''
while (out.length < bytes * 2) out += Math.random().toString(16).slice(2)
return out.slice(0, bytes * 2)
}
function clone(value) { function clone(value) {
if (value === undefined || value === null) return value if (value === undefined || value === null) return value
@@ -59,8 +68,9 @@ function normalizeCmsList(list) {
}, []) }, [])
} }
function createVoteId() { function createVoteId(randomFn) {
return `vote_${Date.now().toString(36)}_${crypto.randomBytes(6).toString('hex')}` const rand = typeof randomFn === 'function' ? randomFn(6) : randomHex(6)
return `vote_${Date.now().toString(36)}_${rand}`
} }
function textOps(item) { function textOps(item) {
@@ -159,12 +169,89 @@ function hasRenderableContent(content, cmsLst) {
) )
} }
module.exports = { /**
* 编辑器写回守卫(P1-2 同源逻辑)。
* 有旧内容但新内容为空时返回 null(调用方跳过覆盖并返回上一页),否则返回合并后的条目。
*/
function mergeCmsItem(oldItem, incoming) {
const next = incoming || {}
const nextText = next.text || ''
const nextHtml = next.html || ''
const nextDelta = next.delta
const hadContent = Boolean(oldItem && (oldItem.text || oldItem.html))
const gotContent = Boolean(
nextText || nextHtml || (nextDelta && Array.isArray(nextDelta.ops) && nextDelta.ops.length)
)
if (hadContent && !gotContent) return null
return {
...(oldItem || {}),
text: nextText,
html: nextHtml,
delta: nextDelta && typeof nextDelta === 'object' ? nextDelta : { ops: [] }
}
}
/**
* 封面归一:始终返回字符串数组(d. editSetting 同源逻辑)。
* mode: 0 无封面 / 1 单图 / 3 三图。
*/
function normalizeThumbnail(raw, mode) {
const srcOf = (v) => (typeof v === 'string' ? v : (v && typeof v.src === 'string' ? v.src : ''))
const list = Array.isArray(raw) ? raw.map(srcOf).filter(Boolean) : []
if (mode === 0) return []
if (mode === 1) return list[0] ? [list[0]] : []
if (mode === 3) return list.slice(0, 3)
return list
}
/**
* 发布 payload 构造(e. preview 同源逻辑)。
* 返回 { query } 或 { error },调用方按 error 弹 toast。
*/
function buildPublishPayload(stored, extra) {
const s = stored || {}
const x = extra || {}
if (!s.title || !String(s.title).trim()) return { error: '请先填写标题' }
const cmsLst = normalizeCmsList(Array.isArray(s.cmsLst) ? s.cmsLst : [])
const articleStatus = x.articleStatus === 1 ? 1 : 0
if (articleStatus === 1 && !hasRenderableContent(s.content, cmsLst)) {
return { error: '正文不能为空' }
}
const content = (s.content && Array.isArray(s.content.ops))
? s.content
: cmsLstToDelta(cmsLst)
return {
query: {
title: s.title,
title_html: s.title_html,
title_delta: s.title_delta,
thumbnail: s.thumbnail,
p_type: s.p_type,
category_id: s.category_id,
temp_id: x.tempId || s.temp_id || '',
music_url_id: s.music_url_id || {},
edit_type: 'mobile',
cmsLst,
content,
excerpt: s.excerpt || '',
article_status: articleStatus
}
}
}
const adapterModule = {
clone, clone,
normalizeCmsList, normalizeCmsList,
cmsLstToDelta, cmsLstToDelta,
createVoteId, createVoteId,
getArticleText, getArticleText,
getArticleImages, getArticleImages,
hasRenderableContent hasRenderableContent,
mergeCmsItem,
normalizeThumbnail,
buildPublishPayload
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = adapterModule
} }