/* @vitest-environment happy-dom */
|
|
import type { VueWrapper } from '@vue/test-utils';
|
|
import { mount } from '@vue/test-utils';
|
import { defineComponent, h, nextTick } from 'vue';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import GlobalOfficeDocumentModal from '../index.vue';
|
|
const mocks = vi.hoisted(() => ({
|
afterEach: vi.fn(),
|
closeModal: vi.fn(),
|
getOnlyOfficeConfig: vi.fn(),
|
getOnlyOfficeSaveStatus: vi.fn(),
|
messageError: vi.fn(),
|
messageSuccess: vi.fn(),
|
openModal: vi.fn(),
|
register: vi.fn(),
|
removeRouteHook: vi.fn(),
|
requestEditorClose: vi.fn(),
|
shouldEditorClose: true,
|
}));
|
|
vi.mock('#/router', () => ({ router: { afterEach: mocks.afterEach, push: vi.fn() } }));
|
|
vi.mock('@jnpf/hooks', () => ({
|
useGlobSetting: () => ({ apiURL: '' }),
|
useMessage: () => ({ createConfirm: vi.fn(), createMessage: { error: mocks.messageError, success: mocks.messageSuccess } }),
|
}));
|
|
vi.mock('@jnpf/ui/modal', async () => {
|
const { defineComponent, h, onMounted } = await import('vue');
|
|
return {
|
BasicModal: defineComponent({
|
name: 'BasicModal',
|
props: { closeFunc: Function },
|
emits: ['register'],
|
setup(_, { emit, slots }) {
|
onMounted(() => emit('register'));
|
|
return () =>
|
h('section', { 'data-test': 'basic-modal' }, [
|
h('header', { 'data-test': 'modal-title' }, slots.title?.()),
|
h('main', { 'data-test': 'modal-content' }, slots.default?.()),
|
]);
|
},
|
}),
|
useModal: () => [mocks.register, { closeModal: mocks.closeModal, openModal: mocks.openModal }],
|
};
|
});
|
|
vi.mock('ant-design-vue', async () => {
|
const { defineComponent, h } = await import('vue');
|
|
return {
|
Button: defineComponent({
|
name: 'AButton',
|
emits: ['click'],
|
setup(_, { emit, slots }) {
|
return () => h('button', { type: 'button', onClick: () => emit('click') }, slots.default?.());
|
},
|
}),
|
Space: defineComponent({
|
name: 'ASpace',
|
setup(_, { slots }) {
|
return () => h('div', slots.default?.());
|
},
|
}),
|
Spin: defineComponent({
|
name: 'ASpin',
|
props: { tip: String },
|
setup(props) {
|
return () => h('div', { 'data-test': 'spin' }, props.tip);
|
},
|
}),
|
Tag: defineComponent({
|
name: 'ATag',
|
setup(_, { slots }) {
|
return () => h('span', { 'data-test': 'tag' }, slots.default?.());
|
},
|
}),
|
Tooltip: defineComponent({
|
name: 'ATooltip',
|
props: { placement: String, title: String },
|
setup(_, { slots }) {
|
return () => slots.default?.();
|
},
|
}),
|
};
|
});
|
|
vi.mock('#/api/x/onlyoffice', () => ({
|
getOnlyOfficeConfig: mocks.getOnlyOfficeConfig,
|
getOnlyOfficeSaveStatus: mocks.getOnlyOfficeSaveStatus,
|
}));
|
|
// 真实编辑器会去加载 DS 的 api.js,测试里换成能手动触发事件的桩
|
vi.mock('#/components/OnlyOfficeEditor/src/Editor.vue', () => ({
|
default: defineComponent({
|
name: 'OnlyOfficeEditor',
|
props: { config: { required: true, type: Object } },
|
emits: ['close', 'error', 'modified', 'ready', 'save'],
|
setup(_, { emit, expose }) {
|
expose({
|
requestClose() {
|
mocks.requestEditorClose();
|
if (mocks.shouldEditorClose) emit('close');
|
},
|
});
|
return () =>
|
h('div', {
|
'data-test': 'onlyoffice-editor',
|
onClick: () => emit('modified', true),
|
onDblclick: () => emit('save'),
|
});
|
},
|
}),
|
}));
|
|
const { onlineUtils } = await import('#/utils/jnpf');
|
|
const FILE = { fileId: 'file_001', name: '检验报告.docx' };
|
const CONFIG_RESPONSE = {
|
data: {
|
document: { fileType: 'docx', key: 'key_v1', title: '检验报告.docx', url: 'http://gw/api/file/Image/annex/file_001?s=sk' },
|
documentType: 'word',
|
editorConfig: { callbackUrl: 'http://gw/api/biz/onlyoffice/callback', mode: 'edit' },
|
token: 'signed.jwt.value',
|
},
|
};
|
|
let wrapper: VueWrapper<any>;
|
/** 组件注册进 router.afterEach 的那个钩子,测试里手动触发以模拟路由跳转 */
|
let routeHook: (to: any, from: any, failure?: any) => void = () => {};
|
|
async function flush(times = 3) {
|
for (let i = 0; i < times; i++) {
|
await nextTick();
|
}
|
}
|
|
describe('globalOfficeDocumentModal', () => {
|
beforeEach(() => {
|
mocks.openModal.mockClear();
|
mocks.closeModal.mockClear();
|
mocks.removeRouteHook.mockClear();
|
mocks.getOnlyOfficeConfig.mockReset();
|
mocks.getOnlyOfficeConfig.mockResolvedValue(CONFIG_RESPONSE);
|
mocks.getOnlyOfficeSaveStatus.mockReset();
|
mocks.getOnlyOfficeSaveStatus.mockResolvedValue({ data: { lastSaveTime: null, version: 1 } });
|
mocks.messageSuccess.mockClear();
|
mocks.messageError.mockClear();
|
mocks.requestEditorClose.mockClear();
|
mocks.shouldEditorClose = true;
|
mocks.afterEach.mockReset();
|
mocks.afterEach.mockImplementation((hook) => {
|
routeHook = hook;
|
return mocks.removeRouteHook;
|
});
|
wrapper = mount(GlobalOfficeDocumentModal);
|
});
|
|
afterEach(() => {
|
wrapper?.unmount();
|
});
|
|
it('拿到后端 config 后才渲染编辑器,且透传文档参数和业务场景', async () => {
|
onlineUtils.openOfficeDocument({ file: FILE, bizScene: 'eln.template.annotate' });
|
await flush();
|
|
expect(mocks.openModal).toHaveBeenCalledWith(true);
|
expect(mocks.getOnlyOfficeConfig).toHaveBeenCalledWith(
|
expect.objectContaining({ bizScene: 'eln.template.annotate', fileId: 'file_001', fileName: '检验报告.docx', mode: 'edit' }),
|
);
|
expect(wrapper.find('[data-test="onlyoffice-editor"]').exists()).toBe(true);
|
});
|
|
it('DMS 预览把文件类型和短期票据交给配置签发接口', async () => {
|
onlineUtils.openOfficeDocument({
|
dmsAccessTicket: 'ticket-1',
|
file: { fileId: 'version-1', name: '质量手册.docx' },
|
fileType: 'dms',
|
mode: 'view',
|
});
|
await flush();
|
|
expect(mocks.getOnlyOfficeConfig).toHaveBeenCalledWith(
|
expect.objectContaining({
|
dmsAccessTicket: 'ticket-1',
|
fileId: 'version-1',
|
fileName: '质量手册.docx',
|
fileType: 'dms',
|
mode: 'view',
|
}),
|
);
|
});
|
|
it('后端把 edit 降级为 view 时给出只读提示', async () => {
|
mocks.getOnlyOfficeConfig.mockResolvedValue({
|
data: { ...CONFIG_RESPONSE.data, editorConfig: { ...CONFIG_RESPONSE.data.editorConfig, mode: 'view' } },
|
});
|
|
onlineUtils.openOfficeDocument({ file: FILE, mode: 'edit' });
|
await flush();
|
|
// 标签紧跟标题,不在右侧按钮区——放右边太容易被当成装饰略过
|
expect(wrapper.find('.header-main [data-test="tag"]').exists()).toBe(true);
|
});
|
|
it('config 缺 token 时不渲染编辑器——没有签名 DS 一定会拒,早报错好过让编辑器空转', async () => {
|
mocks.getOnlyOfficeConfig.mockResolvedValue({ data: { document: { key: 'key_v1' } } });
|
|
onlineUtils.openOfficeDocument({ file: FILE });
|
await flush();
|
|
expect(wrapper.find('[data-test="onlyoffice-editor"]').exists()).toBe(false);
|
expect(wrapper.text()).toContain('编辑器配置不完整');
|
});
|
|
it('改动过再关闭才触发 onSave,onClose 则总会触发', async () => {
|
const onSave = vi.fn();
|
const onClose = vi.fn();
|
|
onlineUtils.openOfficeDocument({ file: FILE, onClose, onSave });
|
await flush();
|
|
await wrapper.find('[data-test="onlyoffice-editor"]').trigger('click'); // 桩组件的 click = emit modified
|
await wrapper.find('button').trigger('click'); // 头部关闭按钮
|
await flush();
|
|
expect(onSave).toHaveBeenCalledWith({ file: FILE, modified: true });
|
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(mocks.closeModal).toHaveBeenCalled();
|
});
|
|
it('没改动就关闭不触发 onSave', async () => {
|
const onSave = vi.fn();
|
|
onlineUtils.openOfficeDocument({ file: FILE, onSave });
|
await flush();
|
await wrapper.find('button').trigger('click');
|
await flush();
|
|
expect(onSave).not.toHaveBeenCalled();
|
});
|
|
it('ONLYOFFICE 取消关闭时保留外层弹窗和编辑器', async () => {
|
const onClose = vi.fn();
|
mocks.shouldEditorClose = false;
|
|
onlineUtils.openOfficeDocument({ file: FILE, onClose });
|
await flush();
|
await wrapper.find('[data-test="onlyoffice-editor"]').trigger('click');
|
await wrapper.find('button').trigger('click');
|
await flush();
|
|
expect(mocks.requestEditorClose).toHaveBeenCalledTimes(1);
|
expect(mocks.closeModal).not.toHaveBeenCalled();
|
expect(onClose).not.toHaveBeenCalled();
|
expect(wrapper.find('[data-test="onlyoffice-editor"]').exists()).toBe(true);
|
});
|
|
it('后端确认新版文件写回后提示保存成功', async () => {
|
mocks.getOnlyOfficeSaveStatus
|
.mockResolvedValueOnce({ data: { lastSaveTime: 1000, version: 1 } })
|
.mockResolvedValueOnce({ data: { lastSaveTime: 2000, version: 1 } });
|
|
onlineUtils.openOfficeDocument({ file: FILE });
|
await flush();
|
await wrapper.find('[data-test="onlyoffice-editor"]').trigger('dblclick');
|
await flush();
|
|
expect(mocks.messageSuccess).toHaveBeenCalledWith('文档保存成功');
|
expect(wrapper.find('[data-test="document-saving-mask"]').exists()).toBe(false);
|
});
|
|
it('保存期间显示全屏 loading 并阻止关闭', async () => {
|
let resolveSaveStatus: (value: any) => void = () => {};
|
mocks.getOnlyOfficeSaveStatus
|
.mockResolvedValueOnce({ data: { lastSaveTime: 1000, version: 1 } })
|
.mockReturnValueOnce(new Promise((resolve) => (resolveSaveStatus = resolve)));
|
|
onlineUtils.openOfficeDocument({ file: FILE });
|
await flush();
|
await wrapper.find('[data-test="onlyoffice-editor"]').trigger('dblclick');
|
await flush();
|
|
expect(wrapper.find('[data-test="document-saving-mask"]').text()).toContain('文档保存中');
|
await wrapper.find('button').trigger('click');
|
expect(mocks.requestEditorClose).not.toHaveBeenCalled();
|
expect(mocks.closeModal).not.toHaveBeenCalled();
|
|
resolveSaveStatus({ data: { lastSaveTime: 2000, version: 1 } });
|
await flush();
|
});
|
|
it('保存确认超时后解除 loading 并提示重试', async () => {
|
vi.useFakeTimers();
|
mocks.getOnlyOfficeSaveStatus.mockResolvedValue({ data: { lastSaveTime: 1000, version: 1 } });
|
|
onlineUtils.openOfficeDocument({ file: FILE });
|
await flush();
|
await wrapper.find('[data-test="onlyoffice-editor"]').trigger('dblclick');
|
await vi.runAllTimersAsync();
|
await flush();
|
|
expect(wrapper.find('[data-test="document-saving-mask"]').exists()).toBe(false);
|
expect(mocks.messageError).toHaveBeenCalledWith('文档保存确认超时,请重试');
|
vi.useRealTimers();
|
});
|
|
it('路由跳走时自动关闭——编辑器占着 DS 编辑锁,不能留在新页面上', async () => {
|
const onClose = vi.fn();
|
onlineUtils.openOfficeDocument({ file: FILE, onClose });
|
await flush();
|
expect(wrapper.find('[data-test="onlyoffice-editor"]').exists()).toBe(true);
|
|
routeHook({ fullPath: '/b' }, { fullPath: '/a' });
|
await flush();
|
|
expect(mocks.closeModal).toHaveBeenCalled();
|
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(wrapper.find('[data-test="onlyoffice-editor"]').exists()).toBe(false);
|
});
|
|
it('导航失败或地址没变时不关——页面根本没动,跟着关是误伤', async () => {
|
onlineUtils.openOfficeDocument({ file: FILE });
|
await flush();
|
|
routeHook({ fullPath: '/b' }, { fullPath: '/a' }, new Error('aborted'));
|
routeHook({ fullPath: '/a' }, { fullPath: '/a' });
|
await flush();
|
|
expect(mocks.closeModal).not.toHaveBeenCalled();
|
expect(wrapper.find('[data-test="onlyoffice-editor"]').exists()).toBe(true);
|
});
|
|
it('组件卸载时摘掉路由钩子', () => {
|
wrapper.unmount();
|
expect(mocks.removeRouteHook).toHaveBeenCalledTimes(1);
|
});
|
|
it('关闭后到达的 config 响应不再打开编辑器(防竞态)', async () => {
|
let resolveConfig: (value: any) => void = () => {};
|
mocks.getOnlyOfficeConfig.mockReturnValue(new Promise((resolve) => (resolveConfig = resolve)));
|
|
onlineUtils.openOfficeDocument({ file: FILE });
|
await flush();
|
await wrapper.find('button').trigger('click'); // 请求还在途中就关闭
|
resolveConfig(CONFIG_RESPONSE);
|
await flush();
|
|
expect(wrapper.find('[data-test="onlyoffice-editor"]').exists()).toBe(false);
|
});
|
});
|