/** * 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) }) }) })