20260921231313

This commit is contained in:
oneao committed 2026-09-21 23:13:14 +08:00
1 parent ac710c1801
commit 583984c24f
81 files changed
+2523 -288

No files matched your search

@@ -23,6 +23,11 @@ describe('Button 组件', () => {
expect(btn.classes()).toContain('button-loading')
})
it('danger-outline 描边危险变体映射 class', () => {
const wrapper = mount(Button, { props: { type: 'danger-outline' } })
expect(wrapper.find('button').classes()).toContain('button-danger-outline')
})
it('loading 或 disabled 时禁用原生按钮', () => {
const loading = mount(Button, { props: { loading: true } })
expect(loading.find('button').attributes('disabled')).toBeDefined()
@@ -47,6 +47,16 @@ describe('app store · 主题与布局', () => {
expect(app.sidebarCollapsed).toBe(false)
})
it('toggleContentMaximize:切换内容最大化状态', () => {
createTestPinia()
const app = useAppStore()
expect(app.contentMaximized).toBe(false)
app.toggleContentMaximize()
expect(app.contentMaximized).toBe(true)
app.toggleContentMaximize()
expect(app.contentMaximized).toBe(false)
})
it('i18n store 管理界面语言切换', () => {
createTestPinia()
const i18n = useI18nStore()
@@ -1,5 +1,5 @@
/**
* 批量导出弹窗:选列、三种导出方式、保存导出列。
* 自定义导出弹窗:选列、三种导出方式、保存导出列。
*
* Modal 用 Teleport 挂到 body,所以断言都从 document 上找节点(与 modal-confirm.spec.js 一致)。
* 这里守住的是「弹窗参数拼得对不对」—— 导出本身由后端测试覆盖,前端错在这里最容易漏。
@@ -56,7 +56,7 @@ async function download() {
await nextTick()
}
describe('FmsExportDialog 批量导出', () => {
describe('FmsExportDialog 自定义导出', () => {
afterEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
@@ -9,9 +9,9 @@ import { menuRouteOptions, routes } from '@/router/routes'
* 页面会下沉一层、脱离 DefaultLayout 的 keep-alive,缓存静默失效。
*
* 2. 分组节点也不配 redirect:分组只是归类,不该自带「默认落地页」语义。
* 直接访问 /base、/module 这类裸前缀时,交给表尾的 /:pathMatch(.*)* 兜到工作台
* (/dashboard)—— 业务入口一律由 s_menu + 路由地址维护,避免出现「菜单上没配、
* 却能落在某个页面」的第二入口。
* 直接访问 /base、/module 这类裸前缀时,交给表尾的 /:pathMatch(.*)* 渲染 404 缺省页
* —— 业务入口一律由 s_menu + 路由地址维护,避免出现「菜单上没配、却能落在某个页面」
* 的第二入口。
*/
function createTestRouter() {
return createRouter({ history: createMemoryHistory(), routes })
@@ -40,12 +40,14 @@ describe('路由分组(嵌套结构)', () => {
expect(group.redirect).toBeFalsy()
})
it('访问分组裸前缀时兜到工作台,不白屏', async () => {
it('访问分组裸前缀时落入 404 缺省页,不白屏也不落业务页', async () => {
const router = createTestRouter()
await router.push('/module')
// 分组无 component、无 redirect,裸前缀由表尾 /:pathMatch(.*)* 兜底
expect(router.currentRoute.value.path).toBe('/dashboard')
// 分组无 component、无 redirect,裸前缀由表尾 /:pathMatch(.*)* 渲染 404 缺省页;
// 地址保留原样不重定向,用户能看到自己输错的是什么
expect(router.currentRoute.value.name).toBe('fallback-404')
expect(router.currentRoute.value.path).toBe('/module')
})
it('菜单路由选项:排除模块中心(adminOnly)整组与分组节点/动态/通配路由', () => {
@@ -57,9 +59,11 @@ describe('路由分组(嵌套结构)', () => {
expect(values).not.toContain('/module/menu-management')
expect(values).not.toContain('/module/i18n-type')
expect(values).toContain('/dashboard')
// 分组节点本身、详情页(:id)、404 通配、登录页都不是菜单入口
// 分组节点本身、详情页(:id)、404 通配、登录页与缺省页都不是菜单入口
expect(values).not.toContain('/module')
expect(values).not.toContain('/detail/:id')
expect(values).not.toContain('/login')
expect(values).not.toContain('/403')
expect(values).not.toContain('/500')
})
})
@@ -0,0 +1,140 @@
/**
* 缺省页:404(未知路径)/ 403(无权限)/ 500(渲染错误边界)/ 离线(断网覆盖层)。
* 挂载真实 App(布局、路由、store 全真,页面用桩替换),覆盖:
* 1. 未知路径渲染 404 页并保留原地址(独立布局,未登录也可达)
* 2. 无权限访问模块页由守卫跳到 /403 并在布局内渲染(保留侧栏 / 页签导航)
* 3. 直接访问 /500 在布局内渲染
* 4. 断网事件显示离线覆盖层,恢复后移除;覆盖层期间原页面不卸载
* 5. ErrorBoundary 捕获子树渲染错误时就地渲染 500 页,不出白屏
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
vi.mock('@/views/dashboard/index.vue', () => ({
default: { name: 'Dashboard', template: '<div data-page="Dashboard">DB</div>' },
}))
vi.mock('@/views/module/module-management/index.vue', () => ({
default: { name: 'ModuleManagement', template: '<div data-page="ModuleManagement">MM</div>' },
}))
vi.mock('@/views/detail/DetailPage.vue', () => ({
default: { name: 'DetailPageStub', props: ['id'], template: '<div data-page="Detail" />' },
}))
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })),
describeApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })),
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
nextIdApi: vi.fn(() => Promise.resolve({ data: 1, code: 0, success: true })),
}))
import App from '@/App.vue'
import ErrorBoundary from '@/views/fallback/ErrorBoundary.vue'
import router from '@/router'
import { createTestPinia } from '../helpers/app.js'
import { useAuthStore } from '@/stores/auth'
beforeEach(async () => {
localStorage.clear()
sessionStorage.clear()
createTestPinia()
// 普通账号(非 g3soft):用于验证「无权限访问模块中心 → 403」
useAuthStore().setSession({ token: 't', user: { id: 1, account: 'user1', name: 'U' } }, 'ORG')
await router.push('/dashboard')
await router.isReady()
})
afterEach(() => {
// 离线用例会把 navigator.onLine 改写为 false,恢复默认值避免串扰
Object.defineProperty(window.navigator, 'onLine', { value: true, configurable: true })
})
async function mountApp() {
const wrapper = mount(App, { global: { plugins: [router] }, attachTo: document.body })
await flushPromises()
return wrapper
}
describe('缺省页', () => {
it('未知路径:渲染 404 页并保留原地址', async () => {
const wrapper = await mountApp()
await router.push('/no-such-page')
await flushPromises()
// 不重定向:地址保持原样,用户能看到自己输错的是什么
expect(router.currentRoute.value.path).toBe('/no-such-page')
expect(wrapper.find('.fms-fallback').exists()).toBe(true)
expect(wrapper.text()).toContain('页面不存在')
// 独立布局:不挂 DefaultLayout(未知路径与登录态无关,未登录也能看)
expect(wrapper.find('.fms-topbar').exists()).toBe(false)
wrapper.unmount()
})
it('无权限访问模块中心:守卫跳 /403,并在布局内渲染(保留导航)', async () => {
const wrapper = await mountApp()
await router.push('/module/module-management')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/403')
expect(wrapper.find('.fms-fallback').exists()).toBe(true)
expect(wrapper.text()).toContain('无权访问')
// 缺省页挂在 DefaultLayout 内:顶栏 / 页签栏都在,用户可直接切走
expect(wrapper.find('.fms-topbar').exists()).toBe(true)
expect(wrapper.find('.fms-tabs').exists()).toBe(true)
wrapper.unmount()
})
it('直接访问 /500:在布局内渲染', async () => {
const wrapper = await mountApp()
await router.push('/500')
await flushPromises()
expect(wrapper.find('.fms-fallback').exists()).toBe(true)
expect(wrapper.text()).toContain('页面出错了')
expect(wrapper.find('.fms-topbar').exists()).toBe(true)
wrapper.unmount()
})
it('断网:显示离线覆盖层,恢复后移除且原页面不卸载', async () => {
const wrapper = await mountApp()
expect(wrapper.find('[data-page="Dashboard"]').exists()).toBe(true)
expect(wrapper.find('.fms-app-offline').exists()).toBe(false)
Object.defineProperty(window.navigator, 'onLine', { value: false, configurable: true })
window.dispatchEvent(new Event('offline'))
await nextTick()
expect(wrapper.find('.fms-app-offline').exists()).toBe(true)
expect(wrapper.text()).toContain('网络已断开')
// 覆盖层方案:原页面仍在 DOM 中(表单状态 / 滚动位置保留)
expect(wrapper.find('[data-page="Dashboard"]').exists()).toBe(true)
Object.defineProperty(window.navigator, 'onLine', { value: true, configurable: true })
window.dispatchEvent(new Event('online'))
await nextTick()
expect(wrapper.find('.fms-app-offline').exists()).toBe(false)
wrapper.unmount()
})
it('ErrorBoundary:子树渲染错误时就地渲染 500 页,不出白屏', async () => {
// 错误是测试故意触发的:静默 Vue / 边界自身的 console.error 输出
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const Boom = {
name: 'Boom',
setup() {
throw new Error('boom')
},
}
const wrapper = mount(ErrorBoundary, {
global: { plugins: [router] },
slots: { default: Boom },
})
await flushPromises()
expect(wrapper.find('.fms-fallback').exists()).toBe(true)
expect(wrapper.text()).toContain('页面出错了')
wrapper.unmount()
spy.mockRestore()
})
})
@@ -0,0 +1,76 @@
/**
* 回归:路由过渡的页面外壳(.fms-page-holder,由 getPageWrapper 的渲染函数创建)。
*
* 路由页面是异步组件(懒加载 chunk),加载中的渲染是注释节点(非元素根)——
* <Transition> 无法动画化非元素根:既报 warning(Component inside <Transition>
* renders non-element root node that cannot be animated),out-in 下 enter 也会失败、
* 页面内容一直不显示。页面外壳是恒定元素,兜住该场景。
*
* 本用例用「先渲染 null 再渲染元素」的桩模拟异步组件加载过程,断言:
* 1. 全程不出现 non-element root 警告;
* 2. 异步就绪后内容正常显示(过渡没有吞掉页面)。
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
vi.mock('@/views/dashboard/index.vue', async () => {
const { h, onMounted, ref } = await import('vue')
return {
default: {
name: 'Dashboard',
setup() {
const ready = ref(false)
onMounted(() => {
setTimeout(() => {
ready.value = true
}, 30)
})
// 加载中渲染 null(Vue 渲染为注释节点),模拟异步组件解析前的状态
return () => (ready.value ? h('div', { 'data-page': 'Dashboard' }, 'DB') : null)
},
},
}
})
vi.mock('@/views/detail/DetailPage.vue', () => ({
default: { name: 'DetailPageStub', props: ['id'], template: '<div />' },
}))
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })),
describeApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })),
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
nextIdApi: vi.fn(() => Promise.resolve({ data: 1, code: 0, success: true })),
}))
import App from '@/App.vue'
import router from '@/router'
import { createTestPinia } from '../helpers/app.js'
import { useAuthStore } from '@/stores/auth'
beforeEach(async () => {
localStorage.clear()
sessionStorage.clear()
createTestPinia()
useAuthStore().setSession({ token: 't', user: { id: 1, account: 'g3soft', name: 'U' } }, 'ORG')
await router.push('/dashboard')
await router.isReady()
})
describe('路由过渡 · 页面外壳', () => {
it('异步页面加载中不产生 non-element root 警告,完成后内容正常显示', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const wrapper = mount(App, { global: { plugins: [router] }, attachTo: document.body })
await flushPromises()
// 等待桩组件异步就绪(模拟 chunk 下载完成)
await new Promise((resolve) => setTimeout(resolve, 60))
await flushPromises()
const warnings = warnSpy.mock.calls.map((args) => String(args[0])).join('\n')
expect(warnings).not.toContain('non-element root node')
// 内容最终可见(过渡未吞掉页面)
expect(wrapper.find('[data-page="Dashboard"]').exists()).toBe(true)
warnSpy.mockRestore()
wrapper.unmount()
})
})
@@ -0,0 +1,128 @@
/**
* 页签栏右侧工具区(照 vben 的 TabsTool*):更多 / 刷新当前页 / 内容最大化。
* 挂载真实 App(布局、页签、store、路由全真),覆盖:
* 1. 三个工具按钮渲染
* 2. 内容最大化:顶栏摘除 + is-maximized 标记(侧栏由该标记隐藏),页签栏保留;再次点击还原
* 3. 刷新按钮:当前页刷新计数 +1(页面组件重挂载由 keepalive-cache 用例覆盖)
* 4. 「更多」菜单 = 当前激活页签的上下文菜单(与右键菜单同源),点击菜单项生效
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
vi.mock('@/views/dashboard/index.vue', () => ({
default: { name: 'Dashboard', template: '<div data-page="Dashboard">DB</div>' },
}))
vi.mock('@/views/module/module-management/index.vue', () => ({
default: { name: 'ModuleManagement', template: '<div data-page="ModuleManagement">MM</div>' },
}))
vi.mock('@/views/module/menu-management/index.vue', () => ({
default: { name: 'MenuManagement', template: '<div data-page="MenuManagement">MENU</div>' },
}))
vi.mock('@/views/detail/DetailPage.vue', () => ({
default: { name: 'DetailPageStub', props: ['id'], template: '<div data-page="Detail" />' },
}))
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })),
describeApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })),
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
nextIdApi: vi.fn(() => Promise.resolve({ data: 1, code: 0, success: true })),
}))
import App from '@/App.vue'
import router from '@/router'
import { createTestPinia } from '../helpers/app.js'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
beforeEach(async () => {
localStorage.clear()
sessionStorage.clear()
createTestPinia()
// 模块中心 /module/* 在路由层仅 g3soft 放行
useAuthStore().setSession({ token: 't', user: { id: 1, account: 'g3soft', name: 'U' } }, 'ORG')
await router.push('/dashboard')
await router.isReady()
})
async function mountApp() {
const wrapper = mount(App, { global: { plugins: [router] }, attachTo: document.body })
await flushPromises()
return wrapper
}
const TOOL_MORE = 0
const TOOL_REFRESH = 1
const TOOL_MAXIMIZE = 2
describe('页签栏右侧工具区', () => {
it('渲染三个工具按钮:更多 / 刷新 / 内容最大化', async () => {
const wrapper = await mountApp()
expect(wrapper.findAll('.fms-tabs-tool')).toHaveLength(3)
wrapper.unmount()
})
it('内容最大化:顶栏摘除、侧栏标记隐藏,页签栏保留;再次点击还原', async () => {
const wrapper = await mountApp()
const app = useAppStore()
// 最大化前:顶栏在、无 is-maximized 标记
expect(wrapper.find('.fms-topbar').exists()).toBe(true)
expect(wrapper.find('.fms-layout').classes()).not.toContain('is-maximized')
await wrapper.findAll('.fms-tabs-tool')[TOOL_MAXIMIZE].trigger('click')
expect(app.contentMaximized).toBe(true)
// 顶栏由模板 v-if 摘除;侧栏由 .is-maximized 的 display:none 隐藏(jsdom 不算样式,断言标记)
expect(wrapper.find('.fms-topbar').exists()).toBe(false)
expect(wrapper.find('.fms-layout').classes()).toContain('is-maximized')
// 页签栏保留 —— 它是还原入口
expect(wrapper.find('.fms-tabs').exists()).toBe(true)
await wrapper.findAll('.fms-tabs-tool')[TOOL_MAXIMIZE].trigger('click')
expect(app.contentMaximized).toBe(false)
expect(wrapper.find('.fms-topbar').exists()).toBe(true)
wrapper.unmount()
})
it('刷新按钮:点击后当前页刷新计数 +1', async () => {
const wrapper = await mountApp()
await router.push('/module/module-management')
await flushPromises()
const app = useAppStore()
const before = app.getRouteRefreshKey('/module/module-management')
await wrapper.findAll('.fms-tabs-tool')[TOOL_REFRESH].trigger('click')
expect(app.getRouteRefreshKey('/module/module-management')).toBe(before + 1)
wrapper.unmount()
})
it('更多菜单:为当前激活页签的上下文菜单,点击「关闭其他」生效', async () => {
const wrapper = await mountApp()
// 制造多个页签:工作台 + 菜单管理 + 模块管理(激活)
await router.push('/module/menu-management')
await flushPromises()
await router.push('/module/module-management')
await flushPromises()
await wrapper.findAll('.fms-tabs-tool')[TOOL_MORE].trigger('click')
await flushPromises()
// 浮层经 Teleport 渲染到 body
const menuItems = Array.from(document.querySelectorAll('.dropdown-item'))
const labels = menuItems.map((el) => el.textContent.trim())
expect(labels).toContain('刷新')
expect(labels).toContain('关闭其他')
expect(labels).toContain('关闭全部')
const closeOthers = menuItems.find((el) => el.textContent.trim() === '关闭其他')
closeOthers.click()
await flushPromises()
// 仅保留工作台与当前激活页签
const app = useAppStore()
expect(app.visitedTabs.map((t) => t.key)).toEqual(['/dashboard', '/module/module-management'])
wrapper.unmount()
})
})
@@ -0,0 +1,132 @@
/**
* 诊断:侧栏菜单与页签的选中态(is-active)翻转序列。
*
* 背景:在"其他数据 / 联系人"之间来回切换时,旧项的选中背景会「再亮一次」再灭。
* 本用例用 MutationObserver 记录完整的类变化序列,用于二分定位:
* - 状态层问题(类被加回):序列出现多余的翻转 → 需要修导航/渲染时序
* - 序列干净(每项只按导航翻转):闪烁来自 CSS 过渡的视觉 → 需要调过渡
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
// 顶级菜单(不经目录):目录的展开状态只在 NavMenuItem 初始化时计算,
// 初始路由 /dashboard 不在目录下 → 目录收起 → 子项不渲染,这里用顶级项保证可见
const MENU_ROWS = [
{
b_id: 'm1',
b_name: '其他数据',
b_menu_type: 'page',
b_canuse: 1,
b_xh: 1,
b_route: '/base/otherdata',
b_parent_id: '',
},
{
b_id: 'm2',
b_name: '联系人',
b_menu_type: 'page',
b_canuse: 1,
b_xh: 2,
b_route: '/base/contact',
b_parent_id: '',
},
]
vi.mock('@/views/dashboard/index.vue', () => ({
default: { name: 'Dashboard', template: '<div data-page="Dashboard" />' },
}))
vi.mock('@/views/base/otherdata/index.vue', () => ({
default: { name: 'BaseOtherdata', template: '<div data-page="Otherdata" />' },
}))
vi.mock('@/views/base/contact/index.vue', () => ({
default: { name: 'BaseContact', template: '<div data-page="Contact" />' },
}))
vi.mock('@/views/module/module-management/index.vue', () => ({
default: { name: 'ModuleManagement', template: '<div data-page="MM" />' },
}))
vi.mock('@/views/module/menu-management/index.vue', () => ({
default: { name: 'MenuManagement', template: '<div data-page="MenuM" />' },
}))
vi.mock('@/views/detail/DetailPage.vue', () => ({
default: { name: 'DetailPageStub', props: ['id'], template: '<div />' },
}))
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn((table) =>
Promise.resolve({ data: table === 's_menu' ? MENU_ROWS : [], code: 0, success: true }),
),
describeApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })),
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
nextIdApi: vi.fn(() => Promise.resolve({ data: 1, code: 0, success: true })),
}))
import App from '@/App.vue'
import router from '@/router'
import { createTestPinia } from '../helpers/app.js'
import { useAuthStore } from '@/stores/auth'
beforeEach(async () => {
localStorage.clear()
sessionStorage.clear()
createTestPinia()
useAuthStore().setSession({ token: 't', user: { id: 1, account: 'g3soft', name: 'U' } }, 'ORG')
await router.push('/dashboard')
await router.isReady()
})
async function mountApp() {
const wrapper = mount(App, { global: { plugins: [router] }, attachTo: document.body })
await flushPromises()
return wrapper
}
describe('选中态翻转序列(闪烁诊断)', () => {
it('其他数据/联系人来回切换:is-active 序列无多余翻转', async () => {
const wrapper = await mountApp()
await router.push('/base/otherdata')
await flushPromises()
const navItem = (name) =>
Array.from(document.querySelectorAll('.fms-nav-link')).find((el) => el.textContent.includes(name))
expect(navItem('其他数据')).toBeTruthy()
const read = () => ({
other: navItem('其他数据')?.classList.contains('is-active') ? 1 : 0,
contact: navItem('联系人')?.classList.contains('is-active') ? 1 : 0,
})
const flips = []
let last = JSON.stringify(read())
const mo = new MutationObserver(() => {
const now = JSON.stringify(read())
if (now !== last) {
flips.push(now)
last = now
}
})
mo.observe(document.body, { subtree: true, attributes: true, attributeFilter: ['class'] })
// 场景 1:慢速来回(每次等导航完成)—— 期望恰好 2 次翻转
await router.push('/base/contact')
await flushPromises()
await router.push('/base/otherdata')
await flushPromises()
// 场景 2:快速来回(不等前一个导航完成)—— 模拟用户快速连点
router.push('/base/contact')
router.push('/base/otherdata')
router.push('/base/contact')
await flushPromises()
mo.disconnect()
console.log('[诊断] is-active 翻转序列:', flips.join(' -> '))
// 最终态:最后 push 的是联系人
const final = read()
expect(final.contact).toBe(1)
expect(final.other).toBe(0)
// 场景 1 恰好 2 次翻转;场景 2 的快速连点可能合并为至多 3 次 → 上限 5。
// 若状态层存在「旧项再亮一次」(类被加回),翻转次数会超过此上限并在这里变红
expect(flips.length).toBeLessThanOrEqual(5)
wrapper.unmount()
})
})