376 lines
13 KiB
JavaScript
376 lines
13 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { spawnSync } from 'node:child_process'
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { join } from 'node:path'
|
|
import test from 'node:test'
|
|
import {
|
|
ClipboardList,
|
|
LayoutGrid,
|
|
Settings,
|
|
Truck,
|
|
} from '@lucide/vue'
|
|
|
|
import {
|
|
clientNavigation,
|
|
filterNavigationByPermissions,
|
|
getFirstAccessiblePath,
|
|
getNavigationLeaves,
|
|
getNavigationParentKeys,
|
|
getRouteBreadcrumbs,
|
|
openNavigationParentKeys,
|
|
} from './clientNavigation.ts'
|
|
|
|
function collectNavigationNodes(nodes) {
|
|
return nodes.flatMap((node) => [
|
|
node,
|
|
...(node.children ? collectNavigationNodes(node.children) : []),
|
|
])
|
|
}
|
|
|
|
test('keeps account and notification routes as fixed-header utilities', () => {
|
|
assert.deepEqual(
|
|
clientNavigation.filter((node) => node.hidden).map(({ title, path, permission }) => ({
|
|
title,
|
|
path,
|
|
permission,
|
|
})),
|
|
[
|
|
{ title: '我的信息', path: '/account/profile', permission: 'account:profile:view' },
|
|
{ title: '我的消息', path: '/notifications', permission: 'merchant:notification:view' },
|
|
],
|
|
)
|
|
})
|
|
|
|
test('organises every implemented business page under five task-oriented sidebar entries', () => {
|
|
assert.deepEqual(
|
|
clientNavigation.filter((node) => !node.hidden).map((node) => ({
|
|
title: node.title,
|
|
children: node.children?.map(({ title }) => title),
|
|
})),
|
|
[
|
|
{ title: '首页', children: undefined },
|
|
{
|
|
title: '订单中心',
|
|
children: ['头程订单', '快速建草稿', '海外采购', '美国本土卡', '美本土散板', '入库订单', '派送订单'],
|
|
},
|
|
{
|
|
title: '海外仓',
|
|
children: ['贴标申请', '仓储转运', '货物废弃', '退货预报与签收', '退货库存', '退货箱号管理'],
|
|
},
|
|
{
|
|
title: '费用与账单',
|
|
children: ['资金账户', '客户账单'],
|
|
},
|
|
{
|
|
title: '企业设置',
|
|
children: ['企业资料', '企业成员', '角色管理', '菜单权限'],
|
|
},
|
|
],
|
|
)
|
|
})
|
|
|
|
test('provides icons and unique keys recursively for every visible item', () => {
|
|
const nodes = collectNavigationNodes(clientNavigation)
|
|
|
|
for (const node of nodes) {
|
|
assert.ok(node.icon, `${node.key} has an icon`)
|
|
}
|
|
assert.equal(new Set(nodes.map((node) => node.key)).size, nodes.length)
|
|
})
|
|
|
|
test('flattens visible leaves with unique paths', () => {
|
|
const leaves = getNavigationLeaves(clientNavigation)
|
|
const paths = leaves.map((leaf) => leaf.path)
|
|
|
|
assert.deepEqual(paths, [
|
|
'/',
|
|
'/orders/first-leg',
|
|
'/first-leg/quick-order',
|
|
'/cross-border/procurement',
|
|
'/cross-border/domestic-trucking',
|
|
'/cross-border/pallet-shipping',
|
|
'/orders/inbound',
|
|
'/orders/delivery',
|
|
'/overseas-warehouse/labeling',
|
|
'/overseas-warehouse/storage-transfer',
|
|
'/overseas-warehouse/cargo-disposal',
|
|
'/overseas-warehouse/return-relabel',
|
|
'/overseas-warehouse/return-inventory',
|
|
'/overseas-warehouse/return-cartons',
|
|
'/finance/account',
|
|
'/finance/bills',
|
|
'/company/profile',
|
|
'/organization/members',
|
|
'/organization/roles',
|
|
'/organization/permissions',
|
|
'/account/profile',
|
|
'/notifications',
|
|
])
|
|
assert.equal(new Set(paths).size, paths.length)
|
|
assert.ok(leaves.every((leaf) => leaf.permission.trim().length > 0))
|
|
})
|
|
|
|
test('filters inaccessible leaves and empty navigation groups', () => {
|
|
const visible = filterNavigationByPermissions(clientNavigation, ['account:profile:view', 'order:first-leg:list'])
|
|
|
|
assert.deepEqual(getNavigationLeaves(visible).map((item) => item.path), ['/orders/first-leg'])
|
|
assert.deepEqual(visible.map((item) => item.title), ['订单中心'])
|
|
assert.equal(getFirstAccessiblePath(['account:profile:view']), '/account/profile')
|
|
assert.equal(getFirstAccessiblePath([]), '/forbidden')
|
|
})
|
|
|
|
test('excludes entries marked for manual hiding without affecting other visible items', () => {
|
|
const visible = filterNavigationByPermissions([
|
|
{ key: 'shown', title: '显示入口', icon: LayoutGrid, path: '/shown', permission: 'shown:view' },
|
|
{ key: 'hidden', title: '隐藏入口', icon: LayoutGrid, path: '/hidden', permission: 'hidden:view', hidden: true },
|
|
], ['shown:view', 'hidden:view'])
|
|
|
|
assert.deepEqual(getNavigationLeaves(visible).map((item) => item.path), ['/shown'])
|
|
})
|
|
|
|
test('puts enterprise settings behind the prototype settings divider', () => {
|
|
const company = clientNavigation.find((node) => node.key === 'company-center')
|
|
|
|
assert.ok(company)
|
|
assert.equal(company.dividerBefore, true)
|
|
})
|
|
|
|
test('flattens a synthetic three-level navigation tree recursively', () => {
|
|
const nestedLeaf = {
|
|
key: 'nested-leaf',
|
|
title: '嵌套叶子',
|
|
icon: ClipboardList,
|
|
path: '/nested/leaf',
|
|
permission: 'nested:leaf:view',
|
|
}
|
|
const syntheticTree = [
|
|
{
|
|
key: 'level-one',
|
|
title: '一级分组',
|
|
icon: Settings,
|
|
children: [
|
|
{
|
|
key: 'level-two',
|
|
title: '二级分组',
|
|
icon: Truck,
|
|
children: [nestedLeaf],
|
|
},
|
|
],
|
|
},
|
|
]
|
|
|
|
assert.deepEqual(getNavigationLeaves(syntheticTree), [nestedLeaf])
|
|
})
|
|
|
|
test('returns navigation parent keys from outermost to innermost', () => {
|
|
const syntheticTree = [
|
|
{
|
|
key: 'level-one',
|
|
title: '一级分组',
|
|
icon: Settings,
|
|
children: [
|
|
{
|
|
key: 'level-two',
|
|
title: '二级分组',
|
|
icon: Truck,
|
|
children: [
|
|
{
|
|
key: 'nested-leaf',
|
|
title: '嵌套叶子',
|
|
icon: ClipboardList,
|
|
path: '/nested/leaf',
|
|
permission: 'nested:leaf:view',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
]
|
|
|
|
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/'), [])
|
|
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/orders/first-leg'), ['order-center'])
|
|
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/organization/members'), ['company-center'])
|
|
assert.deepEqual(getNavigationParentKeys(syntheticTree, '/nested/leaf'), ['level-one', 'level-two'])
|
|
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/not-found'), [])
|
|
})
|
|
|
|
test('builds complete breadcrumbs without repeating the current navigation page', () => {
|
|
assert.deepEqual(getRouteBreadcrumbs('/', '业务总览'), [{ title: '首页' }])
|
|
assert.deepEqual(getRouteBreadcrumbs('/orders/first-leg', '订单列表'), [
|
|
{ title: '首页', path: '/' },
|
|
{ title: '订单中心' },
|
|
{ title: '订单列表' },
|
|
])
|
|
assert.deepEqual(getRouteBreadcrumbs('/orders/first-leg/create', '创建订单'), [
|
|
{ title: '首页', path: '/' },
|
|
{ title: '订单中心' },
|
|
{ title: '头程订单', path: '/orders/first-leg' },
|
|
{ title: '创建订单' },
|
|
])
|
|
assert.deepEqual(getRouteBreadcrumbs('/notifications/NOTICE-001', '消息详情'), [
|
|
{ title: '首页', path: '/' },
|
|
{ title: '我的消息', path: '/notifications' },
|
|
{ title: '消息详情' },
|
|
])
|
|
assert.deepEqual(getRouteBreadcrumbs('/forbidden', '无权访问'), [
|
|
{ title: '首页', path: '/' },
|
|
{ title: '无权访问' },
|
|
])
|
|
})
|
|
|
|
test('opens the enterprise settings parent group for a company route', () => {
|
|
const openedKeys = []
|
|
|
|
openNavigationParentKeys('/organization/members', clientNavigation, (key) => openedKeys.push(key))
|
|
|
|
assert.deepEqual(openedKeys, ['company-center'])
|
|
})
|
|
|
|
test('opens nested parent groups from outermost to innermost', () => {
|
|
const syntheticTree = [
|
|
{
|
|
key: 'level-one',
|
|
title: '一级分组',
|
|
icon: Settings,
|
|
children: [
|
|
{
|
|
key: 'level-two',
|
|
title: '二级分组',
|
|
icon: Truck,
|
|
children: [
|
|
{
|
|
key: 'nested-leaf',
|
|
title: '嵌套叶子',
|
|
icon: ClipboardList,
|
|
path: '/nested/leaf',
|
|
permission: 'nested:leaf:view',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
]
|
|
const openedKeys = []
|
|
|
|
openNavigationParentKeys('/nested/leaf', syntheticTree, (key) => openedKeys.push(key))
|
|
|
|
assert.deepEqual(openedKeys, ['level-one', 'level-two'])
|
|
})
|
|
|
|
test('does not open a group when the navigation path does not exist', () => {
|
|
const openedKeys = []
|
|
|
|
openNavigationParentKeys('/not-found', clientNavigation, (key) => openedKeys.push(key))
|
|
|
|
assert.deepEqual(openedKeys, [])
|
|
})
|
|
|
|
test('enforces mutually exclusive leaf and non-empty group node types', () => {
|
|
const testDirectory = mkdtempSync(join(fileURLToPath(new URL('.', import.meta.url)), '.client-navigation-types-'))
|
|
const fixturePath = join(testDirectory, 'fixture.ts')
|
|
const compilerPath = fileURLToPath(new URL('../../node_modules/typescript/bin/tsc', import.meta.url))
|
|
|
|
try {
|
|
writeFileSync(fixturePath, `
|
|
import type { Component } from 'vue'
|
|
import {
|
|
getNavigationLeaves,
|
|
type ClientNavigationGroup,
|
|
type ClientNavigationLeaf,
|
|
type ClientNavigationNode,
|
|
} from '../tree.ts'
|
|
|
|
declare const icon: Component
|
|
|
|
const leaf: ClientNavigationLeaf = {
|
|
key: 'leaf',
|
|
title: 'Leaf',
|
|
icon,
|
|
path: '/leaf',
|
|
permission: 'leaf:view',
|
|
}
|
|
const group: ClientNavigationGroup = {
|
|
key: 'group',
|
|
title: 'Group',
|
|
icon,
|
|
children: [leaf],
|
|
}
|
|
const nodes: readonly ClientNavigationNode[] = [leaf, group]
|
|
getNavigationLeaves(nodes)
|
|
|
|
// @ts-expect-error groups require at least one child
|
|
const emptyGroup: ClientNavigationGroup = { key: 'empty', title: 'Empty', icon, children: [] }
|
|
// @ts-expect-error groups cannot be route leaves
|
|
const linkedGroup: ClientNavigationGroup = { key: 'linked', title: 'Linked', icon, children: [leaf], path: '/linked' }
|
|
// @ts-expect-error leaves cannot contain child nodes
|
|
const parentLeaf: ClientNavigationLeaf = { key: 'parent', title: 'Parent', icon, path: '/parent', permission: 'parent:view', children: [leaf] }
|
|
|
|
void emptyGroup
|
|
void linkedGroup
|
|
void parentLeaf
|
|
`)
|
|
|
|
const result = spawnSync(process.execPath, [
|
|
compilerPath,
|
|
'--noEmit',
|
|
'--strict',
|
|
'--skipLibCheck',
|
|
'--target',
|
|
'ES2022',
|
|
'--module',
|
|
'ESNext',
|
|
'--moduleResolution',
|
|
'Bundler',
|
|
'--allowImportingTsExtensions',
|
|
fixturePath,
|
|
], { encoding: 'utf8' })
|
|
|
|
assert.equal(result.status, 0, result.stdout || result.stderr)
|
|
} finally {
|
|
rmSync(testDirectory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test('keeps the dashboard route permission synchronized with navigation', () => {
|
|
const routerSource = readFileSync(new URL('../modules/dashboard/routes.ts', import.meta.url), 'utf8')
|
|
const dashboardRoute = routerSource.match(/path: ''[\s\S]*?navigation: \{ key: 'dashboard'[\s\S]*?\}/)?.[0]
|
|
|
|
assert.ok(dashboardRoute)
|
|
assert.match(dashboardRoute, /permission: 'dashboard:view'/)
|
|
})
|
|
|
|
test('keeps only the unified my-orders placeholder cataloged while activating implemented business pages', () => {
|
|
const routerSource = [
|
|
'first-leg',
|
|
'cross-border',
|
|
'overseas-warehouse',
|
|
'order-management',
|
|
].map((moduleName) => readFileSync(new URL(`../modules/${moduleName}/routes.ts`, import.meta.url), 'utf8')).join('\n')
|
|
const routes = [
|
|
['first-leg/quick-order', 'QuickOrderView.vue'],
|
|
['cross-border/procurement', 'ProcurementView.vue'],
|
|
['cross-border/domestic-trucking', 'DomesticTruckingView.vue'],
|
|
['cross-border/pallet-shipping', 'PalletShippingView.vue'],
|
|
['overseas-warehouse/labeling', 'WarehouseServiceView.vue'],
|
|
['overseas-warehouse/storage-transfer', 'WarehouseServiceView.vue'],
|
|
['overseas-warehouse/cargo-disposal', 'WarehouseServiceView.vue'],
|
|
['overseas-warehouse/return-relabel', 'ReturnOrdersView.vue'],
|
|
['overseas-warehouse/return-inventory', 'ReturnInventoryView.vue'],
|
|
['orders/my', 'MyOrdersView.vue'],
|
|
['orders/inbound', 'InboundOrdersView.vue'],
|
|
['orders/delivery', 'DeliveryOrdersView.vue'],
|
|
]
|
|
|
|
for (const [path, view] of routes) {
|
|
if (path === 'first-leg/quick-order' || path.startsWith('cross-border/') || path.startsWith('overseas-warehouse/') || path === 'orders/inbound' || path === 'orders/delivery') {
|
|
assert.match(routerSource, new RegExp(`path: '${path}'[\\s\\S]*?\\./views/${view.replace('.', '\\.')}[\\s\\S]*?release: 'active'[\\s\\S]*?permission:`))
|
|
assert.equal(getNavigationLeaves(clientNavigation).some((item) => item.path === `/${path}`), true)
|
|
continue
|
|
}
|
|
assert.match(routerSource, new RegExp(`path: '${path}'[\\s\\S]*?\\./views/${view.replace('.', '\\.')}[\\s\\S]*?release: 'prototype'[\\s\\S]*?permission:`))
|
|
assert.equal(getNavigationLeaves(clientNavigation).some((item) => item.path === `/${path}`), false)
|
|
}
|
|
assert.doesNotMatch(routerSource, /first-leg\/import|BatchImportView|merchant:first-leg:import/)
|
|
})
|