import { describe, expect, it, vi } from 'vitest';
|
|
import type { FieldDefinition } from '../../src/domain/types';
|
import { createOnlyOfficeBridge } from '../../src/onlyoffice/bridge';
|
import {
|
disableTrackRevisionsCommand,
|
insertTypedControlCommand,
|
listContentControlsCommand,
|
readControlTextCommand,
|
setFormulaResultLockCommand,
|
updateControlSpecCommand,
|
} from '../../src/onlyoffice/commands';
|
import type { TemplateBinding, TemplateControlSnapshot } from '../../src/domain/types';
|
import type { AscGlobal, OfficeContentControl } from '../../src/onlyoffice/types';
|
import { installOnlyOfficeGlobals, makeApiFixture } from '../fixtures/onlyoffice';
|
|
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 {
|
autoCreateContentControl?: boolean;
|
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 {
|
controlType: 'text',
|
fieldCode: 'result',
|
alias: '结果',
|
tag: 'eln.field.result',
|
color: singleColor,
|
placeholder: '请输入结果',
|
defaultValue: '',
|
defaultChecked: false,
|
...overrides,
|
};
|
}
|
|
function typedDefinition(controlType: 'checkbox' | 'date' | 'dropdown' | 'multiline'): FieldDefinition {
|
const base = {
|
fieldCode: 'result',
|
alias: '结果',
|
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) {
|
if (name === 'GetCurrentContentControl') {
|
callback(null);
|
return true;
|
}
|
methodCalls.push([name, args]);
|
const result = methodResults.shift();
|
if (name === 'AddContentControl' && result === undefined && options.autoCreateContentControl !== false) {
|
const properties = args[1] as { Tag?: unknown } | undefined;
|
const tag = typeof properties?.Tag === 'string' ? properties.Tag : '';
|
callback({ InternalId: `id-${tag}`, Tag: tag });
|
return true;
|
}
|
callback(result);
|
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('通过编辑器方法读取当前选中文本', async () => {
|
const fixture = createAscFixture({ methodResults: [' 是否合格 '] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).readSelectedText()).resolves.toBe(' 是否合格 ');
|
expect(fixture.methodCalls).toEqual([['GetSelectedText', [{ Math: false, Numbering: false }]]]);
|
});
|
|
it('按结果主控件 Tag 锁定并恢复主控件及引用快照', async () => {
|
const fixture = createAscFixture({
|
commandResults: [
|
{ ok: true, snapshot: { primaryTag: 'eln.field.result', locks: [{ tag: 'eln.field.result', lock: 'unlocked' }] } },
|
{ ok: true, snapshot: { primaryTag: 'eln.field.result', locks: [{ tag: 'eln.field.result', lock: 'unlocked' }] } },
|
],
|
});
|
const bridge = createOnlyOfficeBridge(fixture.asc);
|
const snapshot = await bridge.lockFormulaResult('eln.field.result');
|
await bridge.restoreFormulaResultLocks(snapshot);
|
|
expect(fixture.callCommands).toEqual([
|
{ command: setFormulaResultLockCommand, scopeKey: 'elnTemplateFormulaLocks', payload: { mode: 'lock', primaryTag: 'eln.field.result' } },
|
{ command: setFormulaResultLockCommand, scopeKey: 'elnTemplateFormulaLocks', payload: { mode: 'restore', snapshot } },
|
]);
|
});
|
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('解除公式结果锁时使用 ONLYOFFICE 9.4 接受的 unlocked 值', () => {
|
const apiFixture = makeApiFixture();
|
const result = apiFixture.makeControl('text', 'eln.field.result');
|
result.lock = 'contentLocked';
|
const locks: string[] = [];
|
result.SetLock = (value) => {
|
locks.push(value);
|
if (value !== 'unlocked') return false;
|
result.lock = value;
|
return true;
|
};
|
apiFixture.document.controls = [result];
|
installOnlyOfficeGlobals(apiFixture.Api, {
|
elnTemplateFormulaLocks: { mode: 'unlock', primaryTag: 'eln.field.result' },
|
});
|
|
expect(JSON.parse(setFormulaResultLockCommand())).toMatchObject({ ok: true });
|
expect(locks).toEqual(['unlocked']);
|
});
|
|
it('关闭文档内部的修订跟踪状态', async () => {
|
const fixture = createAscFixture({ commandResults: [{ ok: true }, { hasRevisionChanges: false, ok: true, trackRevisions: false }] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).disableTrackRevisions()).resolves.toBeUndefined();
|
expect(fixture.callCommands).toEqual([
|
{
|
command: disableTrackRevisionsCommand,
|
scopeKey: 'elnTemplateReview',
|
payload: {},
|
},
|
{
|
command: expect.any(Function),
|
scopeKey: 'elnTemplateReviewState',
|
payload: {},
|
},
|
]);
|
});
|
|
it('写命令回调缺失时仍通过独立只读命令确认修订已关闭', async () => {
|
const fixture = createAscFixture({ commandResults: [undefined, { hasRevisionChanges: false, ok: true, trackRevisions: false }] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).disableTrackRevisions()).resolves.toBeUndefined();
|
expect(fixture.callCommands.map((item) => item.scopeKey)).toEqual(['elnTemplateReview', 'elnTemplateReviewState']);
|
});
|
|
it('独立回读仍为开启状态时禁止模板标注', async () => {
|
const fixture = createAscFixture({ commandResults: [undefined, { hasRevisionChanges: false, ok: true, trackRevisions: true }] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).disableTrackRevisions()).rejects.toThrow('修订跟踪仍处于开启状态');
|
});
|
|
it('独立回读仍有历史修订时禁止模板标注', async () => {
|
const fixture = createAscFixture({ commandResults: [undefined, { hasRevisionChanges: true, ok: true, trackRevisions: false }] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).disableTrackRevisions()).rejects.toThrow('历史修订仍未全部接受');
|
});
|
|
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' }], undefined, after],
|
});
|
|
await expect(createOnlyOfficeBridge(fixture.asc).insertField(definition)).resolves.toBe(after);
|
});
|
|
it('文本字段通过原生接口插入并携带完整控件属性', async () => {
|
const definition = textDefinition();
|
const after = [control(definition.tag)];
|
const fixture = createAscFixture({ methodResults: [[], undefined, after] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).insertField(definition)).resolves.toBe(after);
|
expect(fixture.methodCalls).toEqual([
|
[
|
'AddContentControl',
|
[
|
2,
|
{
|
Alias: definition.alias,
|
Appearance: 1,
|
Color: definition.color,
|
Lock: 3,
|
PlaceHolderText: definition.placeholder,
|
Tag: definition.tag,
|
},
|
],
|
],
|
]);
|
expect(fixture.callCommands).toEqual([]);
|
expect(fixture.listCommands).toHaveLength(2);
|
});
|
|
it('单行文本使用编辑器保存的光标位置插入', async () => {
|
const definition = textDefinition();
|
const after = [control(definition.tag)];
|
const fixture = createAscFixture({
|
commandResults: [{ error: 'API command 无法定位当前光标', ok: false }],
|
methodResults: [[], undefined, after],
|
});
|
|
await expect(createOnlyOfficeBridge(fixture.asc).insertField(definition)).resolves.toBe(after);
|
expect(fixture.methodCalls).toEqual([
|
[
|
'AddContentControl',
|
[
|
2,
|
expect.objectContaining({
|
Alias: definition.alias,
|
PlaceHolderText: definition.placeholder,
|
Tag: definition.tag,
|
}),
|
],
|
],
|
]);
|
expect(fixture.callCommands).toEqual([]);
|
});
|
|
it('文本默认值随文本控件一并插入', async () => {
|
const definition = textDefinition({ defaultValue: 'S-001' });
|
const fixture = createAscFixture({
|
methodResults: [
|
[],
|
undefined,
|
[control(definition.tag)],
|
undefined,
|
undefined,
|
{ InternalId: `id-${definition.tag}`, Tag: definition.tag, content: 'S-001' },
|
[control(definition.tag)],
|
],
|
});
|
|
await createOnlyOfficeBridge(fixture.asc).insertField(definition);
|
|
expect(fixture.methodCalls).toEqual([
|
['AddContentControl', [2, expect.objectContaining({ Tag: definition.tag })]],
|
['GetAllContentControls', []],
|
['MoveCursorToContentControl', [`id-${definition.tag}`, true]],
|
['InputText', ['S-001']],
|
['GetCurrentContentControlPr', ['text']],
|
]);
|
expect(fixture.callCommands).toEqual([]);
|
});
|
|
it('原生文本插入失败时报告光标位置错误且不执行后续命令', async () => {
|
const definition = textDefinition();
|
const fixture = createAscFixture({ autoCreateContentControl: false, methodResults: [[], undefined] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).insertField(definition)).rejects.toThrow('编辑器未能在光标位置插入文本控件');
|
expect(fixture.methodCalls).toEqual([['AddContentControl', [2, expect.objectContaining({ Tag: definition.tag })]]]);
|
expect(fixture.callCommands).toHaveLength(0);
|
});
|
|
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: expect.objectContaining(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(), [[], undefined, 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' });
|
});
|
});
|
|
describe('后端绑定 bridge', () => {
|
const primary: TemplateBinding = {
|
controlAlias: '结果',
|
controlConfig: { placeholder: '请输入结果' },
|
controlRole: 'PRIMARY',
|
controlTag: 'eln.field.result',
|
controlType: 'TEXT',
|
fieldCode: 'result',
|
fieldId: 'field',
|
fieldVersionId: 'version',
|
id: 'primary',
|
primaryBindingId: null,
|
status: 'PENDING',
|
templateId: 'template',
|
version: 0,
|
};
|
|
it('光标位于已有内容控件时拒绝嵌套插入', async () => {
|
const fixture = createAscFixture({ commandResults: [{ ok: true }] });
|
fixture.asc.plugin.executeMethod = (name, args, callback) => {
|
fixture.methodCalls.push([name, args]);
|
callback(name === 'GetCurrentContentControl' ? 'outer-control' : { InternalId: 'created' });
|
return true;
|
};
|
|
await expect(createOnlyOfficeBridge(fixture.asc).insertBinding(primary)).rejects.toThrow('请将光标移到已有字段控件外再插入');
|
|
expect(fixture.methodCalls).toEqual([['GetCurrentContentControl', []]]);
|
});
|
|
it('历史空占位配置创建主控件时回退为控件别名,不重复按 Tag 更新', async () => {
|
const fixture = createAscFixture();
|
|
await createOnlyOfficeBridge(fixture.asc).insertBinding({ ...primary, controlConfig: { placeholder: '' } });
|
|
expect(fixture.methodCalls).toEqual([
|
['AddContentControl', [2, expect.objectContaining({ Alias: '结果', Lock: 3, PlaceHolderText: '结果', Tag: primary.controlTag })]],
|
]);
|
expect(fixture.callCommands).toEqual([]);
|
});
|
|
it('文本引用读取主字段当前文本,使用后端 eln.ref Tag 直接插入只读控件', async () => {
|
const reference = { ...primary, controlRole: 'REFERENCE', controlTag: 'eln.ref.result.abc', id: 'reference', primaryBindingId: 'primary' };
|
const fixture = createAscFixture({
|
commandResults: [{ ok: true, text: '12.3' }],
|
methodResults: [
|
undefined,
|
[control(reference.controlTag)],
|
undefined,
|
undefined,
|
{ InternalId: `id-${reference.controlTag}`, Tag: reference.controlTag, content: '12.3' },
|
],
|
});
|
|
await createOnlyOfficeBridge(fixture.asc).insertReference(reference, primary.controlTag);
|
|
expect(fixture.methodCalls).toEqual([
|
['AddContentControl', [2, expect.objectContaining({ Alias: '结果', Lock: 0, Tag: reference.controlTag })]],
|
['GetAllContentControls', []],
|
['MoveCursorToContentControl', [`id-${reference.controlTag}`, true]],
|
['InputText', ['12.3']],
|
['GetCurrentContentControlPr', ['text']],
|
]);
|
expect(fixture.callCommands).toEqual([{ command: readControlTextCommand, scopeKey: 'elnTemplateReadText', payload: { tag: primary.controlTag } }]);
|
});
|
|
it('中止后读到引用文本不再创建内容控件', async () => {
|
const reference = { ...primary, controlRole: 'REFERENCE', controlTag: 'eln.ref.result.abc', id: 'reference', primaryBindingId: 'primary' };
|
const fixture = createAscFixture();
|
let completeRead!: (result: unknown) => void;
|
fixture.asc.plugin.callCommand = (command, _isClose, _isCalc, callback) => {
|
expect(command).toBe(readControlTextCommand);
|
completeRead = callback;
|
return true;
|
};
|
const controller = new AbortController();
|
const operation = createOnlyOfficeBridge(fixture.asc).insertReference(reference, primary.controlTag, controller.signal);
|
|
controller.abort();
|
completeRead({ ok: true, text: '12.3' });
|
await operation;
|
|
expect(fixture.methodCalls).not.toContainEqual(expect.arrayContaining(['AddContentControl']));
|
});
|
|
it('按后端检查结果删除旧控件并更新主字段与全部引用', async () => {
|
const reference = {
|
...primary,
|
controlRole: 'REFERENCE',
|
controlTag: 'eln.ref.result.abc',
|
id: 'reference',
|
primaryBindingId: 'primary',
|
status: 'ACTIVE',
|
};
|
const fixture = createAscFixture({
|
commandResults: [{ ok: true, updated: 1 }],
|
methodResults: [[control(primary.controlTag, 'primary-control'), control(reference.controlTag, 'reference-control')], true],
|
});
|
const desired: TemplateControlSnapshot[] = [
|
{
|
bindingId: 'reference',
|
controlAlias: '结果',
|
controlConfig: {},
|
controlRole: 'PRIMARY',
|
controlTag: 'eln.field.result',
|
controlType: 'TEXT',
|
fieldVersionId: 'version',
|
primaryBindingId: null,
|
},
|
];
|
|
await createOnlyOfficeBridge(fixture.asc).applyControlPlan([{ ...primary, status: 'ACTIVE' }, reference], desired);
|
|
expect(fixture.methodCalls).not.toContainEqual(['RemoveContentControl', ['primary-control']]);
|
expect(fixture.callCommands).toContainEqual(
|
expect.objectContaining({
|
command: updateControlSpecCommand,
|
payload: { removals: [primary.controlTag], updates: [expect.objectContaining({ currentTag: reference.controlTag, tag: 'eln.field.result' })] },
|
}),
|
);
|
});
|
|
it('一次计划包含多个删除时在修改文档前拒绝执行', async () => {
|
const another = { ...primary, controlTag: 'eln.field.conclusion', fieldCode: 'conclusion', id: 'another', status: 'ACTIVE' };
|
const fixture = createAscFixture({
|
methodResults: [[control(primary.controlTag, 'primary-control'), control(another.controlTag, 'another-control')]],
|
});
|
|
await expect(createOnlyOfficeBridge(fixture.asc).applyControlPlan([{ ...primary, status: 'ACTIVE' }, another], [])).rejects.toThrow(
|
'当前阶段不支持一次删除多个控件',
|
);
|
|
expect(fixture.methodCalls).not.toContainEqual(expect.arrayContaining(['RemoveContentControl']));
|
expect(fixture.callCommands).not.toContainEqual(expect.objectContaining({ command: updateControlSpecCommand }));
|
});
|
|
it('字段版本改变控件类型时在发送更新命令前明确拒绝', async () => {
|
const fixture = createAscFixture({
|
methodResults: [[{ ...control(primary.controlTag, 'primary-control'), ControlType: 'text' }]],
|
});
|
const desired: TemplateControlSnapshot[] = [
|
{
|
bindingId: primary.id,
|
controlAlias: '结果',
|
controlConfig: { dateFormat: 'yyyy-MM-dd' },
|
controlRole: 'PRIMARY',
|
controlTag: primary.controlTag,
|
controlType: 'DATE',
|
fieldVersionId: 'version-2',
|
primaryBindingId: null,
|
},
|
];
|
|
await expect(createOnlyOfficeBridge(fixture.asc).applyControlPlan([{ ...primary, status: 'ACTIVE' }], desired)).rejects.toThrow(
|
'当前阶段不支持修改已有控件类型',
|
);
|
|
expect(fixture.callCommands).not.toContainEqual(expect.objectContaining({ command: updateControlSpecCommand }));
|
});
|
|
it('目标 placeholder 为空但文档旧值非空时在发送更新命令前拒绝', async () => {
|
const fixture = createAscFixture({
|
methodResults: [
|
[
|
{
|
Alias: '结果',
|
ControlConfig: { placeholder: '原提示' },
|
ControlRole: 'PRIMARY',
|
ControlType: 'text',
|
InternalId: 'primary-control',
|
Lock: 'unlocked',
|
Tag: primary.controlTag,
|
},
|
],
|
],
|
});
|
const desired: TemplateControlSnapshot[] = [
|
{
|
bindingId: primary.id,
|
controlAlias: '结果',
|
controlConfig: { placeholder: '' },
|
controlRole: 'PRIMARY',
|
controlTag: primary.controlTag,
|
controlType: 'TEXT',
|
fieldVersionId: 'version-2',
|
primaryBindingId: null,
|
},
|
];
|
|
await expect(createOnlyOfficeBridge(fixture.asc).applyControlPlan([{ ...primary, status: 'ACTIVE' }], desired)).rejects.toThrow(
|
'当前编辑器不支持清空已有占位文本',
|
);
|
|
expect(fixture.callCommands).not.toContainEqual(expect.objectContaining({ command: updateControlSpecCommand }));
|
});
|
|
it('目标和文档 placeholder 均为空时允许发送更新命令', async () => {
|
const emptyPlaceholder = { ...primary, controlConfig: { placeholder: '' }, status: 'ACTIVE' };
|
const fixture = createAscFixture({
|
commandResults: [{ ok: true, updated: 1 }],
|
methodResults: [
|
[
|
{
|
Alias: '结果',
|
ControlConfig: { placeholder: '' },
|
ControlRole: 'PRIMARY',
|
ControlType: 'text',
|
InternalId: 'primary-control',
|
Lock: 'unlocked',
|
Tag: primary.controlTag,
|
},
|
],
|
],
|
});
|
const desired: TemplateControlSnapshot[] = [
|
{
|
bindingId: primary.id,
|
controlAlias: '新结果',
|
controlConfig: { placeholder: '' },
|
controlRole: 'PRIMARY',
|
controlTag: primary.controlTag,
|
controlType: 'TEXT',
|
fieldVersionId: 'version-2',
|
primaryBindingId: null,
|
},
|
];
|
|
await expect(createOnlyOfficeBridge(fixture.asc).applyControlPlan([emptyPlaceholder], desired)).resolves.toBeUndefined();
|
expect(fixture.callCommands).toContainEqual(expect.objectContaining({ command: updateControlSpecCommand }));
|
});
|
|
it('同 Tag 版本升级已写入 DOCX 时属性匹配即跳过重复 setter', async () => {
|
const fixture = createAscFixture({
|
methodResults: [
|
[
|
{
|
Alias: '结果',
|
ControlConfig: { placeholder: '请输入结果' },
|
ControlRole: 'PRIMARY',
|
ControlType: 'text',
|
InternalId: 'primary-control',
|
Lock: 'unlocked',
|
Tag: primary.controlTag,
|
},
|
],
|
],
|
});
|
const desired: TemplateControlSnapshot[] = [
|
{
|
bindingId: primary.id,
|
controlAlias: '结果',
|
controlConfig: { placeholder: '请输入结果' },
|
controlRole: 'PRIMARY',
|
controlTag: primary.controlTag,
|
controlType: 'TEXT',
|
fieldVersionId: 'version-2',
|
primaryBindingId: null,
|
},
|
];
|
|
await expect(createOnlyOfficeBridge(fixture.asc).applyControlPlan([{ ...primary, status: 'ACTIVE' }], desired)).resolves.toBeUndefined();
|
|
expect(fixture.callCommands).not.toContainEqual(expect.objectContaining({ command: updateControlSpecCommand }));
|
});
|
|
it('CHECKBOX 升级幂等重试忽略目标版本 defaultChecked 与文档当前勾选态差异', async () => {
|
const checkbox = {
|
...primary,
|
controlConfig: { defaultChecked: false },
|
controlType: 'CHECKBOX',
|
status: 'ACTIVE',
|
};
|
const fixture = createAscFixture({
|
methodResults: [
|
[
|
{
|
Alias: '结果',
|
ControlConfig: { defaultChecked: false },
|
ControlRole: 'PRIMARY',
|
ControlType: 'checkbox',
|
InternalId: 'checkbox-control',
|
Lock: 'unlocked',
|
Tag: checkbox.controlTag,
|
},
|
],
|
],
|
});
|
const desired: TemplateControlSnapshot[] = [
|
{
|
bindingId: checkbox.id,
|
controlAlias: '结果',
|
controlConfig: { defaultChecked: true },
|
controlRole: 'PRIMARY',
|
controlTag: checkbox.controlTag,
|
controlType: 'CHECKBOX',
|
fieldVersionId: 'version-2',
|
primaryBindingId: null,
|
},
|
];
|
|
await expect(createOnlyOfficeBridge(fixture.asc).applyControlPlan([checkbox], desired)).resolves.toBeUndefined();
|
|
expect(fixture.callCommands).not.toContainEqual(expect.objectContaining({ command: updateControlSpecCommand }));
|
});
|
|
it('引用提升已写入 DOCX 时识别唯一目标控件并跳过重复改写和删除', async () => {
|
const reference = {
|
...primary,
|
controlRole: 'REFERENCE',
|
controlTag: 'eln.ref.result.abc',
|
id: 'reference',
|
primaryBindingId: primary.id,
|
status: 'ACTIVE',
|
};
|
const fixture = createAscFixture({
|
methodResults: [
|
[
|
{
|
Alias: '结果',
|
ControlConfig: { placeholder: '请输入结果' },
|
ControlRole: 'PRIMARY',
|
ControlType: 'text',
|
InternalId: 'promoted-control',
|
Lock: 'unlocked',
|
Tag: primary.controlTag,
|
},
|
],
|
],
|
});
|
const desired: TemplateControlSnapshot[] = [
|
{
|
bindingId: reference.id,
|
controlAlias: '结果',
|
controlConfig: { placeholder: '请输入结果' },
|
controlRole: 'PRIMARY',
|
controlTag: primary.controlTag,
|
controlType: 'TEXT',
|
fieldVersionId: 'version',
|
primaryBindingId: null,
|
},
|
];
|
|
await expect(createOnlyOfficeBridge(fixture.asc).applyControlPlan([{ ...primary, status: 'ACTIVE' }, reference], desired)).resolves.toBeUndefined();
|
|
expect(fixture.callCommands).not.toContainEqual(expect.objectContaining({ command: updateControlSpecCommand }));
|
expect(fixture.methodCalls).not.toContainEqual(expect.arrayContaining(['RemoveContentControl']));
|
});
|
|
it('删除已写入 DOCX 时允许空计划重试且不再发送删除命令', async () => {
|
const fixture = createAscFixture({ methodResults: [[]] });
|
|
await expect(createOnlyOfficeBridge(fixture.asc).applyControlPlan([{ ...primary, status: 'ACTIVE' }], [])).resolves.toBeUndefined();
|
|
expect(fixture.callCommands).not.toContainEqual(expect.objectContaining({ command: updateControlSpecCommand }));
|
expect(fixture.methodCalls).not.toContainEqual(expect.arrayContaining(['RemoveContentControl']));
|
});
|
|
it('中止后首次枚举返回时不删除或更新控件', async () => {
|
const fixture = createAscFixture();
|
let completeList!: (result: unknown) => void;
|
fixture.asc.plugin.callCommand = (command, _isClose, _isCalc, callback) => {
|
expect(command).toBe(listContentControlsCommand);
|
completeList = callback;
|
return true;
|
};
|
const controller = new AbortController();
|
const operation = createOnlyOfficeBridge(fixture.asc).applyControlPlan([{ ...primary, status: 'ACTIVE' }], [], controller.signal);
|
|
controller.abort();
|
completeList({ ok: true, controls: [control(primary.controlTag, 'primary-control')] });
|
await operation;
|
|
expect(fixture.methodCalls).not.toContainEqual(['RemoveContentControl', ['primary-control']]);
|
expect(fixture.callCommands).not.toContainEqual(expect.objectContaining({ command: updateControlSpecCommand }));
|
});
|
|
it('中止后内部枚举返回时不调用编辑器定位控件', async () => {
|
const fixture = createAscFixture();
|
let completeList!: (result: unknown) => void;
|
fixture.asc.plugin.callCommand = (command, _isClose, _isCalc, callback) => {
|
expect(command).toBe(listContentControlsCommand);
|
completeList = callback;
|
return true;
|
};
|
const controller = new AbortController();
|
const operation = createOnlyOfficeBridge(fixture.asc).selectControl('control-1', controller.signal);
|
|
controller.abort();
|
completeList({ ok: true, controls: [control(primary.controlTag, 'control-1')] });
|
await operation;
|
|
expect(fixture.methodCalls).not.toContainEqual(['SelectContentControl', ['control-1']]);
|
});
|
|
it('中止后控件插入返回时不再继续更新属性', async () => {
|
const reference = { ...primary, controlRole: 'REFERENCE', controlTag: 'eln.ref.result.abc', id: 'reference', primaryBindingId: 'primary' };
|
const fixture = createAscFixture();
|
let completeInsert!: (result: unknown) => void;
|
fixture.asc.plugin.executeMethod = (name, args, callback) => {
|
if (name === 'GetCurrentContentControl') {
|
callback(null);
|
return true;
|
}
|
expect(name).toBe('AddContentControl');
|
fixture.methodCalls.push([name, args]);
|
completeInsert = callback;
|
return true;
|
};
|
const controller = new AbortController();
|
const operation = createOnlyOfficeBridge(fixture.asc).insertBinding(reference, controller.signal);
|
|
await vi.waitFor(() => expect(completeInsert).toBeTypeOf('function'));
|
controller.abort();
|
completeInsert({ InternalId: 'reference-control', Tag: reference.controlTag });
|
await operation;
|
|
expect(fixture.methodCalls).toEqual([['AddContentControl', [2, expect.objectContaining({ Tag: reference.controlTag })]]]);
|
expect(fixture.callCommands).toEqual([]);
|
});
|
|
it('确认快照使用文档实际属性,只从绑定关联不可写入 DOCX 的 ID', () => {
|
const bridge = createOnlyOfficeBridge(createAscFixture().asc);
|
const controls: OfficeContentControl[] = [
|
{
|
Alias: '结果',
|
ControlConfig: { placeholder: '请输入结果' },
|
ControlRole: 'PRIMARY',
|
ControlType: 'text',
|
InternalId: 'id',
|
Lock: 'unlocked',
|
Tag: primary.controlTag,
|
},
|
];
|
expect(bridge.snapshotControls([primary], controls)).toEqual([
|
{
|
bindingId: 'primary',
|
controlAlias: '结果',
|
controlConfig: { placeholder: '请输入结果' },
|
controlRole: 'PRIMARY',
|
controlTag: primary.controlTag,
|
controlType: 'TEXT',
|
fieldVersionId: 'version',
|
primaryBindingId: null,
|
},
|
]);
|
});
|
|
it('确认快照保留步骤归属和重复组身份', () => {
|
const bridge = createOnlyOfficeBridge(createAscFixture().asc);
|
const repeat = {
|
...primary,
|
controlRole: 'REPEAT',
|
controlTag: 'eln.repeat.results.prototype.result',
|
primaryBindingId: null,
|
repeatGroupCode: 'results',
|
repeatPrototypeRowId: 'prototype',
|
stepId: 'step-test',
|
};
|
const controls: OfficeContentControl[] = [
|
{
|
Alias: '结果',
|
ControlConfig: { placeholder: '请输入结果' },
|
ControlRole: 'REPEAT',
|
ControlType: 'text',
|
InternalId: 'repeat-id',
|
Lock: 'unlocked',
|
Tag: repeat.controlTag,
|
},
|
];
|
|
expect(bridge.snapshotControls([repeat], controls)).toEqual([
|
expect.objectContaining({
|
repeatGroupCode: 'results',
|
repeatPrototypeRowId: 'prototype',
|
stepId: 'step-test',
|
}),
|
]);
|
});
|
|
it('升版复制快照显式允许公式结果主控件和引用继承完整锁', () => {
|
const bridge = createOnlyOfficeBridge(createAscFixture().asc);
|
const lockedReferenceBinding = {
|
...primary,
|
controlRole: 'REFERENCE',
|
controlTag: 'eln.ref.result.copy',
|
id: 'reference',
|
primaryBindingId: primary.id,
|
};
|
const lockedPrimary: OfficeContentControl = {
|
Alias: '结果',
|
ControlConfig: { placeholder: '请输入结果' },
|
ControlRole: 'PRIMARY',
|
ControlType: 'text',
|
InternalId: 'locked-primary',
|
Lock: 'sdtContentLocked',
|
Tag: primary.controlTag,
|
};
|
const lockedReference: OfficeContentControl = {
|
...lockedPrimary,
|
ControlRole: 'REFERENCE',
|
InternalId: 'locked-reference',
|
Tag: lockedReferenceBinding.controlTag,
|
};
|
|
expect(() => bridge.snapshotControls([primary], [lockedPrimary])).toThrow('控件属性与绑定不一致');
|
expect(
|
bridge.snapshotControls([primary, lockedReferenceBinding], [lockedPrimary, lockedReference], {
|
inheritedFormulaLockBindingIds: new Set([primary.id, lockedReferenceBinding.id]),
|
}),
|
).toEqual([
|
expect.objectContaining({ bindingId: 'primary', controlRole: 'PRIMARY' }),
|
expect.objectContaining({ bindingId: 'reference', controlRole: 'REFERENCE' }),
|
]);
|
expect(() =>
|
bridge.snapshotControls([primary, lockedReferenceBinding], [lockedPrimary, lockedReference], { inheritedFormulaLockBindingIds: new Set([primary.id]) }),
|
).toThrow('控件属性与绑定不一致');
|
});
|
|
it('快照校验将非复选框历史空 placeholder 规范为控件别名', () => {
|
const historical = {
|
...primary,
|
controlConfig: { defaultValue: '', placeholder: '' },
|
};
|
const control: OfficeContentControl = {
|
Alias: '结果',
|
ControlConfig: { placeholder: '结果' },
|
ControlRole: 'PRIMARY',
|
ControlType: 'text',
|
InternalId: 'historical-placeholder',
|
Lock: 'unlocked',
|
Tag: historical.controlTag,
|
};
|
|
expect(createOnlyOfficeBridge(createAscFixture().asc).snapshotControls([historical], [control])).toEqual([
|
{
|
bindingId: 'primary',
|
controlAlias: '结果',
|
controlConfig: { placeholder: '结果' },
|
controlRole: 'PRIMARY',
|
controlTag: primary.controlTag,
|
controlType: 'TEXT',
|
fieldVersionId: 'version',
|
primaryBindingId: null,
|
},
|
]);
|
});
|
|
it('CHECKBOX 升级确认快照忽略字段 defaultChecked 与文档当前勾选态差异', () => {
|
const checkbox = {
|
...primary,
|
controlConfig: { defaultChecked: true },
|
controlType: 'CHECKBOX',
|
fieldVersionId: 'version-2',
|
};
|
const controls: OfficeContentControl[] = [
|
{
|
Alias: '结果',
|
ControlConfig: { defaultChecked: false },
|
ControlRole: 'PRIMARY',
|
ControlType: 'checkbox',
|
InternalId: 'checkbox-control',
|
Lock: 'unlocked',
|
Tag: checkbox.controlTag,
|
},
|
];
|
|
expect(createOnlyOfficeBridge(createAscFixture().asc).snapshotControls([checkbox], controls)).toEqual([
|
{
|
bindingId: checkbox.id,
|
controlAlias: '结果',
|
controlConfig: {},
|
controlRole: 'PRIMARY',
|
controlTag: checkbox.controlTag,
|
controlType: 'CHECKBOX',
|
fieldVersionId: 'version-2',
|
primaryBindingId: null,
|
},
|
]);
|
});
|
|
it('command 实际枚举结果可直接生成规范化快照,比较不受对象键顺序影响', () => {
|
const apiFixture = makeApiFixture();
|
const dropdownControl = apiFixture.makeControl('dropdown', primary.controlTag);
|
dropdownControl.alias = '结果';
|
dropdownControl.placeholder = '请选择结果';
|
dropdownControl.options = [
|
{ display: '合格', value: 'PASS' },
|
{ display: '不合格', value: 'FAIL' },
|
];
|
dropdownControl.elements = [{ text: '合格' }];
|
apiFixture.document.controls = [dropdownControl];
|
installOnlyOfficeGlobals(apiFixture.Api, { elnTemplateList: {} });
|
const enumerated = JSON.parse(listContentControlsCommand()) as { controls: OfficeContentControl[] };
|
const dropdownBinding: TemplateBinding = {
|
...primary,
|
controlConfig: {
|
defaultValue: 'PASS',
|
options: [
|
{ value: 'PASS', display: '合格' },
|
{ value: 'FAIL', display: '不合格' },
|
],
|
placeholder: '请选择结果',
|
},
|
controlType: 'DROPDOWN',
|
};
|
|
expect(createOnlyOfficeBridge(createAscFixture().asc).snapshotControls([dropdownBinding], enumerated.controls)).toEqual([
|
expect.objectContaining({
|
bindingId: 'primary',
|
controlConfig: {
|
defaultValue: 'PASS',
|
options: [
|
{ display: '合格', value: 'PASS' },
|
{ display: '不合格', value: 'FAIL' },
|
],
|
placeholder: '请选择结果',
|
},
|
}),
|
]);
|
});
|
|
it('引用快照不要求或提交无法从结构属性证明的默认值', () => {
|
const reference = {
|
...primary,
|
controlConfig: { defaultValue: '原始默认值', placeholder: '请输入结果' },
|
controlRole: 'REFERENCE',
|
controlTag: 'eln.ref.result.ref1',
|
id: 'reference',
|
primaryBindingId: primary.id,
|
};
|
const controls: OfficeContentControl[] = [
|
{
|
Alias: '结果',
|
ControlConfig: { placeholder: '请输入结果' },
|
ControlRole: 'REFERENCE',
|
ControlType: 'text',
|
InternalId: 'reference-control',
|
Lock: 'contentLocked',
|
Tag: reference.controlTag,
|
},
|
];
|
|
expect(createOnlyOfficeBridge(createAscFixture().asc).snapshotControls([reference], controls)[0]?.controlConfig).toEqual({ placeholder: '请输入结果' });
|
});
|
|
it('实际枚举属性缺失或与绑定期望不一致时拒绝生成确认快照', () => {
|
const bridge = createOnlyOfficeBridge(createAscFixture().asc);
|
expect(() =>
|
bridge.snapshotControls(
|
[primary],
|
[
|
{
|
Alias: '错误名称',
|
ControlConfig: { placeholder: '请输入结果' },
|
ControlRole: 'PRIMARY',
|
ControlType: 'text',
|
InternalId: 'id',
|
Lock: 'unlocked',
|
Tag: primary.controlTag,
|
},
|
],
|
),
|
).toThrow('控件属性与绑定不一致');
|
expect(() => bridge.snapshotControls([primary], [{ Alias: '结果', ControlType: 'text', InternalId: 'id', Tag: primary.controlTag }])).toThrow(
|
'控件枚举属性不完整',
|
);
|
});
|
|
it('使用后端 DROPDOWN/MULTILINE 控件枚举而不是字段数据类型枚举', async () => {
|
const dropdown = { ...primary, controlType: 'DROPDOWN', controlConfig: { options: ['合格', '不合格'], defaultValue: '合格' } };
|
const fixture = createAscFixture({ commandResults: [{ ok: true }, { ok: true }] });
|
await createOnlyOfficeBridge(fixture.asc).insertBinding(dropdown);
|
expect(fixture.callCommands[0]).toMatchObject({
|
command: insertTypedControlCommand,
|
payload: expect.objectContaining({ controlType: 'dropdown', selectedIndex: 0 }),
|
});
|
});
|
|
it('新建多行主控件不再按已有控件重配类型', async () => {
|
const multiline = { ...primary, controlType: 'MULTILINE', controlConfig: { placeholder: '请输入检验结果' } };
|
const fixture = createAscFixture({ commandResults: [{ ok: true }] });
|
|
await createOnlyOfficeBridge(fixture.asc).insertBinding(multiline);
|
|
expect(fixture.callCommands).toEqual([
|
expect.objectContaining({
|
command: insertTypedControlCommand,
|
payload: expect.objectContaining({ controlType: 'multiline' }),
|
}),
|
]);
|
});
|
|
it('快照把 ONLYOFFICE 9.4 枚举为 TEXT 的块级绑定规范为 MULTILINE', () => {
|
const multiline = { ...primary, controlType: 'MULTILINE', controlConfig: { placeholder: '请输入检验结果' } };
|
const control: OfficeContentControl = {
|
Alias: '结果',
|
ControlConfig: { placeholder: '请输入检验结果' },
|
ControlRole: 'PRIMARY',
|
ControlType: 'text',
|
InternalId: 'multiline-control',
|
Lock: 'unlocked',
|
Tag: multiline.controlTag,
|
};
|
|
expect(createOnlyOfficeBridge(createAscFixture().asc).snapshotControls([multiline], [control])).toEqual([
|
expect.objectContaining({ controlType: 'MULTILINE' }),
|
]);
|
});
|
|
it('日期时间绑定把字段版本格式和完整默认值传给插入命令', async () => {
|
const datetime = {
|
...primary,
|
controlType: 'DATE',
|
controlConfig: {
|
dateFormat: 'yyyy-MM-dd HH:mm:ss',
|
defaultValue: '2026-08-01 14:35:27',
|
},
|
};
|
const fixture = createAscFixture({ commandResults: [{ ok: true }, { ok: true }] });
|
|
await createOnlyOfficeBridge(fixture.asc).insertBinding(datetime);
|
|
expect(fixture.callCommands[0]).toMatchObject({
|
command: insertTypedControlCommand,
|
payload: expect.objectContaining({
|
controlType: 'date',
|
dateFormat: 'yyyy-MM-dd HH:mm:ss',
|
defaultValue: '2026-08-01 14:35:27',
|
}),
|
});
|
});
|
});
|