20260913231607
This commit is contained in:
1 parent
f3ad727f1c
commit
c31329d387
179 files changed
+10846
-2684
No files matched your search
@@ -158,3 +158,50 @@ describe('Popover 零包裹交互', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Popover 浮层宽度跟随触发器', () => {
|
||||
/** jsdom 里 offsetWidth 恒为 0,覆写成真实宽度才能断言下发值 */
|
||||
function stubTriggerWidth(wrapper, width) {
|
||||
const trigger = wrapper.find('span.popover-trigger').element
|
||||
Object.defineProperty(trigger, 'offsetWidth', { value: width, configurable: true })
|
||||
}
|
||||
|
||||
it('开启时触发器宽作为 min-width 下发,而非写死 width(内容更宽仍可撑开)', async () => {
|
||||
const wrapper = mount(Popover, {
|
||||
props: { trigger: 'click', followTriggerWidth: true },
|
||||
slots: {
|
||||
trigger: '<button class="t-btn">触发</button>',
|
||||
content: '<div class="t-content">内容</div>',
|
||||
},
|
||||
})
|
||||
stubTriggerWidth(wrapper, 240)
|
||||
await wrapper.find('button.t-btn').trigger('click')
|
||||
await flush()
|
||||
|
||||
const float = bodyPopover()
|
||||
expect(float.style.minWidth).toBe('240px')
|
||||
// 关键:不写死宽度,浮层才能被更宽的内容撑开;内容靠 CSS 铺满浮层
|
||||
expect(float.style.width).toBe('')
|
||||
expect(float.classList.contains('popover-follow-trigger-width')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('未开启时不下发任何宽度约束(完全由内容撑开)', async () => {
|
||||
const wrapper = mount(Popover, {
|
||||
props: { trigger: 'click' },
|
||||
slots: {
|
||||
trigger: '<button class="t-btn">触发</button>',
|
||||
content: '<div class="t-content">内容</div>',
|
||||
},
|
||||
})
|
||||
stubTriggerWidth(wrapper, 240)
|
||||
await wrapper.find('button.t-btn').trigger('click')
|
||||
await flush()
|
||||
|
||||
const float = bodyPopover()
|
||||
expect(float.style.minWidth).toBe('')
|
||||
expect(float.style.width).toBe('')
|
||||
expect(float.classList.contains('popover-follow-trigger-width')).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import Select from '@/components/ui/select/select.vue'
|
||||
|
||||
/** 下拉浮层 Teleport 到 body,需从 body 查 */
|
||||
function popoverContent() {
|
||||
return document.body.querySelector('.popover-content')
|
||||
}
|
||||
|
||||
describe('Select 下拉浮层宽度上限', () => {
|
||||
it('浮层内容盒带 select-popover-content 锚点类(:has() 定位浮层加 max-width 用)', async () => {
|
||||
// 浮层由 Popover 渲染、Teleport 到 body,不在 Select 的 scoped 作用域内,
|
||||
// 只能靠 content-class 传递锚点类,再由全局样式 .popover:has(> .select-popover-content)
|
||||
// 把 max-width 上限压到浮层上;锚点类丢失等于宽度上限静默失效
|
||||
const wrapper = mount(Select, {
|
||||
props: { options: [{ value: 'a', label: '选项' }] },
|
||||
})
|
||||
await wrapper.find('.select-trigger').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const content = popoverContent()
|
||||
expect(content).not.toBeNull()
|
||||
expect(content.classList.contains('select-popover-content')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Select 远程分页增强(visible-change / load-more)', () => {
|
||||
it('展开时 emit visible-change(true),供远程选择在打开时加载首页', async () => {
|
||||
const wrapper = mount(Select, { props: { options: [{ value: 'a', label: '甲' }] } })
|
||||
await wrapper.find('.select-trigger').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('visible-change')?.at(-1)).toEqual([true])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('选项列表滚动触底 emit load-more', async () => {
|
||||
const options = Array.from({ length: 30 }, (_, i) => ({ value: String(i), label: `项${i}` }))
|
||||
const wrapper = mount(Select, { props: { options }, attachTo: document.body })
|
||||
await wrapper.find('.select-trigger').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const list = document.body.querySelector('.select-list')
|
||||
expect(list).not.toBeNull()
|
||||
// jsdom 不做布局,手动造出"已滚到触底"的度量:scrollTop + clientHeight >= scrollHeight - 24
|
||||
Object.defineProperty(list, 'scrollHeight', { value: 300, configurable: true })
|
||||
Object.defineProperty(list, 'clientHeight', { value: 200, configurable: true })
|
||||
Object.defineProperty(list, 'scrollTop', { value: 100, configurable: true })
|
||||
list.dispatchEvent(new Event('scroll'))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('load-more')).toBeTruthy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('loading 中滚动触底不重复 emit load-more', async () => {
|
||||
const options = Array.from({ length: 30 }, (_, i) => ({ value: String(i), label: `项${i}` }))
|
||||
const wrapper = mount(Select, { props: { options, loading: true }, attachTo: document.body })
|
||||
await wrapper.find('.select-trigger').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const list = document.body.querySelector('.select-list')
|
||||
Object.defineProperty(list, 'scrollHeight', { value: 300, configurable: true })
|
||||
Object.defineProperty(list, 'clientHeight', { value: 200, configurable: true })
|
||||
Object.defineProperty(list, 'scrollTop', { value: 100, configurable: true })
|
||||
list.dispatchEvent(new Event('scroll'))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('load-more')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -10,40 +10,33 @@ vi.mock('@/services/api', () => ({
|
||||
|
||||
import { loadDataApi } from '@/services/api'
|
||||
|
||||
// 模块中心等系统管理员专用页面不进 s_menu(入口固定在 AppSidebar、路由层 adminOnly 拦截),
|
||||
// 因此这里用业务菜单构造数据,验证菜单 store 本身无任何编码/账号特判。
|
||||
const mockMenus = [
|
||||
{
|
||||
b_id: '1',
|
||||
b_code: 'sys',
|
||||
b_name: '系统',
|
||||
b_code: 'base',
|
||||
b_name: '基础数据',
|
||||
b_menu_type: 'directory',
|
||||
b_parent_id: null,
|
||||
b_canuse: 1,
|
||||
},
|
||||
{
|
||||
b_id: '2',
|
||||
b_code: 'module',
|
||||
b_name: '模块管理',
|
||||
b_code: 'contact',
|
||||
b_name: '往来单位',
|
||||
b_menu_type: 'page',
|
||||
b_parent_id: '1',
|
||||
b_route: '/module/module-management',
|
||||
b_route: '/base/contact',
|
||||
b_canuse: 1,
|
||||
},
|
||||
{
|
||||
// 没有任何子项的目录:可见性裁剪后不应渲染
|
||||
b_id: '3',
|
||||
b_code: 'menu',
|
||||
b_name: '菜单管理',
|
||||
b_menu_type: 'page',
|
||||
b_parent_id: '1',
|
||||
b_route: '/module/menu-management',
|
||||
b_canuse: 1,
|
||||
},
|
||||
{
|
||||
b_id: '4',
|
||||
b_code: 'i18n',
|
||||
b_name: '多语言类型',
|
||||
b_menu_type: 'page',
|
||||
b_parent_id: '1',
|
||||
b_route: '/module/i18n-type',
|
||||
b_code: 'empty-dir',
|
||||
b_name: '空目录',
|
||||
b_menu_type: 'directory',
|
||||
b_parent_id: null,
|
||||
b_canuse: 1,
|
||||
},
|
||||
]
|
||||
@@ -81,7 +74,7 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('menus store(s_menu 动态菜单)', () => {
|
||||
it('普通用户:隐藏编码 module 的菜单(含空目录剪枝)', async () => {
|
||||
it('普通用户:全部启用菜单按树渲染,空目录剪枝(无编码特判)', async () => {
|
||||
mockSModuleData(mockMenus)
|
||||
setUser('u1', 123)
|
||||
await flush()
|
||||
@@ -89,32 +82,24 @@ describe('menus store(s_menu 动态菜单)', () => {
|
||||
await menus.load()
|
||||
|
||||
const codes = collectCodes(menus.visibleMenus)
|
||||
expect(codes).toContain('sys')
|
||||
expect(codes).toContain('menu')
|
||||
expect(codes).toContain('i18n')
|
||||
expect(codes).not.toContain('module')
|
||||
expect(codes).toContain('base')
|
||||
expect(codes).toContain('contact')
|
||||
// 目录没有可见子菜单时不渲染
|
||||
expect(codes).not.toContain('empty-dir')
|
||||
})
|
||||
|
||||
it('普通用户:目录下仅剩 module 子项时,目录整体不渲染', async () => {
|
||||
// 系统目录下只有「模块管理」这一个可用子菜单
|
||||
mockSModuleData(mockMenus.filter((m) => m.b_code !== 'menu' && m.b_code !== 'i18n'))
|
||||
setUser('u1', 123)
|
||||
await flush()
|
||||
const menus = useMenuStore()
|
||||
await menus.load()
|
||||
|
||||
expect(menus.visibleMenus).toEqual([])
|
||||
})
|
||||
|
||||
it('g3soft:module 菜单可见', async () => {
|
||||
it('管理员特判不在菜单 store:g3soft 与普通用户渲染一致', async () => {
|
||||
// 模块中心的管控在路由守卫(meta.adminOnly)与侧栏渲染(仅 g3soft 追加固定入口),
|
||||
// 菜单 store 对所有登录主体一视同仁
|
||||
mockSModuleData(mockMenus)
|
||||
setUser('g3soft', 1)
|
||||
await flush()
|
||||
const menus = useMenuStore()
|
||||
await menus.load()
|
||||
|
||||
expect(menus.isG3soft).toBe(true)
|
||||
expect(collectCodes(menus.visibleMenus)).toContain('module')
|
||||
const codes = collectCodes(menus.visibleMenus)
|
||||
expect(codes).toHaveLength(2)
|
||||
expect(codes).toEqual(expect.arrayContaining(['base', 'contact']))
|
||||
})
|
||||
|
||||
it('同一主体重复 load 不重复发请求', async () => {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { registerFeature, useAreaSelection } from 'stk-table-vue'
|
||||
|
||||
registerFeature(useAreaSelection)
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ path: '/module/i18n-type', meta: {} }),
|
||||
}))
|
||||
|
||||
const querySchema = {
|
||||
schemaVersion: 1,
|
||||
quick: ['b_name'],
|
||||
groups: [{ logic: 'and', conditions: [{ field: 'b_name', operator: 'like' }] }],
|
||||
}
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: vi.fn(async (viewName) => {
|
||||
if (viewName === 's_module') {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
b_id: 's_i18n_type',
|
||||
b_module_type: 'data',
|
||||
b_canuse: 1,
|
||||
b_view_table: 's_i18n_type',
|
||||
b_save_table: 's_i18n_type',
|
||||
b_key_field: 'b_id',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
if (viewName === 's_field') {
|
||||
return {
|
||||
data: [
|
||||
{ b_field: 'b_id', b_name: '语言编码', b_type: 'input', b_canuse: 1, b_i18n: null },
|
||||
{ b_field: 'b_name', b_name: '语言名称', b_type: 'input', b_canuse: 1, b_i18n: null },
|
||||
{ b_field: 'b_status', b_name: '状态', b_type: 'select', b_canuse: 1, b_i18n: null },
|
||||
],
|
||||
}
|
||||
}
|
||||
if (viewName === 's_module_schema') {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
b_module_id: 's_i18n_type',
|
||||
b_schema_type: 'view',
|
||||
b_schema_json: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
columns: [{ field: 'b_id', width: 120 }, { field: 'b_name', width: 180 }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
b_module_id: 's_i18n_type',
|
||||
b_schema_type: 'query',
|
||||
b_schema_json: JSON.stringify(querySchema),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return { data: [] }
|
||||
}),
|
||||
pageDataApi: vi.fn(async () => ({ data: { rows: [], total: 0 } })),
|
||||
saveObjectApi: vi.fn(async () => ({})),
|
||||
nextIdApi: vi.fn(async () => ({ data: '1' })),
|
||||
loadDataBySqlApi: vi.fn(async () => ({ data: [] })),
|
||||
}))
|
||||
|
||||
import FmsModuleListPage from '@/components/fms-module-list/FmsModuleListPage.vue'
|
||||
|
||||
describe('repro page typing freeze', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return 5000
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('apply advanced then reopen and type char by char', async () => {
|
||||
const wrapper = mount(FmsModuleListPage, {
|
||||
props: { dataCode: 's_i18n_type' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
await nextTick()
|
||||
|
||||
const buttons = () => wrapper.findAll('button')
|
||||
const panelButtonOf = (text) =>
|
||||
wrapper.findAll('.filter-drawer button').find((b) => b.text().trim() === text)
|
||||
|
||||
// 打开高级查询,加条件,选字段,填值,应用
|
||||
await buttons().find((b) => b.text().includes('高级查询')).trigger('click')
|
||||
await nextTick()
|
||||
await panelButtonOf('条件').trigger('click')
|
||||
await nextTick()
|
||||
await wrapper.findComponent('.filter-row__field').vm.$emit('update:modelValue', 'b_name')
|
||||
await nextTick()
|
||||
await wrapper.find('.filter-row__value input').setValue('x')
|
||||
await panelButtonOf('应用').trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
|
||||
// 编辑重开抽屉,逐字符输入
|
||||
await buttons().find((b) => b.text().trim() === '编辑').trigger('click')
|
||||
await nextTick()
|
||||
for (const ch of ['y', 'z', '1', '2']) {
|
||||
const input = wrapper.find('.filter-row__value input')
|
||||
await input.setValue(String(input.element.value) + ch)
|
||||
await nextTick()
|
||||
}
|
||||
expect(wrapper.find('.filter-drawer').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('money field typing', async () => {
|
||||
const wrapper = mount(FmsModuleListPage, {
|
||||
props: { dataCode: 's_i18n_type' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
await nextTick()
|
||||
|
||||
const buttons = () => wrapper.findAll('button')
|
||||
const panelButtonOf = (text) =>
|
||||
wrapper.findAll('.filter-drawer button').find((b) => b.text().trim() === text)
|
||||
|
||||
await buttons().find((b) => b.text().includes('高级查询')).trigger('click')
|
||||
await nextTick()
|
||||
await panelButtonOf('条件').trigger('click')
|
||||
await nextTick()
|
||||
// 模拟选一个 money 字段
|
||||
await wrapper.findComponent('.filter-row__field').vm.$emit('update:modelValue', 'b_amount')
|
||||
await nextTick()
|
||||
const input = wrapper.find('.filter-row__value input')
|
||||
for (const ch of ['1', '.', '5', '0']) {
|
||||
await input.setValue(String(input.element.value) + ch)
|
||||
await nextTick()
|
||||
}
|
||||
expect(wrapper.find('.filter-drawer').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('date field typing', async () => {
|
||||
const wrapper = mount(FmsModuleListPage, {
|
||||
props: { dataCode: 's_i18n_type' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
await nextTick()
|
||||
|
||||
const buttons = () => wrapper.findAll('button')
|
||||
const panelButtonOf = (text) =>
|
||||
wrapper.findAll('.filter-drawer button').find((b) => b.text().trim() === text)
|
||||
|
||||
await buttons().find((b) => b.text().includes('高级查询')).trigger('click')
|
||||
await nextTick()
|
||||
await panelButtonOf('条件').trigger('click')
|
||||
await nextTick()
|
||||
await wrapper.findComponent('.filter-row__field').vm.$emit('update:modelValue', 'b_date')
|
||||
await nextTick()
|
||||
expect(wrapper.find('.filter-drawer').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* FmsField 多态控件渲染器测试。
|
||||
* 覆盖契约:type→控件映射、checkbox 0/1↔boolean 归一、v-model 透传、
|
||||
* control-props 直达、fallthrough(class/原生事件)透传、change 转发、
|
||||
* 默认插槽逃生舱、types 注册表覆盖、selectId(FmsSelect) 双绑回显。
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { h, markRaw } from 'vue'
|
||||
import FmsField from '@/components/fms-field/FmsField.vue'
|
||||
import { Checkbox } from '@/components/ui'
|
||||
|
||||
// selectId 分支会挂载 FmsSelect,其依赖 @/services/api;桩掉避免真实请求
|
||||
vi.mock('@/services/api', () => ({
|
||||
pageDataApi: vi.fn(async () => ({ data: { rows: [], total: 0 } })),
|
||||
}))
|
||||
|
||||
describe('FmsField 类型 → 控件映射', () => {
|
||||
it('缺省 type 兜底渲染 Input', () => {
|
||||
const wrapper = mount(FmsField)
|
||||
expect(wrapper.find('.input-wrapper').exists()).toBe(true)
|
||||
expect(wrapper.find('input').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('未知 type 兜底渲染 Input', () => {
|
||||
const wrapper = mount(FmsField, { props: { type: 'not-a-real-type' } })
|
||||
expect(wrapper.find('.input-wrapper').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['textarea', 'textarea'],
|
||||
['number', 'input[inputmode="decimal"]'],
|
||||
['money', 'input[inputmode="decimal"]'],
|
||||
['checkbox', 'label.checkbox'],
|
||||
['select', '.select-trigger'],
|
||||
['date', '.fms-date-picker'],
|
||||
['datetime', '.fms-date-picker'],
|
||||
])('type=%s 渲染对应控件(%s)', (type, selector) => {
|
||||
const wrapper = mount(FmsField, { props: { type } })
|
||||
expect(wrapper.find(selector).exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FmsField 值绑定', () => {
|
||||
it('input:v-model 双向透传', async () => {
|
||||
const wrapper = mount(FmsField, { props: { modelValue: 'a' } })
|
||||
expect(wrapper.find('input').element.value).toBe('a')
|
||||
await wrapper.find('input').setValue('abc')
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['abc'])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('checkbox:存储 1 → 勾选;取消勾选 → 抛 0', async () => {
|
||||
const wrapper = mount(FmsField, { props: { type: 'checkbox', modelValue: 1 } })
|
||||
const box = wrapper.find('input.checkbox-input')
|
||||
expect(box.element.checked).toBe(true)
|
||||
|
||||
await box.setValue(false)
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([0])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('checkbox:存储 0 → 未勾选;勾选 → 抛 1', async () => {
|
||||
const wrapper = mount(FmsField, { props: { type: 'checkbox', modelValue: 0 } })
|
||||
const box = wrapper.find('input.checkbox-input')
|
||||
expect(box.element.checked).toBe(false)
|
||||
|
||||
await box.setValue(true)
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([1])
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FmsField 扩展口子', () => {
|
||||
it('control-props 直达内部控件', () => {
|
||||
const wrapper = mount(FmsField, { props: { controlProps: { maxlength: 10 } } })
|
||||
expect(wrapper.find('input').attributes('maxlength')).toBe('10')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('fallthrough class 合并到内部控件根节点', () => {
|
||||
const wrapper = mount(FmsField, { attrs: { class: 'my-field' } })
|
||||
expect(wrapper.find('.input-wrapper.my-field').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('原生事件透传:@keydown 由内部控件冒泡命中', async () => {
|
||||
const onKeydown = vi.fn()
|
||||
const wrapper = mount(FmsField, { attrs: { onKeydown } })
|
||||
await wrapper.find('input').trigger('keydown', { key: 'Enter' })
|
||||
expect(onKeydown).toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('内部控件 change 原样转发', async () => {
|
||||
const wrapper = mount(FmsField)
|
||||
await wrapper.find('input').trigger('change')
|
||||
expect(wrapper.emitted('change')).toBeTruthy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('默认插槽整体接管渲染(忽略 type)', () => {
|
||||
const wrapper = mount(FmsField, {
|
||||
props: { type: 'select' },
|
||||
slots: { default: () => h('div', { class: 'custom-control' }, '自定义') },
|
||||
})
|
||||
expect(wrapper.find('.custom-control').exists()).toBe(true)
|
||||
expect(wrapper.find('.select-trigger').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('types 注册表覆盖内置类型映射', () => {
|
||||
const wrapper = mount(FmsField, {
|
||||
// 组件放入响应式容器需 markRaw,避免 Vue 告警(消费者注册自定义类型时同理)
|
||||
props: { type: 'input', types: { input: markRaw(Checkbox) } },
|
||||
})
|
||||
expect(wrapper.find('label.checkbox').exists()).toBe(true)
|
||||
expect(wrapper.find('.input-wrapper').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FmsField selectId(FmsSelect 双绑)', () => {
|
||||
it('渲染 FmsSelect,并用 label 回显选中名', () => {
|
||||
const wrapper = mount(FmsField, {
|
||||
props: {
|
||||
type: 'selectId',
|
||||
modelValue: '7',
|
||||
label: '客户甲',
|
||||
controlProps: { viewName: 'v_customer' },
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.fms-select').exists()).toBe(true)
|
||||
expect(wrapper.find('.select-input').element.value).toBe('客户甲')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,13 @@ vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ path: '/module/i18n-type', meta: {} }),
|
||||
}))
|
||||
|
||||
// 查询界面配置 JSON:模块管理的查询配置面板只写 groups(quick 留空),这里按面板真实产物组织
|
||||
const querySchema = {
|
||||
schemaVersion: 1,
|
||||
quick: ['b_name'],
|
||||
groups: [{ logic: 'and', conditions: [{ field: 'b_name', operator: 'like' }] }],
|
||||
}
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: vi.fn(async (viewName) => {
|
||||
if (viewName === 's_module') {
|
||||
@@ -55,11 +62,7 @@ vi.mock('@/services/api', () => ({
|
||||
{
|
||||
b_module_id: 's_i18n_type',
|
||||
b_schema_type: 'query',
|
||||
b_schema_json: JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
quick: ['b_name'],
|
||||
groups: [{ logic: 'and', conditions: [{ field: 'b_name', operator: 'like' }] }],
|
||||
}),
|
||||
b_schema_json: JSON.stringify(querySchema),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -75,9 +78,11 @@ vi.mock('@/services/api', () => ({
|
||||
}))
|
||||
|
||||
import FmsModuleListPage from '@/components/fms-module-list/FmsModuleListPage.vue'
|
||||
import { pageDataApi } from '@/services/api'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
pageDataApi.mockClear()
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||
configurable: true,
|
||||
get() {
|
||||
@@ -86,6 +91,15 @@ beforeEach(() => {
|
||||
})
|
||||
})
|
||||
|
||||
/** 面板内按钮:文案已精简(条件 / 组 / 清空 / 应用 / 取消),按面板作用域 + 精确文案定位 */
|
||||
function panelButtonOf(wrapper, text) {
|
||||
return wrapper.findAll('.filter-drawer button').find((button) => button.text().trim() === text)
|
||||
}
|
||||
|
||||
/** 最近一次分页请求的条件 SQL */
|
||||
const lastCondition = () =>
|
||||
pageDataApi.mock.calls[pageDataApi.mock.calls.length - 1][0].searchCondition
|
||||
|
||||
describe('FmsModuleListPage 冒烟', () => {
|
||||
it('按 data-code 加载字段与界面配置后渲染列,无配置错误', async () => {
|
||||
const wrapper = mount(FmsModuleListPage, {
|
||||
@@ -99,6 +113,161 @@ describe('FmsModuleListPage 冒烟', () => {
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('语言编码')
|
||||
expect(text).toContain('语言名称')
|
||||
|
||||
// 与 dashboard 同布局:上查询卡 + 下表格卡两张白卡,工具条在表格卡内,分页条在卡片底部
|
||||
expect(wrapper.findAll('.fms-module-card')).toHaveLength(2)
|
||||
expect(wrapper.find('.table-card .list-toolbar').exists()).toBe(true)
|
||||
expect(wrapper.find('.module-main-column > .fms-module-card').exists()).toBe(true)
|
||||
expect(wrapper.find('.list-footer .footer-total').text()).toContain('共')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('高级查询面板配置条件后,应用查询把生成的条件带到分页查询', async () => {
|
||||
const wrapper = mount(FmsModuleListPage, {
|
||||
props: { dataCode: 's_i18n_type' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
await nextTick()
|
||||
|
||||
const buttonOf = (text) =>
|
||||
wrapper.findAll('button').find((button) => button.text().includes(text))
|
||||
await buttonOf('高级查询').trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.find('.filter-drawer').exists()).toBe(true)
|
||||
|
||||
await panelButtonOf(wrapper, '条件').trigger('click')
|
||||
await nextTick()
|
||||
// 条件行结构:字段 / 操作符 / 值 三个独立控件,移除按钮在行尾
|
||||
expect(wrapper.find('.filter-row__field').exists()).toBe(true)
|
||||
expect(wrapper.find('.filter-row__operator').exists()).toBe(true)
|
||||
expect(wrapper.find('.filter-row__value').exists()).toBe(true)
|
||||
expect(wrapper.find('.filter-row__remove').exists()).toBe(true)
|
||||
// 新行字段未选:操作符与值为禁用态(禁用占位 Input),不触发「值换行」的区间修饰类
|
||||
expect(wrapper.find('.filter-row__operator').classes('select-disabled')).toBe(true)
|
||||
expect(wrapper.find('.filter-row__value input').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('.filter-drawer__head').exists()).toBe(true)
|
||||
expect(wrapper.find('.module-main-column > .filter-drawer').exists()).toBe(false)
|
||||
expect(wrapper.find('.filter-row--wide-value').exists()).toBe(false)
|
||||
|
||||
// 选中字段后操作符与值激活,再填搜索值
|
||||
await wrapper.findComponent('.filter-row__field').vm.$emit('update:modelValue', 'b_name')
|
||||
await nextTick()
|
||||
expect(wrapper.find('.filter-row__operator').classes('select-disabled')).toBe(false)
|
||||
await wrapper.find('.filter-row__value input').setValue('abc')
|
||||
await panelButtonOf(wrapper, '应用').trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
|
||||
const lastCall = pageDataApi.mock.calls[pageDataApi.mock.calls.length - 1][0]
|
||||
expect(lastCall.searchCondition).toContain("[b_name] LIKE N'%abc%'")
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('查询配置未写 quick 列表时,配置的条件仍显示在常用区', async () => {
|
||||
const original = querySchema.quick
|
||||
querySchema.quick = []
|
||||
const wrapper = mount(FmsModuleListPage, {
|
||||
props: { dataCode: 's_i18n_type' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
await nextTick()
|
||||
|
||||
// 模块只配置了 b_name 一个查询条件,未写 quick 列表时它必须出现在常用区
|
||||
expect(wrapper.findAll('.query-field')).toHaveLength(1)
|
||||
expect(wrapper.find('.query-field__label').text()).toBe('语言名称')
|
||||
querySchema.quick = original
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('条件组是灰底卡片分支:药丸在卡片内、整组删除在右上角,组内条件是它的子层', async () => {
|
||||
const wrapper = mount(FmsModuleListPage, {
|
||||
props: { dataCode: 's_i18n_type' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
await nextTick()
|
||||
|
||||
const buttons = () => wrapper.findAll('button')
|
||||
await buttons()
|
||||
.find((button) => button.text().includes('高级查询'))
|
||||
.trigger('click')
|
||||
await nextTick()
|
||||
// 面板作用域内找「组」按钮,避免与页面其他按钮文案撞车
|
||||
const panel = wrapper.find('.filter-drawer')
|
||||
await panel
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('组'))
|
||||
.trigger('click')
|
||||
await nextTick()
|
||||
|
||||
const group = wrapper.find('.filter-group')
|
||||
expect(group.exists()).toBe(true)
|
||||
// 组是浅底圆角块:底栏两端是「添加条件」与「删除组」文字按钮,体内是条件行
|
||||
expect(group.find('.filter-group__remove').text()).toBe('删除组')
|
||||
expect(group.find('.filter-group__add').exists()).toBe(true)
|
||||
expect(group.findAll('.filter-row')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('常用查询与高级查询互斥:切换模式只保留当前模式的条件', async () => {
|
||||
const wrapper = mount(FmsModuleListPage, {
|
||||
props: { dataCode: 's_i18n_type' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
await nextTick()
|
||||
|
||||
const buttons = () => wrapper.findAll('button')
|
||||
const buttonOf = (text) => buttons().find((button) => button.text().trim() === text)
|
||||
|
||||
// 1) 常用查询生效
|
||||
await wrapper.find('.query-field input').setValue('abc')
|
||||
await buttonOf('查询').trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
expect(lastCondition()).toContain("[b_name] LIKE N'%abc%'")
|
||||
|
||||
// 2) 切到高级查询并应用:常用条件不再生效
|
||||
await buttons()
|
||||
.find((button) => button.text().includes('高级查询'))
|
||||
.trigger('click')
|
||||
await nextTick()
|
||||
await panelButtonOf(wrapper, '条件').trigger('click')
|
||||
await nextTick()
|
||||
await wrapper.findComponent('.filter-row__field').vm.$emit('update:modelValue', 'b_name')
|
||||
await nextTick()
|
||||
await wrapper.find('.filter-row__value input').setValue('xyz')
|
||||
await panelButtonOf(wrapper, '应用').trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
expect(lastCondition()).toContain("[b_name] LIKE N'%xyz%'")
|
||||
expect(lastCondition()).not.toContain('abc')
|
||||
|
||||
// 3) 高级生效时普通表单隐藏、改为条件预览
|
||||
expect(wrapper.find('.advanced-active-bar').exists()).toBe(true)
|
||||
expect(wrapper.find('.fms-query-toolbar').exists()).toBe(false)
|
||||
|
||||
// 3.1) 重新打开抽屉(编辑)再「取消」:只关抽屉,高级查询仍生效(不能借取消退出)
|
||||
await buttons()
|
||||
.find((button) => button.text().trim() === '编辑')
|
||||
.trigger('click')
|
||||
await nextTick()
|
||||
expect(wrapper.find('.filter-drawer').exists()).toBe(true)
|
||||
await panelButtonOf(wrapper, '取消').trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
expect(wrapper.find('.filter-drawer').exists()).toBe(false)
|
||||
expect(wrapper.find('.advanced-active-bar').exists()).toBe(true)
|
||||
expect(lastCondition()).toContain("[b_name] LIKE N'%xyz%'")
|
||||
expect(lastCondition()).not.toContain('abc')
|
||||
|
||||
// 3.2) 点「回到普通查询」:清空高级条件并恢复常用条件
|
||||
await buttons()
|
||||
.find((button) => button.text().includes('回到普通查询'))
|
||||
.trigger('click')
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
expect(lastCondition()).toContain("[b_name] LIKE N'%abc%'")
|
||||
expect(lastCondition()).not.toContain('xyz')
|
||||
// 高级草稿已清空、普通表单重新显示
|
||||
expect(wrapper.find('.fms-query-toolbar').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* FmsSelect 远程查找选择器测试。
|
||||
* 覆盖契约:展开即加载首页、选中抛出 id/label/整行 record、
|
||||
* 键入按 labelKey 防抖远程 LIKE 搜索、当前选中项不在结果中时回显补入列表首。
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import FmsSelect from '@/components/fms-select/FmsSelect.vue'
|
||||
import { pageDataApi } from '@/services/api'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
pageDataApi: vi.fn(),
|
||||
}))
|
||||
|
||||
/** 让 pageDataApi 返回给定行 */
|
||||
function mockRows(rows, total = rows.length) {
|
||||
pageDataApi.mockResolvedValue({ data: { rows, total } })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pageDataApi.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
// 清空 Teleport 到 body 的下拉浮层残留;replaceChildren 不解析 markup,比 innerHTML='' 更安全
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
describe('FmsSelect 远程查找', () => {
|
||||
it('展开即加载首页并渲染选项', async () => {
|
||||
mockRows([
|
||||
{ b_id: '1', b_name: '甲' },
|
||||
{ b_id: '2', b_name: '乙' },
|
||||
])
|
||||
const wrapper = mount(FmsSelect, {
|
||||
props: { viewName: 'v_customer' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
|
||||
await wrapper.find('.select-trigger').trigger('click')
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(pageDataApi).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ viewName: 'v_customer', pageNo: 1 }),
|
||||
)
|
||||
const options = document.body.querySelectorAll('.select-option')
|
||||
expect(options.length).toBe(2)
|
||||
expect(options[0].textContent).toContain('甲')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('选中抛出 id / label / 整行 record', async () => {
|
||||
mockRows([{ b_id: '1', b_name: '甲', extra: 'x' }])
|
||||
const wrapper = mount(FmsSelect, {
|
||||
props: { viewName: 'v_customer' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
|
||||
await wrapper.find('.select-trigger').trigger('click')
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
document.body.querySelector('.select-option').click()
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['1'])
|
||||
expect(wrapper.emitted('update:label')?.at(-1)).toEqual(['甲'])
|
||||
expect(wrapper.emitted('change')?.at(-1)?.[0]).toEqual({ b_id: '1', b_name: '甲', extra: 'x' })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('当前选中项不在结果中时回显到列表首', async () => {
|
||||
mockRows([{ b_id: '2', b_name: '乙' }])
|
||||
const wrapper = mount(FmsSelect, {
|
||||
props: { viewName: 'v_customer', modelValue: '9', label: '既存客户' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
|
||||
await wrapper.find('.select-trigger').trigger('click')
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
const options = document.body.querySelectorAll('.select-option')
|
||||
expect(options.length).toBe(2)
|
||||
expect(options[0].textContent).toContain('既存客户')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('键入按 labelKey 远程 LIKE 搜索(防抖)', async () => {
|
||||
mockRows([])
|
||||
const wrapper = mount(FmsSelect, {
|
||||
props: { viewName: 'v_customer', searchDelay: 300 },
|
||||
attachTo: document.body,
|
||||
})
|
||||
|
||||
// 先展开:immediate('') 会拉一次全量,与后续搜索区分开
|
||||
await wrapper.find('.select-trigger').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
vi.useFakeTimers()
|
||||
pageDataApi.mockClear()
|
||||
await wrapper.find('.select-input').setValue('kw')
|
||||
// 防抖窗口内不发请求
|
||||
expect(pageDataApi).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
expect(pageDataApi).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ searchCondition: "b_name LIKE '%kw%'" }),
|
||||
)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import FmsQueryControl from '@/components/fms-module-list/FmsQueryControl.vue'
|
||||
import FmsQueryToolbar from '@/components/fms-module-list/FmsQueryToolbar.vue'
|
||||
import FmsAdvancedQueryPanel from '@/components/fms-module-list/FmsAdvancedQueryPanel.vue'
|
||||
|
||||
describe('fms-module-list components', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
@@ -25,4 +26,43 @@ describe('fms-module-list components', () => {
|
||||
expect(wrapper.find('.fms-query-toolbar').exists()).toBe(true)
|
||||
expect(wrapper.html()).not.toContain('<a-')
|
||||
})
|
||||
|
||||
it('高级查询行间连接符表达行与行的连接:≥2 项才出现,点击切换后一项的 join', async () => {
|
||||
const state = {
|
||||
nodes: [
|
||||
{ id: 'n1', join: 'and', field: 'name', operator: 'like', value: 'a' },
|
||||
{ id: 'n2', join: 'and', field: 'name', operator: 'like', value: 'b' },
|
||||
],
|
||||
groups: [],
|
||||
}
|
||||
const wrapper = mount(FmsAdvancedQueryPanel, {
|
||||
props: { open: true, items: [], modelValue: state },
|
||||
})
|
||||
|
||||
const pill = wrapper.find('.filter-joiner .joiner-pill')
|
||||
expect(pill.exists()).toBe(true)
|
||||
expect(pill.text()).toBe('且')
|
||||
|
||||
await pill.trigger('click')
|
||||
expect(state.nodes[1].join).toBe('or')
|
||||
expect(wrapper.find('.filter-joiner .joiner-pill').text()).toBe('或')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('只有 1 个条件时不显示行间连接符(没有连接对象),条件行直接可见', () => {
|
||||
const wrapper = mount(FmsAdvancedQueryPanel, {
|
||||
props: {
|
||||
open: true,
|
||||
items: [],
|
||||
modelValue: {
|
||||
logic: 'and',
|
||||
nodes: [{ id: 'n1', field: 'name', operator: 'like', value: '' }],
|
||||
groups: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.filter-joiner').exists()).toBe(false)
|
||||
expect(wrapper.find('.filter-row').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const loadDataApi = vi.fn()
|
||||
const saveObjectApi = vi.fn()
|
||||
const authState = { userInfo: { account: 'u001' } }
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: (...args) => loadDataApi(...args),
|
||||
saveObjectApi: (...args) => saveObjectApi(...args),
|
||||
}))
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => authState,
|
||||
}))
|
||||
|
||||
const {
|
||||
buildDefaultQueryLayout,
|
||||
currentUserId,
|
||||
isEmptyDelta,
|
||||
layoutDelta,
|
||||
loadFieldPower,
|
||||
loadModulePref,
|
||||
mergeQueryLayout,
|
||||
removeModulePref,
|
||||
saveModulePref,
|
||||
} = await import('@/components/fms-module-list/useModulePref')
|
||||
|
||||
const items = [
|
||||
{ field: { b_field: 'b_no' }, config: { b_operator: 'like' } },
|
||||
{ field: { b_field: 'b_name' }, config: { b_operator: 'like' } },
|
||||
{ field: { b_field: 'b_bz' }, config: { b_operator: 'like' } },
|
||||
]
|
||||
|
||||
describe('用户模块偏好', () => {
|
||||
beforeEach(() => {
|
||||
loadDataApi.mockReset()
|
||||
saveObjectApi.mockReset()
|
||||
loadDataApi.mockResolvedValue({ data: [] })
|
||||
saveObjectApi.mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('取当前登录账号作为偏好用户编码', () => {
|
||||
expect(currentUserId()).toBe('u001')
|
||||
})
|
||||
|
||||
it('默认布局:quick 列表中的字段进常用区并靠前排序', () => {
|
||||
const layout = buildDefaultQueryLayout(items, ['b_name', 'b_no'])
|
||||
expect(layout.area).toEqual({ b_name: 'quick', b_no: 'quick', b_bz: 'advanced' })
|
||||
expect(layout.order.b_name).toBeLessThan(layout.order.b_no)
|
||||
})
|
||||
|
||||
it('未配置 quick 列表时,配置了的查询条件全部进常用区(配置了就要能看到)', () => {
|
||||
const layout = buildDefaultQueryLayout(items, [])
|
||||
expect(layout.area).toEqual({ b_no: 'quick', b_name: 'quick', b_bz: 'quick' })
|
||||
})
|
||||
|
||||
it('查询 JSON 节点上的 area 显式配置优先', () => {
|
||||
const configured = [
|
||||
{ field: { b_field: 'b_no' }, node: { area: 'hidden' } },
|
||||
{ field: { b_field: 'b_name' }, node: { area: 'advanced' } },
|
||||
]
|
||||
const layout = buildDefaultQueryLayout(configured, [])
|
||||
expect(layout.area).toEqual({ b_no: 'hidden', b_name: 'advanced' })
|
||||
})
|
||||
|
||||
it('偏好只覆盖布局,无权限字段不得因偏好复活', () => {
|
||||
const defaultLayout = buildDefaultQueryLayout(items, ['b_no'])
|
||||
const pref = {
|
||||
schemaVersion: 1,
|
||||
area: { b_bz: 'quick', b_status: 'quick' },
|
||||
order: { b_bz: 1 },
|
||||
}
|
||||
const merged = mergeQueryLayout(defaultLayout, pref, new Set(['b_no', 'b_name', 'b_bz']))
|
||||
expect(merged.area.b_bz).toBe('quick')
|
||||
expect(merged.area.b_status).toBeUndefined()
|
||||
expect(merged.order.b_status).toBeUndefined()
|
||||
})
|
||||
|
||||
it('三层合并:企业层盖系统默认,个人层再盖企业层', () => {
|
||||
const sysDefault = buildDefaultQueryLayout(items, ['b_no']) // b_no 常用,其余靠后
|
||||
const globalPref = { schemaVersion: 1, area: { b_name: 'quick' } }
|
||||
const enterprise = mergeQueryLayout(sysDefault, globalPref, new Set(['b_no', 'b_name', 'b_bz']))
|
||||
expect(enterprise.area.b_name).toBe('quick')
|
||||
// 个人层在企业层之上进一步调整
|
||||
const personalPref = { schemaVersion: 1, area: { b_name: 'hidden' } }
|
||||
const merged = mergeQueryLayout(enterprise, personalPref, new Set(['b_no', 'b_name', 'b_bz']))
|
||||
expect(merged.area.b_name).toBe('hidden')
|
||||
// 企业层没动的字段保持系统默认
|
||||
expect(merged.area.b_bz).toBe(sysDefault.area.b_bz)
|
||||
})
|
||||
|
||||
it('增量覆盖:只保留与基线不同的字段', () => {
|
||||
const base = { area: { a: 'quick', b: 'advanced' }, order: { a: 10, b: 20 } }
|
||||
const current = { area: { a: 'quick', b: 'quick' }, order: { a: 30, b: 20 } }
|
||||
const delta = layoutDelta(current, base)
|
||||
expect(delta.area).toEqual({ b: 'quick' })
|
||||
expect(delta.order).toEqual({ a: 30 })
|
||||
expect(isEmptyDelta(delta)).toBe(false)
|
||||
expect(isEmptyDelta(layoutDelta(base, base))).toBe(true)
|
||||
})
|
||||
|
||||
it('读取个人偏好时按用户/模块/schema 类型过滤,解析失败返回 null', async () => {
|
||||
loadDataApi.mockResolvedValueOnce({ data: [{ b_schema_json: '{ "schemaVersion": 1 }' }] })
|
||||
await expect(loadModulePref('u001', 'sea')).resolves.toEqual({ schemaVersion: 1 })
|
||||
expect(loadDataApi.mock.calls[0][0]).toBe('s_user_module_pref')
|
||||
expect(loadDataApi.mock.calls[0][1]).toContain("b_user_id = N'u001'")
|
||||
expect(loadDataApi.mock.calls[0][1]).toContain("b_schema_type = N'query'")
|
||||
|
||||
loadDataApi.mockResolvedValueOnce({ data: [{ b_schema_json: 'not-json' }] })
|
||||
await expect(loadModulePref('u001', 'sea')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('删除个人层覆盖用于恢复默认', async () => {
|
||||
await removeModulePref('u001', 'sea')
|
||||
expect(saveObjectApi.mock.calls[0][0][0].table).toBe('s_user_module_pref')
|
||||
expect(saveObjectApi.mock.calls[0][0][0].deletes).toEqual([
|
||||
{ b_user_id: 'u001', b_module_id: 'sea', b_schema_type: 'query' },
|
||||
])
|
||||
})
|
||||
|
||||
it('保存偏好:首次写入走 inserts,主键冲突时重试 updates', async () => {
|
||||
await saveModulePref('u001', 'sea', { schemaVersion: 1 }, false)
|
||||
expect(saveObjectApi.mock.calls[0][0][0].inserts).toHaveLength(1)
|
||||
|
||||
saveObjectApi.mockRejectedValueOnce(new Error('duplicate key'))
|
||||
await saveModulePref('u001', 'sea', { schemaVersion: 1 }, false)
|
||||
expect(saveObjectApi.mock.calls[2][0][0].updates).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('字段查询权限:b_query = 0 的字段被标记为不可查询', async () => {
|
||||
loadDataApi.mockResolvedValueOnce({
|
||||
data: [
|
||||
{ b_field_id: 'b_no', b_query: 1 },
|
||||
{ b_field_id: 'b_bz', b_query: 0 },
|
||||
],
|
||||
})
|
||||
const power = await loadFieldPower('u001', 'sea')
|
||||
expect(power.get('b_no')).toBe(1)
|
||||
expect(power.get('b_bz')).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
astToSql,
|
||||
buildAdvancedAst,
|
||||
buildQuickAst,
|
||||
defaultOperator,
|
||||
emptyConditionValue,
|
||||
hasConditionValue,
|
||||
operatorsForField,
|
||||
} from '@/components/fms-module-common/queryUtils'
|
||||
|
||||
const fields = new Map([
|
||||
['name', { b_field: 'name', b_type: 'input' }],
|
||||
['amount', { b_field: 'amount', b_type: 'money' }],
|
||||
['created', { b_field: 'created', b_type: 'datetime' }],
|
||||
['status', { b_field: 'status', b_type: 'select' }],
|
||||
])
|
||||
|
||||
describe('查询条件 AST 构造', () => {
|
||||
it('按字段类型给出默认操作符:文本 like、日期 between、其余 eq', () => {
|
||||
expect(defaultOperator({ b_type: 'input' })).toBe('like')
|
||||
expect(defaultOperator({ b_type: 'datetime' })).toBe('between')
|
||||
expect(defaultOperator({ b_type: 'select' })).toBe('eq')
|
||||
expect(operatorsForField({ b_type: 'money' })).toContain('between')
|
||||
})
|
||||
|
||||
it('生成转义后的 like / 不等 / 空值条件', () => {
|
||||
expect(
|
||||
astToSql({ type: 'condition', field: 'name', operator: 'like', value: "O'Reilly" }, fields),
|
||||
).toBe("[name] LIKE N'%O''Reilly%'")
|
||||
expect(
|
||||
astToSql({ type: 'condition', field: 'name', operator: 'not_like', value: 'a' }, fields),
|
||||
).toBe("[name] NOT LIKE N'%a%'")
|
||||
expect(
|
||||
astToSql({ type: 'condition', field: 'status', operator: 'ne', value: 'done' }, fields),
|
||||
).toBe("[status] <> N'done'")
|
||||
expect(
|
||||
astToSql({ type: 'condition', field: 'name', operator: 'is_null', value: '' }, fields),
|
||||
).toBe('[name] IS NULL')
|
||||
})
|
||||
|
||||
it('区间条件可退化为单边比较,日期单边取对应端点', () => {
|
||||
expect(
|
||||
astToSql(
|
||||
{ type: 'condition', field: 'amount', operator: 'between', value: ['10', ''] },
|
||||
fields,
|
||||
),
|
||||
).toBe('[amount] >= 10')
|
||||
expect(
|
||||
astToSql(
|
||||
{ type: 'condition', field: 'created', operator: 'ge', value: ['2026-01-01', ''] },
|
||||
fields,
|
||||
),
|
||||
).toBe("[created] >= N'2026-01-01 00:00:00'")
|
||||
expect(
|
||||
astToSql(
|
||||
{ type: 'condition', field: 'created', operator: 'le', value: ['', '2026-01-31'] },
|
||||
fields,
|
||||
),
|
||||
).toBe("[created] <= N'2026-01-31 23:59:59'")
|
||||
})
|
||||
|
||||
it('嵌套条件组按各自逻辑加括号,空组不产生条件', () => {
|
||||
const ast = {
|
||||
type: 'group',
|
||||
logic: 'and',
|
||||
children: [
|
||||
{ type: 'condition', field: 'status', operator: 'eq', value: 'new' },
|
||||
{
|
||||
type: 'group',
|
||||
logic: 'or',
|
||||
children: [
|
||||
{ type: 'condition', field: 'name', operator: 'like', value: 'a' },
|
||||
{ type: 'condition', field: 'name', operator: 'like', value: '' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
expect(astToSql(ast, fields)).toBe("([status] = N'new' AND [name] LIKE N'%a%')")
|
||||
expect(astToSql({ type: 'group', logic: 'and', children: [] }, fields)).toBe('')
|
||||
})
|
||||
|
||||
it('常用条件跳过空值,操作符取模块配置', () => {
|
||||
const items = [
|
||||
{ field: fields.get('name'), config: { b_operator: 'like' } },
|
||||
{ field: fields.get('status'), config: { b_operator: 'eq' } },
|
||||
]
|
||||
const ast = buildQuickAst(items, { name: 'abc', status: '' })
|
||||
expect(ast.children).toHaveLength(1)
|
||||
expect(astToSql(ast, fields)).toBe("[name] LIKE N'%abc%'")
|
||||
})
|
||||
|
||||
it('高级条件按每项 join 生成 AST,单条件的组不再套一层', () => {
|
||||
const ast = buildAdvancedAst({
|
||||
nodes: [
|
||||
{ join: 'or', field: 'name', operator: 'like', value: 'a' },
|
||||
{ join: 'or', field: 'name', operator: 'like', value: 'b' },
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
id: 'g1',
|
||||
join: 'or',
|
||||
nodes: [{ join: 'and', field: 'status', operator: 'eq', value: 'new' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
// join 决定每项与上一相邻项的连接;首项 join 忽略;单条件的组等价于一个普通条件
|
||||
expect(astToSql(ast, fields)).toBe(
|
||||
"([name] LIKE N'%a%' OR [name] LIKE N'%b%' OR [status] = N'new')",
|
||||
)
|
||||
})
|
||||
|
||||
it('支持 (A 且 B) 或 C:组行内且、组与前一行或', () => {
|
||||
const ast = buildAdvancedAst({
|
||||
nodes: [{ join: 'or', field: 'name', operator: 'eq', value: '张三' }],
|
||||
groups: [
|
||||
{
|
||||
id: 'g1',
|
||||
join: 'or',
|
||||
nodes: [
|
||||
{ join: 'and', field: 'amount', operator: 'gt', value: 100 },
|
||||
{ join: 'and', field: 'amount', operator: 'gt', value: 1000 },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(astToSql(ast, fields)).toBe("([name] = N'张三' OR ([amount] > 100 AND [amount] > 1000))")
|
||||
})
|
||||
|
||||
it('同一层可混用 且/或:(a 且 b) 或 c 从左到右加括号', () => {
|
||||
const ast = buildAdvancedAst({
|
||||
nodes: [
|
||||
{ join: 'and', field: 'name', operator: 'like', value: 'a' },
|
||||
{ join: 'and', field: 'name', operator: 'like', value: 'b' },
|
||||
{ join: 'or', field: 'name', operator: 'like', value: 'c' },
|
||||
],
|
||||
})
|
||||
expect(astToSql(ast, fields)).toBe(
|
||||
"(([name] LIKE N'%a%' AND [name] LIKE N'%b%') OR [name] LIKE N'%c%')",
|
||||
)
|
||||
})
|
||||
|
||||
it('区间 / 多选只要有一项即算已填写,空值按操作符重置', () => {
|
||||
expect(hasConditionValue(['', '20'])).toBe(true)
|
||||
expect(hasConditionValue(['', ''])).toBe(false)
|
||||
expect(hasConditionValue(undefined)).toBe(false)
|
||||
expect(emptyConditionValue({ b_type: 'money' }, 'between')).toEqual(['', ''])
|
||||
expect(emptyConditionValue({ b_type: 'select' }, 'eq')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -30,12 +30,14 @@ describe('路由分组(嵌套结构)', () => {
|
||||
expect(router.currentRoute.value.path).toBe('/module/module-management')
|
||||
})
|
||||
|
||||
it('菜单路由选项:含分组内子路由的完整路径,排除分组节点与动态/通配路由', () => {
|
||||
it('菜单路由选项:排除模块中心(adminOnly)整组与分组节点/动态/通配路由', () => {
|
||||
const values = menuRouteOptions.map((option) => option.value)
|
||||
|
||||
expect(values).toContain('/module/module-management')
|
||||
expect(values).toContain('/module/menu-management')
|
||||
expect(values).toContain('/module/i18n-type')
|
||||
// 模块中心是系统管理员专用页(meta.adminOnly):整组排除,
|
||||
// 「菜单管理」的路由下拉配不出第二入口
|
||||
expect(values).not.toContain('/module/module-management')
|
||||
expect(values).not.toContain('/module/menu-management')
|
||||
expect(values).not.toContain('/module/i18n-type')
|
||||
expect(values).toContain('/dashboard')
|
||||
// 分组节点本身、详情页(:id)、404 通配、登录页都不是菜单入口
|
||||
expect(values).not.toContain('/module')
|
||||
|
||||
Reference in new issue
Block a user