import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
activateBinding,
|
activateFormulaBinding,
|
cancelBinding,
|
cancelFormulaBinding,
|
changeCheckboxGroup,
|
changeCheckboxGroupRule,
|
checkBindingRemoval,
|
checkBindingVersionUpgrade,
|
checkReferencePromotion,
|
confirmBindingRemoval,
|
confirmBindingVersionUpgrade,
|
confirmReferencePromotion,
|
confirmRepair,
|
confirmVersionCopy,
|
confirmFormulaBindingRemoval,
|
confirmFormulaBindingUpgrade,
|
confirmFormulaVersionCopy,
|
fetchCrossTemplateScalarCandidates,
|
fetchCrossTemplateSourceTemplates,
|
fetchFormulaMappings,
|
fetchAnnotationContext,
|
fetchRepairContext,
|
reservePrimaryBinding,
|
reserveCrossTemplateScalarBinding,
|
reservePrivateField,
|
reserveReferenceBinding,
|
reserveFormulaBinding,
|
suggestCode,
|
TemplateAnnotationApiError,
|
} from '../../src/api/template-annotation';
|
import { toTemplateFieldOptions, type TemplateAnnotationContext } from '../../src/domain/types';
|
|
const client = { apiBaseUrl: 'https://api.example.com/', ticket: 'opaque' };
|
|
function actionResult(data: unknown, init?: ResponseInit): Response {
|
return new Response(JSON.stringify({ code: init?.status ?? 200, msg: 'success', data }), {
|
headers: { 'Content-Type': 'application/json' },
|
...init,
|
});
|
}
|
|
function successfulFetch(data: unknown = null) {
|
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(actionResult(data)));
|
vi.stubGlobal('fetch', fetchMock);
|
return fetchMock;
|
}
|
|
afterEach(() => {
|
vi.unstubAllGlobals();
|
});
|
|
describe('template annotation Ticket API', () => {
|
it('requests a code suggestion with the Ticket header and returns the generated code', async () => {
|
const fetchMock = successfulFetch({ code: 'jianyan_jieguo' });
|
|
await expect(suggestCode(client, { text: '检验结果' })).resolves.toBe('jianyan_jieguo');
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
'https://api.example.com/api/eln/onlyoffice/templateAnnotation/codeSuggestions',
|
expect.objectContaining({
|
body: JSON.stringify({ text: '检验结果' }),
|
headers: { Authorization: 'Ticket opaque', 'Content-Type': 'application/json' },
|
method: 'POST',
|
}),
|
);
|
});
|
|
it('rejects an invalid code suggestion response', async () => {
|
successfulFetch({ code: '' });
|
|
await expect(suggestCode(client, { text: '检验结果' })).rejects.toMatchObject({
|
message: '编码生成接口响应无效',
|
status: 200,
|
});
|
});
|
|
it('loads context with the Ticket header and never places the ticket in the URL', async () => {
|
const context = { templateId: 'template-1', templateCode: 'TPL-001', fields: [], bindings: [] };
|
const fetchMock = successfulFetch(context);
|
|
await expect(fetchAnnotationContext(client)).resolves.toEqual(context);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
'https://api.example.com/api/eln/onlyoffice/templateAnnotation/context',
|
expect.objectContaining({ method: 'GET', headers: { Authorization: 'Ticket opaque' } }),
|
);
|
expect(fetchMock.mock.calls[0]?.[0]).not.toContain('opaque');
|
});
|
|
it.each([
|
[401, '标注 Ticket 已过期'],
|
[403, '无模板标注权限'],
|
[409, '模板状态已变化'],
|
[422, '字段版本无效'],
|
])('maps HTTP %i and the backend message to TemplateAnnotationApiError', async (status, msg) => {
|
vi.stubGlobal(
|
'fetch',
|
vi.fn().mockResolvedValue(
|
new Response(JSON.stringify({ code: status, msg, data: null }), {
|
status,
|
statusText: 'Failure',
|
headers: { 'Content-Type': 'application/json' },
|
}),
|
),
|
);
|
|
const error = await fetchAnnotationContext(client).catch((reason: unknown) => reason);
|
|
expect(error).toBeInstanceOf(TemplateAnnotationApiError);
|
expect(error).toMatchObject({ status, message: msg });
|
});
|
|
it('passes one AbortSignal through the shared request client', async () => {
|
const fetchMock = successfulFetch({ templateId: 'template-1', templateCode: 'TPL-001', fields: [], bindings: [] });
|
const controller = new AbortController();
|
|
await fetchAnnotationContext({ ...client, signal: controller.signal });
|
|
expect(fetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ signal: controller.signal }));
|
});
|
|
it('读取跨模板文本候选并以来源模板和字段版本预留普通主控件', async () => {
|
const fetchMock = successfulFetch({ id: 'cross-binding' });
|
const reserve = {
|
operationCode: 'cross-reserve',
|
repeatRequest: false as const,
|
sourceFieldId: 'source-field',
|
sourceFieldVersionId: 'source-version',
|
sourceTemplateCode: 'SOURCE-TPL',
|
sourceTemplateId: 'source-template',
|
stepId: 'step-1',
|
};
|
|
await fetchCrossTemplateSourceTemplates(client);
|
await fetchCrossTemplateScalarCandidates(client, 'source/template');
|
await reserveCrossTemplateScalarBinding(client, reserve);
|
|
expect(fetchMock.mock.calls.map(([url, init]) => [url, init?.method, init?.body])).toEqual([
|
['https://api.example.com/api/eln/onlyoffice/templateAnnotation/crossTemplateSourceTemplates', 'GET', undefined],
|
['https://api.example.com/api/eln/onlyoffice/templateAnnotation/crossTemplateTextCandidates?sourceTemplateId=source%2Ftemplate', 'GET', undefined],
|
[
|
'https://api.example.com/api/eln/onlyoffice/templateAnnotation/crossTemplateTextBindings/reserve',
|
'POST',
|
JSON.stringify(reserve),
|
],
|
]);
|
for (const [, init] of fetchMock.mock.calls) {
|
expect(init?.headers).toMatchObject({ Authorization: 'Ticket opaque' });
|
}
|
});
|
|
it('accepts a successful void ActionResult when Jackson omits null data', async () => {
|
vi.stubGlobal(
|
'fetch',
|
vi.fn().mockResolvedValue(
|
new Response(JSON.stringify({ code: 200, msg: 'success' }), {
|
status: 200,
|
headers: { 'Content-Type': 'application/json' },
|
}),
|
),
|
);
|
|
await expect(activateBinding(client, 'binding-1', { operationCode: 'op-activate' })).resolves.toBeUndefined();
|
});
|
|
it('flattens backend field identities and versions into complete immutable selection options', () => {
|
const context: TemplateAnnotationContext = {
|
templateId: 'template-1',
|
templateCode: 'TPL-001',
|
bindings: [],
|
stepConfiguration: {
|
singleStep: true,
|
steps: [{ id: 'step-1', name: '实验步骤', order: 1 }],
|
},
|
fields: [
|
{
|
id: 'field-1',
|
ziduanBianma: 'jianyan_yiju',
|
zuoyongFanwei: 'GLOBAL',
|
mubanBianhao: null,
|
dangqianZiduanBanbenId: 'version-current',
|
bizEnabled: 'enabled',
|
beizhu: null,
|
versions: [
|
{
|
id: 'version-current',
|
ziduanDingyiId: 'field-1',
|
banbenHao: 2,
|
ziduanMingcheng: '检验依据',
|
shujuLeixing: 'TEXT',
|
danwei: null,
|
shifouBitian: 1,
|
piciMoshi: 'RECORD_PRIVATE',
|
binding: { read: { source: 'LIMS_TASK', property: 'test.basis' }, write: { target: 'ELN_RECORD' } },
|
kongjianPeizhi: { placeholder: '请输入检验依据' },
|
recommendedCheckboxGroupCode: 'inspection_result',
|
recommendedCheckboxMinSelected: 1,
|
recommendedCheckboxMaxSelected: 2,
|
bizStatus: 'PUBLISHED',
|
zuofeiYuanyin: null,
|
chuangjianShijian: null,
|
gengxinShijian: null,
|
},
|
],
|
},
|
],
|
};
|
|
expect(toTemplateFieldOptions(context.fields)).toEqual([
|
{
|
alias: '检验依据',
|
batchMode: 'RECORD_PRIVATE',
|
controlConfig: { placeholder: '请输入检验依据' },
|
fieldId: 'field-1',
|
fieldCode: 'jianyan_yiju',
|
fieldVersionId: 'version-current',
|
scope: 'GLOBAL',
|
label: '检验依据',
|
required: true,
|
recommendedCheckboxGroupCode: 'inspection_result',
|
recommendedCheckboxMinSelected: 1,
|
recommendedCheckboxMaxSelected: 2,
|
type: 'TEXT',
|
source: 'LIMS_TASK',
|
versionStatus: 'PUBLISHED',
|
writeTarget: 'ELN_RECORD',
|
isCurrent: true,
|
},
|
]);
|
});
|
|
it('covers every Task 9 endpoint and sends authoritative IDs instead of fieldCode', async () => {
|
const fetchMock = successfulFetch({ id: 'binding-1' });
|
const snapshots = [{ bindingId: 'binding-1', controlTag: 'eln.field.server_code' }];
|
const confirmation = { operationCode: 'op-confirm', expectedVersions: { 'binding-1': 1 }, controls: snapshots, observedControlTags: ['eln.field.server_code'] };
|
|
await reservePrivateField(client, {
|
checkboxGroupCode: 'inspection_result',
|
operationCode: 'op-private',
|
ziduanMingcheng: '观察结果',
|
shujuLeixing: 'CHECKBOX',
|
stepId: 'step-1',
|
});
|
await reservePrimaryBinding(client, {
|
checkboxGroupCode: 'inspection_result',
|
fieldId: 'field-1',
|
fieldVersionId: 'version-1',
|
operationCode: 'op-primary',
|
repeatRequest: false,
|
stepId: 'step-1',
|
});
|
await changeCheckboxGroup(client, 'binding/1', {
|
checkboxGroupCode: null,
|
expectedVersion: 2,
|
operationCode: 'op-checkbox-group',
|
});
|
await changeCheckboxGroupRule(client, 'inspection/result', {
|
expectedVersion: 3,
|
maxSelected: 4,
|
minSelected: 1,
|
operationCode: 'op-checkbox-group-rule',
|
});
|
await reserveReferenceBinding(client, 'binding/1', { operationCode: 'op-reference' });
|
await activateBinding(client, 'binding-1', { operationCode: 'op-activate' });
|
await cancelBinding(client, 'binding-1', { operationCode: 'op-cancel', reason: '插入失败' });
|
await fetchRepairContext(client, 'binding-1');
|
await confirmRepair(client, 'binding-1', confirmation);
|
await checkBindingVersionUpgrade(client, 'binding-1', { targetFieldVersionId: 'version-2' });
|
await confirmBindingVersionUpgrade(client, 'binding-1', { ...confirmation, targetFieldVersionId: 'version-2' });
|
await checkBindingRemoval(client, 'binding-1');
|
await confirmBindingRemoval(client, 'binding-1', confirmation);
|
await checkReferencePromotion(client, 'binding-1');
|
await confirmReferencePromotion(client, 'binding-1', confirmation);
|
await confirmVersionCopy(client, { upgradeOperationCode: 'op-copy', expectedVersions: { 'binding-1': 1 }, controls: snapshots });
|
|
const calls = fetchMock.mock.calls.map(([url, init]) => [url, init?.method, init?.body]);
|
expect(calls).toEqual([
|
[
|
'https://api.example.com/api/eln/onlyoffice/templateAnnotation/privateFields/reserve',
|
'POST',
|
JSON.stringify({
|
checkboxGroupCode: 'inspection_result',
|
operationCode: 'op-private',
|
ziduanMingcheng: '观察结果',
|
shujuLeixing: 'CHECKBOX',
|
stepId: 'step-1',
|
}),
|
],
|
[
|
'https://api.example.com/api/eln/onlyoffice/templateAnnotation/bindings/reserve',
|
'POST',
|
JSON.stringify({
|
checkboxGroupCode: 'inspection_result',
|
fieldId: 'field-1',
|
fieldVersionId: 'version-1',
|
operationCode: 'op-primary',
|
repeatRequest: false,
|
stepId: 'step-1',
|
}),
|
],
|
[
|
'https://api.example.com/api/eln/onlyoffice/templateAnnotation/bindings/binding%2F1/checkboxGroup',
|
'POST',
|
JSON.stringify({ checkboxGroupCode: null, expectedVersion: 2, operationCode: 'op-checkbox-group' }),
|
],
|
[
|
'https://api.example.com/api/eln/onlyoffice/templateAnnotation/checkboxGroups/inspection%2Fresult',
|
'POST',
|
JSON.stringify({ expectedVersion: 3, maxSelected: 4, minSelected: 1, operationCode: 'op-checkbox-group-rule' }),
|
],
|
[
|
'https://api.example.com/api/eln/onlyoffice/templateAnnotation/bindings/binding%2F1/references/reserve',
|
'POST',
|
JSON.stringify({ operationCode: 'op-reference' }),
|
],
|
['https://api.example.com/api/eln/onlyoffice/templateAnnotation/bindings/binding-1/activate', 'POST', JSON.stringify({ operationCode: 'op-activate' })],
|
[
|
'https://api.example.com/api/eln/onlyoffice/templateAnnotation/bindings/binding-1/cancel',
|
'POST',
|
JSON.stringify({ operationCode: 'op-cancel', reason: '插入失败' }),
|
],
|
['https://api.example.com/api/eln/onlyoffice/templateAnnotation/bindings/binding-1/repairContext', 'GET', undefined],
|
['https://api.example.com/api/eln/onlyoffice/templateAnnotation/bindings/binding-1/repairConfirm', 'POST', JSON.stringify(confirmation)],
|
[
|
'https://api.example.com/api/eln/onlyoffice/templateAnnotation/bindings/binding-1/versionUpgradeCheck',
|
'POST',
|
JSON.stringify({ targetFieldVersionId: 'version-2' }),
|
],
|
[
|
'https://api.example.com/api/eln/onlyoffice/templateAnnotation/bindings/binding-1/versionUpgradeConfirm',
|
'POST',
|
JSON.stringify({ ...confirmation, targetFieldVersionId: 'version-2' }),
|
],
|
['https://api.example.com/api/eln/onlyoffice/templateAnnotation/bindings/binding-1/removeCheck', 'POST', undefined],
|
['https://api.example.com/api/eln/onlyoffice/templateAnnotation/bindings/binding-1/removeConfirm', 'POST', JSON.stringify(confirmation)],
|
['https://api.example.com/api/eln/onlyoffice/templateAnnotation/bindings/binding-1/promoteCheck', 'POST', undefined],
|
['https://api.example.com/api/eln/onlyoffice/templateAnnotation/bindings/binding-1/promoteConfirm', 'POST', JSON.stringify(confirmation)],
|
[
|
'https://api.example.com/api/eln/onlyoffice/templateAnnotation/versionCopy/confirm',
|
'POST',
|
JSON.stringify({ upgradeOperationCode: 'op-copy', expectedVersions: { 'binding-1': 1 }, controls: snapshots }),
|
],
|
]);
|
expect(JSON.parse(fetchMock.mock.calls[1]?.[1]?.body as string)).not.toHaveProperty('fieldCode');
|
for (const [, init] of fetchMock.mock.calls) {
|
expect(init?.headers).toMatchObject({ Authorization: 'Ticket opaque' });
|
}
|
});
|
|
it('从独立公式上下文端点读取候选和映射,不占用兼容列表端点', async () => {
|
const fetchMock = successfulFetch({ id: 'formula-binding' });
|
const request = {
|
formulaVersionId: 'formula-version', parameterBindings: { amount: 'input-binding' }, resultBindingId: 'result-binding', operationCode: 'formula-op',
|
jieguoSuoKuaizhao: [{ bindingId: 'result-binding', controlRole: 'PRIMARY', controlTag: 'eln.field.result', lock: 'unlocked' }],
|
};
|
const controls = [{ bindingId: 'result-binding', controlRole: 'PRIMARY', controlTag: 'eln.field.result', lock: 'sdtContentLocked' }];
|
|
await fetchFormulaMappings(client);
|
await reserveFormulaBinding(client, request);
|
await activateFormulaBinding(client, 'formula-binding', { controls, expectedVersion: 1, operationCode: 'formula-op' });
|
await cancelFormulaBinding(client, 'formula-binding', { expectedVersion: 1, operationCode: 'formula-op', reason: 'failed' });
|
await confirmFormulaBindingUpgrade(client, 'formula-binding', { controls, expectedVersion: 1, operationCode: 'upgrade-op', target: request });
|
await confirmFormulaBindingRemoval(client, 'formula-binding', { controls: [{ ...controls[0], lock: '' }], expectedVersion: 1, operationCode: 'remove-op' });
|
await confirmFormulaVersionCopy(client, { controls, expectedVersions: { 'formula-binding': 1 }, upgradeOperationCode: 'copy-op' });
|
|
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
'https://api.example.com/api/eln/onlyoffice/templateAnnotation/formulaContext',
|
'https://api.example.com/api/eln/onlyoffice/templateAnnotation/formulaBindings/reserve',
|
'https://api.example.com/api/eln/onlyoffice/templateAnnotation/formulaBindings/formula-binding/activate',
|
'https://api.example.com/api/eln/onlyoffice/templateAnnotation/formulaBindings/formula-binding/cancel',
|
'https://api.example.com/api/eln/onlyoffice/templateAnnotation/formulaBindings/formula-binding/upgradeConfirm',
|
'https://api.example.com/api/eln/onlyoffice/templateAnnotation/formulaBindings/formula-binding/removeConfirm',
|
'https://api.example.com/api/eln/onlyoffice/templateAnnotation/formulaBindings/versionCopy/confirm',
|
]);
|
const reservePayload = JSON.parse(fetchMock.mock.calls[1]?.[1]?.body as string);
|
expect(reservePayload).toEqual(request);
|
expect(reservePayload).not.toHaveProperty('expression');
|
expect(reservePayload).not.toHaveProperty('formulaCode');
|
expect(reservePayload).not.toHaveProperty('result');
|
});
|
});
|