import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
import Antd, { DatePicker, Form, Select } from 'ant-design-vue';
|
import dayjs from 'dayjs';
|
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
import FieldEditor from '../../src/components/FieldEditor.vue';
|
import type { ControlType, FieldDefinition } from '../../src/domain/types';
|
|
beforeAll(() => {
|
window.matchMedia ??= () =>
|
({
|
addEventListener() {},
|
addListener() {},
|
dispatchEvent: () => false,
|
matches: false,
|
media: '',
|
onchange: null,
|
removeEventListener() {},
|
removeListener() {},
|
}) as MediaQueryList;
|
|
globalThis.ResizeObserver ??= class ResizeObserver {
|
disconnect() {}
|
observe() {}
|
unobserve() {}
|
};
|
});
|
|
function mountEditor(disabled = false) {
|
const wrapper = mount(FieldEditor, {
|
props: { disabled },
|
attachTo: document.body,
|
global: { plugins: [Antd] },
|
});
|
mountedWrappers.push(wrapper);
|
return wrapper;
|
}
|
|
const mountedWrappers: VueWrapper[] = [];
|
|
afterEach(() => {
|
mountedWrappers.splice(0).forEach((wrapper) => wrapper.unmount());
|
document.body.replaceChildren();
|
});
|
|
function controlTypeSelect(wrapper: VueWrapper) {
|
return wrapper.findAllComponents(Select).find((component) => component.attributes('data-test') === 'control-type')!;
|
}
|
|
async function setControlType(wrapper: VueWrapper, type: ControlType) {
|
controlTypeSelect(wrapper).vm.$emit('update:value', type);
|
await wrapper.vm.$nextTick();
|
}
|
|
async function setRepeatMode(wrapper: VueWrapper) {
|
await wrapper.get('[data-test="mode-repeat"]').trigger('click');
|
await wrapper.vm.$nextTick();
|
}
|
|
async function submit(wrapper: VueWrapper) {
|
await wrapper.get('[data-test="submit-field"]').trigger('click');
|
await wrapper.vm.$nextTick();
|
}
|
|
function lastDefinition(wrapper: VueWrapper): FieldDefinition | undefined {
|
return wrapper.emitted<FieldDefinition[]>('submit')?.at(-1)?.[0];
|
}
|
|
describe('FieldEditor', () => {
|
it('使用旧 UI 默认值并精确提供五种字段类型', async () => {
|
const wrapper = mountEditor();
|
|
expect(wrapper.get('[data-test="field-code"]').element).toHaveProperty('value', 'sample_id');
|
expect(wrapper.get('[data-test="field-alias"]').element).toHaveProperty('value', '样品编号');
|
expect(wrapper.get('[data-test="tag-preview"]').text()).toBe('eln.field.sample_id');
|
await setRepeatMode(wrapper);
|
expect(wrapper.get('[data-test="group-code"]').element).toHaveProperty('value', 'test_results');
|
expect(wrapper.get('[data-test="row-id"]').element).toHaveProperty('value', expect.stringMatching(/^r_[0-9a-f]{16}$/));
|
expect(controlTypeSelect(wrapper).props('options')).toEqual([
|
{ label: '单行文本', value: 'text' },
|
{ label: '下拉选择', value: 'dropdown' },
|
{ label: '复选框', value: 'checkbox' },
|
{ label: '日期', value: 'date' },
|
{ label: '多行文本', value: 'multiline' },
|
]);
|
expect(wrapper.get('[data-test="text-placeholder"]').element).toHaveProperty('value', '请输入样品编号');
|
});
|
|
it('切换类型时只显示对应配置,并保留各类型旧占位文本', async () => {
|
const wrapper = mountEditor();
|
const expectations: Array<[ControlType, string, string | null]> = [
|
['text', 'text-fields', '请输入样品编号'],
|
['dropdown', 'dropdown-fields', '请选择'],
|
['checkbox', 'checkbox-fields', null],
|
['date', 'date-fields', '请选择日期'],
|
['multiline', 'multiline-fields', '请输入内容'],
|
];
|
|
for (const [type, section, placeholder] of expectations) {
|
await setControlType(wrapper, type);
|
expect(wrapper.findAll('[data-test$="-fields"]')).toHaveLength(1);
|
expect(wrapper.find(`[data-test="${section}"]`).exists()).toBe(true);
|
if (placeholder !== null) {
|
expect(wrapper.get(`[data-test="${type}-placeholder"]`).element).toHaveProperty('value', placeholder);
|
}
|
}
|
});
|
|
it('五类非默认草稿往返切换后逐项保留', async () => {
|
const wrapper = mountEditor();
|
await wrapper.get('[data-test="text-placeholder"]').setValue('文本占位');
|
await wrapper.get('[data-test="text-default"]').setValue('文本默认');
|
|
await setControlType(wrapper, 'dropdown');
|
await wrapper.get('[data-test="dropdown-placeholder"]').setValue('下拉占位');
|
await wrapper.get('[data-test="option-display-0"]').setValue('甲');
|
await wrapper.get('[data-test="option-value-0"]').setValue('a');
|
|
await setControlType(wrapper, 'checkbox');
|
await wrapper.get('[data-test="checkbox-default"]').setValue(true);
|
|
await setControlType(wrapper, 'date');
|
await wrapper.get('[data-test="date-placeholder"]').setValue('日期占位');
|
wrapper.findComponent(DatePicker).vm.$emit('update:value', dayjs('2026-08-11'));
|
await wrapper.vm.$nextTick();
|
|
await setControlType(wrapper, 'multiline');
|
await wrapper.get('[data-test="multiline-placeholder"]').setValue('多行占位');
|
await wrapper.get('[data-test="multiline-default"]').setValue('第一行\n第二行');
|
|
await setControlType(wrapper, 'text');
|
expect(wrapper.get('[data-test="text-placeholder"]').element).toHaveProperty('value', '文本占位');
|
expect(wrapper.get('[data-test="text-default"]').element).toHaveProperty('value', '文本默认');
|
await setControlType(wrapper, 'dropdown');
|
expect(wrapper.get('[data-test="dropdown-placeholder"]').element).toHaveProperty('value', '下拉占位');
|
expect(wrapper.get('[data-test="option-display-0"]').element).toHaveProperty('value', '甲');
|
expect(wrapper.get('[data-test="option-value-0"]').element).toHaveProperty('value', 'a');
|
await setControlType(wrapper, 'checkbox');
|
expect(wrapper.get('[data-test="checkbox-default"]').element).toHaveProperty('checked', true);
|
await setControlType(wrapper, 'date');
|
expect(wrapper.findComponent(DatePicker).get('input').element).toHaveProperty('value', '2026-08-11');
|
expect(wrapper.get('[data-test="date-placeholder"]').element).toHaveProperty('value', '日期占位');
|
await setControlType(wrapper, 'multiline');
|
expect(wrapper.get('[data-test="multiline-placeholder"]').element).toHaveProperty('value', '多行占位');
|
expect(wrapper.get('[data-test="multiline-default"]').element).toHaveProperty('value', '第一行\n第二行');
|
});
|
|
it('字段控件具有稳定可访问名称且错误与输入关联', async () => {
|
const wrapper = mountEditor();
|
|
expect(wrapper.get('[data-test="mode-editor"]').attributes('aria-label')).toBe('字段模式');
|
expect(wrapper.get('[data-test="field-code"]').attributes()).toMatchObject({
|
'aria-label': '字段编码',
|
id: 'field-code',
|
});
|
expect(wrapper.get('[data-test="field-alias"]').attributes()).toMatchObject({
|
'aria-label': '字段名称',
|
id: 'field-alias',
|
});
|
expect(controlTypeSelect(wrapper).attributes('aria-label')).toBe('字段类型');
|
expect(controlTypeSelect(wrapper).get('input').attributes('id')).toBe('control-type');
|
expect(wrapper.find('label[for="field-code"]').exists()).toBe(true);
|
expect(wrapper.find('label[for="field-alias"]').exists()).toBe(true);
|
expect(wrapper.find('label[for="control-type"]').exists()).toBe(true);
|
|
await setRepeatMode(wrapper);
|
expect(wrapper.get('[data-test="group-code"]').attributes()).toMatchObject({
|
'aria-label': '重复组编码',
|
id: 'group-code',
|
});
|
expect(wrapper.get('[data-test="row-id"]').attributes()).toMatchObject({ 'aria-label': '行 ID', id: 'row-id' });
|
await wrapper.get('[data-test="mode-single"]').trigger('click');
|
|
await setControlType(wrapper, 'dropdown');
|
expect(wrapper.get('[data-test="dropdown-placeholder"]').attributes()).toMatchObject({
|
'aria-label': '下拉选择占位文本',
|
id: 'dropdown-placeholder',
|
});
|
await setControlType(wrapper, 'checkbox');
|
expect(wrapper.findComponent({ name: 'ACheckbox' }).get('input').attributes('id')).toBe('checkbox-default');
|
await setControlType(wrapper, 'date');
|
expect(wrapper.get('[data-test="date-placeholder"]').attributes()).toMatchObject({
|
'aria-label': '日期占位文本',
|
id: 'date-placeholder',
|
});
|
expect(wrapper.findComponent(DatePicker).get('input').attributes()).toMatchObject({
|
'aria-label': '默认日期',
|
id: 'date-default',
|
});
|
await setControlType(wrapper, 'multiline');
|
expect(wrapper.get('[data-test="multiline-placeholder"]').attributes()).toMatchObject({
|
'aria-label': '多行文本占位文本',
|
id: 'multiline-placeholder',
|
});
|
expect(wrapper.get('[data-test="multiline-default"]').attributes()).toMatchObject({
|
'aria-label': '多行文本默认值',
|
id: 'multiline-default',
|
});
|
|
await setControlType(wrapper, 'text');
|
|
await wrapper.get('[data-test="field-code"]').setValue('中文');
|
await submit(wrapper);
|
expect(wrapper.get('[data-test="field-code"]').attributes()).toMatchObject({
|
'aria-describedby': 'field-code-error',
|
'aria-invalid': 'true',
|
});
|
expect(wrapper.get('#field-code-error').attributes()).toMatchObject({
|
'aria-live': 'polite',
|
role: 'alert',
|
});
|
});
|
|
it('普通与重复模式切换字段、按钮文案和 Tag 预览', async () => {
|
const wrapper = mountEditor();
|
|
expect(wrapper.find('[data-test="repeat-fields"]').exists()).toBe(false);
|
expect(wrapper.get('[data-test="field-code-label"]').text()).toBe('字段编码');
|
expect(wrapper.get('[data-test="submit-field"]').text()).toBe('标注当前位置');
|
|
await setRepeatMode(wrapper);
|
expect(wrapper.find('[data-test="repeat-fields"]').exists()).toBe(true);
|
expect(wrapper.get('[data-test="field-code-label"]').text()).toBe('列字段编码');
|
expect(wrapper.get('[data-test="submit-field"]').text()).toBe('标注当前单元格字段');
|
await wrapper.get('[data-test="group-code"]').setValue(' Test Results ');
|
await wrapper.get('[data-test="row-id"]').setValue('R 01');
|
await wrapper.get('[data-test="field-code"]').setValue('Result Value');
|
expect(wrapper.get('[data-test="tag-preview"]').text()).toBe('eln.repeat.test_results.r_01.result_value');
|
|
await wrapper.get('[data-test="field-code"]').setValue('');
|
await wrapper.get('[data-test="group-code"]').setValue('');
|
await wrapper.get('[data-test="row-id"]').setValue('');
|
expect(wrapper.get('[data-test="tag-preview"]').text()).toBe('eln.repeat.group_id.row_id.field_id');
|
});
|
|
it('生成按钮更新行 ID', async () => {
|
const wrapper = mountEditor();
|
await setRepeatMode(wrapper);
|
await wrapper.get('[data-test="row-id"]').setValue('manual');
|
await wrapper.get('[data-test="generate-row-id"]').trigger('click');
|
expect(wrapper.get('[data-test="row-id"]').element).toHaveProperty('value', expect.stringMatching(/^r_[0-9a-f]{16}$/));
|
});
|
|
it('提交重复字段时归一化定义且不清空草稿', async () => {
|
const wrapper = mountEditor();
|
await setRepeatMode(wrapper);
|
await wrapper.get('[data-test="group-code"]').setValue(' Test Results ');
|
await wrapper.get('[data-test="row-id"]').setValue('R 01');
|
await wrapper.get('[data-test="field-code"]').setValue('Result');
|
await wrapper.get('[data-test="field-alias"]').setValue(' 结果 ');
|
await submit(wrapper);
|
|
expect(lastDefinition(wrapper)).toMatchObject({
|
alias: '结果',
|
fieldCode: 'result',
|
groupCode: 'test_results',
|
mode: 'repeat',
|
rowId: 'r_01',
|
tag: 'eln.repeat.test_results.r_01.result',
|
});
|
expect(wrapper.get('[data-test="field-code"]').element).toHaveProperty('value', 'Result');
|
expect(wrapper.get('[data-test="field-alias"]').element).toHaveProperty('value', ' 结果 ');
|
});
|
|
it('基础字段错误显示在对应表单项、聚焦输入且不提交', async () => {
|
const wrapper = mountEditor();
|
expect(wrapper.get('[data-test="field-code-item"]').element.closest('.ant-form-item')?.classList.contains('ant-form-item-has-feedback')).toBe(false);
|
await wrapper.get('[data-test="field-code"]').setValue('中文');
|
await submit(wrapper);
|
|
expect(wrapper.get('[data-test="field-code-item"]').text()).toContain('请输入有效的字段编码');
|
expect(wrapper.get('[data-test="field-code-item"]').element.closest('.ant-form-item')?.classList.contains('ant-form-item-has-feedback')).toBe(true);
|
expect(document.activeElement).toBe(wrapper.get('[data-test="field-code"]').element);
|
expect(wrapper.emitted('submit')).toBeUndefined();
|
|
await wrapper.get('[data-test="field-code"]').setValue('valid');
|
await wrapper.get('[data-test="field-alias"]').setValue(' ');
|
await submit(wrapper);
|
expect(wrapper.get('[data-test="field-alias-item"]').text()).toContain('请输入字段名称');
|
});
|
|
it('重复字段错误分别映射到重复组和行 ID', async () => {
|
const wrapper = mountEditor();
|
await setRepeatMode(wrapper);
|
await wrapper.get('[data-test="group-code"]').setValue('中文');
|
await submit(wrapper);
|
expect(wrapper.get('[data-test="group-code-item"]').text()).toContain('请输入有效的重复组编码');
|
expect(document.activeElement).toBe(wrapper.get('[data-test="group-code"]').element);
|
|
await wrapper.get('[data-test="group-code"]').setValue('group');
|
await wrapper.get('[data-test="row-id"]').setValue('中文');
|
await submit(wrapper);
|
expect(wrapper.get('[data-test="row-id-item"]').text()).toContain('请生成或输入行 ID');
|
expect(document.activeElement).toBe(wrapper.get('[data-test="row-id"]').element);
|
});
|
|
it('单行换行和下拉不完整选项错误映射到类型配置控件', async () => {
|
const wrapper = mountEditor();
|
expect(wrapper.get('[data-test="text-default-item"]').element.closest('.ant-form-item')?.classList.contains('ant-form-item-has-feedback')).toBe(false);
|
const textDefault = wrapper.findAllComponents({ name: 'AInput' }).find((component) => component.attributes('data-test') === 'text-default')!;
|
textDefault.vm.$emit('update:value', '第一行\n第二行');
|
await wrapper.vm.$nextTick();
|
await submit(wrapper);
|
expect(wrapper.get('[data-test="text-default-item"]').text()).toContain('单行文本默认值不能换行');
|
expect(wrapper.get('[data-test="text-default-item"]').element.closest('.ant-form-item')?.classList.contains('ant-form-item-has-feedback')).toBe(true);
|
expect(document.activeElement).toBe(wrapper.get('[data-test="text-default"]').element);
|
|
await setControlType(wrapper, 'dropdown');
|
await submit(wrapper);
|
expect(wrapper.get('[data-test="dropdown-options-item"]').text()).toContain('下拉框至少需要两个完整选项');
|
expect(wrapper.get('[data-test="option-display-0"]').attributes()).toMatchObject({
|
'aria-describedby': 'dropdown-options-error',
|
'aria-invalid': 'true',
|
});
|
const dropdownItem = wrapper.findAllComponents(Form.Item).find((component) => component.find('[data-test="dropdown-options-item"]').exists())!;
|
expect(dropdownItem.props('htmlFor')).toBe('dropdown-option-display-0');
|
expect(wrapper.get('[data-test="dropdown-options-item"]').element.closest('.ant-form-item')?.classList.contains('ant-form-item-has-feedback')).toBe(true);
|
expect(document.activeElement).toBe(wrapper.get('[data-test="option-display-0"]').element);
|
});
|
|
it('下拉配置按业务值提交完整定义', async () => {
|
const wrapper = mountEditor();
|
await setControlType(wrapper, 'dropdown');
|
await wrapper.get('[data-test="option-display-0"]').setValue(' 甲 ');
|
await wrapper.get('[data-test="option-value-0"]').setValue(' a ');
|
await wrapper.get('[data-test="option-display-1"]').setValue('乙');
|
await wrapper.get('[data-test="option-value-1"]').setValue('b');
|
const defaultSelect = wrapper.findAllComponents(Select).find((component) => component.attributes('data-test') === 'dropdown-default')!;
|
defaultSelect.vm.$emit('update:value', ' a ');
|
await submit(wrapper);
|
|
expect(lastDefinition(wrapper)).toMatchObject({
|
controlType: 'dropdown',
|
options: [
|
{ display: '甲', value: 'a' },
|
{ display: '乙', value: 'b' },
|
],
|
selectedIndex: 0,
|
});
|
});
|
|
it('日期用 Dayjs 编辑并明确转换为 YYYY-MM-DD', async () => {
|
const wrapper = mountEditor();
|
await setControlType(wrapper, 'date');
|
const picker = wrapper.findComponent(DatePicker);
|
picker.vm.$emit('update:value', dayjs('2026-08-11'));
|
await submit(wrapper);
|
|
expect(lastDefinition(wrapper)).toMatchObject({ controlType: 'date', defaultValue: '2026-08-11' });
|
});
|
|
it('无效 Dayjs 映射日期错误、聚焦日期输入且不提交', async () => {
|
const wrapper = mountEditor();
|
await setControlType(wrapper, 'date');
|
wrapper.findComponent(DatePicker).vm.$emit('update:value', dayjs('invalid'));
|
await submit(wrapper);
|
|
expect(wrapper.get('[data-test="date-default-item"]').text()).toContain('请输入有效的日期');
|
expect(wrapper.findComponent(DatePicker).get('input').attributes()).toMatchObject({
|
'aria-describedby': 'date-default-error',
|
'aria-invalid': 'true',
|
});
|
expect(document.activeElement).toBe(wrapper.findComponent(DatePicker).get('input').element);
|
expect(wrapper.emitted('submit')).toBeUndefined();
|
});
|
|
it('真实 Select 和 DatePicker 弹层挂在编辑器根内并通过 DOM 选择日期提交', async () => {
|
const wrapper = mountEditor();
|
const root = wrapper.get('[data-test="field-editor"]');
|
|
const typeSelector = wrapper.get('[data-test="control-type"] .ant-select-selector');
|
await typeSelector.trigger('mousedown');
|
await typeSelector.trigger('click');
|
await flushPromises();
|
const selectDropdown = root.get('.ant-select-dropdown');
|
expect(root.element.contains(selectDropdown.element)).toBe(true);
|
const dateOption = selectDropdown.findAll('.ant-select-item-option').find((item) => item.text() === '日期')!;
|
await dateOption.trigger('click');
|
await flushPromises();
|
expect(wrapper.find('[data-test="date-fields"]').exists()).toBe(true);
|
|
const dateInput = wrapper.findComponent(DatePicker).get('input');
|
await dateInput.trigger('mousedown');
|
await dateInput.trigger('click');
|
await flushPromises();
|
const pickerDropdown = root.get('.ant-picker-dropdown');
|
expect(root.element.contains(pickerDropdown.element)).toBe(true);
|
const cell = pickerDropdown.find('.ant-picker-cell-in-view:not(.ant-picker-cell-disabled)');
|
const expectedDate = cell.attributes('title');
|
expect(expectedDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
await cell.trigger('click');
|
await flushPromises();
|
await submit(wrapper);
|
|
expect(lastDefinition(wrapper)).toMatchObject({ controlType: 'date', defaultValue: expectedDate });
|
});
|
|
it('多行默认值保留换行,复选框提交布尔值', async () => {
|
const wrapper = mountEditor();
|
await setControlType(wrapper, 'multiline');
|
await wrapper.get('[data-test="multiline-default"]').setValue('第一行\n第二行');
|
await submit(wrapper);
|
expect(lastDefinition(wrapper)).toMatchObject({ controlType: 'multiline', defaultValue: '第一行\n第二行' });
|
|
await setControlType(wrapper, 'checkbox');
|
await wrapper.get('[data-test="checkbox-default"]').setValue(true);
|
await submit(wrapper);
|
expect(lastDefinition(wrapper)).toMatchObject({ controlType: 'checkbox', defaultChecked: true });
|
});
|
|
it('Select 和 DatePicker 的弹层容器均返回编辑器根元素', async () => {
|
const wrapper = mountEditor();
|
const root = wrapper.get('[data-test="field-editor"]').element;
|
expect(controlTypeSelect(wrapper).props('getPopupContainer')?.(document.body)).toBe(root);
|
|
await setControlType(wrapper, 'date');
|
expect(wrapper.findComponent(DatePicker).props('getPopupContainer')?.(document.body)).toBe(root);
|
});
|
|
it('disabled 时所有输入、模式、添加删除、生成和提交均不可用', async () => {
|
const wrapper = mountEditor();
|
await setRepeatMode(wrapper);
|
await wrapper.setProps({ disabled: true });
|
|
expect(wrapper.get('[data-test="field-code"]').attributes('disabled')).toBeDefined();
|
expect(wrapper.get('[data-test="mode-editor"]').classes()).toContain('ant-segmented-disabled');
|
expect(controlTypeSelect(wrapper).props('disabled')).toBe(true);
|
expect(wrapper.get('[data-test="generate-row-id"]').attributes('disabled')).toBeDefined();
|
expect(wrapper.get('[data-test="submit-field"]').attributes('disabled')).toBeDefined();
|
const originalTag = wrapper.get('[data-test="tag-preview"]').text();
|
wrapper.getComponent({ name: 'ASegmented' }).vm.$emit('update:value', 'single');
|
controlTypeSelect(wrapper).vm.$emit('update:value', 'date');
|
await wrapper.vm.$nextTick();
|
expect(wrapper.get('[data-test="tag-preview"]').text()).toBe(originalTag);
|
expect(controlTypeSelect(wrapper).props('value')).toBe('text');
|
await submit(wrapper);
|
expect(wrapper.emitted('submit')).toBeUndefined();
|
});
|
|
it('disabled 时主动日期更新不会改变草稿或提交结果', async () => {
|
const wrapper = mountEditor();
|
await setControlType(wrapper, 'date');
|
wrapper.findComponent(DatePicker).vm.$emit('update:value', dayjs('2026-08-11'));
|
await wrapper.vm.$nextTick();
|
await wrapper.setProps({ disabled: true });
|
wrapper.findComponent(DatePicker).vm.$emit('update:value', dayjs('2027-09-12'));
|
await wrapper.vm.$nextTick();
|
expect(wrapper.findComponent(DatePicker).get('input').element).toHaveProperty('value', '2026-08-11');
|
await submit(wrapper);
|
expect(wrapper.emitted('submit')).toBeUndefined();
|
});
|
});
|