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