import { describe, expect, it, vi } from 'vitest';
|
|
import type { FieldDefinition } from '../../src/domain/types';
|
import { createOnlyOfficeBridge } from '../../src/onlyoffice/bridge';
|
import { disableTrackRevisionsCommand, insertTypedControlCommand, listContentControlsCommand, writeTextDefaultCommand } from '../../src/onlyoffice/commands';
|
import type { AscGlobal, OfficeContentControl } from '../../src/onlyoffice/types';
|
|
interface AscFixture {
|
asc: AscGlobal;
|
callCommands: Array<{ command: () => string; payload: unknown; scopeKey: string }>;
|
closeCalls: Array<[string, string]>;
|
listCommands: Array<{ command: () => string; payload: unknown; scopeKey: string }>;
|
methodCalls: Array<[string, unknown[]]>;
|
}
|
|
interface AscFixtureOptions {
|
commandResults?: unknown[];
|
methodResults?: unknown[];
|
}
|
|
const singleColor = { R: 23, G: 109, B: 92 };
|
type TextFieldDefinition = FieldDefinition & { controlType: 'text' };
|
|
function control(Tag: string, InternalId = `id-${Tag}`): OfficeContentControl {
|
return { Alias: Tag, InternalId, Tag };
|
}
|
|
function textDefinition(overrides: Partial<TextFieldDefinition> = {}): FieldDefinition {
|
return {
|
mode: 'single',
|
controlType: 'text',
|
fieldCode: 'result',
|
alias: '结果',
|
groupCode: '',
|
rowId: '',
|
tag: 'eln.field.result',
|
color: singleColor,
|
placeholder: '请输入结果',
|
defaultValue: '',
|
defaultChecked: false,
|
...overrides,
|
};
|
}
|
|
function typedDefinition(controlType: 'checkbox' | 'date' | 'dropdown' | 'multiline'): FieldDefinition {
|
const base = {
|
mode: 'single' as const,
|
fieldCode: 'result',
|
alias: '结果',
|
groupCode: '',
|
rowId: '',
|
tag: 'eln.field.result',
|
color: singleColor,
|
};
|
|
if (controlType === 'dropdown') {
|
return {
|
...base,
|
controlType,
|
placeholder: '请选择结果',
|
options: [
|
{ display: '合格', value: 'pass' },
|
{ display: '不合格', value: 'fail' },
|
],
|
selectedIndex: 1,
|
};
|
}
|
|
return {
|
...base,
|
controlType,
|
placeholder: controlType === 'checkbox' ? '结果' : '请输入结果',
|
defaultValue: controlType === 'date' ? '2026-08-11' : controlType === 'multiline' ? '第一行\n第二行' : '',
|
defaultChecked: controlType === 'checkbox',
|
};
|
}
|
|
function createAscFixture(options: AscFixtureOptions = {}): AscFixture {
|
const methodResults = [...(options.methodResults ?? [])];
|
const commandResults = [...(options.commandResults ?? [])];
|
const methodCalls: Array<[string, unknown[]]> = [];
|
const callCommands: AscFixture['callCommands'] = [];
|
const listCommands: AscFixture['listCommands'] = [];
|
const closeCalls: Array<[string, string]> = [];
|
const scope: AscGlobal['scope'] = {};
|
|
const asc: AscGlobal = {
|
scope,
|
plugin: {
|
executeMethod(name, args, callback) {
|
methodCalls.push([name, args]);
|
callback(methodResults.shift());
|
return true;
|
},
|
callCommand(command, _isClose, _isCalc, callback) {
|
const scopeKey = Object.keys(scope)[0] ?? '';
|
if (command === listContentControlsCommand) {
|
listCommands.push({ command, scopeKey, payload: scope[scopeKey] });
|
callback({ ok: true, controls: methodResults.shift() });
|
return true;
|
}
|
callCommands.push({ command, scopeKey, payload: scope[scopeKey] });
|
callback(commandResults.length > 0 ? commandResults.shift() : { ok: true });
|
return true;
|
},
|
executeCommand(command, data) {
|
closeCalls.push([command, data]);
|
},
|
onThemeChangedBase: vi.fn(),
|
},
|
};
|
|
return { asc, callCommands, closeCalls, listCommands, methodCalls };
|
}
|
|
describe('ONLYOFFICE bridge', () => {
|
it('原样读取插件运行 options', () => {
|
const fixture = createAscFixture();
|
const options = { context: true };
|
fixture.asc.plugin.info = { options };
|
|
expect(createOnlyOfficeBridge(fixture.asc).getRuntimeOptions()).toBe(options);
|
});
|
|
it('通过扫描 command 回读具体类型并清理列表 scope', async () => {
|
const expected = [{ ...control('eln.field.result'), ControlType: 'date' as const }];
|
const fixture = createAscFixture({ methodResults: [expected] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).listControls()).resolves.toEqual(expected);
|
expect(fixture.listCommands).toEqual([{ command: listContentControlsCommand, scopeKey: 'elnTemplateList', payload: {} }]);
|
expect(fixture.asc.scope.elnTemplateList).toBeUndefined();
|
expect(fixture.methodCalls).toHaveLength(0);
|
});
|
|
it('拒绝扫描 command 的非数组内容控件结果', async () => {
|
const fixture = createAscFixture({ methodResults: [null] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).listControls()).rejects.toThrow('编辑器未返回内容控件列表');
|
});
|
|
it('拒绝扫描 command 的同步异常', async () => {
|
const fixture = createAscFixture();
|
fixture.asc.plugin.callCommand = () => {
|
throw new Error('method crashed');
|
};
|
|
await expect(createOnlyOfficeBridge(fixture.asc).listControls()).rejects.toThrow('method crashed');
|
});
|
});
|
|
describe('ONLYOFFICE command bridge', () => {
|
it('关闭文档内部的修订跟踪状态', async () => {
|
const fixture = createAscFixture({ commandResults: [{ ok: true }] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).disableTrackRevisions()).resolves.toBeUndefined();
|
expect(fixture.callCommands).toEqual([
|
{
|
command: disableTrackRevisionsCommand,
|
scopeKey: 'elnTemplateReview',
|
payload: {},
|
},
|
]);
|
});
|
|
it.each([
|
['JSON 字符串', JSON.stringify({ ok: true })],
|
['对象', { ok: true }],
|
])('写入 scope,并接受%s命令结果', async (_label, commandResult) => {
|
const tag = 'eln.field.result';
|
const fixture = createAscFixture({ methodResults: [[], [control(tag)]], commandResults: [commandResult] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).insertField(typedDefinition('checkbox'))).resolves.toEqual([control(tag)]);
|
|
expect(fixture.callCommands).toHaveLength(1);
|
expect(fixture.callCommands[0]).toMatchObject({
|
command: insertTypedControlCommand,
|
scopeKey: 'elnTemplateInsert',
|
});
|
expect(fixture.asc.scope.elnTemplateInsert).toBeUndefined();
|
});
|
|
it.each([
|
[{ ok: false, error: '业务失败' }, '业务失败'],
|
[{ ok: false }, '编辑器命令执行失败'],
|
[{ ok: false, error: 500 }, '编辑器命令执行失败'],
|
])('归一化命令失败 %#', async (commandResult, message) => {
|
const fixture = createAscFixture({ methodResults: [[]], commandResults: [commandResult] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).insertField(typedDefinition('checkbox'))).rejects.toThrow(message);
|
expect(fixture.asc.scope.elnTemplateInsert).toBeUndefined();
|
});
|
|
it.each([
|
['非法 JSON', 'not-json'],
|
['null', null],
|
['数组', []],
|
['数字', 1],
|
['缺少 ok 的对象', { error: '业务失败' }],
|
['ok 非布尔值', { ok: 'false', error: '业务失败' }],
|
])('拒绝%s命令返回值', async (_label, commandResult) => {
|
const fixture = createAscFixture({ methodResults: [[]], commandResults: [commandResult] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).insertField(typedDefinition('checkbox'))).rejects.toThrow('编辑器命令返回无效结果');
|
expect(fixture.asc.scope.elnTemplateInsert).toBeUndefined();
|
});
|
|
it('归一化 callCommand 同步异常并清理 scope', async () => {
|
const fixture = createAscFixture({ methodResults: [[]] });
|
fixture.asc.plugin.callCommand = () => {
|
throw new Error('command crashed');
|
};
|
|
await expect(createOnlyOfficeBridge(fixture.asc).insertField(typedDefinition('checkbox'))).rejects.toThrow('command crashed');
|
expect(fixture.asc.scope.elnTemplateInsert).toBeUndefined();
|
});
|
});
|
|
describe('字段插入', () => {
|
it('插入前拒绝已存在的 Tag,且不执行插入', async () => {
|
const fixture = createAscFixture({ methodResults: [[control('eln.field.result')]] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).insertField(textDefinition())).rejects.toThrow('字段 ID 已存在');
|
expect(fixture.methodCalls).toHaveLength(0);
|
expect(fixture.listCommands).toHaveLength(1);
|
expect(fixture.callCommands).toHaveLength(0);
|
});
|
|
it('插入前拒绝现有 ELN 控件的同名 Alias,且忽略首尾空格', async () => {
|
const fixture = createAscFixture({
|
methodResults: [[{ Alias: ' 结果 ', InternalId: 'existing-1', Tag: 'eln.field.other_result' }]],
|
});
|
|
await expect(createOnlyOfficeBridge(fixture.asc).insertField(textDefinition())).rejects.toThrow('字段名称已存在');
|
expect(fixture.methodCalls).toHaveLength(0);
|
expect(fixture.listCommands).toHaveLength(1);
|
expect(fixture.callCommands).toHaveLength(0);
|
});
|
|
it('非 ELN 内容控件的同名 Alias 不参与字段名称校验', async () => {
|
const definition = textDefinition();
|
const after = [
|
{ Alias: '结果', InternalId: 'custom-1', Tag: 'custom.control' },
|
control(definition.tag),
|
];
|
const fixture = createAscFixture({
|
methodResults: [[{ Alias: '结果', InternalId: 'custom-1', Tag: 'custom.control' }], { InternalId: 'created-1' }, after],
|
});
|
|
await expect(createOnlyOfficeBridge(fixture.asc).insertField(definition)).resolves.toBe(after);
|
});
|
|
it('文本字段使用固定 AddContentControl 参数并返回插入后的列表', async () => {
|
const definition = textDefinition();
|
const after = [control(definition.tag)];
|
const fixture = createAscFixture({ methodResults: [[], { InternalId: 'created-1' }, after] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).insertField(definition)).resolves.toBe(after);
|
expect(fixture.methodCalls).toEqual([
|
[
|
'AddContentControl',
|
[
|
2,
|
{
|
Tag: definition.tag,
|
Alias: definition.alias,
|
Lock: 3,
|
PlaceHolderText: definition.placeholder,
|
Appearance: 1,
|
Color: definition.color,
|
},
|
],
|
],
|
]);
|
expect(fixture.listCommands).toHaveLength(2);
|
});
|
|
it('文本字段创建结果缺少 InternalId 时失败', async () => {
|
const fixture = createAscFixture({ methodResults: [[], {}] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).insertField(textDefinition())).rejects.toThrow('编辑器未返回新内容控件');
|
});
|
|
it('文本默认值通过独立 command 写入', async () => {
|
const definition = textDefinition({ defaultValue: 'S-001' });
|
const fixture = createAscFixture({
|
methodResults: [[], { InternalId: 'created-1' }, [control(definition.tag)]],
|
commandResults: [{ ok: true }],
|
});
|
|
await createOnlyOfficeBridge(fixture.asc).insertField(definition);
|
|
expect(fixture.callCommands).toEqual([
|
{
|
command: writeTextDefaultCommand,
|
scopeKey: 'elnTemplateTextDefault',
|
payload: { tag: definition.tag, value: 'S-001' },
|
},
|
]);
|
});
|
|
it('文本默认值写入失败时说明控件已创建', async () => {
|
const fixture = createAscFixture({
|
methodResults: [[], { InternalId: 'created-1' }],
|
commandResults: [{ ok: false, error: '无法回读' }],
|
});
|
|
await expect(createOnlyOfficeBridge(fixture.asc).insertField(textDefinition({ defaultValue: 'S-001' }))).rejects.toThrow(
|
'控件已创建,但默认值写入失败:无法回读',
|
);
|
});
|
|
it.each([
|
[
|
'dropdown' as const,
|
{
|
controlType: 'dropdown',
|
tag: 'eln.field.result',
|
alias: '结果',
|
placeholder: '请选择结果',
|
defaultValue: '',
|
defaultChecked: false,
|
options: [
|
{ display: '合格', value: 'pass' },
|
{ display: '不合格', value: 'fail' },
|
],
|
selectedIndex: 1,
|
color: singleColor,
|
},
|
],
|
[
|
'checkbox' as const,
|
{
|
controlType: 'checkbox',
|
tag: 'eln.field.result',
|
alias: '结果',
|
placeholder: '结果',
|
defaultValue: '',
|
defaultChecked: true,
|
options: [],
|
selectedIndex: -1,
|
color: singleColor,
|
},
|
],
|
[
|
'date' as const,
|
{
|
controlType: 'date',
|
tag: 'eln.field.result',
|
alias: '结果',
|
placeholder: '请输入结果',
|
defaultValue: '2026-08-11',
|
defaultChecked: false,
|
options: [],
|
selectedIndex: -1,
|
color: singleColor,
|
},
|
],
|
[
|
'multiline' as const,
|
{
|
controlType: 'multiline',
|
tag: 'eln.field.result',
|
alias: '结果',
|
placeholder: '请输入结果',
|
defaultValue: '第一行\n第二行',
|
defaultChecked: false,
|
options: [],
|
selectedIndex: -1,
|
color: singleColor,
|
},
|
],
|
])('%s 字段使用 typed command 的完整兼容 payload', async (controlType, expectedPayload) => {
|
const definition = typedDefinition(controlType);
|
const fixture = createAscFixture({ methodResults: [[], [control(definition.tag)]] });
|
|
await createOnlyOfficeBridge(fixture.asc).insertField(definition);
|
|
expect(fixture.callCommands).toEqual([
|
{
|
command: insertTypedControlCommand,
|
scopeKey: 'elnTemplateInsert',
|
payload: expectedPayload,
|
},
|
]);
|
});
|
|
it.each([
|
['没有增加', []],
|
['异常增加两个', [control('eln.field.result', 'first'), control('eln.field.result', 'second')]],
|
])('插入后 Tag 计数%s时失败', async (_label, after) => {
|
const definition = typedDefinition('checkbox');
|
const fixture = createAscFixture({ methodResults: [[], after] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).insertField(definition)).rejects.toThrow('插入后 Tag 计数未增加');
|
});
|
|
it.each([
|
['text', textDefinition(), [[], { InternalId: 'created-1' }, null]],
|
['typed', typedDefinition('checkbox'), [[], null]],
|
])('%s 字段插入后枚举结果非数组时使用插入后错误', async (_label, definition, methodResults) => {
|
const fixture = createAscFixture({ methodResults });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).insertField(definition)).rejects.toThrow('插入后无法读取内容控件列表');
|
});
|
});
|
|
describe('定位、关闭与生命周期', () => {
|
it('删除拒绝空的内部 ID', async () => {
|
const fixture = createAscFixture();
|
|
await expect(createOnlyOfficeBridge(fixture.asc).deleteControl('')).rejects.toThrow('该内容控件没有可用的内部 ID');
|
expect(fixture.methodCalls).toHaveLength(0);
|
});
|
|
it('解除目标不存在时不调用移除方法', async () => {
|
const fixture = createAscFixture({ methodResults: [[control('eln.field.other', 'other-id')]] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).deleteControl('stale-id')).rejects.toThrow('删除目标已不存在,请刷新字段列表');
|
expect(fixture.methodCalls).toHaveLength(0);
|
expect(fixture.listCommands).toHaveLength(1);
|
});
|
|
it.each([
|
['移除外壳失败', [[control('eln.field.result', 'control-1')], false], '编辑器未能移除字段控件'],
|
['解除后仍存在', [[control('eln.field.result', 'control-1')], true, [control('eln.field.result', 'control-1')]], '解除后字段仍然存在'],
|
])('%s时拒绝解除', async (_label, methodResults, message) => {
|
const fixture = createAscFixture({ methodResults });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).deleteControl('control-1')).rejects.toThrow(message);
|
});
|
|
it('只移除内容控件外壳、保留内容并返回解除后的列表', async () => {
|
const remaining = [control('eln.field.other', 'other-id')];
|
const fixture = createAscFixture({ methodResults: [[control('eln.field.result', 'control-1'), ...remaining], undefined, remaining] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).deleteControl('control-1')).resolves.toBe(remaining);
|
expect(fixture.methodCalls).toEqual([['RemoveContentControl', ['control-1']]]);
|
expect(fixture.listCommands).toHaveLength(2);
|
});
|
|
it('拒绝空的内部 ID', async () => {
|
const fixture = createAscFixture();
|
|
await expect(createOnlyOfficeBridge(fixture.asc).selectControl('')).rejects.toThrow('该内容控件没有可用的内部 ID');
|
expect(fixture.methodCalls).toHaveLength(0);
|
});
|
|
it('目标已不存在时不调用定位方法', async () => {
|
const fixture = createAscFixture({ methodResults: [[control('eln.field.other', 'other-id')]] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).selectControl('stale-id')).rejects.toThrow('定位目标已不存在,请刷新字段列表');
|
expect(fixture.methodCalls).toHaveLength(0);
|
expect(fixture.listCommands).toHaveLength(1);
|
});
|
|
it('编辑器明确返回 false 时报告定位失败', async () => {
|
const fixture = createAscFixture({ methodResults: [[control('eln.field.result', 'control-1')], false] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).selectControl('control-1')).rejects.toThrow('编辑器未能定位字段');
|
expect(fixture.methodCalls).toEqual([['SelectContentControl', ['control-1']]]);
|
expect(fixture.listCommands).toHaveLength(1);
|
});
|
|
it.each([undefined, true])('通过内部 ID 定位内容控件并兼容返回 %s', async (result) => {
|
const fixture = createAscFixture({ methodResults: [[control('eln.field.result', 'control-1')], result] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).selectControl('control-1')).resolves.toBeUndefined();
|
expect(fixture.methodCalls).toEqual([['SelectContentControl', ['control-1']]]);
|
expect(fixture.listCommands).toHaveLength(1);
|
});
|
|
it('关闭插件窗口', () => {
|
const fixture = createAscFixture();
|
|
createOnlyOfficeBridge(fixture.asc).close();
|
|
expect(fixture.closeCalls).toEqual([['close', '']]);
|
});
|
|
it('绑定 init、button 和主题回调,并在 cleanup 时恢复旧引用', () => {
|
const events: string[] = [];
|
const fixture = createAscFixture();
|
const oldInit = vi.fn();
|
const oldButton = vi.fn();
|
const oldTheme = vi.fn();
|
fixture.asc.plugin.init = oldInit;
|
fixture.asc.plugin.button = oldButton;
|
fixture.asc.plugin.onThemeChanged = oldTheme;
|
fixture.asc.plugin.onThemeChangedBase = vi.fn(() => events.push('base'));
|
const cleanup = createOnlyOfficeBridge(fixture.asc).bindLifecycle({
|
onReady: () => events.push('ready'),
|
onThemeChanged: () => events.push('theme'),
|
});
|
|
fixture.asc.plugin.init?.();
|
fixture.asc.plugin.button?.(0);
|
fixture.asc.plugin.button?.(-1);
|
fixture.asc.plugin.onThemeChanged?.({ type: 'dark' });
|
|
expect(events).toEqual(['ready', 'base', 'theme']);
|
expect(fixture.closeCalls).toEqual([['close', '']]);
|
cleanup();
|
expect(fixture.asc.plugin.init).toBe(oldInit);
|
expect(fixture.asc.plugin.button).toBe(oldButton);
|
expect(fixture.asc.plugin.onThemeChanged).toBe(oldTheme);
|
});
|
|
it('旧实例 cleanup 不覆盖较新实例的生命周期函数', () => {
|
const fixture = createAscFixture();
|
const firstCleanup = createOnlyOfficeBridge(fixture.asc).bindLifecycle({ onReady: vi.fn(), onThemeChanged: vi.fn() });
|
createOnlyOfficeBridge(fixture.asc).bindLifecycle({ onReady: vi.fn(), onThemeChanged: vi.fn() });
|
const secondLifecycle = {
|
init: fixture.asc.plugin.init,
|
button: fixture.asc.plugin.button,
|
onThemeChanged: fixture.asc.plugin.onThemeChanged,
|
};
|
|
firstCleanup();
|
|
expect(fixture.asc.plugin.init).toBe(secondLifecycle.init);
|
expect(fixture.asc.plugin.button).toBe(secondLifecycle.button);
|
expect(fixture.asc.plugin.onThemeChanged).toBe(secondLifecycle.onThemeChanged);
|
});
|
|
it('交错 cleanup 后恢复绑定前引用且已释放实例不再响应', () => {
|
const fixture = createAscFixture();
|
const oldInit = vi.fn();
|
const oldButton = vi.fn();
|
const oldTheme = vi.fn();
|
const firstReady = vi.fn();
|
const firstTheme = vi.fn();
|
const secondReady = vi.fn();
|
const secondTheme = vi.fn();
|
fixture.asc.plugin.init = oldInit;
|
fixture.asc.plugin.button = oldButton;
|
fixture.asc.plugin.onThemeChanged = oldTheme;
|
const firstCleanup = createOnlyOfficeBridge(fixture.asc).bindLifecycle({
|
onReady: firstReady,
|
onThemeChanged: firstTheme,
|
});
|
const secondCleanup = createOnlyOfficeBridge(fixture.asc).bindLifecycle({
|
onReady: secondReady,
|
onThemeChanged: secondTheme,
|
});
|
|
fixture.asc.plugin.init?.();
|
fixture.asc.plugin.onThemeChanged?.({ type: 'contrast' });
|
expect(firstReady).not.toHaveBeenCalled();
|
expect(firstTheme).not.toHaveBeenCalled();
|
expect(secondReady).toHaveBeenCalledTimes(1);
|
expect(secondTheme).toHaveBeenCalledTimes(1);
|
|
firstCleanup();
|
fixture.asc.plugin.init?.();
|
fixture.asc.plugin.onThemeChanged?.({ type: 'dark' });
|
|
expect(firstReady).not.toHaveBeenCalled();
|
expect(firstTheme).not.toHaveBeenCalled();
|
expect(secondReady).toHaveBeenCalledTimes(2);
|
expect(secondTheme).toHaveBeenCalledTimes(2);
|
|
secondCleanup();
|
expect(fixture.asc.plugin.init).toBe(oldInit);
|
expect(fixture.asc.plugin.button).toBe(oldButton);
|
expect(fixture.asc.plugin.onThemeChanged).toBe(oldTheme);
|
|
fixture.asc.plugin.init?.();
|
fixture.asc.plugin.button?.(-1);
|
fixture.asc.plugin.onThemeChanged?.({ type: 'light' });
|
expect(firstReady).not.toHaveBeenCalled();
|
expect(firstTheme).not.toHaveBeenCalled();
|
expect(secondReady).toHaveBeenCalledTimes(2);
|
expect(secondTheme).toHaveBeenCalledTimes(2);
|
expect(oldInit).toHaveBeenCalledTimes(1);
|
expect(oldButton).toHaveBeenCalledWith(-1);
|
expect(oldTheme).toHaveBeenCalledWith({ type: 'light' });
|
});
|
});
|