feat: add Today environment summary
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { createApp, h, nextTick } from 'vue'
|
||||
import TodayEnvironmentStrip, { type TodayEnvironment } from './TodayEnvironmentStrip.vue'
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
|
||||
async function mountStrip(props: { environment: TodayEnvironment | null; loading?: boolean; failed?: boolean }) {
|
||||
const host = document.createElement('div')
|
||||
document.body.append(host)
|
||||
const app = createApp(() => h(TodayEnvironmentStrip, props))
|
||||
app.mount(host)
|
||||
cleanups.push(() => { app.unmount(); host.remove() })
|
||||
await nextTick()
|
||||
return host
|
||||
}
|
||||
|
||||
afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()))
|
||||
|
||||
const environment: TodayEnvironment = {
|
||||
calendar: { solar_date: '2026年9月16日', weekday: '星期三', lunar_date: '农历八月初六' },
|
||||
weather: { status: 'fresh', location: '宁波海曙', text: '多云', temperature_c: 27, observed_at: '2026-09-16T09:00:00+08:00' },
|
||||
gold: { status: 'stale', symbol: 'Au99.99', price_cny_per_gram: 782.35, market_date: '2026-09-15', source: '上海黄金交易所' },
|
||||
}
|
||||
|
||||
describe('TodayEnvironmentStrip', () => {
|
||||
it('renders date, fixed-location weather, and delayed SGE Au99.99 quote', async () => {
|
||||
const host = await mountStrip({ environment })
|
||||
expect(host.querySelector('.today-environment')?.getAttribute('aria-label')).toBe('今日环境信息')
|
||||
expect(host.textContent).toContain('2026年9月16日')
|
||||
expect(host.textContent).toContain('星期三')
|
||||
expect(host.textContent).toContain('农历八月初六')
|
||||
expect(host.textContent).toContain('宁波海曙')
|
||||
expect(host.textContent).toContain('多云')
|
||||
expect(host.textContent).toContain('27°C')
|
||||
expect(host.textContent).toContain('Au99.99 ¥782.35/g')
|
||||
expect(host.textContent).toContain('上金所延时')
|
||||
expect(host.textContent).toContain('2026-09-15')
|
||||
expect(host.querySelector('.today-environment__gold')?.classList.contains('is-stale')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts the aggregation endpoint field names and derives source status', async () => {
|
||||
const host = await mountStrip({ environment: {
|
||||
date: { solar_date: '2026-09-16', weekday: '星期三', lunar: '农历八月初六', timezone: 'Asia/Shanghai' },
|
||||
weather: { temperature_c: 28.4, weather_code: 2, observed_at: '2026-09-16T14:15:00+08:00', source: 'Open-Meteo', stale: false },
|
||||
gold: { contract: 'Au99.99', latest_price: '935.990', market_date: '2026-09-16', delayed: true, source: '上海黄金交易所', stale: true },
|
||||
} as TodayEnvironment })
|
||||
expect(host.textContent).toContain('2026-09-16 星期三')
|
||||
expect(host.textContent).toMatch(/宁波海曙 · 多云\s+28\.4°C/)
|
||||
expect(host.textContent).toContain('Au99.99 ¥935.99/g')
|
||||
expect(host.textContent).toContain('上金所延时 · 2026-09-16')
|
||||
expect(host.querySelector('.today-environment__gold')?.classList.contains('is-stale')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows local loading, cached, and per-source unavailable states', async () => {
|
||||
const loading = await mountStrip({ environment: null, loading: true })
|
||||
expect(loading.querySelector('.today-environment')?.getAttribute('aria-busy')).toBe('true')
|
||||
expect(loading.textContent).toContain('环境信息加载中')
|
||||
|
||||
const partial = await mountStrip({ environment: {
|
||||
calendar: environment.calendar,
|
||||
weather: { status: 'stale', location: '宁波海曙', text: '小雨', temperature_c: 24 },
|
||||
gold: { status: 'unavailable', symbol: 'Au99.99' },
|
||||
} })
|
||||
expect(partial.textContent).toContain('缓存')
|
||||
expect(partial.textContent).toContain('Au99.99 暂不可用')
|
||||
expect(partial.textContent).not.toContain('环境信息加载中')
|
||||
})
|
||||
|
||||
it('keeps existing values visible while a refresh is in progress', async () => {
|
||||
const host = await mountStrip({ environment, loading: true })
|
||||
expect(host.textContent).toContain('27°C')
|
||||
expect(host.textContent).toContain('Open-Meteo · 09:00')
|
||||
expect(host.textContent).toContain('¥782.35/g')
|
||||
expect(host.querySelector('.today-environment')?.getAttribute('aria-busy')).toBe('true')
|
||||
const status = host.querySelector('.today-environment__status')
|
||||
expect(status?.getAttribute('role')).toBe('status')
|
||||
expect(status?.getAttribute('aria-live')).toBe('polite')
|
||||
expect(status?.textContent).toContain('更新中')
|
||||
})
|
||||
|
||||
it('announces refresh failure through the same live status slot', async () => {
|
||||
const host = await mountStrip({ environment, failed: true })
|
||||
const status = host.querySelector('.today-environment__status')
|
||||
expect(status?.getAttribute('role')).toBe('status')
|
||||
expect(status?.getAttribute('aria-live')).toBe('polite')
|
||||
expect(status?.textContent).toContain('更新失败')
|
||||
expect(host.querySelectorAll('.today-environment__refreshing')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps refresh announcements out of the visual row layout', () => {
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/style.css'), 'utf8')
|
||||
expect(css).toContain('.today-environment__status:not(.today-environment__state){position:absolute;width:1px;height:1px;')
|
||||
expect(css).toContain('@container(max-width:559px){.today-environment{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);')
|
||||
expect(css).toContain('.today-environment__item{display:flex;gap:4px;overflow:hidden}')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
export type EnvironmentStatus = 'fresh' | 'stale' | 'unavailable'
|
||||
type CalendarValue = { solar_date: string; weekday: string; lunar_date: string }
|
||||
type ApiDateValue = { solar_date: string; weekday: string; lunar: string; timezone?: string }
|
||||
type WeatherValue = {
|
||||
status?: EnvironmentStatus
|
||||
location?: string
|
||||
text?: string | null
|
||||
temperature_c?: number | null
|
||||
weather_code?: number | null
|
||||
observed_at?: string | null
|
||||
source?: string | null
|
||||
stale?: boolean
|
||||
}
|
||||
type GoldValue = {
|
||||
status?: EnvironmentStatus
|
||||
symbol?: string
|
||||
contract?: string
|
||||
price_cny_per_gram?: number | null
|
||||
latest_price?: number | string | null
|
||||
market_date?: string | null
|
||||
source?: string | null
|
||||
quoted_at?: string | null
|
||||
delayed?: boolean
|
||||
stale?: boolean
|
||||
}
|
||||
export type TodayEnvironment = {
|
||||
calendar?: CalendarValue
|
||||
date?: ApiDateValue
|
||||
weather: WeatherValue | null
|
||||
gold: GoldValue | null
|
||||
errors?: Record<string, string>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
environment: TodayEnvironment | null
|
||||
loading?: boolean
|
||||
failed?: boolean
|
||||
}>(), { loading: false, failed: false })
|
||||
|
||||
const calendar = computed<CalendarValue | null>(() => {
|
||||
if (props.environment?.calendar) return props.environment.calendar
|
||||
const value = props.environment?.date
|
||||
return value ? { solar_date: value.solar_date, weekday: value.weekday, lunar_date: value.lunar } : null
|
||||
})
|
||||
const weatherStatus = computed<EnvironmentStatus>(() => {
|
||||
if (!props.environment?.weather) return 'unavailable'
|
||||
return props.environment.weather.status ?? (props.environment.weather.stale ? 'stale' : 'fresh')
|
||||
})
|
||||
const goldStatus = computed<EnvironmentStatus>(() => {
|
||||
if (!props.environment?.gold) return 'unavailable'
|
||||
return props.environment.gold.status ?? (props.environment.gold.stale ? 'stale' : 'fresh')
|
||||
})
|
||||
const weatherText = computed(() => {
|
||||
const explicit = props.environment?.weather?.text
|
||||
if (explicit) return explicit
|
||||
const code = props.environment?.weather?.weather_code
|
||||
if (code == null) return '天气暂缺'
|
||||
if (code === 0) return '晴'
|
||||
if ([1, 2, 3].includes(code)) return '多云'
|
||||
if ([45, 48].includes(code)) return '雾'
|
||||
if (code >= 51 && code <= 67 || code >= 80 && code <= 82) return '雨'
|
||||
if (code >= 71 && code <= 77 || code >= 85) return '雪'
|
||||
if (code >= 95) return '雷雨'
|
||||
return '天气暂缺'
|
||||
})
|
||||
const goldPrice = computed(() => {
|
||||
const value = props.environment?.gold?.price_cny_per_gram ?? props.environment?.gold?.latest_price
|
||||
const numeric = typeof value === 'string' ? Number(value) : value
|
||||
return typeof numeric === 'number' && Number.isFinite(numeric) ? numeric.toFixed(2) : null
|
||||
})
|
||||
const weatherObservation = computed(() => {
|
||||
const value = props.environment?.weather
|
||||
if (!value || weatherStatus.value === 'unavailable') return ''
|
||||
const source = value.source || 'Open-Meteo'
|
||||
if (!value.observed_at) return source
|
||||
const observed = new Date(value.observed_at)
|
||||
if (Number.isNaN(observed.getTime())) return source
|
||||
return `${source} · ${new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour: '2-digit', minute: '2-digit', hour12: false }).format(observed)}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="today-environment" aria-label="今日环境信息" :aria-busy="loading">
|
||||
<span v-if="!environment && loading" class="today-environment__state today-environment__status" role="status" aria-live="polite">环境信息加载中…</span>
|
||||
<span v-else-if="!environment" class="today-environment__state today-environment__status" role="status" aria-live="polite">环境信息暂不可用</span>
|
||||
<template v-else>
|
||||
<span v-if="calendar" class="today-environment__item today-environment__calendar">
|
||||
<strong>{{ calendar.solar_date }} {{ calendar.weekday }}</strong>
|
||||
<small>{{ calendar.lunar_date }}</small>
|
||||
</span>
|
||||
<span class="today-environment__item today-environment__weather" :class="`is-${weatherStatus}`">
|
||||
<strong v-if="weatherStatus !== 'unavailable'">{{ environment.weather?.location || '宁波海曙' }} · {{ weatherText }}<template v-if="environment.weather?.temperature_c != null"> {{ environment.weather.temperature_c }}°C</template></strong>
|
||||
<strong v-else>宁波海曙天气暂不可用</strong>
|
||||
<small v-if="weatherStatus !== 'unavailable'">{{ weatherObservation }}<template v-if="weatherStatus === 'stale'"> · 缓存</template></small>
|
||||
</span>
|
||||
<span class="today-environment__item today-environment__gold" :class="`is-${goldStatus}`">
|
||||
<strong v-if="goldStatus !== 'unavailable' && goldPrice">{{ environment.gold?.symbol || environment.gold?.contract || 'Au99.99' }} ¥{{ goldPrice }}/g</strong>
|
||||
<strong v-else>Au99.99 暂不可用</strong>
|
||||
<small><template v-if="goldStatus !== 'unavailable'">上金所延时<template v-if="environment.gold?.market_date"> · {{ environment.gold.market_date }}</template></template><template v-if="goldStatus === 'stale'"> · 缓存</template></small>
|
||||
</span>
|
||||
<span class="today-environment__status" role="status" aria-live="polite"><template v-if="loading">更新中…</template><template v-else-if="failed">更新失败,显示上次信息</template></span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user