刘光辉
3 小时以前 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
import type { FormulaRule } from '#/api/x/eln/formulas';
 
import { flushPromises, mount } from '@vue/test-utils';
import { defineComponent, nextTick } from 'vue';
 
import { beforeEach, describe, expect, it, vi } from 'vitest';
 
import TrialPanel, { parseDecimalListInput } from '../TrialPanel.vue';
import panelSource from '../TrialPanel.vue?raw';
 
const apiMocks = vi.hoisted(() => ({ previewFormula: vi.fn() }));
 
vi.mock('#/api/x/eln/formulas', () => ({ previewFormula: apiMocks.previewFormula }));
 
const stubs = {
  'a-alert': { props: ['message'], template: '<div>{{ message }}</div>' },
  'a-button': defineComponent({
    props: ['disabled', 'loading'],
    emits: ['click'],
    template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
  }),
  'a-descriptions': { template: '<div><slot /></div>' },
  'a-descriptions-item': { template: '<span><slot /></span>' },
  'a-input': defineComponent({
    props: ['value'],
    emits: ['update:value'],
    template: '<input class="trial-text" :value="value" @input="$emit(\'update:value\', $event.target.value)" />',
  }),
  'a-input-number': defineComponent({
    props: ['value'],
    emits: ['update:value'],
    template: '<input class="trial-number" :value="value" @input="$emit(\'update:value\', Number($event.target.value))" />',
  }),
  'a-select': defineComponent({
    name: 'ASelect',
    props: ['fieldNames', 'options', 'value'],
    emits: ['update:value'],
    template: '<div class="trial-boolean">{{ value }}</div>',
  }),
  'a-textarea': defineComponent({
    props: ['value'],
    emits: ['update:value'],
    template: '<textarea class="trial-list" :value="value" @input="$emit(\'update:value\', $event.target.value)" />',
  }),
};
 
const rule: FormulaRule = {
  biaodashi: 'amount + flag',
  jieguoLeixing: 'DECIMAL',
  parameters: [
    { canshuBianma: 'amount', canshuMingcheng: '含量', paixu: 1, shifouBitian: 1, shujuLeixing: 'DECIMAL' },
    { canshuBianma: 'replicates', canshuMingcheng: '重复值', paixu: 2, shifouBitian: 1, shujuLeixing: 'DECIMAL_LIST' },
    { canshuBianma: 'remark', canshuMingcheng: '备注', paixu: 3, shifouBitian: 0, shujuLeixing: 'TEXT' },
    { canshuBianma: 'flag', canshuMingcheng: '是否合格', paixu: 4, shifouBitian: 1, shujuLeixing: 'BOOLEAN' },
  ],
};
 
