刘光辉
9 小时以前 0dfe84494048ce27ba8449831782128412d3eb13
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
/* @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);
  });
});