刘光辉
2 小时以前 cf11e63b659afcc9792ab3e395f1b45d844431a8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
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();
  });
});