kt-template-admin/test/components/richText/KtTiptapHtmlEditor.spec.tsx

325 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* @vitest-environment happy-dom */
/* eslint-disable vue/one-component-per-file */
import type { ComponentPublicInstance, Ref } from 'vue';
import { mount } from '@vue/test-utils';
import { defineComponent, h, nextTick, ref } from 'vue';
import KtTiptapHtmlEditor from '@test-source/apps/web-antdv-next/src/components/richText/KtTiptapHtmlEditor';
import { beforeEach, describe, expect, it, vi } from 'vitest';
type FakeEditorOptions = {
content?: string;
editable?: boolean;
onBlur?: () => void;
onFocus?: () => void;
onUpdate?: (payload: { editor: FakeEditor }) => void;
};
type TiptapExpose = ComponentPublicInstance & {
getHtml: () => string;
setHtml: (value: string) => void;
};
class FakeEditor {
public html = '';
public commands = {
focus: () => true,
setContent: (value: string, options?: { emitUpdate?: boolean }) => {
this.html = value;
if (options?.emitUpdate !== false) {
this.options.onUpdate?.({ editor: this });
}
return true;
},
};
public destroyed = false;
public editable = true;
public options: FakeEditorOptions;
constructor(options: FakeEditorOptions) {
this.options = options;
this.html = options.content || '';
this.editable = options.editable !== false;
}
public chain = () => ({
focus: () => ({
run: () => true,
setImage: ({ src }: { src: string }) => ({
run: () => {
this.html = `<img src="${src}">`;
this.options.onUpdate?.({ editor: this });
return true;
},
}),
setLink: ({ href }: { href: string }) => ({
run: () => {
this.html = `<p><a href="${href}">${href}</a></p>`;
this.options.onUpdate?.({ editor: this });
return true;
},
}),
toggleBold: () => ({
run: () => {
this.html = '<p><strong>粗体</strong></p>';
this.options.onUpdate?.({ editor: this });
return true;
},
}),
}),
});
/**
* 标记编辑器已销毁,便于组件卸载路径保持可调用。
*/
public destroy() {
this.destroyed = true;
}
/**
* 返回当前 HTML模拟 Tiptap 的 getHTML。
*/
public getHTML() {
return this.html;
}
/**
* 查询命令激活态,测试中默认未激活。
*/
public isActive() {
return false;
}
/**
* 同步只读状态给测试替身。
*
* @param value false 表示 readonly/disabled。
*/
public setEditable(value: boolean) {
this.editable = value;
}
}
const editorRefs: Array<Ref<FakeEditor | null>> = [];
vi.mock('@tiptap/vue-3', () => ({
EditorContent: defineComponent({
name: 'MockEditorContent',
props: {
editor: {
default: null,
type: Object,
},
},
setup(props) {
return () =>
h('div', {
class: 'kt-tiptap-editor__prosemirror ProseMirror',
innerHTML: (props.editor as FakeEditor | null)?.getHTML() || '',
});
},
}),
useEditor: (options: FakeEditorOptions) => {
const editor = ref(new FakeEditor(options));
editorRefs.push(editor);
return editor;
},
}));
vi.mock('antdv-next', () => ({
Button: defineComponent({
name: 'MockButton',
props: {
disabled: Boolean,
type: {
default: undefined,
type: String,
},
},
emits: ['click'],
setup(props, { attrs, emit, slots }) {
return () =>
h(
'button',
{
...attrs,
disabled: props.disabled,
onClick: () => emit('click'),
type: 'button',
},
slots.default?.(),
);
},
}),
Divider: defineComponent({
name: 'MockDivider',
setup() {
return () => h('span', { class: 'mock-divider' });
},
}),
Input: defineComponent({
name: 'MockInput',
props: {
value: {
default: '',
type: String,
},
},
emits: ['update:value'],
setup(props, { emit }) {
return () =>
h('input', {
value: props.value,
onInput: (event: Event) =>
emit('update:value', (event.target as HTMLInputElement).value),
});
},
}),
Modal: defineComponent({
name: 'MockModal',
props: {
open: Boolean,
title: {
default: '',
type: String,
},
},
emits: ['cancel', 'ok', 'update:open'],
setup(props, { slots }) {
return () =>
props.open ? h('div', { role: 'dialog' }, slots.default?.()) : null;
},
}),
Space: defineComponent({
name: 'MockSpace',
setup(_props, { slots }) {
return () => h('div', slots.default?.());
},
}),
Tooltip: defineComponent({
name: 'MockTooltip',
props: {
title: {
default: '',
type: String,
},
},
setup(_props, { slots }) {
return () => h('span', slots.default?.());
},
}),
}));
vi.mock('@vben/icons', () => ({
IconifyIcon: defineComponent({
name: 'MockIconifyIcon',
props: {
icon: {
default: '',
type: String,
},
},
setup(props) {
return () => h('i', { 'data-icon': props.icon });
},
}),
}));
describe('ktTiptapHtmlEditor', () => {
beforeEach(() => {
editorRefs.length = 0;
});
it('renders incoming html content', () => {
const wrapper = mount(KtTiptapHtmlEditor, {
props: {
modelValue: '<p>旧正文</p>',
},
});
expect(wrapper.find('.kt-tiptap-editor__prosemirror').html()).toContain(
'<p>旧正文</p>',
);
});
it('emits normalized html from setHtml', async () => {
const wrapper = mount(KtTiptapHtmlEditor, {
props: {
modelValue: '<p>旧正文</p>',
},
});
(wrapper.vm as unknown as TiptapExpose).setHtml(
'<h2>新标题</h2><p>新正文</p>',
);
await nextTick();
expect(wrapper.emitted('update:modelValue')).toHaveLength(1);
expect(wrapper.emitted('change')).toHaveLength(1);
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
'<h2>新标题</h2><p>新正文</p>',
]);
expect(wrapper.emitted('change')?.at(-1)).toEqual([
'<h2>新标题</h2><p>新正文</p>',
'<p>旧正文</p>',
]);
expect((wrapper.vm as unknown as TiptapExpose).getHtml()).toBe(
'<h2>新标题</h2><p>新正文</p>',
);
});
it('marks disabled editor and disables bold command', () => {
const wrapper = mount(KtTiptapHtmlEditor, {
props: {
disabled: true,
modelValue: '<p>旧正文</p>',
},
});
expect(wrapper.classes()).toContain('kt-tiptap-editor--disabled');
expect(
wrapper.find('[data-kt-tiptap-command="bold"]').attributes('disabled'),
).toBeDefined();
});
it('syncs setHtml changes through a v-model parent', async () => {
const Parent = defineComponent({
name: 'TiptapParentHarness',
setup() {
const html = ref('<p>旧正文</p>');
const editorRef = ref<null | TiptapExpose>(null);
return () => (
<section>
<KtTiptapHtmlEditor ref={editorRef} v-model={html.value} />
<output data-testid="parent-html">{html.value}</output>
<button
data-testid="parent-update"
onClick={() =>
editorRef.value?.setHtml('<h2>新标题</h2><p>新正文</p>')
}
type="button"
>
</button>
</section>
);
},
});
const wrapper = mount(Parent);
await wrapper.find('[data-testid="parent-update"]').trigger('click');
await nextTick();
expect(wrapper.find('[data-testid="parent-html"]').text()).toBe(
'<h2>新标题</h2><p>新正文</p>',
);
});
});