macos-web/tests/cases/30-weather.test.ts

364 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.

/**
* 30-weather: Weather Vue 组件化测试
*
* 覆盖: DOM 结构(搜索框/城市列表/天气主面板)、城市管理(选择/添加/删除/持久化)、
* 搜索建议、天气数据展示(当前温度/逐小时/7日预报、WMO 天气码映射、
* 模拟数据、加载状态、卸载清理
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { h, render, nextTick } from 'vue'
import WeatherComponent from '../../src/apps/weather/Weather.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: 'weather', appId: 'weather', el, body, timers: [] as any[], data: {} }
}
describe('30-weather — Weather Vue 组件化', () => {
let win: any
beforeEach(() => { localStorage.clear(); store.set('weather', null); setupDOM(); win = makeMockWin() })
afterEach(() => { render(null, win.body); win.el.remove() })
function mount() { const v = h(WeatherComponent, { win }); render(v, win.body) }
// ==================== DOM 结构 ====================
describe('DOM 结构', () => {
it('搜索输入框存在', () => {
mount()
expect(win.body.querySelector('.weather-search')).toBeTruthy()
})
it('城市侧边栏存在', () => {
mount()
expect(win.body.querySelector('.weather-side')).toBeTruthy()
})
it('天气主面板存在', () => {
mount()
expect(win.body.querySelector('.weather-main')).toBeTruthy()
})
it('初始城市列表包含默认城市', () => {
mount()
const cities = win.body.querySelectorAll('.weather-city')
expect(cities.length).toBeGreaterThanOrEqual(1)
})
it('首个城市有 sel 类(选中态)', async () => {
mount()
await nextTick()
const first = win.body.querySelector('.weather-city')
expect(first?.classList.contains('sel')).toBe(true)
})
})
// ==================== 城市管理 ====================
describe('城市管理', () => {
it('点击城市切换选中', async () => {
mount()
await nextTick()
const cities = win.body.querySelectorAll('.weather-city')
if (cities.length >= 2) {
(cities[1] as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
expect(cities[1].classList.contains('sel')).toBe(true)
}
})
it('搜索添加新城市', async () => {
mount()
const input = win.body.querySelector('.weather-search') as HTMLInputElement
input.value = '杭州'
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
const sug = win.body.querySelector('.weather-sug')
expect(sug).toBeTruthy()
// 点击建议添加
const item = win.body.querySelector('.maps-result') as HTMLElement
if (item) {
item.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
const names = [...win.body.querySelectorAll('.weather-city b')].map((e: any) => e.textContent)
expect(names).toContain('杭州')
}
})
it('重复城市不添加', async () => {
mount()
await nextTick()
const beforeCount = win.body.querySelectorAll('.weather-city').length
const input = win.body.querySelector('.weather-search') as HTMLInputElement
const firstCity = win.body.querySelector('.weather-city b')?.textContent || ''
input.value = firstCity
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
// 已存在的城市不应出现在建议中
const items = win.body.querySelectorAll('.maps-result')
const names = [...items].map((e: any) => e.textContent)
expect(names).not.toContain(firstCity)
})
it('删除城市按钮存在', () => {
mount()
const delBtns = win.body.querySelectorAll('.weather-del')
expect(delBtns.length).toBeGreaterThanOrEqual(1)
})
it('删除城市后从列表移除', async () => {
// 先添加两个城市
store.set('weather', { cities: ['北京', '上海'], cur: '北京', cache: {} })
mount()
await nextTick()
const beforeCount = win.body.querySelectorAll('.weather-city').length
const delBtn = win.body.querySelector('.weather-del') as HTMLElement
delBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
const afterCount = win.body.querySelectorAll('.weather-city').length
expect(afterCount).toBeLessThan(beforeCount)
})
it('删除当前选中城市后自动切换', async () => {
store.set('weather', { cities: ['北京', '上海'], cur: '北京', cache: {} })
mount()
await nextTick()
const delBtn = win.body.querySelector('.weather-del') as HTMLElement
delBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
// 当前城市应变更为剩余城市
const selCity = win.body.querySelector('.weather-city.sel b')
expect(selCity).toBeTruthy()
})
})
// ==================== 搜索建议 ====================
describe('搜索建议', () => {
it('空输入隐藏建议', () => {
mount()
const sug = win.body.querySelector('.weather-sug')
expect(sug?.classList.contains('hidden')).toBe(true)
})
it('输入关键字显示匹配城市', async () => {
store.set('weather', { cities: ['北京'], cur: '北京', cache: {} })
mount()
await nextTick(); await nextTick() // 等待 onMounted 完成
const input = win.body.querySelector('.weather-search') as HTMLInputElement
input.value = '上海'
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
const items = win.body.querySelectorAll('.weather-sug .maps-result')
expect(items.length).toBeGreaterThanOrEqual(1)
expect([...items].some(e => e.textContent?.includes('上海'))).toBe(true)
})
it('无匹配时建议为空', async () => {
mount()
const input = win.body.querySelector('.weather-search') as HTMLInputElement
input.value = 'zzz_no_city'
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
const items = win.body.querySelectorAll('.weather-sug .maps-result')
expect(items.length).toBe(0)
})
it('已添加的城市不在建议中', async () => {
mount()
await nextTick()
const input = win.body.querySelector('.weather-search') as HTMLInputElement
const existingCity = win.body.querySelector('.weather-city b')?.textContent || ''
input.value = existingCity
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
const items = [...win.body.querySelectorAll('.maps-result')].map(e => e.textContent)
expect(items).not.toContain(existingCity)
})
})
// ==================== 天气数据展示 ====================
describe('天气数据展示', () => {
// ⚠️ onMounted 立即调用 fetchCity同步 simulate数据瞬间就绪
// loading 状态仅在 fetchCity 异步场景可见,此处验证当前城市正常渲染
it('无缓存时 onMounted 自动模拟数据,城市名称正确显示', async () => {
store.set('weather', { cities: ['北京'], cur: '北京', cache: {} })
mount()
await nextTick()
// fetchCity 同步执行 simulate数据已就绪
const city = win.body.querySelector('.weather-now-city')
expect(city).toBeTruthy()
expect(city?.textContent).toContain('北京')
})
it('有缓存数据显示温度', async () => {
// 预设模拟数据
store.set('weather', {
cities: ['北京'],
cur: '北京',
cache: {
'北京': {
temp: 25, code: 0,
hourly: Array.from({ length: 12 }, (_, i) => ({ t: i, temp: 23 + Math.round(Math.sin(i / 3) * 4), code: 0 })),
daily: [
{ day: '今天', hi: 28, lo: 18, code: 0 },
{ day: '周一', hi: 30, lo: 20, code: 1 },
{ day: '周二', hi: 27, lo: 19, code: 2 },
{ day: '周三', hi: 26, lo: 17, code: 3 },
{ day: '周四', hi: 29, lo: 21, code: 61 },
{ day: '周五', hi: 31, lo: 22, code: 0 },
{ day: '周六', hi: 28, lo: 18, code: 80 },
],
simulated: true,
}
}
})
mount()
await nextTick()
expect(win.body.querySelector('.weather-now-temp')?.textContent).toContain('25°')
})
it('缓存数据显示逐小时预报', async () => {
store.set('weather', {
cities: ['北京'], cur: '北京',
cache: { '北京': { temp: 22, code: 2, hourly: Array.from({ length: 12 }, (_, i) => ({ t: i, temp: 20 + i % 5, code: i % 3 })), daily: [{ day: '今天', hi: 28, lo: 18, code: 0 }], simulated: true } }
})
mount()
await nextTick()
const hours = win.body.querySelectorAll('.weather-hour')
expect(hours.length).toBe(12)
})
it('缓存数据显示 7 日预报', async () => {
store.set('weather', {
cities: ['北京'], cur: '北京',
cache: { '北京': { temp: 22, code: 2, hourly: Array.from({ length: 12 }, (_, i) => ({ t: i, temp: 20 + i % 5, code: 0 })), daily: Array.from({ length: 7 }, (_, i) => ({ day: ['今天', '周一', '周二', '周三', '周四', '周五', '周六'][i], hi: 25 + i, lo: 15 + i, code: i % 4 })), simulated: true } }
})
mount()
await nextTick()
const days = win.body.querySelectorAll('.weather-day')
expect(days.length).toBe(7)
})
it('模拟数据显示(模拟数据)标签', async () => {
store.set('weather', {
cities: ['北京'], cur: '北京',
cache: { '北京': { temp: 22, code: 0, hourly: [{ t: 12, temp: 22, code: 0 }], daily: [{ day: '今天', hi: 28, lo: 18, code: 0 }], simulated: true } }
})
mount()
await nextTick()
expect(win.body.querySelector('.weather-now-city')?.textContent).toContain('模拟数据')
})
it('非模拟数据不显示"模拟数据"标签', async () => {
store.set('weather', {
cities: ['北京'], cur: '北京',
cache: { '北京': { temp: 22, code: 0, hourly: [{ t: 12, temp: 22, code: 0 }], daily: [{ day: '今天', hi: 28, lo: 18, code: 0 }] } }
})
mount()
await nextTick()
expect(win.body.querySelector('.weather-now-city')?.textContent).not.toContain('模拟数据')
})
it('缓存数据显示最高/最低温度', async () => {
store.set('weather', {
cities: ['北京'], cur: '北京',
cache: { '北京': { temp: 22, code: 0, hourly: [{ t: 12, temp: 22, code: 0 }], daily: [{ day: '今天', hi: 28, lo: 18, code: 0 }], simulated: true } }
})
mount()
await nextTick()
expect(win.body.querySelector('.weather-now-hl')?.textContent).toContain('28°')
expect(win.body.querySelector('.weather-now-hl')?.textContent).toContain('18°')
})
it('天气描述文本正确映射code: 0 = 晴)', async () => {
store.set('weather', {
cities: ['北京'], cur: '北京',
cache: { '北京': { temp: 25, code: 0, hourly: [{ t: 12, temp: 25, code: 0 }], daily: [{ day: '今天', hi: 28, lo: 18, code: 0 }], simulated: true } }
})
mount()
await nextTick()
expect(win.body.querySelector('.weather-now-desc')?.textContent).toContain('晴')
})
})
// ==================== 城市侧边栏显示 ====================
describe('城市侧边栏', () => {
it('每个城市显示名称和温度', async () => {
store.set('weather', {
cities: ['北京', '上海'], cur: '北京',
cache: {
'北京': { temp: 25, code: 0, hourly: [{ t: 12, temp: 25, code: 0 }], daily: [{ day: '今天', hi: 28, lo: 18, code: 0 }], simulated: true },
'上海': { temp: 28, code: 1, hourly: [{ t: 12, temp: 28, code: 1 }], daily: [{ day: '今天', hi: 32, lo: 24, code: 1 }], simulated: true },
}
})
mount()
await nextTick()
const cities = win.body.querySelectorAll('.weather-city')
expect(cities.length).toBe(2)
expect(cities[0].querySelector('b')?.textContent).toBe('北京')
expect(cities[1].querySelector('b')?.textContent).toBe('上海')
expect(cities[0].querySelector('.weather-city-temp')?.textContent).toContain('25°')
expect(cities[1].querySelector('.weather-city-temp')?.textContent).toContain('28°')
})
// ⚠️ onMounted 调用 fetchCity 同步填充缓存;
// 未预设缓存的城市会被立即模拟数据覆盖
it('无预设缓存的城市在 onMounted 后获得模拟数据', async () => {
store.set('weather', { cities: ['北京'], cur: '北京', cache: {} })
mount()
await nextTick()
// fetchCity 同步生成模拟数据,温度不再是 …
const temp = win.body.querySelector('.weather-city-temp')
expect(temp).toBeTruthy()
// 应为数字°格式
expect(temp?.textContent).toMatch(/\d+°/)
})
it('城市描述文本显示(通过 WMO 映射)', async () => {
store.set('weather', {
cities: ['北京'], cur: '北京',
cache: { '北京': { temp: 22, code: 61, hourly: [{ t: 12, temp: 22, code: 61 }], daily: [{ day: '今天', hi: 25, lo: 18, code: 61 }], simulated: true } }
})
mount()
await nextTick()
expect(win.body.querySelector('.weather-city small')?.textContent).toBe('小雨')
})
})
// ==================== 持久化 ====================
describe('持久化', () => {
it('store 初始数据来自 localStorage', () => {
localStorage.setItem('weather', JSON.stringify({ cities: ['深圳'], cur: '深圳', cache: {} }))
mount()
const cities = win.body.querySelectorAll('.weather-city')
expect(cities.length).toBeGreaterThanOrEqual(1)
})
it('选择城市后 store 更新', async () => {
store.set('weather', { cities: ['北京', '上海'], cur: '北京', cache: {} })
mount()
await nextTick()
const cities = win.body.querySelectorAll('.weather-city')
const sh = [...cities].find(c => c.querySelector('b')?.textContent === '上海') as HTMLElement
if (sh) {
sh.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await nextTick()
}
const data = store.get('weather', {})
expect(data.cur).toBeTruthy()
})
})
// ==================== 卸载 ====================
describe('卸载', () => {
it('卸载后 DOM 为空', () => {
mount()
render(null, win.body)
expect(win.body.children.length).toBe(0)
})
})
})