'use strict'; /** * 投稿编辑器正文基础插件(front-submit-editor-basics.js)的行为测试。 * * 用 jsdom 模拟 TinyMCE 暴露给插件的 API(getBody / dom / selection / getParam / * 事件总线),直接跑真实的插件源码。 * * 两个回归点: * 1. 占位符曾以 `data-mce-bogus` 段落插进正文 —— TinyMCE 在 setContent 时会清掉它, * 表现为「首次加载不显示,输入一个再删掉才出现」;现在改为 body 属性 + CSS ::before。 * 2. 光标在块级结构里时点击正文空白处无法落回正文;现在点击 body 会补一个可编辑 * 尾段并把光标放进去。 */ 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 { JSDOM } = require(path.join(root, 'assets/creator-studio/node_modules/jsdom')); const pluginSource = fs.readFileSync( path.join(root, 'assets/js/front-submit-editor-basics.js'), 'utf8' ); /** 极简 TinyMCE 替身:只实现插件实际用到的部分。 */ function createEditor(html, settings = {}) { const dom = new JSDOM(`${html}`); const doc = dom.window.document; const listeners = {}; const state = { selectionNode: doc.body, caret: null }; const editor = { settings, id: 'jio-test-editor', getParam(name, fallback) { return Object.prototype.hasOwnProperty.call(settings, name) ? settings[name] : fallback; }, getBody: () => doc.body, getDoc: () => doc, dom: { create(tag, attrs, inner) { const node = doc.createElement(tag); Object.entries(attrs || {}).forEach(([key, value]) => node.setAttribute(key, String(value))); if (inner !== undefined) node.innerHTML = inner; return node; }, encode: (value) => String(value), getParent: (node, selector) => (node && node.closest ? node.closest(selector) : null), }, selection: { getNode: () => state.selectionNode, setCursorLocation(node, offset) { state.caret = { node, offset }; }, }, on(names, handler) { String(names) .split(/\s+/) .filter(Boolean) .forEach((name) => { (listeners[name] = listeners[name] || []).push(handler); }); }, fire(name, payload = {}) { (listeners[name] || []).forEach((handler) => handler(payload)); }, }; return { dom, doc, editor, state }; } function loadPlugin() { const registered = {}; const tinymce = { PluginManager: { add(name, factory) { registered[name] = factory; }, }, }; const fakeWindow = { tinymce, setTimeout: (fn) => fn() }; // 插件是 IIFE,只依赖传入的 window;用 Function 注入避免污染全局。 new Function('window', pluginSource)(fakeWindow); return registered; } const PLUGIN = 'jio_editor_basics'; test('placeholder is flagged on first load of an empty document', () => { const { doc, editor } = createEditor('


', { placeholder: '请输入正文' }); const plugin = loadPlugin()[PLUGIN]; assert.equal(typeof plugin, 'function', `${PLUGIN} must register a plugin`); plugin(editor); editor.fire('init'); assert.equal(doc.body.getAttribute('data-jio-placeholder'), '请输入正文'); assert.equal(doc.body.getAttribute('data-jio-empty'), '1', 'empty body must be flagged for the CSS hint'); }); test('placeholder flag clears once the author types and returns when emptied', () => { const { doc, editor } = createEditor('


', { placeholder: '请输入正文' }); const plugin = loadPlugin()[PLUGIN]; plugin(editor); editor.fire('init'); const paragraph = doc.body.querySelector('p'); paragraph.textContent = '真实内容'; editor.fire('input'); assert.equal(doc.body.hasAttribute('data-jio-empty'), false, 'flag must clear when content exists'); paragraph.textContent = ''; editor.fire('input'); assert.equal(doc.body.getAttribute('data-jio-empty'), '1', 'flag must come back when emptied'); }); test('media blocks count as content even without text', () => { const { doc, editor } = createEditor('

', { placeholder: '请输入正文' }); const plugin = loadPlugin()[PLUGIN]; plugin(editor); editor.fire('init'); assert.equal(doc.body.hasAttribute('data-jio-empty'), false); }); test('placeholder never becomes a DOM node in the editable body', () => { const { doc, editor } = createEditor('


', { placeholder: '请输入正文' }); const plugin = loadPlugin()[PLUGIN]; plugin(editor); editor.fire('init'); // 正文里只能有真实段落:占位符若作为节点存在,就会占住首行并挡住点击。 assert.equal(doc.body.children.length, 1); assert.equal(doc.body.querySelector('p').className, ''); assert.equal(doc.body.textContent.trim(), ''); }); test('editor chrome injected into the body does not count as content', () => { // 真实页面上 editor-runtime 会把块删除浮层插进 body,它带 data-mce-bogus // 但含文字(「✕ 选中此块」)。若按 body.textContent 判断,空正文永远非空, // 占位符就永远不显示 —— 这正是「首次加载不显示」的真实原因。 const { doc, editor } = createEditor( '


' + '
' + '
', { placeholder: '请输入正文' } ); const plugin = loadPlugin()[PLUGIN]; plugin(editor); editor.fire('init'); assert.equal(doc.body.getAttribute('data-jio-empty'), '1', 'bogus chrome must not count as content'); }); test('no placeholder configured leaves the body untouched', () => { const { doc, editor } = createEditor('


', {}); const plugin = loadPlugin()[PLUGIN]; plugin(editor); editor.fire('init'); assert.equal(doc.body.hasAttribute('data-jio-placeholder'), false); assert.equal(doc.body.hasAttribute('data-jio-empty'), false); }); test('clicking blank space after a structural block appends an editable paragraph', () => { // 正文以代码块结尾:光标在块内时点击空白处,必须有可编辑段落承载光标。 const { doc, editor, state } = createEditor('

正文

code
'); const plugin = loadPlugin()[PLUGIN]; plugin(editor); editor.fire('init'); state.selectionNode = doc.querySelector('pre'); editor.fire('click', { target: doc.body }); const last = doc.body.lastElementChild; assert.equal(last.tagName, 'P', 'a paragraph must be appended after the structural block'); assert.equal(state.caret && state.caret.node, last, 'caret must move into that paragraph'); }); test('clicking blank space while the caret is already in text does not move it', () => { const { doc, editor, state } = createEditor('

正文

第二段

'); const plugin = loadPlugin()[PLUGIN]; plugin(editor); editor.fire('init'); state.selectionNode = doc.querySelectorAll('p')[1]; editor.fire('click', { target: doc.body }); assert.equal(state.caret, null, 'browser-placed caret must be left alone'); assert.equal(doc.body.children.length, 2, 'no extra paragraph is added'); }); test('clicking a nested editable block does not append a trailing paragraph', () => { const { doc, editor, state } = createEditor( '

引用内容

' ); const plugin = loadPlugin()[PLUGIN]; plugin(editor); editor.fire('init'); state.selectionNode = doc.querySelector('.quote_q p'); editor.fire('click', { target: doc.querySelector('.quote_q p') }); assert.equal(state.caret, null); assert.equal(doc.body.children.length, 1); });