刘光辉
13 小时以前 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
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
 
import type { FieldDefinition } from '../../src/domain/types';
import type { OnlyOfficeBridge } from '../../src/onlyoffice/bridge';
 
import { useTemplateAnnotator } from '../../src/composables/useTemplateAnnotator';
import type { OfficeContentControl } from '../../src/onlyoffice/types';
 
interface Deferred<T> {
  promise: Promise<T>;
  reject: (reason?: unknown) => void;
  resolve: (value: T) => void;
}
 
function deferred<T>(): Deferred<T> {
  let resolve!: (value: T) => void;
  let reject!: (reason?: unknown) => void;
  const promise = new Promise<T>((resolvePromise, rejectPromise) => {
    resolve = resolvePromise;
    reject = rejectPromise;
  });
  return { promise, reject, resolve };
}
 
function control(Tag: string, InternalId = `id-${Tag}`): OfficeContentControl {
  return { Alias: Tag, InternalId, Tag };
}
 
function definition(): FieldDefinition {
  return {
    mode: 'single',
    controlType: 'text',
    fieldCode: 'result',
    alias: '结果',
    groupCode: '',
    rowId: '',
    tag: 'eln.field.result',
    color: { R: 23, G: 109, B: 92 },
    placeholder: '请输入结果',
    defaultValue: '',
    defaultChecked: false,
  };
}
 
const validOptions = {
  apiBaseUrl: 'http://localhost:30000',
    documentId: 'file-1',
    bizDataId: 'record-1',
    bizModule: 'eln.template',
    bizScene: 'eln.template.annotate',
    pluginVersion: '0.1.0',
    canAnnotate: true,
};
 
function createBridge() {
  let callbacks: Parameters<OnlyOfficeBridge['bindLifecycle']>[0] | undefined;
  const cleanup = vi.fn();
  const bridge: OnlyOfficeBridge = {
    bindLifecycle: vi.fn((value) => {
      callbacks = value;
      return cleanup;
    }),
    close: vi.fn(),
    deleteControl: vi.fn(),
    disableTrackRevisions: vi.fn().mockResolvedValue(undefined),
    getRuntimeOptions: vi.fn(() => validOptions),
    insertField: vi.fn(),
    listControls: vi.fn(),
    selectControl: vi.fn(),
  };
  return { bridge, cleanup, getCallbacks: () => callbacks };
}
 
