diff --git a/frontend/src/pages/route-groups/__tests__/index.spec.ts b/frontend/src/pages/route-groups/__tests__/index.spec.ts new file mode 100644 index 0000000..4183d84 --- /dev/null +++ b/frontend/src/pages/route-groups/__tests__/index.spec.ts @@ -0,0 +1,269 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mount, flushPromises, VueWrapper } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { nextTick } from 'vue' +import { useRouteGroupStore } from '@/stores/route-group' +import type { RouteRecord } from '@/stores/route-group' +import RouteGroupsPage from '../index.vue' + +vi.mock('@/utils/request', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +const { api } = await import('@/utils/request') + +// Ant Design Vue jsdom 兼容 +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}) + +const mockGroups = [ + { id: 1, name: 'user-mgmt', label: '用户管理', description: '', sort_order: 1, route_count: 2 }, + { id: 2, name: 'order-mgmt', label: '订单管理', description: '', sort_order: 2, route_count: 1 }, +] + +const mockRoutes: RouteRecord[] = [ + { id: 1, method: 'GET', path: '/api/v1/users', description: '用户列表', group_id: 1, group_name: '用户管理' }, + { id: 2, method: 'POST', path: '/api/v1/users', description: '创建用户', group_id: 1, group_name: '用户管理' }, + { id: 3, method: 'GET', path: '/api/v1/orders', description: '订单列表', group_id: 2, group_name: '订单管理' }, + { id: 4, method: 'GET', path: '/api/v1/products', description: '产品列表', group_id: null, group_name: null }, + { id: 5, method: 'GET', path: '/api/v1/users/{id}', description: '用户详情', group_id: 1, group_name: '用户管理' }, +] + +// ─── Store 测试 ─── + +describe('useRouteGroupStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + describe('routeTree', () => { + it('groups all routes by first path segment as directories', () => { + const store = useRouteGroupStore() + store.routes = [...mockRoutes] + + const tree = store.routeTree + const topKeys = tree.map((n) => n.key) + // 按资源名分组为目录节点 + expect(topKeys).toContain('/users') + expect(topKeys).toContain('/orders') + expect(topKeys).toContain('/products') + expect(tree).toHaveLength(3) + // 所有顶层节点都是目录 + expect(tree.every((n) => !n.isLeaf)).toBe(true) + }) + + it('places same-path different-method routes inside same directory', () => { + const store = useRouteGroupStore() + store.routes = [...mockRoutes] + + const usersDir = store.routeTree.find((n) => n.key === '/users')! + const userLeaves = usersDir.children!.filter((n) => n.isLeaf && n.title === 'users') + expect(userLeaves).toHaveLength(2) + expect(userLeaves.map((n) => n.method)).toEqual(expect.arrayContaining(['GET', 'POST'])) + }) + + it('nests multi-segment routes under same directory', () => { + const store = useRouteGroupStore() + store.routes = [...mockRoutes] + + const usersDir = store.routeTree.find((n) => n.key === '/users')! + // users 目录下:GET users, POST users, GET {id} + expect(usersDir.children).toHaveLength(3) + const idLeaf = usersDir.children!.find((n) => n.key === 'route:5') + expect(idLeaf).toBeDefined() + expect(idLeaf!.title).toBe('{id}') + }) + + it('creates leaf nodes with route metadata', () => { + const store = useRouteGroupStore() + store.routes = [...mockRoutes] + + const productsDir = store.routeTree.find((n) => n.key === '/products')! + const productsLeaf = productsDir.children!.find((n) => n.key === 'route:4') + expect(productsLeaf).toBeDefined() + expect(productsLeaf!.isLeaf).toBe(true) + expect(productsLeaf!.routeId).toBe(4) + expect(productsLeaf!.method).toBe('GET') + expect(productsLeaf!.fullPath).toBe('/api/v1/products') + }) + + it('returns empty array when no routes', () => { + const store = useRouteGroupStore() + expect(store.routeTree).toEqual([]) + }) + }) + + describe('checkedRouteKeys', () => { + it('returns keys for routes matching selected group', () => { + const store = useRouteGroupStore() + store.routes = [...mockRoutes] + store.selectedGroupId = 1 + + expect(store.checkedRouteKeys).toEqual( + expect.arrayContaining(['route:1', 'route:2', 'route:5']), + ) + expect(store.checkedRouteKeys).toHaveLength(3) + }) + + it('returns empty array when no group selected', () => { + const store = useRouteGroupStore() + store.routes = [...mockRoutes] + store.selectedGroupId = null + + expect(store.checkedRouteKeys).toEqual([]) + }) + + it('returns empty array for ungrouped selection', () => { + const store = useRouteGroupStore() + store.routes = [...mockRoutes] + store.selectedGroupId = 'ungrouped' + + expect(store.checkedRouteKeys).toEqual([]) + }) + }) + + describe('batchAssignRoutes', () => { + it('calls API and refreshes data', async () => { + const store = useRouteGroupStore() + vi.mocked(api.put).mockResolvedValueOnce(undefined) + vi.mocked(api.get) + .mockResolvedValueOnce(mockGroups) + .mockResolvedValueOnce(mockRoutes) + + await store.batchAssignRoutes(1, [1, 2, 3]) + + expect(api.put).toHaveBeenCalledWith( + '/api/v1/route-groups/1/routes', + { route_ids: [1, 2, 3] }, + ) + expect(api.get).toHaveBeenCalledWith('/api/v1/route-groups') + expect(api.get).toHaveBeenCalledWith('/api/v1/routes', undefined) + }) + }) +}) + +// ─── Page 组件测试 ─── + +describe('RouteGroupsPage', () => { + let wrapper: VueWrapper + + async function mountPage() { + vi.mocked(api.get) + .mockResolvedValueOnce(mockGroups) + .mockResolvedValueOnce(mockRoutes) + + wrapper = mount(RouteGroupsPage, { + attachTo: document.body, + global: { + stubs: { + PlusOutlined: { template: '' }, + EditOutlined: { template: '' }, + DeleteOutlined: { template: '' }, + SaveOutlined: { template: '' }, + }, + }, + }) + + await flushPromises() + await nextTick() + } + + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + afterEach(() => { + wrapper?.unmount() + document.body.innerHTML = '' + }) + + it('shows empty state when no group is selected', async () => { + await mountPage() + + expect(wrapper.text()).toContain('请选择路由组') + expect(wrapper.find('.ant-empty').exists()).toBe(true) + }) + + it('shows ungrouped routes table when ungrouped is selected', async () => { + await mountPage() + + const store = useRouteGroupStore() + store.selectedGroupId = 'ungrouped' + await nextTick() + + expect(wrapper.text()).toContain('未分组路由') + expect(wrapper.find('.ant-table').exists()).toBe(true) + }) + + it('shows route tree when a group is selected', async () => { + await mountPage() + + const store = useRouteGroupStore() + store.selectedGroupId = 1 + await nextTick() + + expect(wrapper.text()).toContain('用户管理') + expect(wrapper.find('.ant-tree').exists()).toBe(true) + }) + + it('shows save button when a group is selected', async () => { + await mountPage() + + const store = useRouteGroupStore() + store.selectedGroupId = 1 + await nextTick() + + expect(wrapper.text()).toContain('保存分配') + }) + + it('renders group list in left panel', async () => { + await mountPage() + + expect(wrapper.text()).toContain('用户管理') + expect(wrapper.text()).toContain('订单管理') + expect(wrapper.text()).toContain('未分组') + }) + + it('calls batchAssignRoutes on save button click', async () => { + await mountPage() + + const store = useRouteGroupStore() + store.selectedGroupId = 1 + await nextTick() + + // 模拟 batchAssignRoutes 成功 + vi.mocked(api.put).mockResolvedValueOnce(undefined) + vi.mocked(api.get) + .mockResolvedValueOnce(mockGroups) + .mockResolvedValueOnce(mockRoutes) + + // 点击保存按钮 + const saveBtn = wrapper.findAll('.ant-btn').find((b) => b.text().includes('保存分配')) + expect(saveBtn).toBeDefined() + await saveBtn!.trigger('click') + await flushPromises() + + expect(api.put).toHaveBeenCalledWith( + '/api/v1/route-groups/1/routes', + expect.objectContaining({ route_ids: expect.any(Array) }), + ) + }) +}) diff --git a/frontend/src/pages/route-groups/index.vue b/frontend/src/pages/route-groups/index.vue index cfc5196..56e6b76 100644 --- a/frontend/src/pages/route-groups/index.vue +++ b/frontend/src/pages/route-groups/index.vue @@ -1,10 +1,11 @@