feat: add Today environment summary
ci / gitleaks (push) Successful in 51s
ci / docker (push) Successful in 4m34s

This commit is contained in:
2026-09-16 12:23:59 +08:00
parent ed6d999ffb
commit 0a4d5f2f05
12 changed files with 1405 additions and 7 deletions
+6
View File
@@ -69,6 +69,7 @@ from .schemas import (
UserOut, UserOut,
UserUpdate, UserUpdate,
) )
from .today_environment import get_environment as get_today_environment
@asynccontextmanager @asynccontextmanager
@@ -195,6 +196,11 @@ async def me(user: User = Depends(current_user)):
return user return user
@app.get("/api/v1/today/environment")
async def today_environment(_: User = Depends(current_user)):
return await get_today_environment()
@app.patch("/api/v1/me", response_model=UserOut) @app.patch("/api/v1/me", response_model=UserOut)
async def update_me( async def update_me(
payload: UserUpdate, payload: UserUpdate,
+316
View File
@@ -0,0 +1,316 @@
import asyncio
import logging
import math
import re
import threading
import weakref
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal, InvalidOperation
from html import unescape
from typing import Any
from zoneinfo import ZoneInfo
import httpx
from .lunar_support import solar_to_lunar_text
SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
WEATHER_URL = "https://api.open-meteo.com/v1/forecast"
GOLD_URL = "https://www.sge.com.cn/sjzx/yshqbg"
WEATHER_SOURCE = "Open-Meteo"
GOLD_SOURCE = "上海黄金交易所"
WEEKDAYS = ("星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日")
SOURCE_TIMEOUT_SECONDS = 3.0
TOTAL_TIMEOUT_SECONDS = 3.5
WEATHER_TTL = timedelta(minutes=15)
WEATHER_STALE_TTL = timedelta(hours=6)
GOLD_TTL = timedelta(minutes=5)
GOLD_STALE_TTL = timedelta(days=7)
logger = logging.getLogger(__name__)
_TAG_RE = re.compile(r"<[^>]+>")
_DATE_RE = re.compile(r"上海黄金交易所\s*(\d{4})年(\d{2})月(\d{2})日\s*延时行情")
_ROW_RE = re.compile(r"<tr\b[^>]*>(.*?)</tr>", re.IGNORECASE | re.DOTALL)
_CELL_RE = re.compile(r"<t[dh]\b[^>]*>(.*?)</t[dh]>", re.IGNORECASE | re.DOTALL)
@dataclass
class CacheEntry:
value: dict[str, Any]
fetched_at: datetime
@dataclass
class LoopRuntime:
inflight: dict[tuple[int, str], asyncio.Task[dict[str, Any]]]
_cache: dict[str, CacheEntry] = {}
_generation = 0
_state_guard = threading.Lock()
_runtimes: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, LoopRuntime] = (
weakref.WeakKeyDictionary()
)
def _runtime(loop: asyncio.AbstractEventLoop | None = None) -> LoopRuntime:
active_loop = loop or asyncio.get_running_loop()
with _state_guard:
runtime = _runtimes.get(active_loop)
if runtime is None:
runtime = LoopRuntime(inflight={})
_runtimes[active_loop] = runtime
return runtime
def clear_cache() -> None:
global _generation
with _state_guard:
_generation += 1
_cache.clear()
def _text(fragment: str) -> str:
return " ".join(unescape(_TAG_RE.sub(" ", fragment)).split())
def parse_sge_au9999(html: str) -> tuple[Decimal, str]:
date_match = _DATE_RE.search(_text(html))
if date_match is None:
raise ValueError("SGE market date not found")
try:
year, month, day = map(int, date_match.groups())
market_date = date(year, month, day).isoformat()
except ValueError as exc:
raise ValueError("invalid SGE market date") from exc
rows = [[_text(cell) for cell in _CELL_RE.findall(row)] for row in _ROW_RE.findall(html)]
header = next((cells for cells in rows if "合约" in cells and "最新价" in cells), None)
if header is None:
raise ValueError("SGE quote table header not found")
contract_index = header.index("合约")
price_index = header.index("最新价")
required_length = max(contract_index, price_index) + 1
for cells in rows[rows.index(header) + 1 :]:
if len(cells) < required_length or cells[contract_index] != "Au99.99":
continue
try:
price = Decimal(cells[price_index].replace(",", ""))
except InvalidOperation as exc:
raise ValueError("invalid Au99.99 latest price") from exc
if not price.is_finite() or price <= 0:
raise ValueError("invalid Au99.99 latest price")
return price, market_date
raise ValueError("Au99.99 quote not found")
async def fetch_weather(request: Callable[..., Awaitable[dict[str, Any]]]) -> dict[str, Any]:
payload = await request(
WEATHER_URL,
params={
"latitude": 29.88,
"longitude": 121.55,
"current": "temperature_2m,apparent_temperature,weather_code",
"timezone": "Asia/Shanghai",
},
)
if payload.get("timezone") != "Asia/Shanghai":
raise ValueError("weather timezone must be Asia/Shanghai")
current = payload["current"]
if not isinstance(current, dict):
raise TypeError("weather current must be an object")
def finite_number(name: str, minimum: float, maximum: float) -> int | float:
value = current[name]
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"weather {name} must be numeric")
if not math.isfinite(value) or not minimum <= value <= maximum:
raise ValueError(f"weather {name} out of range")
return value
temperature = finite_number("temperature_2m", -100, 100)
apparent_temperature = finite_number("apparent_temperature", -100, 100)
weather_code = current["weather_code"]
if isinstance(weather_code, bool) or not isinstance(weather_code, int):
raise TypeError("weather weather_code must be an integer")
if not 0 <= weather_code <= 99:
raise ValueError("weather weather_code out of range")
raw_time = current["time"]
if not isinstance(raw_time, str):
raise TypeError("weather time must be a string")
observed_at = datetime.fromisoformat(raw_time)
if observed_at.tzinfo is not None:
if observed_at.utcoffset() != SHANGHAI_TZ.utcoffset(observed_at):
raise ValueError("weather time must use Asia/Shanghai")
observed_at = observed_at.astimezone(SHANGHAI_TZ)
else:
observed_at = observed_at.replace(tzinfo=SHANGHAI_TZ)
return {
"temperature_c": temperature,
"apparent_temperature_c": apparent_temperature,
"weather_code": weather_code,
"observed_at": observed_at.isoformat(),
"source": WEATHER_SOURCE,
}
async def fetch_gold(request: Callable[..., Awaitable[str]]) -> dict[str, Any]:
price, market_date = parse_sge_au9999(await request(GOLD_URL))
return {
"contract": "Au99.99",
"latest_price": price,
"currency": "CNY",
"unit": "gram",
"market_date": market_date,
"delayed": True,
"source": GOLD_SOURCE,
}
async def _request_json(client: httpx.AsyncClient, url: str, **kwargs: Any) -> dict[str, Any]:
response = await client.get(url, **kwargs)
response.raise_for_status()
return response.json()
async def _request_text(client: httpx.AsyncClient, url: str, **kwargs: Any) -> str:
response = await client.get(url, **kwargs)
response.raise_for_status()
return response.text
def _serializable(value: dict[str, Any]) -> dict[str, Any]:
return {key: str(item) if isinstance(item, Decimal) else item for key, item in value.items()}
async def _refresh_source(
name: str,
fetcher: Callable[[], Awaitable[dict[str, Any]]],
fetched_at: datetime,
generation: int,
) -> dict[str, Any]:
value = await asyncio.wait_for(fetcher(), timeout=SOURCE_TIMEOUT_SECONDS)
with _state_guard:
if generation == _generation:
newest = _cache.get(name)
if newest is None or newest.fetched_at <= fetched_at:
_cache[name] = CacheEntry(value=value, fetched_at=fetched_at)
return value
def _source_error_code(exc: BaseException) -> str:
if isinstance(exc, TimeoutError):
return "timeout"
if isinstance(exc, (KeyError, TypeError, ValueError, InvalidOperation)):
return "invalid_upstream_response"
return "upstream_unavailable"
def _finish_inflight(
runtime: LoopRuntime,
key: tuple[int, str],
task: asyncio.Task[dict[str, Any]],
) -> None:
# Calling exception() marks failures as retrieved even when every shielded waiter
# was cancelled. This synchronous callback cannot create an untracked cleanup task.
if not task.cancelled():
try:
task.exception()
except asyncio.CancelledError:
pass
if runtime.inflight.get(key) is task:
runtime.inflight.pop(key, None)
async def _cached_source(
name: str,
fetcher: Callable[[], Awaitable[dict[str, Any]]],
now: datetime,
fresh_for: timedelta,
stale_for: timedelta,
) -> tuple[dict[str, Any] | None, str | None]:
runtime = _runtime()
with _state_guard:
generation = _generation
key = (generation, name)
with _state_guard:
cached = _cache.get(name) if generation == _generation else None
if cached is not None and now - cached.fetched_at <= fresh_for:
return {**_serializable(cached.value), "stale": False}, None
task = runtime.inflight.get(key)
if task is None:
task = asyncio.create_task(_refresh_source(name, fetcher, now, generation))
runtime.inflight[key] = task
task.add_done_callback(
lambda completed, active=runtime, task_key=key: _finish_inflight(
active, task_key, completed
)
)
try:
value = await asyncio.shield(task)
except Exception as exc:
error_code = _source_error_code(exc)
logger.warning("Today environment source %s failed (%s)", name, error_code, exc_info=exc)
with _state_guard:
generation_is_current = generation == _generation
if generation_is_current and cached is not None and now - cached.fetched_at <= stale_for:
return {**_serializable(cached.value), "stale": True}, error_code
return None, error_code
return {**_serializable(value), "stale": False}, None
async def _default_weather_fetcher() -> dict[str, Any]:
async with httpx.AsyncClient(
timeout=httpx.Timeout(SOURCE_TIMEOUT_SECONDS),
headers={"User-Agent": "dodo/0.1 (+https://dodo.bboy.app)"},
) as client:
return await fetch_weather(lambda url, **kwargs: _request_json(client, url, **kwargs))
async def _default_gold_fetcher() -> dict[str, Any]:
async with httpx.AsyncClient(
timeout=httpx.Timeout(SOURCE_TIMEOUT_SECONDS),
headers={"User-Agent": "dodo/0.1 (+https://dodo.bboy.app)"},
) as client:
return await fetch_gold(lambda url, **kwargs: _request_text(client, url, **kwargs))
async def get_environment(
*,
now: datetime | None = None,
weather_fetcher: Callable[[], Awaitable[dict[str, Any]]] | None = None,
gold_fetcher: Callable[[], Awaitable[dict[str, Any]]] | None = None,
) -> dict[str, Any]:
current = now or datetime.now(UTC)
local_date = current.astimezone(SHANGHAI_TZ).date()
weather_task = _cached_source(
"weather", weather_fetcher or _default_weather_fetcher, current, WEATHER_TTL, WEATHER_STALE_TTL
)
gold_task = _cached_source(
"gold", gold_fetcher or _default_gold_fetcher, current, GOLD_TTL, GOLD_STALE_TTL
)
weather_result, gold_result = await asyncio.wait_for(
asyncio.gather(weather_task, gold_task), timeout=TOTAL_TIMEOUT_SECONDS
)
weather, weather_error = weather_result
gold, gold_error = gold_result
errors = {}
if weather_error:
errors["weather"] = weather_error
if gold_error:
errors["gold"] = gold_error
return {
"date": {
"solar_date": local_date.isoformat(),
"weekday": WEEKDAYS[local_date.weekday()],
"lunar": solar_to_lunar_text(local_date),
"timezone": "Asia/Shanghai",
},
"weather": weather,
"gold": gold,
"errors": errors,
}
+40 -1
View File
@@ -21,7 +21,8 @@ import FloatingAddButton from './components/FloatingAddButton.vue'
import CompletedFilterPill from './components/CompletedFilterPill.vue' import CompletedFilterPill from './components/CompletedFilterPill.vue'
import CalendarPicker from './components/CalendarPicker.vue' import CalendarPicker from './components/CalendarPicker.vue'
import TaskDueDisplay from './components/TaskDueDisplay.vue' import TaskDueDisplay from './components/TaskDueDisplay.vue'
import { useTaskDueClock } from './lib/task-due-clock' import TodayEnvironmentStrip, { type TodayEnvironment } from './components/TodayEnvironmentStrip.vue'
import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse' import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse'
type FolderItem = { id: string; name: string } type FolderItem = { id: string; name: string }
@@ -108,6 +109,10 @@ const todayTaskTotal = ref(0)
const todayTaskCompleted = ref(0) const todayTaskCompleted = ref(0)
const todayHabitTotal = ref(0) const todayHabitTotal = ref(0)
const todayHabitCompleted = ref(0) const todayHabitCompleted = ref(0)
const todayEnvironment = ref<TodayEnvironment | null>(null)
const todayEnvironmentLoading = ref(false)
const todayEnvironmentError = ref(false)
const todayEnvironmentDateKey = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize))) const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize)))
const taskReorderAvailable = computed(() => activeView.value === 'tasks' && !query.value && totalPages.value === 1 && taskTree.value.length > 1) const taskReorderAvailable = computed(() => activeView.value === 'tasks' && !query.value && totalPages.value === 1 && taskTree.value.length > 1)
const expandedFolders = ref(new Set<string>()) const expandedFolders = ref(new Set<string>())
@@ -157,6 +162,7 @@ const selectedRepeatConfig = ref<TaskRepeatConfig>(defaultRepeatConfig())
const weekdayOptions = [{ value: 'MO', label: '一' }, { value: 'TU', label: '二' }, { value: 'WE', label: '三' }, { value: 'TH', label: '四' }, { value: 'FR', label: '五' }, { value: 'SA', label: '六' }, { value: 'SU', label: '日' }] const weekdayOptions = [{ value: 'MO', label: '一' }, { value: 'TU', label: '二' }, { value: 'WE', label: '三' }, { value: 'TH', label: '四' }, { value: 'FR', label: '五' }, { value: 'SA', label: '六' }, { value: 'SU', label: '日' }]
let recurrenceLoadToken = 0 let recurrenceLoadToken = 0
let todaySummaryLoadToken = 0 let todaySummaryLoadToken = 0
let todayEnvironmentLoadToken = 0
const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null) const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null)
const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null) const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null)
const memoPanel = ref<InstanceType<typeof MemoPanel> | null>(null) const memoPanel = ref<InstanceType<typeof MemoPanel> | null>(null)
@@ -534,6 +540,27 @@ async function loadTodayTaskSummary() {
todayTaskTotal.value = overdue + open + completed todayTaskTotal.value = overdue + open + completed
} catch { /* 概览统计失败不阻断今天页 */ } } catch { /* 概览统计失败不阻断今天页 */ }
} }
async function loadTodayEnvironment() {
const token = ++todayEnvironmentLoadToken
todayEnvironmentLoading.value = true
todayEnvironmentError.value = false
try {
const data = await api('/today/environment') as TodayEnvironment
if (token !== todayEnvironmentLoadToken || activeView.value !== 'today') return
todayEnvironment.value = data
todayEnvironmentDateKey.value = data.date?.solar_date || shanghaiDateKey()
} catch {
if (token !== todayEnvironmentLoadToken || activeView.value !== 'today') return
todayEnvironmentError.value = true
} finally {
if (token === todayEnvironmentLoadToken && activeView.value === 'today') todayEnvironmentLoading.value = false
}
}
function handleTodayEnvironmentResume() {
if (document.visibilityState === 'hidden' || activeView.value !== 'today') return
if (todayEnvironmentDateKey.value !== shanghaiDateKey()) void loadTodayEnvironment()
}
watchShanghaiDateRollover(taskDueNowMs, handleTodayEnvironmentResume)
async function loadOverdueTasks(request = beginLatestRequest('tasks')) { async function loadOverdueTasks(request = beginLatestRequest('tasks')) {
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('due_to', isoAtLocalDayOffset(0)) params.set('due_to', isoAtLocalDayOffset(0))
@@ -601,6 +628,7 @@ async function loadAll() {
if (!navigationLoaded.value) await loadNavigation() if (!navigationLoaded.value) await loadNavigation()
if (!isLatestRequest('tasks', request)) return if (!isLatestRequest('tasks', request)) return
if (activeView.value === 'today') { if (activeView.value === 'today') {
void loadTodayEnvironment()
await startPrimaryWithBackground( await startPrimaryWithBackground(
[() => loadTasksPage(request), () => loadOverdueTasks(request)], [() => loadTasksPage(request), () => loadOverdueTasks(request)],
loadTodayTaskSummary, loadTodayTaskSummary,
@@ -655,6 +683,10 @@ async function switchView(view: View, listId?: string) {
taskMutationNavigation.value += 1 taskMutationNavigation.value += 1
taskReorderMode.value = false taskReorderMode.value = false
cancelTaskReorder() cancelTaskReorder()
if (view !== 'today') {
++todayEnvironmentLoadToken
todayEnvironmentLoading.value = false
}
activeView.value = view activeView.value = view
if (!query.value) mobileSearchOpen.value = false if (!query.value) mobileSearchOpen.value = false
searchPullDistance.value = 0 searchPullDistance.value = 0
@@ -1376,6 +1408,9 @@ onMounted(() => {
document.addEventListener('keydown', handleArchivedListEscape) document.addEventListener('keydown', handleArchivedListEscape)
document.addEventListener('keydown', handleTaskSearchShortcut) document.addEventListener('keydown', handleTaskSearchShortcut)
window.addEventListener('resize', handleViewportResize) window.addEventListener('resize', handleViewportResize)
document.addEventListener('visibilitychange', handleTodayEnvironmentResume)
window.addEventListener('focus', handleTodayEnvironmentResume)
window.addEventListener('pageshow', handleTodayEnvironmentResume)
document.addEventListener('scroll', handleArchivedListViewportChange, true) document.addEventListener('scroll', handleArchivedListViewportChange, true)
void bootstrap() void bootstrap()
}) })
@@ -1384,6 +1419,9 @@ onUnmounted(() => {
document.removeEventListener('keydown', handleArchivedListEscape) document.removeEventListener('keydown', handleArchivedListEscape)
document.removeEventListener('keydown', handleTaskSearchShortcut) document.removeEventListener('keydown', handleTaskSearchShortcut)
window.removeEventListener('resize', handleViewportResize) window.removeEventListener('resize', handleViewportResize)
document.removeEventListener('visibilitychange', handleTodayEnvironmentResume)
window.removeEventListener('focus', handleTodayEnvironmentResume)
window.removeEventListener('pageshow', handleTodayEnvironmentResume)
document.removeEventListener('scroll', handleArchivedListViewportChange, true) document.removeEventListener('scroll', handleArchivedListViewportChange, true)
}) })
</script> </script>
@@ -1491,6 +1529,7 @@ onUnmounted(() => {
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" /> <CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
<template v-else> <template v-else>
<section v-if="activeView==='today'" class="today-board" aria-label="今日进度"> <section v-if="activeView==='today'" class="today-board" aria-label="今日进度">
<TodayEnvironmentStrip :environment="todayEnvironment" :loading="todayEnvironmentLoading" :failed="todayEnvironmentError" />
<button class="today-track today-task-track" type="button" aria-controls="today-tasks" @click="navigateTodaySection('tasks')"> <button class="today-track today-task-track" type="button" aria-controls="today-tasks" @click="navigateTodaySection('tasks')">
<span class="today-track-head"><strong>任务 {{ todayTaskCompleted }} / {{ todayTaskTotal }}</strong></span> <span class="today-track-head"><strong>任务 {{ todayTaskCompleted }} / {{ todayTaskTotal }}</strong></span>
<span class="today-track-rail" role="progressbar" aria-label="今日任务进度" aria-valuemin="0" :aria-valuemax="todayTaskTotal" :aria-valuenow="todayTaskCompleted"><i class="today-track-fill" :style="{ width: todayTaskProgressPercent }" /></span> <span class="today-track-rail" role="progressbar" aria-label="今日任务进度" aria-valuemin="0" :aria-valuemax="todayTaskTotal" :aria-valuenow="todayTaskCompleted"><i class="today-track-fill" :style="{ width: todayTaskProgressPercent }" /></span>
+55
View File
@@ -0,0 +1,55 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const app = readFileSync('src/App.vue', 'utf8')
const css = readFileSync('src/style.css', 'utf8')
describe('Today environment integration', () => {
it('loads environment independently alongside Today task work', () => {
expect(app).toContain("import TodayEnvironmentStrip, { type TodayEnvironment } from './components/TodayEnvironmentStrip.vue'")
expect(app).toContain('let todayEnvironmentLoadToken = 0')
expect(app).toContain("api('/today/environment')")
expect(app).toContain('void loadTodayEnvironment()')
expect(app).toContain('++todayEnvironmentLoadToken')
expect(app).toContain("activeView.value !== 'today'")
expect(app).toContain('<TodayEnvironmentStrip :environment="todayEnvironment" :loading="todayEnvironmentLoading" :failed="todayEnvironmentError" />')
})
it('keeps environment failures local and retains old values during refresh', () => {
const loader = app.slice(app.indexOf('async function loadTodayEnvironment'), app.indexOf('async function loadOverdueTasks'))
expect(loader).toContain('todayEnvironmentLoading.value = true')
expect(loader).not.toContain('todayEnvironment.value = null')
expect(loader).not.toContain('fail(')
expect(loader).not.toContain('toast(')
expect(loader).toContain('todayEnvironmentError.value = true')
})
it('refreshes Today environment on Shanghai date rollover and page restoration', () => {
expect(app).toContain("import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'")
expect(app).toContain('watchShanghaiDateRollover(taskDueNowMs, handleTodayEnvironmentResume)')
expect(app).toContain('todayEnvironmentDateKey')
expect(app).toContain("document.addEventListener('visibilitychange', handleTodayEnvironmentResume)")
expect(app).toContain("window.addEventListener('focus', handleTodayEnvironmentResume)")
expect(app).toContain("window.addEventListener('pageshow', handleTodayEnvironmentResume)")
expect(app).toContain("document.removeEventListener('visibilitychange', handleTodayEnvironmentResume)")
expect(app).toContain("window.removeEventListener('focus', handleTodayEnvironmentResume)")
expect(app).toContain("window.removeEventListener('pageshow', handleTodayEnvironmentResume)")
expect(app).toContain('if (document.visibilityState === \'hidden\' || activeView.value !== \'today\') return')
expect(app).toContain('if (todayEnvironmentDateKey.value !== shanghaiDateKey()) void loadTodayEnvironment()')
})
it('keeps mobile environment copy to two rows including refresh states', () => {
expect(css).toMatch(/@container\(max-width:559px\)\{\.today-environment\{[^}]*grid-template-rows:repeat\(2,minmax\(0,auto\)\)/)
expect(css).toMatch(/\.today-environment__status\{[^}]*grid-row:1/)
expect(css).not.toContain('.today-environment__refreshing')
})
it('places one full-width information row inside the existing cream board', () => {
expect(app.match(/class="today-board"/g)).toHaveLength(1)
expect(css).toMatch(/\.today-environment\{[^}]*grid-column:1\/-1/)
expect(css).toMatch(/@container\(min-width:560px\)\{\.today-environment\{[^}]*flex-wrap:nowrap/)
expect(css).toMatch(/@container\(max-width:559px\)\{\.today-environment\{[^}]*grid-template-columns:/)
expect(css).toMatch(/\.today-environment__item\{[^}]*min-width:0/)
expect(css).not.toMatch(/\.today-environment[^}]*overflow-x:auto/)
})
})
@@ -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">&nbsp;{{ 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>
+16 -2
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h } from 'vue' import { createApp, h } from 'vue'
import { useTaskDueClock } from './task-due-clock' import { useTaskDueClock, watchShanghaiDateRollover } from './task-due-clock'
const cleanups: Array<() => void> = [] const cleanups: Array<() => void> = []
@@ -10,12 +10,13 @@ afterEach(() => {
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
function mountClock() { function mountClock(onShanghaiDateRollover?: () => void) {
let clock: ReturnType<typeof useTaskDueClock> | undefined let clock: ReturnType<typeof useTaskDueClock> | undefined
const host = document.createElement('div') const host = document.createElement('div')
const app = createApp({ const app = createApp({
setup() { setup() {
clock = useTaskDueClock() clock = useTaskDueClock()
if (onShanghaiDateRollover) watchShanghaiDateRollover(clock, onShanghaiDateRollover)
return () => h('span', String(clock!.value)) return () => h('span', String(clock!.value))
}, },
}) })
@@ -65,6 +66,19 @@ describe('useTaskDueClock', () => {
expect(vi.getTimerCount()).toBe(1) expect(vi.getTimerCount()).toBe(1)
}) })
it('refreshes once when the shared visible clock crosses Shanghai midnight', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-16T15:59:30.000Z'))
const refresh = vi.fn()
mountClock(refresh)
vi.advanceTimersByTime(30_000)
expect(refresh).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(60_000)
expect(refresh).toHaveBeenCalledTimes(1)
})
it('removes visibility, focus and pageshow listeners and clears the timer on unmount', () => { it('removes visibility, focus and pageshow listeners and clears the timer on unmount', () => {
vi.useFakeTimers() vi.useFakeTimers()
const removeDocument = vi.spyOn(document, 'removeEventListener') const removeDocument = vi.spyOn(document, 'removeEventListener')
+20 -1
View File
@@ -1,4 +1,23 @@
import { onMounted, onUnmounted, ref, type Ref } from 'vue' import { onMounted, onUnmounted, ref, watch, type Ref } from 'vue'
export function shanghaiDateKey(nowMs = Date.now()) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date(nowMs))
}
export function watchShanghaiDateRollover(clock: Ref<number>, refresh: () => void) {
let dateKey = shanghaiDateKey(clock.value)
return watch(clock, (nowMs) => {
const nextKey = shanghaiDateKey(nowMs)
if (nextKey === dateKey) return
dateKey = nextKey
refresh()
}, { flush: 'sync' })
}
function nextRefreshDelay(nowMs: number) { function nextRefreshDelay(nowMs: number) {
const now = new Date(nowMs) const now = new Date(nowMs)
+3
View File
@@ -48,6 +48,9 @@ main{container-type:inline-size;min-width:0;padding:27px 34px 50px;overflow:auto
.task-row.just-completed,.habit-row.just-completed{animation:completion-row-settle .34s cubic-bezier(.2,.85,.3,1)}.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:completion-check-pop .38s cubic-bezier(.2,1.4,.35,1)}.task-row.completion-exiting,.habit-row.completion-exiting{pointer-events:none;overflow:hidden;animation:completion-slide-out .32s cubic-bezier(.4,0,.8,.2) forwards}@keyframes completion-slide-out{0%{opacity:1;transform:translate(var(--swipe-x),var(--reorder-y,0px));max-height:180px}68%{opacity:0;transform:translate(calc(100% + 24px),var(--reorder-y,0px));max-height:180px}100%{opacity:0;transform:translate(calc(100% + 24px),var(--reorder-y,0px));max-height:0;min-height:0;padding-top:0;padding-bottom:0;border-width:0}}@keyframes completion-row-settle{0%{background:rgba(113,133,107,.16);transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(1)}45%{background:rgba(113,133,107,.1);transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(.992)}100%{transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(1)}}@keyframes completion-check-pop{0%{transform:scale(.72);box-shadow:0 0 0 0 rgba(113,133,107,.28)}58%{transform:scale(1.18);box-shadow:0 0 0 7px rgba(113,133,107,0)}100%{transform:scale(1);box-shadow:none}}@media(prefers-reduced-motion:reduce){.task-row.just-completed,.habit-row.just-completed,.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:none}.task-row.completion-exiting,.habit-row.completion-exiting{animation:none}} .task-row.just-completed,.habit-row.just-completed{animation:completion-row-settle .34s cubic-bezier(.2,.85,.3,1)}.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:completion-check-pop .38s cubic-bezier(.2,1.4,.35,1)}.task-row.completion-exiting,.habit-row.completion-exiting{pointer-events:none;overflow:hidden;animation:completion-slide-out .32s cubic-bezier(.4,0,.8,.2) forwards}@keyframes completion-slide-out{0%{opacity:1;transform:translate(var(--swipe-x),var(--reorder-y,0px));max-height:180px}68%{opacity:0;transform:translate(calc(100% + 24px),var(--reorder-y,0px));max-height:180px}100%{opacity:0;transform:translate(calc(100% + 24px),var(--reorder-y,0px));max-height:0;min-height:0;padding-top:0;padding-bottom:0;border-width:0}}@keyframes completion-row-settle{0%{background:rgba(113,133,107,.16);transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(1)}45%{background:rgba(113,133,107,.1);transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(.992)}100%{transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(1)}}@keyframes completion-check-pop{0%{transform:scale(.72);box-shadow:0 0 0 0 rgba(113,133,107,.28)}58%{transform:scale(1.18);box-shadow:0 0 0 7px rgba(113,133,107,0)}100%{transform:scale(1);box-shadow:none}}@media(prefers-reduced-motion:reduce){.task-row.just-completed,.habit-row.just-completed,.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:none}.task-row.completion-exiting,.habit-row.completion-exiting{animation:none}}
/* Today environment information strip. */
.today-environment{grid-column:1/-1;min-width:0;display:flex;align-items:center;gap:12px;padding:0 0 12px;border-bottom:1px solid var(--border-cream);color:var(--text-secondary)}.today-environment__item{min-width:0;display:flex;align-items:baseline;gap:6px;white-space:nowrap;overflow:hidden}.today-environment__item strong,.today-environment__item small{overflow:hidden;text-overflow:ellipsis}.today-environment__item strong{color:var(--text-primary);font-size:13px}.today-environment__item small,.today-environment__status,.today-environment__state{color:var(--muted);font-size:11px}.today-environment__status:not(.today-environment__state){position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.today-environment__item+.today-environment__item{padding-left:12px;border-left:1px solid var(--border-cream)}.today-environment__item.is-stale small{color:#9a704c}.today-environment__status{margin-left:auto;white-space:nowrap}.today-environment__status:empty{display:none}.today-environment__state{min-height:22px;display:flex;align-items:center}@container(min-width:560px){.today-environment{flex-wrap:nowrap}.today-environment__calendar{flex:1}.today-environment__weather,.today-environment__gold{flex:0 1 auto}}@container(max-width:559px){.today-environment{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);grid-template-rows:repeat(2,minmax(0,auto));gap:7px 10px}.today-environment__calendar{grid-column:1/-1;grid-row:1}.today-environment__item+.today-environment__item{padding-left:0;border-left:0}.today-environment__weather{grid-column:1;grid-row:2}.today-environment__gold{grid-column:2;grid-row:2}.today-environment__status{grid-column:1/-1;grid-row:1;justify-self:end;max-width:42%;margin-left:0;overflow:hidden;text-overflow:ellipsis}.today-environment__calendar{padding-right:42%}.today-environment__item{display:flex;gap:4px;overflow:hidden}.today-environment__item strong{font-size:12px}.today-environment__item small{font-size:10px}}
/* Solid cream material system: opaque surfaces, warm edges, quiet depth. */ /* Solid cream material system: opaque surfaces, warm edges, quiet depth. */
:root{--surface-canvas:#f3ecdf;--surface-base:#fdfaf3;--surface-raised:#fffdf8;--text-primary:#302a24;--text-secondary:#655b50;--border-cream:#e4d5c0;--highlight-inner:inset 0 1px 0 #fff;--shadow-soft:0 4px 14px rgba(88,67,42,.08);--shadow-raised:0 12px 30px rgba(88,67,42,.12);--focus-ring:rgba(180,66,30,.34);--success:#687e61;--scrim:rgba(45,38,31,.38);--radius-control:11px;--radius-card:14px;--radius-list:15px;--radius-panel:20px;--paper:var(--surface-raised);--sidebar:var(--surface-canvas);--line:var(--border-cream);--muted:var(--text-secondary);--shadow:var(--shadow-raised);--sheet-radius:20px;--sheet-scrim:var(--scrim)} :root{--surface-canvas:#f3ecdf;--surface-base:#fdfaf3;--surface-raised:#fffdf8;--text-primary:#302a24;--text-secondary:#655b50;--border-cream:#e4d5c0;--highlight-inner:inset 0 1px 0 #fff;--shadow-soft:0 4px 14px rgba(88,67,42,.08);--shadow-raised:0 12px 30px rgba(88,67,42,.12);--focus-ring:rgba(180,66,30,.34);--success:#687e61;--scrim:rgba(45,38,31,.38);--radius-control:11px;--radius-card:14px;--radius-list:15px;--radius-panel:20px;--paper:var(--surface-raised);--sidebar:var(--surface-canvas);--line:var(--border-cream);--muted:var(--text-secondary);--shadow:var(--shadow-raised);--sheet-radius:20px;--sheet-scrim:var(--scrim)}
body{color:var(--text-primary);background:var(--surface-canvas);font-variant-numeric:tabular-nums} body{color:var(--text-primary);background:var(--surface-canvas);font-variant-numeric:tabular-nums}
+1 -1
View File
@@ -15,13 +15,13 @@ dependencies = [
"uuid-utils>=0.11,<1", "uuid-utils>=0.11,<1",
"structlog>=25,<26", "structlog>=25,<26",
"lunar-python>=1.2,<2", "lunar-python>=1.2,<2",
"httpx>=0.28,<1",
] ]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=8.4,<9", "pytest>=8.4,<9",
"pytest-asyncio>=1.1,<2", "pytest-asyncio>=1.1,<2",
"httpx>=0.28,<1",
"aiosqlite>=0.21,<1", "aiosqlite>=0.21,<1",
"ruff>=0.12,<1", "ruff>=0.12,<1",
] ]
+741
View File
@@ -0,0 +1,741 @@
import asyncio
import threading
from datetime import UTC, datetime, timedelta
from decimal import Decimal
import pytest
from backend import today_environment as environment
SGE_HTML = """
<html><body>
<h1>上海黄金交易所2026年09月16日延时行情</h1>
<table>
<tr><th>合约</th><th>最新价</th><th>最高价</th><th>最低价</th><th>今开盘</th></tr>
<tr><td>Au99.95</td><td>927.2</td><td>949.8</td><td>927.2</td><td>949.8</td></tr>
<tr><td><span>Au99.99</span></td><td>935.99</td><td>936.4</td><td>924.5</td><td>925.0</td></tr>
</table>
</body></html>
"""
def test_parse_sge_delayed_page_selects_exact_au9999_contract():
quote = environment.parse_sge_au9999(SGE_HTML)
assert quote == (Decimal("935.99"), "2026-09-16")
def test_parse_sge_uses_named_columns_when_the_table_is_reordered():
html = SGE_HTML.replace(
'<tr><th>合约</th><th>最新价</th><th>最高价</th><th>最低价</th><th>今开盘</th></tr>',
'<tr><th>最高价</th><th>合约</th><th>今开盘</th><th>最新价</th></tr>',
).replace(
'<tr><td>Au99.95</td><td>927.2</td><td>949.8</td><td>927.2</td><td>949.8</td></tr>',
'<tr><td>949.8</td><td>Au99.95</td><td>927.2</td><td>927.2</td></tr>',
).replace(
'<tr><td><span>Au99.99</span></td><td>935.99</td><td>936.4</td><td>924.5</td><td>925.0</td></tr>',
'<tr><td>936.4</td><td><span>Au99.99</span></td><td>925.0</td><td>935.99</td></tr>',
)
assert environment.parse_sge_au9999(html) == (Decimal("935.99"), "2026-09-16")
@pytest.mark.parametrize(
"html",
[
SGE_HTML.replace("最新价", "收盘价"),
SGE_HTML.replace("Au99.99", "Au99.999"),
SGE_HTML.replace("935.99", "NaN"),
SGE_HTML.replace("935.99", "Infinity"),
SGE_HTML.replace("935.99", "0"),
SGE_HTML.replace("2026年09月16日", "2026年02月30日"),
],
)
def test_parse_sge_rejects_changed_structure_or_invalid_quote(html):
with pytest.raises(ValueError):
environment.parse_sge_au9999(html)
@pytest.mark.asyncio
async def test_fetch_weather_uses_fixed_haishu_location_and_current_conditions():
seen = {}
async def request(url, **kwargs):
seen["url"] = url
seen["params"] = kwargs["params"]
return {
"timezone": "Asia/Shanghai",
"current": {
"time": "2026-09-16T14:15",
"temperature_2m": 28.4,
"apparent_temperature": 30.1,
"weather_code": 2,
}
}
result = await environment.fetch_weather(request)
assert seen == {
"url": environment.WEATHER_URL,
"params": {
"latitude": 29.88,
"longitude": 121.55,
"current": "temperature_2m,apparent_temperature,weather_code",
"timezone": "Asia/Shanghai",
},
}
assert result == {
"temperature_c": 28.4,
"apparent_temperature_c": 30.1,
"weather_code": 2,
"observed_at": "2026-09-16T14:15:00+08:00",
"source": "Open-Meteo",
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"current",
[
{},
{"time": "2026-09-16T14:15", "temperature_2m": True, "apparent_temperature": 30.1, "weather_code": 2},
{"time": "2026-09-16T14:15", "temperature_2m": float("nan"), "apparent_temperature": 30.1, "weather_code": 2},
{"time": "2026-09-16T14:15", "temperature_2m": 28.4, "apparent_temperature": float("inf"), "weather_code": 2},
{"time": "2026-09-16T14:15", "temperature_2m": 101, "apparent_temperature": 30.1, "weather_code": 2},
{"time": "2026-09-16T14:15", "temperature_2m": 28.4, "apparent_temperature": -101, "weather_code": 2},
{"time": "2026-09-16T14:15", "temperature_2m": 28.4, "apparent_temperature": 30.1, "weather_code": 100},
{"time": "2026-02-30T14:15", "temperature_2m": 28.4, "apparent_temperature": 30.1, "weather_code": 2},
{"time": "2026-09-16T14:15+00:00", "temperature_2m": 28.4, "apparent_temperature": 30.1, "weather_code": 2},
],
)
async def test_fetch_weather_rejects_invalid_contract(current):
async def request(*_args, **_kwargs):
return {"timezone": "Asia/Shanghai", "current": current}
with pytest.raises((KeyError, TypeError, ValueError)):
await environment.fetch_weather(request)
@pytest.mark.asyncio
async def test_fetch_weather_rejects_wrong_response_timezone():
async def request(*_args, **_kwargs):
return {
"timezone": "UTC",
"current": {
"time": "2026-09-16T14:15",
"temperature_2m": 28.4,
"apparent_temperature": 30.1,
"weather_code": 2,
},
}
with pytest.raises(ValueError):
await environment.fetch_weather(request)
@pytest.mark.asyncio
async def test_get_environment_fetches_sources_concurrently_and_formats_gold_as_string():
now = datetime(2026, 9, 16, 8, 30, tzinfo=UTC)
environment.clear_cache()
both_started = asyncio.Event()
started = set()
async def weather():
started.add("weather")
if len(started) == 2:
both_started.set()
await asyncio.wait_for(both_started.wait(), timeout=0.1)
return {"temperature_c": 28.4, "source": "Open-Meteo"}
async def gold():
started.add("gold")
if len(started) == 2:
both_started.set()
await asyncio.wait_for(both_started.wait(), timeout=0.1)
return {
"contract": "Au99.99",
"latest_price": Decimal("935.990"),
"market_date": "2026-09-16",
"delayed": True,
"source": "上海黄金交易所",
}
result = await environment.get_environment(now=now, weather_fetcher=weather, gold_fetcher=gold)
assert result["date"] == {
"solar_date": "2026-09-16",
"weekday": "星期三",
"lunar": "农历八月初六",
"timezone": "Asia/Shanghai",
}
assert result["weather"]["temperature_c"] == 28.4
assert result["weather"]["stale"] is False
assert result["gold"]["latest_price"] == "935.990"
assert result["gold"]["delayed"] is True
assert result["gold"]["stale"] is False
assert result["errors"] == {}
@pytest.mark.asyncio
async def test_each_source_has_independent_ttl_and_stale_if_error():
base = datetime(2026, 9, 16, 8, tzinfo=UTC)
environment.clear_cache()
calls = {"weather": 0, "gold": 0}
async def weather():
calls["weather"] += 1
if calls["weather"] > 1:
raise RuntimeError("weather down")
return {"temperature_c": 25, "source": "Open-Meteo"}
async def gold():
calls["gold"] += 1
if calls["gold"] > 1:
raise RuntimeError("gold down")
return {
"contract": "Au99.99",
"latest_price": Decimal("900.10"),
"market_date": "2026-09-16",
"delayed": True,
"source": "上海黄金交易所",
}
await environment.get_environment(now=base, weather_fetcher=weather, gold_fetcher=gold)
after_ten_minutes = await environment.get_environment(
now=base + timedelta(minutes=10), weather_fetcher=weather, gold_fetcher=gold
)
assert calls == {"weather": 1, "gold": 2}
assert after_ten_minutes["weather"]["stale"] is False
assert after_ten_minutes["gold"]["stale"] is True
assert after_ten_minutes["errors"] == {"gold": "upstream_unavailable"}
after_twenty_minutes = await environment.get_environment(
now=base + timedelta(minutes=20), weather_fetcher=weather, gold_fetcher=gold
)
assert calls == {"weather": 2, "gold": 3}
assert after_twenty_minutes["weather"]["stale"] is True
assert after_twenty_minutes["gold"]["stale"] is True
@pytest.mark.asyncio
async def test_failure_without_stale_cache_returns_partial_success():
environment.clear_cache()
async def weather():
raise RuntimeError("weather down")
async def gold():
return {
"contract": "Au99.99",
"latest_price": Decimal("901.2"),
"market_date": "2026-09-16",
"delayed": True,
"source": "上海黄金交易所",
}
result = await environment.get_environment(weather_fetcher=weather, gold_fetcher=gold)
assert result["weather"] is None
assert result["gold"]["latest_price"] == "901.2"
assert result["errors"] == {"weather": "upstream_unavailable"}
@pytest.mark.asyncio
async def test_expired_stale_values_are_not_returned():
base = datetime(2026, 9, 1, tzinfo=UTC)
environment.clear_cache()
async def weather_ok():
return {"temperature_c": 25, "source": "Open-Meteo"}
async def gold_ok():
return {
"contract": "Au99.99",
"latest_price": Decimal("900.10"),
"market_date": "2026-09-01",
"delayed": True,
"source": "上海黄金交易所",
}
await environment.get_environment(
now=base, weather_fetcher=weather_ok, gold_fetcher=gold_ok
)
async def failed():
raise RuntimeError("down")
result = await environment.get_environment(
now=base + timedelta(days=8), weather_fetcher=failed, gold_fetcher=failed
)
assert result["weather"] is None
assert result["gold"] is None
assert result["errors"] == {
"weather": "upstream_unavailable",
"gold": "upstream_unavailable",
}
@pytest.mark.asyncio
async def test_each_source_single_flights_concurrent_cache_misses():
environment.clear_cache()
release = asyncio.Event()
calls = {"weather": 0, "gold": 0}
async def weather():
calls["weather"] += 1
await release.wait()
return {"temperature_c": 25, "source": "Open-Meteo"}
async def gold():
calls["gold"] += 1
await release.wait()
return {
"contract": "Au99.99",
"latest_price": Decimal("900.10"),
"market_date": "2026-09-16",
"delayed": True,
"source": "上海黄金交易所",
}
tasks = [asyncio.create_task(environment.get_environment(weather_fetcher=weather, gold_fetcher=gold)) for _ in range(5)]
for _ in range(10):
if calls == {"weather": 1, "gold": 1}:
break
await asyncio.sleep(0)
assert calls == {"weather": 1, "gold": 1}
release.set()
results = await asyncio.gather(*tasks)
assert len(results) == 5
assert calls == {"weather": 1, "gold": 1}
@pytest.mark.asyncio
async def test_each_source_single_flights_concurrent_upstream_failures():
environment.clear_cache()
release = asyncio.Event()
calls = {"weather": 0, "gold": 0}
async def failed(name):
calls[name] += 1
await release.wait()
raise RuntimeError(f"{name} down")
tasks = [asyncio.create_task(environment.get_environment(
weather_fetcher=lambda: failed("weather"),
gold_fetcher=lambda: failed("gold"),
)) for _ in range(5)]
for _ in range(10):
if calls == {"weather": 1, "gold": 1}:
break
await asyncio.sleep(0)
assert calls == {"weather": 1, "gold": 1}
release.set()
results = await asyncio.gather(*tasks)
assert calls == {"weather": 1, "gold": 1}
assert all(result["weather"] is None and result["gold"] is None for result in results)
assert all(
result["errors"]
== {"weather": "upstream_unavailable", "gold": "upstream_unavailable"}
for result in results
)
@pytest.mark.asyncio
async def test_slow_old_fetch_cannot_overwrite_a_newer_cache_value():
environment.clear_cache()
old_started = asyncio.Event()
release_old = asyncio.Event()
async def old_weather():
old_started.set()
await release_old.wait()
return {"temperature_c": 10, "source": "Open-Meteo"}
old = asyncio.create_task(environment._cached_source(
"weather", old_weather, datetime(2026, 9, 16, 8, tzinfo=UTC),
environment.WEATHER_TTL, environment.WEATHER_STALE_TTL,
))
await old_started.wait()
# Simulate a cache generation reset while the old upstream request is still running.
# The replacement request must be able to finish first, and the old completion must
# neither clear its in-flight slot nor overwrite its newer value.
environment.clear_cache()
release_new = asyncio.Event()
async def new_weather():
await release_new.wait()
return {"temperature_c": 20, "source": "Open-Meteo"}
newer_task = asyncio.create_task(environment._cached_source(
"weather", new_weather, datetime(2026, 9, 16, 8, 20, tzinfo=UTC),
environment.WEATHER_TTL, environment.WEATHER_STALE_TTL,
))
await asyncio.sleep(0)
release_new.set()
newer, _ = await newer_task
release_old.set()
await old
async def should_not_fetch():
raise AssertionError("new cache value should still be fresh")
cached, _ = await environment._cached_source(
"weather", should_not_fetch, datetime(2026, 9, 16, 8, 21, tzinfo=UTC),
environment.WEATHER_TTL, environment.WEATHER_STALE_TTL,
)
assert newer["temperature_c"] == 20
assert cached["temperature_c"] == 20
@pytest.mark.asyncio
async def test_source_timeout_preserves_other_source_success(monkeypatch):
environment.clear_cache()
monkeypatch.setattr(environment, "SOURCE_TIMEOUT_SECONDS", 0.01)
async def weather():
await asyncio.sleep(1)
return {"temperature_c": 25, "source": "Open-Meteo"}
async def gold():
return {"contract": "Au99.99", "latest_price": Decimal("901.2"), "market_date": "2026-09-16", "delayed": True, "source": "上海黄金交易所"}
result = await environment.get_environment(weather_fetcher=weather, gold_fetcher=gold)
assert result["weather"] is None
assert result["gold"]["latest_price"] == "901.2"
assert result["errors"] == {"weather": "timeout"}
@pytest.mark.asyncio
@pytest.mark.parametrize("failure", ["exception", "timeout"])
async def test_cancelled_waiters_do_not_leak_failed_shared_source(monkeypatch, failure):
environment.clear_cache()
monkeypatch.setattr(environment, "SOURCE_TIMEOUT_SECONDS", 0.01)
started = asyncio.Event()
release = asyncio.Event()
loop_errors = []
loop = asyncio.get_running_loop()
previous_handler = loop.get_exception_handler()
loop.set_exception_handler(lambda _loop, context: loop_errors.append(context))
calls = 0
async def source():
nonlocal calls
calls += 1
started.set()
if failure == "timeout":
await asyncio.sleep(1)
else:
await release.wait()
raise RuntimeError("secret https://provider.invalid/feed")
return {"temperature_c": 25, "source": "Open-Meteo"}
try:
waiters = [
asyncio.create_task(
environment._cached_source(
"weather",
source,
datetime(2026, 9, 16, 8, tzinfo=UTC),
environment.WEATHER_TTL,
environment.WEATHER_STALE_TTL,
)
)
for _ in range(2)
]
await started.wait()
for waiter in waiters:
waiter.cancel()
await asyncio.gather(*waiters, return_exceptions=True)
release.set()
await asyncio.sleep(0.03)
assert all(not runtime.inflight for runtime in environment._runtimes.values())
assert not [
context
for context in loop_errors
if context.get("message") == "Task exception was never retrieved"
]
async def replacement():
nonlocal calls
calls += 1
return {"temperature_c": 26, "source": "Open-Meteo"}
value, error = await environment._cached_source(
"weather",
replacement,
datetime(2026, 9, 16, 8, 20, tzinfo=UTC),
environment.WEATHER_TTL,
environment.WEATHER_STALE_TTL,
)
assert value["temperature_c"] == 26
assert error is None
assert calls == 2
finally:
loop.set_exception_handler(previous_handler)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("exc", "expected_code"),
[
(TimeoutError("secret timeout https://provider.invalid"), "timeout"),
(ValueError("secret malformed payload https://provider.invalid"), "invalid_upstream_response"),
(RuntimeError("secret outage https://provider.invalid"), "upstream_unavailable"),
],
)
async def test_source_errors_use_stable_codes_without_exception_details(exc, expected_code):
environment.clear_cache()
async def failed():
raise exc
value, error = await environment._cached_source(
"weather",
failed,
datetime(2026, 9, 16, 8, tzinfo=UTC),
environment.WEATHER_TTL,
environment.WEATHER_STALE_TTL,
)
assert value is None
assert error == expected_code
assert "secret" not in error
assert "provider.invalid" not in error
@pytest.mark.asyncio
async def test_stale_source_error_uses_stable_code_without_exception_details():
base = datetime(2026, 9, 16, 8, tzinfo=UTC)
environment.clear_cache()
async def initial():
return {"temperature_c": 25, "source": "Open-Meteo"}
await environment._cached_source(
"weather", initial, base, environment.WEATHER_TTL, environment.WEATHER_STALE_TTL
)
async def failed():
raise RuntimeError("secret https://provider.invalid/feed")
value, error = await environment._cached_source(
"weather",
failed,
base + timedelta(minutes=20),
environment.WEATHER_TTL,
environment.WEATHER_STALE_TTL,
)
assert value["temperature_c"] == 25
assert value["stale"] is True
assert error == "upstream_unavailable"
@pytest.mark.asyncio
async def test_creator_cancellation_does_not_close_shared_default_fetch_client(monkeypatch):
environment.clear_cache()
started = asyncio.Event()
release = asyncio.Event()
calls = {"weather": 0, "gold": 0}
class Response:
def raise_for_status(self):
return None
def json(self):
return {
"timezone": "Asia/Shanghai",
"current": {
"time": "2026-09-16T14:15",
"temperature_2m": 25,
"apparent_temperature": 26,
"weather_code": 1,
},
}
@property
def text(self):
return SGE_HTML
class Client:
closed = False
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
self.closed = True
async def get(self, url, **_kwargs):
source = "weather" if url == environment.WEATHER_URL else "gold"
calls[source] += 1
if calls == {"weather": 1, "gold": 1}:
started.set()
await release.wait()
if self.closed:
raise RuntimeError("client closed while shared refresh was running")
return Response()
monkeypatch.setattr(environment.httpx, "AsyncClient", lambda **_kwargs: Client())
creator = asyncio.create_task(environment.get_environment())
await started.wait()
survivor = asyncio.create_task(environment.get_environment())
await asyncio.sleep(0)
creator.cancel()
with pytest.raises(asyncio.CancelledError):
await creator
release.set()
result = await survivor
assert result["weather"]["temperature_c"] == 25
assert result["gold"]["latest_price"] == "935.99"
assert result["errors"] == {}
assert calls == {"weather": 1, "gold": 1}
@pytest.mark.asyncio
async def test_clear_cache_generation_blocks_old_refill_and_old_done_cleanup():
environment.clear_cache()
base = datetime(2026, 9, 16, 8, tzinfo=UTC)
old_started = asyncio.Event()
release_old = asyncio.Event()
release_new = asyncio.Event()
async def old_fetch():
old_started.set()
await release_old.wait()
return {"temperature_c": 10}
old_waiter = asyncio.create_task(
environment._cached_source(
"weather", old_fetch, base, environment.WEATHER_TTL, environment.WEATHER_STALE_TTL
)
)
await old_started.wait()
environment.clear_cache()
async def new_fetch():
await release_new.wait()
return {"temperature_c": 20}
new_waiter = asyncio.create_task(
environment._cached_source(
"weather",
new_fetch,
base + timedelta(minutes=20),
environment.WEATHER_TTL,
environment.WEATHER_STALE_TTL,
)
)
await asyncio.sleep(0)
release_old.set()
assert (await old_waiter)[0]["temperature_c"] == 10
# The old task's callback must not remove the new generation's in-flight task.
third_waiter = asyncio.create_task(
environment._cached_source(
"weather",
lambda: pytest.fail("old done callback cleared the new task"),
base + timedelta(minutes=20),
environment.WEATHER_TTL,
environment.WEATHER_STALE_TTL,
)
)
release_new.set()
new_value, third_value = await asyncio.gather(new_waiter, third_waiter)
assert new_value[0]["temperature_c"] == 20
assert third_value[0]["temperature_c"] == 20
async def no_refetch():
raise AssertionError("old generation refilled or new generation was lost")
cached, _ = await environment._cached_source(
"weather",
no_refetch,
base + timedelta(minutes=21),
environment.WEATHER_TTL,
environment.WEATHER_STALE_TTL,
)
assert cached["temperature_c"] == 20
@pytest.mark.asyncio
async def test_runtime_singleflight_state_is_isolated_per_event_loop():
environment.clear_cache()
barrier = threading.Barrier(2)
calls = []
results = []
failures = []
def worker(label):
async def run():
async def fetch():
calls.append(label)
await asyncio.to_thread(barrier.wait)
return {"temperature_c": label}
value, error = await environment._cached_source(
"weather",
fetch,
datetime(2026, 9, 16, 8, tzinfo=UTC),
environment.WEATHER_TTL,
environment.WEATHER_STALE_TTL,
)
results.append((value["temperature_c"], error))
try:
asyncio.run(run())
except RuntimeError as exc:
failures.append(exc)
threads = [threading.Thread(target=worker, args=(label,)) for label in (11, 22)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=2)
assert not any(thread.is_alive() for thread in threads)
assert failures == []
assert sorted(calls) == [11, 22]
assert sorted(results) == [(11, None), (22, None)]
def test_today_environment_endpoint_requires_authentication(client):
response = client.get("/api/v1/today/environment")
assert response.status_code == 401
def test_today_environment_endpoint_returns_aggregated_payload(client, monkeypatch):
client.post(
"/api/v1/setup/initialize",
json={"username": "owner", "password": "correct horse battery staple"},
)
payload = {
"date": {
"solar_date": "2026-09-16",
"weekday": "星期三",
"lunar": "农历八月初六",
"timezone": "Asia/Shanghai",
},
"weather": None,
"gold": None,
"errors": {"weather": "unavailable", "gold": "unavailable"},
}
async def aggregate():
return payload
monkeypatch.setattr("backend.main.get_today_environment", aggregate)
response = client.get("/api/v1/today/environment")
assert response.status_code == 200
assert response.json() == payload
Generated
+2 -2
View File
@@ -276,6 +276,7 @@ dependencies = [
{ name = "argon2-cffi" }, { name = "argon2-cffi" },
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" },
{ name = "lunar-python" }, { name = "lunar-python" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-multipart" }, { name = "python-multipart" },
@@ -288,7 +289,6 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "aiosqlite" }, { name = "aiosqlite" },
{ name = "httpx" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "ruff" }, { name = "ruff" },
@@ -300,6 +300,7 @@ requires-dist = [
{ name = "argon2-cffi", specifier = ">=25,<26" }, { name = "argon2-cffi", specifier = ">=25,<26" },
{ name = "asyncpg", specifier = ">=0.30,<1" }, { name = "asyncpg", specifier = ">=0.30,<1" },
{ name = "fastapi", specifier = ">=0.116,<1" }, { name = "fastapi", specifier = ">=0.116,<1" },
{ name = "httpx", specifier = ">=0.28,<1" },
{ name = "lunar-python", specifier = ">=1.2,<2" }, { name = "lunar-python", specifier = ">=1.2,<2" },
{ name = "pydantic-settings", specifier = ">=2.10,<3" }, { name = "pydantic-settings", specifier = ">=2.10,<3" },
{ name = "python-multipart", specifier = ">=0.0.20,<1" }, { name = "python-multipart", specifier = ">=0.0.20,<1" },
@@ -312,7 +313,6 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "aiosqlite", specifier = ">=0.21,<1" }, { name = "aiosqlite", specifier = ">=0.21,<1" },
{ name = "httpx", specifier = ">=0.28,<1" },
{ name = "pytest", specifier = ">=8.4,<9" }, { name = "pytest", specifier = ">=8.4,<9" },
{ name = "pytest-asyncio", specifier = ">=1.1,<2" }, { name = "pytest-asyncio", specifier = ">=1.1,<2" },
{ name = "ruff", specifier = ">=0.12,<1" }, { name = "ruff", specifier = ">=0.12,<1" },