feat: 重构 clock — Vue 组件化(世界时钟/闹钟/秒表/计时器4tab)+ 37 测试
This commit is contained in:
parent
9ab470a867
commit
9b233a0c86
339
src/apps/clock/Clock.vue
Normal file
339
src/apps/clock/Clock.vue
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
<template>
|
||||||
|
<div class="clock-body">
|
||||||
|
<div class="store-tabs clock-tabs">
|
||||||
|
<button
|
||||||
|
v-for="t in tabs" :key="t[0]"
|
||||||
|
class="store-tab" :class="{ on: tab === t[0] }"
|
||||||
|
:data-tab="t[0]"
|
||||||
|
@click="tab = t[0]"
|
||||||
|
>{{ t[1] }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="clock-content">
|
||||||
|
<!-- 世界时钟 -->
|
||||||
|
<template v-if="tab === 'world'">
|
||||||
|
<div class="clock-addrow">
|
||||||
|
<select class="text-input" @change="addCity(($event.target as HTMLSelectElement).value)">
|
||||||
|
<option value="">添加城市…</option>
|
||||||
|
<option v-for="c in availableCities" :key="c" :value="c">{{ c }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="clock-cities">
|
||||||
|
<div v-for="c in data.cities" :key="c" class="clock-city">
|
||||||
|
<div>
|
||||||
|
<b>{{ c }}</b>
|
||||||
|
<small>{{ tzOffset(c) }}</small>
|
||||||
|
</div>
|
||||||
|
<span class="clock-city-time">{{ fmtTime(c) }}</span>
|
||||||
|
<button class="weather-del" title="删除" @click="removeCity(c)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 闹钟 -->
|
||||||
|
<template v-if="tab === 'alarm'">
|
||||||
|
<div class="clock-addrow">
|
||||||
|
<input class="text-input" type="number" min="0" max="23" v-model="newAlarmH" style="width:64px">
|
||||||
|
<span>:</span>
|
||||||
|
<input class="text-input" type="number" min="0" max="59" v-model="newAlarmM" style="width:64px">
|
||||||
|
<input class="text-input" type="text" placeholder="标签" v-model="newAlarmLabel">
|
||||||
|
<button class="btn primary" @click="addAlarm">添加闹钟</button>
|
||||||
|
</div>
|
||||||
|
<div class="alarm-list">
|
||||||
|
<div v-if="!data.alarms.length" class="empty-state" style="height:120px">没有闹钟</div>
|
||||||
|
<div v-for="a in data.alarms" :key="a.id" class="alarm-row">
|
||||||
|
<div class="alarm-time">{{ pad(a.h) }}:{{ pad(a.m) }}</div>
|
||||||
|
<div class="alarm-label">{{ a.label || '闹钟' }}</div>
|
||||||
|
<div
|
||||||
|
class="switch" :class="{ on: a.on }"
|
||||||
|
role="switch" :aria-checked="a.on"
|
||||||
|
tabindex="0"
|
||||||
|
@click="toggleAlarm(a)"
|
||||||
|
@keydown.space.prevent="toggleAlarm(a)"
|
||||||
|
@keydown.enter.prevent="toggleAlarm(a)"
|
||||||
|
></div>
|
||||||
|
<button class="weather-del" title="删除闹钟" @click="removeAlarm(a)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 秒表 -->
|
||||||
|
<template v-if="tab === 'sw'">
|
||||||
|
<div class="sw-disp">{{ fmtSw() }}</div>
|
||||||
|
<div class="sw-btns">
|
||||||
|
<button class="btn sw-btn" :disabled="!sw.running" @click="swLap">计次</button>
|
||||||
|
<button class="btn primary sw-btn" @click="swToggle">{{ swBtnText }}</button>
|
||||||
|
<button class="btn sw-btn" :disabled="sw.running || sw.acc === 0" @click="swReset">复位</button>
|
||||||
|
</div>
|
||||||
|
<div class="sw-laps">
|
||||||
|
<div v-for="(lp, i) in reversedLaps" :key="i" class="sw-lap">
|
||||||
|
<span>计次 {{ sw.laps.length - i }}</span>
|
||||||
|
<span>{{ fmtSwLap(lp) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 计时器 -->
|
||||||
|
<template v-if="tab === 'timer'">
|
||||||
|
<div class="clock-addrow">
|
||||||
|
<input class="text-input" type="number" min="0" max="180" v-model="timerMin" :disabled="timerRunning" style="width:72px">
|
||||||
|
<span>分</span>
|
||||||
|
<input class="text-input" type="number" min="0" max="59" v-model="timerSec" :disabled="timerRunning" style="width:72px">
|
||||||
|
<span>秒</span>
|
||||||
|
</div>
|
||||||
|
<div class="timer-ring" :style="{ '--p': timerProgress }">
|
||||||
|
<div class="sw-disp">{{ fmtTimerDisplay() }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sw-btns">
|
||||||
|
<button class="btn sw-btn" @click="timerReset">复位</button>
|
||||||
|
<button class="btn primary sw-btn" @click="timerToggle">{{ timerBtnText }}</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { clamp, uid } from '../../utils'
|
||||||
|
import { store } from '../../composables/useStore'
|
||||||
|
import { Notify } from '../../composables/useNotify'
|
||||||
|
|
||||||
|
const props = defineProps<{ win: any }>()
|
||||||
|
|
||||||
|
// ==================== 常量 ====================
|
||||||
|
const tabs: [string, string][] = [['world', '世界时钟'], ['alarm', '闹钟'], ['sw', '秒表'], ['timer', '计时器']]
|
||||||
|
|
||||||
|
const cityTZ: Record<string, string> = {
|
||||||
|
'北京': 'Asia/Shanghai', '上海': 'Asia/Shanghai', '东京': 'Asia/Tokyo', '伦敦': 'Europe/London',
|
||||||
|
'巴黎': 'Europe/Paris', '纽约': 'America/New_York', '洛杉矶': 'America/Los_Angeles', '悉尼': 'Australia/Sydney',
|
||||||
|
'迪拜': 'Asia/Dubai', '新加坡': 'Asia/Singapore',
|
||||||
|
}
|
||||||
|
|
||||||
|
function tzOffsetLabel(tz: string): 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 { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function beep() {
|
||||||
|
try {
|
||||||
|
const AudioCtx = (window as any).AudioContext || (window as any).webkitAudioContext
|
||||||
|
const ctx = new AudioCtx()
|
||||||
|
const o = ctx.createOscillator(); const g = ctx.createGain()
|
||||||
|
o.connect(g); g.connect(ctx.destination)
|
||||||
|
o.frequency.value = 880
|
||||||
|
const vol = (window as any).Sys?.settings?.muted ? 0 : ((window as any).Sys?.settings?.volume || 1) * 0.3
|
||||||
|
g.gain.setValueAtTime(vol, ctx.currentTime)
|
||||||
|
o.start()
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 1.2)
|
||||||
|
o.stop(ctx.currentTime + 1.3)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 持久化数据 ====================
|
||||||
|
const data = reactive(store.get('clock', {
|
||||||
|
cities: ['上海', '东京', '伦敦', '纽约'],
|
||||||
|
alarms: [{ id: 'a1', h: 8, m: 30, label: '起床', on: false }]
|
||||||
|
}))
|
||||||
|
|
||||||
|
function persist() { store.set('clock', { cities: data.cities, alarms: data.alarms }) }
|
||||||
|
|
||||||
|
// ==================== 共享 ====================
|
||||||
|
const tab = ref('world')
|
||||||
|
const now = ref(Date.now())
|
||||||
|
let timer: any = null
|
||||||
|
|
||||||
|
function pad(n: number) { return String(n).padStart(2, '0') }
|
||||||
|
|
||||||
|
// ==================== 世界时钟 ====================
|
||||||
|
const availableCities = computed(() => Object.keys(cityTZ).filter(c => !data.cities.includes(c)))
|
||||||
|
|
||||||
|
function tzOffset(city: string): string { return tzOffsetLabel(cityTZ[city] || '') }
|
||||||
|
|
||||||
|
function fmtTime(city: string): string {
|
||||||
|
const tz = cityTZ[city]
|
||||||
|
if (!tz) return '--:--'
|
||||||
|
const d = new Date(now.value)
|
||||||
|
const h24 = (window as any).Sys?.settings?.h24 ?? true
|
||||||
|
const h = parseInt(new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', hour12: false }).format(d))
|
||||||
|
const m = parseInt(new Intl.DateTimeFormat('en-US', { timeZone: tz, minute: 'numeric' }).format(d))
|
||||||
|
if (h24) return `${pad(h)}:${pad(m)}`
|
||||||
|
const ampm = h >= 12 ? '下午' : '上午'
|
||||||
|
const h12 = h % 12 || 12
|
||||||
|
return `${ampm}${h12}:${pad(m)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCity(city: string) {
|
||||||
|
if (!city || data.cities.includes(city)) return
|
||||||
|
data.cities.push(city); persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCity(city: string) {
|
||||||
|
data.cities = data.cities.filter(c => c !== city); persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 闹钟 ====================
|
||||||
|
const newAlarmH = ref(8)
|
||||||
|
const newAlarmM = ref(0)
|
||||||
|
const newAlarmLabel = ref('闹钟')
|
||||||
|
const firedKeys = new Set<string>()
|
||||||
|
|
||||||
|
function addAlarm() {
|
||||||
|
const h = clamp(newAlarmH.value, 0, 23)
|
||||||
|
const m = clamp(newAlarmM.value, 0, 59)
|
||||||
|
data.alarms.push({ id: uid(), h, m, label: newAlarmLabel.value.trim() || '闹钟', on: true })
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAlarm(a: any) { a.on = !a.on; persist() }
|
||||||
|
|
||||||
|
function removeAlarm(a: any) {
|
||||||
|
data.alarms = data.alarms.filter((x: any) => x !== a); persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkAlarms() {
|
||||||
|
const d = new Date(now.value)
|
||||||
|
const hm = d.getHours() * 60 + d.getMinutes()
|
||||||
|
for (const a of data.alarms) {
|
||||||
|
if (!a.on) continue
|
||||||
|
const key = `_fired_${a.id}_${d.toDateString()}`
|
||||||
|
if (a.h * 60 + a.m === hm && !firedKeys.has(key)) {
|
||||||
|
firedKeys.add(key)
|
||||||
|
Notify.send({ appId: 'clock', title: '闹钟', body: `${a.label || '闹钟'} ${pad(a.h)}:${pad(a.m)}` })
|
||||||
|
beep()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 秒表 ====================
|
||||||
|
const sw = reactive({ running: false, t0: 0, acc: 0, laps: [] as number[] })
|
||||||
|
|
||||||
|
function fmtSwMs(ms: number): string {
|
||||||
|
const t = Math.floor(ms / 100)
|
||||||
|
return `${pad(Math.floor(t / 600))}:${pad(Math.floor(t / 10) % 60)}.${t % 10}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function swCur(): number { return sw.acc + (sw.running ? Date.now() - sw.t0 : 0) }
|
||||||
|
|
||||||
|
function fmtSw(): string { return fmtSwMs(swCur()) }
|
||||||
|
|
||||||
|
function fmtSwLap(ms: number): string { return fmtSwMs(ms) }
|
||||||
|
|
||||||
|
const swBtnText = computed(() => sw.running ? '暂停' : (sw.acc > 0 ? '继续' : '开始'))
|
||||||
|
|
||||||
|
const reversedLaps = computed(() => [...sw.laps].reverse())
|
||||||
|
|
||||||
|
function swToggle() {
|
||||||
|
sw.running = !sw.running
|
||||||
|
if (sw.running) sw.t0 = Date.now()
|
||||||
|
else sw.acc += Date.now() - sw.t0
|
||||||
|
}
|
||||||
|
|
||||||
|
function swLap() { sw.laps.push(swCur()) }
|
||||||
|
|
||||||
|
function swReset() { sw.running = false; sw.t0 = 0; sw.acc = 0; sw.laps = [] }
|
||||||
|
|
||||||
|
// ==================== 计时器 ====================
|
||||||
|
const timerMin = ref(5)
|
||||||
|
const timerSec = ref(0)
|
||||||
|
const timerRunning = ref(false)
|
||||||
|
const timerLeft = ref(0)
|
||||||
|
const timerTotal = ref(0)
|
||||||
|
const timerEndAt = ref(0)
|
||||||
|
const timerNotified = ref(false)
|
||||||
|
|
||||||
|
function timerCurLeft(): number {
|
||||||
|
if (timerRunning.value) return Math.max(0, timerEndAt.value - Date.now())
|
||||||
|
return timerLeft.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const timerProgress = computed(() => {
|
||||||
|
if (!timerTotal.value) return '0'
|
||||||
|
return String(1 - timerCurLeft() / timerTotal.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const timerBtnText = computed(() => {
|
||||||
|
if (timerRunning.value) return '暂停'
|
||||||
|
if (timerLeft.value > 0) return '继续'
|
||||||
|
return '开始'
|
||||||
|
})
|
||||||
|
|
||||||
|
function fmtTimerMs(ms: number): string {
|
||||||
|
const t = Math.max(0, Math.ceil(ms / 1000))
|
||||||
|
return `${pad(Math.floor(t / 60))}:${pad(t % 60)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTimerDisplay(): string {
|
||||||
|
const left = timerCurLeft()
|
||||||
|
if (!timerRunning.value && left <= 0) {
|
||||||
|
return fmtTimerMs((timerMin.value * 60 + timerSec.value) * 1000)
|
||||||
|
}
|
||||||
|
return fmtTimerMs(left)
|
||||||
|
}
|
||||||
|
|
||||||
|
function timerToggle() {
|
||||||
|
if (timerRunning.value) {
|
||||||
|
timerLeft.value = Math.max(0, timerEndAt.value - Date.now())
|
||||||
|
timerRunning.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (timerLeft.value <= 0) {
|
||||||
|
timerTotal.value = (timerMin.value * 60 + timerSec.value) * 1000
|
||||||
|
if (timerTotal.value <= 0) return
|
||||||
|
timerLeft.value = timerTotal.value
|
||||||
|
}
|
||||||
|
timerEndAt.value = Date.now() + timerLeft.value
|
||||||
|
timerNotified.value = false
|
||||||
|
timerRunning.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function timerReset() {
|
||||||
|
timerRunning.value = false
|
||||||
|
timerLeft.value = 0
|
||||||
|
timerTotal.value = 0
|
||||||
|
timerNotified.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function timerTick() {
|
||||||
|
if (!timerRunning.value) return
|
||||||
|
if (timerEndAt.value - Date.now() <= 0) {
|
||||||
|
timerRunning.value = false
|
||||||
|
timerLeft.value = 0
|
||||||
|
if (!timerNotified.value) {
|
||||||
|
timerNotified.value = true
|
||||||
|
Notify.send({ appId: 'clock', title: '计时器', body: '时间到!', breakthrough: true })
|
||||||
|
beep()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 生命周期 ====================
|
||||||
|
onMounted(() => {
|
||||||
|
timer = setInterval(() => {
|
||||||
|
now.value = Date.now()
|
||||||
|
if (tab.value === 'alarm') checkAlarms()
|
||||||
|
timerTick()
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => { clearInterval(timer) })
|
||||||
|
|
||||||
|
// ==================== 确认关闭 ====================
|
||||||
|
watch(() => props.win, (w) => {
|
||||||
|
if (!w) return
|
||||||
|
w.confirmClose = (done: () => void, cancel: () => void) => {
|
||||||
|
if (!sw.running && !timerRunning.value) return done()
|
||||||
|
const what = timerRunning.value ? '计时器正在运行,关闭窗口将停止计时。' : '秒表正在运行,关闭窗口将停止计时。'
|
||||||
|
const ui = (window as any).ui
|
||||||
|
if (ui) {
|
||||||
|
ui.dialog({ icon: '/assets/icons/clock.svg', title: '关闭时钟?', msg: what, buttons: ['取消', '关闭'] })
|
||||||
|
.then((r: any) => { r.index === 1 ? done() : cancel() })
|
||||||
|
} else { done() }
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// 暴露给 menus
|
||||||
|
defineExpose({ tab, sw, timerRunning, timerLeft })
|
||||||
|
</script>
|
||||||
@ -1,84 +1,16 @@
|
|||||||
// 时钟应用 — 从 js/apps3.js 手动转写
|
// 时钟应用 — Vue 组件化
|
||||||
import { el, $$, clamp, uid, fmtTimeHM } from '../../utils'
|
import { h, render } from 'vue'
|
||||||
import { store as Store } from '../../composables/useStore'
|
|
||||||
import { ui as UI } from '../../composables/useUI'
|
|
||||||
import { Apps, stdMenus } from '../../composables/useApps'
|
import { Apps, stdMenus } from '../../composables/useApps'
|
||||||
import { Notify } from '../../composables/useNotify'
|
import ClockComponent from './Clock.vue'
|
||||||
const Sys = () => (window as any).Sys
|
|
||||||
|
|
||||||
export const ClockApp = {
|
export const ClockApp = {
|
||||||
id: 'clock', name: '时钟', icon: '/assets/icons/clock.svg',
|
id: 'clock', name: '时钟', icon: '/assets/icons/clock.svg',
|
||||||
w: 680, h: 480, minW: 480, minH: 360,
|
w: 680, h: 480, minW: 480, minH: 360,
|
||||||
menus() { return stdMenus(this) },
|
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<string, string>,
|
|
||||||
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) {
|
render(win: any) {
|
||||||
const S = Sys()
|
const vnode = h(ClockComponent, { win })
|
||||||
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 } }
|
render(vnode, win.body)
|
||||||
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')
|
|
||||||
},
|
},
|
||||||
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);
|
Apps.register(ClockApp)
|
||||||
(window as any).ClockApp = ClockApp
|
;(window as any).ClockApp = ClockApp
|
||||||
|
|||||||
376
tests/cases/31-clock.test.ts
Normal file
376
tests/cases/31-clock.test.ts
Normal file
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user