「作废」和「撤销作废」轮流出现在同一列,2 字对 4 字,宽度不一样,最右边按钮的 位置会参差。把「作废」改成「作废物料」,两边都是 4 个中文字,同字体同字号下自然 等宽,不依赖写死的 px 值——本项目的测试环境是 jsdom,不做布局计算,量不了像素, 写死的值只能靠猜。 顺带修了测试里的一个自欺:那行物料原本叫「作废物料」,和按钮文案撞名,而按钮 自身就在那一行里,用 toContain 断言行归属会恒为真。改成「旧螺丝」才真的在测。 测试补 1 条(锁住两个按钮的文案字数相同)。变异验证:把「作废物料」改回「作废」, 文案断言和字数断言都变红。
383 lines
14 KiB
JavaScript
383 lines
14 KiB
JavaScript
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: '<div class="select-stub"></div>'
|
||
}
|
||
|
||
const OptionStub = { props: ['label', 'value'], template: '<span />' }
|
||
|
||
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()
|
||
})
|
||
})
|