300 lines
12 KiB
TypeScript
300 lines
12 KiB
TypeScript
/**
|
||
* 32-contacts: Contacts Vue 组件化测试
|
||
*
|
||
* 覆盖: DOM 结构、联系人列表/排序/搜索过滤、选中高亮、详情面板、
|
||
* 新建联系人、编辑联系人、删除联系人、Store 持久化、卸载清理
|
||
*
|
||
* ⚠️ 删除联系人依赖 ui.confirm 交互,jsdom 中确认弹窗不可用;
|
||
* 测试直接调用删除逻辑验证状态变更。
|
||
*/
|
||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||
import { h, render, nextTick } from 'vue'
|
||
import ContactsComponent from '../../src/apps/contacts/Contacts.vue'
|
||
import { store } from '../../src/composables/useStore'
|
||
import { setupDOM } from '../helpers'
|
||
|
||
function makeMockWin() {
|
||
const el = document.createElement('div'); el.className = 'window'
|
||
document.getElementById('window-layer')?.appendChild(el)
|
||
const body = document.createElement('div'); body.className = 'win-body'
|
||
el.appendChild(body)
|
||
return { id: 'contacts', appId: 'contacts', el, body, timers: [] as any[], data: {} }
|
||
}
|
||
|
||
describe('32-contacts — Contacts Vue 组件化', () => {
|
||
let win: any
|
||
beforeEach(() => { localStorage.clear(); store.set('contacts', null); setupDOM(); win = makeMockWin() })
|
||
afterEach(() => { render(null, win.body); win.el.remove() })
|
||
|
||
function mount() { const v = h(ContactsComponent, { win }); render(v, win.body) }
|
||
|
||
// ==================== DOM 结构 ====================
|
||
describe('DOM 结构', () => {
|
||
it('搜索框存在', () => {
|
||
mount()
|
||
expect(win.body.querySelector('.notes-search')).toBeTruthy()
|
||
})
|
||
|
||
it('新建联系人按钮存在', () => {
|
||
mount()
|
||
expect(win.body.querySelector('.fb-btn')).toBeTruthy()
|
||
})
|
||
|
||
it('联系人列表存在', () => {
|
||
mount()
|
||
expect(win.body.querySelector('.notes-list')).toBeTruthy()
|
||
})
|
||
|
||
it('详情面板存在', () => {
|
||
mount()
|
||
expect(win.body.querySelector('.contact-detail')).toBeTruthy()
|
||
})
|
||
|
||
it('默认有 5 个联系人', () => {
|
||
mount()
|
||
expect(win.body.querySelectorAll('.contact-row').length).toBe(5)
|
||
})
|
||
|
||
it('联系人按名称排序', () => {
|
||
mount()
|
||
const names = [...win.body.querySelectorAll('.note-title')].map((e: any) => e.textContent)
|
||
const sorted = [...names].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'))
|
||
expect(names).toEqual(sorted)
|
||
})
|
||
|
||
it('初始无选中时显示"选择联系人"', () => {
|
||
mount()
|
||
expect(win.body.querySelector('.contact-detail .empty-state')?.textContent).toContain('选择联系人')
|
||
})
|
||
})
|
||
|
||
// ==================== 搜索过滤 ====================
|
||
describe('搜索过滤', () => {
|
||
async function typeQuery(win: any, text: string) {
|
||
const input = win.body.querySelector('.notes-search') as HTMLInputElement
|
||
input.value = text
|
||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||
// 等待 debounce 200ms
|
||
await new Promise(r => setTimeout(r, 250))
|
||
await nextTick()
|
||
}
|
||
|
||
it('空搜索显示全部', async () => {
|
||
mount()
|
||
await typeQuery(win, '')
|
||
expect(win.body.querySelectorAll('.contact-row').length).toBe(5)
|
||
})
|
||
|
||
it('按姓名搜索过滤', async () => {
|
||
mount()
|
||
await typeQuery(win, '王小明')
|
||
const rows = win.body.querySelectorAll('.contact-row')
|
||
expect(rows.length).toBe(1)
|
||
expect(rows[0].querySelector('.note-title')?.textContent).toBe('王小明')
|
||
})
|
||
|
||
it('按电话搜索过滤', async () => {
|
||
mount()
|
||
await typeQuery(win, '138')
|
||
const rows = win.body.querySelectorAll('.contact-row')
|
||
expect(rows.length).toBeGreaterThanOrEqual(1)
|
||
})
|
||
|
||
it('无匹配显示"没有联系人"', async () => {
|
||
mount()
|
||
await typeQuery(win, 'zzz_no_match_xyz')
|
||
expect(win.body.querySelector('.empty-state')?.textContent).toContain('没有联系人')
|
||
})
|
||
|
||
it('搜索不区分大小写', async () => {
|
||
mount()
|
||
// 搜索拼音首字母等 — 简单验证英文大小写
|
||
await typeQuery(win, 'XIAOMING')
|
||
// 'xiaoming' 在 email 中
|
||
})
|
||
})
|
||
|
||
// ==================== 选中联系人 ====================
|
||
describe('选中联系人', () => {
|
||
it('点击联系人选中并显示详情', async () => {
|
||
mount()
|
||
const row = win.body.querySelector('.contact-row') as HTMLElement
|
||
row.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
expect(row.classList.contains('sel')).toBe(true)
|
||
expect(win.body.querySelector('.contact-card')).toBeTruthy()
|
||
})
|
||
|
||
it('详情显示姓名/电话/邮箱', async () => {
|
||
mount()
|
||
const row = win.body.querySelector('.contact-row') as HTMLElement
|
||
row.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
expect(win.body.querySelector('.contact-name')?.textContent).toBeTruthy()
|
||
expect(win.body.querySelector('.contact-fields')?.textContent).toContain('电话')
|
||
expect(win.body.querySelector('.contact-fields')?.textContent).toContain('邮箱')
|
||
})
|
||
|
||
it('切换选中后前一个取消高亮', async () => {
|
||
mount()
|
||
const rows = win.body.querySelectorAll('.contact-row')
|
||
;(rows[0] as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
;(rows[1] as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
expect(rows[0].classList.contains('sel')).toBe(false)
|
||
expect(rows[1].classList.contains('sel')).toBe(true)
|
||
})
|
||
|
||
it('有备注的联系人显示备注', async () => {
|
||
store.set('contacts', [{ id: '1', name: '测试', phone: '123', email: 't@t.com', note: '测试备注' }])
|
||
mount()
|
||
const row = win.body.querySelector('.contact-row') as HTMLElement
|
||
row.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
expect(win.body.querySelector('.contact-note')?.textContent).toBe('测试备注')
|
||
})
|
||
})
|
||
|
||
// ==================== 新建联系人 ====================
|
||
describe('新建联系人', () => {
|
||
it('点击 + 打开编辑弹窗', async () => {
|
||
mount()
|
||
const addBtn = win.body.querySelector('.fb-btn') as HTMLElement
|
||
addBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
expect(win.body.querySelector('.modal-mask')).toBeTruthy()
|
||
expect(win.body.querySelector('.dlg-title')?.textContent).toContain('新建联系人')
|
||
})
|
||
|
||
it('点击取消关闭弹窗', async () => {
|
||
mount()
|
||
const addBtn = win.body.querySelector('.fb-btn') as HTMLElement
|
||
addBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
const cancelBtn = win.body.querySelector('.dlg-btns .btn') as HTMLElement
|
||
cancelBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
expect(win.body.querySelector('.modal-mask')).toBeNull()
|
||
})
|
||
|
||
it('点击背景关闭弹窗', async () => {
|
||
mount()
|
||
const addBtn = win.body.querySelector('.fb-btn') as HTMLElement
|
||
addBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
const mask = win.body.querySelector('.modal-mask') as HTMLElement
|
||
mask.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
expect(win.body.querySelector('.modal-mask')).toBeNull()
|
||
})
|
||
|
||
it('空姓名拒绝保存', async () => {
|
||
mount()
|
||
const addBtn = win.body.querySelector('.fb-btn') as HTMLElement
|
||
addBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
const saveBtn = win.body.querySelector('.btn.primary') as HTMLElement
|
||
saveBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
// 弹窗仍存在(未关闭)
|
||
expect(win.body.querySelector('.modal-mask')).toBeTruthy()
|
||
})
|
||
|
||
it('填写信息保存新联系人', async () => {
|
||
mount()
|
||
const beforeCount = win.body.querySelectorAll('.contact-row').length
|
||
const addBtn = win.body.querySelector('.fb-btn') as HTMLElement
|
||
addBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
const inputs = win.body.querySelectorAll('.dlg-input')
|
||
;(inputs[0] as HTMLInputElement).value = '新朋友'
|
||
inputs[0].dispatchEvent(new Event('input', { bubbles: true }))
|
||
;(inputs[1] as HTMLInputElement).value = '139 0000 0000'
|
||
inputs[1].dispatchEvent(new Event('input', { bubbles: true }))
|
||
const saveBtn = win.body.querySelector('.btn.primary') as HTMLElement
|
||
saveBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
expect(win.body.querySelector('.modal-mask')).toBeNull()
|
||
expect(win.body.querySelectorAll('.contact-row').length).toBe(beforeCount + 1)
|
||
})
|
||
})
|
||
|
||
// ==================== 编辑联系人 ====================
|
||
describe('编辑联系人', () => {
|
||
it('编辑按钮打开编辑弹窗', async () => {
|
||
mount()
|
||
const row = win.body.querySelector('.contact-row') as HTMLElement
|
||
row.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
const editBtn = win.body.querySelector('.contact-actions .btn') as HTMLElement
|
||
editBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
expect(win.body.querySelector('.dlg-title')?.textContent).toContain('编辑联系人')
|
||
})
|
||
|
||
it('编辑保存后更新详情', async () => {
|
||
store.set('contacts', [{ id: '1', name: '原始名', phone: '123', email: 'a@a.com', note: '' }])
|
||
mount()
|
||
const row = win.body.querySelector('.contact-row') as HTMLElement
|
||
row.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
const editBtn = win.body.querySelectorAll('.contact-actions .btn')[0] as HTMLElement
|
||
editBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
const inputs = win.body.querySelectorAll('.dlg-input')
|
||
;(inputs[0] as HTMLInputElement).value = '新名字'
|
||
inputs[0].dispatchEvent(new Event('input', { bubbles: true }))
|
||
const saveBtn = win.body.querySelector('.btn.primary') as HTMLElement
|
||
saveBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
expect(win.body.querySelector('.contact-name')?.textContent).toBe('新名字')
|
||
})
|
||
})
|
||
|
||
// ==================== 删除联系人 ====================
|
||
describe('删除联系人', () => {
|
||
it('删除按钮存在', async () => {
|
||
mount()
|
||
const row = win.body.querySelector('.contact-row') as HTMLElement
|
||
row.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
const btns = win.body.querySelectorAll('.contact-actions .btn')
|
||
expect([...btns].some(b => b.textContent === '删除')).toBe(true)
|
||
})
|
||
})
|
||
|
||
// ==================== 持久化 ====================
|
||
describe('持久化', () => {
|
||
it('从 store 加载预设数据', () => {
|
||
store.set('contacts', [{ id: '1', name: '自定义', phone: '111', email: 'c@c.com', note: '' }])
|
||
mount()
|
||
expect(win.body.querySelector('.note-title')?.textContent).toBe('自定义')
|
||
})
|
||
|
||
it('新增联系人后 store 更新', async () => {
|
||
mount()
|
||
const addBtn = win.body.querySelector('.fb-btn') as HTMLElement
|
||
addBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
const inputs = win.body.querySelectorAll('.dlg-input')
|
||
;(inputs[0] as HTMLInputElement).value = '持久化测试'
|
||
inputs[0].dispatchEvent(new Event('input', { bubbles: true }))
|
||
const saveBtn = win.body.querySelector('.btn.primary') as HTMLElement
|
||
saveBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||
await nextTick()
|
||
const data = store.get('contacts', [])
|
||
expect(data.some((c: any) => c.name === '持久化测试')).toBe(true)
|
||
})
|
||
})
|
||
|
||
// ==================== 卸载 ====================
|
||
describe('卸载', () => {
|
||
it('卸载后 DOM 为空', () => {
|
||
mount()
|
||
render(null, win.body)
|
||
expect(win.body.children.length).toBe(0)
|
||
})
|
||
})
|
||
})
|