diff --git a/src/apps/clock/Clock.vue b/src/apps/clock/Clock.vue new file mode 100644 index 0000000..3d4658d --- /dev/null +++ b/src/apps/clock/Clock.vue @@ -0,0 +1,339 @@ + + + diff --git a/src/apps/clock/index.ts b/src/apps/clock/index.ts index c5e6c72..5409d38 100644 --- a/src/apps/clock/index.ts +++ b/src/apps/clock/index.ts @@ -1,84 +1,16 @@ -// 时钟应用 — 从 js/apps3.js 手动转写 -import { el, $$, clamp, uid, fmtTimeHM } from '../../utils' -import { store as Store } from '../../composables/useStore' -import { ui as UI } from '../../composables/useUI' +// 时钟应用 — Vue 组件化 +import { h, render } from 'vue' import { Apps, stdMenus } from '../../composables/useApps' -import { Notify } from '../../composables/useNotify' -const Sys = () => (window as any).Sys +import ClockComponent from './Clock.vue' export const ClockApp = { id: 'clock', name: '时钟', icon: '/assets/icons/clock.svg', w: 680, h: 480, minW: 480, minH: 360, menus() { return stdMenus(this) }, - store: { - get(): any { return Store.get('clock', { cities: ['上海', '东京', '伦敦', '纽约'], alarms: [{ id: 'a1', h: 8, m: 30, label: '起床', on: false }] }) }, - set(v: any) { Store.set('clock', v) } - }, - cityTZ: { - '北京': 'Asia/Shanghai', '上海': 'Asia/Shanghai', '东京': 'Asia/Tokyo', '伦敦': 'Europe/London', - '巴黎': 'Europe/Paris', '纽约': 'America/New_York', '洛杉矶': 'America/Los_Angeles', '悉尼': 'Australia/Sydney', - '迪拜': 'Asia/Dubai', '新加坡': 'Asia/Singapore', - } as Record, - tzOffsetLabel(tz: string) { - try { const part = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'shortOffset' }).formatToParts(new Date()).find(p => p.type === 'timeZoneName'); return part ? part.value.replace('GMT', 'UTC').replace(/^UTC$/, 'UTC+0') : '' } catch (e) { return '' } - }, - beep() { - try { const ctx = new ((window as any).AudioContext || (window as any).webkitAudioContext)(); const o = ctx.createOscillator(), g = ctx.createGain(); o.connect(g); g.connect(ctx.destination); o.frequency.value = 880; const S = Sys(); g.gain.setValueAtTime(S.settings.muted ? 0 : S.settings.volume * 0.3, ctx.currentTime); o.start(); g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 1.2); o.stop(ctx.currentTime + 1.3) } catch (e) {} - }, - tick() { - const st = this.store.get(); const now = new Date(); const hm = now.getHours() * 60 + now.getMinutes() - for (const a of st.alarms) { if (!a.on) continue; const key = '_fired_' + a.id + '_' + now.toDateString(); if (a.h * 60 + a.m === hm && !(this as any)[key]) { (this as any)[key] = true; Notify.send({ appId: 'clock', title: '闹钟', body: `${a.label || '闹钟'} ${String(a.h).padStart(2, '0')}:${String(a.m).padStart(2, '0')}` }); this.beep() } } - }, render(win: any) { - const S = Sys() - const st = win.appState = { tab: 'world', data: this.store.get(), sw: { running: false, t0: 0, acc: 0, laps: [] as number[] }, timer: { left: 0, running: false, total: 0, notified: false, endAt: 0 } } - win.body.classList.add('clock-body') - const tabbar = el('div', { class: 'store-tabs clock-tabs' }), content = el('div', { class: 'clock-content' }) - win.body.append(tabbar, content) - const save = () => this.store.set(st.data) - const tabs: [string, string][] = [['world', '世界时钟'], ['alarm', '闹钟'], ['sw', '秒表'], ['timer', '计时器']] - tabs.forEach(([id, name]) => { const b = el('button', { class: 'store-tab', text: name, dataset: { tab: id } }); b.addEventListener('click', () => go(id)); tabbar.append(b) }) - const go = (id: string) => { st.tab = id; $$('.store-tab', tabbar).forEach((n: HTMLElement) => n.classList.toggle('on', n.dataset.tab === id)); content.innerHTML = ''; (this as any)['render_' + id](content, st, save) } - win.timers.push(setInterval(() => { if (!document.body.contains(content)) return; if (st.tab === 'world') this.render_world(content, st, save, true); if (st.sw.running && (this as any)._swTick) (this as any)._swTick(); if (st.timer.running && (this as any)._timerTick) (this as any)._timerTick() }, 500)) - win.confirmClose = (done: () => void, cancel: () => void) => { if (!st.sw.running && !st.timer.running) return done(); const what = st.timer.running ? '计时器正在运行,关闭窗口将停止计时。' : '秒表正在运行,关闭窗口将停止计时。'; UI.dialog({ icon: this.icon, title: '关闭时钟?', msg: what, buttons: ['取消', '关闭'] }).then((r: any) => { r.index === 1 ? done() : cancel() }) } - go('world') + const vnode = h(ClockComponent, { win }) + render(vnode, win.body) }, - render_world(box: HTMLElement, st: any, save: () => void, soft?: boolean) { - const S = Sys(); const now = new Date() - if (!soft) { box.innerHTML = ''; const sel = el('select', { class: 'text-input' }, el('option', { text: '添加城市…', value: '' }), ...Object.keys(this.cityTZ).filter(c => !st.data.cities.includes(c)).map(c => el('option', { text: c, value: c }))); (sel as HTMLSelectElement).addEventListener('change', () => { if ((sel as HTMLSelectElement).value) { st.data.cities.push((sel as HTMLSelectElement).value); save(); this.render_world(box, st, save) } }); box.append(el('div', { class: 'clock-addrow' }, sel), el('div', { class: 'clock-cities' })) } - const list = box.querySelector('.clock-cities')!; list.innerHTML = '' - st.data.cities.forEach((c: string) => { const tz = this.cityTZ[c]; if (!tz) return; const row = el('div', { class: 'clock-city' }, el('div', null, el('b', { text: c }), el('small', { text: this.tzOffsetLabel(tz) })), el('span', { class: 'clock-city-time', text: fmtTimeHM(now, S.settings.h24, tz) }), el('button', { class: 'weather-del', text: '✕', title: '删除', onclick: () => { st.data.cities = st.data.cities.filter((x: string) => x !== c); save(); this.render_world(box, st, save) } })); list.append(row) }) - }, - render_alarm(box: HTMLElement, st: any, save: () => void) { - box.innerHTML = ''; const list = el('div', { class: 'alarm-list' }) - const renderList = () => { list.innerHTML = ''; if (!st.data.alarms.length) list.append(el('div', { class: 'empty-state', style: { height: '120px' }, text: '没有闹钟' })); st.data.alarms.forEach((a: any) => { const sw = el('div', { class: 'switch' + (a.on ? ' on' : ''), role: 'switch', 'aria-checked': String(!!a.on), tabindex: '0' }); const flipSw = () => { a.on = !a.on; sw.classList.toggle('on', a.on); sw.setAttribute('aria-checked', String(!!a.on)); save() }; sw.addEventListener('click', flipSw); sw.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); flipSw() } }); list.append(el('div', { class: 'alarm-row' }, el('div', { class: 'alarm-time', text: `${String(a.h).padStart(2, '0')}:${String(a.m).padStart(2, '0')}` }), el('div', { class: 'alarm-label', text: a.label || '闹钟' }), sw, el('button', { class: 'weather-del', text: '✕', title: '删除闹钟', onclick: () => { st.data.alarms = st.data.alarms.filter((x: any) => x !== a); save(); renderList() } }))) }) } - const hIn = el('input', { class: 'text-input', type: 'number', min: '0', max: '23', value: '8', style: { width: '64px' } }); const mIn = el('input', { class: 'text-input', type: 'number', min: '0', max: '59', value: '0', style: { width: '64px' } }); const lIn = el('input', { class: 'text-input', type: 'text', placeholder: '标签', value: '闹钟' }); const add = el('button', { class: 'btn primary', text: '添加闹钟' }) - add.addEventListener('click', () => { const h = clamp(+(hIn as HTMLInputElement).value || 0, 0, 23), m = clamp(+(mIn as HTMLInputElement).value || 0, 0, 59); st.data.alarms.push({ id: uid(), h, m, label: (lIn as HTMLInputElement).value.trim() || '闹钟', on: true }); save(); renderList() }) - box.append(el('div', { class: 'clock-addrow' }, hIn, el('span', { text: ':' }), mIn, lIn, add), list); renderList() - }, - render_sw(box: HTMLElement, st: any) { - box.innerHTML = ''; const disp = el('div', { class: 'sw-disp' }); const startBtn = el('button', { class: 'btn primary sw-btn' }); const lapBtn = el('button', { class: 'btn sw-btn', text: '计次' }); const resetBtn = el('button', { class: 'btn sw-btn', text: '复位' }); const laps = el('div', { class: 'sw-laps' }); box.append(disp, el('div', { class: 'sw-btns' }, lapBtn, startBtn, resetBtn), laps) - const fmtT = (ms: number) => { const t = Math.floor(ms / 100); return `${String(Math.floor(t / 600)).padStart(2, '0')}:${String(Math.floor(t / 10) % 60).padStart(2, '0')}.${t % 10}` } - const cur = () => st.sw.acc + (st.sw.running ? Date.now() - st.sw.t0 : 0) - const renderLaps = () => { laps.innerHTML = ''; st.sw.laps.forEach((lp: number, i: number) => laps.prepend(el('div', { class: 'sw-lap' }, el('span', { text: `计次 ${i + 1}` }), el('span', { text: fmtT(lp) })))) } - const syncUI = () => { disp.textContent = fmtT(cur()); (startBtn as HTMLButtonElement).textContent = st.sw.running ? '暂停' : (st.sw.acc > 0 ? '继续' : '开始'); (lapBtn as HTMLButtonElement).disabled = !st.sw.running; (resetBtn as HTMLButtonElement).disabled = st.sw.running || st.sw.acc === 0 }; - (this as any)._swTick = () => { if (disp.isConnected) disp.textContent = fmtT(cur()) } - startBtn.addEventListener('click', () => { st.sw.running = !st.sw.running; if (st.sw.running) st.sw.t0 = Date.now(); else st.sw.acc += Date.now() - st.sw.t0; syncUI() }) - lapBtn.addEventListener('click', () => { st.sw.laps.push(cur()); renderLaps() }) - resetBtn.addEventListener('click', () => { st.sw = { running: false, t0: 0, acc: 0, laps: [] }; renderLaps(); syncUI() }) - renderLaps(); syncUI() - }, - render_timer(box: HTMLElement, st: any) { - box.innerHTML = ''; const mIn = el('input', { class: 'text-input', type: 'number', min: '0', max: '180', value: '5', style: { width: '72px' } }); const sIn = el('input', { class: 'text-input', type: 'number', min: '0', max: '59', value: '0', style: { width: '72px' } }); const disp = el('div', { class: 'sw-disp', text: '05:00' }); const startBtn = el('button', { class: 'btn primary sw-btn' }); const resetBtn = el('button', { class: 'btn sw-btn', text: '复位' }); const ring = el('div', { class: 'timer-ring' }, disp); box.append(el('div', { class: 'clock-addrow' }, mIn, el('span', { text: '分' }), sIn, el('span', { text: '秒' })), ring, el('div', { class: 'sw-btns' }, resetBtn, startBtn)) - const fmtT = (ms: number) => { const t = Math.max(0, Math.ceil(ms / 1000)); return `${String(Math.floor(t / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}` } - const left = () => st.timer.running ? Math.max(0, st.timer.endAt - Date.now()) : st.timer.left - const sync = () => { const l = left(); disp.textContent = fmtT(l); ring.style.setProperty('--p', st.timer.total ? String(1 - l / st.timer.total) : '0'); (startBtn as HTMLButtonElement).textContent = st.timer.running ? '暂停' : (st.timer.left > 0 ? '继续' : '开始'); (mIn as HTMLInputElement).disabled = (sIn as HTMLInputElement).disabled = st.timer.running }; - (this as any)._timerTick = () => { if (!st.timer.running) return; if (st.timer.endAt - Date.now() <= 0) { st.timer.running = false; st.timer.left = 0; if (!st.timer.notified) { st.timer.notified = true; Notify.send({ appId: 'clock', title: '计时器', body: '时间到!', breakthrough: true }); this.beep() } } else st.timer.left = st.timer.endAt - Date.now(); if (disp.isConnected) sync() } - startBtn.addEventListener('click', () => { if (st.timer.running) { st.timer.left = Math.max(0, st.timer.endAt - Date.now()); st.timer.running = false; sync(); return }; if (st.timer.left <= 0) { st.timer.total = ((+(mIn as HTMLInputElement).value || 0) * 60 + (+(sIn as HTMLInputElement).value || 0)) * 1000; if (st.timer.total <= 0) return; st.timer.left = st.timer.total }; st.timer.endAt = Date.now() + st.timer.left; st.timer.notified = false; st.timer.running = true; sync() }) - resetBtn.addEventListener('click', () => { st.timer = { left: 0, running: false, total: 0, notified: false }; sync(); disp.textContent = fmtT(((+mIn.value || 0) * 60 + (+(sIn as HTMLInputElement).value || 0)) * 1000) }) - mIn.addEventListener('input', () => { if (!st.timer.running && st.timer.left <= 0) disp.textContent = fmtT(((+mIn.value || 0) * 60 + (+(sIn as HTMLInputElement).value || 0)) * 1000) }) - sIn.addEventListener('input', () => mIn.dispatchEvent(new Event('input'))); sync() - } } -Apps.register(ClockApp); -(window as any).ClockApp = ClockApp +Apps.register(ClockApp) +;(window as any).ClockApp = ClockApp diff --git a/tests/cases/31-clock.test.ts b/tests/cases/31-clock.test.ts new file mode 100644 index 0000000..680eb1a --- /dev/null +++ b/tests/cases/31-clock.test.ts @@ -0,0 +1,376 @@ +/** + * 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) + }) + }) +})