import type { OnlyOfficeBridge } from '../src/onlyoffice/bridge';
|
|
import { readFileSync } from 'node:fs';
|
import { resolve } from 'node:path';
|
|
import { mount } from '@vue/test-utils';
|
import Antd, { ConfigProvider, Tooltip } from 'ant-design-vue';
|
import dayjs from 'dayjs';
|
import { createPinia } from 'pinia';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import App from '../src/App.vue';
|
|
const styles = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8');
|
|
function bridgeFixture(
|
fields: Awaited<ReturnType<OnlyOfficeBridge['inspectFields']>>,
|
runtimeOptions: ReturnType<OnlyOfficeBridge['getRuntimeOptions']> | undefined = undefined,
|
) {
|
let ready = () => undefined;
|
let changed = (_control: { InternalId?: unknown; Tag?: unknown }) => undefined;
|
let focused = (_control: { InternalId?: unknown; Tag?: unknown }) => undefined;
|
const bridge: OnlyOfficeBridge = {
|
applyFields: vi.fn().mockResolvedValue([]),
|
appendRepeatRow: vi.fn().mockResolvedValue([]),
|
bindLifecycle: vi.fn((callbacks) => {
|
changed = callbacks.onControlChanged;
|
focused = callbacks.onControlFocused;
|
ready = callbacks.onReady;
|
return vi.fn();
|
}),
|
getRuntimeOptions: () =>
|
runtimeOptions ?? {
|
apiBaseUrl: 'http://localhost:30000',
|
bizDataId: 'record-1',
|
bizModule: 'eln.record',
|
bizScene: 'eln.record.fill',
|
canFill: true,
|
documentId: 'file-1',
|
pluginVersion: '0.1.0',
|
scope: 'onlyoffice:eln-record-fill',
|
ticket: 'ticket-1',
|
},
|
inspectFields: vi.fn().mockResolvedValue(fields),
|
removeRepeatRow: vi.fn().mockResolvedValue([]),
|
selectControl: vi.fn().mockResolvedValue(undefined),
|
setFillOnlyMode: vi.fn().mockResolvedValue(undefined),
|
};
|
return {
|
bridge,
|
changed: (control: { InternalId?: unknown; Tag?: unknown }) => changed(control),
|
focused: (control: { InternalId?: unknown; Tag?: unknown }) => focused(control),
|
ready: () => ready(),
|
};
|
}
|
|
describe('App step navigation', () => {
|
afterEach(() => {
|
vi.restoreAllMocks();
|
vi.unstubAllGlobals();
|
});
|
|
it('provides Chinese locale to Ant Design and dayjs', () => {
|
const wrapper = mount(App, { global: { plugins: [createPinia(), Antd] } });
|
|
const provider = wrapper.getComponent(ConfigProvider);
|
const trigger = document.createElement('button');
|
wrapper.get('.plugin-shell').element.append(trigger);
|
const getPopupContainer = provider.props('getPopupContainer');
|
|
expect(provider.props('locale')).toMatchObject({ locale: 'zh-cn' });
|
expect(getPopupContainer).toBeTypeOf('function');
|
expect(getPopupContainer?.(trigger)).toBe(wrapper.get('.plugin-shell').element);
|
expect(dayjs.locale()).toBe('zh-cn');
|
wrapper.unmount();
|
});
|
|
it('reports unapplied record drafts to the host window', async () => {
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ fields: {}, repeatGroups: [] }), { status: 200 })));
|
const postMessage = vi.spyOn(window, 'postMessage');
|
const runtime = {
|
apiBaseUrl: 'http://localhost:30000',
|
bizDataId: 'record-1',
|
bizModule: 'eln.record' as const,
|
bizScene: 'eln.record.fill' as const,
|
canFill: true,
|
documentId: 'file-1',
|
pluginVersion: '0.1.0' as const,
|
scope: 'onlyoffice:eln-record-fill' as const,
|
ticket: 'ticket-1',
|
};
|
const fixture = bridgeFixture([{ alias: '检验结果', currentValue: '', internalId: '1', options: [], tag: 'eln.field.result', type: 'text' }], runtime);
|
const wrapper = mount(App, {
|
global: { plugins: [createPinia(), Antd], provide: { onlyOfficeBridge: fixture.bridge } },
|
});
|
fixture.ready();
|
await vi.waitFor(() => expect(wrapper.text()).toContain('检验结果'));
|
|
await wrapper.get('.field-row input').setValue('合格');
|
|
await vi.waitFor(() =>
|
expect(postMessage).toHaveBeenCalledWith(
|
{
|
bizDataId: 'record-1',
|
documentId: 'file-1',
|
pendingCount: 1,
|
type: 'jnpf:eln-record-fill-state',
|
version: 1,
|
},
|
'*',
|
),
|
);
|
wrapper.unmount();
|
});
|
|
it('uses the annotator status bar and keeps fields in a bounded scroll region', async () => {
|
vi.stubGlobal(
|
'fetch',
|
vi.fn().mockResolvedValue(
|
new Response(
|
JSON.stringify({
|
fields: {},
|
repeatGroups: [],
|
stepStructure: {
|
configuration: {
|
singleStep: true,
|
steps: [{ id: 'step-only', name: '填写', order: 1 }],
|
},
|
controlSteps: { 'eln.field.sample': 'step-only' },
|
repeatGroupSteps: {},
|
},
|
}),
|
{ status: 200 },
|
),
|
),
|
);
|
const { bridge, ready } = bridgeFixture([{ alias: '供试品名称', currentValue: '', internalId: '1', options: [], tag: 'eln.field.sample', type: 'text' }]);
|
const wrapper = mount(App, {
|
global: { plugins: [createPinia(), Antd], provide: { onlyOfficeBridge: bridge } },
|
});
|
ready();
|
await vi.waitFor(() => expect(wrapper.text()).toContain('供试品名称'));
|
|
expect(wrapper.get('[data-test="connection-status"]').text()).toContain('已连接');
|
expect(wrapper.find('[data-test="source-sync-button"]').exists()).toBe(true);
|
expect(wrapper.find('.plugin-header .ant-tag').exists()).toBe(false);
|
expect(wrapper.get('.plugin-shell').find('[data-test="plugin-scroll-region"]').exists()).toBe(true);
|
const toolbar = wrapper.get('[data-test="field-toolbar"]');
|
const scrollRegion = wrapper.get('[data-test="plugin-scroll-region"]');
|
expect(toolbar.element.parentElement).toBe(wrapper.get('.plugin-shell').element);
|
expect(toolbar.element.nextElementSibling).toBe(scrollRegion.element);
|
expect(toolbar.get('[data-test="field-filter"]').classes()).not.toContain('ant-input-lg');
|
expect(styles).toMatch(/\.plugin-shell\s*\{[^}]*display:\s*flex;/s);
|
expect(styles).toMatch(/\.plugin-scroll-region\s*\{[^}]*overflow-y:\s*auto;/s);
|
expect(styles).toMatch(/\.field-toolbar\s*\{[^}]*flex:\s*0 0 auto;/s);
|
expect(styles).not.toMatch(/\.field-toolbar\s*\{[^}]*position:\s*sticky;/s);
|
expect(styles).toMatch(/\.plugin-shell footer\s*\{[^}]*position:\s*static;/s);
|
});
|
|
it('locates a document field without scrolling the plugin content', async () => {
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ fields: {}, repeatGroups: [] }), { status: 200 })));
|
const scrollIntoView = vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(() => undefined);
|
const fixture = bridgeFixture([
|
{ alias: '检验结果', currentValue: '', internalId: 'field-1', options: [], tag: 'eln.field.result', type: 'text' },
|
{ alias: '来源', currentValue: '', internalId: 'field-2', options: [], tag: 'eln.field.source', type: 'text' },
|
]);
|
const wrapper = mount(App, {
|
global: { plugins: [createPinia(), Antd], provide: { onlyOfficeBridge: fixture.bridge } },
|
});
|
fixture.ready();
|
await vi.waitFor(() => expect(wrapper.text()).toContain('检验结果'));
|
|
await wrapper.get('[aria-label="在文档中定位 检验结果"]').trigger('click');
|
|
await vi.waitFor(() => expect(fixture.bridge.selectControl).toHaveBeenCalledWith('field-1'));
|
expect(scrollIntoView).not.toHaveBeenCalled();
|
|
fixture.focused({ InternalId: 'field-2', Tag: 'eln.field.source' });
|
await vi.waitFor(() => expect(scrollIntoView).toHaveBeenCalledOnce());
|
wrapper.unmount();
|
});
|
|
it('distinguishes task and cross-template differences and disables source sync when readonly', async () => {
|
vi.stubGlobal(
|
'fetch',
|
vi.fn().mockImplementation(async (url: string) => {
|
if (url.endsWith('/sourceSync/preview'))
|
return new Response(
|
JSON.stringify({
|
changedCount: 2,
|
differences: [
|
{
|
bindingId: 'binding-source',
|
changed: true,
|
currentValue: 'GYDW',
|
dataType: 'TEXT',
|
fieldCode: 'source',
|
fieldName: '来源',
|
previousValue: null,
|
sourceProperty: 'quality_standard.source',
|
sourceType: 'LIMS_TASK',
|
},
|
{
|
bindingId: 'binding-cross',
|
changed: true,
|
currentValue: '复测合格',
|
dataType: 'TEXT',
|
fieldCode: 'source_result',
|
fieldName: '源检验结果',
|
previousValue: '合格',
|
sourceFieldVersionId: 'source-field-version-2',
|
sourceFetchedAt: '2026-09-02T08:30:00Z',
|
sourceProperty: null,
|
sourceRecordId: 'source-record-1',
|
sourceRevisionId: 'source-revision-2',
|
sourceType: 'CROSS_TEMPLATE',
|
sourceValueId: 'source-value-2',
|
},
|
],
|
previewToken: 'preview-token',
|
recordId: 'record-1',
|
recordVersion: 4,
|
revisionId: 'revision-1',
|
}),
|
{ status: 200 },
|
);
|
return new Response(JSON.stringify({ fields: {}, repeatGroups: [] }), { status: 200 });
|
}),
|
);
|
const runtime = {
|
apiBaseUrl: 'http://localhost:30000',
|
bizDataId: 'record-1',
|
bizModule: 'eln.record' as const,
|
bizScene: 'eln.record.fill' as const,
|
canFill: true,
|
documentId: 'file-1',
|
pluginVersion: '0.1.0' as const,
|
scope: 'onlyoffice:eln-record-fill' as const,
|
ticket: 'ticket-1',
|
};
|
const fixture = bridgeFixture([{ alias: '来源', currentValue: 'GYDW', internalId: '1', options: [], tag: 'eln.field.source', type: 'text' }], runtime);
|
const wrapper = mount(App, {
|
global: { plugins: [createPinia(), Antd], provide: { onlyOfficeBridge: fixture.bridge } },
|
});
|
fixture.ready();
|
await vi.waitFor(() => expect(wrapper.get('[data-test="connection-status"]').text()).toContain('已连接'));
|
|
expect(wrapper.get('[data-test="field-toolbar"]').find('[data-test="source-sync-button"]').exists()).toBe(true);
|
expect(wrapper.find('.plugin-header [data-test="source-sync-button"]').exists()).toBe(false);
|
expect(wrapper.get('[data-test="source-sync-button"]').attributes('disabled')).toBeUndefined();
|
expect(wrapper.get('[data-test="source-sync-button"]').classes()).not.toContain('ant-btn-lg');
|
expect(wrapper.findAllComponents(Tooltip).find((tooltip) => tooltip.props('title') === '重新同步来源快照')?.props('placement')).toBe('topRight');
|
await wrapper.get('[aria-label="重新同步来源快照"]').trigger('click');
|
|
await vi.waitFor(() => expect(document.body.textContent).toContain('quality_standard.source'));
|
const differences = document.body.querySelector('[data-test="source-sync-differences"]')?.textContent;
|
expect(differences).toContain('任务快照');
|
expect(differences).toContain('跨模板字段值');
|
expect(differences).toContain('source-record-1');
|
expect(differences).toContain('source-revision-2');
|
|
const readonlyFixture = bridgeFixture([], { ...runtime, canFill: false });
|
const readonlyWrapper = mount(App, {
|
global: { plugins: [createPinia(), Antd], provide: { onlyOfficeBridge: readonlyFixture.bridge } },
|
});
|
readonlyFixture.ready();
|
await vi.waitFor(() => expect(readonlyWrapper.get('[data-test="connection-status"]').text()).toContain('当前记录仅可查看'));
|
expect(readonlyWrapper.get('[data-test="source-sync-button"]').attributes('disabled')).toBeDefined();
|
|
const emptyEditableFixture = bridgeFixture([], runtime);
|
const emptyEditableWrapper = mount(App, {
|
global: { plugins: [createPinia(), Antd], provide: { onlyOfficeBridge: emptyEditableFixture.bridge } },
|
});
|
emptyEditableFixture.ready();
|
await vi.waitFor(() => expect(emptyEditableWrapper.get('[data-test="connection-status"]').text()).toContain('已连接'));
|
expect(emptyEditableWrapper.find('[data-test="field-toolbar"]').exists()).toBe(false);
|
expect(emptyEditableWrapper.get('.plugin-header').find('[data-test="source-sync-button"]').exists()).toBe(true);
|
});
|
|
it('navigates multi-step content and returns from the directory to the selected step', async () => {
|
vi.stubGlobal(
|
'fetch',
|
vi.fn().mockResolvedValue(
|
new Response(
|
JSON.stringify({
|
fields: {},
|
repeatGroups: [],
|
stepStructure: {
|
configuration: {
|
singleStep: false,
|
steps: [
|
{ id: 'step-prepare', name: '准备', order: 1 },
|
{ id: 'step-test', name: '检验', order: 2 },
|
],
|
},
|
controlSteps: { 'eln.field.sample': 'step-prepare', 'eln.field.result': 'step-test' },
|
repeatGroupSteps: {},
|
},
|
}),
|
{ status: 200 },
|
),
|
),
|
);
|
const { bridge, ready } = bridgeFixture([
|
{ alias: '样品编号', currentValue: '', internalId: '1', options: [], tag: 'eln.field.sample', type: 'text' },
|
{ alias: '检验结果', currentValue: '', internalId: '2', options: [], tag: 'eln.field.result', type: 'text' },
|
]);
|
const wrapper = mount(App, {
|
global: { plugins: [createPinia(), Antd], provide: { onlyOfficeBridge: bridge } },
|
});
|
ready();
|
await vi.waitFor(() => expect(wrapper.text()).toContain('样品编号'));
|
|
expect(wrapper.text()).not.toContain('检验结果');
|
await wrapper.get('.field-row input').setValue('S-001');
|
expect(wrapper.text()).toContain('应用到文档(1)');
|
await wrapper.get('[aria-label="下一步骤"]').trigger('click');
|
expect(wrapper.text()).toContain('检验结果');
|
expect(wrapper.text()).not.toContain('样品编号');
|
|
await wrapper.get('[aria-label="打开填写目录"]').trigger('click');
|
expect(wrapper.find('[aria-label="填写目录"]').exists()).toBe(true);
|
expect(wrapper.find('.field-row').exists()).toBe(false);
|
await wrapper.get('[data-step-id="step-prepare"]').trigger('click');
|
expect(wrapper.find('[aria-label="填写目录"]').exists()).toBe(false);
|
expect(wrapper.text()).toContain('样品编号');
|
expect(wrapper.get('.field-row input').element.getAttribute('value')).toBe('S-001');
|
expect(bridge.selectControl).toHaveBeenCalledWith('1');
|
});
|
|
it('keeps the compact field layout and hides step terminology for a single-step template', async () => {
|
vi.stubGlobal(
|
'fetch',
|
vi.fn().mockResolvedValue(
|
new Response(
|
JSON.stringify({
|
fields: {},
|
repeatGroups: [],
|
stepStructure: {
|
configuration: {
|
singleStep: true,
|
steps: [{ id: 'step-only', name: '不应显示的唯一步骤', order: 1 }],
|
},
|
controlSteps: { 'eln.field.sample': 'step-only' },
|
repeatGroupSteps: {},
|
},
|
}),
|
{ status: 200 },
|
),
|
),
|
);
|
const { bridge, ready } = bridgeFixture([{ alias: '样品编号', currentValue: '', internalId: '1', options: [], tag: 'eln.field.sample', type: 'text' }]);
|
const wrapper = mount(App, {
|
global: { plugins: [createPinia(), Antd], provide: { onlyOfficeBridge: bridge } },
|
});
|
ready();
|
await vi.waitFor(() => expect(wrapper.text()).toContain('样品编号'));
|
|
expect(wrapper.find('[aria-label="步骤导航"]').exists()).toBe(false);
|
expect(wrapper.find('[aria-label="打开填写目录"]').exists()).toBe(false);
|
expect(wrapper.text()).not.toContain('不应显示的唯一步骤');
|
});
|
|
it('follows document focus and blocks apply until a cross-step conflict is resolved', async () => {
|
vi.stubGlobal(
|
'fetch',
|
vi.fn().mockResolvedValue(
|
new Response(
|
JSON.stringify({
|
fields: {},
|
repeatGroups: [],
|
stepStructure: {
|
configuration: {
|
singleStep: false,
|
steps: [
|
{ id: 'step-first', name: '第一步', order: 1 },
|
{ id: 'step-second', name: '第二步', order: 2 },
|
],
|
},
|
controlSteps: { 'eln.field.first': 'step-first', 'eln.field.second': 'step-second' },
|
repeatGroupSteps: {},
|
},
|
}),
|
{ status: 200 },
|
),
|
),
|
);
|
const initial = [
|
{ alias: '第一项', currentValue: 'A', internalId: '1', options: [], tag: 'eln.field.first', type: 'text' as const },
|
{ alias: '第二项', currentValue: 'B', internalId: '2', options: [], tag: 'eln.field.second', type: 'text' as const },
|
];
|
const fixture = bridgeFixture(initial);
|
const wrapper = mount(App, {
|
global: { plugins: [createPinia(), Antd], provide: { onlyOfficeBridge: fixture.bridge } },
|
});
|
fixture.ready();
|
await vi.waitFor(() => expect(wrapper.text()).toContain('第一项'));
|
|
fixture.focused({ InternalId: '2', Tag: 'eln.field.second' });
|
await vi.waitFor(() => expect(wrapper.text()).toContain('第二项'));
|
expect(wrapper.get('[data-field-tag="eln.field.second"]').classes()).toContain('field-row-focused');
|
await wrapper.get('.field-row input').setValue('侧栏值');
|
vi.mocked(fixture.bridge.inspectFields).mockResolvedValue([initial[0]!, { ...initial[1]!, currentValue: '文档值' }]);
|
fixture.changed({ InternalId: '2', Tag: 'eln.field.second' });
|
|
await vi.waitFor(() => expect(wrapper.find('[aria-label="字段冲突"]').exists()).toBe(true));
|
expect(wrapper.text()).toContain('1 个字段冲突');
|
expect(wrapper.get('footer button').attributes('disabled')).toBeDefined();
|
await wrapper.get('[data-conflict-tag="eln.field.second"]').trigger('click');
|
expect(fixture.bridge.selectControl).toHaveBeenCalledWith('2');
|
await wrapper.get('[data-resolution="draft"]').trigger('click');
|
|
expect(wrapper.find('[aria-label="字段冲突"]').exists()).toBe(false);
|
expect(wrapper.get('footer button').attributes('disabled')).toBeUndefined();
|
expect(wrapper.text()).toContain('应用到文档(1)');
|
});
|
|
it('shows a grouped checkbox error and disables document apply', async () => {
|
vi.stubGlobal(
|
'fetch',
|
vi.fn().mockResolvedValue(
|
new Response(
|
JSON.stringify({
|
fields: {
|
'eln.field.failed': { checkboxGroupCode: 'inspection_result', type: 'checkbox', value: true },
|
'eln.field.passed': { checkboxGroupCode: 'inspection_result', type: 'checkbox', value: true },
|
},
|
repeatGroups: [],
|
stepStructure: {
|
configuration: {
|
singleStep: true,
|
steps: [{ id: 'step-1', name: '检验', order: 1 }],
|
},
|
controlSteps: {
|
'eln.field.failed': 'step-1',
|
'eln.field.passed': 'step-1',
|
},
|
repeatGroupSteps: {},
|
},
|
}),
|
{ status: 200 },
|
),
|
),
|
);
|
const fixture = bridgeFixture([
|
{ alias: '合格', currentValue: true, internalId: 'passed', options: [], tag: 'eln.field.passed', type: 'checkbox' },
|
{ alias: '不合格', currentValue: true, internalId: 'failed', options: [], tag: 'eln.field.failed', type: 'checkbox' },
|
]);
|
const wrapper = mount(App, {
|
global: { plugins: [createPinia(), Antd], provide: { onlyOfficeBridge: fixture.bridge } },
|
});
|
fixture.ready();
|
|
await vi.waitFor(() => expect(wrapper.text()).toContain('选择组“inspection_result”最多选择 1 项'));
|
expect(wrapper.get('footer button').attributes('disabled')).toBeDefined();
|
});
|
});
|