feat: add Today environment summary
This commit is contained in:
@@ -69,6 +69,7 @@ from .schemas import (
|
||||
UserOut,
|
||||
UserUpdate,
|
||||
)
|
||||
from .today_environment import get_environment as get_today_environment
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -195,6 +196,11 @@ async def me(user: User = Depends(current_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)
|
||||
async def update_me(
|
||||
payload: UserUpdate,
|
||||
|
||||
@@ -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
@@ -21,7 +21,8 @@ import FloatingAddButton from './components/FloatingAddButton.vue'
|
||||
import CompletedFilterPill from './components/CompletedFilterPill.vue'
|
||||
import CalendarPicker from './components/CalendarPicker.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'
|
||||
|
||||
type FolderItem = { id: string; name: string }
|
||||
@@ -108,6 +109,10 @@ const todayTaskTotal = ref(0)
|
||||
const todayTaskCompleted = ref(0)
|
||||
const todayHabitTotal = 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 taskReorderAvailable = computed(() => activeView.value === 'tasks' && !query.value && totalPages.value === 1 && taskTree.value.length > 1)
|
||||
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: '日' }]
|
||||
let recurrenceLoadToken = 0
|
||||
let todaySummaryLoadToken = 0
|
||||
let todayEnvironmentLoadToken = 0
|
||||
const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null)
|
||||
const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null)
|
||||
const memoPanel = ref<InstanceType<typeof MemoPanel> | null>(null)
|
||||
@@ -534,6 +540,27 @@ async function loadTodayTaskSummary() {
|
||||
todayTaskTotal.value = overdue + open + completed
|
||||
} 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')) {
|
||||
const params = new URLSearchParams()
|
||||
params.set('due_to', isoAtLocalDayOffset(0))
|
||||
@@ -601,6 +628,7 @@ async function loadAll() {
|
||||
if (!navigationLoaded.value) await loadNavigation()
|
||||
if (!isLatestRequest('tasks', request)) return
|
||||
if (activeView.value === 'today') {
|
||||
void loadTodayEnvironment()
|
||||
await startPrimaryWithBackground(
|
||||
[() => loadTasksPage(request), () => loadOverdueTasks(request)],
|
||||
loadTodayTaskSummary,
|
||||
@@ -655,6 +683,10 @@ async function switchView(view: View, listId?: string) {
|
||||
taskMutationNavigation.value += 1
|
||||
taskReorderMode.value = false
|
||||
cancelTaskReorder()
|
||||
if (view !== 'today') {
|
||||
++todayEnvironmentLoadToken
|
||||
todayEnvironmentLoading.value = false
|
||||
}
|
||||
activeView.value = view
|
||||
if (!query.value) mobileSearchOpen.value = false
|
||||
searchPullDistance.value = 0
|
||||
@@ -1376,6 +1408,9 @@ onMounted(() => {
|
||||
document.addEventListener('keydown', handleArchivedListEscape)
|
||||
document.addEventListener('keydown', handleTaskSearchShortcut)
|
||||
window.addEventListener('resize', handleViewportResize)
|
||||
document.addEventListener('visibilitychange', handleTodayEnvironmentResume)
|
||||
window.addEventListener('focus', handleTodayEnvironmentResume)
|
||||
window.addEventListener('pageshow', handleTodayEnvironmentResume)
|
||||
document.addEventListener('scroll', handleArchivedListViewportChange, true)
|
||||
void bootstrap()
|
||||
})
|
||||
@@ -1384,6 +1419,9 @@ onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleArchivedListEscape)
|
||||
document.removeEventListener('keydown', handleTaskSearchShortcut)
|
||||
window.removeEventListener('resize', handleViewportResize)
|
||||
document.removeEventListener('visibilitychange', handleTodayEnvironmentResume)
|
||||
window.removeEventListener('focus', handleTodayEnvironmentResume)
|
||||
window.removeEventListener('pageshow', handleTodayEnvironmentResume)
|
||||
document.removeEventListener('scroll', handleArchivedListViewportChange, true)
|
||||
})
|
||||
</script>
|
||||
@@ -1491,6 +1529,7 @@ onUnmounted(() => {
|
||||
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
|
||||
<template v-else>
|
||||
<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')">
|
||||
<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>
|
||||
|
||||
@@ -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"> {{ 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>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h } from 'vue'
|
||||
import { useTaskDueClock } from './task-due-clock'
|
||||
import { useTaskDueClock, watchShanghaiDateRollover } from './task-due-clock'
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
|
||||
@@ -10,12 +10,13 @@ afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function mountClock() {
|
||||
function mountClock(onShanghaiDateRollover?: () => void) {
|
||||
let clock: ReturnType<typeof useTaskDueClock> | undefined
|
||||
const host = document.createElement('div')
|
||||
const app = createApp({
|
||||
setup() {
|
||||
clock = useTaskDueClock()
|
||||
if (onShanghaiDateRollover) watchShanghaiDateRollover(clock, onShanghaiDateRollover)
|
||||
return () => h('span', String(clock!.value))
|
||||
},
|
||||
})
|
||||
@@ -65,6 +66,19 @@ describe('useTaskDueClock', () => {
|
||||
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', () => {
|
||||
vi.useFakeTimers()
|
||||
const removeDocument = vi.spyOn(document, 'removeEventListener')
|
||||
|
||||
@@ -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) {
|
||||
const now = new Date(nowMs)
|
||||
|
||||
@@ -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}}
|
||||
|
||||
/* 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. */
|
||||
: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}
|
||||
|
||||
+1
-1
@@ -15,13 +15,13 @@ dependencies = [
|
||||
"uuid-utils>=0.11,<1",
|
||||
"structlog>=25,<26",
|
||||
"lunar-python>=1.2,<2",
|
||||
"httpx>=0.28,<1",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.4,<9",
|
||||
"pytest-asyncio>=1.1,<2",
|
||||
"httpx>=0.28,<1",
|
||||
"aiosqlite>=0.21,<1",
|
||||
"ruff>=0.12,<1",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -276,6 +276,7 @@ dependencies = [
|
||||
{ name = "argon2-cffi" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "lunar-python" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-multipart" },
|
||||
@@ -288,7 +289,6 @@ dependencies = [
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "ruff" },
|
||||
@@ -300,6 +300,7 @@ requires-dist = [
|
||||
{ name = "argon2-cffi", specifier = ">=25,<26" },
|
||||
{ name = "asyncpg", specifier = ">=0.30,<1" },
|
||||
{ name = "fastapi", specifier = ">=0.116,<1" },
|
||||
{ name = "httpx", specifier = ">=0.28,<1" },
|
||||
{ name = "lunar-python", specifier = ">=1.2,<2" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.10,<3" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.20,<1" },
|
||||
@@ -312,7 +313,6 @@ requires-dist = [
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "aiosqlite", specifier = ">=0.21,<1" },
|
||||
{ name = "httpx", specifier = ">=0.28,<1" },
|
||||
{ name = "pytest", specifier = ">=8.4,<9" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.1,<2" },
|
||||
{ name = "ruff", specifier = ">=0.12,<1" },
|
||||
|
||||
Reference in New Issue
Block a user