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