import { mount, type VueWrapper } from '@vue/test-utils';
|
import Antd from 'ant-design-vue';
|
import { createPinia } from 'pinia';
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import App from '../src/App.vue';
|
import type { OnlyOfficeBridge } from '../src/onlyoffice/bridge';
|
import type { OfficeContentControl } from '../src/onlyoffice/types';
|
|
beforeAll(() => {
|
window.matchMedia ??= () =>
|
({
|
addEventListener() {},
|
addListener() {},
|
dispatchEvent: () => false,
|
matches: false,
|
media: '',
|
onchange: null,
|
removeEventListener() {},
|
removeListener() {},
|
}) as MediaQueryList;
|
globalThis.ResizeObserver ??= class ResizeObserver {
|
disconnect() {}
|
observe() {}
|
unobserve() {}
|
};
|
});
|
|
const wrappers: VueWrapper[] = [];
|
afterEach(() => wrappers.splice(0).forEach((wrapper) => wrapper.unmount()));
|
|
function deferred<T>() {
|
let resolve!: (value: T) => void;
|
const promise = new Promise<T>((done) => {
|
resolve = done;
|
});
|
return { promise, resolve };
|
}
|
|
function createBridge(canAnnotate = true) {
|
let lifecycle: Parameters<OnlyOfficeBridge['bindLifecycle']>[0] | undefined;
|
const cleanup = vi.fn();
|
const bridge: OnlyOfficeBridge = {
|
bindLifecycle: vi.fn((callbacks) => {
|
lifecycle = callbacks;
|
return cleanup;
|
}),
|
close: vi.fn(),
|
deleteControl: vi.fn(),
|
disableTrackRevisions: vi.fn().mockResolvedValue(undefined),
|
getRuntimeOptions: vi.fn(() => ({
|
apiBaseUrl: 'http://localhost:30000',
|
documentId: 'file-1',
|
bizDataId: 'record-1',
|
bizModule: 'eln.template',
|
bizScene: 'eln.template.annotate',
|
pluginVersion: '0.1.0',
|
canAnnotate,
|
})),
|
insertField: vi.fn(),
|
listControls: vi.fn(),
|
selectControl: vi.fn(),
|
};
|
return { bridge, cleanup, triggerReady: () => lifecycle?.onReady() };
|
}
|
|
function mountApp(bridge: OnlyOfficeBridge | null) {
|
const wrapper = mount(App, { global: { plugins: [createPinia(), Antd], provide: { onlyOfficeBridge: bridge } } });
|
wrappers.push(wrapper);
|
return wrapper;
|
}
|
|
describe('App', () => {
|
it('初始显示版本、加载状态并禁用编辑', () => {
|
const fixture = createBridge();
|
const wrapper = mountApp(fixture.bridge);
|
|
expect(wrapper.text()).not.toContain('ELN 模板标注');
|
expect(wrapper.get('[data-test="plugin-version"]').text()).toBe('v0.1.0');
|
expect(wrapper.get('[data-test="connection-status"]').text()).toBe('加载中');
|
expect(wrapper.get('[data-test="submit-field"]').attributes('disabled')).toBeDefined();
|
});
|
|
it('SDK ready 后刷新列表并进入已连接状态', async () => {
|
const fixture = createBridge();
|
const controls = [{ Alias: '样品编号', InternalId: 'control-1', Tag: 'eln.field.sample_id' }];
|
vi.mocked(fixture.bridge.listControls).mockResolvedValue(controls);
|
const wrapper = mountApp(fixture.bridge);
|
|
fixture.triggerReady();
|
|
await vi.waitFor(() => expect(wrapper.get('[data-test="connection-status"]').text()).toBe('已连接'));
|
expect(wrapper.get('[data-test="control-item"]').text()).toContain('样品编号');
|
});
|
|
it('只读上下文禁用插入但允许刷新文档字段', async () => {
|
const fixture = createBridge(false);
|
vi.mocked(fixture.bridge.listControls).mockResolvedValue([]);
|
const wrapper = mountApp(fixture.bridge);
|
|
fixture.triggerReady();
|
|
await vi.waitFor(() => expect(wrapper.get('[data-test="connection-status"]').text()).toBe('已连接'));
|
expect(wrapper.get('[data-test="submit-field"]').attributes('disabled')).toBeDefined();
|
expect(wrapper.get('[data-test="refresh-controls"]').attributes('disabled')).toBeUndefined();
|
});
|
|
it('FieldEditor 提交经 bridge 插入并更新列表和成功状态', async () => {
|
const fixture = createBridge();
|
vi.mocked(fixture.bridge.listControls).mockResolvedValue([]);
|
vi.mocked(fixture.bridge.insertField).mockImplementation(async (definition) => [{ Alias: definition.alias, InternalId: 'new-1', Tag: definition.tag }]);
|
const wrapper = mountApp(fixture.bridge);
|
fixture.triggerReady();
|
await vi.waitFor(() => expect(wrapper.get('[data-test="connection-status"]').text()).toBe('已连接'));
|
|
await wrapper.get('[data-test="submit-field"]').trigger('click');
|
|
await vi.waitFor(() => expect(fixture.bridge.insertField).toHaveBeenCalledOnce());
|
expect(wrapper.get('[data-test="control-item"]').text()).toContain('样品编号');
|
expect(wrapper.text()).toContain('已创建字段:样品编号');
|
});
|
|
it('刷新和定位事件分别调用 bridge', async () => {
|
const fixture = createBridge();
|
const controls: OfficeContentControl[] = [{ Alias: '结果', InternalId: 'control-1', Tag: 'eln.field.result' }];
|
vi.mocked(fixture.bridge.listControls).mockResolvedValue(controls);
|
vi.mocked(fixture.bridge.selectControl).mockResolvedValue();
|
const wrapper = mountApp(fixture.bridge);
|
fixture.triggerReady();
|
await vi.waitFor(() => expect(wrapper.find('[data-test="control-item"]').exists()).toBe(true));
|
|
await wrapper.get('[data-test="refresh-controls"]').trigger('click');
|
await vi.waitFor(() => expect(fixture.bridge.listControls).toHaveBeenCalledTimes(2));
|
await wrapper.get('[data-test="control-item"]').trigger('click');
|
await vi.waitFor(() => expect(fixture.bridge.selectControl).toHaveBeenCalledWith('control-1'));
|
});
|
|
it('定位字段期间保持页面已连接且不禁用其他内容', async () => {
|
const fixture = createBridge();
|
const controls: OfficeContentControl[] = [{ Alias: '结果', InternalId: 'control-1', Tag: 'eln.field.result' }];
|
const pending = deferred<void>();
|
vi.mocked(fixture.bridge.listControls).mockResolvedValue(controls);
|
vi.mocked(fixture.bridge.selectControl).mockReturnValue(pending.promise);
|
const wrapper = mountApp(fixture.bridge);
|
fixture.triggerReady();
|
await vi.waitFor(() => expect(wrapper.find('[data-test="control-item"]').exists()).toBe(true));
|
|
await wrapper.get('[data-test="control-item"]').trigger('click');
|
|
expect(wrapper.get('[data-test="connection-status"]').text()).toBe('已连接');
|
expect(wrapper.find('[data-test="runtime-message"]').exists()).toBe(false);
|
expect(wrapper.get('[data-test="submit-field"]').attributes('disabled')).toBeUndefined();
|
expect(wrapper.get('[data-test="control-item"]').attributes('disabled')).toBeUndefined();
|
|
pending.resolve();
|
await vi.waitFor(() => expect(fixture.bridge.selectControl).toHaveBeenCalledWith('control-1'));
|
});
|
|
it('确认解除标注后调用 bridge 并更新列表', async () => {
|
const fixture = createBridge();
|
const controls: OfficeContentControl[] = [{ Alias: '结果', InternalId: 'control-1', Tag: 'eln.field.result' }];
|
vi.mocked(fixture.bridge.listControls).mockResolvedValue(controls);
|
vi.mocked(fixture.bridge.deleteControl).mockResolvedValue([]);
|
const wrapper = mountApp(fixture.bridge);
|
fixture.triggerReady();
|
await vi.waitFor(() => expect(wrapper.find('[data-test="delete-control"]').exists()).toBe(true));
|
|
wrapper.getComponent({ name: 'APopconfirm' }).vm.$emit('confirm');
|
|
await vi.waitFor(() => expect(fixture.bridge.deleteControl).toHaveBeenCalledWith('control-1'));
|
await vi.waitFor(() => expect(wrapper.text()).toContain('标注已解除'));
|
expect(wrapper.find('[data-test="control-item"]').exists()).toBe(false);
|
});
|
|
it('working 时禁用编辑、刷新与定位', async () => {
|
const fixture = createBridge();
|
const pending = deferred<OfficeContentControl[]>();
|
vi.mocked(fixture.bridge.listControls).mockReturnValue(pending.promise);
|
const wrapper = mountApp(fixture.bridge);
|
fixture.triggerReady();
|
|
await vi.waitFor(() => expect(wrapper.text()).toContain('正在读取文档字段...'));
|
expect(wrapper.get('[data-test="submit-field"]').attributes('disabled')).toBeDefined();
|
expect(wrapper.get('[data-test="refresh-controls"]').attributes('disabled')).toBeDefined();
|
pending.resolve([]);
|
});
|
|
it('异步错误显示 Alert 且事件处理不产生未处理拒绝', async () => {
|
const fixture = createBridge();
|
vi.mocked(fixture.bridge.listControls).mockResolvedValueOnce([]).mockRejectedValueOnce(new Error('刷新失败'));
|
const wrapper = mountApp(fixture.bridge);
|
fixture.triggerReady();
|
await vi.waitFor(() => expect(wrapper.get('[data-test="connection-status"]').text()).toBe('已连接'));
|
|
await wrapper.get('[data-test="refresh-controls"]').trigger('click');
|
|
await vi.waitFor(() => expect(wrapper.text()).toContain('读取内容控件失败:刷新失败'));
|
expect(wrapper.find('.ant-alert-error').exists()).toBe(true);
|
});
|
|
it('初始化失败后的重试再次失败时保持 fatal 并继续禁用编辑', async () => {
|
const fixture = createBridge();
|
vi.mocked(fixture.bridge.listControls).mockRejectedValueOnce(new Error('SDK 初始化失败')).mockRejectedValueOnce(new Error('SDK 仍不可用'));
|
const wrapper = mountApp(fixture.bridge);
|
fixture.triggerReady();
|
await vi.waitFor(() => expect(wrapper.get('[data-test="connection-status"]').text()).toBe('连接异常'));
|
|
expect(wrapper.get('[data-test="submit-field"]').attributes('disabled')).toBeDefined();
|
expect(wrapper.text()).toContain('读取内容控件失败:SDK 初始化失败');
|
await wrapper.get('[data-test="refresh-controls"]').trigger('click');
|
|
await vi.waitFor(() => expect(fixture.bridge.listControls).toHaveBeenCalledTimes(2));
|
await vi.waitFor(() => expect(wrapper.text()).toContain('读取内容控件失败:SDK 仍不可用'));
|
expect(wrapper.get('[data-test="connection-status"]').text()).toBe('连接异常');
|
expect(wrapper.get('[data-test="submit-field"]').attributes('disabled')).toBeDefined();
|
});
|
|
it('卸载时清理 lifecycle', () => {
|
const fixture = createBridge();
|
const wrapper = mountApp(fixture.bridge);
|
|
wrapper.unmount();
|
|
expect(fixture.cleanup).toHaveBeenCalledOnce();
|
});
|
|
it('SDK 缺失时显示 fatal、禁用操作且不绑定 lifecycle', () => {
|
const wrapper = mountApp(null);
|
|
expect(wrapper.text()).toContain('ONLYOFFICE SDK 未加载,无法初始化插件');
|
expect(wrapper.get('[data-test="submit-field"]').attributes('disabled')).toBeDefined();
|
expect(wrapper.get('[data-test="refresh-controls"]').attributes('disabled')).toBeDefined();
|
});
|
});
|