describe('eLN formula trial panel', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });
 
  it('parses decimal-list values line by line and rejects invalid values', () => {
    expect(parseDecimalListInput('1.2\n-3\n')).toEqual(['1.2', '-3']);
    expect(() => parseDecimalListInput('1\n1e3')).toThrow('第 2 行不是有效十进制数');
  });
 
  it('keeps typed inputs locally and calls the preview API with all slot types', async () => {
    apiMocks.previewFormula.mockResolvedValue({ data: { diagnostics: [], rawResult: '3.0', roundedResult: '3.00', status: 'SUCCESS' } });
    const wrapper = mount(TrialPanel, { props: { rule }, global: { stubs } });
 
    await wrapper.find('.trial-number').setValue('1.25');
    await wrapper.find('.trial-list').setValue('1\n2.5');
    await wrapper.find('.trial-text').setValue('备注');
    await wrapper.findComponent({ name: 'ASelect' }).vm.$emit('update:value', false);
    await wrapper.find('[data-test="run-trial"]').trigger('click');
    await flushPromises();
 
    expect(apiMocks.previewFormula).toHaveBeenCalledWith({
      ...rule,
      inputs: { amount: 1.25, flag: false, remark: '备注', replicates: ['1', '2.5'] },
    });
    expect(wrapper.text()).toContain('原始结果:3.0');
    expect(wrapper.text()).toContain('修约结果:3.00');
    expect(panelSource).toContain("slot.shujuLeixing === 'DECIMAL'");
    expect(panelSource).toContain("slot.shujuLeixing === 'DECIMAL_LIST'");
    expect(panelSource).toContain("slot.shujuLeixing === 'TEXT'");
    expect(panelSource).toContain("slot.shujuLeixing === 'BOOLEAN'");
    expect(panelSource).not.toContain('localStorage');
    expect(panelSource).not.toContain('sessionStorage');
  });
 
  it('shows missing-input status and diagnostics while leaving results empty', async () => {
    apiMocks.previewFormula.mockResolvedValue({
      data: {
        diagnostics: [{ end: 6, message: '缺少参数:amount', severity: 'ERROR', start: 0 }],
        rawResult: null,
        roundedResult: null,
        status: 'MISSING_INPUT',
      },
    });
    const wrapper = mount(TrialPanel, { props: { rule }, global: { stubs } });
 
    await wrapper.find('[data-test="run-trial"]').trigger('click');
    await flushPromises();
    expect(apiMocks.previewFormula).toHaveBeenCalledTimes(1);
    expect(wrapper.text()).toContain('MISSING_INPUT');
    expect(wrapper.text()).toContain('缺少参数:amount');
    expect(wrapper.text()).not.toContain('原始结果');
    expect(wrapper.text()).not.toContain('修约结果');
  });
 
  it('submits an untouched boolean parameter as null instead of false', async () => {
    apiMocks.previewFormula.mockResolvedValue({ data: { diagnostics: [], rawResult: null, roundedResult: null, status: 'MISSING_INPUT' } });
    const wrapper = mount(TrialPanel, { props: { rule }, global: { stubs } });
 
    await wrapper.find('[data-test="run-trial"]').trigger('click');
    await flushPromises();
 
    expect(apiMocks.previewFormula).toHaveBeenCalledWith(expect.objectContaining({ inputs: expect.objectContaining({ flag: null }) }));
  });
 
  it('shows error status and diagnostics while leaving results empty', async () => {
    apiMocks.previewFormula.mockResolvedValue({
      data: { diagnostics: [{ end: 4, message: '除数不能为零', severity: 'ERROR', start: 2 }], rawResult: null, roundedResult: null, status: 'ERROR' },
    });
    const wrapper = mount(TrialPanel, { props: { rule }, global: { stubs } });
 
    await wrapper.find('[data-test="run-trial"]').trigger('click');
    await flushPromises();
 
    expect(wrapper.text()).toContain('ERROR');
    expect(wrapper.text()).toContain('除数不能为零');
    expect(wrapper.text()).not.toContain('原始结果');
    expect(wrapper.text()).not.toContain('修约结果');
  });
 
  it('clears trial inputs and marks an existing result stale after rule changes', async () => {
    apiMocks.previewFormula.mockResolvedValue({ data: { diagnostics: [], rawResult: '1.25', roundedResult: '1.25', status: 'SUCCESS' } });
    const wrapper = mount(TrialPanel, { props: { rule }, global: { stubs } });
    await wrapper.find('.trial-number').setValue('1.25');
    await wrapper.find('[data-test="run-trial"]').trigger('click');
    await flushPromises();
 
    await wrapper.setProps({ rule: { ...rule, biaodashi: 'amount * 2' } });
    await nextTick();
    expect(wrapper.text()).toContain('结果已过期');
    expect(wrapper.text()).toContain('原始结果:1.25');
    expect(apiMocks.previewFormula).toHaveBeenCalledTimes(1);
 
    await wrapper.find('[data-test="clear-trial-inputs"]').trigger('click');
    expect((wrapper.find('.trial-number').element as HTMLInputElement).value).toBe('');
  });
 
  it('removes inputs for deleted parameters before the next trial', async () => {
    apiMocks.previewFormula.mockResolvedValue({ data: { diagnostics: [], rawResult: null, roundedResult: null, status: 'MISSING_INPUT' } });
    const wrapper = mount(TrialPanel, { props: { rule }, global: { stubs } });
    await wrapper.find('.trial-text').setValue('将被删除');
    await wrapper.setProps({ rule: { ...rule, parameters: rule.parameters.filter((slot) => slot.canshuBianma !== 'remark') } });
    await nextTick();
    await wrapper.find('[data-test="run-trial"]').trigger('click');
    await flushPromises();
 
    expect(apiMocks.previewFormula).toHaveBeenCalledWith(expect.objectContaining({ inputs: expect.not.objectContaining({ remark: expect.anything() }) }));
  });
 
  it('prevents duplicate submissions and keeps inputs when the request fails', async () => {
    let rejectRequest!: (error: Error) => void;
    apiMocks.previewFormula.mockReturnValue(
      new Promise((_resolve, reject) => {
        rejectRequest = reject;
      }),
    );
    const wrapper = mount(TrialPanel, { props: { rule }, global: { stubs } });
    await wrapper.find('.trial-number').setValue('2');
    void wrapper.find('[data-test="run-trial"]').trigger('click');
    await wrapper.find('[data-test="run-trial"]').trigger('click');
    expect(apiMocks.previewFormula).toHaveBeenCalledTimes(1);
 
    rejectRequest(new Error('network'));
    await flushPromises();
    expect(wrapper.text()).toContain('试算请求失败');
    expect((wrapper.find('.trial-number').element as HTMLInputElement).value).toBe('2');
  });
});