20260913231607

This commit is contained in:
oneao committed 2026-09-13 23:16:08 +08:00
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()
})
})
+23 -38
View File
@@ -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')