20260914172820
This commit is contained in:
1 parent
c31329d387
commit
549c8bd2eb
38 files changed
+3430
-502
No files matched your search
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 回归用例:菜单管理 - 保存时必须带上派生的树字段,并按 s_i18n 真实主键读写多语言。
|
||||
*
|
||||
* 1) s_menu.b_path 为 not null 且无默认值(b_depth / b_xh / b_canuse 才有默认值),
|
||||
* 请求里缺 b_path 会直接失败:
|
||||
* Cannot insert the value NULL into column 'b_path', table 'FMS.dbo.s_menu'
|
||||
* 另外新增草稿的 b_id 是本地临时负数 id(仅用于树 key),保存时必须换成录入的
|
||||
* 菜单编码后重算路径,否则会把 /-1/ 这类临时路径写进库。
|
||||
*
|
||||
* 2) s_i18n 主键为 (b_key, b_locale),表内没有 b_id / b_module_id。
|
||||
* 菜单多语言行必须按 b_key = menu.{菜单编码} 定位,否则加载会报无效列名、
|
||||
* 保存会报「主键字段不存在: s_i18n.b_id」。
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import MenuManagement from '@/views/module/menu-management/index.vue'
|
||||
import { useI18nStore } from '@/stores/i18n'
|
||||
import { mountPage } from '../helpers/app.js'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: vi.fn(),
|
||||
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
|
||||
nextIdApi: vi.fn(() => Promise.resolve({ data: 9001, code: 0, success: true })),
|
||||
describeApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/message/message-manager.js', () => {
|
||||
const m = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }
|
||||
return { Message: m, default: m }
|
||||
})
|
||||
|
||||
import { loadDataApi, saveObjectApi } from '@/services/api'
|
||||
|
||||
const systemMenu = {
|
||||
b_id: 'system',
|
||||
b_parent_id: null,
|
||||
b_depth: 0,
|
||||
b_path: '/system/',
|
||||
b_name: '系统管理',
|
||||
b_i18n: 'menu.system',
|
||||
b_menu_type: 'directory',
|
||||
b_route: null,
|
||||
b_icon: null,
|
||||
b_xh: 10,
|
||||
b_canuse: 1,
|
||||
}
|
||||
|
||||
const languages = [
|
||||
{ b_id: 'zh-CN', b_name: '简体中文', b_canuse: 1, b_default: 1, b_xh: 10 },
|
||||
{ b_id: 'en-US', b_name: '英文', b_canuse: 1, b_default: 0, b_xh: 20 },
|
||||
]
|
||||
|
||||
const i18nRows = [{ b_key: 'menu.system', b_locale: 'en-US', b_value: 'System', b_canuse: 1 }]
|
||||
|
||||
beforeEach(() => {
|
||||
loadDataApi.mockReset()
|
||||
saveObjectApi.mockClear()
|
||||
loadDataApi.mockImplementation((view) => {
|
||||
if (view === 's_menu') return Promise.resolve({ data: [systemMenu] })
|
||||
if (view === 's_i18n') return Promise.resolve({ data: i18nRows })
|
||||
if (view === 's_i18n_type') return Promise.resolve({ data: languages })
|
||||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
})
|
||||
|
||||
/** 取出最近一次保存请求里的指定表请求 */
|
||||
function lastRequestOf(table) {
|
||||
const requests = saveObjectApi.mock.calls.at(-1)[0]
|
||||
return requests.find((request) => request.table === table)
|
||||
}
|
||||
|
||||
describe('菜单管理:新增菜单的派生树字段', () => {
|
||||
it('新增根菜单按最终菜单编码写入 b_path / b_depth', async () => {
|
||||
const { wrapper } = await mountPage(MenuManagement)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.vm.addMenu(null, 'page')
|
||||
await flushPromises()
|
||||
wrapper.vm.maindata.b_id = 'home'
|
||||
wrapper.vm.maindata.b_name = '首页'
|
||||
wrapper.vm.maindata.b_route = '/dashboard'
|
||||
await wrapper.vm.handleSave()
|
||||
|
||||
const request = lastRequestOf('s_menu')
|
||||
expect(request.inserts).toHaveLength(1)
|
||||
expect(request.inserts[0].b_id).toBe('home')
|
||||
expect(request.inserts[0].b_path).toBe('/home/')
|
||||
expect(request.inserts[0].b_depth).toBe(0)
|
||||
})
|
||||
|
||||
it('新增子菜单按父级路径拼出 b_path 并递增 b_depth', async () => {
|
||||
const { wrapper } = await mountPage(MenuManagement)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.vm.addMenu('system', 'page')
|
||||
await flushPromises()
|
||||
wrapper.vm.maindata.b_id = 'user'
|
||||
wrapper.vm.maindata.b_name = '用户管理'
|
||||
wrapper.vm.maindata.b_route = '/system/user'
|
||||
await wrapper.vm.handleSave()
|
||||
|
||||
const request = lastRequestOf('s_menu')
|
||||
expect(request.inserts[0].b_path).toBe('/system/user/')
|
||||
expect(request.inserts[0].b_depth).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('菜单管理:多语言按 s_i18n 真实主键 (b_key, b_locale) 读写', () => {
|
||||
it('加载按 b_key 精确匹配,保存以 (b_key, b_locale) 为键且不写不存在的列', async () => {
|
||||
const { wrapper, pinia } = await mountPage(MenuManagement)
|
||||
setActivePinia(pinia)
|
||||
await useI18nStore().loadLanguages()
|
||||
await flushPromises()
|
||||
|
||||
wrapper.vm.menuSelectedId = 'system'
|
||||
await flushPromises()
|
||||
|
||||
expect(loadDataApi).toHaveBeenCalledWith('s_i18n', "b_key = N'menu.system'", 'b_locale ASC')
|
||||
expect(wrapper.vm.i18n_values['en-US']).toBe('System')
|
||||
|
||||
wrapper.vm.setI18nValue('en-US', 'System 2')
|
||||
await wrapper.vm.handleSave()
|
||||
|
||||
const request = lastRequestOf('s_i18n')
|
||||
expect(request.key_field).toBe('b_key,b_locale')
|
||||
expect(request.updates).toHaveLength(1)
|
||||
expect(request.updates[0]).toMatchObject({
|
||||
b_key: 'menu.system',
|
||||
b_locale: 'en-US',
|
||||
b_value: 'System 2',
|
||||
b_canuse: 1,
|
||||
})
|
||||
expect(request.updates[0].b_id).toBeUndefined()
|
||||
expect(request.updates[0].b_module_id).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,8 @@
|
||||
* (表现为 "Component inside <Transition> renders non-element root node" +
|
||||
* "Cannot set properties of null (setting '__vnode')")。
|
||||
* 修复:watch(menuSelectedId) 对未落库节点不走 loadMenuConfig,从树节点重建草稿。
|
||||
*
|
||||
* 临时 id 现由 createTempId() 生成本地负数(不再取号),断言按实际值 -1 校验。
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
@@ -28,9 +30,11 @@ import { loadDataApi } from '@/services/api'
|
||||
const savedMenu = {
|
||||
b_id: '1',
|
||||
b_code: 'home',
|
||||
b_parent_id: null,
|
||||
b_depth: 0,
|
||||
b_path: '/1/',
|
||||
b_name: '首页',
|
||||
b_menu_type: 'page',
|
||||
b_parent_id: null,
|
||||
b_route: '/dashboard',
|
||||
b_xh: 10,
|
||||
b_canuse: 1,
|
||||
@@ -40,8 +44,10 @@ beforeEach(() => {
|
||||
loadDataApi.mockReset()
|
||||
loadDataApi.mockImplementation((view, where) => {
|
||||
if (view === 's_menu' && where === '1 = 1') return Promise.resolve({ data: [savedMenu] })
|
||||
if (view === 's_menu' && where === 'b_id = 1') return Promise.resolve({ data: [savedMenu] })
|
||||
if (view === 's_menu') return Promise.resolve({ data: [] }) // 含 b_id = 9001(临时 id):查无此行
|
||||
// 加载单行用 sqlText 拼的 N'1' 字面量
|
||||
if (view === 's_menu' && where === "b_id = N'1'")
|
||||
return Promise.resolve({ data: [savedMenu] })
|
||||
if (view === 's_menu') return Promise.resolve({ data: [] }) // 含本地临时 id(负数):查无此行
|
||||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
})
|
||||
@@ -54,7 +60,7 @@ describe('菜单管理:未保存新节点来回切换', () => {
|
||||
// 1. 新建节点(未保存)
|
||||
await wrapper.vm.addMenu(null, 'page')
|
||||
await flushPromises()
|
||||
expect(String(wrapper.vm.menuSelectedId)).toBe('9001')
|
||||
expect(String(wrapper.vm.menuSelectedId)).toBe('-1')
|
||||
expect(wrapper.vm.maindata?.b_menu_type).toBe('page')
|
||||
|
||||
// 2. 切到旧节点(已保存)
|
||||
@@ -64,14 +70,14 @@ describe('菜单管理:未保存新节点来回切换', () => {
|
||||
|
||||
// 3. 切回新节点:不应对临时 id 发起查库请求
|
||||
loadDataApi.mockClear()
|
||||
wrapper.vm.menuSelectedId = '9001'
|
||||
wrapper.vm.menuSelectedId = '-1'
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
expect(loadDataApi).not.toHaveBeenCalled()
|
||||
expect(wrapper.vm.maindata).not.toBeNull()
|
||||
expect(String(wrapper.vm.maindata?.b_id)).toBe('9001')
|
||||
expect(String(wrapper.vm.maindata?.b_id)).toBe('-1')
|
||||
expect(wrapper.vm.maindata?.b_menu_type).toBe('page')
|
||||
expect(String(wrapper.vm.loadedMenuId)).toBe('9001')
|
||||
expect(String(wrapper.vm.loadedMenuId)).toBe('-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 回归用例:往来单位分类树(b_othercompany_bmfl)的新增 / 修改 / 删除 / 拖拽排序。
|
||||
*
|
||||
* 1) b_path 为 not null 且无默认值(b_depth / b_xh / b_canuse 才有默认值),新增分类时
|
||||
* 必须按父级派生 b_depth / b_path 一起写入,否则插入直接失败;
|
||||
* 且两个派生字段与 b_parent_id 必须同批落库,否则会留下「路径按新父级、父级列还是旧值」。
|
||||
*
|
||||
* 2) 删除分类有两条守卫:还有子分类、分类下还挂着往来单位。两条都必须拦在发请求之前。
|
||||
*
|
||||
* 3) 分类编码 b_id 是主键,修改时只提交名称与排序号,不能改编码。
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import Othercompany from '@/views/base/othercompany/index.vue'
|
||||
import CategoryTreePanel from '@/views/base/othercompany/CategoryTreePanel.vue'
|
||||
import { mountPage } from '../helpers/app.js'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: vi.fn(),
|
||||
pageDataApi: vi.fn(),
|
||||
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
|
||||
nextIdApi: vi.fn(() => Promise.resolve({ data: 9001, code: 0, success: true })),
|
||||
describeApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/message/message-manager.js', () => {
|
||||
const m = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }
|
||||
return { Message: m, default: m }
|
||||
})
|
||||
|
||||
import { loadDataApi, pageDataApi, saveObjectApi } from '@/services/api'
|
||||
import { Message } from '@/components/ui/message/message-manager.js'
|
||||
|
||||
const ROOT1 = {
|
||||
b_id: 'root1',
|
||||
b_parent_id: null,
|
||||
b_depth: 0,
|
||||
b_path: '/root1/',
|
||||
b_name: '一级分类',
|
||||
b_xh: 10,
|
||||
b_canuse: 1,
|
||||
}
|
||||
const ROOT2 = {
|
||||
b_id: 'root2',
|
||||
b_parent_id: null,
|
||||
b_depth: 0,
|
||||
b_path: '/root2/',
|
||||
b_name: '另一个一级分类',
|
||||
b_xh: 20,
|
||||
b_canuse: 1,
|
||||
}
|
||||
const CHILD1 = {
|
||||
b_id: 'child1',
|
||||
b_parent_id: 'root1',
|
||||
b_depth: 1,
|
||||
b_path: '/root1/child1/',
|
||||
b_name: '二级分类',
|
||||
b_xh: 10,
|
||||
b_canuse: 1,
|
||||
}
|
||||
|
||||
const CATEGORIES = [ROOT1, ROOT2, CHILD1]
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
loadDataApi.mockReset()
|
||||
pageDataApi.mockReset()
|
||||
saveObjectApi.mockClear()
|
||||
Message.success.mockClear()
|
||||
Message.error.mockClear()
|
||||
Message.warning.mockClear()
|
||||
loadDataApi.mockImplementation((view) => {
|
||||
if (view === 'b_othercompany_bmfl')
|
||||
return Promise.resolve({ data: CATEGORIES.map((row) => ({ ...row })) })
|
||||
return Promise.resolve({ data: [] })
|
||||
})
|
||||
pageDataApi.mockResolvedValue({ data: { rows: [], total: 0 } })
|
||||
})
|
||||
|
||||
/** 取出最近一次保存请求里的指定表请求 */
|
||||
function lastRequestOf(table) {
|
||||
const requests = saveObjectApi.mock.calls.at(-1)?.[0] || []
|
||||
return requests.find((request) => request.table === table)
|
||||
}
|
||||
|
||||
/** 汇总最近一次保存请求里全部 updates 行 */
|
||||
function lastUpdates() {
|
||||
const requests = saveObjectApi.mock.calls.at(-1)?.[0] || []
|
||||
return requests.flatMap((request) => request.updates || [])
|
||||
}
|
||||
|
||||
async function mountOthercompany() {
|
||||
const { wrapper } = await mountPage(Othercompany)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('往来单位:新增分类写派生树字段', () => {
|
||||
it('新增顶级分类写入 b_parent_id=null 与根路径', async () => {
|
||||
const wrapper = await mountOthercompany()
|
||||
|
||||
wrapper.vm.onCategoryAdd({ parentId: null })
|
||||
await wrapper.vm.onCategorySubmit({ b_id: 'newcat', b_name: '新分类', b_xh: 30 })
|
||||
await flushPromises()
|
||||
|
||||
const request = lastRequestOf('b_othercompany_bmfl')
|
||||
expect(request.key_field).toBe('b_id')
|
||||
expect(request.inserts).toHaveLength(1)
|
||||
expect(request.inserts[0]).toMatchObject({
|
||||
b_id: 'newcat',
|
||||
b_parent_id: null,
|
||||
b_depth: 0,
|
||||
b_path: '/newcat/',
|
||||
b_name: '新分类',
|
||||
b_xh: 30,
|
||||
b_canuse: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('新增子分类按父级路径拼出 b_path 并递增 b_depth', async () => {
|
||||
const wrapper = await mountOthercompany()
|
||||
|
||||
wrapper.vm.onCategoryAdd({ parentId: 'root1' })
|
||||
await wrapper.vm.onCategorySubmit({ b_id: 'child2', b_name: '三级分类', b_xh: 20 })
|
||||
await flushPromises()
|
||||
|
||||
expect(lastRequestOf('b_othercompany_bmfl').inserts[0]).toMatchObject({
|
||||
b_id: 'child2',
|
||||
b_parent_id: 'root1',
|
||||
b_depth: 1,
|
||||
b_path: '/root1/child2/',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('往来单位:修改分类', () => {
|
||||
it('只提交名称与排序号,分类编码保持为主键值', async () => {
|
||||
const wrapper = await mountOthercompany()
|
||||
|
||||
wrapper.vm.onCategoryEdit({ b_id: 'child1' })
|
||||
await wrapper.vm.onCategorySubmit({ b_id: 'child1', b_name: '改过的名字', b_xh: 99 })
|
||||
await flushPromises()
|
||||
|
||||
const request = lastRequestOf('b_othercompany_bmfl')
|
||||
expect(request.inserts).toBeUndefined()
|
||||
expect(request.updates).toHaveLength(1)
|
||||
expect(request.updates[0]).toEqual({ b_id: 'child1', b_name: '改过的名字', b_xh: 99 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('往来单位:删除分类的两条守卫', () => {
|
||||
it('还有子分类时直接拦下,不发任何请求', async () => {
|
||||
const wrapper = await mountOthercompany()
|
||||
|
||||
await wrapper.vm.onCategoryDelete({ b_id: 'root1' })
|
||||
|
||||
expect(Message.warning).toHaveBeenCalledWith('该分类下还有子分类,请先删除子分类')
|
||||
expect(pageDataApi).not.toHaveBeenCalled()
|
||||
expect(saveObjectApi).not.toHaveBeenCalled()
|
||||
expect(wrapper.vm.categoryDeleteTarget).toBe(null)
|
||||
})
|
||||
|
||||
it('分类下还有往来单位时拦下,不发删除请求', async () => {
|
||||
const wrapper = await mountOthercompany()
|
||||
pageDataApi.mockResolvedValueOnce({ data: { rows: [{ b_id: 'OC001' }], total: 1 } })
|
||||
|
||||
await wrapper.vm.onCategoryDelete({ b_id: 'child1' })
|
||||
|
||||
expect(Message.warning).toHaveBeenCalledWith('该分类下还有往来单位数据,不能删除')
|
||||
expect(saveObjectApi).not.toHaveBeenCalled()
|
||||
expect(wrapper.vm.categoryDeleteTarget).toBe(null)
|
||||
})
|
||||
|
||||
it('守卫通过后弹确认,确认才落库删除', async () => {
|
||||
const wrapper = await mountOthercompany()
|
||||
|
||||
await wrapper.vm.onCategoryDelete({ b_id: 'child1' })
|
||||
expect(wrapper.vm.categoryDeleteTarget?.b_id).toBe('child1')
|
||||
expect(saveObjectApi).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.vm.confirmCategoryDelete()
|
||||
await flushPromises()
|
||||
|
||||
const request = lastRequestOf('b_othercompany_bmfl')
|
||||
expect(request.deletes).toEqual([{ b_id: 'child1' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('往来单位:拖拽分类', () => {
|
||||
it('跨级拖入时同批落库 b_parent_id 与重建后的 b_depth / b_path', async () => {
|
||||
const wrapper = await mountOthercompany()
|
||||
|
||||
await wrapper.vm.onCategoryDrop({
|
||||
dragKey: 'child1',
|
||||
dropKey: 'root2',
|
||||
dropPosition: 'inside',
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const moved = lastUpdates().find((row) => row.b_id === 'child1')
|
||||
expect(moved).toMatchObject({
|
||||
b_id: 'child1',
|
||||
b_parent_id: 'root2',
|
||||
b_depth: 1,
|
||||
b_path: '/root2/child1/',
|
||||
})
|
||||
})
|
||||
|
||||
it('不能把分类拖到它自己的下级', async () => {
|
||||
const wrapper = await mountOthercompany()
|
||||
|
||||
await wrapper.vm.onCategoryDrop({
|
||||
dragKey: 'root1',
|
||||
dropKey: 'child1',
|
||||
dropPosition: 'inside',
|
||||
})
|
||||
|
||||
expect(Message.warning).toHaveBeenCalledWith('不能把分类移动到它自己的下级')
|
||||
expect(saveObjectApi).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('往来单位分类树面板:搜索', () => {
|
||||
it('按名称与编码过滤,并把命中的层级展开', async () => {
|
||||
// 面板收到的是已经建好的树(页面的 categoryTree)。这里整树真实渲染:
|
||||
// Input / Button 与保留字同名,shallowMount 打桩会触发 Vue 保留名告警。
|
||||
const treeData = [{ ...ROOT1, children: [{ ...CHILD1 }] }, { ...ROOT2 }]
|
||||
const wrapper = mount(CategoryTreePanel, { props: { treeData } })
|
||||
|
||||
expect(wrapper.vm.filteredTree).toHaveLength(2)
|
||||
expect(wrapper.emitted('update:expandedKeys')).toBeUndefined()
|
||||
|
||||
// 命中子分类的编码:保留其祖先,保证命中节点可见
|
||||
wrapper.vm.keyword = 'child1'
|
||||
await flushPromises()
|
||||
expect(wrapper.vm.filteredTree.map((node) => node.b_id)).toEqual(['root1'])
|
||||
expect(wrapper.vm.filteredTree[0].children.map((node) => node.b_id)).toEqual(['child1'])
|
||||
// 搜索中展开全部,保证命中节点可见
|
||||
expect(wrapper.emitted('update:expandedKeys')?.at(-1)?.[0]).toEqual([
|
||||
'root1',
|
||||
'child1',
|
||||
'root2',
|
||||
])
|
||||
|
||||
// 命中根分类的名称
|
||||
wrapper.vm.keyword = '另一个'
|
||||
await flushPromises()
|
||||
expect(wrapper.vm.filteredTree.map((node) => node.b_id)).toEqual(['root2'])
|
||||
|
||||
// 清空搜索后收起
|
||||
wrapper.vm.keyword = ''
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('update:expandedKeys')?.at(-1)?.[0]).toEqual([])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user