import { flushPromises, mount } from '@vue/test-utils' import ElementPlus, { ElMessage } from 'element-plus' import { beforeEach, describe, expect, test, vi } from 'vitest' import Materials from './Materials.vue' import { codeRulesApi, materialCategoriesApi, unitsApi } from '../api/masterData' const materialsStore = vi.hoisted(() => ({ materials: [], materialOptions: [], loading: false, pagination: { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }, fetchMaterials: vi.fn(), fetchMaterialOptions: vi.fn(), createMaterial: vi.fn(), updateMaterial: vi.fn(), obsoleteMaterial: vi.fn(), restoreMaterial: vi.fn(), getCategoryDisplay: vi.fn(() => '-') })) vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore })) vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) })) vi.mock('../api/masterData', () => ({ materialCategoriesApi: { list: vi.fn() }, unitsApi: { list: vi.fn() }, codeRulesApi: { next: vi.fn() } })) // 真实的 ElSelect 在 jsdom 下会因自身更新报 "Maximum recursive updates exceeded", // 这里用替身直接派发 change,模拟用户选中某个分类。 const SelectStub = { props: ['modelValue', 'placeholder', 'filterable', 'filterMethod', 'noDataText', 'disabled', 'clearable'], emits: ['update:modelValue', 'change'], methods: { pick(value) { this.$emit('update:modelValue', value) this.$emit('change', value) } }, template: '
' } const OptionStub = { props: ['label', 'value'], template: '' } const categories = [ { id: 1, code: 'A', name: '机械结构类', parent_id: null, status: 'active' }, { id: 12, code: 'A01', name: '标准件', parent_id: 1, status: 'active' }, { id: 13, code: 'A02', name: '成品件', parent_id: 1, status: 'active' } ] function mountMaterials() { return mount(Materials, { global: { plugins: [ElementPlus], stubs: { teleport: true, ElSelect: SelectStub, ElOption: OptionStub } } }) } function findButton(wrapper, text) { return wrapper.findAll('button').find(button => button.text().includes(text)) } // 页面上的物料搜索框也用了 ElSelect,弹窗里的三个依次是大类、小类、单位。 function formSelects(wrapper) { const selects = wrapper.findAllComponents(SelectStub) return { major: selects[1], minor: selects[2], unit: selects[3] } } function codeInput(wrapper) { return wrapper.find('.material-code-field input') } async function openAddDialog(wrapper) { await findButton(wrapper, '新增物料').trigger('click') await flushPromises() } async function pickCategories(wrapper, { major = 1, minor = 12 } = {}) { await formSelects(wrapper).major.vm.pick(major) await formSelects(wrapper).minor.vm.pick(minor) await flushPromises() } describe('新增物料时的编码联动', () => { let warnSpy beforeEach(() => { materialCategoriesApi.list.mockResolvedValue({ data: categories }) unitsApi.list.mockResolvedValue({ data: [] }) codeRulesApi.next.mockReset() materialsStore.createMaterial.mockReset() materialsStore.updateMaterial.mockReset() warnSpy = vi.spyOn(ElMessage, 'warning').mockImplementation(() => {}) }) test('还没选小类时,编码框提示先选分类', async () => { codeRulesApi.next.mockResolvedValue({ data: { available: true, code: 'A010201' } }) const wrapper = mountMaterials() await flushPromises() await openAddDialog(wrapper) expect(codeInput(wrapper).attributes('placeholder')).toBe('请先选择大类类型与小类类型') await pickCategories(wrapper) expect(codeInput(wrapper).attributes('placeholder')).toBe('') wrapper.unmount() }) test('选完小类后自动填入按规则算出的编码', async () => { codeRulesApi.next.mockResolvedValue({ data: { available: true, code: 'A010201' } }) const wrapper = mountMaterials() await flushPromises() await openAddDialog(wrapper) await pickCategories(wrapper) expect(codeRulesApi.next).toHaveBeenCalledWith(12) expect(codeInput(wrapper).element.value).toBe('A010201') expect(wrapper.find('.material-code-hint').exists()).toBe(false) wrapper.unmount() }) test('小类没有规则时清空编码并提示手工填写', async () => { codeRulesApi.next.mockResolvedValue({ data: { available: false, code: '' } }) const wrapper = mountMaterials() await flushPromises() await openAddDialog(wrapper) await pickCategories(wrapper) expect(codeInput(wrapper).element.value).toBe('') expect(wrapper.find('.material-code-hint').text()).toContain('请手工填写') wrapper.unmount() }) test('没手改过编码时,换小类会按新分类的规则覆盖', async () => { codeRulesApi.next .mockResolvedValueOnce({ data: { available: true, code: 'A010201' } }) .mockResolvedValueOnce({ data: { available: true, code: 'A020202' } }) const wrapper = mountMaterials() await flushPromises() await openAddDialog(wrapper) await pickCategories(wrapper) expect(codeInput(wrapper).element.value).toBe('A010201') await formSelects(wrapper).minor.vm.pick(13) await flushPromises() expect(codeRulesApi.next).toHaveBeenLastCalledWith(13) expect(codeInput(wrapper).element.value).toBe('A020202') wrapper.unmount() }) test('手改过编码后,换小类保留用户填的值', async () => { codeRulesApi.next.mockResolvedValue({ data: { available: true, code: 'A010201' } }) const wrapper = mountMaterials() await flushPromises() await openAddDialog(wrapper) await pickCategories(wrapper) await codeInput(wrapper).setValue('MY-001') await flushPromises() await formSelects(wrapper).minor.vm.pick(13) await flushPromises() expect(codeInput(wrapper).element.value).toBe('MY-001') // 手改过就不再白白请求一次预览接口 expect(codeRulesApi.next).toHaveBeenCalledTimes(1) wrapper.unmount() }) test('点「重新生成」把编码交回当前分类的规则', async () => { codeRulesApi.next .mockResolvedValueOnce({ data: { available: true, code: 'A010201' } }) .mockResolvedValueOnce({ data: { available: true, code: 'A010299' } }) const wrapper = mountMaterials() await flushPromises() await openAddDialog(wrapper) await pickCategories(wrapper) await codeInput(wrapper).setValue('MY-001') await flushPromises() await findButton(wrapper, '重新生成').trigger('click') await flushPromises() expect(codeInput(wrapper).element.value).toBe('A010299') wrapper.unmount() }) test('小类没有规则且没填编码时,保存被拦下并提示补编码', async () => { codeRulesApi.next.mockResolvedValue({ data: { available: false, code: '' } }) const wrapper = mountMaterials() await flushPromises() await openAddDialog(wrapper) await pickCategories(wrapper) await findButton(wrapper, '保存').trigger('click') await flushPromises() expect(materialsStore.createMaterial).not.toHaveBeenCalled() expect(warnSpy).toHaveBeenCalledWith('请填写物料编码') wrapper.unmount() }) })