describe('useTemplateAnnotator', () => {
  beforeEach(() => {
    setActivePinia(createPinia());
  });
 
  it('由 SDK ready 回调初始化并写入字段列表', async () => {
    const fixture = createBridge();
    const controls = [control('eln.field.result')];
    vi.mocked(fixture.bridge.listControls).mockResolvedValue(controls);
    const api = useTemplateAnnotator(fixture.bridge);
 
    fixture.getCallbacks()?.onReady();
 
    await vi.waitFor(() => expect(api.store.status).toBe('ready'));
    expect(fixture.bridge.disableTrackRevisions).toHaveBeenCalledOnce();
    expect(api.store.controls).toEqual(controls);
    expect(api.store.operation).toEqual({ kind: 'success', message: '文档字段已刷新' });
  });
 
  it('SDK ready 时上下文非法则进入 fatal 且不读取控件', async () => {
    const fixture = createBridge();
    vi.mocked(fixture.bridge.getRuntimeOptions).mockReturnValue({});
    const api = useTemplateAnnotator(fixture.bridge);
 
    fixture.getCallbacks()?.onReady();
 
    await vi.waitFor(() => expect(api.store.status).toBe('error'));
    expect(api.store.operation).toEqual({ kind: 'error', message: '插件运行上下文无效' });
    expect(fixture.bridge.listControls).not.toHaveBeenCalled();
  });
 
  it('记录 SDK ready 初始化失败且生命周期调用不产生未处理拒绝', async () => {
    const fixture = createBridge();
    vi.mocked(fixture.bridge.listControls).mockRejectedValue(new Error('SDK 未就绪'));
    const api = useTemplateAnnotator(fixture.bridge);
 
    expect(() => fixture.getCallbacks()?.onReady()).not.toThrow();
 
    await vi.waitFor(() => expect(api.store.status).toBe('error'));
    expect(api.store.operation).toEqual({ kind: 'error', message: '读取内容控件失败:SDK 未就绪' });
  });
 
  it('SDK ready 永不触发时结束加载并进入 fatal error', () => {
    vi.useFakeTimers();
    try {
      const fixture = createBridge();
      const api = useTemplateAnnotator(fixture.bridge);
 
      vi.advanceTimersByTime(5_000);
 
      expect(api.store.status).toBe('error');
      expect(api.store.operation).toEqual({ kind: 'error', message: 'ONLYOFFICE SDK 初始化超时' });
      expect(fixture.bridge.listControls).not.toHaveBeenCalled();
    } finally {
      vi.useRealTimers();
    }
  });
 
  it('initialize 失败进入 fatal error 并继续抛出错误', async () => {
    const fixture = createBridge();
    const error = new Error('SDK 不可用');
    vi.mocked(fixture.bridge.listControls).mockRejectedValue(error);
    const api = useTemplateAnnotator(fixture.bridge);
 
    await expect(api.initialize()).rejects.toBe(error);
    expect(api.store.status).toBe('error');
    expect(api.store.operation).toEqual({ kind: 'error', message: '读取内容控件失败:SDK 不可用' });
  });
 
  it('refresh 成功同步列表和成功状态', async () => {
    const fixture = createBridge();
    const controls = [control('eln.field.sample')];
    vi.mocked(fixture.bridge.listControls).mockResolvedValue(controls);
    const api = useTemplateAnnotator(fixture.bridge);
 
    await api.refresh();
 
    expect(api.store.controls).toEqual(controls);
    expect(api.store.status).toBe('ready');
    expect(api.store.operation).toEqual({ kind: 'success', message: '文档字段已刷新' });
  });
 
  it('refresh 失败恢复 ready、显示错误并继续抛出', async () => {
    const fixture = createBridge();
    const error = new Error('编辑器未返回内容控件列表');
    vi.mocked(fixture.bridge.listControls).mockRejectedValue(error);
    const api = useTemplateAnnotator(fixture.bridge);
 
    await expect(api.refresh()).rejects.toBe(error);
 
    expect(api.store.status).toBe('ready');
    expect(api.store.operation).toEqual({ kind: 'error', message: '读取内容控件失败:编辑器未返回内容控件列表' });
  });
 
  it('initialize 失败后再次 refresh 失败保持 fatal error', async () => {
    const fixture = createBridge();
    vi.mocked(fixture.bridge.listControls).mockRejectedValueOnce(new Error('初始化失败')).mockRejectedValueOnce(new Error('刷新失败'));
    const api = useTemplateAnnotator(fixture.bridge);
 
    await expect(api.initialize()).rejects.toThrow('初始化失败');
    expect(api.store.status).toBe('error');
 
    await expect(api.refresh()).rejects.toThrow('刷新失败');
 
    expect(api.store.status).toBe('error');
    expect(api.store.operation).toEqual({ kind: 'error', message: '读取内容控件失败:刷新失败' });
  });
 
  it('insert 成功同步 bridge 返回列表并显示字段别名', async () => {
    const fixture = createBridge();
    const field = definition();
    const controls = [control(field.tag)];
    vi.mocked(fixture.bridge.insertField).mockResolvedValue(controls);
    const api = useTemplateAnnotator(fixture.bridge);
 
    await api.insert(field);
 
    expect(fixture.bridge.insertField).toHaveBeenCalledWith(field);
    expect(api.store.controls).toEqual(controls);
    expect(api.store.status).toBe('ready');
    expect(api.store.operation).toEqual({ kind: 'success', message: '已创建字段:结果' });
  });
 
  it('insert 失败恢复 ready、显示错误并继续抛出', async () => {
    const fixture = createBridge();
    const error = new Error('Tag 重复');
    vi.mocked(fixture.bridge.insertField).mockRejectedValue(error);
    const api = useTemplateAnnotator(fixture.bridge);
 
    await expect(api.insert(definition())).rejects.toBe(error);
 
    expect(api.store.status).toBe('ready');
    expect(api.store.operation).toEqual({ kind: 'error', message: '标注失败:Tag 重复' });
  });
 
  it('deleteControl 成功同步列表并显示标注已解除', async () => {
    const fixture = createBridge();
    const remaining = [control('eln.field.other')];
    vi.mocked(fixture.bridge.deleteControl).mockResolvedValue(remaining);
    const api = useTemplateAnnotator(fixture.bridge);
 
    await api.deleteControl('control-1');
 
    expect(fixture.bridge.deleteControl).toHaveBeenCalledWith('control-1');
    expect(api.store.controls).toEqual(remaining);
    expect(api.store.status).toBe('ready');
    expect(api.store.operation).toEqual({ kind: 'success', message: '标注已解除' });
  });
 
  it('deleteControl 失败恢复 ready、显示错误并继续抛出', async () => {
    const fixture = createBridge();
    const error = new Error('解除后字段仍然存在');
    vi.mocked(fixture.bridge.deleteControl).mockRejectedValue(error);
    const api = useTemplateAnnotator(fixture.bridge);
 
    await expect(api.deleteControl('control-1')).rejects.toBe(error);
 
    expect(api.store.status).toBe('ready');
    expect(api.store.operation).toEqual({ kind: 'error', message: '解除标注失败:解除后字段仍然存在' });
  });
 
  it('select 失败后刷新控件,但保留并抛出原始定位错误', async () => {
    const fixture = createBridge();
    const selectError = new Error('定位目标已不存在,请刷新字段列表');
    const controls = [control('eln.field.current')];
    vi.mocked(fixture.bridge.selectControl).mockRejectedValue(selectError);
    vi.mocked(fixture.bridge.listControls).mockResolvedValue(controls);
    const api = useTemplateAnnotator(fixture.bridge);
 
    await expect(api.select('missing-id')).rejects.toBe(selectError);
 
    expect(fixture.bridge.listControls).toHaveBeenCalledOnce();
    expect(api.store.controls).toEqual(controls);
    expect(api.store.status).toBe('ready');
    expect(api.store.operation).toEqual({ kind: 'error', message: '定位失败:定位目标已不存在,请刷新字段列表' });
  });
 
  it('select 补偿刷新失败不替换原始错误,空 ID 也传给 bridge', async () => {
    const fixture = createBridge();
    const selectError = new Error('该内容控件没有可用的内部 ID');
    vi.mocked(fixture.bridge.selectControl).mockRejectedValue(selectError);
    vi.mocked(fixture.bridge.listControls).mockRejectedValue(new Error('刷新也失败'));
    const api = useTemplateAnnotator(fixture.bridge);
 
    await expect(api.select('')).rejects.toBe(selectError);
 
    expect(fixture.bridge.selectControl).toHaveBeenCalledWith('');
    expect(api.store.operation).toEqual({ kind: 'error', message: '定位失败:该内容控件没有可用的内部 ID' });
  });
 
  it('select 定位期间保持页面状态且继续使用并发锁', async () => {
    const fixture = createBridge();
    const pending = deferred<void>();
    vi.mocked(fixture.bridge.selectControl).mockReturnValue(pending.promise);
    const api = useTemplateAnnotator(fixture.bridge);
    api.store.setStatus('ready');
    api.store.setOperation({ kind: 'success', message: '文档字段已刷新' });
 
    const operation = api.select('control-1');
 
    expect(api.store.status).toBe('ready');
    expect(api.store.operation).toEqual({ kind: 'success', message: '文档字段已刷新' });
    await expect(api.refresh()).rejects.toThrow('已有操作正在执行');
 
    pending.resolve();
    await operation;
 
    expect(api.store.operation).toEqual({ kind: 'success', message: '文档字段已刷新' });
  });
 
  it('所有异步操作共用并发锁,重入不覆盖当前状态', async () => {
    const fixture = createBridge();
    const pending = deferred<OfficeContentControl[]>();
    vi.mocked(fixture.bridge.listControls).mockReturnValue(pending.promise);
    const api = useTemplateAnnotator(fixture.bridge);
    const first = api.refresh();
 
    await expect(api.insert(definition())).rejects.toThrow('已有操作正在执行');
    await expect(api.select('control-1')).rejects.toThrow('已有操作正在执行');
    await expect(api.deleteControl('control-1')).rejects.toThrow('已有操作正在执行');
    expect(api.store.status).toBe('working');
    expect(api.store.operation).toEqual({ kind: 'working', message: '正在读取文档字段...' });
 
    pending.resolve([]);
    await first;
  });
 
  it('操作失败后释放并发锁,可再次执行', async () => {
    const fixture = createBridge();
    vi.mocked(fixture.bridge.listControls).mockRejectedValueOnce(new Error('第一次失败')).mockResolvedValueOnce([]);
    const api = useTemplateAnnotator(fixture.bridge);
 
    await expect(api.refresh()).rejects.toThrow('第一次失败');
    await expect(api.refresh()).resolves.toBeUndefined();
 
    expect(fixture.bridge.listControls).toHaveBeenCalledTimes(2);
  });
 
  it('dispose 后忽略 SDK ready,且迟到的异步结果不再写 Store', async () => {
    const fixture = createBridge();
    const pending = deferred<OfficeContentControl[]>();
    vi.mocked(fixture.bridge.listControls).mockReturnValue(pending.promise);
    const api = useTemplateAnnotator(fixture.bridge);
    const operation = api.refresh();
    const snapshot = {
      status: api.store.status,
      controls: [...api.store.controls],
      operation: { ...api.store.operation },
    };
 
    api.dispose();
    fixture.getCallbacks()?.onReady();
    pending.resolve([control('eln.field.late')]);
    await operation;
 
    expect(api.store.$state).toEqual(snapshot);
    expect(fixture.bridge.listControls).toHaveBeenCalledOnce();
  });
 
  it('dispose 幂等调用 lifecycle cleanup', () => {
    const fixture = createBridge();
    const api = useTemplateAnnotator(fixture.bridge);
 
    api.dispose();
    api.dispose();
 
    expect(fixture.cleanup).toHaveBeenCalledOnce();
  });
});