macos-web/tests/cases/31-clock.test.ts

377 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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