material_manage_system/frontend/src/views/Materials.test.js
文睿 张 688dffebaa fix: 物料页两个作废入口的按钮宽度对齐
「作废」和「撤销作废」轮流出现在同一列,2 字对 4 字,宽度不一样,最右边按钮的
位置会参差。把「作废」改成「作废物料」,两边都是 4 个中文字,同字体同字号下自然
等宽,不依赖写死的 px 值——本项目的测试环境是 jsdom,不做布局计算,量不了像素,
写死的值只能靠猜。

顺带修了测试里的一个自欺:那行物料原本叫「作废物料」,和按钮文案撞名,而按钮
自身就在那一行里,用 toContain 断言行归属会恒为真。改成「旧螺丝」才真的在测。

测试补 1 条(锁住两个按钮的文案字数相同)。变异验证:把「作废物料」改回「作废」,
文案断言和字数断言都变红。
2026-09-22 15:15:54 +08:00

383 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
})
})