'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('
短正文
', { height: 520, placeholder: '请输入正文' }); // 极简 iframe 替身:插件只读写 style.height / overflow / scrolling。 const iframe = doc.createElement('iframe'); const container = doc.createElement('div'); container.appendChild(iframe); editor.getContentAreaContainer = () => container; const plugin = loadPlugin()[PLUGIN]; plugin(editor); editor.fire('init'); const initial = iframe.style.height; assert.equal(initial, '520px', 'short content keeps the declared writing area'); // 正文变长:scrollHeight 变大,iframe 必须跟着长。 Object.defineProperty(doc.body, 'scrollHeight', { configurable: true, get: () => 2400 }); Object.defineProperty(doc.body, 'offsetHeight', { configurable: true, get: () => 2400 }); editor.fire('input'); assert.equal(iframe.style.height, '2400px', 'iframe height must follow the content'); assert.equal(iframe.style.overflow, 'hidden'); assert.equal(iframe.getAttribute('scrolling'), 'no'); }); test('height measurement ignores the body min-height fill', () => { // body 的 min-height 让空正文铺满视口;如果测量时也带上它, // 就会得到「撑高 → 再撑高」的反馈循环(实测会涨到两万多 px)。 const { doc, editor } = createEditor('正文
', { height: 520 }); const iframe = doc.createElement('iframe'); const container = doc.createElement('div'); container.appendChild(iframe); editor.getContentAreaContainer = () => container; // 模拟「body 被 min-height 撑到 5000」但真实内容只有 1800。 let minHeightApplied = false; Object.defineProperty(doc.body, 'scrollHeight', { configurable: true, get: () => (minHeightApplied ? 5000 : 1800), }); Object.defineProperty(doc.body, 'offsetHeight', { configurable: true, get: () => (minHeightApplied ? 5000 : 1800), }); // 插件把 min-height 归零时视为摘掉撑满。 Object.defineProperty(doc.body.style, 'minHeight', { configurable: true, get: () => (minHeightApplied ? '100vh' : ''), set: (value) => { minHeightApplied = value !== '0'; }, }); minHeightApplied = true; const plugin = loadPlugin()[PLUGIN]; plugin(editor); editor.fire('init'); assert.equal(iframe.style.height, '1800px', 'measure the content, not the viewport fill'); }); test('no placeholder configured leaves the body untouched', () => { const { doc, editor } = 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( '引用内容