2026-07-30 17:59:43 +08:00
|
|
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
|
|
|
|
import { flushPromises, mount } from '@vue/test-utils'
|
2026-08-05 15:54:59 +08:00
|
|
|
|
import { defineComponent, ref } from 'vue'
|
2026-07-30 17:59:43 +08:00
|
|
|
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
2026-08-14 14:27:03 +08:00
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
2026-07-31 15:34:35 +08:00
|
|
|
|
import { ElMessageBox } from 'element-plus'
|
2026-08-03 18:49:18 +08:00
|
|
|
|
import { useAuthStore } from '@/core/auth/auth'
|
2026-07-30 17:59:43 +08:00
|
|
|
|
import AppLayout from './AppLayout.vue'
|
|
|
|
|
|
|
2026-08-05 15:54:59 +08:00
|
|
|
|
const CachedPage = defineComponent({
|
|
|
|
|
|
setup() {
|
|
|
|
|
|
const count = ref(0)
|
|
|
|
|
|
return { count }
|
|
|
|
|
|
},
|
|
|
|
|
|
template: '<button class="cached-count" @click="count += 1">{{ count }}</button>',
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-07-30 17:59:43 +08:00
|
|
|
|
vi.mock('element-plus', async (importOriginal) => {
|
|
|
|
|
|
const actual = await importOriginal<typeof import('element-plus')>()
|
2026-07-31 15:34:35 +08:00
|
|
|
|
return { ...actual, ElMessage: { warning: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }
|
2026-07-30 17:59:43 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-01 12:17:33 +08:00
|
|
|
|
async function mountLayout(path = '/') {
|
2026-07-30 17:59:43 +08:00
|
|
|
|
const pinia = createPinia()
|
|
|
|
|
|
setActivePinia(pinia)
|
|
|
|
|
|
const auth = useAuthStore()
|
2026-07-31 15:34:35 +08:00
|
|
|
|
auth.token = 'token'
|
|
|
|
|
|
auth.user = { id: '1', name: '平台管理员', account: 'admin', roles: ['PLATFORM_ADMIN'] }
|
2026-08-13 19:00:37 +08:00
|
|
|
|
auth.permissions = ['platform:warehouse:list', 'platform:notification:task:list', 'platform:notification:template:list']
|
2026-07-30 17:59:43 +08:00
|
|
|
|
const router = createRouter({
|
|
|
|
|
|
history: createMemoryHistory(),
|
2026-08-01 12:17:33 +08:00
|
|
|
|
routes: [
|
2026-08-05 15:54:59 +08:00
|
|
|
|
{ path: '/', name: 'home', component: { template: '<p>page</p>' }, meta: { requiresAuth: true, workspaceTab: false, keepAlive: false } },
|
|
|
|
|
|
{ path: '/customers', name: 'customers', component: CachedPage, meta: { title: '客户管理', showPageHeading: false, requiresAuth: true } },
|
|
|
|
|
|
{ path: '/warehouses', name: 'warehouses', component: { template: '<p>warehouses</p>' }, meta: { title: '仓库管理', requiresAuth: true } },
|
2026-08-01 12:17:33 +08:00
|
|
|
|
{ path: '/login', component: { template: '<p>login</p>' } },
|
|
|
|
|
|
],
|
2026-07-30 17:59:43 +08:00
|
|
|
|
})
|
2026-08-01 12:17:33 +08:00
|
|
|
|
await router.push(path)
|
2026-07-30 17:59:43 +08:00
|
|
|
|
await router.isReady()
|
|
|
|
|
|
const wrapper = mount(AppLayout, {
|
2026-08-12 18:50:03 +08:00
|
|
|
|
global: { plugins: [pinia, router], stubs: { AdminSidebarMenu: { template: '<nav />' }, WorkspaceTabs: { template: '<div class="workspace-tabs-stub" />' }, 'el-dropdown': { template: '<div><slot /><slot name="dropdown" /></div>' }, 'el-dropdown-menu': { template: '<div><slot /></div>' }, 'el-dropdown-item': { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' }, 'el-button': { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
|
2026-07-30 17:59:43 +08:00
|
|
|
|
})
|
2026-07-31 15:34:35 +08:00
|
|
|
|
return { wrapper, router }
|
2026-07-30 17:59:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 14:27:03 +08:00
|
|
|
|
function mockViewport(isMobile: boolean) {
|
|
|
|
|
|
const mediaQuery = {
|
|
|
|
|
|
matches: isMobile,
|
|
|
|
|
|
media: '(max-width: 720px)',
|
|
|
|
|
|
onchange: null,
|
|
|
|
|
|
addEventListener: vi.fn(),
|
|
|
|
|
|
removeEventListener: vi.fn(),
|
|
|
|
|
|
addListener: vi.fn(),
|
|
|
|
|
|
removeListener: vi.fn(),
|
|
|
|
|
|
dispatchEvent: vi.fn(),
|
|
|
|
|
|
} as unknown as MediaQueryList
|
|
|
|
|
|
vi.stubGlobal('matchMedia', vi.fn(() => mediaQuery))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 15:34:35 +08:00
|
|
|
|
describe('AppLayout', () => {
|
2026-08-14 14:27:03 +08:00
|
|
|
|
beforeEach(() => {
|
2026-08-19 16:45:51 +08:00
|
|
|
|
window.localStorage.clear()
|
2026-08-14 14:27:03 +08:00
|
|
|
|
vi.mocked(ElMessageBox.confirm).mockReset()
|
|
|
|
|
|
mockViewport(false)
|
|
|
|
|
|
})
|
|
|
|
|
|
afterEach(() => vi.unstubAllGlobals())
|
|
|
|
|
|
|
|
|
|
|
|
it('reports the desktop navigation expanded and collapsed states', async () => {
|
|
|
|
|
|
const { wrapper } = await mountLayout()
|
|
|
|
|
|
const menuButton = wrapper.get('.shell-menu-button')
|
|
|
|
|
|
|
|
|
|
|
|
expect(menuButton.attributes('aria-label')).toBe('收起导航')
|
|
|
|
|
|
expect(menuButton.attributes('aria-expanded')).toBe('true')
|
|
|
|
|
|
|
|
|
|
|
|
await menuButton.trigger('click')
|
|
|
|
|
|
|
|
|
|
|
|
expect(menuButton.attributes('aria-label')).toBe('展开导航')
|
|
|
|
|
|
expect(menuButton.attributes('aria-expanded')).toBe('false')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('reports the mobile menu closed and open states', async () => {
|
|
|
|
|
|
mockViewport(true)
|
|
|
|
|
|
const { wrapper } = await mountLayout()
|
|
|
|
|
|
const menuButton = wrapper.get('.shell-menu-button')
|
|
|
|
|
|
|
|
|
|
|
|
expect(menuButton.attributes('aria-label')).toBe('打开菜单')
|
|
|
|
|
|
expect(menuButton.attributes('aria-expanded')).toBe('false')
|
|
|
|
|
|
|
|
|
|
|
|
await menuButton.trigger('click')
|
|
|
|
|
|
|
|
|
|
|
|
expect(menuButton.attributes('aria-label')).toBe('关闭菜单')
|
|
|
|
|
|
expect(menuButton.attributes('aria-expanded')).toBe('true')
|
|
|
|
|
|
})
|
2026-07-31 15:34:35 +08:00
|
|
|
|
|
2026-08-14 16:06:30 +08:00
|
|
|
|
it('closes the mobile menu when returning to the desktop breakpoint and removes the listener', async () => {
|
|
|
|
|
|
mockViewport(true)
|
|
|
|
|
|
const { wrapper } = await mountLayout()
|
|
|
|
|
|
const menuButton = wrapper.get('.shell-menu-button')
|
|
|
|
|
|
const mediaQuery = vi.mocked(window.matchMedia).mock.results[0]?.value as MediaQueryList
|
|
|
|
|
|
const changeHandler = vi.mocked(mediaQuery.addEventListener).mock.calls[0]?.[1] as ((event: MediaQueryListEvent) => void)
|
|
|
|
|
|
|
|
|
|
|
|
await menuButton.trigger('click')
|
|
|
|
|
|
expect(menuButton.attributes('aria-expanded')).toBe('true')
|
|
|
|
|
|
|
|
|
|
|
|
changeHandler({ matches: false } as MediaQueryListEvent)
|
|
|
|
|
|
await wrapper.vm.$nextTick()
|
|
|
|
|
|
expect(menuButton.attributes('aria-label')).toBe('收起导航')
|
|
|
|
|
|
expect(menuButton.attributes('aria-expanded')).toBe('true')
|
|
|
|
|
|
|
|
|
|
|
|
wrapper.unmount()
|
|
|
|
|
|
expect(mediaQuery.removeEventListener).toHaveBeenCalledWith('change', changeHandler)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-19 16:45:51 +08:00
|
|
|
|
it('shows the authenticated identity and the localhost full-access control', async () => {
|
2026-07-31 15:34:35 +08:00
|
|
|
|
const { wrapper } = await mountLayout()
|
2026-10-20 23:34:11 +08:00
|
|
|
|
expect(wrapper.get('.user-copy').text()).toBe('平台管理员(admin)')
|
|
|
|
|
|
expect(wrapper.get('.user-copy').attributes('aria-label')).toBe('登录身份:平台管理员(admin)')
|
2026-08-19 16:45:51 +08:00
|
|
|
|
const accessToggle = wrapper.get('[role="switch"]')
|
|
|
|
|
|
expect(accessToggle.text()).toContain('本地全权限')
|
|
|
|
|
|
expect(accessToggle.text()).toContain('已关闭')
|
|
|
|
|
|
expect(accessToggle.attributes('aria-checked')).toBe('false')
|
|
|
|
|
|
await accessToggle.trigger('click')
|
|
|
|
|
|
expect(accessToggle.text()).toContain('已开启')
|
|
|
|
|
|
expect(accessToggle.attributes('aria-checked')).toBe('true')
|
2026-08-13 19:00:37 +08:00
|
|
|
|
expect(wrapper.get('[aria-label="消息中心"]').attributes('disabled')).toBeUndefined()
|
|
|
|
|
|
expect(wrapper.text()).toContain('消息管理')
|
|
|
|
|
|
expect(wrapper.text()).toContain('模板管理')
|
2026-08-27 21:48:15 +08:00
|
|
|
|
expect(wrapper.text()).toContain('我的消息')
|
2026-08-13 19:19:34 +08:00
|
|
|
|
const notificationTrigger = wrapper.get('.notification-trigger')
|
|
|
|
|
|
expect(notificationTrigger.find('.notification-count').exists()).toBe(false)
|
2026-07-30 17:59:43 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-10-20 23:34:11 +08:00
|
|
|
|
it('avoids repeating identical display names and accounts while retaining the full accessible identity', async () => {
|
|
|
|
|
|
const { wrapper } = await mountLayout()
|
|
|
|
|
|
const auth = useAuthStore()
|
|
|
|
|
|
auth.user = { ...auth.user!, name: 'admin', account: 'admin' }
|
|
|
|
|
|
await wrapper.vm.$nextTick()
|
|
|
|
|
|
|
|
|
|
|
|
expect(wrapper.get('.user-copy').text()).toBe('admin')
|
|
|
|
|
|
expect(wrapper.get('.user-copy').attributes('aria-label')).toBe('登录身份:admin(admin)')
|
|
|
|
|
|
expect(wrapper.get('.user-copy').attributes('title')).toBe('登录身份:admin(admin)')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('limits the shell theme to navigation and header instead of inheriting it into routed pages', async () => {
|
|
|
|
|
|
const { wrapper } = await mountLayout('/customers')
|
|
|
|
|
|
|
|
|
|
|
|
expect(wrapper.get('.app-sidebar').classes()).toContain('admin-workbench')
|
|
|
|
|
|
expect(wrapper.get('.app-header').classes()).toContain('admin-workbench')
|
|
|
|
|
|
expect(wrapper.get('.cached-count').element.closest('.admin-workbench')).toBeNull()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-01 12:17:33 +08:00
|
|
|
|
it('hides the shell heading when the route opts out', async () => {
|
|
|
|
|
|
const { wrapper } = await mountLayout('/customers')
|
|
|
|
|
|
expect(wrapper.find('.page-heading').exists()).toBe(false)
|
2026-10-20 23:34:11 +08:00
|
|
|
|
expect(wrapper.get('.user-copy').attributes('aria-label')).toBe('登录身份:平台管理员(admin)')
|
2026-08-01 12:17:33 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-07-31 15:34:35 +08:00
|
|
|
|
it('logs out after confirmation', async () => {
|
2026-07-30 17:59:43 +08:00
|
|
|
|
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never)
|
2026-07-31 15:34:35 +08:00
|
|
|
|
const { wrapper, router } = await mountLayout()
|
2026-07-30 17:59:43 +08:00
|
|
|
|
await wrapper.get('[aria-label="退出登录"]').trigger('click')
|
|
|
|
|
|
await flushPromises()
|
2026-07-31 13:43:16 +08:00
|
|
|
|
expect(router.currentRoute.value.path).toBe('/login')
|
|
|
|
|
|
})
|
2026-08-05 15:54:59 +08:00
|
|
|
|
|
|
|
|
|
|
it('opens business routes as workspace tabs and keeps route component state while switching', async () => {
|
|
|
|
|
|
const { wrapper, router } = await mountLayout('/customers')
|
|
|
|
|
|
expect(wrapper.find('.workspace-tabs-stub').exists()).toBe(true)
|
|
|
|
|
|
await wrapper.get('.cached-count').trigger('click')
|
|
|
|
|
|
expect(wrapper.get('.cached-count').text()).toBe('1')
|
|
|
|
|
|
|
|
|
|
|
|
await router.push('/warehouses')
|
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
await router.push('/customers')
|
|
|
|
|
|
await flushPromises()
|
|
|
|
|
|
|
|
|
|
|
|
expect(wrapper.get('.cached-count').text()).toBe('1')
|
|
|
|
|
|
})
|
2026-07-30 17:59:43 +08:00
|
|
|
|
})
|