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:
@@ -99,6 +99,16 @@
|
||||
success: async (res) => {
|
||||
let { html, text, delta } = res
|
||||
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] = {
|
||||
...oldItem,
|
||||
text: text || '',
|
||||
|
||||
@@ -120,6 +120,17 @@
|
||||
if (!cms || !Array.isArray(cms.cmsLst) || !editorCtx.value) return
|
||||
editorCtx.value.getContents({
|
||||
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()) {
|
||||
uni.showToast({ title: '小标题不能为空', icon: 'none' })
|
||||
return
|
||||
|
||||
+149
-24
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
if (value === undefined || value === null) return value
|
||||
@@ -59,8 +68,9 @@ function normalizeCmsList(list) {
|
||||
}, [])
|
||||
}
|
||||
|
||||
function createVoteId() {
|
||||
return `vote_${Date.now().toString(36)}_${crypto.randomBytes(6).toString('hex')}`
|
||||
function createVoteId(randomFn) {
|
||||
const rand = typeof randomFn === 'function' ? randomFn(6) : randomHex(6)
|
||||
return `vote_${Date.now().toString(36)}_${rand}`
|
||||
}
|
||||
|
||||
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,
|
||||
normalizeCmsList,
|
||||
cmsLstToDelta,
|
||||
createVoteId,
|
||||
getArticleText,
|
||||
getArticleImages,
|
||||
hasRenderableContent
|
||||
hasRenderableContent,
|
||||
mergeCmsItem,
|
||||
normalizeThumbnail,
|
||||
buildPublishPayload
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = adapterModule
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user