The iframe height is synced explicitly, and two paths were missing it, so long content ended up clipped inside the editor's own scroll area. - restoring a local draft calls setContent with a whole article at once; the iframe stayed at 520px while the body grew to 1410px, so only the first few paragraphs appeared to survive the restore - media inserts called insertContent directly without resyncing, and images load asynchronously: the height was measured while the image was still 0px tall, leaving the paragraphs below it cut off (measured: iframe 946 vs body 1006 with a single image after long text) Fixes - resync the height right after the restore's setContent, and add SetContent to the events that already sync height and content - resync after both media callbacks (picker and embed) - add installHeightWatchers(): capture-phase load listener inside the iframe for img / iframe / video / audio, plus a ResizeObserver on the body, so the height follows assets that finish loading later Tests cover the restore path and both media paths, including async loading.
812 lines
44 KiB
JavaScript
812 lines
44 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* Creator Studio 契约测试。
|
||
*
|
||
* 这些断言锁定“服务端才是权威”的边界:状态、付费、审核与私有附件不能由前端
|
||
* 直接写穿;已发布文章的改稿不得覆盖线上内容;私有附件不得暴露公开路径。
|
||
*/
|
||
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const test = require('node:test');
|
||
|
||
const root = path.resolve(__dirname, '..', '..');
|
||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||
const exists = (file) => fs.existsSync(path.join(root, file));
|
||
|
||
const creatorRest = read('src/Core/CreatorRest.php');
|
||
const versions = read('src/Core/SubmissionVersion.php');
|
||
const privateAssets = read('src/Content/PrivateAssets.php');
|
||
const protectedDownload = read('src/Content/ProtectedDownload.php');
|
||
const studio = read('src/Core/CreatorStudio.php');
|
||
const pageSubmit = read('templates/pages/page-submit.php');
|
||
const saveTrait = read('src/Core/FrontSubmitSave.php');
|
||
const domainModel = read('src/Core/DomainModel.php');
|
||
const appSource = read('assets/creator-studio/src/index.tsx');
|
||
const editorToolbar = read('assets/creator-studio/src/components/editor-toolbar.tsx');
|
||
const drawer = read('assets/creator-studio/src/panels/publish-drawer.tsx');
|
||
const api = read('assets/creator-studio/src/services/api.ts');
|
||
|
||
test('creator REST routes always register a permission callback', () => {
|
||
const routes = creatorRest.match(/register_rest_route\(/g) || [];
|
||
const permissions = creatorRest.match(/'permission_callback'\s*=>/g) || [];
|
||
assert.ok(routes.length >= 5, 'creator API should expose bootstrap/save/submit/preview/upload routes');
|
||
assert.equal(routes.length, permissions.length, 'every creator route needs a permission callback');
|
||
assert.doesNotMatch(creatorRest, /'permission_callback'\s*=>\s*'__return_true'/);
|
||
});
|
||
|
||
test('studio writes reuse the existing submit service instead of raw post APIs', () => {
|
||
assert.match(creatorRest, /\$front->persist\(\$data, 'posts_draft'\)/);
|
||
assert.match(creatorRest, /\$front->persist\(\$data, 'posts_save'\)/);
|
||
assert.doesNotMatch(creatorRest, /wp_insert_post\(/);
|
||
assert.doesNotMatch(creatorRest, /wp_update_post\(\$update/);
|
||
assert.match(creatorRest, /can_use_studio/);
|
||
assert.match(creatorRest, /user_can_cap\('new_post_audit_no', \$user_id\)/);
|
||
});
|
||
|
||
test('published edits never overwrite the live post before approval', () => {
|
||
assert.match(versions, /public static function requires_version/);
|
||
assert.match(versions, /post_status === 'publish'/);
|
||
assert.match(versions, /self::POST_TYPE/);
|
||
assert.match(versions, /public function approve/);
|
||
assert.match(versions, /public function reject/);
|
||
assert.match(versions, /jio_version_source_changed/);
|
||
assert.match(versions, /'status'\s*=>\s*409/);
|
||
// Approval is the only path that writes title/content back to the source post.
|
||
assert.match(versions, /'ID'\s*=>\s*\$source_id[\s\S]{0,400}?wp_update_post/);
|
||
assert.match(creatorRest, /SubmissionVersion::requires_version\(\$post_id\)[\s\S]{0,400}?save_working/);
|
||
});
|
||
|
||
test('stale autosave requests are rejected instead of overwriting', () => {
|
||
assert.match(versions, /'jio_version_conflict'/);
|
||
assert.match(versions, /current_rev/);
|
||
assert.match(versions, /base_rev/);
|
||
assert.match(appSource, /status === 409/);
|
||
});
|
||
|
||
test('drafts save with partial content instead of being rejected', () => {
|
||
// 只写标题、或正文只有图片/表格/代码块时,也必须能存草稿。
|
||
assert.match(saveTrait, /请先写点什么再保存/);
|
||
const draftBranch = saveTrait.indexOf('if (!$is_draft) {');
|
||
const titleGuard = saveTrait.indexOf("if ($title === '') {");
|
||
assert.ok(draftBranch !== -1 && titleGuard !== -1, 'draft branch and title guard both exist');
|
||
assert.ok(draftBranch < titleGuard, 'title/content requirements only apply to submit');
|
||
// 前端空文档不再发保存请求。
|
||
assert.match(appSource, /const hasDocument = /);
|
||
assert.match(appSource, /if \(!hasDocument\) \{\s*return true;/);
|
||
assert.match(appSource, /img\|table\|pre\|hr\|iframe\|video/);
|
||
});
|
||
|
||
test('paid payload is validated before any post write', () => {
|
||
const prepareAt = saveTrait.indexOf('prepare_pay_meta');
|
||
const insertAt = saveTrait.indexOf('wp_insert_post');
|
||
const updateAt = saveTrait.indexOf('wp_update_post');
|
||
assert.notEqual(prepareAt, -1, 'persist must validate pay payload');
|
||
assert.ok(prepareAt < insertAt, 'pay validation must precede insert');
|
||
assert.ok(prepareAt < updateAt, 'pay validation must precede update');
|
||
assert.match(saveTrait, /if \(empty\(\$pay_result\['success'\]\)\) \{\s*return \$pay_result;/);
|
||
});
|
||
|
||
test('private attachments never expose a public URL', () => {
|
||
assert.match(privateAssets, /define\('JIO_PRIVATE_STORAGE_DIR'/);
|
||
assert.match(privateAssets, /is_available\(\)/);
|
||
assert.match(privateAssets, /is_verified/);
|
||
assert.match(privateAssets, /return \$outside \|\| \$explicit;/);
|
||
assert.doesNotMatch(privateAssets, /wp_upload_dir\(/);
|
||
assert.doesNotMatch(privateAssets, /wp_get_attachment_url/);
|
||
assert.match(privateAssets, /asset_key/);
|
||
assert.match(privateAssets, /@chmod\(\$target, 0600\)/);
|
||
});
|
||
|
||
test('protected downloads require entitlement, tokens, and the private asset id', () => {
|
||
assert.match(protectedDownload, /jio_can_download/);
|
||
assert.match(protectedDownload, /Visibility::can_download_resource/);
|
||
assert.match(protectedDownload, /asset_id/);
|
||
assert.match(protectedDownload, /jio_token_expired/);
|
||
assert.match(protectedDownload, /jio_token_exhausted/);
|
||
assert.match(protectedDownload, /UPDATE \{\$table\} SET used_count = used_count \+ 1/);
|
||
assert.match(protectedDownload, /GREATEST\(used_count - 1, 0\)/);
|
||
assert.match(protectedDownload, /X-Content-Type-Options/);
|
||
assert.doesNotMatch(protectedDownload, /wp_get_attachment_url/);
|
||
});
|
||
|
||
test('model stores protected downloads by asset key only', () => {
|
||
assert.match(domainModel, /'protected'/);
|
||
assert.match(domainModel, /'asset_key'/);
|
||
assert.match(domainModel, /PrivateAssets::sanitize_asset_key/);
|
||
assert.match(domainModel, /\$is_protected\) \{\s*\$type = 'protected';\s*\$url = '';/);
|
||
});
|
||
|
||
test('studio is mounted only on the submit template with a fallback', () => {
|
||
assert.match(pageSubmit, /CreatorStudio::get_instance\(\)/);
|
||
assert.match(pageSubmit, /is_built\(\)/);
|
||
assert.match(pageSubmit, /get_current_user_id\(\) > 0/);
|
||
assert.match(pageSubmit, /id="jio-creator-studio"/);
|
||
// Guests and missing builds fall back to the existing TinyMCE form.
|
||
assert.match(pageSubmit, /wp_enqueue_editor\(\);/);
|
||
assert.match(studio, /is_page_template\('templates\/pages\/page-submit.php'\)/);
|
||
assert.match(studio, /index\.asset\.php/);
|
||
assert.match(studio, /wp_add_inline_script/);
|
||
});
|
||
|
||
test('studio app uses TinyMCE while preserving autosave, recovery, and publishing', () => {
|
||
assert.match(appSource, /useAutosave/);
|
||
assert.match(appSource, /useLocalRecovery/);
|
||
assert.match(appSource, /PublishDrawer/);
|
||
assert.match(appSource, /wpEditor\.initialize\(EDITOR_ID/);
|
||
assert.match(appSource, /editor\.getContent\(\)/);
|
||
assert.match(appSource, /editor\?\.setContent\(content\)/);
|
||
assert.doesNotMatch(appSource, /BlockEditorProvider|registerCoreBlocks|BlockList|registerCoreBlocks/);
|
||
assert.doesNotMatch(creatorRest, /block_content\(/);
|
||
assert.match(creatorRest, /'post_content'\s*=>\s*\$payload\['content'\]/);
|
||
assert.match(api, /'X-WP-Nonce'/);
|
||
assert.match(drawer, /canPay/);
|
||
assert.match(drawer, /canPayDownload/);
|
||
assert.match(drawer, /<PayPanel/);
|
||
assert.match(read('assets/creator-studio/src/panels/pay-panel.tsx'), /私有附件存储尚未配置/);
|
||
// 发布动作在底部状态栏 + 确认弹窗里,抽屉只做付费配置。
|
||
assert.doesNotMatch(drawer, /onSubmit\(/);
|
||
assert.doesNotMatch(drawer, /立即发布/);
|
||
assert.doesNotMatch(drawer, /保存草稿/);
|
||
assert.match(read('assets/creator-studio/src/components/publish-dialog.tsx'), /onSubmit\(false\)/);
|
||
assert.match(read('assets/creator-studio/src/components/publish-dialog.tsx'), /立即发布/);
|
||
});
|
||
|
||
test('studio enqueues the WordPress editor and TinyMCE media tools', () => {
|
||
const enqueue = read('src/Core/CreatorStudio.php');
|
||
assert.match(enqueue, /wp_enqueue_editor\(\)/);
|
||
assert.match(enqueue, /\$deps\[\]\s*=\s*'editor'/);
|
||
assert.match(enqueue, /jio-creator-tinymce-tools/);
|
||
assert.match(enqueue, /tinymce_payload/);
|
||
assert.doesNotMatch(enqueue, /wp-format-library|gutenberg-blocks\.js/);
|
||
});
|
||
|
||
test('fixed top toolbar has TinyMCE formatting, media, and extension actions', () => {
|
||
assert.match(appSource, /className="jio-creator__bar"/);
|
||
assert.match(appSource, /undoManager\.undo\(\)/);
|
||
assert.match(appSource, /undoManager\.redo\(\)/);
|
||
assert.match(appSource, /execCommand\('FormatBlock', false, block\)/);
|
||
assert.match(appSource, /JustifyCenter|JustifyRight|JustifyLeft/);
|
||
assert.match(editorToolbar, /canUndo/);
|
||
assert.match(editorToolbar, /canRedo/);
|
||
assert.match(editorToolbar, /onCommand\('Bold'\)/);
|
||
assert.match(editorToolbar, /onCommand\('Italic'\)/);
|
||
assert.match(editorToolbar, /onOpenMedia\('image'\)/);
|
||
assert.match(editorToolbar, /onOpenMedia\('video'\)/);
|
||
assert.match(editorToolbar, /onClick=\{onOpenLink\}/);
|
||
// 引用不再是标题下拉的一部分,而是独立菜单(四种配色)。
|
||
assert.match(editorToolbar, /onInsertQuote/);
|
||
assert.doesNotMatch(editorToolbar, /<option value="blockquote"/);
|
||
assert.doesNotMatch(editorToolbar, /onCommand\('format', 'blockquote'\)/);
|
||
// 标题级别 / 列表 / 对齐 / 引用 / 隐藏内容都是下拉,不再是原生 select。
|
||
assert.doesNotMatch(editorToolbar, /jio-creator__format-select/);
|
||
assert.doesNotMatch(editorToolbar, /<option value=/);
|
||
assert.match(editorToolbar, /<ToolMenu/);
|
||
for (const kind of ['divider', 'code', 'footnote', 'math', 'table']) {
|
||
assert.ok(editorToolbar.includes(`onInsertBlock('${kind}')`), `missing block button: ${kind}`);
|
||
}
|
||
// 列表改为下拉:有序 / 无序两种,走 TinyMCE 的列表命令。
|
||
assert.ok(editorToolbar.includes("key: 'ul'"), 'missing unordered list option');
|
||
assert.ok(editorToolbar.includes("key: 'ol'"), 'missing ordered list option');
|
||
assert.match(appSource, /InsertOrderedList|InsertUnorderedList/);
|
||
// 目录按钮已删除,工具栏不再输出目录入口。
|
||
assert.ok(!editorToolbar.includes("onInsertBlock('toc')"), 'TOC button must be removed');
|
||
assert.ok(!editorToolbar.includes('tocMarkup'), 'tocMarkup must not be used');
|
||
// 三个对齐按钮:居左 / 居中 / 居右。
|
||
for (const align of ['left', 'center', 'right']) {
|
||
assert.ok(editorToolbar.includes(`key: '${align}'`), `missing align option: ${align}`);
|
||
}
|
||
// 隐藏内容四档可见性。
|
||
for (const kind of ['reply', 'login', 'vip', 'payshow']) {
|
||
assert.ok(editorToolbar.includes(`key: '${kind}'`), `missing hidden-content kind: ${kind}`);
|
||
}
|
||
assert.match(editorToolbar, /onInsertHidden/);
|
||
assert.match(appSource, /wrapSelection\(\(inner\) => hiddenContentMarkup\(kind, inner\)\)/);
|
||
// 表格 / 公式 / 注释 / 代码都走参数弹窗,不再是固定内容。
|
||
assert.match(appSource, /<BlockDialog/);
|
||
assert.match(appSource, /setBlockDialog\(kind as BlockDialogKind\)/);
|
||
// 代码块直接插入,不弹窗、不选语言;插入后光标留在代码块里。
|
||
assert.match(appSource, /insertCodeBlock\(\)/);
|
||
assert.match(appSource, /editor\.selection\.setCursorLocation\(pre, 0\)/);
|
||
assert.doesNotMatch(appSource, /setCodeDialog/);
|
||
assert.doesNotMatch(read('assets/creator-studio/src/components/block-dialog.tsx'), /kind === 'code'/);
|
||
assert.match(read('assets/creator-studio/src/components/block-dialog.tsx'), /onSubmit\(\{ kind, rows: clamp\(rows, 1, 20\), cols: clamp\(cols, 1, 10\), header, striped \}\)/);
|
||
assert.match(appSource, /onInsertBlock=\{insertBlock\}/);
|
||
assert.match(appSource, /onInsertQuote=\{insertQuote\}/);
|
||
const markup = read('assets/creator-studio/src/utils/block-markup.ts');
|
||
const serialize = read('assets/creator-studio/src/utils/serialize.ts');
|
||
assert.match(markup, /jio-math/);
|
||
// 引用沿用主题的 quote_q 结构与配色类。
|
||
assert.match(markup, /quote_q/);
|
||
assert.match(markup, /qe_wzk_c-red/);
|
||
// 公式与注释用短代码落库,由服务端保证渲染。
|
||
assert.doesNotMatch(markup, /E = mc\^2/);
|
||
// 编辑器展示渲染结果,序列化时再换回短代码。
|
||
assert.match(markup, /data-jio-math-source/);
|
||
assert.match(read('assets/creator-studio/src/utils/serialize.ts'), /\[jio_math display=/);
|
||
assert.match(read('assets/creator-studio/src/utils/serialize.ts'), /\[jio_footnote\]/);
|
||
// 代码块:编辑器里是裸 pre(TinyMCE 把 pre > code 当行内边界,光标落不进去),
|
||
// 保存时才由 serializeBlocks 套回前台的 pre > code。不写语言 / 主题属性。
|
||
assert.match(markup, /<pre><br><\/pre><p><\/p>/);
|
||
assert.doesNotMatch(markup, /<pre><code><\/code><\/pre>/);
|
||
assert.match(serialize, /querySelectorAll\('pre'\)[\s\S]*?serializeCodeBlock/);
|
||
assert.match(serialize, /createElement\('code'\)/);
|
||
assert.match(serialize, /hydrateCodeBlock/);
|
||
assert.doesNotMatch(markup, /enlighter-pre/);
|
||
assert.doesNotMatch(markup, /data-enlighter-language/);
|
||
assert.doesNotMatch(read('assets/creator-studio/src/editor-runtime.ts'), /syncCodeLabels/);
|
||
assert.doesNotMatch(read('assets/creator-studio/src/components/block-dialog.tsx'), /CODE_LANGUAGES/);
|
||
// 外观:编辑器 iframe 与前台 Enlighter 渲染值一致(不折行、浅底、8px 圆角)。
|
||
const editorCss = read('assets/css/frontend/pages/submit-editor.css');
|
||
assert.doesNotMatch(editorCss, /\.enlighter-pre/);
|
||
assert.match(editorCss, /pre code \{/);
|
||
assert.match(editorCss, /pre \{[^}]*white-space: pre;/);
|
||
assert.match(editorCss, /pre \{[^}]*background: #f8f9fa/);
|
||
// 隐藏内容沿用 .tinymce-hide 结构,短代码作为文本落库。
|
||
assert.match(markup, /tinymce-hide/);
|
||
assert.match(markup, /\[jio_hide mode="login"\]/);
|
||
assert.match(markup, /\[payshow\]/);
|
||
|
||
const blocks = read('src/Content/ContentBlocks.php');
|
||
const shortcodes = read('src/Core/Shortcodes.php');
|
||
assert.match(shortcodes, /add_shortcode\('jio_math'/);
|
||
assert.match(shortcodes, /add_shortcode\('jio_footnote'/);
|
||
assert.match(shortcodes, /add_shortcode\('jio_toc'/);
|
||
assert.match(blocks, /function shortcode_math/);
|
||
assert.match(blocks, /function shortcode_footnote/);
|
||
assert.match(blocks, /function append_footnotes/);
|
||
assert.match(blocks, /<sup>\$1\$2<\/sup>/);
|
||
// 删除键必须能移除相邻的块级元素。
|
||
const editorRuntime = read('assets/creator-studio/src/editor-runtime.ts');
|
||
assert.match(editorRuntime, /function removeAdjacentBlock/);
|
||
assert.match(appSource, /removeAdjacentBlock\(editor, event\)/);
|
||
// 工具栏按钮统一为「图标在上、文字在下」。
|
||
assert.match(editorToolbar, /jio-creator__tool-label/);
|
||
assert.match(read('assets/creator-studio/src/styles/studio.scss'), /\.jio-creator__tool-btn\s*\{[\s\S]*?flex-direction:\s*column/);
|
||
// 保存 / 预览 / 发布固定在底部状态栏,顶栏只保留工具。
|
||
assert.match(appSource, /<StatusBar/);
|
||
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /保存草稿/);
|
||
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /预览/);
|
||
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /字数/);
|
||
// 状态栏展示真实保存状态:编辑器是 TinyMCE 富文本,不支持 Markdown 语法。
|
||
assert.doesNotMatch(read('assets/creator-studio/src/components/status-bar.tsx'), /Markdown/);
|
||
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /saveStateLabel\(saveState\)/);
|
||
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /<SaveState value=\{saveState\}/);
|
||
assert.match(appSource, /draftRef\.current\.postId/);
|
||
const barStyles = read('assets/creator-studio/src/styles/_block-tools.scss');
|
||
assert.match(barStyles, /\.jio-creator__status\s*\{[\s\S]*?position:\s*fixed/);
|
||
assert.match(barStyles, /\.jio-creator__status\s*\{[\s\S]*?bottom:\s*0/);
|
||
assert.match(editorToolbar, /role="toolbar"/);
|
||
assert.match(read('assets/creator-studio/src/styles/studio.scss'), /\.jio-creator__top\s*\{[^}]*position:\s*fixed/);
|
||
assert.match(read('assets/creator-studio/src/styles/studio.scss'), /\.jio-creator\s*\{[\s\S]*?padding-top:\s*var\(--jc-top\)/);
|
||
});
|
||
|
||
test('top bar is masthead + centered toolbar, with no brand text in the toolbar row', () => {
|
||
const masthead = read('assets/creator-studio/src/components/studio-masthead.tsx');
|
||
const styles = read('assets/creator-studio/src/styles/studio.scss');
|
||
const rest = read('src/Core/CreatorRest.php');
|
||
|
||
// 第一行:左站点 LOGO、右用户头像。
|
||
assert.match(appSource, /<StudioMasthead/);
|
||
assert.match(masthead, /jio-creator__masthead/);
|
||
assert.match(masthead, /jio-creator__brand-logo/);
|
||
assert.match(masthead, /jio-creator__me-avatar/);
|
||
assert.match(masthead, /site\.logo/);
|
||
assert.match(masthead, /viewer\.avatar/);
|
||
// 工具条行不再有品牌字 / 模式文案。
|
||
assert.doesNotMatch(appSource, /jio-creator__bar-brand/);
|
||
assert.doesNotMatch(appSource, /jio-creator__bar-mode/);
|
||
assert.doesNotMatch(appSource, /jio-creator__bar-left/);
|
||
// 工具条居中。
|
||
assert.match(styles, /\.jio-creator__bar\s*\{[^}]*justify-content:\s*center/);
|
||
// Logo 与头像由服务端 bootstrap 提供,不在前端拼 URL。
|
||
assert.match(rest, /private function identity_payload/);
|
||
assert.match(rest, /'site'\s*=>\s*\$ident\['site'\]/);
|
||
assert.match(rest, /'viewer'\s*=>\s*\$ident\['viewer'\]/);
|
||
assert.match(rest, /jio_get_site_logo_url/);
|
||
assert.match(rest, /jio_get_avatar_url/);
|
||
// 顶栏身份字段与前端读取保持一致,避免悬空字段。
|
||
assert.doesNotMatch(read('assets/creator-studio/src/components/studio-masthead.tsx'), /viewer\.home/);
|
||
assert.doesNotMatch(rest, /'home'\s*=>\s*esc_url_raw\(\$home\)/);
|
||
});
|
||
|
||
test('block delete overlay lives outside the editable body and stays hidden', () => {
|
||
const runtime = read('assets/creator-studio/src/editor-runtime.ts');
|
||
const contentStyle = read('assets/creator-studio/src/editor-content-style.ts');
|
||
const pageStyles = read('assets/creator-studio/src/styles/_block-tools.scss');
|
||
|
||
// 浮层挂在 iframe 的 documentElement 上,不能进 body:data-mce-bogus 只跳过
|
||
// 序列化,body 里多一个非常规子节点会让 mceInsertContent 把整篇正文清空
|
||
// (表现为「第一次点隐藏块没有插进正文」)。
|
||
assert.match(runtime, /doc\.documentElement\.appendChild\(bar\)/);
|
||
assert.doesNotMatch(runtime, /doc\.body\.appendChild\(bar\)/);
|
||
assert.match(runtime, /bar\.id = 'jio-block-actions'/);
|
||
// iframe 是独立文档:页面级样式表管不到它,隐藏规则必须写在 content_style。
|
||
assert.match(contentStyle, /#jio-block-actions\s*\{[^}]*position:\s*absolute/);
|
||
assert.match(contentStyle, /#jio-block-actions\s*\{[^}]*opacity:\s*0/);
|
||
assert.match(contentStyle, /#jio-block-actions\.is-visible\s*\{[^}]*opacity:\s*1/);
|
||
assert.match(contentStyle, /body\s*\{[^}]*position:\s*relative/);
|
||
// 页面级 SCSS 不再重复定义 iframe 内部的选择器(否则只是死样式)。
|
||
assert.doesNotMatch(pageStyles, /\.jio-creator__block-actions\s*\{/);
|
||
// data-mce-bogus 让 TinyMCE 序列化、字数和选区都跳过这个浮层。
|
||
assert.match(runtime, /setAttribute\('data-mce-bogus', 'all'\)/);
|
||
|
||
// 两步删除:第一次点击只选中,第二次才真删;不再有关闭 / 叉号语义。
|
||
const firstClick = runtime.indexOf('if (selected !== block) {');
|
||
const secondClick = runtime.indexOf('// 第二次点击:真正删除。');
|
||
assert.ok(firstClick !== -1 && secondClick !== -1, 'two-step delete branches both exist');
|
||
assert.ok(firstClick < secondClick, 'first click only selects');
|
||
assert.ok(
|
||
runtime.indexOf('block.classList.add(SELECTED_CLASS)', firstClick) < secondClick,
|
||
'selection class is applied on the first click'
|
||
);
|
||
assert.match(contentStyle, /\.jio-creator__block-selected\s*\{/);
|
||
assert.match(runtime, /再次点击删除此块/);
|
||
});
|
||
|
||
// 工具栏下拉:菜单必须 portal 到 body,否则会被横向滚动的工具条裁剪。
|
||
test('toolbar dropdowns render through a portal with shared trigger geometry', () => {
|
||
const menu = read('assets/creator-studio/src/components/tool-menu.tsx');
|
||
const styles = read('assets/creator-studio/src/styles/studio.scss');
|
||
const toolbar = read('assets/creator-studio/src/components/editor-toolbar.tsx');
|
||
|
||
assert.match(menu, /createPortal/);
|
||
assert.match(menu, /document\.body/);
|
||
// 定位写成内联 style,只保留 left / top / width。
|
||
assert.match(menu, /style=\{\{\s*left:/);
|
||
assert.match(styles, /\.jio-creator__menu\s*\{[^}]*position:\s*fixed/);
|
||
assert.match(styles, /\.jio-creator__menu\s*\{[^}]*box-shadow/);
|
||
// 下拉触发器复用 .jio-creator__tool-btn,高度 / 字号 / 定位与其它按钮一致。
|
||
assert.doesNotMatch(styles, /\.jio-creator__format-field\s*\{/);
|
||
assert.doesNotMatch(styles, /\.jio-creator__format-select\s*\{/);
|
||
assert.doesNotMatch(toolbar, /jio-creator__quote-menu/);
|
||
});
|
||
|
||
// 引用块 / 隐藏内容 / 公式的编辑器内表现。
|
||
test('quote, hidden content, and block math render like the live article', () => {
|
||
const contentStyle = read('assets/creator-studio/src/editor-content-style.ts');
|
||
const markup = read('assets/creator-studio/src/utils/block-markup.ts');
|
||
const css = read('assets/css/frontend/pages/submit-editor.css');
|
||
|
||
// 引用:四种配色都有独立变量,引号标记不再依赖 FA 字体。
|
||
for (const cls of ['qe_wzk_c-red', 'qe_wzk_lan', 'qe_wzk_lv']) {
|
||
assert.match(contentStyle, new RegExp(`\\.quote_q\\.${cls}`));
|
||
assert.match(css, new RegExp(`\\.${cls}\\s*\\{`));
|
||
}
|
||
assert.match(contentStyle, /\.quote_q \.quote-mce::after|\.quote_q\.quote-mce::after/);
|
||
assert.match(contentStyle, /-webkit-mask/);
|
||
// 引用保持整块可识别,内部仍可直接编辑。
|
||
assert.match(markup, /class="\$\{classes\.join\(' '\)\}" contenteditable="false"><div contenteditable="true">/);
|
||
|
||
// 隐藏内容:短代码文本被 CSS 隐藏,只显示可读标签。
|
||
assert.match(contentStyle, /\.tinymce-hide > \.hide-before::before/);
|
||
assert.match(contentStyle, /content: attr\(data-jio-hide-label\)/);
|
||
assert.match(css, /content: attr\(data-jio-hide-label\)/);
|
||
|
||
// 公式独占一行时居中,不再铺满一条灰底横条。
|
||
assert.match(contentStyle, /\.jio-math--block\s*\{[^}]*text-align:\s*center/);
|
||
assert.match(contentStyle, /\.jio-math--block\s*\{[^}]*background:\s*transparent/);
|
||
assert.doesNotMatch(contentStyle, /\.jio-math--block\s*\{[^}]*background:\s*#/);
|
||
});
|
||
|
||
test('submit editor renders the configured body placeholder', () => {
|
||
const plugin = read('assets/js/front-submit-editor-basics.js');
|
||
const classic = read('templates/parts/submit/editor-main.php');
|
||
const studio = read('src/Core/CreatorStudio.php');
|
||
const styles = read('assets/css/frontend/pages/submit-editor.css');
|
||
const contentStyle = read('assets/creator-studio/src/editor-content-style.ts');
|
||
const runtime = read('assets/creator-studio/src/editor-runtime.ts');
|
||
|
||
// WordPress 打包的 TinyMCE 4.9 没有 placeholder 插件,主题自己实现。
|
||
assert.match(plugin, /PluginManager\.add\('jio_editor_basics'/);
|
||
assert.match(plugin, /getParam\('placeholder'/);
|
||
// 占位符不插 DOM 节点:插入的段落会被 setContent 清掉(首次加载不显示),
|
||
// 也会占住首行挡住点击。改为 body 属性 + CSS ::before。
|
||
assert.match(plugin, /body\.setAttribute\(EMPTY_ATTR, '1'\)/);
|
||
// 占位符不能作为 bogus 段落插进正文(补空段落用的 <br data-mce-bogus> 是另一回事)。
|
||
assert.doesNotMatch(plugin, /'data-mce-bogus':\s*'all'/);
|
||
assert.doesNotMatch(plugin, /addNodeFilter/);
|
||
// 图片 / 视频 / 表格即使没有文字也算有内容。
|
||
assert.match(plugin, /MEDIA = 'img,iframe,table,pre,hr,video,audio,figure/);
|
||
|
||
// 两条投稿路径都要加载插件,否则占位符在其中一个页面消失。
|
||
// 注意:WordPress 会丢弃名字已出现在 plugins 列表里的外部插件,
|
||
// 所以 jio_editor_basics 只能走 external_plugins。
|
||
assert.match(classic, /\$plugins\s*=\s*'[^']*'/);
|
||
assert.doesNotMatch(classic, /\$plugins\s*=\s*'[^']*jio_editor_basics/);
|
||
assert.match(classic, /\$external\['jio_editor_basics'\]\s*=\s*\$editor_basics_plugin_url/);
|
||
assert.match(classic, /'placeholder'\s*=>\s*\(string\) \(\$settings\['content_placeholder'\]/);
|
||
assert.match(studio, /\['editorBasicsPlugin'\]\s*=\s*\$this->editor_basics_plugin_url\(\)/);
|
||
assert.doesNotMatch(appSource, /plugins: '[^']*jio_editor_basics/);
|
||
assert.match(appSource, /external_plugins: bootstrap\.creator\?\.editorBasicsPlugin/);
|
||
|
||
// 占位符样式必须写进 iframe 的 content CSS,页面样式影响不到编辑器内部。
|
||
assert.match(styles, /body\[data-jio-empty="1"\]::before\s*\{/);
|
||
assert.match(contentStyle, /body\[data-jio-empty="1"\]::before\s*\{/);
|
||
// 两份 content CSS 都要让 body 撑满,否则正文下方的空白不属于 iframe。
|
||
// 用 100vh 而不是 100%:iframe 随正文变高,百分比会形成撑高循环。
|
||
assert.match(styles, /body\.mce-content-body\s*\{[^}]*min-height:\s*100vh/);
|
||
assert.match(contentStyle, /body\s*\{[^}]*min-height:\s*100vh/);
|
||
// 外边距不计入 body 高度,会让 html 比 iframe 高一截、长正文时出现内滚动。
|
||
assert.match(styles, /body\.mce-content-body\s*\{[^}]*margin:\s*0/);
|
||
|
||
// 空正文时 iframe 不能缩到只剩一行:高度下限取编辑器声明的高度。
|
||
assert.match(runtime, /getParam\?\.\('height', 0\)/);
|
||
});
|
||
|
||
test('clicking blank space returns the caret to the body', () => {
|
||
const plugin = read('assets/js/front-submit-editor-basics.js');
|
||
const styles = read('assets/css/frontend/pages/submit-editor.css');
|
||
|
||
// 光标停在结构块(表格 / 代码块 / 引用…)里时,点击正文空白处要补一个尾段。
|
||
assert.match(plugin, /var STRUCTURAL = 'table,pre,hr,figure,\.quote_q,\.tinymce-hide/);
|
||
assert.match(plugin, /function caretHome\(\)/);
|
||
assert.match(plugin, /editor\.selection\.setCursorLocation\(home/);
|
||
assert.match(plugin, /editor\.on\('click', restoreCaret\)/);
|
||
// 光标已经在普通段落里时不能抢走浏览器的落点。
|
||
assert.match(plugin, /if \(insideText\) \{\s*return;/);
|
||
// 占位提示不能吃掉点击。
|
||
assert.match(styles, /body\[data-jio-empty="1"\]::before\s*\{[^}]*pointer-events:\s*none/);
|
||
});
|
||
|
||
test('creator studio build artifacts and contracts exist', () => {
|
||
assert.ok(exists('assets/creator-studio/package.json'));
|
||
assert.ok(exists('assets/creator-studio/package-lock.json'));
|
||
// build/ is gitignored and deploy-only (same as assets/admin/build); when a
|
||
// local build is present, all three entry artifacts must be there together.
|
||
const built = ['index.asset.php', 'index.js', 'index.css'];
|
||
const present = built.filter((file) => exists(`assets/creator-studio/build/${file}`));
|
||
assert.ok(
|
||
present.length === 0 || present.length === built.length,
|
||
`build/ is partially built: ${present.join(', ')}`
|
||
);
|
||
assert.ok(exists('assets/js/protected-download.js'));
|
||
assert.ok(exists('src/Core/PreviewToken.php'));
|
||
assert.ok(exists('src/Admin/Native/SubmissionReview.php'));
|
||
});
|
||
|
||
test('沉浸式布局屏蔽主题头部/页脚与挂件区', () => {
|
||
const header = read('header.php');
|
||
const footer = read('footer.php');
|
||
// 工作台模式下这些输出必须可关闭,否则顶栏重复、正文被挂件挤压。
|
||
assert.match(header, /apply_filters\('jio_render_header', true\)/);
|
||
assert.match(header, /apply_filters\('jio_render_fluid_widgets', true\)/);
|
||
assert.match(footer, /apply_filters\('jio_render_footer', true\)/);
|
||
assert.match(pageSubmit, /add_filter\('jio_render_header', '__return_false'\)/);
|
||
assert.match(pageSubmit, /add_filter\('jio_render_footer', '__return_false'\)/);
|
||
assert.match(pageSubmit, /add_filter\('jio_render_fluid_widgets', '__return_false'\)/);
|
||
});
|
||
|
||
test('TinyMCE media buttons reuse Jio uploads and preserve bookmarks', () => {
|
||
assert.match(appSource, /JioSubmitMedia/);
|
||
assert.match(appSource, /editor\.selection\.getBookmark/);
|
||
assert.match(appSource, /moveToBookmark/);
|
||
assert.match(appSource, /data-edit-file-id/);
|
||
assert.match(appSource, /data-video-url/);
|
||
assert.match(read('src/Core/CreatorStudio.php'), /front-submit\/upload\/image/);
|
||
assert.match(read('assets/css/frontend/pages/submit.css'), /\.jio-editor-media\s*\{/);
|
||
assert.match(read('assets/creator-studio/src/components/publish-settings.tsx'), /添加封面/);
|
||
assert.match(appSource, /<PublishSettings/);
|
||
});
|
||
|
||
// 封面复用工具栏「图片」按钮的媒体弹窗,不再自带一套上传 input。
|
||
test('cover picker reuses the toolbar image media dialog', () => {
|
||
const settings = read('assets/creator-studio/src/components/publish-settings.tsx');
|
||
const styles = read('assets/creator-studio/src/styles/studio.scss');
|
||
|
||
assert.match(settings, /JioSubmitMedia/);
|
||
assert.match(settings, /type: 'image'/);
|
||
assert.match(settings, /onFeaturedChange\(\{ type: 'image'/);
|
||
// 旧的本地上传链路已经移除,不再有两套上传 UI。
|
||
assert.doesNotMatch(settings, /<input[^>]*type="file"/);
|
||
assert.doesNotMatch(settings, /apiUpload/);
|
||
assert.doesNotMatch(styles, /jio-creator__cover-input/);
|
||
});
|
||
|
||
test('writing surface is a centered paper card with a publish settings card below', () => {
|
||
const styles = read('assets/creator-studio/src/styles/studio.scss');
|
||
const settings = read('assets/creator-studio/src/components/publish-settings.tsx');
|
||
|
||
assert.doesNotMatch(appSource, /jio-creator__excerpt/);
|
||
assert.match(appSource, /请输入标题(最多 %d 个字)/, 'title placeholder must state the character limit');
|
||
assert.match(appSource, /className="jio-creator__title"/);
|
||
assert.match(appSource, /<PublishSettings/);
|
||
|
||
assert.match(styles, /\.jio-creator__canvas\s*\{[^}]*background:\s*#fff/);
|
||
assert.match(styles, /\.jio-creator__canvas\s*\{[^}]*width:\s*min\(900px/);
|
||
assert.match(styles, /\.jio-creator__canvas\s*\{[^}]*margin:\s*40px auto 0/);
|
||
assert.match(styles, /\.jio-page-submit\s*\{[^}]*background:\s*#f6f6f6/);
|
||
assert.match(styles, /\.jio-creator__settings\s*\{[^}]*background:\s*#fff/);
|
||
assert.match(styles, /\.jio-creator__title\s*\{[\s\S]*?font-size:\s*clamp\(26px, 3vw, 32px\)/);
|
||
assert.match(styles, /\.jio-creator__title\s*\{[\s\S]*?border-bottom:\s*1px solid/);
|
||
|
||
assert.match(settings, /定时发布/);
|
||
assert.match(settings, /type="datetime-local"/);
|
||
assert.match(settings, /canSchedule/);
|
||
assert.match(appSource, /scheduleEnabled=\{!!draft\.schedule\.enabled\}/);
|
||
|
||
// 付费开关在卡片里,且能直接打开发布抽屉的付费配置。
|
||
assert.match(settings, /canPay/);
|
||
assert.match(settings, /onPayEnabledChange/);
|
||
assert.match(settings, /onOpenPaySettings/);
|
||
assert.match(settings, /配置价格与付费资源/);
|
||
assert.match(appSource, /onOpenPaySettings=\{/);
|
||
|
||
// 分类与标签已从抽屉移到发布设置卡片。
|
||
assert.match(settings, /__\('分类', 'jio'\)/);
|
||
assert.match(settings, /__\('标签', 'jio'\)/);
|
||
assert.match(settings, /onCategoriesChange/);
|
||
assert.match(settings, /onTagsChange/);
|
||
assert.match(settings, /onCustomChange/);
|
||
assert.match(appSource, /onCategoriesChange=\{/);
|
||
assert.match(appSource, /onTagsChange=\{/);
|
||
assert.doesNotMatch(read('assets/creator-studio/src/panels/publish-drawer.tsx'), /taxonomy/);
|
||
|
||
// 发布动作从抽屉移到确认弹窗,人机验证挂载点随之迁移。
|
||
assert.match(appSource, /<PublishDialog/);
|
||
assert.match(read('assets/creator-studio/src/components/publish-dialog.tsx'), /jio-creator-captcha/);
|
||
assert.doesNotMatch(drawer, /jio-creator-captcha/);
|
||
});
|
||
|
||
test('publish settings use a multi-select category picker and a creatable tag input', () => {
|
||
const settings = read('assets/creator-studio/src/components/publish-settings.tsx');
|
||
const category = read('assets/creator-studio/src/components/category-select.tsx');
|
||
const tag = read('assets/creator-studio/src/components/tag-input.tsx');
|
||
const styles = read('assets/creator-studio/src/styles/_publish-drawer.scss');
|
||
|
||
// 分类:带搜索的多选下拉,触发器里是已选令牌。
|
||
assert.match(settings, /<CategorySelect/);
|
||
assert.match(category, /createPortal/);
|
||
assert.match(category, /type="checkbox"/);
|
||
assert.match(category, /onChange\(/);
|
||
assert.match(category, /选择分类(可多选)/);
|
||
// 旧的分类平铺复选列表已移除(自定义分类法仍是复选标签,不受影响)。
|
||
assert.doesNotMatch(settings, /toggleCategory/);
|
||
assert.doesNotMatch(settings, /categories\.includes\(/);
|
||
|
||
// 标签:可直接新建,并展示站点已有标签作为建议。
|
||
assert.match(settings, /<TagInput/);
|
||
assert.match(tag, /新建标签/);
|
||
assert.match(tag, /站点已有标签/);
|
||
assert.match(tag, /SEPARATORS/);
|
||
assert.match(tag, /Backspace/);
|
||
assert.doesNotMatch(styles, /\.jio-creator-inline\s*\{/);
|
||
|
||
// 服务端把已有标签作为建议下发,前端只读。
|
||
assert.match(creatorRest, /'suggestions'\s*=>/);
|
||
assert.match(creatorRest, /'taxonomy'\s*=>\s*'post_tag'/);
|
||
assert.match(appSource, /suggestions: \(bootstrap\.tags && bootstrap\.tags\.suggestions\)/);
|
||
});
|
||
|
||
// 二级 / 三级分类必须出现在投稿页:列表是完整层级树,白名单向下展开到子分类。
|
||
test('submit categories resolve the full hierarchy and expand the whitelist', () => {
|
||
const categories = read('src/Core/SubmitCategories.php');
|
||
const submitContext = read('templates/parts/submit/context.php');
|
||
const save = read('src/Core/FrontSubmitSave.php');
|
||
|
||
// 不再用 parent=0 过滤,改为取全部分类后按父子关系展平。
|
||
assert.doesNotMatch(categories, /'parent'\s*=>\s*0/);
|
||
assert.match(categories, /get_term_children\(\$id, 'category'\)/);
|
||
assert.match(categories, /'number'\s*=>\s*0/);
|
||
assert.match(categories, /'depth'\s*=>\s*\$depth/);
|
||
// 父级不在集合里时,子分类仍作为根节点出现,不会被静默丢弃。
|
||
assert.match(categories, /if \(!isset\(\$present\[\$parent\]\)\) \{/);
|
||
|
||
// 创作者工作台与经典投稿表单共用同一份分类数据。
|
||
assert.match(creatorRest, /SubmitCategories::tree\(\$settings\)/);
|
||
assert.match(submitContext, /SubmitCategories::tree\(\$settings\)/);
|
||
assert.doesNotMatch(submitContext, /\$cat_args\['parent'\]\s*=\s*0/);
|
||
|
||
// 白名单与审核应用都走同一套"含后代"的过滤。
|
||
assert.match(save, /SubmitCategories::filter_ids\(\$categories, \$settings\)/);
|
||
assert.match(versions, /SubmitCategories::filter_ids\(\$cats, \$settings\)/);
|
||
});
|
||
|
||
test('scheduled publishing is validated and applied server side', () => {
|
||
const save = read('src/Core/FrontSubmitSave.php');
|
||
const rest = read('src/Core/CreatorRest.php');
|
||
|
||
assert.match(save, /private function normalize_schedule\(array \$data\): string/);
|
||
assert.match(save, /\$status\s*=\s*'future';/);
|
||
assert.match(save, /\$postarr\['post_date'\]\s*=\s*\$schedule_at;/);
|
||
assert.match(save, /get_gmt_from_date\(\$schedule_at\)/);
|
||
assert.match(save, /case 'future':/);
|
||
assert.match(rest, /'schedule'\s*=>\s*\(array\) \$request->get_param\('schedule'\)/);
|
||
assert.match(rest, /'canSchedule'\s*=>\s*\$cap\('new_post_audit_no'\)/);
|
||
});
|
||
|
||
test('the writing card has no author strip above the title', () => {
|
||
assert.doesNotMatch(appSource, /AuthorRow/);
|
||
assert.doesNotMatch(appSource, /原创内容/);
|
||
assert.doesNotMatch(read('assets/creator-studio/src/styles/studio.scss'), /jio-creator__author/);
|
||
});
|
||
|
||
test('character count lives in the fixed bottom bar, not a reading-time footer', () => {
|
||
assert.doesNotMatch(appSource, /jio-creator__footer/);
|
||
assert.doesNotMatch(appSource, /分钟阅读/);
|
||
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /字数/);
|
||
});
|
||
|
||
// 发布抽屉的付费配置必须与前台投稿的「付费内容」卡片等价。
|
||
test('publish drawer pay settings match the front-submit pay card', () => {
|
||
const payPanel = read('assets/creator-studio/src/panels/pay-panel.tsx');
|
||
const resource = read('assets/creator-studio/src/components/resource-dialog.tsx');
|
||
const payUtil = read('assets/creator-studio/src/utils/pay.ts');
|
||
const draft = read('assets/creator-studio/src/draft.ts');
|
||
const rest = read('src/Core/CreatorRest.php');
|
||
|
||
// 现金 / 积分两种计价方式,且积分只在站点开启积分体系时出现。
|
||
assert.match(payPanel, /现金支付/);
|
||
assert.match(payPanel, /积分支付/);
|
||
assert.match(payPanel, /bootstrap\.pointsEnabled/);
|
||
assert.match(rest, /'pointsEnabled'\s*=>/);
|
||
|
||
// 会员价:手动填写模式与自动折扣模式由服务端决定。
|
||
assert.match(payPanel, /bootstrap\.vipManual/);
|
||
assert.match(payPanel, /bootstrap\.vip1Enabled/);
|
||
assert.match(payPanel, /bootstrap\.vip2Enabled/);
|
||
assert.match(payPanel, /vip1Points/);
|
||
assert.match(payPanel, /vip2Points/);
|
||
assert.match(rest, /'vipManual'\s*=>/);
|
||
assert.match(rest, /'vip1Name'\s*=>/);
|
||
assert.match(draft, /vip1Points: Number\(pay\.vip1Points/);
|
||
|
||
// 请求体带齐服务端会校验的会员价字段。
|
||
assert.match(payUtil, /vip1_points:/);
|
||
assert.match(payUtil, /vip2_points:/);
|
||
assert.match(payUtil, /vip1_price:/);
|
||
assert.match(payUtil, /vip2_price:/);
|
||
|
||
// 资源编辑器:与前台投稿一致的七个字段。
|
||
for (const field of ['extract_code', 'copy_key', 'copy_val', 'class', 'attachment_id']) {
|
||
assert.ok(payUtil.includes(field), `missing download field in request: ${field}`);
|
||
}
|
||
for (const label of ['下载地址', '资源备注', '复制名称', '复制内容', '自定义按钮文案', '按钮样式']) {
|
||
assert.ok(resource.includes(label), `missing resource field: ${label}`);
|
||
}
|
||
assert.match(resource, /上传附件/);
|
||
assert.match(resource, /提取码/);
|
||
assert.match(resource, /onPaste/);
|
||
assert.match(payPanel, /添加资源/);
|
||
assert.match(payPanel, /<ResourceDialog/);
|
||
|
||
// 按钮样式色块与保存端的白名单同源。
|
||
assert.match(rest, /'downloadClasses'\s*=>\s*DomainModel::DOWNLOAD_BUTTON_CLASSES/);
|
||
assert.match(payPanel, /bootstrap\.downloadClasses/);
|
||
|
||
// 受保护附件仍然只提交 asset_key。
|
||
assert.match(payUtil, /asset_key: assetKey/);
|
||
assert.doesNotMatch(payUtil, /wp-content|file_path/);
|
||
});
|
||
|
||
// 审核 / 发布路径要把资源的展示字段一并带进快照,否则会丢提取码与按钮样式。
|
||
test('creator snapshot keeps every download display field', () => {
|
||
const rest = read('src/Core/CreatorRest.php');
|
||
const snapshot = rest.slice(
|
||
rest.indexOf('private function snapshot_from_payload'),
|
||
rest.indexOf('private function claim_private_assets')
|
||
);
|
||
|
||
assert.match(snapshot, /'extract_code'\s*=>/);
|
||
assert.match(snapshot, /'copy_key'\s*=>/);
|
||
assert.match(snapshot, /'copy_val'\s*=>/);
|
||
assert.match(snapshot, /'class'\s*=>/);
|
||
assert.match(snapshot, /'attachment_id'\s*=>/);
|
||
assert.match(snapshot, /'net_disk'/);
|
||
// bootstrap 回读时字段名要与前端模型一致。
|
||
assert.match(rest, /'extractCode'\s*=>/);
|
||
assert.match(rest, /'copyKey'\s*=>/);
|
||
assert.match(rest, /'copyVal'\s*=>/);
|
||
});
|
||
|
||
// 草稿链路:自动保存不能因为「不停手」而永远不触发,也不能在成功后把本地副本删掉。
|
||
test('autosave keeps a max-wait window and retries after failure', () => {
|
||
const hook = read('assets/creator-studio/src/hooks/use-autosave.ts');
|
||
|
||
// 只靠防抖的话持续输入永远不会落盘(每次按键都会重置定时器)。
|
||
assert.match(hook, /maxWait\s*=\s*30000/);
|
||
assert.match(hook, /deadlineTimer/);
|
||
// 上限窗口不能随每次按键重置。
|
||
assert.match(hook, /if \(!deadlineTimer\.current\) \{/);
|
||
// 失败后按退避重试,而不是停在「保存失败」。
|
||
assert.match(hook, /RETRY_DELAYS = \[5000, 15000, 60000\]/);
|
||
assert.match(hook, /retryTimer\.current = window\.setTimeout/);
|
||
});
|
||
|
||
test('draft save binds the id into the url and only clears the local backup once settled', () => {
|
||
const app = read('assets/creator-studio/src/index.tsx');
|
||
|
||
// 首次保存后地址栏要能回到同一篇草稿,否则刷新就丢。
|
||
assert.match(app, /function bindDraftUrl/);
|
||
assert.match(app, /window\.history\.replaceState/);
|
||
assert.match(app, /url\.searchParams\.set\('edit', String\(target\)\)/);
|
||
assert.match(app, /bindDraftUrl\(updated\)/);
|
||
// 本地副本只在「保存成功且之后没有再改动」时才清。
|
||
assert.match(app, /if \(ok && !dirtyRef\.current\) \{/);
|
||
// 存储键跟着草稿 ID 走,两篇草稿不会互相覆盖。
|
||
assert.match(app, /const storageKeyFor = /);
|
||
assert.match(app, /key: storageKeyFor\(draft\)/);
|
||
// 恢复快照时要还原成编辑器里的可编辑形态。
|
||
assert.match(app, /const content = hydrateBlocks\(parsed\.content\)/);
|
||
// 自动保存不阻塞发布按钮。
|
||
assert.match(app, /saveState\.state === 'saving' && !saveState\.autosave/);
|
||
// 冲突要同步 rev 并告知作者。
|
||
assert.match(app, /error\?\.status === 409/);
|
||
assert.match(app, /current_rev/);
|
||
});
|
||
|
||
test('autosave state is labelled differently from a manual save', () => {
|
||
const saveState = read('assets/creator-studio/src/components/save-state.tsx');
|
||
|
||
assert.match(saveState, /autosave\?: boolean/);
|
||
assert.match(saveState, /AUTOSAVE_LABELS/);
|
||
assert.match(saveState, /自动保存中…/);
|
||
assert.match(saveState, /已自动保存/);
|
||
assert.match(saveState, /export function saveStateLabel/);
|
||
});
|
||
|
||
// 服务端:草稿不能改写文章生命周期、不能吃发布配额、自动保存不建 revision。
|
||
test('draft saves never demote a submitted post or consume the publish quota', () => {
|
||
const save = read('src/Core/FrontSubmitSave.php');
|
||
|
||
// 自动保存标记从 REST 一路透传到 persist()。
|
||
assert.match(save, /\$is_auto\s*=\s*\$is_draft && !empty\(\$data\['autosave'\]\)/);
|
||
// pending / future / publish 的草稿保存保持原状态。
|
||
assert.match(save, /\$status = in_array\(\$current_status, \['pending', 'future', 'publish', 'private'\], true\)/);
|
||
// 草稿与自动保存不扣防刷配额,只有正式投稿计数。
|
||
assert.match(save, /if \(!\$is_draft && !\$is_edit && !current_user_can\('manage_options'\)/);
|
||
// 自动保存不建 revision。
|
||
assert.match(save, /remove_action\('post_updated', 'wp_save_post_revision'\)/);
|
||
// 只有媒体的草稿也算有内容。
|
||
assert.match(save, /private function draft_has_content/);
|
||
assert.match(save, /img\|table\|pre\|hr\|iframe\|video\|audio\|figure/);
|
||
// 响应要带上真实状态,前端才能区分「刚提交」与「仍在审核中」。
|
||
assert.match(save, /'status'\s*=>\s*\$status/);
|
||
assert.match(save, /已保存,文章仍在审核队列中/);
|
||
|
||
// 改稿版本同样抑制自动保存的 revision。
|
||
const version = read('src/Core/SubmissionVersion.php');
|
||
assert.match(version, /remove_action\('post_updated', 'wp_save_post_revision'\)/);
|
||
assert.match(version, /\$is_auto = !empty\(\$data\['autosave'\]\)/);
|
||
|
||
// REST 侧要把 autosave 传给 persist()。
|
||
assert.match(read('src/Core/CreatorRest.php'), /'autosave'\s*=>\s*\$is_auto/);
|
||
});
|
||
|
||
// 恢复本地草稿会一次性把正文换成很长的一篇:iframe 必须跟着变高,
|
||
// 否则超出的段落留在 iframe 内部滚动区,看起来像「只剩第一段」。
|
||
test('restoring a draft resyncs the editor height', () => {
|
||
const app = read('assets/creator-studio/src/index.tsx');
|
||
const restoreAt = app.indexOf('const restore = useCallback');
|
||
const restoreBody = app.slice(restoreAt, app.indexOf('const rememberSelection'));
|
||
|
||
// setContent 之后必须重新同步高度。
|
||
assert.match(restoreBody, /editor\?\.setContent\(content\)/);
|
||
assert.match(restoreBody, /syncEditorHeight\(editor\)/);
|
||
// SetContent 事件本身也要参与高度同步与内容同步。
|
||
assert.match(app, /editor\.on\('input change Undo Redo AddUndo SetContent'/);
|
||
});
|
||
|
||
// 图片/视频插入后正文会变高,且资源是异步加载的:高度必须跟着重算,
|
||
// 否则图片下方的段落会被 iframe 的固定高度裁掉。
|
||
test('media inserts resync the editor height, including after async loading', () => {
|
||
const app = read('assets/creator-studio/src/index.tsx');
|
||
const runtime = read('assets/creator-studio/src/editor-runtime.ts');
|
||
|
||
// 两个媒体回调(选择器与嵌入)插入后都要同步高度。
|
||
const mediaAt = app.indexOf('const openMedia = useCallback');
|
||
const mediaBody = app.slice(mediaAt, app.indexOf('return (', mediaAt));
|
||
assert.equal((mediaBody.match(/syncEditorHeight\(target\)/g) || []).length, 2);
|
||
|
||
// 图片 / iframe / video 加载完成后补一次同步。
|
||
assert.match(runtime, /export function installHeightWatchers/);
|
||
assert.match(runtime, /addEventListener\(\s*'load'/);
|
||
assert.match(runtime, /tag === 'IMG' \|\| tag === 'IFRAME' \|\| tag === 'VIDEO' \|\| tag === 'AUDIO'/);
|
||
assert.match(runtime, /ResizeObserver/);
|
||
// 只在 init 路径挂一次,重复安装要短路。
|
||
assert.match(runtime, /__jioHeightWatched/);
|
||
assert.match(app, /installHeightWatchers\(editor\)/);
|
||
});
|