import type { FormulaFunction, ParameterSlot } from '#/api/x/eln/formulas';
|
|
import { flushPromises, mount } from '@vue/test-utils';
|
import { defineComponent, nextTick } from 'vue';
|
|
import { CompletionContext } from '@codemirror/autocomplete';
|
import { EditorState } from '@codemirror/state';
|
import { EditorView } from '@codemirror/view';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import FormulaEditor from '../FormulaEditor.vue';
|
import editorSource from '../FormulaEditor.vue?raw';
|
import { createFormulaCompletions, formulaCompletionSource, formulaLanguage, insertFormulaText } from '../formulaLanguage';
|
import languageSource from '../formulaLanguage.ts?raw';
|
import { toEditorDiagnostics } from '../formulaRules';
|
|
const apiMocks = vi.hoisted(() => ({ validateFormula: vi.fn() }));
|
|
vi.mock('#/api/x/eln/formulas', async (importOriginal) => ({
|
...(await importOriginal<typeof import('#/api/x/eln/formulas')>()),
|
validateFormula: apiMocks.validateFormula,
|
}));
|
|
const parameters: ParameterSlot[] = [{ canshuBianma: 'sample_weight', canshuMingcheng: '样品质量', paixu: 1, shifouBitian: 1, shujuLeixing: 'DECIMAL' }];
|
const functions: FormulaFunction[] = [
|
{
|
category: '数学',
|
chineseName: '平均值',
|
code: 'MEAN',
|
description: '计算平均值',
|
example: 'MEAN(replicates)',
|
returnType: 'DECIMAL',
|
signature: 'MEAN(DECIMAL_LIST)',
|
},
|
];
|
|
describe('eLN formula editor', () => {
|
beforeEach(() => {
|
vi.stubGlobal(
|
'ResizeObserver',
|
class {
|
disconnect() {}
|
observe() {}
|
unobserve() {}
|
},
|
);
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0));
|
vi.stubGlobal('cancelAnimationFrame', (handle: number) => window.clearTimeout(handle));
|
});
|
|
afterEach(() => {
|
vi.useRealTimers();
|
vi.unstubAllGlobals();
|
vi.restoreAllMocks();
|
vi.clearAllMocks();
|
});
|
|
it('offers parameter codes with their business names and functions with signatures and examples', () => {
|
const completions = createFormulaCompletions(parameters, functions);
|
|
expect(completions).toContainEqual(
|
expect.objectContaining({ apply: 'sample_weight', displayLabel: '样品质量', label: expect.stringContaining('sample_weight'), type: 'variable' }),
|
);
|
expect(completions).toContainEqual(
|
expect.objectContaining({
|
apply: 'MEAN(',
|
displayLabel: '平均值',
|
detail: 'MEAN(DECIMAL_LIST)',
|
label: expect.stringContaining('MEAN'),
|
type: 'function',
|
}),
|
);
|
});
|
|
it('filters resource completions and groups them by business category', () => {
|
const parameterCompletions = createFormulaCompletions(parameters, functions, 'parameter');
|
expect(parameterCompletions).toHaveLength(1);
|
expect(parameterCompletions[0]).toMatchObject({
|
apply: 'sample_weight',
|
displayLabel: '样品质量',
|
section: expect.objectContaining({ name: '参数' }),
|
type: 'variable',
|
});
|
expect(parameterCompletions[0]?.label).toContain('样品质量');
|
expect(parameterCompletions[0]?.label).toContain('sample_weight');
|
|
const functionCompletions = createFormulaCompletions(parameters, functions, 'function');
|
expect(functionCompletions).toHaveLength(1);
|
expect(functionCompletions[0]).toMatchObject({
|
apply: 'MEAN(',
|
displayLabel: '平均值',
|
section: expect.objectContaining({ name: '函数' }),
|
type: 'function',
|
});
|
});
|
|
it('replaces a completion prefix that starts at offset zero', () => {
|
const source = formulaCompletionSource(parameters, functions);
|
const result = source({ explicit: true, matchBefore: () => ({ from: 0, text: 'sam', to: 3 }), pos: 3 } as any);
|
|
expect(result).toEqual(expect.objectContaining({ from: 0 }));
|
});
|
|
it('keeps completion candidates open while searching by Chinese name', () => {
|
const source = formulaCompletionSource(parameters, functions);
|
const state = EditorState.create({ doc: '样品' });
|
const result = source(new CompletionContext(state, state.doc.length, false));
|
|
expect(result).toEqual(expect.objectContaining({ from: 0 }));
|
});
|
|
it('inserts selected parameters and functions at the current selection', () => {
|
expect(insertFormulaText('a + b', 4, 5, 'MEAN(')).toBe('a + MEAN(');
|
expect(insertFormulaText('', 0, 0, 'sample_weight')).toBe('sample_weight');
|
});
|
|
it('maps backend UTF-16 positions and clears latex for syntax errors', () => {
|
expect(toEditorDiagnostics([{ code: 'SYNTAX', end: 8, severity: 'ERROR', start: 4 }], 6)).toEqual([expect.objectContaining({ from: 4, to: 6 })]);
|
expect(toEditorDiagnostics([{ code: 'SYNTAX', end: 5, severity: 'ERROR', start: 3 }], '样品😀+a'.length)).toEqual([
|
expect.objectContaining({ from: 3, to: 5 }),
|
]);
|
expect(editorSource).toContain('hasSyntaxError');
|
expect(editorSource).toContain('latex: hasSyntaxError');
|
});
|
|
it('uses the requested CodeMirror extensions and destroys its EditorView', () => {
|
expect(FormulaEditor).toBeTruthy();
|
expect(editorSource).toContain('history()');
|
expect(editorSource).toContain('historyKeymap');
|
expect(editorSource).toContain('bracketMatching()');
|
expect(editorSource).toContain('autocompletion');
|
expect(editorSource).toContain('lineWrapping');
|
expect(languageSource).toContain('StreamLanguage');
|
expect(editorSource).toContain('editorView?.destroy()');
|
expect(editorSource).toContain('min-height: 180px');
|
expect(editorSource).toContain('max-height: 360px');
|
});
|
|
it('restores the editor top border when the readonly toolbar is hidden', async () => {
|
const wrapper = mount(FormulaEditor, {
|
props: { functions, modelValue: 'sample_weight', parameters, readonly: true, rule: { biaodashi: 'sample_weight', jieguoLeixing: 'DECIMAL', parameters } },
|
global: { stubs: { 'a-alert': true, 'a-button': true } },
|
});
|
await nextTick();
|
|
expect(wrapper.find('.formula-editor__toolbar').exists()).toBe(false);
|
expect(wrapper.classes()).toContain('is-readonly');
|
expect(editorSource).toMatch(/\.formula-editor\.is-readonly\s+\.formula-editor__surface\s*\{[^}]*border-top:\s*1px solid var\(--border-color-base\);/);
|
wrapper.unmount();
|
});
|
|
it('tokenizes strings and logical, comparison, negation, and modulo operators', () => {
|
const expression = "name == '样品' && !passed % 2";
|
const stringTokens: string[] = [];
|
const operatorTokens: string[] = [];
|
formulaLanguage.parser.parse(expression).iterate({
|
enter(node) {
|
if (node.name === 'string') stringTokens.push(expression.slice(node.from, node.to));
|
if (node.name === 'operator') operatorTokens.push(expression.slice(node.from, node.to));
|
},
|
});
|
|
expect(stringTokens).toEqual(["'样品'"]);
|
expect(operatorTokens).toEqual(['==', '&&', '!', '%']);
|
});
|
|
it('inserts helpers, validates immediately, displays backend diagnostics, and destroys the mounted EditorView', async () => {
|
const destroy = vi.spyOn(EditorView.prototype, 'destroy');
|
apiMocks.validateFormula.mockResolvedValue({ data: { diagnostics: [{ end: 2, message: '参数无效', severity: 'ERROR', start: 0 }], latex: 'a' } });
|
const Button = defineComponent({ emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' });
|
const wrapper = mount(FormulaEditor, {
|
attachTo: document.body,
|
props: { functions, modelValue: '', parameters, rule: { biaodashi: '', jieguoLeixing: 'DECIMAL', parameters } },
|
global: { stubs: { 'a-alert': true, 'a-button': Button } },
|
});
|
await nextTick();
|
|
expect(wrapper.find('.cm-placeholder').text()).toBe('请输入公式表达式');
|
expect(wrapper.findAll('button').map((button) => button.text())).toEqual(expect.arrayContaining(['插入参数', '插入函数']));
|
await wrapper
|
.findAll('button')
|
.find((button) => button.text() === '插入参数')!
|
.trigger('click');
|
await nextTick();
|
await vi.waitFor(() => expect(document.body.querySelector('.cm-tooltip-autocomplete')).not.toBeNull());
|
expect(document.body.querySelector('.cm-tooltip-autocomplete')!.textContent).toContain('样品质量');
|
expect(document.body.querySelector('.cm-tooltip-autocomplete')!.textContent).not.toContain('平均值');
|
(wrapper.vm as any).insert('sample_weight');
|
await (wrapper.vm as any).validate();
|
await flushPromises();
|
|
expect(apiMocks.validateFormula).toHaveBeenCalledWith(expect.objectContaining({ biaodashi: 'sample_weight' }), expect.any(AbortSignal));
|
expect(wrapper.emitted('validation')?.at(-1)?.[0]).toEqual(expect.objectContaining({ latex: null }));
|
expect(wrapper.find('.cm-lintRange').exists()).toBe(true);
|
wrapper.unmount();
|
expect(destroy).toHaveBeenCalledTimes(1);
|
});
|
|
it('clears old validation and revalidates after a non-expression rule changes', async () => {
|
vi.useFakeTimers();
|
apiMocks.validateFormula.mockResolvedValue({ data: { diagnostics: [], latex: 'x' } });
|
const wrapper = mount(FormulaEditor, {
|
attachTo: document.body,
|
props: { functions, modelValue: 'sample_weight', parameters, rule: { biaodashi: 'sample_weight', jieguoLeixing: 'DECIMAL', parameters } },
|
global: { stubs: { 'a-alert': true, 'a-button': true } },
|
});
|
|
await (wrapper.vm as any).validate();
|
await flushPromises();
|
await wrapper.setProps({ rule: { biaodashi: 'sample_weight', jieguoLeixing: 'BOOLEAN', parameters: [{ ...parameters[0]!, shifouBitian: 0 }] } });
|
await nextTick();
|
|
expect(wrapper.emitted('validation')?.at(-1)?.[0]).toEqual({ diagnostics: [], latex: null });
|
await vi.advanceTimersByTimeAsync(400);
|
await flushPromises();
|
expect(apiMocks.validateFormula).toHaveBeenLastCalledWith(
|
expect.objectContaining({ jieguoLeixing: 'BOOLEAN', parameters: [{ ...parameters[0]!, shifouBitian: 0 }] }),
|
expect.any(AbortSignal),
|
);
|
wrapper.unmount();
|
});
|
});
|