Files
wordpress-theme-aksrt/tests/widget-system/creator-studio-contract.test.cjs
T
kate522 0e4e4670dd feat(creator-studio): full pay settings and paid resources in the publish drawer
The drawer only had a pay toggle and one bare price field, so several
options from the front-submit pay card could not be set at all.

- New PayPanel mirrors the front-submit card: cash / points segment
  (points only when the site has credits enabled), price or points with
  the currency suffix, and VIP prices. VIP fields render only when the
  site asks for manual VIP pricing and the level is enabled, and now
  submit vip1_points / vip2_points as well.
- New ResourceDialog restores "添加资源": download URL with an attachment
  picker, resource note (extraction code), copy name / value, custom
  button label and button style swatches. Pasting a net-disk share link
  splits the URL and the extraction code.
- pay.ts sends every download display field and tags net-disk links.
- CreatorRest exposes vipManual / vip*Enabled / vip names / pointsEnabled
  / downloadClasses / currencySymbol, and the readback carries
  extractCode / copyKey / copyVal / class / attachmentId.
- Fixes an existing data loss: snapshot_from_payload dropped copy_key,
  copy_val, class and attachment_id, so approving a revision erased them.

Files split to stay within budget: publish-drawer 332 lines, pay-panel
343, resource-dialog 254. 886 widget-system tests pass.
2026-11-09 15:24:30 +08:00

530 lines
28 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, /editorRef\.current\?\.setContent/);
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.match(drawer, /onSubmit\(false\)/);
assert.match(drawer, /立即发布/);
});
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, /insertHtml\(codeBlockMarkup\(\)\)/);
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');
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 > code,不写语言 / 主题属性。
assert.match(markup, /<pre><code><\/code><\/pre>/);
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 与前台投稿编辑器保持同一套代码块样式。
const editorCss = read('assets/css/frontend/pages/submit-editor.css');
assert.doesNotMatch(editorCss, /\.enlighter-pre/);
assert.match(editorCss, /pre code \{/);
// 隐藏内容沿用 .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'), /字数/);
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /Markdown 语法输入中/);
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 inside the editor iframe 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');
// 浮层插在 TinyMCE iframe 的 body 里。
assert.match(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('creator studio build artifacts and contracts exist', () => {
assert.ok(exists('assets/creator-studio/package.json'));
assert.ok(exists('assets/creator-studio/package-lock.json'));
assert.ok(exists('assets/creator-studio/build/index.asset.php'));
assert.ok(exists('assets/creator-studio/build/index.js'));
assert.ok(exists('assets/creator-studio/build/index.css'));
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/);
});
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\}/);
});
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*=>/);
});