20260819173221

This commit is contained in:
oneao committed 2026-08-19 17:32:25 +08:00
1 parent 1ffc8e1ac9
commit a0a4273f4c
326 files changed
+301646 -2314

No files matched your search

+271
View File
@@ -0,0 +1,271 @@
# FMS Vue 前端测试规范(AI 全权测试版)
本文档是 fms-vue 前端测试的**唯一权威约定**。测试工作(编写、运行、补覆盖)默认全权交给 AI 执行,本文档保证 AI 在任何时候都能:
- 知道**测什么**(每个对象的测试清单)
- 知道**怎么写**(mock 模板与装配方式)
- 知道**怎么跑**(按文件/页面/全部/覆盖率)
- 知道**什么时候算完**(覆盖率门槛与验收标准)
## 1. 测试命令速查
| 场景 | 命令 |
| --- | --- |
| 跑全部测试(CI 风格,一次性) | `pnpm test`(同 `pnpm test:all`) |
| 只测一个文件 | `pnpm test:file tests/views/login.spec.js` |
| 只测一个页面/功能对应的测试文件 | `pnpm test:file tests/views/<页面>.spec.js` |
| 只测一个目录(如全部 store) | `pnpm test:file tests/stores` |
| 带覆盖率跑(检查缺口) | `pnpm test:coverage` |
| 监听模式(改代码自动重跑) | `pnpm test:watch` |
注意:`pnpm test` 与 `pnpm test:coverage` 的区别——**只有 `test:coverage` 会检查 60% 阈值**。普通 `pnpm test` 只看用例红绿,不看覆盖率。
## 2. 目录与命名约定
```
tests/
setup.js # 全局环境:jsdom polyfill、nprogress mock(已就绪,勿删)
helpers/
app.js # 统一装配:createTestPinia / createTestRouter / mountPage(已就绪)
unit/ # 纯函数/工具测试(无 DOM 或极轻 DOM)
utils-*.spec.js # 对应 src/utils/*
theme.spec.js # 对应 src/theme/*
ui-utils-*.spec.js # 对应 src/components/ui/utils/*
stores/ # pinia store 测试
auth.spec.js
app.spec.js
preferences.spec.js
components/ # UI 组件测试(冒烟 + 核心交互)
input.spec.js
select.spec.js
... 每个组件一个文件
views/ # 页面测试
login.spec.js # 已存在
dashboard.spec.js
...
services/ # http 拦截器/请求层测试
http.spec.js
router-auth.spec.js # 路由守卫集成测试(已存在,留在根目录)
permissions.spec.js # store 测试(已存在,留在根目录,不强制迁移)
```
规则:
- 测试文件一律 `*.spec.js`,与源码文件同名(`login/index.vue` → `tests/views/login.spec.js`)。
- **每个被测源码文件最多对应一个 spec**,禁止把多个对象的测试堆进一个文件。
- `describe` 第一层用中文描述被测对象(如 `describe('登录页')`);用例名用中文描述业务场景。
- 新测试按上表归类;**已存在的测试文件(permissions.spec.js、router-auth.spec.js、views/login.spec.js)不必迁移**,保持原位即可,新增的按新约定放。
## 3. 编写模板
### 3.1 页面测试(用 mountPage)
所有请求都收敛在 `@/services/api`,页面测试只需 mock 这一个模块:
```js
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { nextTick } from 'vue'
import { flushPromises } from '@vue/test-utils'
import LoginPage from '@/views/login/index.vue'
import { useAuthStore } from '@/stores/auth'
import { mountPage } from '../helpers/app.js'
// —— 单一接缝:mock 业务 API 层,确定性驱动页面状态 ——
vi.mock('@/services/api', () => ({
loginApi: vi.fn(),
loadDataApi: vi.fn(),
}))
// 命令式组件(Message 等)需要 mock,避免 jsdom 下创建 DOM 容器
vi.mock('@/components/ui/message/message-manager.js', () => {
const m = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }
return { Message: m, default: m }
})
import { loginApi } from '@/services/api'
// 子组件用 shallowMount 自动打桩;需要触发子组件事件时给一个稳定 name
const formStub = { name: 'Form', template: '<form class="stub"><slot /></form>' }
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
loginApi.mockReset()
})
describe('登录页', () => {
it('登录成功:调用 loginApi、写入会话、跳转 redirect', async () => {
loginApi.mockResolvedValue({
data: { token: 'TK', user: { id: 1, account: 'admin' }, orgid: 'ORG1' },
code: 0, success: true,
})
const { wrapper, router } = await mountPage(LoginPage, {
location: '/login?redirect=/dashboard',
stubs: { Form: formStub },
})
await wrapper.findComponent({ name: 'Form' }).vm.$emit('submit', { orgId: 'ORG1', account: 'admin', password: 'secret' })
await flushPromises()
await nextTick()
expect(loginApi).toHaveBeenCalledWith('ORG1', 'admin', 'secret')
expect(useAuthStore().isAuthenticated).toBe(true)
expect(router.currentRoute.value.fullPath).toBe('/dashboard')
})
})
```
要点:
- `beforeEach` 里必须清理 `localStorage` / `sessionStorage`(persist 插件会写它们,跨用例泄漏)。
- 用 `flushPromises()` + `nextTick()` 冲刷异步,不要用固定 `setTimeout`。
- 需要路由参数时用 `mountPage(Comp, { location: '/detail/123' })`。
### 3.2 store 测试
```js
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useAuthStore } from '@/stores/auth'
import { createTestPinia } from '../helpers/app.js'
vi.mock('@/services/api', () => ({ loadDataApi: vi.fn() }))
beforeEach(() => {
localStorage.clear()
})
describe('auth store', () => {
it('setSession 写入会话', () => {
createTestPinia() // 内部已 install,persist 插件真实注册(见 helpers/app.js 注释)
const auth = useAuthStore()
auth.setSession({ token: 't', user: { id: 1 } }, 'ORG')
expect(auth.isAuthenticated).toBe(true)
})
})
```
**必须用 `createTestPinia()`,不要手动 `createPinia()`**:
- pinia 4 中 `pinia.use()` 在未安装 app 前只把插件放入队列,直接 `setActivePinia(createPinia())` 会让 persist 插件静默失效,store 上缺 `$hydrate`/`$persist`。
- `createTestPinia()` 内部用假 app 触发 `install()`,行为与页面测试(经 @vue/test-utils 挂载)一致。
### 3.3 纯函数测试(unit)
```js
import { describe, it, expect } from 'vitest'
import { buildTree, sortTree } from '@/utils/tree'
describe('utils/tree', () => {
it('buildTree 按 parentKey 构建树', () => {
const list = [
{ b_id: 1, b_parent_id: null },
{ b_id: 2, b_parent_id: 1 },
]
const tree = buildTree(list)
expect(tree).toHaveLength(1)
expect(tree[0].children).toHaveLength(1)
})
})
```
纯函数测试**不需要** mountPage / pinia / mock,直接 import 断言即可。
### 3.4 UI 组件测试(components)
```js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Input from '@/components/ui/input/input.vue'
describe('Input 组件', () => {
it('渲染输入框并转发 update:modelValue', async () => {
const wrapper = mount(Input, { props: { modelValue: '' } })
const input = wrapper.find('[data-slot="input"]')
await input.setValue('abc')
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['abc'])
})
})
```
组件测试用 `mount`(组件自身逻辑需要真实渲染);页面测试用 `shallowMount`(经 mountPage)。定位元素优先用组件已有的 `data-slot`、`aria-label`、`role` 等稳定属性,**不要依赖 CSS class 或文本**。
## 4. 每个对象的测试清单(AI 补测试时逐项对照)
### 4.1 services(最高优先级,当前 0%)
`tests/services/http.spec.js` 必须覆盖:
- [ ] 请求拦截器:有 token 时注入 `Authorization: Bearer <token>`;无 token 不注入
- [ ] 响应拦截器:`code === 0` → success
- [ ] 响应拦截器:`code === 401` → 清会话 + 跳 `/login?reason=expired`
- [ ] 响应拦截器:`code === 40101` → 清会话 + 跳 `/login?reason=session-replaced`
- [ ] 响应拦截器:业务错误码 → 抛 `Error` 且带 `code`/`data`
- [ ] 响应拦截器:网络异常/超时 → 抛中文错误信息
- [ ] 已在 `/login` 页时不重复跳转
### 4.2 utils(纯函数,零成本高收益)
- [ ] `src/utils/tree.js`:`buildTree`(空数组/孤儿子节点/多根/自定义 key 名)、`sortTree`(b_xh 排序/递归子节点/兜底 b_id)
- [ ] `src/theme/index.js`:`applyPrimaryColor` 写 CSS 变量、`applyDarkMode` 切换 `dark` class
- [ ] `src/components/ui/utils/*.js`:`focus.js` / `scroll.js` / `drag.js` / `position.js` 按导出函数逐一声明测试
### 4.3 stores
- [ ] `auth`:setSession 兼容 `token`/`access_token`、`orgid`、`user`/`user_info`;clearSession;sessionStorage 旧会话迁移
- [ ] `app`:cachedViews 去重;addVisitedTab 去重;removeVisitedTab / closeOthers / closeLeft / closeRight / closeAll 的页签与 routeRefreshKeys 联动;refreshRoute / getRouteRefreshKey
- [ ] `preferences`:saveLastLogin 写入、持久化 key 为 `fms-preferences`
- [ ] `permissions`:已有 5 个用例,继续补充 `resolvePageId` / `canPower` 分支
### 4.4 UI 组件(冒烟 + 核心交互,20+ 个)
每个组件最少 3 个用例:**默认渲染、关键交互、禁用/边界**。按复杂度从低到高:
- [ ] 简单组件:`button` / `tag` / `switch` / `radio` / `checkbox` / `spin` / `col` / `row`
- [ ] 输入类:`input`(default/password/number/textarea 四种模式)、`select`、`date`(date-picker/range-picker/time-select/calendar-panel)
- [ ] 布局类:`tabs` / `splitter` / `grid` / `tree` / `dropdown` / `menu` / `popover` / `tooltip`
- [ ] 反馈类:`modal` / `drawer` / `message` / `notification` / `pagination` / `form` / `form-item`
### 4.5 页面
- [ ] `login`:已有 7 个用例,补充"记住上次登录"回填
- [ ] `dashboard`:渲染、菜单/权限入口
- [ ] `detail`:id 渲染、计数交互
- [ ] `demo`:各组件演示区基本渲染
- [ ] `test/TestPage`:基本渲染
### 4.6 集成
- [ ] `router-auth.spec.js`:已有 5 个用例(保持全绿)
- [ ] 布局:`DefaultLayout` / `AppSidebar` / `AppTopbar` / `AppTabs` / `NavMain` / `NavMenuItem` 菜单渲染与折叠逻辑
## 5. 覆盖率策略
- 门槛:**lines/functions/branches/statements 均 ≥ 60%**(见 `vitest.config.js`)。
- `pnpm test:coverage` 输出会列出每个文件的未覆盖行号(`Uncovered Line #s`),这是 AI 补测试的**路线图**:按行号定位,按第 4 节清单补齐。
- 当前基线约 5%(UI 组件几乎全空),**先做第 4.1–4.3 节(services/utils/stores)**,这些是纯逻辑、无 DOM 依赖、成本最低,可快速把全局覆盖率拉到接近 60%;再做组件冒烟补齐剩余。
- 阈值检查只在 `--coverage` 时生效,普通 `pnpm test` 不会因覆盖率失败。
## 6. 常见坑(写测试前必读)
1. **pinia 必须用 `createTestPinia()`**,否则 persist 插件不生效(详见 3.2)。
2. **persist 的存储格式**:插件 v4 存的是纯 state JSON(`{"token":...}`),**不是** `{state, version}` 旧格式;测试里预置 localStorage 时按 v4 格式写。
3. **跨用例泄漏**:凡是测试里创建过 store 或挂载过页面,`beforeEach` 必须 `localStorage.clear()` + `sessionStorage.clear()`。
4. **mock 工厂提升**:`vi.mock` 工厂不能引用外部变量,需要的数据要么内联、要么在 `mockImplementation` 里再取。
5. **`vi.mock('@/services/api')` 时**要提供被测代码用到的**所有**导出(`loginApi`、`loadDataApi`、`pageDataApi` 等),缺一个就报 undefined。
6. **异步断言**:`flushPromises()` + `nextTick()`;不要用 `setTimeout` 猜时序。
7. **不 mock 的东西**:`nprogress` 已在 `tests/setup.js` 全局 mock;`ResizeObserver`/`IntersectionObserver`/`matchMedia`/`scrollTo` 已有 polyfill,组件测试直接可用。
8. **jsdom 不实现布局**:`offsetHeight`/`getBoundingClientRect` 返回 0,涉及拖拽/滚动/尺寸的断言改为验证状态与事件,不验证像素值。
## 7. AI 工作流(每次测试任务的固定步骤)
1. **先跑基线**:`pnpm test` —— 确认现有用例全绿再动手。
2. **定位范围**:根据任务(测一个功能/页面/全部)用第 1 节命令锁定目标测试文件。
3. **对照清单**:按第 4 节对应对象的清单逐项写用例,不遗漏边界。
4. **写测试**:遵循第 3 节模板与第 6 节坑位。
5. **跑目标文件**:`pnpm test:file <目标>`,红了就修,直到绿。
6. **跑全量 + 覆盖率**:`pnpm test` 全绿、`pnpm test:coverage` 达 60%(或说明缺口与下一步)。
7. **汇报**:列出新增测试文件、覆盖提升幅度、未达标项与原因。
## 8. 与开发规范的关系
`../开发规范.md` 第 2 节"不主动执行构建、测试"约束的是**改业务代码时**的默认行为;
**当任务本身是"测试"时**(如本文档适用的场景),测试命令必须执行,且以本文档第 7 节流程为准。
@@ -0,0 +1,149 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from '@/components/ui/button/button.vue'
import Tag from '@/components/ui/tag/tag.vue'
import Switch from '@/components/ui/switch/switch.vue'
import Radio from '@/components/ui/radio/radio.vue'
import RadioGroup from '@/components/ui/radio/radio-group.vue'
import Checkbox from '@/components/ui/checkbox/checkbox.vue'
import Spin from '@/components/ui/spin/spin.vue'
describe('Button 组件', () => {
it('默认渲染 primary 按钮并显示插槽内容', () => {
const wrapper = mount(Button, { slots: { default: '提交' } })
expect(wrapper.find('button').classes()).toContain('button-primary')
expect(wrapper.text()).toContain('提交')
})
it('type/block/loading 映射 class', () => {
const wrapper = mount(Button, { props: { type: 'danger', block: true, loading: true } })
const btn = wrapper.find('button')
expect(btn.classes()).toContain('button-danger')
expect(btn.classes()).toContain('button-block')
expect(btn.classes()).toContain('button-loading')
})
it('loading 或 disabled 时禁用原生按钮', () => {
const loading = mount(Button, { props: { loading: true } })
expect(loading.find('button').attributes('disabled')).toBeDefined()
const disabled = mount(Button, { props: { disabled: true } })
expect(disabled.find('button').attributes('disabled')).toBeDefined()
})
it('htmlType 透传原生 type', () => {
const wrapper = mount(Button, { props: { htmlType: 'submit' } })
expect(wrapper.find('button').attributes('type')).toBe('submit')
})
})
describe('Tag 组件', () => {
it('默认渲染 default 变体', () => {
const wrapper = mount(Tag, { slots: { default: '标签' } })
expect(wrapper.text()).toContain('标签')
})
it('variant 映射 class', () => {
const wrapper = mount(Tag, { props: { variant: 'danger' } })
expect(wrapper.classes()).toContain('tag-danger')
})
it('closable 点击关闭:emit close 并隐藏', async () => {
const wrapper = mount(Tag, { props: { closable: true } })
await wrapper.find('[role="button"]').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
expect(wrapper.classes()).toContain('tag-hidden')
})
it('disabled 时点击不关闭', async () => {
const wrapper = mount(Tag, { props: { closable: true, disabled: true } })
await wrapper.find('[role="button"]').trigger('click')
expect(wrapper.emitted('close')).toBeUndefined()
})
it('href 渲染为链接', () => {
const wrapper = mount(Tag, { props: { href: '/x' } })
expect(wrapper.find('a').exists()).toBe(true)
})
})
describe('Switch 组件', () => {
it('点击触发 update:modelValue 与 change', async () => {
const wrapper = mount(Switch, { props: { modelValue: false } })
await wrapper.find('[role="switch"]').trigger('click')
expect(wrapper.emitted('update:modelValue')[0]).toEqual([true])
expect(wrapper.emitted('change')[0][0]).toBe(true)
})
it('checked 状态映射 class 与 aria-checked', () => {
const wrapper = mount(Switch, { props: { modelValue: true } })
expect(wrapper.find('[role="switch"]').classes()).toContain('switch-checked')
expect(wrapper.find('[role="switch"]').attributes('aria-checked')).toBe('true')
})
it('disabled 时点击不触发', async () => {
const wrapper = mount(Switch, { props: { disabled: true } })
await wrapper.find('[role="switch"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
})
describe('RadioGroup + Radio 组件', () => {
it('选中 radio 触发 v-model 更新', async () => {
const real = mount({
components: { RadioGroup, Radio },
template: '<RadioGroup v-model="v"><Radio value="a" /><Radio value="b" /></RadioGroup>',
data: () => ({ v: 'a' }),
})
const radios = real.findAll('input[type="radio"]')
expect(radios[0].element.checked).toBe(true)
await radios[1].setValue()
expect(real.vm.v).toBe('b')
})
it('独立 Radio 未分组时默认不选中', () => {
const wrapper = mount(Radio, { props: { value: 'x' } })
expect(wrapper.find('input').element.checked).toBe(false)
})
})
describe('Checkbox 组件', () => {
it('勾选触发 update:modelValue 与 change', async () => {
const wrapper = mount(Checkbox, { props: { modelValue: false } })
await wrapper.find('input[type="checkbox"]').setValue(true)
expect(wrapper.emitted('update:modelValue')[0]).toEqual([true])
expect(wrapper.emitted('change')[0][0]).toBe(true)
})
it('checked/indeterminate/disabled 映射 class', () => {
const checked = mount(Checkbox, { props: { modelValue: true } })
expect(checked.classes()).toContain('checkbox-checked')
const semi = mount(Checkbox, { props: { indeterminate: true, modelValue: false } })
expect(semi.classes()).toContain('checkbox-indeterminate')
const disabled = mount(Checkbox, { props: { disabled: true } })
expect(disabled.classes()).toContain('checkbox-disabled')
})
})
describe('Spin 组件', () => {
it('默认加载中显示 aria-busy', () => {
const wrapper = mount(Spin)
expect(wrapper.attributes('aria-busy')).toBe('true')
})
it('spinning=false 不显示', () => {
const wrapper = mount(Spin, { props: { spinning: false } })
expect(wrapper.attributes('aria-busy')).toBe('false')
})
it('delay 延迟生效:立即不 active,延时后 active', async () => {
const wrapper = mount(Spin, { props: { spinning: true, delay: 50 } })
expect(wrapper.attributes('aria-busy')).toBe('false')
await new Promise((r) => setTimeout(r, 80))
expect(wrapper.attributes('aria-busy')).toBe('true')
})
it('description 渲染加载文案', () => {
const wrapper = mount(Spin, { props: { description: '加载中...' } })
expect(wrapper.text()).toContain('加载中...')
})
})
@@ -0,0 +1,411 @@
import { describe, it, expect, vi } from 'vitest'
import { nextTick } from 'vue'
import { mount } from '@vue/test-utils'
import Input from '@/components/ui/input/input.vue'
const inputEl = (wrapper) => wrapper.find('[data-slot="input"]')
const textareaEl = (wrapper) => wrapper.find('[data-slot="textarea"]')
const clearBtn = (wrapper) => wrapper.find('button[aria-label="清除"]')
const resizeHandle = (wrapper) => wrapper.find('[role="slider"]')
describe('Input 组件 · default 模式', () => {
it('渲染 input 并透传 value', () => {
const wrapper = mount(Input, { props: { modelValue: 'abc' } })
expect(inputEl(wrapper).element.value).toBe('abc')
})
it('Number 类型的 modelValue 正常渲染', () => {
const wrapper = mount(Input, { props: { modelValue: 123 } })
expect(inputEl(wrapper).element.value).toBe('123')
})
it('输入触发 update:modelValue', async () => {
const wrapper = mount(Input, { props: { modelValue: '' } })
await inputEl(wrapper).setValue('hello')
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['hello'])
})
it('change 事件转发', async () => {
const wrapper = mount(Input, { props: { modelValue: '' } })
await inputEl(wrapper).setValue('x')
await inputEl(wrapper).trigger('change')
expect(wrapper.emitted('change')[0]).toEqual(['x'])
})
it('focus/blur 触发事件并切换聚焦态', async () => {
const wrapper = mount(Input)
await inputEl(wrapper).trigger('focus')
expect(wrapper.emitted('focus')).toHaveLength(1)
expect(wrapper.classes()).toContain('input-focused')
await inputEl(wrapper).trigger('blur')
expect(wrapper.emitted('blur')).toHaveLength(1)
expect(wrapper.classes()).not.toContain('input-focused')
})
it('maxlength 透传原生属性', () => {
const wrapper = mount(Input, { props: { maxlength: 20 } })
expect(inputEl(wrapper).attributes('maxlength')).toBe('20')
})
it('disabled/readonly 反映到原生属性与 wrapper class', () => {
const d = mount(Input, { props: { disabled: true } })
expect(inputEl(d).attributes('disabled')).toBeDefined()
expect(d.classes()).toContain('input-disabled')
const r = mount(Input, { props: { readonly: true } })
expect(inputEl(r).attributes('readonly')).toBeDefined()
expect(r.classes()).toContain('input-readonly')
})
it('status=error 映射 aria-invalid 与 class', () => {
const wrapper = mount(Input, { props: { status: 'error' } })
expect(inputEl(wrapper).attributes('aria-invalid')).toBe('true')
expect(wrapper.classes()).toContain('input-error')
})
it('status=warning 映射 class 且不设置 aria-invalid', () => {
const wrapper = mount(Input, { props: { status: 'warning' } })
expect(wrapper.classes()).toContain('input-warning')
expect(inputEl(wrapper).attributes('aria-invalid')).toBeUndefined()
})
it('prefix/suffix 插槽渲染', () => {
const wrapper = mount(Input, {
slots: { prefix: '<span class="pfx">P</span>', suffix: '<span class="sfx">S</span>' },
})
expect(wrapper.find('.pfx').exists()).toBe(true)
expect(wrapper.find('.sfx').exists()).toBe(true)
})
it('allowClear:有值但未聚焦/未悬停时不显示清除按钮', () => {
const wrapper = mount(Input, { props: { modelValue: 'v', allowClear: true } })
expect(clearBtn(wrapper).exists()).toBe(false)
})
it('allowClear:悬停时显示清除按钮,点击清空', async () => {
const wrapper = mount(Input, { props: { modelValue: 'v', allowClear: true } })
await wrapper.trigger('mouseenter')
expect(clearBtn(wrapper).exists()).toBe(true)
await clearBtn(wrapper).trigger('click')
expect(wrapper.emitted('update:modelValue')[0]).toEqual([''])
expect(wrapper.emitted('clear')).toHaveLength(1)
})
it('allowClear:disabled 时即使悬停也不显示清除按钮', async () => {
const wrapper = mount(Input, { props: { modelValue: 'v', allowClear: true, disabled: true } })
await wrapper.trigger('mouseenter')
expect(clearBtn(wrapper).exists()).toBe(false)
})
it('allowClear:readonly 时即使悬停也不显示清除按钮', async () => {
const wrapper = mount(Input, { props: { modelValue: 'v', allowClear: true, readonly: true } })
await wrapper.trigger('mouseenter')
expect(clearBtn(wrapper).exists()).toBe(false)
})
it('allowClear:mouseleave 后清除按钮隐藏', async () => {
const wrapper = mount(Input, { props: { modelValue: 'v', allowClear: true } })
await wrapper.trigger('mouseenter')
expect(clearBtn(wrapper).exists()).toBe(true)
await wrapper.trigger('mouseleave')
expect(clearBtn(wrapper).exists()).toBe(false)
})
it('showCount 显示字数统计(无上限仅长度)', () => {
const wrapper = mount(Input, { props: { modelValue: 'abc', showCount: true } })
expect(wrapper.text()).toContain('3')
})
it('showCount 配合 maxlength 显示 当前 / 上限', () => {
const wrapper = mount(Input, { props: { modelValue: 'abc', showCount: true, maxlength: 10 } })
expect(wrapper.text()).toContain('3 / 10')
})
it('type 校验器拒绝非法模式', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mount(Input, { props: { type: 'foo' } })
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
})
describe('Input 组件 · password 模式', () => {
it('默认 type=password,点击切换明文,再点击切回密文', async () => {
const wrapper = mount(Input, { props: { type: 'password', modelValue: 'pw' } })
const input = inputEl(wrapper)
expect(input.attributes('type')).toBe('password')
await wrapper.find('button[aria-label="显示密码"]').trigger('click')
expect(input.attributes('type')).toBe('text')
expect(input.element.value).toBe('pw')
await wrapper.find('button[aria-label="隐藏密码"]').trigger('click')
expect(input.attributes('type')).toBe('password')
})
it('切换按钮 aria-label/aria-pressed 随状态变化', async () => {
const wrapper = mount(Input, { props: { type: 'password' } })
const toggle = wrapper.find('button[aria-label="显示密码"]')
expect(toggle.attributes('aria-pressed')).toBe('false')
await toggle.trigger('click')
expect(wrapper.find('button[aria-label="隐藏密码"]').attributes('aria-pressed')).toBe('true')
})
it('disabled 时切换按钮禁用', () => {
const wrapper = mount(Input, { props: { type: 'password', disabled: true } })
expect(wrapper.find('button[aria-label="显示密码"]').attributes('disabled')).toBeDefined()
})
it('readonly 时切换按钮禁用', () => {
const wrapper = mount(Input, { props: { type: 'password', readonly: true } })
expect(wrapper.find('button[aria-label="显示密码"]').attributes('disabled')).toBeDefined()
})
it('password 模式仍支持字数统计', () => {
const wrapper = mount(Input, { props: { type: 'password', modelValue: 'abc', showCount: true } })
expect(wrapper.text()).toContain('3')
})
})
describe('Input 组件 · number 模式', () => {
it('过滤非法字符:字母被剔除,只保留数字与单个小数点', async () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: '' } })
await inputEl(wrapper).setValue('12a.b.c')
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['12.'])
})
it('过滤多余小数点:12.3.4 归一化为 12.34', async () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: '' } })
await inputEl(wrapper).setValue('12.3.4')
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['12.34'])
})
it('纯字母输入被清空', async () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: '' } })
await inputEl(wrapper).setValue('abc')
expect(wrapper.emitted('update:modelValue')[0]).toEqual([''])
})
it('保留小数中间态:输入 "12." 不被截断', async () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: '' } })
await inputEl(wrapper).setValue('12.')
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['12.'])
})
it('输入 "." 保留中间态,失焦后提交为空串', async () => {
const real = mount({
components: { Input },
template: '<Input v-model="v" type="number" />',
data: () => ({ v: '' }),
})
const input = real.find('[data-slot="input"]')
await input.setValue('.')
expect(real.vm.v).toBe('.')
await input.trigger('blur')
expect(real.vm.v).toBe('')
})
it('失焦提交转为 Number:3.50 → 3.5', async () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: '' } })
await inputEl(wrapper).setValue('3.50')
await inputEl(wrapper).trigger('blur')
expect(wrapper.emitted('update:modelValue').at(-1)).toEqual([3.5])
})
it('失焦后原生输入框显示规范化值', async () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: '' } })
await inputEl(wrapper).setValue('3.50')
await inputEl(wrapper).trigger('blur')
expect(inputEl(wrapper).element.value).toBe('3.5')
})
it('change 事件提交 Number 值', async () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: '' } })
await inputEl(wrapper).setValue('3.50')
await inputEl(wrapper).trigger('change')
expect(wrapper.emitted('change')[0]).toEqual([3.5])
})
it('值未变化时失焦不重复触发 update:modelValue', async () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: 3.5 } })
await inputEl(wrapper).setValue('3.5')
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
await inputEl(wrapper).trigger('blur')
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
})
it('空值失焦不重复触发 update:modelValue', async () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: '' } })
await inputEl(wrapper).setValue('')
await inputEl(wrapper).trigger('blur')
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
})
it('透传 inputmode=decimal 与 number 校验 pattern', () => {
const wrapper = mount(Input, { props: { type: 'number' } })
expect(inputEl(wrapper).attributes('inputmode')).toBe('decimal')
expect(inputEl(wrapper).attributes('pattern')).toBe('[0-9]*[.]?[0-9]*')
})
it('Number 类型 modelValue 渲染为字符串', () => {
const wrapper = mount(Input, { props: { type: 'number', modelValue: 1.28 } })
expect(inputEl(wrapper).element.value).toBe('1.28')
})
})
describe('Input 组件 · textarea 模式', () => {
it('渲染 textarea 并转发输入', async () => {
const wrapper = mount(Input, { props: { type: 'textarea', modelValue: 'multi' } })
expect(textareaEl(wrapper).element.value).toBe('multi')
await textareaEl(wrapper).setValue('new')
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['new'])
})
it('change 事件转发', async () => {
const wrapper = mount(Input, { props: { type: 'textarea', modelValue: '' } })
await textareaEl(wrapper).setValue('x')
await textareaEl(wrapper).trigger('change')
expect(wrapper.emitted('change')[0]).toEqual(['x'])
})
it('focus/blur 触发事件', async () => {
const wrapper = mount(Input, { props: { type: 'textarea' } })
await textareaEl(wrapper).trigger('focus')
expect(wrapper.emitted('focus')).toHaveLength(1)
await textareaEl(wrapper).trigger('blur')
expect(wrapper.emitted('blur')).toHaveLength(1)
})
it('rows 透传(默认 3,可自定义)', () => {
const d = mount(Input, { props: { type: 'textarea' } })
expect(textareaEl(d).attributes('rows')).toBe('3')
const custom = mount(Input, { props: { type: 'textarea', rows: 6 } })
expect(textareaEl(custom).attributes('rows')).toBe('6')
})
it('maxlength 透传', () => {
const wrapper = mount(Input, { props: { type: 'textarea', maxlength: 100 } })
expect(textareaEl(wrapper).attributes('maxlength')).toBe('100')
})
it('disabled/readonly 反映到原生属性', () => {
const d = mount(Input, { props: { type: 'textarea', disabled: true } })
expect(textareaEl(d).attributes('disabled')).toBeDefined()
const r = mount(Input, { props: { type: 'textarea', readonly: true } })
expect(textareaEl(r).attributes('readonly')).toBeDefined()
})
it('status=error 映射 aria-invalid', () => {
const wrapper = mount(Input, { props: { type: 'textarea', status: 'error' } })
expect(textareaEl(wrapper).attributes('aria-invalid')).toBe('true')
})
it('textarea 无清除按钮', () => {
const wrapper = mount(Input, { props: { type: 'textarea', modelValue: 'x', allowClear: true } })
expect(clearBtn(wrapper).exists()).toBe(false)
})
it('showCount 配合 maxlength 显示 当前 / 上限', () => {
const wrapper = mount(Input, {
props: { type: 'textarea', modelValue: 'abc', showCount: true, maxlength: 100 },
})
expect(wrapper.text()).toContain('3 / 100')
})
it('可编辑时显示拖拽调整手柄', () => {
const wrapper = mount(Input, { props: { type: 'textarea' } })
expect(resizeHandle(wrapper).exists()).toBe(true)
})
it('disabled 时不显示拖拽调整手柄', () => {
const wrapper = mount(Input, { props: { type: 'textarea', disabled: true } })
expect(resizeHandle(wrapper).exists()).toBe(false)
})
it('readonly 时不显示拖拽调整手柄', () => {
const wrapper = mount(Input, { props: { type: 'textarea', readonly: true } })
expect(resizeHandle(wrapper).exists()).toBe(false)
})
it('键盘调整高度:ArrowDown 增高 / ArrowUp 降低 / Home 还原', async () => {
const wrapper = mount(Input, { props: { type: 'textarea' } })
const handle = resizeHandle(wrapper)
const ta = textareaEl(wrapper)
await handle.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(ta.element.style.height).toBe('20px')
await handle.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(ta.element.style.height).toBe('40px')
await handle.trigger('keydown', { key: 'ArrowUp' })
await nextTick()
expect(ta.element.style.height).toBe('20px')
await handle.trigger('keydown', { key: 'Home' })
await nextTick()
expect(ta.element.style.height).toBe('0px')
})
it('键盘调整不会低于默认高度', async () => {
const wrapper = mount(Input, { props: { type: 'textarea' } })
await resizeHandle(wrapper).trigger('keydown', { key: 'ArrowUp' })
await nextTick()
expect(textareaEl(wrapper).element.style.height).toBe('0px')
})
it('拖拽手柄忽略非调整键', async () => {
const wrapper = mount(Input, { props: { type: 'textarea' } })
await resizeHandle(wrapper).trigger('keydown', { key: 'Enter' })
await nextTick()
expect(textareaEl(wrapper).element.style.height).toBe('')
})
it('textarea 模式切换后高度状态重置', async () => {
const wrapper = mount(Input, { props: { type: 'textarea' } })
await resizeHandle(wrapper).trigger('keydown', { key: 'ArrowDown' })
await nextTick()
expect(textareaEl(wrapper).element.style.height).toBe('20px')
await wrapper.setProps({ type: 'default' })
await wrapper.setProps({ type: 'textarea' })
await nextTick()
expect(textareaEl(wrapper).element.style.height).toBe('')
})
it('拖拽调整高度:pointermove 更新高度', async () => {
const wrapper = mount(Input, { props: { type: 'textarea' } })
const handleEl = resizeHandle(wrapper).element
handleEl.dispatchEvent(new MouseEvent('pointerdown', { clientY: 100, bubbles: true }))
document.dispatchEvent(new MouseEvent('pointermove', { clientY: 150, bubbles: true }))
await vi.waitFor(() => {
expect(textareaEl(wrapper).element.style.height).toBe('50px')
})
document.dispatchEvent(new MouseEvent('pointerup'))
wrapper.unmount()
})
it('拖拽未渲染帧即结束:立即应用待定高度', async () => {
const wrapper = mount(Input, { props: { type: 'textarea' } })
const handleEl = resizeHandle(wrapper).element
handleEl.dispatchEvent(new MouseEvent('pointerdown', { clientY: 100, bubbles: true }))
document.dispatchEvent(new MouseEvent('pointermove', { clientY: 130, bubbles: true }))
// 同步结束拖拽:rAF 帧尚未渲染,flushResizeFrame 应取消帧并应用待定高度
document.dispatchEvent(new MouseEvent('pointerup', { clientY: 130, bubbles: true }))
await nextTick()
expect(textareaEl(wrapper).element.style.height).toBe('30px')
wrapper.unmount()
})
it('拖拽移动小于 2px 忽略', async () => {
const wrapper = mount(Input, { props: { type: 'textarea' } })
const handleEl = resizeHandle(wrapper).element
handleEl.dispatchEvent(new MouseEvent('pointerdown', { clientY: 100, bubbles: true }))
document.dispatchEvent(new MouseEvent('pointermove', { clientY: 101, bubbles: true }))
document.dispatchEvent(new MouseEvent('pointerup', { clientY: 101, bubbles: true }))
await nextTick()
expect(textareaEl(wrapper).element.style.height).toBe('')
wrapper.unmount()
})
})
@@ -0,0 +1,153 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { registerFeature, useAreaSelection } from 'stk-table-vue'
import FmsTable from '@/components/fms-table/FmsTable.vue'
// 与 main.js 一致:注册 stk-table 的按需 feature(行编辑依赖 area-selection)
registerFeature(useAreaSelection)
const COLS = 20
function makeColumns() {
const cols = []
for (let i = 0; i < COLS; i++) {
cols.push({ title: `列 ${i}`, dataIndex: `c${i}`, width: 120, editor: { type: 'input' } })
}
return cols
}
function makeRows(n) {
return Array.from({ length: n }, (_, i) => {
const row = { id: i + 1 }
for (let j = 0; j < COLS; j++) row[`c${j}`] = `${i + 1}-${j}`
return row
})
}
describe('fms-table 虚拟滚动验证', () => {
beforeEach(() => setActivePinia(createPinia()))
it('5000 行时仅渲染可视区行数(虚拟滚动切片生效)', async () => {
const wrapper = mount(FmsTable, {
props: { columns: makeColumns(), dataSource: makeRows(5000), rowKey: 'id', seq: false },
attachTo: document.body,
})
await nextTick()
await new Promise((r) => setTimeout(r, 100))
const trCount = wrapper.findAll('.stk-tbody-main tr').length
console.log('=== 5000 行时 DOM 实际渲染的 <tr> 数量:', trCount)
expect(trCount).toBeGreaterThan(0)
expect(trCount).toBeLessThan(100)
wrapper.unmount()
})
it('20 行时全量渲染(数据量小于阈值关闭虚拟滚动,不再吞掉最后一行)', async () => {
const wrapper = mount(FmsTable, {
props: { columns: makeColumns(), dataSource: makeRows(20), rowKey: 'id', seq: false },
attachTo: document.body,
})
await nextTick()
await new Promise((r) => setTimeout(r, 100))
const trCount = wrapper.findAll('.stk-tbody-main tr').length
console.log('=== 20 行时 DOM 实际渲染的 <tr> 数量:', trCount)
expect(trCount).toBe(20)
wrapper.unmount()
})
it('数据量从 20 行增长到 5000 行后,DOM 行数保持为可视区行数', async () => {
const wrapper = mount(FmsTable, {
props: { columns: makeColumns(), dataSource: makeRows(20), rowKey: 'id', seq: false },
attachTo: document.body,
})
await nextTick()
await new Promise((r) => setTimeout(r, 100))
const before = wrapper.findAll('.stk-tbody-main tr').length
await wrapper.setProps({ dataSource: makeRows(5000) })
await nextTick()
await new Promise((r) => setTimeout(r, 100))
const after = wrapper.findAll('.stk-tbody-main tr').length
console.log(`=== 20 行时渲染 ${before} 行,改为 5000 行后渲染 ${after} 行`)
expect(before).toBeGreaterThan(0)
expect(after).toBeGreaterThan(0)
// 核心断言:5000 行数据时 DOM 只保留可视区行数,不会渲染全部 5000 行
expect(after).toBeLessThan(100)
wrapper.unmount()
})
// 性能优化回归(item #1):非编辑态下可编辑列/纯展示列走 StkTable 原生 td 渲染,
// 不注入 customCell 函数式组件;只有进入编辑后才挂载编辑器。
it('非编辑态:全部单元格走原生 table-cell-wrapper 渲染,零编辑器组件', async () => {
const wrapper = mount(FmsTable, {
props: { columns: makeColumns(), dataSource: makeRows(20), rowKey: 'id', seq: false },
attachTo: document.body,
})
await nextTick()
await new Promise((r) => setTimeout(r, 100))
const tds = wrapper.findAll('.stk-tbody-main td[data-col-key]')
expect(tds.length).toBeGreaterThan(0)
// 没有活动编辑行 → 不注入任何编辑器渲染
expect(wrapper.find('.fms-cell-editor-root').exists()).toBe(false)
// 每个数据单元格都走库原生 table-cell-wrapper 路径(无 fms-cell-display 包裹)
const nativeWrappers = wrapper.findAll('.stk-tbody-main .table-cell-wrapper')
expect(nativeWrappers.length).toBe(tds.length)
wrapper.unmount()
})
// 性能优化回归(item #1):进入编辑后仅活动编辑行挂载编辑器,其余行保持原生渲染
it('进入编辑后:仅活动编辑行挂载编辑器,其余行保持原生渲染', async () => {
const wrapper = mount(FmsTable, {
props: { columns: makeColumns(), dataSource: makeRows(20), rowKey: 'id', seq: false },
attachTo: document.body,
})
await nextTick()
await new Promise((r) => setTimeout(r, 100))
const trs = wrapper.findAll('.stk-tbody-main tr')
const td = trs[1].find('td[data-col-key]')
await td.trigger('mousedown', { button: 0 })
await nextTick()
await td.trigger('mouseup')
await nextTick()
await new Promise((r) => setTimeout(r, 100))
const editingRow = wrapper.findAll('.stk-tbody-main tr')[1]
const editorCountInEditingRow = editingRow.findAll('.fms-cell-editor-root').length
expect(editorCountInEditingRow).toBeGreaterThan(0)
expect(editorCountInEditingRow).toBeLessThanOrEqual(makeColumns().length)
// 其余行不挂载编辑器(保持原生渲染,滚动时行间切换不重建整表)
trs.forEach((tr, index) => {
if (index === 1) return
expect(tr.findAll('.fms-cell-editor-root').length).toBe(0)
})
wrapper.unmount()
})
it('点击单元格进入单行编辑(area-selection 注册后生效,编辑器渲染)', async () => {
const wrapper = mount(FmsTable, {
props: { columns: makeColumns(), dataSource: makeRows(20), rowKey: 'id', seq: false },
attachTo: document.body,
})
await nextTick()
await new Promise((r) => setTimeout(r, 100))
// 点击第 2 行第 1 个数据列(跳过 virtualX 的占位 td,选中带 data-col-key 的单元格)
const trs = wrapper.findAll('.stk-tbody-main tr')
const td = trs[1].find('td[data-col-key]')
console.log('=== td outerHTML:', td.element.outerHTML.slice(0, 200))
await td.trigger('mousedown', { button: 0 })
await nextTick()
console.log('=== mousedown 后 td data-cs-s:', td.attributes()['data-cs-s'])
await td.trigger('mouseup')
await nextTick()
await new Promise((r) => setTimeout(r, 100))
const editor = wrapper.find('.fms-cell-editor-root')
console.log('=== 点击后编辑器是否渲染:', editor.exists())
expect(editor.exists()).toBe(true)
wrapper.unmount()
})
})
+70
View File
@@ -0,0 +1,70 @@
/**
* 测试 harness:为「页面/功能」组件测试提供统一装配。
*
* 设计原则:
* - 所有请求都收敛在 @/services/api,测试时只需 vi.mock('@/services/api') 即可确定性驱动状态。
* - pinia 带 persist 插件(与生产一致),避免 stores 在测试里行为漂移。
* - 用 memory history 路由,避免真实 History API 副作用。
* - 子组件默认 shallowMount 打桩,页面测试只关注「页面自身逻辑」:把子组件事件 emit 出来即可。
*
* 用法见 tests/views/*.spec.js
*/
import { shallowMount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import { createRouter, createMemoryHistory } from 'vue-router'
/**
* 创建一个带 persist 插件的全新 pinia,并设为 active(便于测试里直接 useXStore())。
*
* 注意:pinia 4 的 `pinia.use()` 在实例未安装到 app 前只把插件放入 toBeInstalled 队列,
* 必须经过 `install(app)` 才会真正注册。页面测试经 @vue/test-utils 的 plugins 挂载会自动 install,
* 但"纯 store 层测试"(setActivePinia + useXStore)不会,导致 persist 插件静默失效、
* store 上缺少 $hydrate/$persist。这里用一个假 app 触发 install,保证两类测试行为一致。
*/
export function createTestPinia() {
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
pinia.install({ provide() {}, config: { globalProperties: {} } })
setActivePinia(pinia)
return pinia
}
/** 创建一个 memory history 路由,并可预先定位到某个 location(含 query) */
export function createTestRouter(initialLocation = '/') {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/:pathMatch(.*)*', name: 'catch-all', component: { template: '<div />' } },
],
})
router.push(initialLocation)
return router
}
/**
* 挂载一个页面/功能组件。
* @param {object} Component 组件定义(通常 import 自 @/views/...)
* @param {object} [options]
* @param {string} [options.location] 初始路由 location,如 '/login?redirect=/dashboard'
* @param {object} [options.stubs] 额外子组件打桩(覆盖 shallowMount 默认打桩)
* @param {object} [options.global] 透传给 @vue/test-utils 的 global 选项
* @param {object} [options.mountOptions] 透传其余 mount 选项
* @returns {Promise<{ wrapper, router, pinia }>}
*/
export async function mountPage(Component, options = {}) {
const pinia = createTestPinia()
const router = createTestRouter(options.location || '/')
await router.isReady()
const wrapper = shallowMount(Component, {
global: {
plugins: [pinia, router],
stubs: options.stubs || {},
...(options.global || {}),
},
...(options.mountOptions || {}),
})
return { wrapper, router, pinia }
}
+121
View File
@@ -0,0 +1,121 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import { usePermissionStore } from '@/stores/permissions'
// mock 后端数据接口,避免真实网络请求
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(),
}))
import { loadDataApi } from '@/services/api'
const mockModules = [
{
b_id: '1',
b_code: 'm1',
b_name: 'M1',
b_canuse: 1,
b_canmenu: 1,
b_module_type: 'directory',
b_parent_id: null,
b_icon: '',
b_route: '',
},
]
const mockUserModules = [{ b_id: '1', b_module_id: '1', b_user_id: '123' }]
const mockUserPowers = []
const mockPowers = []
function mockApiResponses() {
loadDataApi.mockImplementation((view) => {
const data =
view === 's_module'
? mockModules
: view === 'b_user_module'
? mockUserModules
: view === 'b_user_power'
? mockUserPowers
: view === 's_module_power'
? mockPowers
: []
return Promise.resolve({ data, code: 0, success: true })
})
}
function setLoggedInUser() {
const auth = useAuthStore()
auth.setSession({ token: 't', user: { id: 123, account: 'u1', name: 'U' } }, 'ORG')
}
// 冲刷微任务:让 setSession 触发的 watch(clear) 先执行,隔离“手动 clear”的断言
const flush = () => Promise.resolve()
beforeEach(() => {
setActivePinia(createPinia())
loadDataApi.mockReset()
})
describe('permissions store', () => {
it('load 成功后写入 modules 且记录 loadedPrincipalKey', async () => {
mockApiResponses()
setLoggedInUser()
const perm = usePermissionStore()
await perm.load()
expect(perm.modules.length).toBe(1)
})
it('同一主体重复 load 不重复发请求(按主体去重)', async () => {
mockApiResponses()
setLoggedInUser()
const perm = usePermissionStore()
await perm.load()
await perm.load()
// 首次发 4 个接口调用;第二次因主体已加载直接 return
expect(loadDataApi).toHaveBeenCalledTimes(4)
})
it('登录场景:load 进行中触发 clear(主体相同)不作废结果', async () => {
mockApiResponses()
setLoggedInUser()
await flush() // 先让 watch(clear) 跑完(清空空状态,不作废)
const perm = usePermissionStore()
const p = perm.load() // loadingPrincipalKey = 'ORG:123'
// 模拟登录后 setSession 触发的 watch(clear):当前主体与进行中请求主体相同
perm.clear()
await p
// 关键修复:clear 未作废进行中请求,modules 正常写入
expect(perm.modules.length).toBe(1)
})
it('切换账号:load 进行中触发 clear(主体不同)作废旧请求', async () => {
mockApiResponses()
setLoggedInUser()
await flush()
const perm = usePermissionStore()
const p = perm.load() // 主体 ORG:123 进行中
// 模拟切换账号:authStore 变为另一主体,watch 触发 clear
const auth = useAuthStore()
auth.setSession({ token: 't2', user: { id: 999, account: 'u2', name: 'U2' } }, 'ORG2')
perm.clear()
await p
// 旧请求被作废:结果丢弃,modules 仍为空(且未触发新主体的 load)
expect(perm.modules.length).toBe(0)
})
it('g3soft 提权:仅加载全部启用模块', async () => {
loadDataApi.mockImplementation((view) =>
Promise.resolve({
data: view === 's_module' ? mockModules : [],
code: 0,
success: true,
}),
)
const auth = useAuthStore()
auth.setSession({ token: 't', user: { id: 1, account: 'g3soft', name: 'G' } }, 'ORG')
const perm = usePermissionStore()
await perm.load()
expect(perm.isG3soft).toBe(true)
expect(perm.modules.length).toBe(1)
})
})
+108
View File
@@ -0,0 +1,108 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import { usePermissionStore } from '@/stores/permissions'
import { createTestPinia } from './helpers/app.js'
import router from '@/router'
// mock 后端数据接口
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(),
}))
import { loadDataApi } from '@/services/api'
const mockModules = [
{
b_id: '1',
b_code: 'm1',
b_name: 'M1',
b_canuse: 1,
b_canmenu: 1,
b_module_type: 'directory',
b_parent_id: null,
b_icon: '',
b_route: '',
},
]
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
setActivePinia(createPinia())
loadDataApi.mockReset()
loadDataApi.mockImplementation((view) =>
Promise.resolve({ data: view === 's_module' ? mockModules : [], code: 0, success: true }),
)
// 回到登录页,断开受保护路由状态
return router.push('/login').catch(() => {})
})
afterEach(() => {
if (router.hasRoute('biz')) router.removeRoute('biz')
})
describe('路由守卫 权限加载(三场景)', () => {
it('场景·未登录:访问受保护页 → 重定向 /login?redirect', async () => {
await router.push('/dashboard')
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query.redirect).toBe('/dashboard')
})
it('场景·登录:访问受保护页 → 触发 load,菜单数据就绪(同主体不重复请求)', async () => {
const auth = useAuthStore()
auth.setSession({ token: 't', user: { id: 123, account: 'u1', name: 'U' } }, 'ORG')
await router.push('/dashboard')
expect(loadDataApi).toHaveBeenCalled()
expect(usePermissionStore().modules.length).toBe(1)
// 再次进入另一受保护页(同主体)不应重复发请求
loadDataApi.mockClear()
await router.push('/test/list')
expect(loadDataApi).not.toHaveBeenCalled()
})
it('场景·登录:访问无权限 moduleId 路由 → 重定向 /dashboard', async () => {
const auth = useAuthStore()
auth.setSession({ token: 't', user: { id: 123, account: 'u1', name: 'U' } }, 'ORG')
router.addRoute({
path: '/biz',
name: 'biz',
component: { render: () => null },
meta: { requiresAuth: true, moduleId: 'm-no' },
})
await router.push('/biz')
expect(router.currentRoute.value.path).toBe('/dashboard')
})
it('场景·刷新已登录态:persist 恢复后守卫 load 成功,无 clear 竞态', async () => {
// 按 pinia-plugin-persistedstate v4 的实际存储格式写入(纯 state JSON,无 {state, version} 包装)
sessionStorage.setItem(
'fms-login',
JSON.stringify({ loginInfo: { token: 't', user: { id: 123, account: 'u1', name: 'U' } } }),
)
localStorage.setItem(
'fms-user',
JSON.stringify({ userInfo: { orgId: 'ORG', account: 'u1' } }),
)
// 用 createTestPinia(内部已 install,persist 插件真实注册),等价于刷新页面
const pinia = createTestPinia()
const a = useAuthStore() // 首次创建
await a.$hydrate() // 显式从 localStorage 恢复(等价于刷新页面)
expect(useAuthStore().isAuthenticated).toBe(true)
await router.push('/dashboard')
expect(usePermissionStore().modules.length).toBe(1)
})
it('场景·切换账号:旧主体请求作废,新主体重新加载', async () => {
const auth = useAuthStore()
auth.setSession({ token: 't', user: { id: 123, account: 'u1', name: 'U' } }, 'ORG')
await router.push('/dashboard')
expect(usePermissionStore().modules.length).toBe(1)
// 切换到另一账号
auth.setSession({ token: 't2', user: { id: 999, account: 'u2', name: 'U2' } }, 'ORG2')
loadDataApi.mockClear()
await router.push('/test/list')
expect(loadDataApi).toHaveBeenCalled() // 新主体重新加载
expect(usePermissionStore().modules.length).toBe(1)
})
})
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
// api.js 只依赖 http.js 的 post,mock 掉后断言每个接口的请求路径与参数
vi.mock('@/services/http', () => ({
post: vi.fn(),
}))
import { post } from '@/services/http'
import {
loginApi,
loadDataApi,
loadDataBySqlApi,
pageDataApi,
saveObjectApi,
nextIdApi,
nextCodeApi,
describeApi,
} from '@/services/api'
beforeEach(() => {
post.mockReset()
})
describe('services/api', () => {
it('loginApi:POST /auth/login,参数 orgid/userid/password', () => {
loginApi('ORG1', 'admin', 'secret')
expect(post).toHaveBeenCalledWith('/auth/login', {
orgid: 'ORG1',
userid: 'admin',
password: 'secret',
})
})
it('loadDataApi:POST /data/loaddata,携带全部查询参数', () => {
loadDataApi('s_module', 'b_canuse = 1', 'b_id ASC', ['b_id', 'b_code'])
expect(post).toHaveBeenCalledWith('/data/loaddata', {
view_name: 's_module',
search_condition: 'b_canuse = 1',
order_by: 'b_id ASC',
search_columns: ['b_id', 'b_code'],
})
})
it('loadDataApi:省略可选参数时传 undefined', () => {
loadDataApi('s_module')
expect(post).toHaveBeenCalledWith('/data/loaddata', {
view_name: 's_module',
search_condition: undefined,
order_by: undefined,
search_columns: undefined,
})
})
it('loadDataBySqlApi:POST /data/loaddatabysql,携带 sql', () => {
loadDataBySqlApi('select * from t')
expect(post).toHaveBeenCalledWith('/data/loaddatabysql', { sql: 'select * from t' })
})
it('pageDataApi:使用默认分页 1/20', () => {
pageDataApi({ viewName: 's_module' })
expect(post).toHaveBeenCalledWith('/data/page', {
view_name: 's_module',
order_by: undefined,
search_columns: undefined,
search_condition: undefined,
page_no: 1,
page_size: 20,
})
})
it('pageDataApi:自定义分页与条件', () => {
pageDataApi({
viewName: 'ocean_booking',
orderBy: 'b_id DESC',
searchColumns: ['b_id'],
searchCondition: 'b_org_id = 1',
pageNo: 3,
pageSize: 50,
})
expect(post).toHaveBeenCalledWith('/data/page', {
view_name: 'ocean_booking',
order_by: 'b_id DESC',
search_columns: ['b_id'],
search_condition: 'b_org_id = 1',
page_no: 3,
page_size: 50,
})
})
it('saveObjectApi:POST /data/saveobjt,透传请求数组', () => {
const requests = [{ table: 's_module', data: { b_id: 1 } }]
saveObjectApi(requests)
expect(post).toHaveBeenCalledWith('/data/saveobjt', requests)
})
it('nextIdApi:默认 count=1', () => {
nextIdApi()
expect(post).toHaveBeenCalledWith('/data/nextid', { count: 1 })
})
it('nextIdApi:自定义 count', () => {
nextIdApi(5)
expect(post).toHaveBeenCalledWith('/data/nextid', { count: 5 })
})
it('nextCodeApi:moduleId 转字符串并携带 count', () => {
nextCodeApi(42, 3)
expect(post).toHaveBeenCalledWith('/data/nextcode', { moduleId: '42', count: 3 })
})
it('describeApi:POST /data/describe,携带表名', () => {
describeApi('ocean_booking')
expect(post).toHaveBeenCalledWith('/data/describe', { table: 'ocean_booking' })
})
})
@@ -0,0 +1,200 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createTestPinia } from '../helpers/app.js'
import { useAuthStore } from '@/stores/auth'
// http 模块持有模块级状态(redirectingToLogin),每个用例前重置模块并动态 import,
// 保证跳转去重标志不跨用例泄漏。
async function loadHttp() {
vi.resetModules()
const { default: http } = await import('@/services/http')
return http
}
function stubLocation() {
// jsdom 的 location.assign 会抛 "Not implemented",替换为 spy
const assign = vi.fn()
Object.defineProperty(window, 'location', {
writable: true,
value: { ...window.location, assign, pathname: '/dashboard' },
})
return assign
}
const requestFulfilled = (http) => http.interceptors.request.handlers[0].fulfilled
const responseFulfilled = (http) => http.interceptors.response.handlers[0].fulfilled
const responseRejected = (http) => http.interceptors.response.handlers[0].rejected
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
vi.restoreAllMocks()
})
describe('http 请求拦截器', () => {
it('无 token 时不注入 Authorization', async () => {
const http = await loadHttp()
createTestPinia()
const config = { headers: {} }
const result = requestFulfilled(http)(config)
expect(result.headers.Authorization).toBeUndefined()
})
it('有 token 时注入 Bearer 头', async () => {
const http = await loadHttp()
createTestPinia()
useAuthStore().setSession({ token: 'tk-123' }, 'ORG')
const config = { headers: {} }
const result = requestFulfilled(http)(config)
expect(result.headers.Authorization).toBe('Bearer tk-123')
})
})
describe('http 响应拦截器 · 成功分支', () => {
it('code === 0:标记 success 并原样返回', async () => {
const http = await loadHttp()
const res = { data: { code: 0, data: [1, 2] } }
const result = responseFulfilled(http)(res)
expect(result.data.success).toBe(true)
expect(result).toBe(res)
})
it('无 code 字段:按成功透传并包装 ApiResult', async () => {
const http = await loadHttp()
const payload = { id: 1, name: 'x' }
const result = responseFulfilled(http)({ data: payload })
expect(result).toEqual({
success: true,
code: 0,
message: '',
data: payload,
})
})
it('业务错误码(非认证):抛 Error 并携带 code/data/success', async () => {
const http = await loadHttp()
const res = { data: { code: 500, message: '服务器开小差了', data: { a: 1 } } }
let caught
try {
responseFulfilled(http)(res)
} catch (e) {
caught = e
}
expect(caught).toBeInstanceOf(Error)
expect(caught.message).toBe('服务器开小差了')
expect(caught.code).toBe(500)
expect(caught.data).toEqual({ a: 1 })
expect(caught.success).toBe(false)
})
it('业务错误码无 message:使用默认文案', async () => {
const http = await loadHttp()
let caught
try {
responseFulfilled(http)({ data: { code: 1001 } })
} catch (e) {
caught = e
}
expect(caught.message).toBe('请求失败')
})
})
describe('http 响应拦截器 · 认证失效分支', () => {
it('401:清空会话并跳转 /login?reason=expired', async () => {
const http = await loadHttp()
createTestPinia()
useAuthStore().setSession({ token: 't', user: { id: 1 } }, 'ORG')
const assign = stubLocation()
let caught
try {
responseRejected(http)({ response: { status: 401, data: { code: 401 } } })
} catch (e) {
caught = e
}
expect(useAuthStore().isAuthenticated).toBe(false)
expect(assign).toHaveBeenCalledTimes(1)
const url = assign.mock.calls[0][0]
expect(url.startsWith('/login?')).toBe(true)
expect(url).toContain('reason=expired')
expect(caught.code).toBe(401)
})
it('40101:跳转 /login?reason=session-replaced', async () => {
const http = await loadHttp()
createTestPinia()
useAuthStore().setSession({ token: 't', user: { id: 1 } }, 'ORG')
const assign = stubLocation()
try {
responseRejected(http)({ response: { status: 200, data: { code: 40101 } } })
} catch {
// 预期抛错
}
const url = assign.mock.calls[0][0]
expect(url).toContain('reason=session-replaced')
})
it('已在 /login 页面时不重复跳转(不抛导航错误)', async () => {
const http = await loadHttp()
createTestPinia()
const assign = vi.fn()
Object.defineProperty(window, 'location', {
writable: true,
value: { ...window.location, assign, pathname: '/login' },
})
let caught
try {
responseRejected(http)({ response: { status: 401, data: { code: 401 } } })
} catch (e) {
caught = e
}
expect(assign).not.toHaveBeenCalled()
expect(caught.code).toBe(401)
})
})
describe('http 响应拦截器 · 网络异常分支', () => {
it('超时(ECONNABORTED):抛"请求超时"', async () => {
const http = await loadHttp()
stubLocation()
let caught
try {
responseRejected(http)({ code: 'ECONNABORTED' })
} catch (e) {
caught = e
}
expect(caught.message).toBe('请求超时,请稍后重试')
expect(caught.code).toBe(-1)
})
it('其他网络错误:抛"网络异常"', async () => {
const http = await loadHttp()
stubLocation()
let caught
try {
responseRejected(http)({ message: 'Network Error' })
} catch (e) {
caught = e
}
expect(caught.message).toBe('网络异常,请检查服务是否正常')
expect(caught.code).toBe(-1)
})
it('HTTP 状态错误且带后端 message:优先使用后端文案', async () => {
const http = await loadHttp()
stubLocation()
let caught
try {
responseRejected(http)({
response: { status: 500, data: { code: 500, message: '后端错误信息' } },
})
} catch (e) {
caught = e
}
expect(caught.message).toBe('后端错误信息')
expect(caught.code).toBe(500)
})
})
+56
View File
@@ -0,0 +1,56 @@
import { vi } from 'vitest'
// 路由守卫依赖 nprogress,jsdom 下避免其 DOM 副作用
vi.mock('nprogress', () => ({
default: { configure: vi.fn(), start: vi.fn(), done: vi.fn() },
configure: vi.fn(),
start: vi.fn(),
done: vi.fn(),
}))
// ----- jsdom 缺失的浏览器 API polyfill -----
// 自建 UI 组件(modal/select/dropdown/tree 等)常依赖以下全局对象,jsdom 未实现,需补桩。
// ResizeObserver:组件监听元素尺寸时使用
if (!globalThis.ResizeObserver) {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
}
}
// IntersectionObserver:虚拟列表/懒加载等使用
if (!globalThis.IntersectionObserver) {
globalThis.IntersectionObserver = class {
constructor() {}
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return []
}
}
}
// matchMedia:主题/响应式/偏好判断使用
if (!globalThis.matchMedia) {
globalThis.matchMedia = (query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})
}
// 元素滚动方法:jsdom 未实现会抛 "not implemented"
if (!Element.prototype.scrollTo) {
Element.prototype.scrollTo = vi.fn()
}
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = vi.fn()
}
@@ -0,0 +1,200 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { nextTick } from 'vue'
import { createTestPinia } from '../helpers/app.js'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
// app store 的 initThemeSync 依赖 theme 模块操作 documentElement,jest 环境可安全执行
vi.mock('@/theme/index', () => ({
applyPrimaryColor: vi.fn(),
applyDarkMode: vi.fn(),
}))
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
vi.clearAllMocks()
})
describe('app store · 主题与布局', () => {
it('初始状态:暗色关、默认主色、侧栏展开、工作台页签固定', () => {
createTestPinia()
const app = useAppStore()
expect(app.darkMode).toBe(false)
expect(app.sidebarCollapsed).toBe(false)
expect(app.locale).toBe('zh-CN')
expect(app.visitedTabs).toHaveLength(1)
expect(app.visitedTabs[0]).toMatchObject({ key: '/dashboard', closable: false })
})
it('setDark / toggleDark 切换暗色', () => {
createTestPinia()
const app = useAppStore()
app.setDark(true)
expect(app.darkMode).toBe(true)
app.toggleDark()
expect(app.darkMode).toBe(false)
})
it('setPrimary / toggleSidebar / setSidebar / toggleLocale', () => {
createTestPinia()
const app = useAppStore()
app.setPrimary('#123456')
expect(app.primaryColor).toBe('#123456')
app.toggleSidebar()
expect(app.sidebarCollapsed).toBe(true)
app.setSidebar(false)
expect(app.sidebarCollapsed).toBe(false)
app.toggleLocale()
expect(app.locale).toBe('en-US')
})
})
describe('app store · 页签管理', () => {
function seedTabs() {
const app = useAppStore()
app.addVisitedTab({ key: '/a', label: 'A', closable: true, name: 'A' })
app.addVisitedTab({ key: '/b', label: 'B', closable: true, name: 'B' })
app.addVisitedTab({ key: '/c', label: 'C', closable: true, name: 'C' })
app.refreshRoute('/a')
app.refreshRoute('/a')
app.refreshRoute('/b')
return app
}
it('addVisitedTab 去重:重复 key 不重复添加', () => {
createTestPinia()
const app = seedTabs()
app.addVisitedTab({ key: '/a', label: 'A2', closable: true, name: 'A' })
expect(app.visitedTabs.filter((t) => t.key === '/a')).toHaveLength(1)
})
it('cachedViews:按 visitedTabs 去重取组件名', () => {
createTestPinia()
const app = seedTabs()
app.addVisitedTab({ key: '/a', label: 'A', closable: true, name: 'A' })
expect(app.cachedViews).toEqual(['Dashboard', 'A', 'B', 'C'])
})
it('removeVisitedTab:移除页签并清理该页刷新计数', () => {
createTestPinia()
const app = seedTabs()
app.removeVisitedTab('/a')
expect(app.visitedTabs.some((t) => t.key === '/a')).toBe(false)
expect(app.routeRefreshKeys['/a']).toBeUndefined()
})
it('closeOthers:保留工作台与指定页签', () => {
createTestPinia()
const app = seedTabs()
app.closeOthers('/b')
expect(app.visitedTabs.map((t) => t.key)).toEqual(['/dashboard', '/b'])
expect(app.routeRefreshKeys['/a']).toBeUndefined()
})
it('closeLeft:保留工作台、指定页签及其右侧', () => {
createTestPinia()
const app = seedTabs()
app.closeLeft('/b')
expect(app.visitedTabs.map((t) => t.key)).toEqual(['/dashboard', '/b', '/c'])
})
it('closeLeft:指定工作台时不裁剪', () => {
createTestPinia()
const app = seedTabs()
app.closeLeft('/dashboard')
expect(app.visitedTabs).toHaveLength(4)
})
it('closeRight:保留指定页签及其左侧', () => {
createTestPinia()
const app = seedTabs()
app.closeRight('/b')
expect(app.visitedTabs.map((t) => t.key)).toEqual(['/dashboard', '/a', '/b'])
})
it('closeAll:仅保留工作台并清理全部刷新计数', () => {
createTestPinia()
const app = seedTabs()
app.closeAll()
expect(app.visitedTabs.map((t) => t.key)).toEqual(['/dashboard'])
expect(Object.keys(app.routeRefreshKeys)).toHaveLength(0)
})
it('refreshRoute / getRouteRefreshKey:计数递增与读取', () => {
createTestPinia()
const app = useAppStore()
expect(app.getRouteRefreshKey('/x')).toBe(0)
app.refreshRoute('/x')
app.refreshRoute('/x')
expect(app.getRouteRefreshKey('/x')).toBe(2)
})
})
describe('auth store · 用户信息回显(userInfo)', () => {
it('saveLastLogin 写入机构与账号', () => {
createTestPinia()
const auth = useAuthStore()
auth.saveLastLogin('ORG1', 'admin')
expect(auth.userInfo).toEqual({ orgId: 'ORG1', account: 'admin' })
})
it('持久化 key 为 fms-user(localStorage)', async () => {
createTestPinia()
const auth = useAuthStore()
auth.saveLastLogin('ORG9', 'u9')
// persist 插件通过 $subscribe 同步写 storage,冲刷微任务兜底
await nextTick()
expect(localStorage.getItem('fms-user')).toContain('ORG9')
expect(localStorage.getItem('fms-user')).toContain('"account":"u9"')
})
})
describe('auth store', () => {
it('setSession:token/orgid/user 三种字段形态兼容', () => {
createTestPinia()
const auth = useAuthStore()
auth.setSession(
{ access_token: 'at', orgid: 'O1', user_info: { id: 7 } },
'O_FALLBACK',
)
expect(auth.loginInfo.token).toBe('at')
expect(auth.userInfo.orgId).toBe('O1')
expect(auth.loginInfo.user).toEqual({ id: 7 })
expect(auth.isAuthenticated).toBe(true)
})
it('setSession:缺失字段时回退到 organizationId 参数', () => {
createTestPinia()
const auth = useAuthStore()
auth.setSession({ token: 't' }, 'ORG-FB')
expect(auth.userInfo.orgId).toBe('ORG-FB')
})
it('clearSession:清会话凭证,保留 userInfo 回显数据', () => {
createTestPinia()
const auth = useAuthStore()
auth.setSession({ token: 't', user: { id: 1 } }, 'ORG')
auth.saveLastLogin('ORG', 'admin')
auth.clearSession()
expect(auth.loginInfo.token).toBe('')
expect(auth.loginInfo.user).toBe(null)
expect(auth.isAuthenticated).toBe(false)
// userInfo 是登录页回显数据,不属于会话,退出登录后保留
expect(auth.userInfo).toEqual({ orgId: 'ORG', account: 'admin' })
})
it('持久化:会话写 sessionStorage(fms-login)、回显写 localStorage(fms-user)', async () => {
createTestPinia()
const auth = useAuthStore()
auth.setSession({ token: 't', user: { id: 1 } }, 'ORG')
auth.saveLastLogin('ORG', 'admin')
await nextTick()
expect(sessionStorage.getItem('fms-login')).toContain('"token":"t"')
expect(localStorage.getItem('fms-user')).toContain('"orgId":"ORG"')
// 会话与回显物理隔离:清会话不影响回显 key
auth.clearSession()
await nextTick()
expect(localStorage.getItem('fms-user')).toContain('"orgId":"ORG"')
})
})
+119
View File
@@ -0,0 +1,119 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { buildTree, sortTree } from '@/utils/tree'
describe('utils/tree buildTree', () => {
it('空数组返回空树', () => {
expect(buildTree([])).toEqual([])
})
it('无父节点的节点成为根', () => {
const list = [
{ b_id: 1, b_parent_id: null },
{ b_id: 2, b_parent_id: null },
]
const tree = buildTree(list)
expect(tree).toHaveLength(2)
expect(tree.map((n) => n.b_id)).toEqual([1, 2])
})
it('按 parentKey 挂载子节点', () => {
const list = [
{ b_id: 1, b_parent_id: null },
{ b_id: 2, b_parent_id: 1 },
{ b_id: 3, b_parent_id: 2 },
]
const tree = buildTree(list)
expect(tree).toHaveLength(1)
expect(tree[0].b_id).toBe(1)
expect(tree[0].children).toHaveLength(1)
expect(tree[0].children[0].b_id).toBe(2)
expect(tree[0].children[0].children[0].b_id).toBe(3)
})
it('父节点不存在的子节点提升为根(孤儿节点)', () => {
const list = [
{ b_id: 2, b_parent_id: 999 },
{ b_id: 3, b_parent_id: null },
]
const tree = buildTree(list)
expect(tree).toHaveLength(2)
})
it('支持自定义 idKey / parentKey / childrenKey', () => {
const list = [
{ id: 'a', pid: null },
{ id: 'b', pid: 'a' },
]
const tree = buildTree(list, 'id', 'pid', 'kids')
expect(tree[0].kids[0].id).toBe('b')
})
it('副作用说明:向父节点对象注入 children 属性(当前实现行为)', () => {
const list = [
{ b_id: 1, b_parent_id: null },
{ b_id: 2, b_parent_id: 1 },
]
buildTree(list)
// 当前实现直接修改传入的节点对象(parent[childrenKey] = []),
// 调用方若复用原数组需注意此副作用;若要求纯函数需后续改造
expect(Array.isArray(list[0].children)).toBe(true)
expect(list[0].children).toHaveLength(1)
})
})
describe('utils/tree sortTree', () => {
it('按 b_xh 升序排序', () => {
const tree = [{ b_xh: 3 }, { b_xh: 1 }, { b_xh: 2 }]
sortTree(tree)
expect(tree.map((n) => n.b_xh)).toEqual([1, 2, 3])
})
it('b_xh 相同按 b_id 排序', () => {
const tree = [{ b_xh: 1, b_id: 'b' }, { b_xh: 1, b_id: 'a' }]
sortTree(tree)
expect(tree.map((n) => n.b_id)).toEqual(['a', 'b'])
})
it('递归排序子节点', () => {
const tree = [
{
b_xh: 1,
children: [{ b_xh: 2 }, { b_xh: 1 }],
},
]
sortTree(tree)
expect(tree[0].children.map((n) => n.b_xh)).toEqual([1, 2])
})
it('返回原数组引用', () => {
const tree = [{ b_xh: 2 }, { b_xh: 1 }]
expect(sortTree(tree)).toBe(tree)
})
})
describe('utils/theme', () => {
beforeEach(() => {
document.documentElement.style.cssText = ''
document.documentElement.classList.remove('dark')
})
it('applyPrimaryColor 写入 --fms-primary CSS 变量', async () => {
const { applyPrimaryColor } = await import('@/theme/index')
applyPrimaryColor('#ff0000')
expect(document.documentElement.style.getPropertyValue('--fms-primary')).toBe('#ff0000')
})
it('applyPrimaryColor 空值不写入', async () => {
const { applyPrimaryColor } = await import('@/theme/index')
applyPrimaryColor('')
expect(document.documentElement.style.getPropertyValue('--fms-primary')).toBe('')
})
it('applyDarkMode true 添加 dark class,false 移除', async () => {
const { applyDarkMode } = await import('@/theme/index')
applyDarkMode(true)
expect(document.documentElement.classList.contains('dark')).toBe(true)
applyDarkMode(false)
expect(document.documentElement.classList.contains('dark')).toBe(false)
})
})
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { nextTick } from 'vue'
import { flushPromises } from '@vue/test-utils'
import LoginPage from '@/views/login/index.vue'
import { useAuthStore } from '@/stores/auth'
import { mountPage } from '../helpers/app.js'
// —— 单一接缝:mock 业务 API 层,确定性驱动页面状态(无需真实后端)——
vi.mock('@/services/api', () => ({
loginApi: vi.fn(),
}))
// —— mock 命令式 Message(避免 jsdom 下创建/操作 DOM 容器)——
// 该模块同时有具名导出与默认导出(页面用默认导入),两者都要提供。
// 注意:vi.mock 工厂会被提升,不能引用顶层变量,需内联。
vi.mock('@/components/ui/message/message-manager.js', () => {
const m = {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}
return { Message: m, default: m }
})
import { loginApi } from '@/services/api'
import { Message } from '@/components/ui/message/message-manager.js'
// 把 Form 打桩为可定位、可 emit 的组件:页面测试只关心「自己逻辑」,
// 子组件(Form/Input/Button)由 shallowMount 自动打桩,这里仅给 Form 一个稳定 name 以便触发 @submit。
const formStub = {
name: 'Form',
template: '<form class="login-form-stub"><slot /></form>',
}
const validValues = { orgId: 'ORG1', account: 'admin', password: 'secret' }
beforeEach(() => {
// pinia persist 插件会把状态写入 localStorage,跨测试会泄漏,需清零
localStorage.clear()
sessionStorage.clear()
loginApi.mockReset()
Message.success.mockReset()
Message.error.mockReset()
Message.warning.mockReset()
})
describe('登录页', () => {
it('渲染标题与品牌文案', async () => {
const { wrapper } = await mountPage(LoginPage)
expect(wrapper.text()).toContain('登录 FMS')
expect(wrapper.text()).toContain('企业管理系统')
})
it('无 reason 时不展示会话提示条', async () => {
const { wrapper } = await mountPage(LoginPage)
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
})
it('reason=expired:展示「登录已过期」提示', async () => {
const { wrapper } = await mountPage(LoginPage, { location: '/login?reason=expired' })
await nextTick()
const hint = wrapper.find('[role="alert"]')
expect(hint.exists()).toBe(true)
expect(hint.text()).toContain('登录已过期')
})
it('reason=session-replaced:展示「已在其他设备登录」提示', async () => {
const { wrapper } = await mountPage(LoginPage, { location: '/login?reason=session-replaced' })
await nextTick()
expect(wrapper.find('[role="alert"]').text()).toContain('已在其他设备登录')
})
it('表单校验失败:触发 onFailed,弹出 warning', async () => {
const { wrapper } = await mountPage(LoginPage, { stubs: { Form: formStub } })
await wrapper.findComponent({ name: 'Form' }).vm.$emit('submit-failed')
await nextTick()
expect(Message.warning).toHaveBeenCalledWith('请填写完整的登录信息')
})
it('登录成功:调用 loginApi、写入会话、跳转 redirect、弹成功', async () => {
loginApi.mockResolvedValue({
data: { token: 'TK', user: { id: 1, account: 'admin' }, orgid: 'ORG1' },
code: 0,
success: true,
})
const { wrapper, router } = await mountPage(LoginPage, {
location: '/login?redirect=/dashboard',
stubs: { Form: formStub },
})
await wrapper.findComponent({ name: 'Form' }).vm.$emit('submit', validValues)
await flushPromises()
await nextTick()
await flushPromises()
// 请求按页面入参发出
expect(loginApi).toHaveBeenCalledWith('ORG1', 'admin', 'secret')
// 会话已写入
const auth = useAuthStore()
expect(auth.isAuthenticated).toBe(true)
expect(auth.loginInfo.token).toBe('TK')
expect(auth.userInfo.orgId).toBe('ORG1')
// 跳转目标
expect(router.currentRoute.value.fullPath).toBe('/dashboard')
// 成功提示
expect(Message.success).toHaveBeenCalledWith('登录成功')
})
it('登录失败:弹 error,且不写入会话', async () => {
loginApi.mockRejectedValue(new Error('账号或密码错误'))
const { wrapper } = await mountPage(LoginPage, { stubs: { Form: formStub } })
await wrapper.findComponent({ name: 'Form' }).vm.$emit('submit', validValues)
await flushPromises()
await nextTick()
await flushPromises()
expect(Message.error).toHaveBeenCalledWith('账号或密码错误')
expect(useAuthStore().isAuthenticated).toBe(false)
})
})