import { flushPromises, mount } from '@vue/test-utils' import ElementPlus, { ElMessage, ElMessageBox } from 'element-plus' import { afterEach, 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: [], historicalMaterialOptions: [], loading: false, pagination: { currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }, fetchMaterials: vi.fn(), fetchMaterialOptions: vi.fn(), fetchHistoricalMaterialOptions: 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', 'disabled'], 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') } function setupState(wrapper) { return wrapper.vm.$.setupState } 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 let alertSpy 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(() => {}) alertSpy = vi.spyOn(ElMessageBox, 'alert').mockResolvedValue('confirm') }) 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(alertSpy).not.toHaveBeenCalled() 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(alertSpy).toHaveBeenCalledWith( '该小类未配置编码规则,请手工填写物料编码', '提示', expect.objectContaining({ confirmButtonText: '知道了' }) ) 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: true, code: 'A010201' } }) 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() }) 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() }) }) describe('新增物料后列表与搜索框保持一致', () => { beforeEach(() => { materialCategoriesApi.list.mockResolvedValue({ data: categories }) unitsApi.list.mockResolvedValue({ data: [] }) codeRulesApi.next.mockReset() codeRulesApi.next.mockResolvedValue({ data: { available: true, code: 'A010201' } }) materialsStore.createMaterial.mockReset() materialsStore.updateMaterial.mockReset() materialsStore.createMaterial.mockResolvedValue({ data: {} }) materialsStore.updateMaterial.mockResolvedValue({ data: {} }) vi.spyOn(ElMessage, 'success').mockImplementation(() => {}) }) // 页面里第一个 ElSelect 就是物料搜索框。 async function searchBy(wrapper, keyword) { await wrapper.findAllComponents(SelectStub)[0].vm.pick(keyword) await flushPromises() } // store 里新建后的那次刷新是刻意清空筛选的(好让刚建的物料出现在列表里),页面这份 // 状态(搜索框)必须跟着一起清空:否则会出现搜索框写着关键字、列表却是全量的 // 表里不一,用户会把全量列表当成筛选结果。 test('新增物料成功后搜索框要清空', async () => { const wrapper = mountMaterials() await flushPromises() await searchBy(wrapper, '螺丝') expect(setupState(wrapper).searchKeyword).toBe('螺丝') await openAddDialog(wrapper) await pickCategories(wrapper) setupState(wrapper).form.name = '内六角螺丝' await setupState(wrapper).handleSave() await flushPromises() expect(materialsStore.createMaterial).toHaveBeenCalled() expect(setupState(wrapper).searchKeyword).toBe('') wrapper.unmount() }) // 编辑走的是「沿用搜索」那条路:刷新后列表仍是筛选结果,搜索框不该被动。 test('编辑物料成功后搜索框要保留', async () => { const wrapper = mountMaterials() await flushPromises() await searchBy(wrapper, '螺丝') setupState(wrapper).showEditModal({ id: 7, category_id: 12, material_code: 'A010201', name: '螺丝' }) await flushPromises() await setupState(wrapper).handleSave() await flushPromises() expect(materialsStore.updateMaterial).toHaveBeenCalled() expect(setupState(wrapper).searchKeyword).toBe('螺丝') wrapper.unmount() }) }) describe('物料检索框的候选项', () => { beforeEach(() => { materialCategoriesApi.list.mockResolvedValue({ data: categories }) unitsApi.list.mockResolvedValue({ data: [] }) materialsStore.fetchHistoricalMaterialOptions.mockReset() materialsStore.materialOptions = [] }) // 这一页的列表查询走 status='all',本身就显示着作废物料;检索框的候选要是只取有效 // 物料,作废的那些就搜不出来——而这一页正是恢复它们的入口。列表和检索框必须同源。 test('能检索到已作废的物料', async () => { const obsolete = { id: 1, material_code: 'A010201', name: '作废物料', status: 'obsolete' } materialsStore.historicalMaterialOptions = [obsolete] const wrapper = mountMaterials() await flushPromises() expect(materialsStore.fetchHistoricalMaterialOptions).toHaveBeenCalled() setupState(wrapper).materialFilter('A010201') await flushPromises() expect(setupState(wrapper).getFilteredMaterials()).toEqual([obsolete]) wrapper.unmount() }) }) describe('物料列表的作废入口文案', () => { beforeEach(() => { materialCategoriesApi.list.mockResolvedValue({ data: categories }) unitsApi.list.mockResolvedValue({ data: [] }) // 名字特意避开按钮文案(「作废物料」/「撤销作废」):否则按钮自身就在那一行里, // 用 toContain 断言行归属会恒为真,测了等于没测。 materialsStore.materials = [ { id: 1, material_code: 'A010201', name: '旧螺丝', status: 'obsolete' }, { id: 2, material_code: 'A010202', name: '新螺丝', status: 'active' } ] }) afterEach(() => { materialsStore.materials = [] }) // 按钮文案得精确匹配:「作废物料」和「撤销作废」都含「作废」,用 includes 会把 // 两个按钮看成同一个。 function exactButton(wrapper, text) { return wrapper.findAll('button').find(button => button.text().trim() === text) } // 这一页没有状态列(文案即状态),所以按钮文案本身得说清这条物料怎么了。 // 「撤销」在中文 UI 里默认指撤销上一步(Ctrl+Z),看不出在撤销什么;「撤销作废」 // 才读得出「这条被作废了,点这里撤销那次作废」。 test('作废的那行显示「撤销作废」,有效的那行显示「作废物料」', async () => { const wrapper = mountMaterials() await flushPromises() const restore = exactButton(wrapper, '撤销作废') const obsolete = exactButton(wrapper, '作废物料') expect(restore).toBeTruthy() expect(obsolete).toBeTruthy() // 两个入口互斥:作废的那行不该再有「作废物料」,有效的那行不该有「撤销作废」。 // 注意断言的是行归属,不是「页面上有没有这个词」——两个词都含「作废」。 expect(restore.element.closest('tr').textContent).toContain('旧螺丝') expect(obsolete.element.closest('tr').textContent).toContain('新螺丝') wrapper.unmount() }) // 两个按钮轮流出现在同一列,宽度不一样会让「编辑」右边参差不齐。这里靠的是两边 // 都是 4 个中文字,同字体同字号下自然等宽——jsdom 不做布局计算,量不了像素,但 // 字数不等就一定会参差,所以把字数锁住。 test('两个按钮的文案字数相同,保证宽度一致', async () => { const wrapper = mountMaterials() await flushPromises() const restore = exactButton(wrapper, '撤销作废') const obsolete = exactButton(wrapper, '作废物料') expect(restore.text().trim().length).toBe(obsolete.text().trim().length) wrapper.unmount() }) }) describe('停用分类下的物料编辑', () => { test('保留原分类并显示停用状态,修改名称时不改变分类归属', async () => { materialCategoriesApi.list.mockResolvedValue({ data: [ { id: 1, code: 'A', name: '旧大类', parent_id: null, status: 'inactive' }, { id: 12, code: 'A01', name: '旧小类', parent_id: 1, status: 'inactive' }, { id: 2, code: 'B', name: '新大类', parent_id: null, status: 'active' }, { id: 21, code: 'B01', name: '新小类', parent_id: 2, status: 'active' } ] }) unitsApi.list.mockResolvedValue({ data: [] }) materialsStore.updateMaterial.mockReset().mockResolvedValue({ data: {} }) vi.spyOn(ElMessage, 'success').mockImplementation(() => {}) const wrapper = mountMaterials() await flushPromises() const state = setupState(wrapper) const row = { id: 7, category_id: 12, category: 'A01', material_code: 'MAT-7', name: '原名称' } expect(state.getMajorCategoryDisplay(row)).toBe('A 旧大类(已停用)') expect(state.getMinorCategoryDisplay(row)).toBe('旧小类') state.showEditModal(row) await flushPromises() expect(state.form.major_category_id).toBe(1) expect(state.form.category_id).toBe(12) expect(state.majorCategories.map(category => category.id)).toContain(1) expect(state.minorCategories.map(category => category.id)).toContain(12) expect(state.minorCategoryLabel(state.minorCategories.find(category => category.id === 12), 1)).toBe('旧小类') state.form.name = '新名称' await state.handleSave() expect(materialsStore.updateMaterial).toHaveBeenCalledWith(7, expect.objectContaining({ category_id: 12, name: '新名称' })) wrapper.unmount() }) test('只有小类停用时在小类名称后标注', async () => { materialCategoriesApi.list.mockResolvedValue({ data: [ { id: 1, code: 'A', name: '机械结构类', parent_id: null, status: 'active' }, { id: 12, code: 'A01', name: '标准件', parent_id: 1, status: 'inactive' } ] }) unitsApi.list.mockResolvedValue({ data: [] }) const wrapper = mountMaterials() await flushPromises() const state = setupState(wrapper) const row = { category_id: 12, category: 'A01' } expect(state.getMajorCategoryDisplay(row)).toBe('A 机械结构类') expect(state.getMinorCategoryDisplay(row)).toBe('标准件(已停用)') wrapper.unmount() }) })