Hovering a quote, hidden block, code block or table showed a ✕ button in the block's top-right corner. It read as a stray close button and is not wanted. Removed - installBlockActions() and the two-step select/delete flow it drove - the block-actions markup, positioning, selected-state CSS in the iframe content style - BLOCK_SELECTOR, which only the overlay used Deleting blocks still works: removeAdjacentBlock() keeps Backspace / Delete next to a structural block (table, pre, hr, quote, hidden block) working, and that is covered by both code and tests.
799 lines
43 KiB
JavaScript
799 lines
43 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\)/);
|
||
});
|
||
|
||
// 块右上角的悬停删除浮层已移除:删除统一走键盘(退格 / Delete)与工具栏。
|
||
// 浮层留在这里会被误当成「不需要的关闭按钮」,也曾在 body 里破坏 mceInsertContent。
|
||
test('hover block-delete overlay is gone', () => {
|
||
const runtime = read('assets/creator-studio/src/editor-runtime.ts');
|
||
const contentStyle = read('assets/creator-studio/src/editor-content-style.ts');
|
||
const app = read('assets/creator-studio/src/index.tsx');
|
||
|
||
assert.doesNotMatch(runtime, /jio-block-actions/);
|
||
assert.doesNotMatch(runtime, /jio-creator__block-actions/);
|
||
assert.doesNotMatch(runtime, /installBlockActions/);
|
||
assert.doesNotMatch(runtime, /BLOCK_SELECTOR/);
|
||
assert.doesNotMatch(contentStyle, /jio-block-actions/);
|
||
assert.doesNotMatch(contentStyle, /jio-creator__block-selected/);
|
||
assert.doesNotMatch(app, /installBlockActions/);
|
||
|
||
// 键盘删除仍然保留:空段落旁的结构块要能被退格删掉。
|
||
assert.match(runtime, /export function removeAdjacentBlock/);
|
||
assert.match(runtime, /STRUCTURAL_SELECTOR/);
|
||
assert.match(app, /removeAdjacentBlock\(editor, event\)/);
|
||
});
|
||
|
||
// 工具栏下拉:菜单必须 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\)/);
|
||
});
|