import { createPinia, setActivePinia } from 'pinia';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { TemplateAnnotationClient } from '../../src/api/template-annotation';
|
import { TemplateAnnotationApiError } from '../../src/api/template-annotation';
|
import { useTemplateAnnotator } from '../../src/composables/useTemplateAnnotator';
|
import type {
|
CrossTemplateScalarCandidate,
|
CrossTemplateSourceTemplate,
|
TemplateAnnotationContext,
|
TemplateBinding,
|
TemplateChangeCheck,
|
TemplateFieldOption,
|
TemplateRepairContext,
|
} from '../../src/domain/types';
|
import type { TemplateFormulaBinding } from '../../src/domain/formula-mapping';
|
import type { OnlyOfficeBridge } from '../../src/onlyoffice/bridge';
|
import type { OfficeContentControl } from '../../src/onlyoffice/types';
|
|
function binding(overrides: Partial<TemplateBinding> = {}): TemplateBinding {
|
return {
|
controlAlias: '结果',
|
controlConfig: { placeholder: '请输入' },
|
controlRole: 'PRIMARY',
|
controlTag: 'eln.field.result',
|
controlType: 'TEXT',
|
fieldCode: 'result',
|
fieldId: 'field',
|
fieldVersionId: 'version',
|
id: 'binding',
|
primaryBindingId: null,
|
status: 'PENDING',
|
templateId: 'template',
|
version: 0,
|
...overrides,
|
};
|
}
|
|
const option: TemplateFieldOption = {
|
alias: '结果',
|
controlConfig: { placeholder: '请输入' },
|
fieldCode: 'result',
|
fieldId: 'field',
|
fieldVersionId: 'version',
|
isCurrent: true,
|
label: '结果',
|
required: false,
|
scope: 'GLOBAL',
|
source: 'NONE',
|
type: 'TEXT',
|
versionStatus: 'PUBLISHED',
|
};
|
const crossTemplateCandidate: CrossTemplateScalarCandidate = {
|
sourceFieldCode: 'result',
|
sourceFieldId: 'source-field',
|
sourceFieldName: '来源结果',
|
sourceFieldType: 'TEXT',
|
sourceFieldVersionId: 'source-version',
|
sourceTemplateCode: 'SOURCE-TPL',
|
sourceTemplateId: 'source-template',
|
};
|
const validOptions = {
|
apiBaseUrl: 'http://localhost:30000',
|
documentId: 'file-1',
|
bizDataId: 'record-1',
|
bizModule: 'eln.template',
|
bizScene: 'eln.template.annotate',
|
pluginVersion: '0.1.0',
|
scope: 'onlyoffice:eln-template-annotate',
|
ticket: 'ticket',
|
canAnnotate: true,
|
};
|
|
function annotationContext(overrides: Partial<TemplateAnnotationContext> = {}): TemplateAnnotationContext {
|
return {
|
bindings: [],
|
fields: [],
|
stepConfiguration: {
|
singleStep: true,
|
steps: [{ id: 'step-1', name: '实验步骤', order: 1 }],
|
},
|
templateCode: 'TMP',
|
templateId: 'template',
|
...overrides,
|
} as TemplateAnnotationContext;
|
}
|
|
function check(overrides: Partial<TemplateChangeCheck> = {}): TemplateChangeCheck {
|
return { controls: [], dependencies: [], expectedVersions: { binding: 2 }, operationCode: 'check-op', ...overrides };
|
}
|
|
function formulaBinding(overrides: Partial<TemplateFormulaBinding> = {}): TemplateFormulaBinding {
|
return {
|
formulaVersionId: 'formula-version',
|
id: 'formula-binding',
|
jieguoSuoKuaizhao: [{ bindingId: 'result-binding', controlRole: 'PRIMARY', controlTag: 'eln.field.result', lock: 'unlocked' }],
|
operationCode: 'formula-reserve',
|
parameterBindings: { amount: 'input-binding' },
|
resultBindingId: 'result-binding',
|
status: 'PENDING',
|
version: 1,
|
...overrides,
|
};
|
}
|
|
function createFixture() {
|
let ready: (() => void) | undefined;
|
const order: string[] = [];
|
const bridge: OnlyOfficeBridge = {
|
applyControlPlan: vi.fn(async () => {
|
order.push('document');
|
}),
|
bindLifecycle: vi.fn((callbacks) => {
|
ready = callbacks.onReady;
|
return vi.fn();
|
}),
|
close: vi.fn(),
|
deleteControl: vi.fn(),
|
disableTrackRevisions: vi.fn(async () => {
|
order.push('review');
|
}),
|
getRuntimeOptions: vi.fn(() => validOptions),
|
insertBinding: vi.fn(async () => {
|
order.push('insert');
|
}),
|
insertReadOnlyBinding: vi.fn(async () => {
|
order.push('insert-cross-template');
|
}),
|
insertField: vi.fn(),
|
insertReference: vi.fn(async () => {
|
order.push('insert-reference');
|
}),
|
isFormulaResultLocked: vi.fn(async () => false),
|
listControls: vi.fn(async () => {
|
order.push('list');
|
return [];
|
}),
|
lockFormulaResult: vi.fn(async () => ({ primaryTag: 'eln.field.result', locks: [{ tag: 'eln.field.result', lock: 'unlocked' }] })),
|
readSelectedText: vi.fn(async () => ''),
|
restoreFormulaResultLocks: vi.fn(async () => undefined),
|
unlockFormulaResult: vi.fn(async () => undefined),
|
selectControl: vi.fn(),
|
snapshotControls: vi.fn((bindings: TemplateBinding[], controls: OfficeContentControl[]) => {
|
const bindingIds = new Set(bindings.map((item) => item.id));
|
return controls.flatMap((control) =>
|
control.BindingId && control.Tag && bindingIds.has(control.BindingId) ? [{ bindingId: control.BindingId, controlTag: control.Tag }] : [],
|
);
|
}),
|
};
|
const client: TemplateAnnotationClient = {
|
activateBinding: vi.fn(async () => {
|
order.push('activate');
|
}),
|
cancelBinding: vi.fn(async () => {
|
order.push('cancel');
|
}),
|
changeCheckboxGroup: vi.fn(async (_context, _bindingId, form) =>
|
binding({ checkboxGroupCode: form.checkboxGroupCode, status: 'ACTIVE', version: form.expectedVersion + 1 }),
|
),
|
changeCheckboxGroupRule: vi.fn(async (_context, groupCode, form) => ({
|
groupCode,
|
maxSelected: form.maxSelected,
|
minSelected: form.minSelected,
|
version: form.expectedVersion + 1,
|
})),
|
checkBindingRemoval: vi.fn(async () => check()),
|
checkBindingVersionUpgrade: vi.fn(async () => check()),
|
checkReferencePromotion: vi.fn(async () => check()),
|
confirmBindingRemoval: vi.fn(async () => {
|
order.push('confirm-remove');
|
}),
|
confirmBindingVersionUpgrade: vi.fn(async () => {
|
order.push('confirm-upgrade');
|
}),
|
confirmReferencePromotion: vi.fn(async () => {
|
order.push('confirm-promote');
|
}),
|
confirmRepair: vi.fn(async () => {
|
order.push('confirm-repair');
|
}),
|
confirmVersionCopy: vi.fn(async () => {
|
order.push('confirm-copy');
|
}),
|
fetchAnnotationContext: vi.fn(async () => {
|
order.push('context');
|
return annotationContext();
|
}),
|
fetchCrossTemplateScalarCandidates: vi.fn(async () => []),
|
fetchCrossTemplateSourceTemplates: vi.fn(async () => []),
|
fetchRepairContext: vi.fn(async () => ({ ...check(), binding: binding({ status: 'ACTIVE', version: 2 }) })),
|
reservePrimaryBinding: vi.fn(async () => {
|
order.push('reserve');
|
return binding();
|
}),
|
reserveCrossTemplateScalarBinding: vi.fn(async () => {
|
order.push('reserve-cross-template');
|
return binding({ id: 'cross-binding', readOnly: true, valueSource: 'CROSS_TEMPLATE' });
|
}),
|
reservePrivateField: vi.fn(async () => {
|
order.push('reserve-private');
|
return binding({ fieldId: 'private' });
|
}),
|
reserveReferenceBinding: vi.fn(async () => {
|
order.push('reserve-reference');
|
return binding({ controlRole: 'REFERENCE', controlTag: 'eln.ref.result.abc', id: 'reference', primaryBindingId: 'binding' });
|
}),
|
activateFormulaBinding: vi.fn(async () => {
|
order.push('activate-formula');
|
}),
|
cancelFormulaBinding: vi.fn(async () => {
|
order.push('cancel-formula');
|
}),
|
checkFormulaBindingRemoval: vi.fn(async () => ({
|
downstreamFormulaBindingIds: [],
|
expectedVersions: { 'formula-binding': 1 },
|
operationCode: 'remove-formula',
|
})),
|
checkFormulaBindingUpgrade: vi.fn(async () => ({
|
downstreamFormulaBindingIds: [],
|
expectedVersions: { 'formula-binding': 1 },
|
operationCode: 'upgrade-formula',
|
})),
|
confirmFormulaBindingRemoval: vi.fn(async () => {
|
order.push('confirm-remove-formula');
|
}),
|
confirmFormulaBindingUpgrade: vi.fn(async () => {
|
order.push('confirm-upgrade-formula');
|
}),
|
confirmFormulaVersionCopy: vi.fn(async () => {
|
order.push('confirm-copy-formula');
|
}),
|
fetchFormulaMappings: vi.fn(async () => ({ bindings: [], versions: [] })),
|
reserveFormulaBinding: vi.fn(async () => {
|
order.push('reserve-formula');
|
return formulaBinding();
|
}),
|
suggestCode: vi.fn(async () => 'jianyan_jieguo'),
|
};
|
return { bridge, client, order, ready: () => ready?.() };
|
}
|
|
describe('useTemplateAnnotator binding workflow', () => {
|
beforeEach(() => setActivePinia(createPinia()));
|
|
it('使用当前插件 Ticket 上下文请求编码建议', async () => {
|
const fixture = createFixture();
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await expect(api.suggestCode('检验结果')).resolves.toBe('jianyan_jieguo');
|
|
expect(fixture.client.suggestCode).toHaveBeenCalledWith(
|
expect.objectContaining({ apiBaseUrl: validOptions.apiBaseUrl, ticket: validOptions.ticket }),
|
{ text: '检验结果' },
|
);
|
});
|
|
it('公式 reserve 后锁定结果控件并确认 activate,保存请求不含表达式或计算结果', async () => {
|
const fixture = createFixture();
|
const result = binding({ id: 'result-binding', status: 'ACTIVE' });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [result] }));
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await api.reserveFormulaBinding({ formulaVersionId: 'formula-version', parameterBindings: { amount: 'input-binding' }, resultBindingId: result.id });
|
|
expect(fixture.client.reserveFormulaBinding).toHaveBeenCalledWith(expect.anything(), {
|
formulaVersionId: 'formula-version',
|
parameterBindings: { amount: 'input-binding' },
|
resultBindingId: result.id,
|
operationCode: expect.any(String),
|
jieguoSuoKuaizhao: [{ bindingId: result.id, controlRole: 'PRIMARY', controlTag: result.controlTag, lock: 'unlocked' }],
|
});
|
expect(fixture.bridge.lockFormulaResult).toHaveBeenCalledWith('eln.field.result');
|
expect(fixture.client.activateFormulaBinding).toHaveBeenCalledWith(expect.anything(), 'formula-binding', {
|
controls: [{ bindingId: result.id, controlRole: 'PRIMARY', controlTag: result.controlTag, lock: 'sdtContentLocked' }],
|
expectedVersion: 1,
|
operationCode: 'formula-reserve',
|
});
|
});
|
|
it('公式 activate 失败时先恢复锁再 cancel PENDING', async () => {
|
const fixture = createFixture();
|
const result = binding({ id: 'result-binding', status: 'ACTIVE' });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [result] }));
|
vi.mocked(fixture.client.activateFormulaBinding).mockRejectedValue(new Error('activate failed'));
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await expect(api.reserveFormulaBinding({ formulaVersionId: 'formula-version', parameterBindings: {}, resultBindingId: result.id })).rejects.toThrow(
|
'activate failed',
|
);
|
|
expect(fixture.bridge.restoreFormulaResultLocks).toHaveBeenCalledBefore(fixture.client.cancelFormulaBinding as ReturnType<typeof vi.fn>);
|
expect(fixture.client.cancelFormulaBinding).toHaveBeenCalledOnce();
|
});
|
|
it('公式 activate 返回过期 Ticket 后不恢复控件或发送 cancel', async () => {
|
const fixture = createFixture();
|
const result = binding({ id: 'result-binding', status: 'ACTIVE' });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [result] }));
|
vi.mocked(fixture.client.activateFormulaBinding).mockRejectedValue(new TemplateAnnotationApiError(401, 'Ticket 已过期'));
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await expect(api.reserveFormulaBinding({ formulaVersionId: 'formula-version', parameterBindings: {}, resultBindingId: result.id })).rejects.toThrow(
|
'Ticket 已过期',
|
);
|
|
expect(fixture.bridge.restoreFormulaResultLocks).not.toHaveBeenCalled();
|
expect(fixture.client.cancelFormulaBinding).not.toHaveBeenCalled();
|
});
|
|
it('启动时对已完整锁定的公式 PENDING 使用原操作码 activate,不重复 reserve', async () => {
|
const fixture = createFixture();
|
const result = binding({ id: 'result-binding', status: 'ACTIVE' });
|
const pending = formulaBinding();
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [result] }));
|
vi.mocked(fixture.client.fetchFormulaMappings).mockResolvedValue({ bindings: [pending], versions: [] });
|
vi.mocked(fixture.bridge.isFormulaResultLocked).mockResolvedValue(true);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
expect(fixture.client.activateFormulaBinding).toHaveBeenCalledWith(
|
expect.anything(),
|
pending.id,
|
expect.objectContaining({ operationCode: pending.operationCode }),
|
);
|
expect(fixture.client.reserveFormulaBinding).not.toHaveBeenCalled();
|
expect(api.formulaBindings[0]?.status).toBe('ACTIVE');
|
});
|
|
it('启动时对锁不完整的公式 PENDING 先恢复服务端快照再 cancel', async () => {
|
const fixture = createFixture();
|
const result = binding({ id: 'result-binding', status: 'ACTIVE' });
|
const pending = formulaBinding();
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [result] }));
|
vi.mocked(fixture.client.fetchFormulaMappings).mockResolvedValue({ bindings: [pending], versions: [] });
|
vi.mocked(fixture.bridge.isFormulaResultLocked).mockResolvedValue(false);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
expect(fixture.bridge.restoreFormulaResultLocks).toHaveBeenCalledWith({
|
primaryTag: result.controlTag,
|
locks: [{ tag: result.controlTag, lock: 'unlocked' }],
|
});
|
expect(fixture.bridge.restoreFormulaResultLocks).toHaveBeenCalledBefore(fixture.client.cancelFormulaBinding as ReturnType<typeof vi.fn>);
|
expect(fixture.client.cancelFormulaBinding).toHaveBeenCalledWith(expect.anything(), pending.id, {
|
expectedVersion: pending.version,
|
operationCode: pending.operationCode,
|
reason: 'PENDING_LOCK_INCOMPLETE',
|
});
|
expect(api.formulaBindings[0]?.status).toBe('CANCELLED');
|
});
|
|
it('启动时服务端快照恢复失败则保留 PENDING 且不 cancel', async () => {
|
const fixture = createFixture();
|
const result = binding({ id: 'result-binding', status: 'ACTIVE' });
|
const pending = formulaBinding();
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [result] }));
|
vi.mocked(fixture.client.fetchFormulaMappings).mockResolvedValue({ bindings: [pending], versions: [] });
|
vi.mocked(fixture.bridge.isFormulaResultLocked).mockResolvedValue(false);
|
vi.mocked(fixture.bridge.restoreFormulaResultLocks).mockRejectedValue(new Error('恢复锁失败'));
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
expect(fixture.client.cancelFormulaBinding).not.toHaveBeenCalled();
|
expect(api.formulaBindings[0]?.status).toBe('PENDING');
|
expect(api.store.operation).toMatchObject({ kind: 'error', message: expect.stringContaining('需对账') });
|
});
|
|
it('启动时缺少服务端快照的锁不完整 PENDING 保持待对账', async () => {
|
const fixture = createFixture();
|
const result = binding({ id: 'result-binding', status: 'ACTIVE' });
|
const pending = formulaBinding({ jieguoSuoKuaizhao: [] });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [result] }));
|
vi.mocked(fixture.client.fetchFormulaMappings).mockResolvedValue({ bindings: [pending], versions: [] });
|
vi.mocked(fixture.bridge.isFormulaResultLocked).mockResolvedValue(false);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
expect(fixture.bridge.restoreFormulaResultLocks).not.toHaveBeenCalled();
|
expect(fixture.client.cancelFormulaBinding).not.toHaveBeenCalled();
|
expect(api.formulaBindings[0]?.status).toBe('PENDING');
|
expect(api.store.operation).toMatchObject({ kind: 'error', message: expect.stringContaining('需对账') });
|
});
|
|
it('公式 PENDING activate 的 Ticket 过期后停止后续写入', async () => {
|
const fixture = createFixture();
|
const result = binding({ id: 'result-binding', status: 'ACTIVE' });
|
const pending = formulaBinding();
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [result] }));
|
vi.mocked(fixture.client.fetchFormulaMappings).mockResolvedValue({ bindings: [pending], versions: [] });
|
vi.mocked(fixture.bridge.isFormulaResultLocked).mockResolvedValue(true);
|
vi.mocked(fixture.client.activateFormulaBinding).mockRejectedValue(new TemplateAnnotationApiError(401, 'Ticket 已过期'));
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('error'));
|
|
expect(fixture.client.cancelFormulaBinding).not.toHaveBeenCalled();
|
expect(api.contextStore.context).toBeNull();
|
});
|
|
it('启动时只对跨模板标识且已完整锁定的 PENDING 绑定 activate,不重复 reserve', async () => {
|
const fixture = createFixture();
|
const pending = binding({ id: 'cross-pending', readOnly: true, status: 'PENDING', valueSource: 'CROSS_TEMPLATE', version: 3 });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [pending] }));
|
vi.mocked(fixture.bridge.listControls).mockResolvedValue([{ BindingId: pending.id, Lock: 'sdtContentLocked', Tag: pending.controlTag }]);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
expect(fixture.client.activateBinding).toHaveBeenCalledWith(expect.anything(), pending.id, { operationCode: expect.any(String) });
|
expect(fixture.client.reserveCrossTemplateScalarBinding).not.toHaveBeenCalled();
|
expect(api.bindings[0]).toMatchObject({ id: pending.id, status: 'ACTIVE' });
|
});
|
|
it('启动时跨模板 PENDING 锁不完整则先删除控件再 cancel', async () => {
|
const fixture = createFixture();
|
const pending = binding({ id: 'cross-pending', readOnly: true, status: 'PENDING', valueSource: 'CROSS_TEMPLATE', version: 3 });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [pending] }));
|
vi.mocked(fixture.bridge.listControls).mockResolvedValue([{ InternalId: 'cross-control', Lock: 'unlocked', Tag: pending.controlTag }]);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
expect(fixture.bridge.deleteControl).toHaveBeenCalledWith('cross-control');
|
expect(fixture.bridge.deleteControl).toHaveBeenCalledBefore(fixture.client.cancelBinding as ReturnType<typeof vi.fn>);
|
expect(fixture.client.cancelBinding).toHaveBeenCalledWith(expect.anything(), pending.id, {
|
operationCode: expect.any(String),
|
reason: 'PENDING_LOCK_INCOMPLETE',
|
});
|
expect(api.bindings[0]).toMatchObject({ id: pending.id, status: 'CANCELLED' });
|
});
|
|
it('公式复制 PENDING 仅在全部结果锁已确认后批量 confirm', async () => {
|
const fixture = createFixture();
|
const result = binding({ id: 'result-binding', status: 'ACTIVE' });
|
const copied = formulaBinding({ id: 'copied-formula', operationCode: 'copy-op' });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [result] }));
|
vi.mocked(fixture.client.fetchFormulaMappings).mockResolvedValue({
|
bindings: [copied],
|
copiedPendingBindingIds: [copied.id],
|
upgradeOperationCode: 'copy-op',
|
versions: [],
|
});
|
vi.mocked(fixture.bridge.isFormulaResultLocked).mockResolvedValue(true);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
expect(fixture.client.confirmFormulaVersionCopy).toHaveBeenCalledWith(expect.anything(), {
|
controls: [{ bindingId: result.id, controlRole: 'PRIMARY', controlTag: result.controlTag, lock: 'sdtContentLocked' }],
|
expectedVersions: { [copied.id]: copied.version },
|
upgradeOperationCode: 'copy-op',
|
});
|
expect(fixture.client.reserveFormulaBinding).not.toHaveBeenCalled();
|
expect(api.formulaBindings[0]?.status).toBe('ACTIVE');
|
});
|
|
it('升级锁定新结果后锁旧结果失败时立即恢复新结果', async () => {
|
const fixture = createFixture();
|
const oldResult = binding({ id: 'old-result', status: 'ACTIVE', controlTag: 'eln.field.old' });
|
const newResult = binding({ id: 'new-result', status: 'ACTIVE', controlTag: 'eln.field.new' });
|
const current = formulaBinding({ id: 'formula-binding', resultBindingId: oldResult.id, status: 'ACTIVE' });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [oldResult, newResult] }));
|
vi.mocked(fixture.client.fetchFormulaMappings).mockResolvedValue({ bindings: [current], versions: [] });
|
vi.mocked(fixture.client.checkFormulaBindingUpgrade).mockResolvedValue({
|
downstreamFormulaBindingIds: [],
|
expectedVersions: { [current.id]: current.version },
|
operationCode: 'upgrade-op',
|
});
|
const newSnapshot = { primaryTag: newResult.controlTag, locks: [{ tag: newResult.controlTag, lock: 'unlocked' }] };
|
vi.mocked(fixture.bridge.lockFormulaResult).mockResolvedValueOnce(newSnapshot).mockRejectedValueOnce(new Error('旧结果锁失败'));
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await expect(
|
api.upgradeFormulaBinding(current.id, { formulaVersionId: 'formula-version-2', parameterBindings: {}, resultBindingId: newResult.id }),
|
).rejects.toThrow('旧结果锁失败');
|
|
expect(fixture.bridge.restoreFormulaResultLocks).toHaveBeenCalledWith(newSnapshot);
|
expect(fixture.client.confirmFormulaBindingUpgrade).not.toHaveBeenCalled();
|
});
|
|
it('公式升级预检失败后恢复可操作状态并展示完整原因', async () => {
|
const fixture = createFixture();
|
const result = binding({ id: 'result-binding', status: 'ACTIVE' });
|
const current = formulaBinding({ id: 'formula-binding', resultBindingId: result.id, status: 'ACTIVE' });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [result] }));
|
vi.mocked(fixture.client.fetchFormulaMappings).mockResolvedValue({ bindings: [current], versions: [] });
|
vi.mocked(fixture.client.checkFormulaBindingUpgrade).mockRejectedValue(new TemplateAnnotationApiError(422, '公式依赖存在循环: [公式A, 公式B, 公式A]'));
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await expect(
|
api.upgradeFormulaBinding(current.id, {
|
formulaVersionId: 'formula-version-2',
|
parameterBindings: {},
|
resultBindingId: result.id,
|
}),
|
).rejects.toThrow('公式依赖存在循环: [公式A, 公式B, 公式A]');
|
|
expect(api.store.status).toBe('ready');
|
expect(api.store.operation).toEqual({ kind: 'error', message: '公式依赖存在循环: [公式A, 公式B, 公式A]' });
|
});
|
|
it('公式升级 confirm 响应丢失时使用同一操作码重试且不恢复结果锁', async () => {
|
const fixture = createFixture();
|
const oldResult = binding({ id: 'old-result', status: 'ACTIVE', controlTag: 'eln.field.old' });
|
const newResult = binding({ id: 'new-result', status: 'ACTIVE', controlTag: 'eln.field.new' });
|
const current = formulaBinding({ id: 'formula-binding', resultBindingId: oldResult.id, status: 'ACTIVE' });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [oldResult, newResult] }));
|
vi.mocked(fixture.client.fetchFormulaMappings).mockResolvedValue({ bindings: [current], versions: [] });
|
vi.mocked(fixture.client.checkFormulaBindingUpgrade).mockResolvedValue({
|
downstreamFormulaBindingIds: [],
|
expectedVersions: { [current.id]: current.version },
|
operationCode: 'upgrade-op',
|
});
|
vi.mocked(fixture.client.confirmFormulaBindingUpgrade).mockRejectedValueOnce(new TypeError('Failed to fetch')).mockResolvedValueOnce(undefined);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await api.upgradeFormulaBinding(current.id, { formulaVersionId: 'formula-version-2', parameterBindings: {}, resultBindingId: newResult.id });
|
|
expect(fixture.client.confirmFormulaBindingUpgrade).toHaveBeenCalledTimes(2);
|
expect(vi.mocked(fixture.client.confirmFormulaBindingUpgrade).mock.calls[0]?.[2]?.operationCode).toBe('upgrade-op');
|
expect(vi.mocked(fixture.client.confirmFormulaBindingUpgrade).mock.calls[1]?.[2]?.operationCode).toBe('upgrade-op');
|
expect(fixture.bridge.restoreFormulaResultLocks).not.toHaveBeenCalled();
|
expect(api.formulaBindings[0]?.status).toBe('ACTIVE');
|
});
|
|
it('公式升级 confirm 连续响应丢失后保留结果锁并恢复可操作状态', async () => {
|
const fixture = createFixture();
|
const oldResult = binding({ id: 'old-result', status: 'ACTIVE', controlTag: 'eln.field.old' });
|
const newResult = binding({ id: 'new-result', status: 'ACTIVE', controlTag: 'eln.field.new' });
|
const current = formulaBinding({ id: 'formula-binding', resultBindingId: oldResult.id, status: 'ACTIVE' });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [oldResult, newResult] }));
|
vi.mocked(fixture.client.fetchFormulaMappings).mockResolvedValue({ bindings: [current], versions: [] });
|
vi.mocked(fixture.client.checkFormulaBindingUpgrade).mockResolvedValue({
|
downstreamFormulaBindingIds: [],
|
expectedVersions: { [current.id]: current.version },
|
operationCode: 'upgrade-op',
|
});
|
vi.mocked(fixture.client.confirmFormulaBindingUpgrade)
|
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await expect(
|
api.upgradeFormulaBinding(current.id, { formulaVersionId: 'formula-version-2', parameterBindings: {}, resultBindingId: newResult.id }),
|
).rejects.toThrow('待确认');
|
|
expect(fixture.client.confirmFormulaBindingUpgrade).toHaveBeenCalledTimes(2);
|
expect(vi.mocked(fixture.client.confirmFormulaBindingUpgrade).mock.calls.map((call) => call[2].operationCode)).toEqual(['upgrade-op', 'upgrade-op']);
|
expect(fixture.bridge.restoreFormulaResultLocks).not.toHaveBeenCalled();
|
expect(api.store.status).toBe('ready');
|
expect(api.store.operation).toMatchObject({ kind: 'error', message: expect.stringContaining('待对账') });
|
expect(api.formulaBindings[0]).toMatchObject({ id: current.id, resultBindingId: oldResult.id, status: 'ACTIVE' });
|
});
|
|
it('刷新会重新读取服务端公式映射以完成公式升级对账', async () => {
|
const fixture = createFixture();
|
const oldResult = binding({ id: 'old-result', status: 'ACTIVE', controlTag: 'eln.field.old' });
|
const newResult = binding({ id: 'new-result', status: 'ACTIVE', controlTag: 'eln.field.new' });
|
const current = formulaBinding({ id: 'formula-binding', resultBindingId: oldResult.id, status: 'ACTIVE' });
|
const committed = formulaBinding({
|
formulaVersionId: 'formula-version-2',
|
id: current.id,
|
resultBindingId: newResult.id,
|
status: 'ACTIVE',
|
version: current.version + 1,
|
});
|
vi.mocked(fixture.client.fetchAnnotationContext)
|
.mockResolvedValueOnce(annotationContext({ bindings: [oldResult, newResult] }))
|
.mockResolvedValueOnce(annotationContext({ bindings: [oldResult, newResult] }));
|
vi.mocked(fixture.client.fetchFormulaMappings)
|
.mockResolvedValueOnce({ bindings: [current], versions: [] })
|
.mockResolvedValueOnce({ bindings: [committed], versions: [] });
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await api.refresh();
|
|
expect(fixture.client.fetchAnnotationContext).toHaveBeenCalledTimes(2);
|
expect(fixture.client.fetchFormulaMappings).toHaveBeenCalledTimes(2);
|
expect(api.formulaBindings[0]).toMatchObject({
|
formulaVersionId: committed.formulaVersionId,
|
id: committed.id,
|
resultBindingId: newResult.id,
|
status: 'ACTIVE',
|
version: committed.version,
|
});
|
});
|
|
it('刷新步骤配置时保留有效选择,并清空已失效的当前步骤', async () => {
|
const fixture = createFixture();
|
const initial = annotationContext({
|
stepConfiguration: {
|
singleStep: false,
|
steps: [
|
{ id: 'step-1', name: '称量', order: 1 },
|
{ id: 'step-2', name: '溶解', order: 2 },
|
],
|
},
|
} as Partial<TemplateAnnotationContext>);
|
const withoutSelectedStep = annotationContext({
|
stepConfiguration: {
|
singleStep: false,
|
steps: [{ id: 'step-1', name: '称量', order: 1 }],
|
},
|
} as Partial<TemplateAnnotationContext>);
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValueOnce(initial).mockResolvedValueOnce(initial).mockResolvedValueOnce(withoutSelectedStep);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
api.setCurrentStepId('step-2');
|
|
await api.refresh();
|
expect(api.currentStepId).toBe('step-2');
|
|
await api.refresh();
|
expect(api.currentStepId).toBeNull();
|
});
|
|
it('删除仍被下游公式引用的映射时不解除任何结果锁', async () => {
|
const fixture = createFixture();
|
const result = binding({ id: 'result-binding', status: 'ACTIVE' });
|
const current = formulaBinding({ id: 'formula-binding', resultBindingId: result.id, status: 'ACTIVE' });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [result] }));
|
vi.mocked(fixture.client.fetchFormulaMappings).mockResolvedValue({ bindings: [current], versions: [] });
|
vi.mocked(fixture.client.checkFormulaBindingRemoval).mockResolvedValue({
|
downstreamFormulaBindingIds: ['downstream-formula'],
|
expectedVersions: { [current.id]: current.version },
|
operationCode: 'remove-formula',
|
});
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await expect(api.removeFormulaBinding(current.id)).rejects.toThrow('公式结果仍被下游公式引用');
|
|
expect(fixture.bridge.lockFormulaResult).not.toHaveBeenCalled();
|
expect(fixture.bridge.unlockFormulaResult).not.toHaveBeenCalled();
|
expect(fixture.client.confirmFormulaBindingRemoval).not.toHaveBeenCalled();
|
});
|
|
it('SDK ready 先关闭修订再读取模板标注,避免写操作前打断用户选择', async () => {
|
const fixture = createFixture();
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
expect(fixture.order).toEqual(['review', 'list', 'context']);
|
expect(fixture.bridge.disableTrackRevisions).toHaveBeenCalledOnce();
|
expect(fixture.client.fetchAnnotationContext).toHaveBeenCalledWith(expect.objectContaining({ ticket: 'ticket' }));
|
});
|
|
it('重复 SDK ready 复用同一个修订关闭操作', async () => {
|
const fixture = createFixture();
|
let completeReview!: () => void;
|
vi.mocked(fixture.bridge.disableTrackRevisions).mockImplementation(
|
() =>
|
new Promise<void>((resolve) => {
|
completeReview = resolve;
|
}),
|
);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
|
fixture.ready();
|
fixture.ready();
|
await vi.waitFor(() => expect(fixture.bridge.disableTrackRevisions).toHaveBeenCalledOnce());
|
completeReview();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
expect(fixture.bridge.disableTrackRevisions).toHaveBeenCalledOnce();
|
});
|
|
it('只读定位模式仅枚举并唯一定位,不创建 API 上下文或关闭修订', async () => {
|
const fixture = createFixture();
|
const { scope: _scope, ticket: _ticket, ...locateOptions } = validOptions;
|
vi.mocked(fixture.bridge.getRuntimeOptions).mockReturnValue({ ...locateOptions, canAnnotate: false, locateControlTag: 'eln.field.orphan' });
|
vi.mocked(fixture.bridge.listControls).mockResolvedValue([{ InternalId: 'orphan-control', Tag: 'eln.field.orphan' }]);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
expect(fixture.bridge.selectControl).toHaveBeenCalledWith('orphan-control', expect.any(AbortSignal));
|
expect(fixture.bridge.disableTrackRevisions).not.toHaveBeenCalled();
|
expect(fixture.client.fetchAnnotationContext).not.toHaveBeenCalled();
|
expect(fixture.client.fetchRepairContext).not.toHaveBeenCalled();
|
expect(fixture.client.activateBinding).not.toHaveBeenCalled();
|
expect(fixture.client.cancelBinding).not.toHaveBeenCalled();
|
expect(fixture.client.checkBindingRemoval).not.toHaveBeenCalled();
|
expect(fixture.client.checkBindingVersionUpgrade).not.toHaveBeenCalled();
|
expect(fixture.client.checkReferencePromotion).not.toHaveBeenCalled();
|
expect(fixture.client.confirmBindingRemoval).not.toHaveBeenCalled();
|
expect(fixture.client.confirmBindingVersionUpgrade).not.toHaveBeenCalled();
|
expect(fixture.client.confirmReferencePromotion).not.toHaveBeenCalled();
|
expect(fixture.client.confirmRepair).not.toHaveBeenCalled();
|
expect(fixture.client.reservePrimaryBinding).not.toHaveBeenCalled();
|
expect(fixture.client.reservePrivateField).not.toHaveBeenCalled();
|
expect(fixture.client.reserveReferenceBinding).not.toHaveBeenCalled();
|
expect(fixture.client.confirmVersionCopy).not.toHaveBeenCalled();
|
|
await api.refresh();
|
expect(fixture.bridge.listControls).toHaveBeenCalledTimes(2);
|
expect(fixture.bridge.selectControl).toHaveBeenCalledTimes(2);
|
expect(fixture.client.fetchAnnotationContext).not.toHaveBeenCalled();
|
});
|
|
it('纯定位 initialize 的枚举在 dispose 后返回时不再定位控件', async () => {
|
const fixture = createFixture();
|
let resolveControls!: (controls: OfficeContentControl[]) => void;
|
vi.mocked(fixture.bridge.getRuntimeOptions).mockReturnValue({ ...validOptions, canAnnotate: false, locateControlTag: 'eln.field.orphan' });
|
vi.mocked(fixture.bridge.listControls).mockReturnValue(
|
new Promise((done) => {
|
resolveControls = done;
|
}),
|
);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(fixture.bridge.listControls).toHaveBeenCalledOnce());
|
|
api.dispose();
|
resolveControls([{ InternalId: 'orphan-control', Tag: 'eln.field.orphan' }]);
|
await new Promise((done) => setTimeout(done, 0));
|
|
expect(fixture.bridge.selectControl).not.toHaveBeenCalled();
|
});
|
|
it.each([
|
['不存在', [], '不存在'],
|
[
|
'出现多次',
|
[
|
{ InternalId: 'first', Tag: 'eln.field.orphan' },
|
{ InternalId: 'second', Tag: 'eln.field.orphan' },
|
],
|
'出现多次',
|
],
|
])('只读定位刷新目标%s时只报告本地诊断', async (_label, refreshControls, diagnostic) => {
|
const fixture = createFixture();
|
vi.mocked(fixture.bridge.getRuntimeOptions).mockReturnValue({ ...validOptions, canAnnotate: false, locateControlTag: 'eln.field.orphan' });
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([{ InternalId: 'orphan-control', Tag: 'eln.field.orphan' }])
|
.mockResolvedValueOnce(refreshControls);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await expect(api.refresh()).rejects.toThrow(diagnostic);
|
|
expect(api.store.operation.message).toContain(diagnostic);
|
expect(fixture.client.fetchAnnotationContext).not.toHaveBeenCalled();
|
expect(fixture.client.fetchRepairContext).not.toHaveBeenCalled();
|
});
|
|
it.each([
|
['不存在', [], '不存在'],
|
[
|
'出现多次',
|
[
|
{ InternalId: 'first', Tag: 'eln.field.orphan' },
|
{ InternalId: 'second', Tag: 'eln.field.orphan' },
|
],
|
'出现多次',
|
],
|
])('只读定位目标%s时明确失败', async (_label, controls, diagnostic) => {
|
const fixture = createFixture();
|
vi.mocked(fixture.bridge.getRuntimeOptions).mockReturnValue({ ...validOptions, canAnnotate: false, locateControlTag: 'eln.field.orphan' });
|
vi.mocked(fixture.bridge.listControls).mockResolvedValue(controls);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('error'));
|
|
expect(api.store.operation.message).toContain(diagnostic);
|
expect(fixture.bridge.selectControl).not.toHaveBeenCalled();
|
expect(fixture.client.fetchAnnotationContext).not.toHaveBeenCalled();
|
expect(fixture.client.fetchRepairContext).not.toHaveBeenCalled();
|
});
|
|
it('固定执行 reserve→插入→重新枚举确认 0→1→activate', async () => {
|
const fixture = createFixture();
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([{ Tag: 'eln.field.result', BindingId: 'binding' }]);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
fixture.order.splice(0);
|
await api.insertSelectedField(option);
|
expect(fixture.order).toEqual(['reserve', 'insert', 'activate']);
|
const calls = [
|
vi.mocked(fixture.client.reservePrimaryBinding).mock.invocationCallOrder[0]!,
|
vi.mocked(fixture.bridge.listControls).mock.invocationCallOrder[1]!,
|
vi.mocked(fixture.bridge.insertBinding).mock.invocationCallOrder[0]!,
|
vi.mocked(fixture.bridge.listControls).mock.invocationCallOrder[2]!,
|
vi.mocked(fixture.client.activateBinding).mock.invocationCallOrder[0]!,
|
];
|
expect(calls).toEqual([...calls].sort((left, right) => left - right));
|
});
|
|
it('复选框标注携带选择组规则,改组和改规则只更新元数据而不写 DOCX', async () => {
|
const fixture = createFixture();
|
const checkbox = { ...option, fieldCode: 'passed_new', fieldId: 'passed-new-field', fieldVersionId: 'passed-new-version', type: 'CHECKBOX' as const };
|
const active = binding({
|
checkboxGroupCode: 'inspection_result',
|
controlTag: 'eln.field.passed',
|
controlType: 'CHECKBOX',
|
fieldCode: 'passed',
|
fieldId: 'passed-field',
|
fieldVersionId: 'passed-version',
|
status: 'ACTIVE',
|
version: 4,
|
});
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(
|
annotationContext({
|
bindings: [active],
|
checkboxGroups: [{ groupCode: 'inspection_result', maxSelected: 2, minSelected: 1, version: 3 }],
|
}),
|
);
|
vi.mocked(fixture.bridge.listControls).mockResolvedValue([{ BindingId: active.id, Tag: active.controlTag }]);
|
vi.mocked(fixture.client.reservePrimaryBinding).mockResolvedValue(binding({
|
checkboxGroupCode: 'inspection_result',
|
controlTag: 'eln.field.passed_new',
|
controlType: 'CHECKBOX',
|
fieldCode: 'passed_new',
|
id: 'new-checkbox',
|
}));
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([{ BindingId: active.id, Tag: active.controlTag }])
|
.mockResolvedValueOnce([
|
{ BindingId: active.id, Tag: active.controlTag },
|
{ BindingId: 'new-checkbox', Tag: 'eln.field.passed_new' },
|
]);
|
await api.insertSelectedField(checkbox, { groupCode: 'inspection_result', maxSelected: 2, minSelected: 1 });
|
expect(fixture.client.reservePrimaryBinding).toHaveBeenCalledWith(
|
expect.anything(),
|
expect.objectContaining({
|
checkboxGroupCode: 'inspection_result',
|
checkboxGroupMaxSelected: 2,
|
checkboxGroupMinSelected: 1,
|
}),
|
);
|
expect(api.checkboxGroups).toEqual([{ groupCode: 'inspection_result', maxSelected: 2, minSelected: 1, version: 3 }]);
|
|
vi.mocked(fixture.bridge.insertBinding).mockClear();
|
await api.changeCheckboxGroup(active.id, { groupCode: 'review_result', maxSelected: null, minSelected: 2 }, active.version);
|
expect(fixture.client.changeCheckboxGroup).toHaveBeenCalledWith(expect.anything(), active.id, {
|
checkboxGroupCode: 'review_result',
|
checkboxGroupMaxSelected: null,
|
checkboxGroupMinSelected: 2,
|
expectedVersion: 4,
|
operationCode: expect.any(String),
|
});
|
await api.changeCheckboxGroupRule('inspection_result', 0, 3, 3);
|
expect(fixture.client.changeCheckboxGroupRule).toHaveBeenCalledWith(expect.anything(), 'inspection_result', {
|
expectedVersion: 3,
|
maxSelected: 3,
|
minSelected: 0,
|
operationCode: expect.any(String),
|
});
|
expect(fixture.bridge.insertBinding).not.toHaveBeenCalled();
|
expect(api.bindings.find((item) => item.id === active.id)).toMatchObject({ checkboxGroupCode: 'review_result', version: 5 });
|
expect(api.checkboxGroups).toContainEqual({ groupCode: 'inspection_result', maxSelected: 3, minSelected: 0, version: 4 });
|
});
|
|
it('先加载来源模板,再按所选模板加载字段候选', async () => {
|
const fixture = createFixture();
|
const sourceTemplate: CrossTemplateSourceTemplate = {
|
sourceTemplateCode: 'SOURCE-TPL',
|
sourceTemplateId: 'source-template',
|
};
|
vi.mocked(fixture.client.fetchCrossTemplateSourceTemplates).mockResolvedValue([sourceTemplate]);
|
vi.mocked(fixture.client.fetchCrossTemplateScalarCandidates).mockResolvedValue([crossTemplateCandidate]);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await api.loadCrossTemplateSourceTemplates();
|
expect(api.crossTemplateSourceTemplates).toEqual([sourceTemplate]);
|
expect(api.crossTemplateScalarCandidates).toEqual([]);
|
|
await api.loadCrossTemplateScalarCandidates('source-template');
|
expect(fixture.client.fetchCrossTemplateScalarCandidates).toHaveBeenCalledWith(
|
expect.anything(),
|
'source-template',
|
);
|
expect(api.crossTemplateScalarCandidates).toEqual([crossTemplateCandidate]);
|
});
|
|
it('跨模板字段值走独立 reserve,并以锁定的 PRIMARY 控件完成 activate', async () => {
|
const fixture = createFixture();
|
const bridge = fixture.bridge as OnlyOfficeBridge & { insertReadOnlyBinding: ReturnType<typeof vi.fn> };
|
bridge.insertReadOnlyBinding = vi.fn(async () => {
|
fixture.order.push('insert-cross-template');
|
});
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([{ BindingId: 'cross-binding', Lock: 'sdtContentLocked', Tag: 'eln.field.result' }]);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
fixture.order.splice(0);
|
|
await api.insertCrossTemplateScalar(crossTemplateCandidate);
|
|
expect(fixture.order).toEqual(['reserve-cross-template', 'insert-cross-template', 'activate']);
|
expect(fixture.client.reserveCrossTemplateScalarBinding).toHaveBeenCalledWith(expect.anything(), {
|
operationCode: expect.any(String),
|
repeatRequest: false,
|
sourceFieldId: 'source-field',
|
sourceFieldVersionId: 'source-version',
|
sourceTemplateCode: 'SOURCE-TPL',
|
sourceTemplateId: 'source-template',
|
stepId: 'step-1',
|
});
|
expect(fixture.client.reservePrimaryBinding).not.toHaveBeenCalled();
|
expect(bridge.insertReadOnlyBinding).toHaveBeenCalledWith(
|
expect.objectContaining({ controlRole: 'PRIMARY', controlTag: 'eln.field.result', readOnly: true, valueSource: 'CROSS_TEMPLATE' }),
|
expect.any(AbortSignal),
|
);
|
});
|
|
it('跨模板 activate 响应丢失但服务端已 ACTIVE 时保留只读控件', async () => {
|
const fixture = createFixture();
|
const active = binding({ id: 'cross-binding', readOnly: true, status: 'ACTIVE', valueSource: 'CROSS_TEMPLATE' });
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([{ BindingId: active.id, InternalId: 'cross-control', Lock: 'sdtContentLocked', Tag: active.controlTag }]);
|
vi.mocked(fixture.client.activateBinding).mockRejectedValue(new TypeError('Failed to fetch'));
|
vi.mocked(fixture.client.fetchAnnotationContext)
|
.mockResolvedValueOnce(annotationContext())
|
.mockResolvedValueOnce(annotationContext({ bindings: [active] }));
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await api.insertCrossTemplateScalar(crossTemplateCandidate);
|
|
expect(fixture.bridge.deleteControl).not.toHaveBeenCalled();
|
expect(fixture.client.cancelBinding).not.toHaveBeenCalled();
|
expect(api.bindings).toContainEqual(expect.objectContaining({ id: active.id, status: 'ACTIVE' }));
|
});
|
|
it('跨模板 activate 结果无法查明时保留控件等待对账', async () => {
|
const fixture = createFixture();
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([{ BindingId: 'cross-binding', InternalId: 'cross-control', Lock: 'sdtContentLocked', Tag: 'eln.field.result' }]);
|
vi.mocked(fixture.client.activateBinding).mockRejectedValue(new TypeError('Failed to fetch'));
|
vi.mocked(fixture.client.fetchAnnotationContext)
|
.mockResolvedValueOnce(annotationContext())
|
.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await expect(api.insertCrossTemplateScalar(crossTemplateCandidate)).rejects.toThrow('待对账');
|
|
expect(fixture.bridge.deleteControl).not.toHaveBeenCalled();
|
expect(fixture.client.cancelBinding).not.toHaveBeenCalled();
|
expect(api.store.operation).toMatchObject({ kind: 'error', message: expect.stringContaining('待对账') });
|
});
|
|
it('跨模板 activate 结果查询返回过期 Ticket 时清空上下文且停止补偿写', async () => {
|
const fixture = createFixture();
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([{ BindingId: 'cross-binding', InternalId: 'cross-control', Lock: 'sdtContentLocked', Tag: 'eln.field.result' }]);
|
vi.mocked(fixture.client.activateBinding).mockRejectedValue(new TypeError('Failed to fetch'));
|
vi.mocked(fixture.client.fetchAnnotationContext)
|
.mockResolvedValueOnce(annotationContext())
|
.mockRejectedValueOnce(new TemplateAnnotationApiError(401, 'Ticket 已过期'));
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await expect(api.insertCrossTemplateScalar(crossTemplateCandidate)).rejects.toThrow('Ticket 已过期');
|
|
expect(fixture.bridge.deleteControl).not.toHaveBeenCalled();
|
expect(fixture.client.cancelBinding).not.toHaveBeenCalled();
|
expect(api.contextStore.context).toBeNull();
|
expect(api.store.status).toBe('error');
|
});
|
|
it('跨模板插入命令已落控件但响应失败时删除控件后 cancel', async () => {
|
const fixture = createFixture();
|
vi.mocked(fixture.bridge.insertReadOnlyBinding).mockRejectedValue(new Error('OnlyOffice command response lost'));
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([{ InternalId: 'cross-control', Lock: 'sdtContentLocked', Tag: 'eln.field.result' }]);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await expect(api.insertCrossTemplateScalar(crossTemplateCandidate)).rejects.toThrow('response lost');
|
|
expect(fixture.bridge.deleteControl).toHaveBeenCalledWith('cross-control');
|
expect(fixture.bridge.deleteControl).toHaveBeenCalledBefore(fixture.client.cancelBinding as ReturnType<typeof vi.fn>);
|
});
|
|
it('多步骤模板选择当前步骤后才允许标注,并在 reserve 中携带步骤归属', async () => {
|
const fixture = createFixture();
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(
|
annotationContext({
|
stepConfiguration: {
|
singleStep: false,
|
steps: [
|
{ id: 'step-1', name: '称量', order: 1 },
|
{ id: 'step-2', name: '溶解', order: 2 },
|
],
|
},
|
} as Partial<TemplateAnnotationContext>),
|
);
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([{ Tag: 'eln.field.result', BindingId: 'binding' }]);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
expect(api.currentStepId).toBeNull();
|
await expect(api.insertSelectedField(option)).rejects.toThrow('多步骤模板必须选择当前步骤');
|
|
api.setCurrentStepId('step-2');
|
await api.insertSelectedField(option);
|
|
expect(fixture.client.reservePrimaryBinding).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ stepId: 'step-2' }));
|
});
|
|
it('重复行 reserve 携带模板版本内的行数约束', async () => {
|
const fixture = createFixture();
|
const repeat = binding({
|
controlRole: 'REPEAT',
|
controlTag: 'eln.repeat.results.prototype.result',
|
repeatGroupCode: 'results',
|
repeatPrototypeRowId: 'prototype',
|
});
|
vi.mocked(fixture.client.reservePrimaryBinding).mockResolvedValue(repeat);
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([{ Tag: repeat.controlTag, BindingId: repeat.id }]);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await api.insertRepeatField(option, { groupCode: 'results', maxRows: 20, minRows: 2 });
|
|
expect(fixture.client.reservePrimaryBinding).toHaveBeenCalledWith(
|
expect.anything(),
|
expect.objectContaining({
|
maxRows: 20,
|
minRows: 2,
|
repeatGroupCode: 'results',
|
repeatPrototypeRowId: 'prototype',
|
repeatRequest: true,
|
stepId: 'step-1',
|
}),
|
);
|
});
|
|
it('已有重复组后续字段继承组原步骤,不随当前步骤变化', async () => {
|
const fixture = createFixture();
|
const repeat = binding({
|
controlRole: 'REPEAT',
|
controlTag: 'eln.repeat.results.prototype.result',
|
repeatGroupCode: 'results',
|
repeatPrototypeRowId: 'prototype',
|
stepId: 'step-1',
|
});
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(
|
annotationContext({
|
repeatGroups: [{ groupCode: 'results', maxRows: 20, minRows: 2, stepId: 'step-1' }],
|
stepConfiguration: {
|
singleStep: false,
|
steps: [
|
{ id: 'step-1', name: '称量', order: 1 },
|
{ id: 'step-2', name: '溶解', order: 2 },
|
],
|
},
|
} as Partial<TemplateAnnotationContext>),
|
);
|
vi.mocked(fixture.client.reservePrimaryBinding).mockResolvedValue(repeat);
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([{ Tag: repeat.controlTag, BindingId: repeat.id }]);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
api.setCurrentStepId('step-2');
|
|
await api.insertRepeatField(option, { groupCode: 'results', maxRows: 20, minRows: 2 });
|
|
expect(fixture.client.reservePrimaryBinding).toHaveBeenCalledWith(
|
expect.anything(),
|
expect.objectContaining({ repeatGroupCode: 'results', stepId: 'step-1' }),
|
);
|
});
|
|
it('已有重复组缺少步骤归属时拒绝标注,不回退到当前步骤', async () => {
|
const fixture = createFixture();
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(
|
annotationContext({
|
repeatGroups: [{ groupCode: 'results', maxRows: 20, minRows: 2 }],
|
stepConfiguration: {
|
singleStep: false,
|
steps: [
|
{ id: 'step-1', name: '称量', order: 1 },
|
{ id: 'step-2', name: '溶解', order: 2 },
|
],
|
},
|
} as Partial<TemplateAnnotationContext>),
|
);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
api.setCurrentStepId('step-2');
|
|
await expect(api.insertRepeatField(option, { groupCode: 'results', maxRows: 20, minRows: 2 })).rejects.toThrow('重复组步骤归属无效');
|
|
expect(fixture.client.reservePrimaryBinding).not.toHaveBeenCalled();
|
});
|
|
it('重复行 reserve 拒绝越界行数约束', async () => {
|
const fixture = createFixture();
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await expect(
|
api.insertRepeatField(option, {
|
groupCode: 'results',
|
maxRows: 20,
|
minRows: -1,
|
}),
|
).rejects.toThrow('重复组行数约束无效');
|
await expect(
|
api.insertRepeatField(option, {
|
groupCode: 'results',
|
maxRows: 4,
|
minRows: 5,
|
}),
|
).rejects.toThrow('重复组行数约束无效');
|
await expect(
|
api.insertRepeatField(option, {
|
groupCode: 'results',
|
maxRows: 1001,
|
minRows: 0,
|
}),
|
).rejects.toThrow('重复组行数约束无效');
|
expect(fixture.client.reservePrimaryBinding).not.toHaveBeenCalled();
|
});
|
|
it('插入失败只取消仍为 PENDING 的绑定,cancel 失败不覆盖原始错误', async () => {
|
const fixture = createFixture();
|
vi.mocked(fixture.bridge.insertBinding).mockRejectedValue(new Error('文档写入失败'));
|
vi.mocked(fixture.client.cancelBinding).mockRejectedValue(new Error('补偿失败'));
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
await expect(api.insertSelectedField(option)).rejects.toThrow('文档写入失败');
|
expect(fixture.client.cancelBinding).toHaveBeenCalledWith(expect.anything(), 'binding', expect.objectContaining({ reason: '文档写入失败' }));
|
|
vi.mocked(fixture.client.reservePrimaryBinding).mockResolvedValue(binding({ status: 'ACTIVE' }));
|
await expect(api.insertSelectedField(option)).rejects.toThrow('文档写入失败');
|
expect(fixture.client.cancelBinding).toHaveBeenCalledTimes(1);
|
});
|
|
it('私有字段创建激活后刷新字段上下文,使公式映射可立即使用', async () => {
|
const fixture = createFixture();
|
const privateBinding = binding({ checkboxGroupCode: 'inspection_result', controlType: 'CHECKBOX', fieldId: 'private', fieldVersionId: 'private-version' });
|
const updatedContext = annotationContext({
|
bindings: [{ ...privateBinding, status: 'ACTIVE' }],
|
fields: [
|
{
|
beizhu: null,
|
bizEnabled: 'enabled',
|
dangqianZiduanBanbenId: null,
|
id: 'private',
|
mubanBianhao: 'TMP',
|
versions: [
|
{
|
banbenHao: 1,
|
binding: { read: { property: null, source: 'NONE' }, write: { target: 'ELN_RECORD' } },
|
bizStatus: 'PRE_RELEASE',
|
chuangjianShijian: null,
|
danwei: null,
|
gengxinShijian: null,
|
id: 'private-version',
|
kongjianPeizhi: { defaultChecked: false, placeholder: '' },
|
piciMoshi: 'RECORD_PRIVATE',
|
shifouBitian: 0,
|
shujuLeixing: 'CHECKBOX',
|
ziduanDingyiId: 'private',
|
ziduanMingcheng: 'P3验收参数A',
|
zuofeiYuanyin: null,
|
},
|
],
|
ziduanBianma: 'p3_parameter_a',
|
zuoyongFanwei: 'TEMPLATE',
|
},
|
],
|
});
|
vi.mocked(fixture.client.reservePrivateField).mockImplementation(async () => {
|
fixture.order.push('reserve-private');
|
return privateBinding;
|
});
|
vi.mocked(fixture.client.fetchAnnotationContext)
|
.mockImplementationOnce(async () => {
|
fixture.order.push('context');
|
return annotationContext();
|
})
|
.mockImplementationOnce(async () => {
|
fixture.order.push('context');
|
return updatedContext;
|
});
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([{ Tag: 'eln.field.result', BindingId: 'binding' }]);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
fixture.order.splice(0);
|
await api.createPrivateField({
|
checkboxGroupCode: 'inspection_result',
|
kongjianPeizhi: { defaultChecked: false, placeholder: '' },
|
shujuLeixing: 'CHECKBOX',
|
ziduanMingcheng: 'P3验收参数A',
|
});
|
expect(fixture.order).toEqual(['reserve-private', 'insert', 'activate', 'context']);
|
expect(fixture.client.reservePrivateField).toHaveBeenCalledWith(
|
expect.anything(),
|
expect.objectContaining({ checkboxGroupCode: 'inspection_result', stepId: 'step-1' }),
|
);
|
expect(fixture.bridge.listControls).toHaveBeenCalledTimes(3);
|
expect(api.fields).toEqual(expect.arrayContaining([expect.objectContaining({ fieldVersionId: 'private-version', label: 'P3验收参数A', type: 'CHECKBOX' })]));
|
});
|
|
it('引用使用后端 eln.ref Tag,在插入和枚举后激活', async () => {
|
const fixture = createFixture();
|
const primary = binding({ status: 'ACTIVE', version: 2 });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [primary] }));
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([{ Tag: primary.controlTag, Text: '12.3', BindingId: primary.id }])
|
.mockResolvedValueOnce([{ Tag: primary.controlTag }])
|
.mockResolvedValueOnce([{ Tag: primary.controlTag }, { Tag: 'eln.ref.result.abc', BindingId: 'reference' }]);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
fixture.order.splice(0);
|
await api.insertReference('binding');
|
expect(fixture.bridge.insertReference).toHaveBeenCalledWith(
|
expect.objectContaining({ controlTag: 'eln.ref.result.abc' }),
|
primary.controlTag,
|
expect.any(AbortSignal),
|
);
|
expect(fixture.order).toEqual(['reserve-reference', 'insert-reference', 'activate']);
|
expect(fixture.bridge.listControls).toHaveBeenCalledTimes(3);
|
});
|
|
it('升版复制 PENDING 枚举全部控件后一次 confirm,成功后才显示 ACTIVE', async () => {
|
const fixture = createFixture();
|
const pending = binding({ id: 'copy', version: 4 });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(
|
annotationContext({ bindings: [pending], copiedPendingBindingIds: ['copy'], upgradeOperationCode: 'upgrade-1' } as any),
|
);
|
vi.mocked(fixture.bridge.listControls).mockResolvedValue([{ Tag: pending.controlTag, BindingId: pending.id }]);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
expect(fixture.client.confirmVersionCopy).toHaveBeenCalledWith(expect.anything(), {
|
controls: [{ bindingId: 'copy', controlTag: 'eln.field.result' }],
|
expectedVersions: { copy: 4 },
|
upgradeOperationCode: 'upgrade-1',
|
});
|
const snapshotOptions = vi.mocked(fixture.bridge.snapshotControls).mock.calls[0]?.[2];
|
expect([...snapshotOptions!.inheritedFormulaLockBindingIds!]).toEqual([]);
|
expect(api.bindings[0]?.status).toBe('ACTIVE');
|
});
|
|
it('升版字段确认使用服务端公式锁白名单,并刷新上下文确认随后生成的公式复制', async () => {
|
const fixture = createFixture();
|
const pending = binding({ id: 'locked-copy', status: 'PENDING', version: 4 });
|
const reference = binding({
|
controlRole: 'REFERENCE',
|
controlTag: 'eln.ref.result.copy',
|
id: 'locked-reference-copy',
|
primaryBindingId: pending.id,
|
status: 'PENDING',
|
version: 3,
|
});
|
const copiedFormula = formulaBinding({
|
id: 'copied-formula',
|
operationCode: 'upgrade-locked',
|
resultBindingId: pending.id,
|
status: 'PENDING',
|
version: 2,
|
});
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(
|
annotationContext({
|
bindings: [pending, reference],
|
copiedPendingBindingIds: [pending.id, reference.id],
|
upgradeOperationCode: 'upgrade-locked',
|
} as any),
|
);
|
vi.mocked(fixture.client.fetchFormulaMappings)
|
.mockResolvedValueOnce({
|
bindings: [],
|
inheritedFormulaLockBindingIds: [pending.id, reference.id],
|
versions: [],
|
})
|
.mockResolvedValueOnce({
|
bindings: [copiedFormula],
|
copiedPendingBindingIds: [copiedFormula.id],
|
upgradeOperationCode: 'upgrade-locked',
|
versions: [],
|
});
|
vi.mocked(fixture.bridge.listControls).mockResolvedValue([
|
{
|
BindingId: pending.id,
|
Lock: 'sdtContentLocked',
|
Tag: pending.controlTag,
|
},
|
{
|
BindingId: reference.id,
|
Lock: 'sdtContentLocked',
|
Tag: reference.controlTag,
|
},
|
]);
|
vi.mocked(fixture.bridge.snapshotControls).mockImplementation((_bindings, _controls, options) => {
|
if (!options?.inheritedFormulaLockBindingIds?.has(pending.id) || !options.inheritedFormulaLockBindingIds.has(reference.id))
|
throw new Error('复制公式结果锁未按绑定显式允许');
|
return [
|
{ bindingId: pending.id, controlTag: pending.controlTag },
|
{ bindingId: reference.id, controlTag: reference.controlTag },
|
];
|
});
|
vi.mocked(fixture.bridge.isFormulaResultLocked).mockResolvedValue(true);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
expect(fixture.client.fetchFormulaMappings).toHaveBeenCalledTimes(2);
|
expect(fixture.client.confirmVersionCopy).toHaveBeenCalledOnce();
|
expect(fixture.client.confirmFormulaVersionCopy).toHaveBeenCalledOnce();
|
expect(api.bindings.every((item) => item.status === 'ACTIVE')).toBe(true);
|
expect(api.formulaBindings[0]?.status).toBe('ACTIVE');
|
});
|
|
it('复制 PENDING 缺少后端升级操作码时明确失败且不猜测', async () => {
|
const fixture = createFixture();
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [binding()], copiedPendingBindingIds: ['binding'] }));
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('error'));
|
expect(api.store.operation.message).toContain('缺少升版操作码');
|
expect(fixture.client.confirmVersionCopy).not.toHaveBeenCalled();
|
});
|
|
it('普通 PENDING 不触发复制确认,且无升级操作码时保持 PENDING', async () => {
|
const fixture = createFixture();
|
const ordinary = binding({ id: 'ordinary' });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [ordinary] }));
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
expect(fixture.client.confirmVersionCopy).not.toHaveBeenCalled();
|
expect(api.bindings).toEqual([ordinary]);
|
});
|
|
it('混合 PENDING 只确认后端标记的复制绑定', async () => {
|
const fixture = createFixture();
|
const ordinary = binding({ id: 'ordinary', controlTag: 'eln.field.ordinary' });
|
const copied = binding({ id: 'copied', controlTag: 'eln.field.copied', version: 4 });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(
|
annotationContext({
|
bindings: [ordinary, copied],
|
copiedPendingBindingIds: ['copied'],
|
upgradeOperationCode: 'upgrade-1',
|
} as any),
|
);
|
vi.mocked(fixture.bridge.listControls).mockResolvedValue([
|
{ BindingId: ordinary.id, Tag: ordinary.controlTag },
|
{ BindingId: copied.id, Tag: copied.controlTag },
|
]);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
expect(fixture.client.confirmVersionCopy).toHaveBeenCalledWith(expect.anything(), {
|
controls: [{ bindingId: copied.id, controlTag: copied.controlTag }],
|
expectedVersions: { copied: 4 },
|
upgradeOperationCode: 'upgrade-1',
|
});
|
expect(api.bindings.find((item) => item.id === ordinary.id)?.status).toBe('PENDING');
|
expect(api.bindings.find((item) => item.id === copied.id)?.status).toBe('ACTIVE');
|
});
|
|
it('复制 PENDING 集合指向非 PENDING 绑定时拒绝不一致上下文', async () => {
|
const fixture = createFixture();
|
const active = binding({ id: 'active', status: 'ACTIVE' });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(
|
annotationContext({
|
bindings: [active],
|
copiedPendingBindingIds: [active.id],
|
} as any),
|
);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('error'));
|
|
expect(api.store.operation.message).toContain('复制绑定上下文不一致');
|
expect(fixture.client.confirmVersionCopy).not.toHaveBeenCalled();
|
});
|
|
it('dispose 后 reserve 首轮枚举返回时不写 DOCX 或使用新会话 Ticket', async () => {
|
const fixture = createFixture();
|
let resolveControls!: (controls: OfficeContentControl[]) => void;
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockReturnValueOnce(
|
new Promise((done) => {
|
resolveControls = done;
|
}),
|
);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
const operation = api.insertSelectedField(option);
|
await vi.waitFor(() => expect(fixture.bridge.listControls).toHaveBeenCalledTimes(2));
|
api.dispose();
|
const freshFixture = createFixture();
|
vi.mocked(freshFixture.bridge.getRuntimeOptions).mockReturnValue({ ...validOptions, ticket: 'fresh-ticket' });
|
const freshApi = useTemplateAnnotator(freshFixture.bridge, freshFixture.client);
|
freshFixture.ready();
|
await vi.waitFor(() => expect(freshApi.store.status).toBe('ready'));
|
resolveControls([]);
|
await operation;
|
|
expect(fixture.bridge.insertBinding).not.toHaveBeenCalled();
|
expect(fixture.client.activateBinding).not.toHaveBeenCalled();
|
expect(fixture.client.cancelBinding).not.toHaveBeenCalled();
|
});
|
|
it('dispose 后 DOCX 插入返回时不继续枚举、激活、取消或删除,PENDING 残留留给重开对账', async () => {
|
const fixture = createFixture();
|
let resolveInsert!: () => void;
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([{ Tag: 'eln.field.result', BindingId: 'binding' }]);
|
vi.mocked(fixture.bridge.insertBinding).mockReturnValue(
|
new Promise((done) => {
|
resolveInsert = done;
|
}),
|
);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
const operation = api.insertSelectedField(option);
|
await vi.waitFor(() => expect(fixture.bridge.insertBinding).toHaveBeenCalledOnce());
|
api.dispose();
|
resolveInsert();
|
await operation;
|
|
expect(fixture.bridge.listControls).toHaveBeenCalledTimes(2);
|
expect(fixture.client.activateBinding).not.toHaveBeenCalled();
|
expect(fixture.client.cancelBinding).not.toHaveBeenCalled();
|
expect(fixture.bridge.deleteControl).not.toHaveBeenCalled();
|
});
|
|
it('dispose 后 repair-context 返回时不继续插入或确认', async () => {
|
const fixture = createFixture();
|
const active = binding({ status: 'ACTIVE', version: 2 });
|
let resolveRepair!: (value: TemplateRepairContext) => void;
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [active] }));
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([{ Tag: active.controlTag, BindingId: active.id }]);
|
vi.mocked(fixture.client.fetchRepairContext).mockReturnValue(
|
new Promise((done) => {
|
resolveRepair = done;
|
}),
|
);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
const operation = api.repairBindingId(active.id);
|
await vi.waitFor(() => expect(fixture.client.fetchRepairContext).toHaveBeenCalledOnce());
|
api.dispose();
|
resolveRepair({ ...check(), binding: active });
|
await operation;
|
|
expect(fixture.bridge.insertBinding).not.toHaveBeenCalled();
|
expect(fixture.client.confirmRepair).not.toHaveBeenCalled();
|
});
|
|
it('dispose 后拒绝新的独占操作', async () => {
|
const fixture = createFixture();
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
api.dispose();
|
|
await expect(api.refresh()).rejects.toThrow('插件已关闭');
|
});
|
|
it('公开定位向 bridge 传递实例中止信号', async () => {
|
const fixture = createFixture();
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await api.select('control-1');
|
|
expect(fixture.bridge.selectControl).toHaveBeenCalledWith('control-1', expect.any(AbortSignal));
|
});
|
|
it('repairBindingId 启动时 Tag 缺失只加载修复上下文,点击恢复后才插入并 confirm', async () => {
|
const fixture = createFixture();
|
const active = binding({ status: 'ACTIVE', version: 2 });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [active] }));
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([{ Tag: active.controlTag, BindingId: active.id }]);
|
vi.mocked(fixture.bridge.getRuntimeOptions).mockReturnValue({ ...validOptions, repairBindingId: 'binding' });
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
expect(fixture.client.fetchRepairContext).toHaveBeenCalledWith(expect.anything(), 'binding');
|
expect(fixture.bridge.insertBinding).not.toHaveBeenCalled();
|
expect(fixture.client.confirmRepair).not.toHaveBeenCalled();
|
expect(api.repairMode).toBe(true);
|
expect(api.pendingRepairBindingId).toBe('binding');
|
|
await api.repairBindingId('binding');
|
expect(fixture.bridge.insertBinding).toHaveBeenCalledWith(expect.objectContaining({ id: 'binding' }), expect.any(AbortSignal));
|
expect(fixture.client.confirmRepair).toHaveBeenCalledWith(expect.anything(), 'binding', expect.objectContaining({ operationCode: 'check-op' }));
|
});
|
|
it('点击恢复后立即进入处理中状态', async () => {
|
const fixture = createFixture();
|
const active = binding({ status: 'ACTIVE', version: 2 });
|
let resolveControls!: (controls: OfficeContentControl[]) => void;
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [active] }));
|
vi.mocked(fixture.bridge.getRuntimeOptions).mockReturnValue({ ...validOptions, repairBindingId: active.id });
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockImplementationOnce(
|
() =>
|
new Promise((resolve) => {
|
resolveControls = resolve;
|
}),
|
);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.pendingRepairBindingId).toBe(active.id));
|
|
const operation = api.repairBindingId(active.id);
|
|
expect(api.store.status).toBe('working');
|
expect(api.store.operation).toEqual({ kind: 'working', message: '正在恢复控件...' });
|
|
resolveControls([{ InternalId: 'restored', Tag: active.controlTag }]);
|
await operation;
|
});
|
|
it('点击恢复前重新枚举,Tag 已被其他会话恢复时只定位且不重复写入', async () => {
|
const fixture = createFixture();
|
const active = binding({ status: 'ACTIVE', version: 2 });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [active] }));
|
vi.mocked(fixture.bridge.getRuntimeOptions).mockReturnValue({ ...validOptions, repairBindingId: 'binding' });
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([{ InternalId: 'restored-elsewhere', Tag: active.controlTag }]);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.pendingRepairBindingId).toBe('binding'));
|
|
await api.repairBindingId('binding');
|
|
expect(fixture.bridge.selectControl).toHaveBeenCalledWith('restored-elsewhere', expect.any(AbortSignal));
|
expect(fixture.bridge.insertBinding).not.toHaveBeenCalled();
|
expect(fixture.client.confirmRepair).not.toHaveBeenCalled();
|
});
|
|
it('repair confirm 网络失败后发现 Tag 已存在时复用同一 operationCode 幂等确认', async () => {
|
const fixture = createFixture();
|
const active = binding({ status: 'ACTIVE', version: 2 });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [active] }));
|
vi.mocked(fixture.bridge.getRuntimeOptions).mockReturnValue({ ...validOptions, repairBindingId: 'binding' });
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([{ BindingId: active.id, InternalId: 'restored', Tag: active.controlTag }])
|
.mockResolvedValueOnce([{ BindingId: active.id, InternalId: 'restored', Tag: active.controlTag }]);
|
vi.mocked(fixture.client.confirmRepair).mockRejectedValueOnce(new TypeError('network failed')).mockResolvedValueOnce(undefined);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.pendingRepairBindingId).toBe('binding'));
|
|
await expect(api.repairBindingId('binding')).rejects.toThrow('network failed');
|
await expect(api.repairBindingId('binding')).resolves.toBeUndefined();
|
|
expect(fixture.client.fetchRepairContext).toHaveBeenCalledOnce();
|
expect(fixture.bridge.insertBinding).toHaveBeenCalledOnce();
|
expect(fixture.client.confirmRepair).toHaveBeenCalledTimes(2);
|
expect(vi.mocked(fixture.client.confirmRepair).mock.calls.map((call) => call[2].operationCode)).toEqual(['check-op', 'check-op']);
|
expect(fixture.bridge.selectControl).toHaveBeenCalledWith('restored', expect.any(AbortSignal));
|
});
|
|
it('点击恢复前重新枚举,Tag 出现多次时拒绝写入和确认', async () => {
|
const fixture = createFixture();
|
const active = binding({ status: 'ACTIVE', version: 2 });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [active] }));
|
vi.mocked(fixture.bridge.getRuntimeOptions).mockReturnValue({ ...validOptions, repairBindingId: 'binding' });
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([
|
{ InternalId: 'duplicate-1', Tag: active.controlTag },
|
{ InternalId: 'duplicate-2', Tag: active.controlTag },
|
]);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.pendingRepairBindingId).toBe('binding'));
|
|
await expect(api.repairBindingId('binding')).rejects.toThrow('修复目标 Tag 在文档中出现多次');
|
|
expect(fixture.bridge.selectControl).not.toHaveBeenCalled();
|
expect(fixture.bridge.insertBinding).not.toHaveBeenCalled();
|
expect(fixture.client.confirmRepair).not.toHaveBeenCalled();
|
});
|
|
it('repairBindingId 启动时 Tag 已存在只定位,不加载修复上下文或重复插入', async () => {
|
const fixture = createFixture();
|
const active = binding({ status: 'ACTIVE', version: 2 });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [active] }));
|
vi.mocked(fixture.bridge.getRuntimeOptions).mockReturnValue({ ...validOptions, repairBindingId: 'binding' });
|
vi.mocked(fixture.bridge.listControls).mockResolvedValue([{ InternalId: 'existing', Tag: active.controlTag }]);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
expect(fixture.bridge.selectControl).toHaveBeenCalledWith('existing', expect.any(AbortSignal));
|
expect(fixture.client.fetchRepairContext).not.toHaveBeenCalled();
|
expect(fixture.bridge.insertBinding).not.toHaveBeenCalled();
|
expect(api.pendingRepairBindingId).toBeNull();
|
});
|
|
it('repair 初始化定位响应在 dispose 后返回时不覆盖新会话 operation', async () => {
|
const fixture = createFixture();
|
const active = binding({ status: 'ACTIVE', version: 2 });
|
let resolve!: () => void;
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [active] }));
|
vi.mocked(fixture.bridge.getRuntimeOptions).mockReturnValue({ ...validOptions, repairBindingId: active.id });
|
vi.mocked(fixture.bridge.listControls).mockResolvedValue([{ InternalId: 'existing', Tag: active.controlTag }]);
|
vi.mocked(fixture.bridge.selectControl).mockReturnValue(
|
new Promise((done) => {
|
resolve = done;
|
}),
|
);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(fixture.bridge.selectControl).toHaveBeenCalledOnce());
|
api.dispose();
|
|
const freshFixture = createFixture();
|
const freshApi = useTemplateAnnotator(freshFixture.bridge, freshFixture.client);
|
freshFixture.ready();
|
await vi.waitFor(() => expect(freshApi.store.status).toBe('ready'));
|
const operationAfterFreshReady = { ...freshApi.store.operation };
|
resolve();
|
await new Promise((done) => setTimeout(done, 0));
|
expect(freshApi.store.operation).toEqual(operationAfterFreshReady);
|
});
|
|
it('repair 初始化上下文响应在 dispose 后返回时不回填 pending repair 或新会话 operation', async () => {
|
const fixture = createFixture();
|
const active = binding({ status: 'ACTIVE', version: 2 });
|
let resolve!: (value: TemplateRepairContext) => void;
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [active] }));
|
vi.mocked(fixture.bridge.getRuntimeOptions).mockReturnValue({ ...validOptions, repairBindingId: active.id });
|
vi.mocked(fixture.bridge.listControls).mockResolvedValue([]);
|
vi.mocked(fixture.client.fetchRepairContext).mockReturnValue(
|
new Promise((done) => {
|
resolve = done;
|
}),
|
);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(fixture.client.fetchRepairContext).toHaveBeenCalledOnce());
|
api.dispose();
|
|
const freshFixture = createFixture();
|
const freshApi = useTemplateAnnotator(freshFixture.bridge, freshFixture.client);
|
freshFixture.ready();
|
await vi.waitFor(() => expect(freshApi.store.status).toBe('ready'));
|
const operationAfterFreshReady = { ...freshApi.store.operation };
|
resolve({ ...check(), binding: active });
|
await new Promise((done) => setTimeout(done, 0));
|
expect(api.pendingRepairBindingId).toBeNull();
|
expect(freshApi.store.operation).toEqual(operationAfterFreshReady);
|
});
|
|
it('字段升级按 check→文档改写→枚举→confirm 执行并携带目标版本', async () => {
|
const fixture = createFixture();
|
const active = binding({ status: 'ACTIVE', version: 2 });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [active] }));
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
fixture.order.splice(0);
|
|
await api.upgradeBinding('binding', 'published-v2');
|
|
expect(fixture.client.checkBindingVersionUpgrade).toHaveBeenCalledWith(expect.anything(), 'binding', { targetFieldVersionId: 'published-v2' });
|
expect(fixture.bridge.applyControlPlan).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.any(AbortSignal));
|
expect(fixture.order).toEqual(['document', 'list', 'confirm-upgrade']);
|
expect(fixture.client.confirmBindingVersionUpgrade).toHaveBeenCalledWith(
|
expect.anything(),
|
'binding',
|
expect.objectContaining({ targetFieldVersionId: 'published-v2' }),
|
);
|
});
|
|
it('局部变更计划不会把未涉及的其他 ACTIVE 绑定标记为 REMOVED', async () => {
|
const fixture = createFixture();
|
const active = binding({ status: 'ACTIVE', version: 2 });
|
const unrelated = binding({ controlTag: 'eln.field.other', fieldCode: 'other', fieldId: 'other-field', id: 'other', status: 'ACTIVE', version: 3 });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [active, unrelated] }));
|
vi.mocked(fixture.client.checkBindingVersionUpgrade).mockResolvedValue(
|
check({
|
controls: [{ bindingId: active.id, controlTag: active.controlTag }],
|
}),
|
);
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([{ BindingId: active.id, Tag: active.controlTag }]);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await api.upgradeBinding(active.id, 'published-v2');
|
|
expect(api.bindings.find((item) => item.id === unrelated.id)?.status).toBe('ACTIVE');
|
});
|
|
it('删除必须 check→文档改写→枚举→confirm,confirm 失败时绑定保持 ACTIVE', async () => {
|
const fixture = createFixture();
|
const active = binding({ status: 'ACTIVE', version: 2 });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [active] }));
|
const enumeratedControls = [{ BindingId: 'unrelated', Tag: 'eln.field.unrelated' }, { Tag: '' }, { Tag: ' ' }];
|
vi.mocked(fixture.bridge.listControls).mockImplementation(async () => {
|
fixture.order.push('list');
|
return enumeratedControls;
|
});
|
vi.mocked(fixture.client.confirmBindingRemoval).mockRejectedValue(new Error('确认冲突'));
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
fixture.order.splice(0);
|
await expect(api.removeBinding('binding')).rejects.toThrow('确认冲突');
|
expect(fixture.order).toEqual(['document', 'list']);
|
expect(fixture.client.confirmBindingRemoval).toHaveBeenCalledOnce();
|
expect(fixture.client.confirmBindingRemoval).toHaveBeenCalledWith(
|
expect.anything(),
|
active.id,
|
expect.objectContaining({ observedControlTags: ['eln.field.unrelated'] }),
|
);
|
expect(api.bindings[0]?.status).toBe('ACTIVE');
|
expect(api.store.operation).toEqual({ kind: 'error', message: '删除失败:确认冲突' });
|
});
|
|
it('删除孤立控件只操作 DOCX 控件并更新字段列表', async () => {
|
const fixture = createFixture();
|
const remainingControls = [{ InternalId: 'remaining-id', Tag: 'eln.field.other' }];
|
vi.mocked(fixture.bridge.deleteControl).mockResolvedValue(remainingControls);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
fixture.order.splice(0);
|
|
await api.removeOrphanControl('orphan-id');
|
|
expect(fixture.bridge.disableTrackRevisions).toHaveBeenCalledOnce();
|
expect(fixture.bridge.deleteControl).toHaveBeenCalledWith('orphan-id');
|
expect(fixture.client.checkBindingRemoval).not.toHaveBeenCalled();
|
expect(fixture.client.confirmBindingRemoval).not.toHaveBeenCalled();
|
expect(api.store.controls).toEqual(remainingControls);
|
expect(api.store.operation).toEqual({ kind: 'success', message: '异常控件已删除' });
|
});
|
|
it('引用提升必须 check→文档改写→枚举→confirm', async () => {
|
const fixture = createFixture();
|
const reference = binding({ controlRole: 'REFERENCE', controlTag: 'eln.ref.result.abc', id: 'reference', primaryBindingId: 'binding', status: 'ACTIVE' });
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [binding({ status: 'ACTIVE' }), reference] }));
|
vi.mocked(fixture.client.checkReferencePromotion).mockResolvedValue(
|
check({
|
controls: [{ bindingId: reference.id, controlRole: 'PRIMARY', controlTag: 'eln.field.result', primaryBindingId: null }],
|
}),
|
);
|
vi.mocked(fixture.bridge.listControls).mockImplementation(async () => {
|
fixture.order.push('list');
|
return [{ BindingId: reference.id, Tag: 'eln.field.result' }, { BindingId: 'unrelated', Tag: 'eln.field.unrelated' }, { Tag: '' }, { Tag: ' ' }];
|
});
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
fixture.order.splice(0);
|
await api.promoteReference('reference');
|
expect(fixture.order).toEqual(['document', 'list', 'confirm-promote']);
|
expect(fixture.client.confirmReferencePromotion).toHaveBeenCalledWith(
|
expect.anything(),
|
reference.id,
|
expect.objectContaining({ observedControlTags: ['eln.field.result', 'eln.field.unrelated'] }),
|
);
|
});
|
|
it('引用提升 confirm 网络失败后用同一 operationCode 重新枚举并幂等重试,不回滚 DOCX', async () => {
|
const fixture = createFixture();
|
const reference = binding({ controlRole: 'REFERENCE', controlTag: 'eln.ref.result.abc', id: 'reference', primaryBindingId: 'binding', status: 'ACTIVE' });
|
const promotionCheck = check({
|
controls: [
|
{
|
bindingId: reference.id,
|
controlAlias: '结果',
|
controlConfig: { placeholder: '请输入' },
|
controlRole: 'PRIMARY',
|
controlTag: 'eln.field.result',
|
controlType: 'TEXT',
|
fieldVersionId: 'version',
|
primaryBindingId: null,
|
},
|
],
|
operationCode: 'promote-op',
|
});
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [binding({ status: 'ACTIVE' }), reference] }));
|
vi.mocked(fixture.client.checkReferencePromotion).mockResolvedValue(promotionCheck);
|
vi.mocked(fixture.bridge.listControls).mockResolvedValue([{ BindingId: reference.id, Tag: 'eln.field.result' }]);
|
vi.mocked(fixture.client.confirmReferencePromotion).mockRejectedValueOnce(new TypeError('network failed')).mockResolvedValueOnce(undefined);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
await expect(api.promoteReference(reference.id)).rejects.toThrow('network failed');
|
await expect(api.promoteReference(reference.id)).resolves.toBeUndefined();
|
|
expect(fixture.bridge.applyControlPlan).toHaveBeenCalledTimes(2);
|
expect(fixture.bridge.listControls).toHaveBeenCalledTimes(3);
|
expect(fixture.client.checkReferencePromotion).toHaveBeenCalledOnce();
|
expect(fixture.client.confirmReferencePromotion).toHaveBeenCalledTimes(2);
|
expect(vi.mocked(fixture.client.confirmReferencePromotion).mock.calls.map((call) => call[2].operationCode)).toEqual(['promote-op', 'promote-op']);
|
expect(fixture.bridge.deleteControl).not.toHaveBeenCalled();
|
});
|
|
it.each([
|
['删除', 'confirmBindingRemoval', (api: ReturnType<typeof useTemplateAnnotator>) => api.removeBinding('binding')],
|
['引用提升', 'confirmReferencePromotion', (api: ReturnType<typeof useTemplateAnnotator>) => api.promoteReference('binding')],
|
['字段升级', 'confirmBindingVersionUpgrade', (api: ReturnType<typeof useTemplateAnnotator>) => api.upgradeBinding('binding', 'published-v2')],
|
] as const)('%s确认响应在 dispose 后才返回时不回填本地状态', async (_label, confirmMethod, execute) => {
|
const fixture = createFixture();
|
const active = binding({ status: 'ACTIVE', version: 2 });
|
let resolve!: () => void;
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [active] }));
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([{ BindingId: active.id, Tag: active.controlTag }]);
|
vi.mocked(fixture.client[confirmMethod]).mockReturnValue(
|
new Promise((done) => {
|
resolve = done;
|
}),
|
);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
const operation = execute(api);
|
await vi.waitFor(() => expect(fixture.client[confirmMethod]).toHaveBeenCalledOnce());
|
api.dispose();
|
const controlsAfterDispose = [...api.store.controls];
|
resolve();
|
await operation;
|
|
expect(api.bindings).toEqual([]);
|
expect(api.contextStore.context).toBeNull();
|
expect(api.store.controls).toEqual(controlsAfterDispose);
|
});
|
|
it('dispose 后晚到的 confirm 401 不清空新会话上下文', async () => {
|
const fixture = createFixture();
|
const active = binding({ status: 'ACTIVE', version: 2 });
|
let reject!: (error: Error) => void;
|
vi.mocked(fixture.client.fetchAnnotationContext).mockResolvedValue(annotationContext({ bindings: [active] }));
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([{ BindingId: active.id, Tag: active.controlTag }]);
|
vi.mocked(fixture.client.confirmBindingRemoval).mockReturnValue(
|
new Promise((_, fail) => {
|
reject = fail;
|
}),
|
);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
|
const operation = api.removeBinding(active.id);
|
await vi.waitFor(() => expect(fixture.client.confirmBindingRemoval).toHaveBeenCalledOnce());
|
api.dispose();
|
|
const freshFixture = createFixture();
|
const freshApi = useTemplateAnnotator(freshFixture.bridge, freshFixture.client);
|
freshFixture.ready();
|
await vi.waitFor(() => expect(freshApi.store.status).toBe('ready'));
|
reject(new TemplateAnnotationApiError(401, '旧会话 Ticket 已过期'));
|
await expect(operation).rejects.toThrow('旧会话 Ticket 已过期');
|
|
expect(freshApi.contextStore.context).not.toBeNull();
|
expect(freshApi.store.status).toBe('ready');
|
});
|
|
it('401/403 Ticket 失败后清空上下文并停止后续写操作', async () => {
|
const fixture = createFixture();
|
vi.mocked(fixture.client.reservePrimaryBinding).mockRejectedValue(new TemplateAnnotationApiError(401, 'Ticket 已过期'));
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
await expect(api.insertSelectedField(option)).rejects.toThrow('Ticket 已过期');
|
expect(api.contextStore.context).toBeNull();
|
await expect(api.insertSelectedField(option)).rejects.toThrow('插件运行上下文不可用');
|
expect(fixture.client.reservePrimaryBinding).toHaveBeenCalledTimes(1);
|
});
|
|
it('activate 返回 Ticket 过期后不再发送 cancel 补偿写请求', async () => {
|
const fixture = createFixture();
|
vi.mocked(fixture.bridge.listControls)
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([{ Tag: 'eln.field.result' }]);
|
vi.mocked(fixture.client.activateBinding).mockRejectedValue(new TemplateAnnotationApiError(401, 'Ticket 已过期'));
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
await vi.waitFor(() => expect(api.store.status).toBe('ready'));
|
await expect(api.insertSelectedField(option)).rejects.toThrow('Ticket 已过期');
|
expect(fixture.client.cancelBinding).not.toHaveBeenCalled();
|
});
|
|
it('dispose 后晚到 context 响应不覆盖新状态', async () => {
|
const fixture = createFixture();
|
let resolve!: (value: TemplateAnnotationContext) => void;
|
vi.mocked(fixture.client.fetchAnnotationContext).mockReturnValue(
|
new Promise((done) => {
|
resolve = done;
|
}),
|
);
|
const api = useTemplateAnnotator(fixture.bridge, fixture.client);
|
fixture.ready();
|
api.dispose();
|
resolve(annotationContext({ bindings: [binding({ status: 'ACTIVE' })] }));
|
await Promise.resolve();
|
expect(api.bindings).toEqual([]);
|
expect(api.contextStore.context).toBeNull();
|
});
|
});
|