/* @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 = ``; this.options.onUpdate?.({ editor: this }); return true; }, }), setLink: ({ href }: { href: string }) => ({ run: () => { this.html = `

${href}

`; this.options.onUpdate?.({ editor: this }); return true; }, }), toggleBold: () => ({ run: () => { this.html = '

粗体

'; 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> = []; 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: '

旧正文

', }, }); expect(wrapper.find('.kt-tiptap-editor__prosemirror').html()).toContain( '

旧正文

', ); }); it('emits normalized html from setHtml', async () => { const wrapper = mount(KtTiptapHtmlEditor, { props: { modelValue: '

旧正文

', }, }); (wrapper.vm as unknown as TiptapExpose).setHtml( '

新标题

新正文

', ); await nextTick(); expect(wrapper.emitted('update:modelValue')).toHaveLength(1); expect(wrapper.emitted('change')).toHaveLength(1); expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([ '

新标题

新正文

', ]); expect(wrapper.emitted('change')?.at(-1)).toEqual([ '

新标题

新正文

', '

旧正文

', ]); expect((wrapper.vm as unknown as TiptapExpose).getHtml()).toBe( '

新标题

新正文

', ); }); it('marks disabled editor and disables bold command', () => { const wrapper = mount(KtTiptapHtmlEditor, { props: { disabled: true, modelValue: '

旧正文

', }, }); 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('

旧正文

'); const editorRef = ref(null); return () => (
{html.value}
); }, }); const wrapper = mount(Parent); await wrapper.find('[data-testid="parent-update"]').trigger('click'); await nextTick(); expect(wrapper.find('[data-testid="parent-html"]').text()).toBe( '

新标题

新正文

', ); }); });