import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
applyFieldsCommand,
|
cloneRepeatRowCommand,
|
inspectFieldsCommand,
|
removeRepeatRowCommand,
|
} from '../../src/onlyoffice/commands';
|
|
function control(
|
tag: string,
|
kind: 'checkbox' | 'date' | 'multiline' | 'select' | 'text',
|
initial: boolean | string,
|
initialLock = 'unlocked',
|
dateFormat = 'yyyy-MM-dd',
|
initialTextPr: Record<string, unknown> = {},
|
) {
|
let value = initial;
|
let lock = initialLock;
|
let textPr = initialTextPr;
|
const setLock = vi.fn((next: string) => {
|
lock = next;
|
return true;
|
});
|
const options = [
|
{ GetText: () => '合格', GetValue: () => 'qualified' },
|
{ GetText: () => '不合格', GetValue: () => 'unqualified' },
|
];
|
return {
|
GetTag: () => tag,
|
GetAlias: () => '字段',
|
GetInternalId: () => `id-${tag}`,
|
GetLock: () => lock,
|
GetRange: () => {
|
const range = {
|
GetText: () => {
|
if (typeof value === 'boolean') return value ? '☒' : '☐';
|
return kind === 'multiline' && value ? `${value.replaceAll('\n', '\r\n')}\r\n` : value;
|
},
|
GetTextPr: () => textPr,
|
SetTextPr(next: Record<string, unknown>) {
|
textPr = next;
|
return range;
|
},
|
};
|
return range;
|
},
|
IsCheckBox: () => kind === 'checkbox',
|
IsComboBox: () => kind === 'select',
|
IsDropDownList: () => false,
|
IsDatePicker: () => kind === 'date',
|
IsBlockLevel: () => kind === 'multiline',
|
GetDropdownList: () => ({ GetElementsCount: () => options.length, GetItem: (index: number) => options[index] }),
|
SetCheckBoxChecked(next: boolean) {
|
value = next;
|
return true;
|
},
|
SelectListItem(next: string) {
|
value = options.find((item) => item.GetValue() === next)?.GetText() ?? '';
|
return true;
|
},
|
SetDate(next: Date) {
|
const date = `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, '0')}-${String(next.getDate()).padStart(2, '0')}`;
|
value = /h/iu.test(dateFormat)
|
? `${date} ${String(next.getHours()).padStart(2, '0')}:${String(next.getMinutes()).padStart(2, '0')}:${String(next.getSeconds()).padStart(2, '0')}`
|
: date;
|
return true;
|
},
|
SetLock: setLock,
|
ToJSON: () => JSON.stringify({ sdtPr: { date: { dateFormat } } }),
|
RemoveAllElements() {
|
value = '';
|
textPr = {};
|
return true;
|
},
|
AddElement(item: { text?: string }) {
|
const text = item.text ?? '';
|
value = kind === 'multiline' && value !== '' ? `${value}\n${text}` : `${value}${text}`;
|
return true;
|
},
|
};
|
}
|
|
function install(controls: unknown[], changes: unknown[] = []) {
|
const createParagraph = vi.fn(() => ({
|
text: '',
|
AddText(text: string) {
|
this.text += text;
|
return true;
|
},
|
}));
|
vi.stubGlobal('Asc', { scope: { elnRecordFillInspect: {}, elnRecordFillChanges: changes } });
|
vi.stubGlobal('Api', {
|
GetDocument: () => ({ GetAllContentControls: () => controls }),
|
CreateRun: () => ({
|
text: '',
|
AddText(value: string) {
|
this.text = value;
|
return true;
|
},
|
}),
|
CreateParagraph: createParagraph,
|
});
|
return { createParagraph };
|
}
|
|
function serializeCommand(command: () => string): () => string {
|
// The ONLYOFFICE command is intentionally re-evaluated without module scope.
|
// eslint-disable-next-line no-new-func
|
return new Function(`"use strict"; return (${command.toString()});`)() as () => string;
|
}
|
|
function installRepeatDocument(options: { failTag?: string } = {}) {
|
const controls: any[] = [];
|
const rows: any[] = [];
|
let targetSequence = 0;
|
let table: any;
|
|
function makeControl(initialTag: string, cell: any) {
|
let tag = initialTag;
|
return {
|
GetParentTableCell: () => cell,
|
GetTag: () => tag,
|
SetTag: vi.fn((next: string) => {
|
if (next === options.failTag) return false;
|
tag = next;
|
return true;
|
}),
|
};
|
}
|
|
function makeRow(id: string, sourceTags: Array<null | string>) {
|
const row: any = {
|
GetCellsCount: () => sourceTags.length,
|
GetInternalId: () => id,
|
GetCell: (index: number) => row.cells[index],
|
Remove: vi.fn(() => {
|
const index = rows.indexOf(row);
|
if (index >= 0) rows.splice(index, 1);
|
for (let controlIndex = controls.length - 1; controlIndex >= 0; controlIndex -= 1) {
|
if (controls[controlIndex].GetParentTableCell().GetParentRow() === row) controls.splice(controlIndex, 1);
|
}
|
return true;
|
}),
|
cells: [] as any[],
|
};
|
row.cells = sourceTags.map((sourceTag) => {
|
const elements = sourceTag ? [{ Copy: () => ({ tag: sourceTag }) }] : [{ Copy: () => ({ tag: null }) }];
|
const cell: any = {
|
Cell: { SetVMerge: vi.fn() },
|
AddElement: vi.fn((_index: number, element: { tag: null | string }) => {
|
if (element.tag) controls.push(makeControl(element.tag, cell));
|
return true;
|
}),
|
Clear: vi.fn(() => true),
|
GetContent: () => ({ GetContent: () => elements }),
|
GetParentRow: () => row,
|
GetParentTable: () => table,
|
};
|
return cell;
|
});
|
return row;
|
}
|
|
const sourceTags = ['eln.repeat.results.prototype.value', 'eln.repeat.results.prototype.unit'];
|
const sourceRow = makeRow('source-row', sourceTags);
|
table = {
|
AddRow: vi.fn((anchorCell: any, isBefore = false) => {
|
targetSequence += 1;
|
const target = makeRow(`target-row-${targetSequence}`, sourceTags.map(() => null));
|
const anchorIndex = rows.indexOf(anchorCell.GetParentRow());
|
rows.splice(anchorIndex + (isBefore ? 0 : 1), 0, target);
|
return target;
|
}),
|
GetInternalId: () => 'table-1',
|
GetRow: (index: number) => rows[index],
|
GetRowsCount: () => rows.length,
|
};
|
rows.push(sourceRow);
|
sourceTags.forEach((tag, index) => controls.push(makeControl(tag, sourceRow.GetCell(index))));
|
vi.stubGlobal('Asc', { scope: { elnRecordFillRepeatRow: { groupCode: 'results', rowId: 'row-1' } } });
|
vi.stubGlobal('Api', { GetDocument: () => ({ GetAllContentControls: () => controls }) });
|
return { controls, rows, table };
|
}
|
|
// eslint-disable-next-line test/prefer-lowercase-title -- ONLYOFFICE is the product's canonical spelling.
|
describe('ONLYOFFICE fill commands', () => {
|
afterEach(() => vi.unstubAllGlobals());
|
|
it('inspects typed primary and reference ELN controls and ignores other tags', () => {
|
install([
|
control('eln.field.name', 'text', 'A'),
|
control('eln.ref.name.instance_1', 'text', 'A'),
|
control('eln.field.ok', 'checkbox', false),
|
control('other', 'text', 'x'),
|
]);
|
const result = JSON.parse(inspectFieldsCommand());
|
expect(result.fields).toHaveLength(3);
|
expect(result.fields[1]).toMatchObject({ tag: 'eln.ref.name.instance_1', type: 'text', currentValue: 'A' });
|
expect(result.fields[2]).toMatchObject({ tag: 'eln.field.ok', type: 'checkbox', currentValue: false });
|
});
|
|
it('inspects dotted shared field codes for primary and reference controls', () => {
|
install([
|
control('eln.field.inspection.result', 'text', '合格'),
|
control('eln.ref.inspection.result.instance_1', 'text', '合格'),
|
]);
|
|
expect(JSON.parse(inspectFieldsCommand()).fields).toEqual([
|
expect.objectContaining({ tag: 'eln.field.inspection.result', currentValue: '合格' }),
|
expect.objectContaining({ tag: 'eln.ref.inspection.result.instance_1', currentValue: '合格' }),
|
]);
|
});
|
|
it('treats ONLYOFFICE placeholder text as an empty field value', () => {
|
const item = control('eln.field.observation', 'text', '请输入检测观察');
|
item.ToJSON = () => JSON.stringify({ sdtPr: { showingPlcHdr: true } });
|
install([item]);
|
|
expect(JSON.parse(inspectFieldsCommand()).fields).toEqual([
|
expect.objectContaining({ tag: 'eln.field.observation', currentValue: '' }),
|
]);
|
});
|
|
it('inspects fields after ONLYOFFICE serializes the command without module closures', () => {
|
install([control('eln.field.name', 'text', 'A')]);
|
|
expect(JSON.parse(serializeCommand(inspectFieldsCommand)())).toMatchObject({
|
ok: true,
|
fields: [{ tag: 'eln.field.name', type: 'text', currentValue: 'A' }],
|
});
|
});
|
|
it('keeps a datetime control format when inspecting fields', () => {
|
install([control('eln.field.tested_at', 'date', '2026-08-13 09:10:11', 'unlocked', 'yyyy-MM-dd HH:mm:ss')]);
|
|
expect(JSON.parse(inspectFieldsCommand()).fields).toEqual([
|
expect.objectContaining({ dateFormat: 'yyyy-MM-dd HH:mm:ss', tag: 'eln.field.tested_at', type: 'date' }),
|
]);
|
});
|
|
it('preserves Chinese date formats and applies canonical datetime values', () => {
|
const item = control('eln.field.tested_at', 'date', '', 'unlocked', 'yyyy年MM月dd日 HH:mm:ss');
|
install([item], [{ tag: 'eln.field.tested_at', type: 'date', value: '2026-09-05 14:30:25' }]);
|
|
expect(JSON.parse(inspectFieldsCommand()).fields).toEqual([
|
expect.objectContaining({ dateFormat: 'yyyy年MM月dd日 HH:mm:ss', tag: 'eln.field.tested_at', type: 'date' }),
|
]);
|
expect(JSON.parse(applyFieldsCommand()).results).toEqual([{ tag: 'eln.field.tested_at', ok: true }]);
|
});
|
|
it('recognizes a block control from JSON when ONLYOFFICE reports IsBlockLevel as false', () => {
|
const item = control('eln.field.result', 'text', '检验结果');
|
item.ToJSON = () => JSON.stringify({ sdtPr: {}, type: 'blockLvlSdt' });
|
install([item]);
|
|
expect(JSON.parse(inspectFieldsCommand()).fields).toEqual([
|
expect.objectContaining({ tag: 'eln.field.result', type: 'multiline' }),
|
]);
|
});
|
|
it('applies text, checkbox, select and date values', () => {
|
const controls = [
|
control('eln.field.name', 'text', ''),
|
control('eln.field.ok', 'checkbox', false),
|
control('eln.field.result', 'select', '合格'),
|
control('eln.field.date', 'date', '2026-08-12'),
|
];
|
install(controls, [
|
{ tag: 'eln.field.name', type: 'text', value: '样品' },
|
{ tag: 'eln.field.ok', type: 'checkbox', value: true },
|
{ tag: 'eln.field.result', type: 'select', value: 'unqualified' },
|
{ tag: 'eln.field.date', type: 'date', value: '2026-08-13' },
|
]);
|
expect(JSON.parse(applyFieldsCommand()).results).toEqual([
|
{ tag: 'eln.field.name', ok: true },
|
{ tag: 'eln.field.ok', ok: true },
|
{ tag: 'eln.field.result', ok: true },
|
{ tag: 'eln.field.date', ok: true },
|
]);
|
});
|
|
it.each([
|
{ kind: 'text', value: '样品' },
|
{ kind: 'multiline', value: '第一行\n第二行' },
|
{ kind: 'checkbox', value: true },
|
{ kind: 'select', value: 'unqualified' },
|
{ kind: 'date', value: '2026-08-13' },
|
] as const)('填写 $kind 控件后保留原字符格式', ({ kind, value }) => {
|
const textPr = { fontFamily: 'SimSun', fontSize: 14, underline: true };
|
const item = control(`eln.field.${kind}`, kind, '', 'unlocked', 'yyyy-MM-dd', textPr);
|
install([item], [{ tag: `eln.field.${kind}`, type: kind, value }]);
|
|
expect(JSON.parse(applyFieldsCommand()).results).toEqual([{ tag: `eln.field.${kind}`, ok: true }]);
|
expect(item.GetRange().GetTextPr()).toEqual(textPr);
|
});
|
|
it('applies datetime values without dropping the time component', () => {
|
const item = control('eln.field.tested_at', 'date', '2026-08-12 01:02:03', 'unlocked', 'yyyy-MM-dd HH:mm:ss');
|
install([item], [{ tag: 'eln.field.tested_at', type: 'date', value: '2026-08-13 09:10:11' }]);
|
|
expect(JSON.parse(applyFieldsCommand()).results).toEqual([{ tag: 'eln.field.tested_at', ok: true }]);
|
expect(item.GetRange().GetText()).toBe('2026-08-13 09:10:11');
|
});
|
|
it.each([
|
{ kind: 'select', initial: '合格', format: 'yyyy-MM-dd' },
|
{ kind: 'date', initial: '2026-08-13', format: 'yyyy-MM-dd' },
|
{ kind: 'date', initial: '2026-08-13 09:10:11', format: 'yyyy-MM-dd HH:mm:ss' },
|
] as const)('clears $kind ($format) without replacing its definition or lock', ({ kind, initial, format }) => {
|
const tag = 'eln.field.source';
|
const textPr = { fontFamily: 'SimSun', fontSize: 14 };
|
const item = control(tag, kind, initial, 'sdtContentLocked', format, textPr);
|
install([item], [{ tag, type: kind, value: '' }]);
|
const before = JSON.parse(serializeCommand(inspectFieldsCommand)()).fields[0];
|
|
expect(JSON.parse(serializeCommand(applyFieldsCommand)()).results).toEqual([{ tag, ok: true }]);
|
const after = JSON.parse(serializeCommand(inspectFieldsCommand)()).fields[0];
|
expect(after).toEqual({ ...before, currentValue: '' });
|
expect(item.GetLock()).toBe('sdtContentLocked');
|
expect(item.GetRange().GetTextPr()).toEqual(textPr);
|
});
|
|
it('applies fields after ONLYOFFICE serializes the command without module closures', () => {
|
const item = control('eln.field.name', 'text', '');
|
install([item], [{ tag: 'eln.field.name', type: 'text', value: '样品' }]);
|
|
expect(JSON.parse(serializeCommand(applyFieldsCommand)()).results).toEqual([{ tag: 'eln.field.name', ok: true }]);
|
expect(item.GetRange().GetText()).toBe('样品');
|
});
|
|
it('applies synchronized data to a reference control without treating it as an independent value', () => {
|
const item = control('eln.ref.name.instance_1', 'text', 'old');
|
install([item], [{ tag: 'eln.ref.name.instance_1', type: 'text', value: '同步值' }]);
|
|
expect(JSON.parse(applyFieldsCommand()).results).toEqual([{ tag: 'eln.ref.name.instance_1', ok: true }]);
|
expect(item.GetRange().GetText()).toBe('同步值');
|
});
|
|
it('restores a locked reference control after synchronizing it', () => {
|
const item = control('eln.ref.name.instance_1', 'text', 'old', 'contentLocked');
|
install([item], [{ tag: 'eln.ref.name.instance_1', type: 'text', value: '同步值' }]);
|
|
expect(JSON.parse(applyFieldsCommand()).results).toEqual([{ tag: 'eln.ref.name.instance_1', ok: true }]);
|
expect(item.GetRange().GetText()).toBe('同步值');
|
expect(item.GetLock()).toBe('contentLocked');
|
expect(item.SetLock).toHaveBeenNthCalledWith(1, 'unlocked');
|
expect(item.SetLock).toHaveBeenNthCalledWith(2, 'contentLocked');
|
});
|
|
it('temporarily unlocks formula result primary and reference controls while restoring current data', () => {
|
const primary = control('eln.field.formula_result', 'text', 'old-primary', 'sdtContentLocked');
|
const reference = control('eln.ref.formula_result.instance_1', 'text', 'old-reference', 'sdtContentLocked');
|
install([primary, reference], [
|
{ tag: 'eln.field.formula_result', type: 'text', value: '42' },
|
{ tag: 'eln.ref.formula_result.instance_1', type: 'text', value: '42' },
|
]);
|
|
expect(JSON.parse(applyFieldsCommand()).results).toEqual([
|
{ tag: 'eln.field.formula_result', ok: true },
|
{ tag: 'eln.ref.formula_result.instance_1', ok: true },
|
]);
|
for (const item of [primary, reference]) {
|
expect(item.GetRange().GetText()).toBe('42');
|
expect(item.GetLock()).toBe('sdtContentLocked');
|
expect(item.SetLock).toHaveBeenNthCalledWith(1, 'unlocked');
|
expect(item.SetLock).toHaveBeenNthCalledWith(2, 'sdtContentLocked');
|
}
|
});
|
|
it('rejects unknown select values without changing the field', () => {
|
const item = control('eln.field.result', 'select', '合格');
|
install([item], [{ tag: 'eln.field.result', type: 'select', value: 'missing' }]);
|
const result = JSON.parse(applyFieldsCommand()).results[0];
|
expect(result).toMatchObject({ tag: 'eln.field.result', ok: false, error: '下拉值不在文档选项中' });
|
expect(item.GetRange().GetText()).toBe('合格');
|
});
|
|
it('writes multiline values as one paragraph per line', () => {
|
const item = control('eln.field.notes', 'multiline', '');
|
const { createParagraph } = install([item], [{ tag: 'eln.field.notes', type: 'multiline', value: '第一行\n第二行' }]);
|
|
expect(JSON.parse(applyFieldsCommand()).results).toEqual([{ tag: 'eln.field.notes', ok: true }]);
|
expect(createParagraph).toHaveBeenCalledTimes(2);
|
expect(item.GetRange().GetText()).toBe('第一行\r\n第二行\r\n');
|
});
|
|
it('continues applying later fields after one field fails', () => {
|
const select = control('eln.field.result', 'select', '合格');
|
const text = control('eln.field.name', 'text', '');
|
install(
|
[select, text],
|
[
|
{ tag: 'eln.field.result', type: 'select', value: 'missing' },
|
{ tag: 'eln.field.name', type: 'text', value: '样品' },
|
],
|
);
|
|
expect(JSON.parse(applyFieldsCommand()).results).toEqual([
|
{ tag: 'eln.field.result', ok: false, error: '下拉值不在文档选项中' },
|
{ tag: 'eln.field.name', ok: true },
|
]);
|
expect(text.GetRange().GetText()).toBe('样品');
|
});
|
|
it('clones the prototype row at the table end and rewrites every repeat tag', () => {
|
const { controls, rows, table } = installRepeatDocument();
|
|
const result = JSON.parse(serializeCommand(cloneRepeatRowCommand)());
|
|
expect(result).toEqual({
|
ok: true,
|
tags: ['eln.repeat.results.row-1.value', 'eln.repeat.results.row-1.unit'],
|
});
|
expect(table.AddRow).toHaveBeenCalledTimes(1);
|
expect(table.AddRow).toHaveBeenCalledWith(expect.anything(), false);
|
expect(rows).toHaveLength(2);
|
expect(rows[1].GetCell(0).Clear).toHaveBeenCalledTimes(1);
|
expect(rows[1].GetCell(1).Clear).not.toHaveBeenCalled();
|
expect(rows[1].GetCell(0).Cell.SetVMerge).toHaveBeenCalledWith(undefined);
|
expect(rows[1].GetCell(1).Cell.SetVMerge).toHaveBeenCalledWith(undefined);
|
expect(controls.map((item) => item.GetTag())).toEqual([
|
'eln.repeat.results.prototype.value',
|
'eln.repeat.results.prototype.unit',
|
'eln.repeat.results.row-1.value',
|
'eln.repeat.results.row-1.unit',
|
]);
|
|
expect(JSON.parse(cloneRepeatRowCommand())).toMatchObject({ ok: true });
|
expect(table.AddRow).toHaveBeenCalledTimes(1);
|
});
|
|
it('removes the appended row when tag rewriting fails', () => {
|
const { controls, rows } = installRepeatDocument({ failTag: 'eln.repeat.results.row-1.unit' });
|
|
expect(JSON.parse(cloneRepeatRowCommand())).toMatchObject({ ok: false, error: '重写重复行 Tag 失败' });
|
expect(rows).toHaveLength(1);
|
expect(controls.map((item) => item.GetTag())).toEqual([
|
'eln.repeat.results.prototype.value',
|
'eln.repeat.results.prototype.unit',
|
]);
|
});
|
|
it('removes exactly one complete runtime row by stable group and row identity', () => {
|
const { controls, rows } = installRepeatDocument();
|
expect(JSON.parse(cloneRepeatRowCommand())).toMatchObject({ ok: true });
|
(globalThis as any).Asc.scope.elnRecordFillRepeatRow = { groupCode: 'results', rowId: 'row-1' };
|
|
const result = JSON.parse(serializeCommand(removeRepeatRowCommand)());
|
|
expect(result).toEqual({
|
ok: true,
|
tags: ['eln.repeat.results.row-1.value', 'eln.repeat.results.row-1.unit'],
|
});
|
expect(rows).toHaveLength(1);
|
expect(controls.map((item) => item.GetTag())).toEqual([
|
'eln.repeat.results.prototype.value',
|
'eln.repeat.results.prototype.unit',
|
]);
|
});
|
|
it('recreates a removed row before the next logical runtime row', () => {
|
const { controls, rows } = installRepeatDocument();
|
expect(JSON.parse(cloneRepeatRowCommand())).toMatchObject({ ok: true });
|
(globalThis as any).Asc.scope.elnRecordFillRepeatRow = { groupCode: 'results', rowId: 'row-2' };
|
expect(JSON.parse(cloneRepeatRowCommand())).toMatchObject({ ok: true });
|
(globalThis as any).Asc.scope.elnRecordFillRepeatRow = { groupCode: 'results', rowId: 'row-1' };
|
expect(JSON.parse(removeRepeatRowCommand())).toMatchObject({ ok: true });
|
(globalThis as any).Asc.scope.elnRecordFillRepeatRow = {
|
beforeRowId: 'row-2', groupCode: 'results', rowId: 'row-1',
|
};
|
|
expect(JSON.parse(serializeCommand(cloneRepeatRowCommand)())).toMatchObject({ ok: true });
|
expect(rows).toHaveLength(3);
|
expect(controls.filter((item) => item.GetTag().startsWith('eln.repeat.results.row-1.'))[0]
|
.GetParentTableCell().GetParentRow()).toBe(rows[1]);
|
expect(controls.filter((item) => item.GetTag().startsWith('eln.repeat.results.row-2.'))[0]
|
.GetParentTableCell().GetParentRow()).toBe(rows[2]);
|
});
|
});
|