377 lines
15 KiB
TypeScript
377 lines
15 KiB
TypeScript
/**
|
||
* 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)
|
||
})
|
||
})
|
||
})
|