/** * 31-clock: Clock Vue 组件化测试 * * 覆盖: DOM 结构/4 个 tab 切换、世界时钟(城市显示/时区偏移/加减城市)、 * 闹钟(列表/开关/增删)、秒表(开始/暂停/计次/复位)、 * 计时器(设置/启动/暂停/复位)、Store 持久化、卸载清理 * * ⚠️ 秒表/计时器基于 Date.now() 的实时 tick 逻辑在 jsdom 中可验证状态切换, * 但精确时间值变化需在浏览器环境验证。 */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { h, render, nextTick } from 'vue' import ClockComponent from '../../src/apps/clock/Clock.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: 'clock', appId: 'clock', el, body, timers: [] as any[], data: {} } } describe('31-clock — Clock Vue 组件化', () => { let win: any beforeEach(() => { localStorage.clear(); store.set('clock', null); setupDOM(); win = makeMockWin() }) afterEach(() => { render(null, win.body); win.el.remove() }) function mount() { const v = h(ClockComponent, { win }); render(v, win.body) } // ==================== DOM 结构 ==================== describe('DOM 结构', () => { it('4 个 tab 按钮存在', () => { mount() expect(win.body.querySelectorAll('.store-tab').length).toBe(4) }) it('默认 tab 为 world', () => { mount() expect(win.body.querySelector('.store-tab.on')?.textContent).toContain('世界时钟') }) it('clock-content 存在', () => { mount() expect(win.body.querySelector('.clock-content')).toBeTruthy() }) }) // ==================== Tab 切换 ==================== describe('Tab 切换', () => { it('点击闹钟 tab 切换', async () => { mount() const tabs = win.body.querySelectorAll('.store-tab') const alarmTab = [...tabs].find(t => t.textContent.includes('闹钟')) as HTMLElement alarmTab.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() expect(win.body.querySelector('.alarm-list')).toBeTruthy() }) it('点击秒表 tab 切换', async () => { mount() const tabs = win.body.querySelectorAll('.store-tab') const swTab = [...tabs].find(t => t.textContent.includes('秒表')) as HTMLElement swTab.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() expect(win.body.querySelector('.sw-disp')).toBeTruthy() }) it('点击计时器 tab 切换', async () => { mount() const tabs = win.body.querySelectorAll('.store-tab') const timerTab = [...tabs].find(t => t.textContent.includes('计时器')) as HTMLElement timerTab.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() expect(win.body.querySelector('.timer-ring')).toBeTruthy() }) it('切回世界时钟 tab', async () => { mount() const tabs = win.body.querySelectorAll('.store-tab') ;([...tabs].find(t => t.textContent.includes('闹钟')) as HTMLElement) .dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() ;([...tabs].find(t => t.textContent.includes('世界时钟')) as HTMLElement) .dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() expect(win.body.querySelector('.clock-cities')).toBeTruthy() }) }) // ==================== 世界时钟 ==================== describe('世界时钟', () => { it('默认显示 4 个城市', () => { store.set('clock', { cities: ['上海', '东京', '伦敦', '纽约'], alarms: [] }) mount() expect(win.body.querySelectorAll('.clock-city').length).toBe(4) }) it('每个城市有时区偏移', () => { store.set('clock', { cities: ['上海', '东京'], alarms: [] }) mount() const offsets = win.body.querySelectorAll('.clock-city small') expect(offsets.length).toBeGreaterThanOrEqual(1) expect(offsets[0].textContent).toBeTruthy() // UTC+8 or similar }) it('每个城市有时间显示', () => { store.set('clock', { cities: ['上海'], alarms: [] }) mount() expect(win.body.querySelector('.clock-city-time')?.textContent).toBeTruthy() }) it('添加城市下拉框存在', () => { mount() expect(win.body.querySelector('select.text-input')).toBeTruthy() }) it('删除按钮存在', () => { store.set('clock', { cities: ['上海'], alarms: [] }) mount() expect(win.body.querySelector('.clock-city .weather-del')).toBeTruthy() }) it('删除城市后从列表移除', async () => { store.set('clock', { cities: ['上海', '东京'], alarms: [] }) mount() const delBtn = win.body.querySelector('.clock-city .weather-del') as HTMLElement delBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() const remaining = store.get('clock', {}).cities expect(remaining).not.toContain('上海') }) it('通过 select 添加城市', async () => { store.set('clock', { cities: ['北京'], alarms: [] }) mount() const select = win.body.querySelector('select') as HTMLSelectElement expect(select).toBeTruthy() // select 中有未添加的城市 option const options = [...select.options].map((o: any) => o.value) expect(options).toContain('上海') }) }) // ==================== 闹钟 ==================== describe('闹钟', () => { function goAlarm(win: any) { const tabs = win.body.querySelectorAll('.store-tab') ;([...tabs].find(t => t.textContent.includes('闹钟')) as HTMLElement) .dispatchEvent(new MouseEvent('click', { bubbles: true })) } it('初始无闹钟显示"没有闹钟"', async () => { store.set('clock', { cities: [], alarms: [] }) mount() goAlarm(win); await nextTick() expect(win.body.querySelector('.empty-state')?.textContent).toContain('没有闹钟') }) it('预设闹钟显示在列表', async () => { store.set('clock', { cities: [], alarms: [{ id: 'a1', h: 8, m: 30, label: '起床', on: false }] }) mount(); goAlarm(win); await nextTick() expect(win.body.querySelector('.alarm-time')?.textContent).toBe('08:30') expect(win.body.querySelector('.alarm-label')?.textContent).toBe('起床') }) it('闹钟开关 toggle', async () => { store.set('clock', { cities: [], alarms: [{ id: 'a1', h: 7, m: 0, label: '测试', on: false }] }) mount(); goAlarm(win); await nextTick() const sw = win.body.querySelector('.switch') as HTMLElement expect(sw.classList.contains('on')).toBe(false) sw.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() const data = store.get('clock', {}) expect(data.alarms[0].on).toBe(true) }) it('添加闹钟', async () => { store.set('clock', { cities: [], alarms: [] }) mount(); goAlarm(win); await nextTick() const addBtn = win.body.querySelector('.btn.primary') as HTMLElement addBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() const data = store.get('clock', {}) expect(data.alarms.length).toBe(1) expect(data.alarms[0].on).toBe(true) }) it('删除闹钟', async () => { store.set('clock', { cities: [], alarms: [{ id: 'a1', h: 8, m: 0, label: 'T', on: false }] }) mount(); goAlarm(win); await nextTick() const delBtn = win.body.querySelector('.alarm-row .weather-del') as HTMLElement delBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() const data = store.get('clock', {}) expect(data.alarms.length).toBe(0) }) }) // ==================== 秒表 ==================== describe('秒表', () => { function goSw(win: any) { const tabs = win.body.querySelectorAll('.store-tab') ;([...tabs].find(t => t.textContent.includes('秒表')) as HTMLElement) .dispatchEvent(new MouseEvent('click', { bubbles: true })) } it('初始显示 00:00.0', async () => { mount(); goSw(win); await nextTick() expect(win.body.querySelector('.sw-disp')?.textContent).toBe('00:00.0') }) it('开始按钮默认显示"开始"', async () => { mount(); goSw(win); await nextTick() const btns = win.body.querySelectorAll('.sw-btn') expect([...btns].some(b => b.textContent === '开始')).toBe(true) }) it('计次按钮初始禁用', async () => { mount(); goSw(win); await nextTick() const btn = win.body.querySelector('.sw-btn') as HTMLButtonElement expect(btn.disabled).toBe(true) }) it('复位按钮初始禁用', async () => { mount(); goSw(win); await nextTick() const btns = win.body.querySelectorAll('.sw-btn') const resetBtn = [...btns].find(b => b.textContent === '复位') as HTMLButtonElement expect(resetBtn.disabled).toBe(true) }) // ⚠️ jsdom 中 now.value 需真实时间流逝才能计算 acc; // 两次点击之间加入小延时让 Date.now() 推进 it('点击开始→暂停→继续', async () => { mount(); goSw(win); await nextTick() const primaryBtn = win.body.querySelector('.btn.primary') as HTMLElement primaryBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() expect(primaryBtn.textContent).toBe('暂停') await new Promise(r => setTimeout(r, 20)) // 让 now 推进 primaryBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() expect(primaryBtn.textContent).toBe('继续') }) it('开始后计次按钮可用', async () => { mount(); goSw(win); await nextTick() const primaryBtn = win.body.querySelector('.btn.primary') as HTMLElement primaryBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() const lapBtn = win.body.querySelector('.sw-btn') as HTMLButtonElement expect(lapBtn.disabled).toBe(false) }) it('计次添加 lap', async () => { mount(); goSw(win); await nextTick() const primaryBtn = win.body.querySelector('.btn.primary') as HTMLElement primaryBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() const lapBtn = win.body.querySelectorAll('.sw-btn')[0] as HTMLElement lapBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() expect(win.body.querySelector('.sw-lap')).toBeTruthy() }) // ⚠️ jsdom 中两次点击 start→pause 后需延时让 acc 累积 it('暂停后复位按钮可用', async () => { mount(); goSw(win); await nextTick() const primaryBtn = win.body.querySelector('.btn.primary') as HTMLElement primaryBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() await new Promise(r => setTimeout(r, 20)) primaryBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() const btns = win.body.querySelectorAll('.sw-btn') const resetBtn = [...btns].find(b => b.textContent === '复位') as HTMLButtonElement expect(resetBtn.disabled).toBe(false) }) }) // ==================== 计时器 ==================== describe('计时器', () => { function goTimer(win: any) { const tabs = win.body.querySelectorAll('.store-tab') ;([...tabs].find(t => t.textContent.includes('计时器')) as HTMLElement) .dispatchEvent(new MouseEvent('click', { bubbles: true })) } it('默认显示 05:00', async () => { mount(); goTimer(win); await nextTick() expect(win.body.querySelector('.timer-ring .sw-disp')?.textContent).toBe('05:00') }) it('有分钟和秒钟输入框', async () => { mount(); goTimer(win); await nextTick() const inputs = win.body.querySelectorAll('.clock-addrow input[type="number"]') expect(inputs.length).toBeGreaterThanOrEqual(2) }) it('有开始和复位按钮', async () => { mount(); goTimer(win); await nextTick() const btns = win.body.querySelectorAll('.sw-btn') expect(btns.length).toBe(2) }) it('点击开始后按钮变为暂停', async () => { mount(); goTimer(win); await nextTick() const startBtn = win.body.querySelector('.btn.primary') as HTMLElement startBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() expect(startBtn.textContent).toBe('暂停') }) it('点击暂停后变为继续', async () => { mount(); goTimer(win); await nextTick() const startBtn = win.body.querySelector('.btn.primary') as HTMLElement startBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() startBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() expect(startBtn.textContent).toBe('继续') }) it('运行时输入框禁用', async () => { mount(); goTimer(win); await nextTick() const startBtn = win.body.querySelector('.btn.primary') as HTMLElement startBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() const inputs = win.body.querySelectorAll('.clock-addrow input[type="number"]') expect((inputs[0] as HTMLInputElement).disabled).toBe(true) }) it('复位后恢复 05:00 显示', async () => { mount(); goTimer(win); await nextTick() const startBtn = win.body.querySelector('.btn.primary') as HTMLElement startBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() startBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() const btns = win.body.querySelectorAll('.sw-btn') const resetBtn = [...btns].find(b => b.textContent === '复位') as HTMLElement resetBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() expect(win.body.querySelector('.timer-ring .sw-disp')?.textContent).toBe('05:00') }) }) // ==================== 持久化 ==================== describe('持久化', () => { it('store 加载预设数据', () => { store.set('clock', { cities: ['上海', '东京'], alarms: [] }) mount() expect(win.body.querySelectorAll('.clock-city').length).toBe(2) }) it('修改后 store 更新', async () => { store.set('clock', { cities: ['北京'], alarms: [] }) mount() const delBtn = win.body.querySelector('.weather-del') as HTMLElement delBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) await nextTick() const data = store.get('clock', {}) expect(data.cities).not.toContain('北京') }) }) // ==================== 卸载 ==================== describe('卸载', () => { it('卸载后 DOM 为空', () => { mount() render(null, win.body) expect(win.body.children.length).toBe(0) }) }) })