feat: 补写 weather 测试 — 29 测试(结构/城市管理/搜索建议/天气展示/侧边栏/持久化/卸载)

This commit is contained in:
李岩岩 2026-07-23 10:55:38 +08:00
parent 0df029272b
commit 9ab470a867

View File

@ -0,0 +1,363 @@
/**
* 30-weather: Weather Vue
*
* 覆盖: DOM /////
* //7WMO
*
*/
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)
})
})
})