diff --git a/tests/cases/30-weather.test.ts b/tests/cases/30-weather.test.ts new file mode 100644 index 0000000..ea2cc49 --- /dev/null +++ b/tests/cases/30-weather.test.ts @@ -0,0 +1,363 @@ +/** + * 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) + }) + }) +})