20260819173221
This commit is contained in:
1 parent
1ffc8e1ac9
commit
a0a4273f4c
326 files changed
+301646
-2314
No files matched your search
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user