feat: 今天环境条新增下一个倒数日、浙江油价、上证指数、降雨/AQI
- 后端新增三源:中石化浙江油价(3步会话)、新浪上证行情、Open-Meteo 空气质量(国标AQI换算),按源独立超时与状态 - 天气行改为 降雨概率% · AQI 等级(无雨无AQI时保留观测时间),stale 补缓存标记 - 前端 strip 六格两行(桌面)/四行(移动),倒数日取本地缓存最近一条,超12字截断 - 修复两处 AppSheet 动画竞态存量 e2e 失败(modal contract footer 时序、scrollable 重开残留 dialog)
This commit is contained in:
+9
-1
@@ -74,6 +74,7 @@ from .schemas import (
|
|||||||
UserUpdate,
|
UserUpdate,
|
||||||
)
|
)
|
||||||
from .today_environment import get_environment as get_today_environment
|
from .today_environment import get_environment as get_today_environment
|
||||||
|
from .today_environment import get_environment_extras as get_today_environment_extras
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -216,7 +217,14 @@ async def me(user: User = Depends(current_user)):
|
|||||||
|
|
||||||
@app.get("/api/v1/today/environment")
|
@app.get("/api/v1/today/environment")
|
||||||
async def today_environment(_: User = Depends(current_user)):
|
async def today_environment(_: User = Depends(current_user)):
|
||||||
return await get_today_environment()
|
environment, extras = await asyncio.gather(
|
||||||
|
get_today_environment(), get_today_environment_extras()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
**{key: value for key, value in environment.items() if key != "errors"},
|
||||||
|
**{key: value for key, value in extras.items() if key != "errors"},
|
||||||
|
"errors": {**environment.get("errors", {}), **extras.get("errors", {})},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/v1/me", response_model=UserOut)
|
@app.patch("/api/v1/me", response_model=UserOut)
|
||||||
|
|||||||
@@ -23,16 +23,34 @@ GOLD_HISTORY_URL = (
|
|||||||
"https://www.sge.com.cn/sjzx/quotation_daily_new"
|
"https://www.sge.com.cn/sjzx/quotation_daily_new"
|
||||||
"?start_date={start_date}&end_date={end_date}"
|
"?start_date={start_date}&end_date={end_date}"
|
||||||
)
|
)
|
||||||
|
AIR_URL = "https://air-quality-api.open-meteo.com/v1/air-quality"
|
||||||
|
OIL_BASE_URL = "https://cx.sinopecsales.com"
|
||||||
|
OIL_MAIN_PATH = "/yjkqiantai/core/main"
|
||||||
|
OIL_SWITCH_PATH = "/yjkqiantai/data/switchProvince"
|
||||||
|
OIL_INIT_PATH = "/yjkqiantai/data/initMainData"
|
||||||
|
ASHARE_URL = "https://hq.sinajs.cn/list=sh000001"
|
||||||
WEATHER_SOURCE = "Open-Meteo"
|
WEATHER_SOURCE = "Open-Meteo"
|
||||||
GOLD_SOURCE = "上海黄金交易所"
|
GOLD_SOURCE = "上海黄金交易所"
|
||||||
|
AIR_SOURCE = "Open-Meteo 空气质量"
|
||||||
|
OIL_SOURCE = "中国石化"
|
||||||
|
ASHARE_SOURCE = "新浪财经"
|
||||||
|
ASHARE_HEADERS = {"Referer": "https://finance.sina.com.cn", "User-Agent": "Mozilla/5.0"}
|
||||||
|
REQUEST_HEADERS = {"User-Agent": "dodo/0.1 (+https://dodo.bboy.app)"}
|
||||||
WEEKDAYS = ("星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日")
|
WEEKDAYS = ("星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日")
|
||||||
SOURCE_TIMEOUT_SECONDS = 3.0
|
SOURCE_TIMEOUT_SECONDS = 3.0
|
||||||
GOLD_SOURCE_TIMEOUT_SECONDS = 6.0
|
GOLD_SOURCE_TIMEOUT_SECONDS = 6.0
|
||||||
|
OIL_SOURCE_TIMEOUT_SECONDS = 6.0
|
||||||
TOTAL_TIMEOUT_SECONDS = 6.5
|
TOTAL_TIMEOUT_SECONDS = 6.5
|
||||||
WEATHER_TTL = timedelta(minutes=15)
|
WEATHER_TTL = timedelta(minutes=15)
|
||||||
WEATHER_STALE_TTL = timedelta(hours=6)
|
WEATHER_STALE_TTL = timedelta(hours=6)
|
||||||
GOLD_TTL = timedelta(minutes=5)
|
GOLD_TTL = timedelta(minutes=5)
|
||||||
GOLD_STALE_TTL = timedelta(days=7)
|
GOLD_STALE_TTL = timedelta(days=7)
|
||||||
|
AIR_TTL = timedelta(hours=1)
|
||||||
|
AIR_STALE_TTL = timedelta(days=3)
|
||||||
|
OIL_TTL = timedelta(hours=6)
|
||||||
|
OIL_STALE_TTL = timedelta(days=30)
|
||||||
|
ASHARE_TTL = timedelta(minutes=10)
|
||||||
|
ASHARE_STALE_TTL = timedelta(days=7)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -149,6 +167,7 @@ async def fetch_weather(request: Callable[..., Awaitable[dict[str, Any]]]) -> di
|
|||||||
"latitude": 29.88,
|
"latitude": 29.88,
|
||||||
"longitude": 121.55,
|
"longitude": 121.55,
|
||||||
"current": "temperature_2m,apparent_temperature,weather_code",
|
"current": "temperature_2m,apparent_temperature,weather_code",
|
||||||
|
"hourly": "precipitation_probability",
|
||||||
"timezone": "Asia/Shanghai",
|
"timezone": "Asia/Shanghai",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -183,15 +202,45 @@ async def fetch_weather(request: Callable[..., Awaitable[dict[str, Any]]]) -> di
|
|||||||
observed_at = observed_at.astimezone(SHANGHAI_TZ)
|
observed_at = observed_at.astimezone(SHANGHAI_TZ)
|
||||||
else:
|
else:
|
||||||
observed_at = observed_at.replace(tzinfo=SHANGHAI_TZ)
|
observed_at = observed_at.replace(tzinfo=SHANGHAI_TZ)
|
||||||
|
precipitation_probability = _precipitation_probability(payload, observed_at)
|
||||||
return {
|
return {
|
||||||
"temperature_c": temperature,
|
"temperature_c": temperature,
|
||||||
"apparent_temperature_c": apparent_temperature,
|
"apparent_temperature_c": apparent_temperature,
|
||||||
"weather_code": weather_code,
|
"weather_code": weather_code,
|
||||||
|
"precipitation_probability": precipitation_probability,
|
||||||
"observed_at": observed_at.isoformat(),
|
"observed_at": observed_at.isoformat(),
|
||||||
"source": WEATHER_SOURCE,
|
"source": WEATHER_SOURCE,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _precipitation_probability(payload: dict[str, Any], observed_at: datetime) -> int | None:
|
||||||
|
hourly = payload.get("hourly")
|
||||||
|
if not isinstance(hourly, dict):
|
||||||
|
raise TypeError("weather hourly must be an object")
|
||||||
|
times = hourly.get("time")
|
||||||
|
probabilities = hourly.get("precipitation_probability")
|
||||||
|
if not isinstance(times, list) or not isinstance(probabilities, list):
|
||||||
|
raise TypeError("weather hourly precipitation_probability must be a list")
|
||||||
|
if len(times) != len(probabilities) or not times or not all(isinstance(value, str) for value in times):
|
||||||
|
raise ValueError("weather hourly precipitation_probability must align with time")
|
||||||
|
current_hour = observed_at.strftime("%Y-%m-%dT%H:00")
|
||||||
|
try:
|
||||||
|
index = times.index(current_hour)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
window = probabilities[index : index + 3]
|
||||||
|
valid: list[float] = []
|
||||||
|
for value in window:
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
raise TypeError("weather precipitation_probability must be numeric or null")
|
||||||
|
if not math.isfinite(value) or not 0 <= value <= 100:
|
||||||
|
raise ValueError("weather precipitation_probability out of range")
|
||||||
|
valid.append(float(value))
|
||||||
|
return round(max(valid)) if valid else None
|
||||||
|
|
||||||
|
|
||||||
async def fetch_gold(request: Callable[..., Awaitable[str]]) -> dict[str, Any]:
|
async def fetch_gold(request: Callable[..., Awaitable[str]]) -> dict[str, Any]:
|
||||||
html = await request(GOLD_URL)
|
html = await request(GOLD_URL)
|
||||||
try:
|
try:
|
||||||
@@ -220,6 +269,101 @@ async def fetch_gold(request: Callable[..., Awaitable[str]]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_PM25_BREAKPOINTS = (
|
||||||
|
(0.0, 35.0, 0.0, 50.0),
|
||||||
|
(35.0, 75.0, 50.0, 100.0),
|
||||||
|
(75.0, 115.0, 100.0, 150.0),
|
||||||
|
(115.0, 150.0, 150.0, 200.0),
|
||||||
|
(150.0, 250.0, 200.0, 300.0),
|
||||||
|
(250.0, 350.0, 300.0, 400.0),
|
||||||
|
(350.0, 500.0, 400.0, 500.0),
|
||||||
|
)
|
||||||
|
_PM10_BREAKPOINTS = (
|
||||||
|
(0.0, 50.0, 0.0, 50.0),
|
||||||
|
(50.0, 150.0, 50.0, 100.0),
|
||||||
|
(150.0, 250.0, 100.0, 150.0),
|
||||||
|
(250.0, 350.0, 150.0, 200.0),
|
||||||
|
(350.0, 420.0, 200.0, 300.0),
|
||||||
|
(420.0, 500.0, 300.0, 400.0),
|
||||||
|
(500.0, 600.0, 400.0, 500.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sub_index(concentration: float, breakpoints: tuple[tuple[float, float, float, float], ...]) -> float:
|
||||||
|
if concentration <= 0:
|
||||||
|
return 0.0
|
||||||
|
for low, high, index_low, index_high in breakpoints:
|
||||||
|
if concentration <= high:
|
||||||
|
return (index_high - index_low) / (high - low) * (concentration - low) + index_low
|
||||||
|
return 500.0
|
||||||
|
|
||||||
|
|
||||||
|
def china_aqi(pm2_5: float, pm10: float) -> int:
|
||||||
|
"""HJ 633-2012 AQI from PM2.5 / PM10 concentrations (μg/m³), capped at 500."""
|
||||||
|
return round(max(_sub_index(pm2_5, _PM25_BREAKPOINTS), _sub_index(pm10, _PM10_BREAKPOINTS)))
|
||||||
|
|
||||||
|
|
||||||
|
def aqi_level(aqi: int) -> str:
|
||||||
|
if aqi <= 50:
|
||||||
|
return "优"
|
||||||
|
if aqi <= 100:
|
||||||
|
return "良"
|
||||||
|
if aqi <= 150:
|
||||||
|
return "轻度污染"
|
||||||
|
if aqi <= 200:
|
||||||
|
return "中度污染"
|
||||||
|
if aqi <= 300:
|
||||||
|
return "重度污染"
|
||||||
|
return "严重污染"
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_air(request: Callable[..., Awaitable[dict[str, Any]]]) -> dict[str, Any]:
|
||||||
|
payload = await request(
|
||||||
|
AIR_URL,
|
||||||
|
params={
|
||||||
|
"latitude": 29.88,
|
||||||
|
"longitude": 121.55,
|
||||||
|
"current": "pm2_5,pm10",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if payload.get("timezone") != "Asia/Shanghai":
|
||||||
|
raise ValueError("air quality timezone must be Asia/Shanghai")
|
||||||
|
current = payload["current"]
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
raise TypeError("air quality current must be an object")
|
||||||
|
|
||||||
|
def concentration(name: str) -> float:
|
||||||
|
value = current[name]
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
raise TypeError(f"air quality {name} must be numeric")
|
||||||
|
if not math.isfinite(value) or not 0 <= value <= 2000:
|
||||||
|
raise ValueError(f"air quality {name} out of range")
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
pm2_5 = concentration("pm2_5")
|
||||||
|
pm10 = concentration("pm10")
|
||||||
|
raw_time = current["time"]
|
||||||
|
if not isinstance(raw_time, str):
|
||||||
|
raise TypeError("air quality 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("air quality time must use Asia/Shanghai")
|
||||||
|
observed_at = observed_at.astimezone(SHANGHAI_TZ)
|
||||||
|
else:
|
||||||
|
observed_at = observed_at.replace(tzinfo=SHANGHAI_TZ)
|
||||||
|
aqi = china_aqi(pm2_5, pm10)
|
||||||
|
return {
|
||||||
|
"aqi": aqi,
|
||||||
|
"level": aqi_level(aqi),
|
||||||
|
"pm2_5": pm2_5,
|
||||||
|
"pm10": pm10,
|
||||||
|
"observed_at": observed_at.isoformat(),
|
||||||
|
"source": AIR_SOURCE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _request_json(client: httpx.AsyncClient, url: str, **kwargs: Any) -> dict[str, Any]:
|
async def _request_json(client: httpx.AsyncClient, url: str, **kwargs: Any) -> dict[str, Any]:
|
||||||
response = await client.get(url, **kwargs)
|
response = await client.get(url, **kwargs)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -232,19 +376,134 @@ async def _request_text(client: httpx.AsyncClient, url: str, **kwargs: Any) -> s
|
|||||||
return response.text
|
return response.text
|
||||||
|
|
||||||
|
|
||||||
|
async def _request_bytes(client: httpx.AsyncClient, url: str, **kwargs: Any) -> bytes:
|
||||||
|
response = await client.get(url, **kwargs)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
|
||||||
|
def parse_sina_index(text: str) -> dict[str, Any]:
|
||||||
|
match = re.search(r'"([^"]*)"', text)
|
||||||
|
if match is None:
|
||||||
|
raise ValueError("sina quote not found")
|
||||||
|
fields = match.group(1).split(",")
|
||||||
|
if len(fields) < 5:
|
||||||
|
raise ValueError("sina quote is too short")
|
||||||
|
if fields[0] != "上证指数":
|
||||||
|
raise ValueError("unexpected sina symbol")
|
||||||
|
|
||||||
|
def number(index: int) -> Decimal:
|
||||||
|
try:
|
||||||
|
value = Decimal(fields[index].replace(",", ""))
|
||||||
|
except InvalidOperation as exc:
|
||||||
|
raise ValueError("invalid sina number") from exc
|
||||||
|
if not value.is_finite() or value <= 0:
|
||||||
|
raise ValueError("invalid sina number")
|
||||||
|
return value
|
||||||
|
|
||||||
|
previous_close = number(2)
|
||||||
|
price = number(3)
|
||||||
|
as_of = next((field for field in fields if re.fullmatch(r"\d{4}-\d{2}-\d{2}", field)), None)
|
||||||
|
if as_of is None:
|
||||||
|
raise ValueError("sina quote date not found")
|
||||||
|
try:
|
||||||
|
date.fromisoformat(as_of)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("invalid sina quote date") from exc
|
||||||
|
change_percent = float((price - previous_close) / previous_close * 100)
|
||||||
|
return {
|
||||||
|
"name": fields[0],
|
||||||
|
"price": price,
|
||||||
|
"prev_close": previous_close,
|
||||||
|
"change_percent": round(change_percent, 2),
|
||||||
|
"as_of": as_of,
|
||||||
|
"source": ASHARE_SOURCE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_ashare(request: Callable[..., Awaitable[bytes]]) -> dict[str, Any]:
|
||||||
|
raw = await request(ASHARE_URL, headers=ASHARE_HEADERS)
|
||||||
|
try:
|
||||||
|
text = raw.decode("gbk")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise ValueError("sina quote is not decodable as GBK") from exc
|
||||||
|
return parse_sina_index(text)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_oil_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise TypeError("sinopec payload must be an object")
|
||||||
|
data = payload.get("data")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise TypeError("sinopec data must be an object")
|
||||||
|
province = data.get("provinceData")
|
||||||
|
if not isinstance(province, dict):
|
||||||
|
raise TypeError("sinopec provinceData must be an object")
|
||||||
|
|
||||||
|
def price(key: str) -> Decimal:
|
||||||
|
if key not in province:
|
||||||
|
raise ValueError(f"sinopec {key} missing")
|
||||||
|
try:
|
||||||
|
value = Decimal(str(province[key]))
|
||||||
|
except InvalidOperation as exc:
|
||||||
|
raise ValueError(f"sinopec {key} invalid") from exc
|
||||||
|
if not value.is_finite() or not Decimal("0.01") <= value <= Decimal(100):
|
||||||
|
raise ValueError(f"sinopec {key} out of range")
|
||||||
|
return value
|
||||||
|
|
||||||
|
effective_at = None
|
||||||
|
raw_effective = province.get("START_DATE")
|
||||||
|
if isinstance(raw_effective, str) and raw_effective:
|
||||||
|
try:
|
||||||
|
parsed = datetime.strptime(raw_effective, "%Y-%m-%d %H:%M:%S").replace(tzinfo=SHANGHAI_TZ)
|
||||||
|
except ValueError:
|
||||||
|
effective_at = None
|
||||||
|
else:
|
||||||
|
effective_at = parsed.isoformat()
|
||||||
|
return {
|
||||||
|
"gas_92": price("GAS_92"),
|
||||||
|
"gas_95": price("GAS_95"),
|
||||||
|
"gas_98": price("AIPAO_GAS_98"),
|
||||||
|
"diesel_0": price("CHECHAI_0"),
|
||||||
|
"effective_at": effective_at,
|
||||||
|
"source": OIL_SOURCE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_oil(client: httpx.AsyncClient) -> dict[str, Any]:
|
||||||
|
main = await client.get(f"{OIL_BASE_URL}{OIL_MAIN_PATH}")
|
||||||
|
main.raise_for_status()
|
||||||
|
if client.cookies.get("SESSION") is None:
|
||||||
|
raise ValueError("sinopec session cookie missing")
|
||||||
|
switch = await client.post(f"{OIL_BASE_URL}{OIL_SWITCH_PATH}", json={"provinceId": "33"})
|
||||||
|
switch.raise_for_status()
|
||||||
|
init = await client.get(
|
||||||
|
f"{OIL_BASE_URL}{OIL_INIT_PATH}",
|
||||||
|
headers={"Referer": f"{OIL_BASE_URL}{OIL_MAIN_PATH}"},
|
||||||
|
)
|
||||||
|
init.raise_for_status()
|
||||||
|
return parse_oil_payload(init.json())
|
||||||
|
|
||||||
|
|
||||||
def _serializable(value: dict[str, Any]) -> dict[str, Any]:
|
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()}
|
return {key: str(item) if isinstance(item, Decimal) else item for key, item in value.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _source_timeout(name: str) -> float:
|
||||||
|
if name == "gold":
|
||||||
|
return GOLD_SOURCE_TIMEOUT_SECONDS
|
||||||
|
if name == "oil":
|
||||||
|
return OIL_SOURCE_TIMEOUT_SECONDS
|
||||||
|
return SOURCE_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
|
||||||
async def _refresh_source(
|
async def _refresh_source(
|
||||||
name: str,
|
name: str,
|
||||||
fetcher: Callable[[], Awaitable[dict[str, Any]]],
|
fetcher: Callable[[], Awaitable[dict[str, Any]]],
|
||||||
fetched_at: datetime,
|
fetched_at: datetime,
|
||||||
generation: int,
|
generation: int,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
value = await asyncio.wait_for(
|
value = await asyncio.wait_for(fetcher(), timeout=_source_timeout(name))
|
||||||
fetcher(), timeout=GOLD_SOURCE_TIMEOUT_SECONDS if name == "gold" else SOURCE_TIMEOUT_SECONDS
|
|
||||||
)
|
|
||||||
with _state_guard:
|
with _state_guard:
|
||||||
if generation == _generation:
|
if generation == _generation:
|
||||||
newest = _cache.get(name)
|
newest = _cache.get(name)
|
||||||
@@ -331,6 +590,30 @@ async def _default_gold_fetcher() -> dict[str, Any]:
|
|||||||
return await fetch_gold(lambda url, **kwargs: _request_text(client, url, **kwargs))
|
return await fetch_gold(lambda url, **kwargs: _request_text(client, url, **kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
async def _default_air_fetcher() -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(SOURCE_TIMEOUT_SECONDS),
|
||||||
|
headers=REQUEST_HEADERS,
|
||||||
|
) as client:
|
||||||
|
return await fetch_air(lambda url, **kwargs: _request_json(client, url, **kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
async def _default_oil_fetcher() -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(OIL_SOURCE_TIMEOUT_SECONDS),
|
||||||
|
headers={"User-Agent": "Mozilla/5.0"},
|
||||||
|
) as client:
|
||||||
|
return await fetch_oil(client)
|
||||||
|
|
||||||
|
|
||||||
|
async def _default_ashare_fetcher() -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(SOURCE_TIMEOUT_SECONDS),
|
||||||
|
headers=REQUEST_HEADERS,
|
||||||
|
) as client:
|
||||||
|
return await fetch_ashare(lambda url, **kwargs: _request_bytes(client, url, **kwargs))
|
||||||
|
|
||||||
|
|
||||||
async def get_environment(
|
async def get_environment(
|
||||||
*,
|
*,
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
@@ -366,3 +649,35 @@ async def get_environment(
|
|||||||
"gold": gold,
|
"gold": gold,
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_environment_extras(
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
air_fetcher: Callable[[], Awaitable[dict[str, Any]]] | None = None,
|
||||||
|
oil_fetcher: Callable[[], Awaitable[dict[str, Any]]] | None = None,
|
||||||
|
ashare_fetcher: Callable[[], Awaitable[dict[str, Any]]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Air quality, Zhejiang oil prices, and the SSE index for the Today strip."""
|
||||||
|
current = now or datetime.now(UTC)
|
||||||
|
sources = (
|
||||||
|
("air", air_fetcher or _default_air_fetcher, AIR_TTL, AIR_STALE_TTL),
|
||||||
|
("oil", oil_fetcher or _default_oil_fetcher, OIL_TTL, OIL_STALE_TTL),
|
||||||
|
("ashare", ashare_fetcher or _default_ashare_fetcher, ASHARE_TTL, ASHARE_STALE_TTL),
|
||||||
|
)
|
||||||
|
results = await asyncio.wait_for(
|
||||||
|
asyncio.gather(
|
||||||
|
*(
|
||||||
|
_cached_source(name, fetcher, current, fresh_for, stale_for)
|
||||||
|
for name, fetcher, fresh_for, stale_for in sources
|
||||||
|
)
|
||||||
|
),
|
||||||
|
timeout=TOTAL_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
values: dict[str, Any] = {}
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
for (name, *_), (value, error) in zip(sources, results):
|
||||||
|
values[name] = value
|
||||||
|
if error:
|
||||||
|
errors[name] = error
|
||||||
|
return {**values, "errors": errors}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ test('desktop task composer stays compact, centered, and scrollable on short scr
|
|||||||
expect(metrics.footerBottom).toBeLessThanOrEqual(metrics.viewport.height + 1)
|
expect(metrics.footerBottom).toBeLessThanOrEqual(metrics.viewport.height + 1)
|
||||||
|
|
||||||
await page.keyboard.press('Escape')
|
await page.keyboard.press('Escape')
|
||||||
|
// AppSheet 关闭有 160ms leave 动画,等旧 dialog 完全移除再重开(否则 strict mode 命中残留按钮)
|
||||||
|
await expect(page.getByRole('dialog', { name: /添加(?:今天)?任务/ })).toHaveCount(0)
|
||||||
await page.setViewportSize({ width: 930, height: 844 })
|
await page.setViewportSize({ width: 930, height: 844 })
|
||||||
dialog = await openTaskComposer(page)
|
dialog = await openTaskComposer(page)
|
||||||
const mobile = await dialog.evaluate(element => {
|
const mobile = await dialog.evaluate(element => {
|
||||||
@@ -346,6 +348,11 @@ test('task, habit, countdown, memo, action and confirmation overlays share the m
|
|||||||
const assertModal = async (dialog: ReturnType<Page['getByRole']>) => {
|
const assertModal = async (dialog: ReturnType<Page['getByRole']>) => {
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
await expect(page.locator('#app')).toHaveAttribute('inert', '')
|
await expect(page.locator('#app')).toHaveAttribute('inert', '')
|
||||||
|
const footer = dialog.locator('.app-sheet__footer, footer').first()
|
||||||
|
if (await footer.count()) {
|
||||||
|
// AppSheet 打开有 210ms 滑入动画,先等 footer 落位再测量(否则测在动画中途)
|
||||||
|
await expect.poll(async () => footer.evaluate(element => element.getBoundingClientRect().bottom - innerHeight)).toBeLessThanOrEqual(1)
|
||||||
|
}
|
||||||
const metrics = await dialog.evaluate(element => {
|
const metrics = await dialog.evaluate(element => {
|
||||||
const panel = element as HTMLElement
|
const panel = element as HTMLElement
|
||||||
const header = panel.querySelector<HTMLElement>('.app-sheet__header, header')
|
const header = panel.querySelector<HTMLElement>('.app-sheet__header, header')
|
||||||
|
|||||||
@@ -20,25 +20,39 @@ test('Today plain-list layout matches the approved responsive geometry', async (
|
|||||||
expect(bootstrap.ok(), await bootstrap.text()).toBeTruthy()
|
expect(bootstrap.ok(), await bootstrap.text()).toBeTruthy()
|
||||||
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
|
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
|
||||||
expect(inbox).toBeTruthy()
|
expect(inbox).toBeTruthy()
|
||||||
const today = new Intl.DateTimeFormat('sv-SE', {
|
const formatShanghai = (date: Date) => new Intl.DateTimeFormat('sv-SE', {
|
||||||
timeZone: 'Asia/Shanghai',
|
timeZone: 'Asia/Shanghai',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
}).format(new Date())
|
}).format(date)
|
||||||
|
const today = formatShanghai(new Date())
|
||||||
const taskResponse = await mutate(request, baseURL!, '/api/v1/tasks', { title: taskTitle, list_id: inbox.id, due_at: `${today}T23:59:00+08:00`, due_has_time: false })
|
const taskResponse = await mutate(request, baseURL!, '/api/v1/tasks', { title: taskTitle, list_id: inbox.id, due_at: `${today}T23:59:00+08:00`, due_has_time: false })
|
||||||
expect(taskResponse.ok(), await taskResponse.text()).toBeTruthy()
|
expect(taskResponse.ok(), await taskResponse.text()).toBeTruthy()
|
||||||
const taskId = (await taskResponse.json()).id as string
|
const taskId = (await taskResponse.json()).id as string
|
||||||
const habitResponse = await mutate(request, baseURL!, '/api/v1/habits', { name: habitName, kind: 'numeric', target: 8, max_value: 8, schedule_type: 'daily' })
|
const habitResponse = await mutate(request, baseURL!, '/api/v1/habits', { name: habitName, kind: 'numeric', target: 8, max_value: 8, schedule_type: 'daily' })
|
||||||
expect(habitResponse.ok(), await habitResponse.text()).toBeTruthy()
|
expect(habitResponse.ok(), await habitResponse.text()).toBeTruthy()
|
||||||
const habitId = (await habitResponse.json()).id as string
|
const habitId = (await habitResponse.json()).id as string
|
||||||
|
const countdownTitle = `原型对齐倒数日-${runId}`
|
||||||
|
const countdownResponse = await mutate(request, baseURL!, '/api/v1/countdowns', {
|
||||||
|
title: countdownTitle,
|
||||||
|
event_date: formatShanghai(new Date(Date.now() + 30 * 86400000)),
|
||||||
|
calendar_mode: 'solar',
|
||||||
|
kind: 'countdown',
|
||||||
|
repeat_rule: 'none',
|
||||||
|
icon: '📅',
|
||||||
|
})
|
||||||
|
expect(countdownResponse.ok(), await countdownResponse.text()).toBeTruthy()
|
||||||
await page.route('**/api/v1/today/environment', route => route.fulfill({
|
await page.route('**/api/v1/today/environment', route => route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
date: { solar_date: '2026-09-19', weekday: '星期六', lunar: '八月初九' },
|
date: { solar_date: '2026-09-19', weekday: '星期六', lunar: '八月初九' },
|
||||||
weather: { status: 'fresh', temperature_c: 26.4, text: '多云', observed_at: '2026-09-19T20:45:00+08:00' },
|
weather: { status: 'fresh', temperature_c: 26.4, text: '多云', observed_at: '2026-09-19T20:45:00+08:00', precipitation_probability: 60 },
|
||||||
gold: { status: 'fresh', contract: 'Au99.99', price_cny_per_gram: 835.62, market_date: '2026-09-18' },
|
gold: { status: 'fresh', contract: 'Au99.99', price_cny_per_gram: 835.62, market_date: '2026-09-18' },
|
||||||
|
air: { status: 'fresh', aqi: 36, level: '优' },
|
||||||
|
oil: { status: 'fresh', gas_92: '8.58', gas_95: '9.12', gas_98: '11.12', diesel_0: '8.28', effective_at: '2026-09-17T00:12:00+08:00' },
|
||||||
|
ashare: { status: 'fresh', name: '上证指数', price: '3888.37', change_percent: -1.22, as_of: '2026-09-18' },
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
@@ -61,7 +75,7 @@ test('Today plain-list layout matches the approved responsive geometry', async (
|
|||||||
})).toEqual({
|
})).toEqual({
|
||||||
media720: width <= 720,
|
media720: width <= 720,
|
||||||
contentWidth: width >= 1440 ? 1080 : width > 930 ? Math.min(1080, width - 324) : width === 721 ? 630 : width - 56,
|
contentWidth: width >= 1440 ? 1080 : width > 930 ? Math.min(1080, width - 324) : width === 721 ? 630 : width - 56,
|
||||||
environmentHeight: width >= 721 ? 55 : width === 375 ? 79 : 81,
|
environmentHeight: width >= 721 ? 91 : width === 375 ? 154 : 158,
|
||||||
})
|
})
|
||||||
await expect(page.locator('.today-heading')).toHaveCount(1)
|
await expect(page.locator('.today-heading')).toHaveCount(1)
|
||||||
await expect(page.locator('.today-inline-add')).toHaveCount(0)
|
await expect(page.locator('.today-inline-add')).toHaveCount(0)
|
||||||
@@ -72,12 +86,19 @@ test('Today plain-list layout matches the approved responsive geometry', async (
|
|||||||
await expect(page.getByRole('button', { name: /刷新|搜索/ })).toHaveCount(0)
|
await expect(page.getByRole('button', { name: /刷新|搜索/ })).toHaveCount(0)
|
||||||
await expect(page.getByRole('switch', { name: '显示已完成' })).toHaveCount(1)
|
await expect(page.getByRole('switch', { name: '显示已完成' })).toHaveCount(1)
|
||||||
await expect(page.locator('.today-environment__gold')).toContainText('Au99.99')
|
await expect(page.locator('.today-environment__gold')).toContainText('Au99.99')
|
||||||
|
await expect(page.locator('.today-environment__weather')).toContainText('降雨60% · AQI 36 优')
|
||||||
|
await expect(page.locator('.today-environment__countdown')).toContainText(/还有 \d+ 天|就是今天/)
|
||||||
|
await expect(page.locator('.today-environment__oil')).toContainText('92# 8.58')
|
||||||
|
await expect(page.locator('.today-environment__ashare')).toContainText('上证 3888.37')
|
||||||
const metrics = await page.evaluate(({ taskId, habitId }) => {
|
const metrics = await page.evaluate(({ taskId, habitId }) => {
|
||||||
const rect = (selector: string) => document.querySelector<HTMLElement>(selector)!.getBoundingClientRect()
|
const rect = (selector: string) => document.querySelector<HTMLElement>(selector)!.getBoundingClientRect()
|
||||||
const environment = document.querySelector<HTMLElement>('.today-environment')!
|
const environment = document.querySelector<HTMLElement>('.today-environment')!
|
||||||
const calendar = document.querySelector<HTMLElement>('.today-environment__calendar')!
|
const calendar = document.querySelector<HTMLElement>('.today-environment__calendar')!
|
||||||
const weather = document.querySelector<HTMLElement>('.today-environment__weather')!
|
const weather = document.querySelector<HTMLElement>('.today-environment__weather')!
|
||||||
const gold = document.querySelector<HTMLElement>('.today-environment__gold')!
|
const gold = document.querySelector<HTMLElement>('.today-environment__gold')!
|
||||||
|
const countdown = document.querySelector<HTMLElement>('.today-environment__countdown')!
|
||||||
|
const oil = document.querySelector<HTMLElement>('.today-environment__oil')!
|
||||||
|
const ashare = document.querySelector<HTMLElement>('.today-environment__ashare')!
|
||||||
const title = document.querySelector<HTMLElement>('.today-page-title')!
|
const title = document.querySelector<HTMLElement>('.today-page-title')!
|
||||||
const remaining = document.querySelector<HTMLElement>('.today-remaining')!
|
const remaining = document.querySelector<HTMLElement>('.today-remaining')!
|
||||||
const filter = document.querySelector<HTMLElement>('.today-inline-filter')!
|
const filter = document.querySelector<HTMLElement>('.today-inline-filter')!
|
||||||
@@ -121,7 +142,7 @@ test('Today plain-list layout matches the approved responsive geometry', async (
|
|||||||
.filter((node): node is HTMLElement => node instanceof HTMLElement && visible(node) && !node.classList.contains('sr-only'))
|
.filter((node): node is HTMLElement => node instanceof HTMLElement && visible(node) && !node.classList.contains('sr-only'))
|
||||||
.map(node => Math.round(node.getBoundingClientRect().top))
|
.map(node => Math.round(node.getBoundingClientRect().top))
|
||||||
const uniqueRows = [...new Set(rows)]
|
const uniqueRows = [...new Set(rows)]
|
||||||
const collisions = [calendar, weather, gold].some((a, index, items) => items.slice(index + 1).some(b => {
|
const collisions = [calendar, weather, gold, countdown, oil, ashare].some((a, index, items) => items.slice(index + 1).some(b => {
|
||||||
const ar = a.getBoundingClientRect(); const br = b.getBoundingClientRect()
|
const ar = a.getBoundingClientRect(); const br = b.getBoundingClientRect()
|
||||||
return ar.left < br.right && ar.right > br.left && ar.top < br.bottom && ar.bottom > br.top
|
return ar.left < br.right && ar.right > br.left && ar.top < br.bottom && ar.bottom > br.top
|
||||||
}))
|
}))
|
||||||
@@ -135,12 +156,12 @@ test('Today plain-list layout matches the approved responsive geometry', async (
|
|||||||
content: { left: content.left, right: content.right, width: content.width },
|
content: { left: content.left, right: content.right, width: content.width },
|
||||||
menuButton: { left: menuButton.getBoundingClientRect().left, width: menuButton.getBoundingClientRect().width },
|
menuButton: { left: menuButton.getBoundingClientRect().left, width: menuButton.getBoundingClientRect().width },
|
||||||
environment: { ...environment.getBoundingClientRect().toJSON(), scrollWidth: environment.scrollWidth, clientWidth: environment.clientWidth },
|
environment: { ...environment.getBoundingClientRect().toJSON(), scrollWidth: environment.scrollWidth, clientWidth: environment.clientWidth },
|
||||||
directions: { calendar: getComputedStyle(calendar).flexDirection, weather: getComputedStyle(weather).flexDirection, gold: getComputedStyle(gold).flexDirection },
|
directions: { calendar: getComputedStyle(calendar).flexDirection, weather: getComputedStyle(weather).flexDirection, gold: getComputedStyle(gold).flexDirection, countdown: getComputedStyle(countdown).flexDirection, oil: getComputedStyle(oil).flexDirection, ashare: getComputedStyle(ashare).flexDirection },
|
||||||
itemWidths: [calendar, weather, gold].map((element) => element.getBoundingClientRect().width),
|
itemWidths: [calendar, weather, gold, countdown, oil, ashare].map((element) => element.getBoundingClientRect().width),
|
||||||
itemHeights: [calendar, weather, gold].map((element) => element.getBoundingClientRect().height),
|
itemHeights: [calendar, weather, gold, countdown, oil, ashare].map((element) => element.getBoundingClientRect().height),
|
||||||
uniqueRows,
|
uniqueRows,
|
||||||
collisions,
|
collisions,
|
||||||
collisionRects: [calendar, weather, gold].map(element => element.getBoundingClientRect().toJSON()),
|
collisionRects: [calendar, weather, gold, countdown, oil, ashare].map(element => element.getBoundingClientRect().toJSON()),
|
||||||
secondary,
|
secondary,
|
||||||
ordered,
|
ordered,
|
||||||
orderedPairs,
|
orderedPairs,
|
||||||
@@ -148,6 +169,9 @@ test('Today plain-list layout matches the approved responsive geometry', async (
|
|||||||
insideContent,
|
insideContent,
|
||||||
weatherText: weather.innerText,
|
weatherText: weather.innerText,
|
||||||
goldText: gold.innerText,
|
goldText: gold.innerText,
|
||||||
|
oilText: oil.innerText,
|
||||||
|
ashareText: ashare.innerText,
|
||||||
|
countdownText: countdown.innerText,
|
||||||
goldLabel: gold.querySelector('.today-environment__gold-date')?.getAttribute('aria-label') ?? '',
|
goldLabel: gold.querySelector('.today-environment__gold-date')?.getAttribute('aria-label') ?? '',
|
||||||
summaries,
|
summaries,
|
||||||
styles: {
|
styles: {
|
||||||
@@ -208,6 +232,10 @@ test('Today plain-list layout matches the approved responsive geometry', async (
|
|||||||
expect(metrics.insideContent).toBeTruthy()
|
expect(metrics.insideContent).toBeTruthy()
|
||||||
expect(metrics.orderedPairs.every(pair => pair.separated)).toBeTruthy()
|
expect(metrics.orderedPairs.every(pair => pair.separated)).toBeTruthy()
|
||||||
expect(metrics.goldText).toContain('Au99.99')
|
expect(metrics.goldText).toContain('Au99.99')
|
||||||
|
expect(metrics.weatherText).toContain('降雨60% · AQI 36 优')
|
||||||
|
expect(metrics.oilText).toContain('92# 8.58')
|
||||||
|
expect(metrics.ashareText).toContain('上证 3888.37')
|
||||||
|
expect(metrics.countdownText).toMatch(/还有 \d+ 天|就是今天/)
|
||||||
expect(metrics.goldLabel).toMatch(/市场日期 \d{4}-\d{2}-\d{2}/)
|
expect(metrics.goldLabel).toMatch(/市场日期 \d{4}-\d{2}-\d{2}/)
|
||||||
expect(metrics.secondary.every(line => line.visible && line.scrollWidth <= line.clientWidth)).toBeTruthy()
|
expect(metrics.secondary.every(line => line.visible && line.scrollWidth <= line.clientWidth)).toBeTruthy()
|
||||||
expect(metrics.styles.titleFontWeight).toBe('700')
|
expect(metrics.styles.titleFontWeight).toBe('700')
|
||||||
@@ -247,18 +275,18 @@ test('Today plain-list layout matches the approved responsive geometry', async (
|
|||||||
expect(metrics.styles.filterTop).toBeGreaterThanOrEqual(metrics.ordered[1].top)
|
expect(metrics.styles.filterTop).toBeGreaterThanOrEqual(metrics.ordered[1].top)
|
||||||
expect(metrics.styles.filterTop).toBeLessThanOrEqual(metrics.ordered[1].bottom - metrics.styles.filterHeight)
|
expect(metrics.styles.filterTop).toBeLessThanOrEqual(metrics.ordered[1].bottom - metrics.styles.filterHeight)
|
||||||
expect(metrics.styles.filterRight).toBeCloseTo(metrics.content.right, 0)
|
expect(metrics.styles.filterRight).toBeCloseTo(metrics.content.right, 0)
|
||||||
if (width <= 720) expect(metrics.styles.contextHeight).toBeLessThanOrEqual(210)
|
if (width <= 720) expect(metrics.styles.contextHeight).toBeLessThanOrEqual(240)
|
||||||
expect(metrics.styles.fabShadow).toContain('8px 18px')
|
expect(metrics.styles.fabShadow).toContain('8px 18px')
|
||||||
expect(metrics.styles.fabIconWidth).toBeCloseTo(30, 0)
|
expect(metrics.styles.fabIconWidth).toBeCloseTo(30, 0)
|
||||||
if (width >= 721) {
|
if (width >= 721) {
|
||||||
expect(metrics.uniqueRows).toHaveLength(1)
|
expect(metrics.uniqueRows).toHaveLength(2)
|
||||||
expect(metrics.content.width).toBeCloseTo(width >= 1440 ? 1080 : width > 930 ? Math.min(1080, width - 324) : 630, 0)
|
expect(metrics.content.width).toBeCloseTo(width >= 1440 ? 1080 : width > 930 ? Math.min(1080, width - 324) : 630, 0)
|
||||||
expect(Math.max(...metrics.itemWidths) - Math.min(...metrics.itemWidths)).toBeLessThanOrEqual(1)
|
expect(Math.max(...metrics.itemWidths) - Math.min(...metrics.itemWidths)).toBeLessThanOrEqual(1)
|
||||||
expect(Math.max(...metrics.itemHeights) - Math.min(...metrics.itemHeights)).toBeLessThanOrEqual(1)
|
expect(Math.max(...metrics.itemHeights) - Math.min(...metrics.itemHeights)).toBeLessThanOrEqual(1)
|
||||||
expect(metrics.environment.height).toBeLessThanOrEqual(72)
|
expect(Math.round(metrics.environment.height)).toBe(91)
|
||||||
expect(metrics.styles.titleFontSize).toBe('34px')
|
expect(metrics.styles.titleFontSize).toBe('34px')
|
||||||
} else {
|
} else {
|
||||||
expect(metrics.uniqueRows).toHaveLength(2)
|
expect(metrics.uniqueRows).toHaveLength(4)
|
||||||
expect(metrics.collisionRects[0].width).toBeCloseTo(metrics.content.width, 0)
|
expect(metrics.collisionRects[0].width).toBeCloseTo(metrics.content.width, 0)
|
||||||
expect(metrics.collisionRects[0].left).toBeCloseTo(metrics.content.left, 0)
|
expect(metrics.collisionRects[0].left).toBeCloseTo(metrics.content.left, 0)
|
||||||
expect(metrics.collisionRects[0].right).toBeCloseTo(metrics.content.right, 0)
|
expect(metrics.collisionRects[0].right).toBeCloseTo(metrics.content.right, 0)
|
||||||
@@ -266,15 +294,26 @@ test('Today plain-list layout matches the approved responsive geometry', async (
|
|||||||
expect(metrics.collisionRects[1].left).toBeCloseTo(metrics.content.left, 0)
|
expect(metrics.collisionRects[1].left).toBeCloseTo(metrics.content.left, 0)
|
||||||
expect(metrics.collisionRects[2].right).toBeCloseTo(metrics.content.right, 0)
|
expect(metrics.collisionRects[2].right).toBeCloseTo(metrics.content.right, 0)
|
||||||
expect(metrics.collisionRects[1].right).toBeCloseTo(metrics.collisionRects[2].left, 0)
|
expect(metrics.collisionRects[1].right).toBeCloseTo(metrics.collisionRects[2].left, 0)
|
||||||
|
expect(metrics.collisionRects[3].width).toBeCloseTo(metrics.content.width, 0)
|
||||||
|
expect(metrics.collisionRects[3].left).toBeCloseTo(metrics.content.left, 0)
|
||||||
|
expect(metrics.collisionRects[4].width).toBeCloseTo(metrics.collisionRects[5].width, 0)
|
||||||
|
expect(metrics.collisionRects[4].left).toBeCloseTo(metrics.content.left, 0)
|
||||||
|
expect(metrics.collisionRects[5].right).toBeCloseTo(metrics.content.right, 0)
|
||||||
|
expect(metrics.collisionRects[4].right).toBeCloseTo(metrics.collisionRects[5].left, 0)
|
||||||
|
expect(metrics.collisionRects[4].top).toBeCloseTo(metrics.collisionRects[5].top, 0)
|
||||||
|
expect(metrics.collisionRects[3].top).toBeGreaterThan(metrics.collisionRects[4].top)
|
||||||
expect(metrics.directions.calendar).toBe('row')
|
expect(metrics.directions.calendar).toBe('row')
|
||||||
expect(metrics.directions.weather).toBe('column')
|
expect(metrics.directions.weather).toBe('column')
|
||||||
expect(metrics.directions.gold).toBe('column')
|
expect(metrics.directions.gold).toBe('column')
|
||||||
|
expect(metrics.directions.countdown).toBe('row')
|
||||||
|
expect(metrics.directions.oil).toBe('column')
|
||||||
|
expect(metrics.directions.ashare).toBe('column')
|
||||||
if (width <= 720) {
|
if (width <= 720) {
|
||||||
expect(metrics.content.width).toBeCloseTo(width - 56, 0)
|
expect(metrics.content.width).toBeCloseTo(width - 56, 0)
|
||||||
expect(metrics.content.left).toBeCloseTo(28, 0)
|
expect(metrics.content.left).toBeCloseTo(28, 0)
|
||||||
expect(metrics.styles.mainPaddingLeft).toBe('28px')
|
expect(metrics.styles.mainPaddingLeft).toBe('28px')
|
||||||
expect(metrics.styles.titleFontSize).toBe('24px')
|
expect(metrics.styles.titleFontSize).toBe('24px')
|
||||||
expect(metrics.environment.height).toBeCloseTo(width === 375 ? 79 : 81, 0)
|
expect(Math.round(metrics.environment.height)).toBe(width === 375 ? 154 : 158)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (width >= 721) {
|
if (width >= 721) {
|
||||||
|
|||||||
+28
-3
@@ -6,7 +6,7 @@ import {
|
|||||||
Settings, Trash2, X, Repeat2, StickyNote, TimerReset,
|
Settings, Trash2, X, Repeat2, StickyNote, TimerReset,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, groupTrashTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type AfterCompletionUnit, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
|
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, groupTrashTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type AfterCompletionUnit, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
|
||||||
import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
|
import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readCountdownCache, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
|
||||||
import { csrfHeader } from './lib/csrf'
|
import { csrfHeader } from './lib/csrf'
|
||||||
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
||||||
import { captureListDragPointer, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
|
import { captureListDragPointer, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
|
||||||
@@ -22,7 +22,7 @@ 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 TodayEnvironmentStrip, { type TodayEnvironment } from './components/TodayEnvironmentStrip.vue'
|
import TodayEnvironmentStrip, { type TodayCountdown, type TodayEnvironment } from './components/TodayEnvironmentStrip.vue'
|
||||||
import AppSheet from './components/AppSheet.vue'
|
import AppSheet from './components/AppSheet.vue'
|
||||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||||
import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
|
import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
|
||||||
@@ -126,6 +126,7 @@ 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 todayEnvironment = ref<TodayEnvironment | null>(null)
|
||||||
|
const todayCountdown = ref<TodayCountdown | null>(null)
|
||||||
const todayEnvironmentLoading = ref(false)
|
const todayEnvironmentLoading = ref(false)
|
||||||
const todayEnvironmentError = ref(false)
|
const todayEnvironmentError = ref(false)
|
||||||
const todayEnvironmentDateKey = ref('')
|
const todayEnvironmentDateKey = ref('')
|
||||||
@@ -617,10 +618,34 @@ async function loadTodayTaskSummary() {
|
|||||||
todayTaskTotal.value = overdue + open + completed
|
todayTaskTotal.value = overdue + open + completed
|
||||||
} catch { /* 概览统计失败不阻断今天页 */ }
|
} catch { /* 概览统计失败不阻断今天页 */ }
|
||||||
}
|
}
|
||||||
|
type CachedCountdown = { title?: unknown; days?: unknown; day_text?: unknown; pinned?: unknown }
|
||||||
|
function pickNextTodayCountdown(): TodayCountdown | null {
|
||||||
|
const cache = readCountdownCache<CachedCountdown>()
|
||||||
|
const upcoming = (cache?.items ?? []).filter(
|
||||||
|
(item): item is CachedCountdown & { title: string; days: number } =>
|
||||||
|
!!item && typeof item.title === 'string' && typeof item.days === 'number' && item.days >= 0,
|
||||||
|
)
|
||||||
|
if (!upcoming.length) return null
|
||||||
|
upcoming.sort((a, b) => a.days - b.days || Number(!!b.pinned) - Number(!!a.pinned))
|
||||||
|
const next = upcoming[0]
|
||||||
|
return {
|
||||||
|
title: next.title,
|
||||||
|
days: next.days,
|
||||||
|
day_text: typeof next.day_text === 'string' && next.day_text
|
||||||
|
? next.day_text
|
||||||
|
: next.days === 0 ? '就是今天' : `还有 ${next.days} 天`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function refreshTodayCountdown(token: number) {
|
||||||
|
await preloadCountdowns()
|
||||||
|
if (token !== todayEnvironmentLoadToken || activeView.value !== 'today') return
|
||||||
|
todayCountdown.value = pickNextTodayCountdown()
|
||||||
|
}
|
||||||
async function loadTodayEnvironment() {
|
async function loadTodayEnvironment() {
|
||||||
const token = ++todayEnvironmentLoadToken
|
const token = ++todayEnvironmentLoadToken
|
||||||
todayEnvironmentLoading.value = true
|
todayEnvironmentLoading.value = true
|
||||||
todayEnvironmentError.value = false
|
todayEnvironmentError.value = false
|
||||||
|
void refreshTodayCountdown(token)
|
||||||
try {
|
try {
|
||||||
const data = await api('/today/environment') as TodayEnvironment
|
const data = await api('/today/environment') as TodayEnvironment
|
||||||
if (token !== todayEnvironmentLoadToken || activeView.value !== 'today') return
|
if (token !== todayEnvironmentLoadToken || activeView.value !== 'today') return
|
||||||
@@ -1838,7 +1863,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-context" aria-label="今日概览">
|
<section v-if="activeView==='today'" class="today-context" aria-label="今日概览">
|
||||||
<TodayEnvironmentStrip :environment="todayEnvironment" :loading="todayEnvironmentLoading" :failed="todayEnvironmentError" />
|
<TodayEnvironmentStrip :environment="todayEnvironment" :countdown="todayCountdown" :loading="todayEnvironmentLoading" :failed="todayEnvironmentError" />
|
||||||
<div class="today-heading">
|
<div class="today-heading">
|
||||||
<div><h1 class="today-page-title">今天</h1><p class="today-remaining">还有 {{ todayTaskRemaining + todayHabitRemaining }} 项待完成</p></div>
|
<div><h1 class="today-page-title">今天</h1><p class="today-remaining">还有 {{ todayTaskRemaining + todayHabitRemaining }} 项待完成</p></div>
|
||||||
<CompletedFilterPill v-model="showCompleted" class="today-inline-filter" />
|
<CompletedFilterPill v-model="showCompleted" class="today-inline-filter" />
|
||||||
|
|||||||
@@ -6,13 +6,17 @@ const css = readFileSync('src/style.css', 'utf8')
|
|||||||
|
|
||||||
describe('Today environment integration', () => {
|
describe('Today environment integration', () => {
|
||||||
it('loads environment independently alongside Today task work', () => {
|
it('loads environment independently alongside Today task work', () => {
|
||||||
expect(app).toContain("import TodayEnvironmentStrip, { type TodayEnvironment } from './components/TodayEnvironmentStrip.vue'")
|
expect(app).toContain("import TodayEnvironmentStrip, { type TodayCountdown, type TodayEnvironment } from './components/TodayEnvironmentStrip.vue'")
|
||||||
expect(app).toContain('let todayEnvironmentLoadToken = 0')
|
expect(app).toContain('let todayEnvironmentLoadToken = 0')
|
||||||
expect(app).toContain("api('/today/environment')")
|
expect(app).toContain("api('/today/environment')")
|
||||||
expect(app).toContain('void loadTodayEnvironment()')
|
expect(app).toContain('void loadTodayEnvironment()')
|
||||||
expect(app).toContain('++todayEnvironmentLoadToken')
|
expect(app).toContain('++todayEnvironmentLoadToken')
|
||||||
expect(app).toContain("activeView.value !== 'today'")
|
expect(app).toContain("activeView.value !== 'today'")
|
||||||
expect(app).toContain('<TodayEnvironmentStrip :environment="todayEnvironment" :loading="todayEnvironmentLoading" :failed="todayEnvironmentError" />')
|
expect(app).toContain('<TodayEnvironmentStrip :environment="todayEnvironment" :countdown="todayCountdown" :loading="todayEnvironmentLoading" :failed="todayEnvironmentError" />')
|
||||||
|
expect(app).toContain('const todayCountdown = ref<TodayCountdown | null>(null)')
|
||||||
|
expect(app).toContain('void refreshTodayCountdown(token)')
|
||||||
|
expect(app).toContain('readCountdownCache<CachedCountdown>()')
|
||||||
|
expect(app).toContain('upcoming.sort((a, b) => a.days - b.days || Number(!!b.pinned) - Number(!!a.pinned))')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps environment failures local and retains old values during refresh', () => {
|
it('keeps environment failures local and retains old values during refresh', () => {
|
||||||
@@ -38,8 +42,12 @@ describe('Today environment integration', () => {
|
|||||||
expect(app).toContain('if (todayEnvironmentDateKey.value !== shanghaiDateKey()) void loadTodayEnvironment()')
|
expect(app).toContain('if (todayEnvironmentDateKey.value !== shanghaiDateKey()) void loadTodayEnvironment()')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps mobile environment copy to two rows including refresh states', () => {
|
it('keeps mobile environment copy to four rows including refresh states', () => {
|
||||||
expect(css).toMatch(/@media\(max-width:720px\)\{\.today-environment\{[^}]*grid-template-rows:auto auto/)
|
expect(css).toMatch(/@media\(max-width:720px\)\{\.today-environment\{[^}]*grid-template-rows:auto auto/)
|
||||||
|
expect(css).toContain('.today-environment__oil,.today-environment__ashare{grid-row:3;')
|
||||||
|
expect(css).toContain('.today-environment__oil{grid-column:1;padding-left:0;padding-right:var(--space-10);border-left:0!important}')
|
||||||
|
expect(css).toContain('.today-environment__ashare{grid-column:2;padding-left:var(--space-12)}')
|
||||||
|
expect(css).toContain('.today-environment__countdown{grid-column:1/-1;grid-row:4;')
|
||||||
expect(css).not.toMatch(/\.today-board\{[^}]*grid-template-rows:/)
|
expect(css).not.toMatch(/\.today-board\{[^}]*grid-template-rows:/)
|
||||||
expect(css).not.toContain('@container(max-width:720px){.today-environment')
|
expect(css).not.toContain('@container(max-width:720px){.today-environment')
|
||||||
expect(css).not.toContain('@container(min-width:721px){.today-environment')
|
expect(css).not.toContain('@container(min-width:721px){.today-environment')
|
||||||
@@ -105,6 +113,8 @@ describe('Today environment integration', () => {
|
|||||||
expect(css).toMatch(/\.today-environment__weather\{[^}]*grid-column:1[^}]*grid-row:2/)
|
expect(css).toMatch(/\.today-environment__weather\{[^}]*grid-column:1[^}]*grid-row:2/)
|
||||||
expect(css).toMatch(/\.today-environment__gold\{[^}]*grid-column:2[^}]*grid-row:2/)
|
expect(css).toMatch(/\.today-environment__gold\{[^}]*grid-column:2[^}]*grid-row:2/)
|
||||||
expect(css).toMatch(/\.today-environment__weather,\.today-environment__gold\{[^}]*flex-direction:column/)
|
expect(css).toMatch(/\.today-environment__weather,\.today-environment__gold\{[^}]*flex-direction:column/)
|
||||||
|
expect(css).toContain('.today-environment__item:nth-child(3n+1){border-left:0;padding-left:0}')
|
||||||
|
expect(css).toContain('.today-environment__item:nth-child(n+4){border-top:1px solid var(--border-hairline)}')
|
||||||
expect(css).not.toMatch(/\.today-environment[^}]*overflow-x:auto/)
|
expect(css).not.toMatch(/\.today-environment[^}]*overflow-x:auto/)
|
||||||
expect(css).not.toMatch(/@media\(max-width:720px\)[\s\S]*?\.today-environment[^}]*text-overflow:ellipsis/)
|
expect(css).not.toMatch(/@media\(max-width:720px\)[\s\S]*?\.today-environment[^}]*text-overflow:ellipsis/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import { createApp, h, nextTick } from 'vue'
|
import { createApp, h, nextTick } from 'vue'
|
||||||
import TodayEnvironmentStrip, { type TodayEnvironment } from './TodayEnvironmentStrip.vue'
|
import TodayEnvironmentStrip, { type TodayCountdown, type TodayEnvironment } from './TodayEnvironmentStrip.vue'
|
||||||
|
|
||||||
const cleanups: Array<() => void> = []
|
const cleanups: Array<() => void> = []
|
||||||
|
|
||||||
async function mountStrip(props: { environment: TodayEnvironment | null; loading?: boolean; failed?: boolean }) {
|
async function mountStrip(props: { environment: TodayEnvironment | null; countdown?: TodayCountdown | null; loading?: boolean; failed?: boolean }) {
|
||||||
const host = document.createElement('div')
|
const host = document.createElement('div')
|
||||||
document.body.append(host)
|
document.body.append(host)
|
||||||
const app = createApp(() => h(TodayEnvironmentStrip, props))
|
const app = createApp(() => h(TodayEnvironmentStrip, props))
|
||||||
@@ -151,4 +151,104 @@ describe('TodayEnvironmentStrip', () => {
|
|||||||
const mobileBlock = css.slice(css.indexOf('@media(max-width:720px){.today-environment'), css.indexOf('/* Solid cream material system'))
|
const mobileBlock = css.slice(css.indexOf('@media(max-width:720px){.today-environment'), css.indexOf('/* Solid cream material system'))
|
||||||
expect(mobileBlock).not.toContain('text-overflow:ellipsis')
|
expect(mobileBlock).not.toContain('text-overflow:ellipsis')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows umbrella probability and Chinese AQI instead of the observation time', async () => {
|
||||||
|
const host = await mountStrip({ environment: {
|
||||||
|
calendar: environment.calendar,
|
||||||
|
weather: { status: 'fresh', text: '多云', temperature_c: 27, precipitation_probability: 60, observed_at: '2026-09-16T09:00:00+08:00' },
|
||||||
|
gold: environment.gold,
|
||||||
|
air: { status: 'fresh', aqi: 36, level: '优' },
|
||||||
|
} })
|
||||||
|
const weather = host.querySelector('.today-environment__weather')!
|
||||||
|
expect(weather.querySelector('strong')?.textContent).toBe('宁波 27°C 多云')
|
||||||
|
expect(weather.querySelector('small')?.textContent).toBe('降雨60% · AQI 36 优')
|
||||||
|
expect(host.textContent).not.toContain('海曙')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks stale weather with cached data after the rain and AQI segments', async () => {
|
||||||
|
const host = await mountStrip({ environment: {
|
||||||
|
calendar: environment.calendar,
|
||||||
|
weather: { status: 'stale', text: '小雨', temperature_c: 24, precipitation_probability: 20 },
|
||||||
|
gold: environment.gold,
|
||||||
|
air: { status: 'stale', aqi: 85, level: '良' },
|
||||||
|
} })
|
||||||
|
expect(host.querySelector('.today-environment__weather small')?.textContent).toBe('降雨20% · AQI 85 良 · 缓存')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the observation time when rain and AQI are unavailable', async () => {
|
||||||
|
const host = await mountStrip({ environment: {
|
||||||
|
calendar: environment.calendar,
|
||||||
|
weather: { status: 'fresh', text: '晴', temperature_c: 30, observed_at: '2026-09-16T09:00:00+08:00' },
|
||||||
|
gold: environment.gold,
|
||||||
|
air: null,
|
||||||
|
} })
|
||||||
|
expect(host.querySelector('.today-environment__weather small')?.textContent).toBe('海曙 09:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Zhejiang oil and the SSE index with compact metadata', async () => {
|
||||||
|
const host = await mountStrip({ environment: {
|
||||||
|
calendar: environment.calendar,
|
||||||
|
weather: environment.weather,
|
||||||
|
gold: environment.gold,
|
||||||
|
oil: { status: 'fresh', gas_92: '8.58', gas_95: 9.12, gas_98: '11.12', diesel_0: '8.28', effective_at: '2026-09-24T00:12:00' },
|
||||||
|
ashare: { status: 'fresh', name: '上证指数', price: '3888.3738', prev_close: '3936.5199', change_percent: -1.22, as_of: '2026-09-24' },
|
||||||
|
} })
|
||||||
|
const oil = host.querySelector('.today-environment__oil')!
|
||||||
|
expect(oil.classList.contains('is-fresh')).toBe(true)
|
||||||
|
expect(oil.querySelector('strong')?.textContent).toBe('92# 8.58 · 95# 9.12')
|
||||||
|
expect(oil.querySelector('small')?.textContent).toBe('98# 11.12 · 09-24生效')
|
||||||
|
const ashare = host.querySelector('.today-environment__ashare')!
|
||||||
|
expect(ashare.querySelector('strong')?.textContent).toBe('上证 3888.37')
|
||||||
|
expect(ashare.querySelector('small')?.textContent).toBe('-1.22% · 09-24')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('signs index gains and marks stale oil', async () => {
|
||||||
|
const host = await mountStrip({ environment: {
|
||||||
|
calendar: environment.calendar,
|
||||||
|
weather: environment.weather,
|
||||||
|
gold: environment.gold,
|
||||||
|
oil: { status: 'stale', gas_92: '8.58', gas_95: '9.12', gas_98: '11.12', effective_at: '2026-09-24T00:12:00', stale: true },
|
||||||
|
ashare: { status: 'fresh', name: '上证指数', price: '3900.5', change_percent: 0.35, as_of: '2026-09-25' },
|
||||||
|
} })
|
||||||
|
expect(host.querySelector('.today-environment__ashare small')?.textContent).toBe('+0.35% · 09-25')
|
||||||
|
const oil = host.querySelector('.today-environment__oil')!
|
||||||
|
expect(oil.classList.contains('is-stale')).toBe(true)
|
||||||
|
expect(oil.querySelector('small')?.textContent).toBe('98# 11.12 · 09-24生效 · 缓存')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders unavailable oil and index sources as two-line placeholders', async () => {
|
||||||
|
const host = await mountStrip({ environment: {
|
||||||
|
calendar: environment.calendar,
|
||||||
|
weather: environment.weather,
|
||||||
|
gold: environment.gold,
|
||||||
|
oil: null,
|
||||||
|
ashare: null,
|
||||||
|
} })
|
||||||
|
expect(host.querySelectorAll('.today-environment__oil > .today-environment__skeleton')).toHaveLength(2)
|
||||||
|
expect(host.querySelectorAll('.today-environment__ashare > .today-environment__skeleton')).toHaveLength(2)
|
||||||
|
expect(host.textContent).toContain('浙江油价暂不可用')
|
||||||
|
expect(host.textContent).toContain('上证指数暂不可用')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the new slots when an older payload does not carry their keys', async () => {
|
||||||
|
const host = await mountStrip({ environment })
|
||||||
|
expect(host.querySelector('.today-environment__oil')).toBeNull()
|
||||||
|
expect(host.querySelector('.today-environment__ashare')).toBeNull()
|
||||||
|
expect(host.querySelector('.today-environment__countdown')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the next countdown and truncates very long titles', async () => {
|
||||||
|
const host = await mountStrip({ environment, countdown: { title: '国庆节', days: 6, day_text: '还有 6 天' } })
|
||||||
|
const countdown = host.querySelector('.today-environment__countdown')!
|
||||||
|
expect(countdown.querySelector('strong')?.textContent).toBe('国庆节')
|
||||||
|
expect(countdown.querySelector('small')?.textContent).toBe('还有 6 天')
|
||||||
|
|
||||||
|
const long = await mountStrip({ environment, countdown: { title: '一二三四五六七八九十甲乙丙', days: 3, day_text: '还有 3 天' } })
|
||||||
|
expect(long.querySelector('.today-environment__countdown strong')?.textContent).toBe('一二三四五六七八九十甲…')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a today countdown slot with its own copy', async () => {
|
||||||
|
const host = await mountStrip({ environment, countdown: { title: '中秋节', days: 0, day_text: '就是今天' } })
|
||||||
|
expect(host.querySelector('.today-environment__countdown small')?.textContent).toBe('就是今天')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type WeatherValue = {
|
|||||||
text?: string | null
|
text?: string | null
|
||||||
temperature_c?: number | null
|
temperature_c?: number | null
|
||||||
weather_code?: number | null
|
weather_code?: number | null
|
||||||
|
precipitation_probability?: number | null
|
||||||
observed_at?: string | null
|
observed_at?: string | null
|
||||||
source?: string | null
|
source?: string | null
|
||||||
stale?: boolean
|
stale?: boolean
|
||||||
@@ -26,19 +27,54 @@ type GoldValue = {
|
|||||||
delayed?: boolean
|
delayed?: boolean
|
||||||
stale?: boolean
|
stale?: boolean
|
||||||
}
|
}
|
||||||
|
type AirValue = {
|
||||||
|
status?: EnvironmentStatus
|
||||||
|
aqi?: number | null
|
||||||
|
level?: string | null
|
||||||
|
pm2_5?: number | null
|
||||||
|
pm10?: number | null
|
||||||
|
observed_at?: string | null
|
||||||
|
source?: string | null
|
||||||
|
stale?: boolean
|
||||||
|
}
|
||||||
|
type OilValue = {
|
||||||
|
status?: EnvironmentStatus
|
||||||
|
gas_92?: number | string | null
|
||||||
|
gas_95?: number | string | null
|
||||||
|
gas_98?: number | string | null
|
||||||
|
diesel_0?: number | string | null
|
||||||
|
effective_at?: string | null
|
||||||
|
source?: string | null
|
||||||
|
stale?: boolean
|
||||||
|
}
|
||||||
|
type AshareValue = {
|
||||||
|
status?: EnvironmentStatus
|
||||||
|
name?: string | null
|
||||||
|
price?: number | string | null
|
||||||
|
prev_close?: number | string | null
|
||||||
|
change_percent?: number | null
|
||||||
|
as_of?: string | null
|
||||||
|
source?: string | null
|
||||||
|
stale?: boolean
|
||||||
|
}
|
||||||
|
export type TodayCountdown = { title: string; days: number; day_text: string }
|
||||||
export type TodayEnvironment = {
|
export type TodayEnvironment = {
|
||||||
calendar?: CalendarValue
|
calendar?: CalendarValue
|
||||||
date?: ApiDateValue
|
date?: ApiDateValue
|
||||||
weather: WeatherValue | null
|
weather: WeatherValue | null
|
||||||
gold: GoldValue | null
|
gold: GoldValue | null
|
||||||
|
air?: AirValue | null
|
||||||
|
oil?: OilValue | null
|
||||||
|
ashare?: AshareValue | null
|
||||||
errors?: Record<string, string>
|
errors?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
environment: TodayEnvironment | null
|
environment: TodayEnvironment | null
|
||||||
|
countdown?: TodayCountdown | null
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
failed?: boolean
|
failed?: boolean
|
||||||
}>(), { loading: false, failed: false })
|
}>(), { countdown: null, loading: false, failed: false })
|
||||||
|
|
||||||
const calendar = computed<CalendarValue | null>(() => {
|
const calendar = computed<CalendarValue | null>(() => {
|
||||||
if (props.environment?.calendar) return props.environment.calendar
|
if (props.environment?.calendar) return props.environment.calendar
|
||||||
@@ -95,6 +131,79 @@ const weatherObservationTime = computed(() => {
|
|||||||
if (Number.isNaN(observed.getTime())) return ''
|
if (Number.isNaN(observed.getTime())) return ''
|
||||||
return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour: '2-digit', minute: '2-digit', hour12: false }).format(observed)
|
return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour: '2-digit', minute: '2-digit', hour12: false }).format(observed)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function joinSegments(segments: Array<string | null>): string {
|
||||||
|
return segments.filter((segment): segment is string => Boolean(segment)).join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const weatherMeta = computed(() => {
|
||||||
|
const weather = props.environment?.weather
|
||||||
|
if (!weather || weatherStatus.value === 'unavailable') return ''
|
||||||
|
const rain = typeof weather.precipitation_probability === 'number' ? weather.precipitation_probability : null
|
||||||
|
const air = props.environment?.air
|
||||||
|
const aqi = air && typeof air.aqi === 'number' ? air.aqi : null
|
||||||
|
const segments: Array<string | null> = []
|
||||||
|
if (rain != null) segments.push(`降雨${rain}%`)
|
||||||
|
if (aqi != null) segments.push(`AQI ${aqi}${air?.level ? ` ${air.level}` : ''}`)
|
||||||
|
if (!segments.length && weatherObservationTime.value) segments.push(`海曙 ${weatherObservationTime.value}`)
|
||||||
|
if (weatherStatus.value === 'stale') segments.push('缓存')
|
||||||
|
return joinSegments(segments)
|
||||||
|
})
|
||||||
|
|
||||||
|
function sourceStatus(value: AirValue | OilValue | AshareValue | null | undefined): EnvironmentStatus {
|
||||||
|
if (!value) return 'unavailable'
|
||||||
|
return value.status ?? (value.stale ? 'stale' : 'fresh')
|
||||||
|
}
|
||||||
|
const hasOil = computed(() => props.environment != null && 'oil' in props.environment)
|
||||||
|
const hasAshare = computed(() => props.environment != null && 'ashare' in props.environment)
|
||||||
|
const oilStatus = computed(() => sourceStatus(props.environment?.oil))
|
||||||
|
const ashareStatus = computed(() => sourceStatus(props.environment?.ashare))
|
||||||
|
|
||||||
|
function priceText(value: number | string | null | undefined): string | null {
|
||||||
|
const numeric = typeof value === 'string' ? Number(value) : value
|
||||||
|
if (typeof numeric !== 'number' || !Number.isFinite(numeric)) return null
|
||||||
|
return numeric.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const oilPrimary = computed(() => {
|
||||||
|
const oil = props.environment?.oil
|
||||||
|
if (!oil) return null
|
||||||
|
const gas92 = priceText(oil.gas_92)
|
||||||
|
const gas95 = priceText(oil.gas_95)
|
||||||
|
return joinSegments([gas92 && `92# ${gas92}`, gas95 && `95# ${gas95}`]) || null
|
||||||
|
})
|
||||||
|
const oilMeta = computed(() => {
|
||||||
|
const oil = props.environment?.oil
|
||||||
|
if (!oil || oilStatus.value === 'unavailable') return ''
|
||||||
|
const gas98 = priceText(oil.gas_98)
|
||||||
|
const effective = typeof oil.effective_at === 'string'
|
||||||
|
? oil.effective_at.match(/^\d{4}-(\d{2}-\d{2})/)?.[1] ?? null
|
||||||
|
: null
|
||||||
|
return joinSegments([
|
||||||
|
gas98 && `98# ${gas98}`,
|
||||||
|
effective && `${effective}生效`,
|
||||||
|
oilStatus.value === 'stale' ? '缓存' : null,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
const ashareLabel = computed(() => {
|
||||||
|
const name = props.environment?.ashare?.name
|
||||||
|
return name ? name.replace('指数', '') : '上证'
|
||||||
|
})
|
||||||
|
const asharePrice = computed(() => priceText(props.environment?.ashare?.price))
|
||||||
|
const ashareMeta = computed(() => {
|
||||||
|
const ashare = props.environment?.ashare
|
||||||
|
if (!ashare || ashareStatus.value === 'unavailable') return ''
|
||||||
|
const change = typeof ashare.change_percent === 'number' && Number.isFinite(ashare.change_percent)
|
||||||
|
? `${ashare.change_percent > 0 ? '+' : ''}${ashare.change_percent.toFixed(2)}%`
|
||||||
|
: null
|
||||||
|
const asOf = typeof ashare.as_of === 'string' ? ashare.as_of.match(/^\d{4}-(\d{2}-\d{2})/)?.[1] ?? null : null
|
||||||
|
return joinSegments([change, asOf, ashareStatus.value === 'stale' ? '缓存' : null])
|
||||||
|
})
|
||||||
|
const countdownTitle = computed(() => {
|
||||||
|
const title = props.countdown?.title?.trim() ?? ''
|
||||||
|
if (title.length > 12) return `${title.slice(0, 11)}…`
|
||||||
|
return title
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -116,7 +225,7 @@ const weatherObservationTime = computed(() => {
|
|||||||
<span class="today-environment__item today-environment__weather" :class="`is-${weatherStatus}`">
|
<span class="today-environment__item today-environment__weather" :class="`is-${weatherStatus}`">
|
||||||
<template v-if="weatherStatus !== 'unavailable'">
|
<template v-if="weatherStatus !== 'unavailable'">
|
||||||
<strong>宁波 <template v-if="environment.weather?.temperature_c != null">{{ environment.weather.temperature_c }}°C </template>{{ weatherText }}</strong>
|
<strong>宁波 <template v-if="environment.weather?.temperature_c != null">{{ environment.weather.temperature_c }}°C </template>{{ weatherText }}</strong>
|
||||||
<small>{{ `海曙${weatherObservationTime ? ` ${weatherObservationTime}` : ''}` }}<template v-if="weatherStatus === 'stale'"> · 缓存</template></small>
|
<small v-if="weatherMeta">{{ weatherMeta }}</small>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="today-environment__skeleton" aria-hidden="true"></span>
|
<span class="today-environment__skeleton" aria-hidden="true"></span>
|
||||||
@@ -135,6 +244,32 @@ const weatherObservationTime = computed(() => {
|
|||||||
<span class="sr-only">Au99.99 暂不可用</span>
|
<span class="sr-only">Au99.99 暂不可用</span>
|
||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="countdown && countdownTitle" class="today-environment__item today-environment__countdown">
|
||||||
|
<strong>{{ countdownTitle }}</strong>
|
||||||
|
<small>{{ countdown.day_text }}</small>
|
||||||
|
</span>
|
||||||
|
<span v-if="hasOil" class="today-environment__item today-environment__oil" :class="`is-${oilStatus}`">
|
||||||
|
<template v-if="oilStatus !== 'unavailable' && oilPrimary">
|
||||||
|
<strong>{{ oilPrimary }}</strong>
|
||||||
|
<small v-if="oilMeta">{{ oilMeta }}</small>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="today-environment__skeleton" aria-hidden="true"></span>
|
||||||
|
<span class="today-environment__skeleton today-environment__skeleton--short" aria-hidden="true"></span>
|
||||||
|
<span class="sr-only">浙江油价暂不可用</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<span v-if="hasAshare" class="today-environment__item today-environment__ashare" :class="`is-${ashareStatus}`">
|
||||||
|
<template v-if="ashareStatus !== 'unavailable' && asharePrice">
|
||||||
|
<strong>{{ ashareLabel }} {{ asharePrice }}</strong>
|
||||||
|
<small v-if="ashareMeta">{{ ashareMeta }}</small>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="today-environment__skeleton" aria-hidden="true"></span>
|
||||||
|
<span class="today-environment__skeleton today-environment__skeleton--short" aria-hidden="true"></span>
|
||||||
|
<span class="sr-only">上证指数暂不可用</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -184,13 +184,16 @@ main.today-main .topbar{margin-bottom:var(--space-12)}
|
|||||||
@media(min-width:721px){.today-environment__calendar{flex-direction:column;align-items:flex-start;gap:0}.today-environment__calendar small{margin-top:var(--space-2);font-size:11px}}
|
@media(min-width:721px){.today-environment__calendar{flex-direction:column;align-items:flex-start;gap:0}.today-environment__calendar small{margin-top:var(--space-2);font-size:11px}}
|
||||||
.today-environment__weather strong,.today-environment__gold strong,.today-environment__gold-primary{margin-top:0;font-size:13px;font-weight:600}
|
.today-environment__weather strong,.today-environment__gold strong,.today-environment__gold-primary{margin-top:0;font-size:13px;font-weight:600}
|
||||||
.today-environment__item small{margin-top:var(--space-2);font-size:11px}
|
.today-environment__item small{margin-top:var(--space-2);font-size:11px}
|
||||||
|
.today-environment__item:nth-child(3n+1){border-left:0;padding-left:0}
|
||||||
|
.today-environment__item:nth-child(n+4){border-top:1px solid var(--border-hairline)}
|
||||||
|
.today-environment__countdown strong,.today-environment__oil strong,.today-environment__ashare strong{display:block;color:var(--text-primary);font-size:13px;line-height:1.2;font-weight:600}
|
||||||
.today-main :is(.task-row,.habit-row){height:58px;min-height:58px;max-height:58px;padding:0;background:transparent;border:0;border-bottom:1px solid var(--border-hairline);border-radius:0;box-shadow:none}
|
.today-main :is(.task-row,.habit-row){height:58px;min-height:58px;max-height:58px;padding:0;background:transparent;border:0;border-bottom:1px solid var(--border-hairline);border-radius:0;box-shadow:none}
|
||||||
.today-main .task-row{gap:0}.today-main .task-main{padding:0}.today-main .task-main strong,.today-main .habit-name{font-size:15px;font-weight:400}
|
.today-main .task-row{gap:0}.today-main .task-main{padding:0}.today-main .task-main strong,.today-main .habit-name{font-size:15px;font-weight:400}
|
||||||
.today-main .task-tail{padding-left:var(--space-12);font-size:12px}.today-main .habit-row{gap:0;row-gap:5px}.today-main .habit-main{min-width:0;padding:0}
|
.today-main .task-tail{padding-left:var(--space-12);font-size:12px}.today-main .habit-row{gap:0;row-gap:5px}.today-main .habit-main{min-width:0;padding:0}
|
||||||
.shell.today-active .unified-fab{box-shadow:0 8px 18px rgba(174,65,29,.22)}.shell.today-active .unified-fab>svg{width:30px;height:30px;stroke-width:1.5}
|
.shell.today-active .unified-fab{box-shadow:0 8px 18px rgba(174,65,29,.22)}.shell.today-active .unified-fab>svg{width:30px;height:30px;stroke-width:1.5}
|
||||||
@media(max-width:930px){main.today-main{padding-left:max(44px,calc((100% - 630px)/2));padding-right:max(44px,calc((100% - 630px)/2))}}
|
@media(max-width:930px){main.today-main{padding-left:max(44px,calc((100% - 630px)/2));padding-right:max(44px,calc((100% - 630px)/2))}}
|
||||||
@media(max-width:720.98px){main.today-main{padding-left:var(--space-28)!important;padding-right:var(--space-28)!important;padding-bottom:calc(78px + var(--safe-area-bottom))}.today-environment{grid-template-columns:minmax(0,1fr) minmax(0,1fr);grid-template-rows:21px auto;align-items:stretch;column-gap:0;row-gap:7px;padding-bottom:var(--space-10)}.today-environment__calendar{grid-column:1/-1;grid-row:1;height:21px!important;padding:0;justify-content:space-between;align-items:start;border-left:0}.today-environment__weather,.today-environment__gold{grid-row:2;height:clamp(38px,calc(25vw - 55.75px),42px);padding-top:var(--space-6);border-top:1px solid var(--border-hairline)}.today-environment__weather{grid-column:1;padding-left:0;padding-right:var(--space-10);border-left:0}.today-environment__gold{grid-column:2;padding-left:var(--space-12)}.today-environment__weather strong,.today-environment__gold strong,.today-environment__gold-primary{line-height:16px}.today-environment__item small{line-height:13px}.today-heading{margin-top:var(--space-16)}.today-page-title{font-size:24px}.today-heading .today-remaining{margin-top:var(--space-4)}.today-main .task-row,.today-main .habit-row{height:58px;min-height:58px;max-height:58px}.today-context .completed-filter-pill{width:87px;height:44px;min-height:44px}.today-context .completed-filter-pill__track{width:30px;height:18px}.today-context .completed-filter-pill__thumb{width:14px;height:14px}.today-context .completed-filter-pill[aria-checked="true"] .completed-filter-pill__thumb{transform:translateX(12px)}}
|
@media(max-width:720.98px){main.today-main{padding-left:var(--space-28)!important;padding-right:var(--space-28)!important;padding-bottom:calc(78px + var(--safe-area-bottom))}.today-environment{grid-template-columns:minmax(0,1fr) minmax(0,1fr);grid-template-rows:21px auto;align-items:stretch;column-gap:0;row-gap:7px;padding-bottom:var(--space-10)}.today-environment__calendar{grid-column:1/-1;grid-row:1;height:21px!important;padding:0;justify-content:space-between;align-items:start;border-left:0}.today-environment__weather,.today-environment__gold{grid-row:2;height:clamp(38px,calc(25vw - 55.75px),42px);padding-top:var(--space-6);border-top:1px solid var(--border-hairline)}.today-environment__weather{grid-column:1;padding-left:0;padding-right:var(--space-10);border-left:0}.today-environment__gold{grid-column:2;padding-left:var(--space-12)}.today-environment__weather strong,.today-environment__gold strong,.today-environment__gold-primary{line-height:16px}.today-environment__oil,.today-environment__ashare{grid-row:3;height:clamp(38px,calc(25vw - 55.75px),42px);padding-top:var(--space-6);padding-bottom:0}.today-environment__oil{grid-column:1;padding-left:0;padding-right:var(--space-10);border-left:0!important}.today-environment__ashare{grid-column:2;padding-left:var(--space-12)}.today-environment__countdown{grid-column:1/-1;grid-row:4;height:21px;padding:0;flex-direction:row;align-items:baseline;justify-content:space-between;gap:10px}.today-environment__countdown strong,.today-environment__oil strong,.today-environment__ashare strong{line-height:16px}.today-environment__item small{line-height:13px}.today-heading{margin-top:var(--space-16)}.today-page-title{font-size:24px}.today-heading .today-remaining{margin-top:var(--space-4)}.today-main .task-row,.today-main .habit-row{height:58px;min-height:58px;max-height:58px}.today-context .completed-filter-pill{width:87px;height:44px;min-height:44px}.today-context .completed-filter-pill__track{width:30px;height:18px}.today-context .completed-filter-pill__thumb{width:14px;height:14px}.today-context .completed-filter-pill[aria-checked="true"] .completed-filter-pill__thumb{transform:translateX(12px)}}
|
||||||
@media(max-width:380px){.today-environment__weather,.today-environment__gold{height:40px;padding-top:var(--space-4);padding-bottom:var(--space-4)}}
|
@media(max-width:380px){.today-environment__weather,.today-environment__gold,.today-environment__oil,.today-environment__ashare{height:40px;padding-top:var(--space-4);padding-bottom:var(--space-4)}}
|
||||||
|
|
||||||
/* Approved Settings 01: continuous paper ledger. */
|
/* Approved Settings 01: continuous paper ledger. */
|
||||||
main:has(>.mvp-view .settings-sections){background:var(--surface-raised)}
|
main:has(>.mvp-view .settings-sections){background:var(--surface-raised)}
|
||||||
|
|||||||
@@ -125,7 +125,11 @@ async def test_fetch_weather_uses_fixed_haishu_location_and_current_conditions()
|
|||||||
"temperature_2m": 28.4,
|
"temperature_2m": 28.4,
|
||||||
"apparent_temperature": 30.1,
|
"apparent_temperature": 30.1,
|
||||||
"weather_code": 2,
|
"weather_code": 2,
|
||||||
}
|
},
|
||||||
|
"hourly": {
|
||||||
|
"time": ["2026-09-16T14:00", "2026-09-16T15:00", "2026-09-16T16:00"],
|
||||||
|
"precipitation_probability": [40, 75, None],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await environment.fetch_weather(request)
|
result = await environment.fetch_weather(request)
|
||||||
@@ -136,6 +140,7 @@ async def test_fetch_weather_uses_fixed_haishu_location_and_current_conditions()
|
|||||||
"latitude": 29.88,
|
"latitude": 29.88,
|
||||||
"longitude": 121.55,
|
"longitude": 121.55,
|
||||||
"current": "temperature_2m,apparent_temperature,weather_code",
|
"current": "temperature_2m,apparent_temperature,weather_code",
|
||||||
|
"hourly": "precipitation_probability",
|
||||||
"timezone": "Asia/Shanghai",
|
"timezone": "Asia/Shanghai",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -143,6 +148,7 @@ async def test_fetch_weather_uses_fixed_haishu_location_and_current_conditions()
|
|||||||
"temperature_c": 28.4,
|
"temperature_c": 28.4,
|
||||||
"apparent_temperature_c": 30.1,
|
"apparent_temperature_c": 30.1,
|
||||||
"weather_code": 2,
|
"weather_code": 2,
|
||||||
|
"precipitation_probability": 75,
|
||||||
"observed_at": "2026-09-16T14:15:00+08:00",
|
"observed_at": "2026-09-16T14:15:00+08:00",
|
||||||
"source": "Open-Meteo",
|
"source": "Open-Meteo",
|
||||||
}
|
}
|
||||||
@@ -171,6 +177,102 @@ async def test_fetch_weather_rejects_invalid_contract(current):
|
|||||||
await environment.fetch_weather(request)
|
await environment.fetch_weather(request)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"hourly",
|
||||||
|
[
|
||||||
|
None,
|
||||||
|
[],
|
||||||
|
"now",
|
||||||
|
{"time": ["2026-09-16T14:00"]},
|
||||||
|
{"time": [], "precipitation_probability": []},
|
||||||
|
{"time": ["2026-09-16T14:00"], "precipitation_probability": [10, 20]},
|
||||||
|
{"time": [123], "precipitation_probability": [10]},
|
||||||
|
{"time": ["2026-09-16T14:00"], "precipitation_probability": ["40"]},
|
||||||
|
{"time": ["2026-09-16T14:00"], "precipitation_probability": [True]},
|
||||||
|
{"time": ["2026-09-16T14:00"], "precipitation_probability": [120]},
|
||||||
|
{"time": ["2026-09-16T14:00"], "precipitation_probability": [-1]},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_fetch_weather_rejects_invalid_precipitation_hourly(hourly):
|
||||||
|
payload = {
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"current": {
|
||||||
|
"time": "2026-09-16T14:15",
|
||||||
|
"temperature_2m": 28.4,
|
||||||
|
"apparent_temperature": 30.1,
|
||||||
|
"weather_code": 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if hourly is not None:
|
||||||
|
payload["hourly"] = hourly
|
||||||
|
|
||||||
|
async def request(*_args, **_kwargs):
|
||||||
|
return payload
|
||||||
|
|
||||||
|
with pytest.raises((KeyError, TypeError, ValueError)):
|
||||||
|
await environment.fetch_weather(request)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("probabilities", "expected"),
|
||||||
|
[
|
||||||
|
([40, 75, None], 75),
|
||||||
|
([None, None, None], None),
|
||||||
|
([0], 0),
|
||||||
|
([None, 60], 60),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_fetch_weather_precipitation_probability_window(probabilities, expected):
|
||||||
|
payload = {
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"current": {
|
||||||
|
"time": "2026-09-16T14:15",
|
||||||
|
"temperature_2m": 28.4,
|
||||||
|
"apparent_temperature": 30.1,
|
||||||
|
"weather_code": 2,
|
||||||
|
},
|
||||||
|
"hourly": {
|
||||||
|
"time": [
|
||||||
|
"2026-09-16T13:00",
|
||||||
|
"2026-09-16T14:00",
|
||||||
|
"2026-09-16T15:00",
|
||||||
|
"2026-09-16T16:00",
|
||||||
|
][: len(probabilities) + 1],
|
||||||
|
"precipitation_probability": [99, *probabilities],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def request(*_args, **_kwargs):
|
||||||
|
return payload
|
||||||
|
|
||||||
|
result = await environment.fetch_weather(request)
|
||||||
|
|
||||||
|
assert result["precipitation_probability"] == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_weather_precipitation_probability_none_when_hour_slot_missing():
|
||||||
|
payload = {
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"current": {
|
||||||
|
"time": "2026-09-16T14:15",
|
||||||
|
"temperature_2m": 28.4,
|
||||||
|
"apparent_temperature": 30.1,
|
||||||
|
"weather_code": 2,
|
||||||
|
},
|
||||||
|
"hourly": {"time": ["2026-09-20T14:00"], "precipitation_probability": [40]},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def request(*_args, **_kwargs):
|
||||||
|
return payload
|
||||||
|
|
||||||
|
result = await environment.fetch_weather(request)
|
||||||
|
|
||||||
|
assert result["precipitation_probability"] is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fetch_weather_rejects_wrong_response_timezone():
|
async def test_fetch_weather_rejects_wrong_response_timezone():
|
||||||
async def request(*_args, **_kwargs):
|
async def request(*_args, **_kwargs):
|
||||||
@@ -613,6 +715,10 @@ async def test_creator_cancellation_does_not_close_shared_default_fetch_client(m
|
|||||||
"apparent_temperature": 26,
|
"apparent_temperature": 26,
|
||||||
"weather_code": 1,
|
"weather_code": 1,
|
||||||
},
|
},
|
||||||
|
"hourly": {
|
||||||
|
"time": ["2026-09-16T14:00"],
|
||||||
|
"precipitation_probability": [10],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -774,7 +880,7 @@ def test_today_environment_endpoint_returns_aggregated_payload(client, monkeypat
|
|||||||
"/api/v1/setup/initialize",
|
"/api/v1/setup/initialize",
|
||||||
json={"username": "owner", "password": "correct horse battery staple"},
|
json={"username": "owner", "password": "correct horse battery staple"},
|
||||||
)
|
)
|
||||||
payload = {
|
environment_payload = {
|
||||||
"date": {
|
"date": {
|
||||||
"solar_date": "2026-09-16",
|
"solar_date": "2026-09-16",
|
||||||
"weekday": "星期三",
|
"weekday": "星期三",
|
||||||
@@ -785,12 +891,36 @@ def test_today_environment_endpoint_returns_aggregated_payload(client, monkeypat
|
|||||||
"gold": None,
|
"gold": None,
|
||||||
"errors": {"weather": "unavailable", "gold": "unavailable"},
|
"errors": {"weather": "unavailable", "gold": "unavailable"},
|
||||||
}
|
}
|
||||||
|
extras_payload = {
|
||||||
|
"air": None,
|
||||||
|
"oil": None,
|
||||||
|
"ashare": None,
|
||||||
|
"errors": {"air": "unavailable", "oil": "unavailable", "ashare": "unavailable"},
|
||||||
|
}
|
||||||
|
|
||||||
async def aggregate():
|
async def aggregate():
|
||||||
return payload
|
return environment_payload
|
||||||
|
|
||||||
|
async def aggregate_extras():
|
||||||
|
return extras_payload
|
||||||
|
|
||||||
monkeypatch.setattr("backend.main.get_today_environment", aggregate)
|
monkeypatch.setattr("backend.main.get_today_environment", aggregate)
|
||||||
|
monkeypatch.setattr("backend.main.get_today_environment_extras", aggregate_extras)
|
||||||
response = client.get("/api/v1/today/environment")
|
response = client.get("/api/v1/today/environment")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == payload
|
assert response.json() == {
|
||||||
|
"date": environment_payload["date"],
|
||||||
|
"weather": None,
|
||||||
|
"gold": None,
|
||||||
|
"air": None,
|
||||||
|
"oil": None,
|
||||||
|
"ashare": None,
|
||||||
|
"errors": {
|
||||||
|
"weather": "unavailable",
|
||||||
|
"gold": "unavailable",
|
||||||
|
"air": "unavailable",
|
||||||
|
"oil": "unavailable",
|
||||||
|
"ashare": "unavailable",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend import today_environment as environment
|
||||||
|
|
||||||
|
SINA_QUOTE = (
|
||||||
|
'var hq_str_sh000001="上证指数,3925.3230,3936.5199,3888.3738,3930.4955,3888.3738,0,0,'
|
||||||
|
"438530412,783613001376,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,"
|
||||||
|
'2026-09-24,15:36:00,00,";'
|
||||||
|
)
|
||||||
|
|
||||||
|
OIL_PAYLOAD = {
|
||||||
|
"data": {
|
||||||
|
"provinceData": {
|
||||||
|
"GAS_92": 8.58,
|
||||||
|
"GAS_95": 9.12,
|
||||||
|
"AIPAO_GAS_98": 11.12,
|
||||||
|
"CHECHAI_0": 8.28,
|
||||||
|
"START_DATE": "2026-09-24 00:12:00",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AIR_PAYLOAD = {
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"current": {
|
||||||
|
"time": "2026-09-16T14:00",
|
||||||
|
"pm2_5": 25.5,
|
||||||
|
"pm10": 33.8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("pm2_5", "pm10", "expected"),
|
||||||
|
[
|
||||||
|
(0.0, 0.0, 0),
|
||||||
|
(35.0, 0.0, 50),
|
||||||
|
(36.0, 0.0, 51),
|
||||||
|
(75.0, 0.0, 100),
|
||||||
|
(115.0, 0.0, 150),
|
||||||
|
(150.0, 0.0, 200),
|
||||||
|
(250.0, 0.0, 300),
|
||||||
|
(350.0, 0.0, 400),
|
||||||
|
(500.0, 0.0, 500),
|
||||||
|
(600.0, 0.0, 500),
|
||||||
|
(25.5, 0.0, 36),
|
||||||
|
(0.0, 50.0, 50),
|
||||||
|
(0.0, 100.0, 75),
|
||||||
|
(0.0, 150.0, 100),
|
||||||
|
(0.0, 600.0, 500),
|
||||||
|
(10.0, 100.0, 75),
|
||||||
|
(75.0, 10.0, 100),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_china_aqi_interpolates_breakpoints_and_uses_the_worst_pollutant(pm2_5, pm10, expected):
|
||||||
|
assert environment.china_aqi(pm2_5, pm10) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("aqi", "expected"),
|
||||||
|
[
|
||||||
|
(0, "优"),
|
||||||
|
(50, "优"),
|
||||||
|
(51, "良"),
|
||||||
|
(100, "良"),
|
||||||
|
(101, "轻度污染"),
|
||||||
|
(150, "轻度污染"),
|
||||||
|
(151, "中度污染"),
|
||||||
|
(200, "中度污染"),
|
||||||
|
(201, "重度污染"),
|
||||||
|
(300, "重度污染"),
|
||||||
|
(301, "严重污染"),
|
||||||
|
(500, "严重污染"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_aqi_level_boundaries(aqi, expected):
|
||||||
|
assert environment.aqi_level(aqi) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_air_uses_fixed_haishu_location_and_chinese_aqi():
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
async def request(url, **kwargs):
|
||||||
|
seen["url"] = url
|
||||||
|
seen["params"] = kwargs["params"]
|
||||||
|
return AIR_PAYLOAD
|
||||||
|
|
||||||
|
result = await environment.fetch_air(request)
|
||||||
|
|
||||||
|
assert seen == {
|
||||||
|
"url": environment.AIR_URL,
|
||||||
|
"params": {
|
||||||
|
"latitude": 29.88,
|
||||||
|
"longitude": 121.55,
|
||||||
|
"current": "pm2_5,pm10",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert result == {
|
||||||
|
"aqi": 36,
|
||||||
|
"level": "优",
|
||||||
|
"pm2_5": 25.5,
|
||||||
|
"pm10": 33.8,
|
||||||
|
"observed_at": "2026-09-16T14:00:00+08:00",
|
||||||
|
"source": "Open-Meteo 空气质量",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"payload",
|
||||||
|
[
|
||||||
|
{**AIR_PAYLOAD, "timezone": "UTC"},
|
||||||
|
{**AIR_PAYLOAD, "current": {}},
|
||||||
|
{**AIR_PAYLOAD, "current": {**AIR_PAYLOAD["current"], "pm2_5": True}},
|
||||||
|
{**AIR_PAYLOAD, "current": {**AIR_PAYLOAD["current"], "pm10": float("nan")}},
|
||||||
|
{**AIR_PAYLOAD, "current": {**AIR_PAYLOAD["current"], "pm2_5": -1}},
|
||||||
|
{**AIR_PAYLOAD, "current": {**AIR_PAYLOAD["current"], "pm10": 5000}},
|
||||||
|
{**AIR_PAYLOAD, "current": {**AIR_PAYLOAD["current"], "time": "2026-13-40T00:00"}},
|
||||||
|
{**AIR_PAYLOAD, "current": {**AIR_PAYLOAD["current"], "time": "2026-09-16T14:00+00:00"}},
|
||||||
|
{"timezone": "Asia/Shanghai"},
|
||||||
|
{"timezone": "Asia/Shanghai", "current": []},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_fetch_air_rejects_invalid_contract(payload):
|
||||||
|
async def request(*_args, **_kwargs):
|
||||||
|
return payload
|
||||||
|
|
||||||
|
with pytest.raises((KeyError, TypeError, ValueError)):
|
||||||
|
await environment.fetch_air(request)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_sina_index_returns_price_change_and_quote_date():
|
||||||
|
result = environment.parse_sina_index(SINA_QUOTE)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"name": "上证指数",
|
||||||
|
"price": Decimal("3888.3738"),
|
||||||
|
"prev_close": Decimal("3936.5199"),
|
||||||
|
"change_percent": -1.22,
|
||||||
|
"as_of": "2026-09-24",
|
||||||
|
"source": "新浪财经",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"text",
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"var hq_str_sh000001=",
|
||||||
|
'var hq_str_sz399001="深证成指,1,2,3,4";',
|
||||||
|
'var hq_str_sh000001="上证指数,1,2";',
|
||||||
|
'var hq_str_sh000001="上证指数,0,0,3888.37,1";',
|
||||||
|
'var hq_str_sh000001="上证指数,3925.32,3936.52,abc,1";',
|
||||||
|
'var hq_str_sh000001="上证指数,3925.32,3936.52,3888.37";',
|
||||||
|
'var hq_str_sh000001="上证指数,3925.32,3936.52,3888.37,1,1,2026-13-40";',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_sina_index_rejects_unexpected_quotes(text):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
environment.parse_sina_index(text)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_ashare_decodes_gbk_quote_bytes():
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
async def request(url, **kwargs):
|
||||||
|
seen["url"] = url
|
||||||
|
seen["headers"] = kwargs["headers"]
|
||||||
|
return SINA_QUOTE.encode("gbk")
|
||||||
|
|
||||||
|
result = await environment.fetch_ashare(request)
|
||||||
|
|
||||||
|
assert seen["url"] == environment.ASHARE_URL
|
||||||
|
assert seen["headers"] == environment.ASHARE_HEADERS
|
||||||
|
assert result["change_percent"] == -1.22
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_ashare_rejects_non_gbk_bytes():
|
||||||
|
async def request(*_args, **_kwargs):
|
||||||
|
return b'\x80\x81\x82 "'
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await environment.fetch_ashare(request)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_oil_payload_reads_zhejiang_prices_and_effective_date():
|
||||||
|
result = environment.parse_oil_payload(OIL_PAYLOAD)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"gas_92": Decimal("8.58"),
|
||||||
|
"gas_95": Decimal("9.12"),
|
||||||
|
"gas_98": Decimal("11.12"),
|
||||||
|
"diesel_0": Decimal("8.28"),
|
||||||
|
"effective_at": "2026-09-24T00:12:00+08:00",
|
||||||
|
"source": "中国石化",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"payload",
|
||||||
|
[
|
||||||
|
[],
|
||||||
|
{},
|
||||||
|
{"data": {}},
|
||||||
|
{"data": {"provinceData": []}},
|
||||||
|
{"data": {"provinceData": {"GAS_95": 9.12, "AIPAO_GAS_98": 11.12, "CHECHAI_0": 8.28}}},
|
||||||
|
{"data": {"provinceData": {**OIL_PAYLOAD["data"]["provinceData"], "GAS_92": "abc"}}},
|
||||||
|
{"data": {"provinceData": {**OIL_PAYLOAD["data"]["provinceData"], "GAS_92": 200}}},
|
||||||
|
{"data": {"provinceData": {**OIL_PAYLOAD["data"]["provinceData"], "GAS_92": float("nan")}}},
|
||||||
|
{"data": {"provinceData": {**OIL_PAYLOAD["data"]["provinceData"], "GAS_92": True}}},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_oil_payload_rejects_invalid_structure_or_prices(payload):
|
||||||
|
with pytest.raises((TypeError, ValueError)):
|
||||||
|
environment.parse_oil_payload(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_oil_payload_keeps_prices_when_effective_date_is_unparsable():
|
||||||
|
payload = {
|
||||||
|
"data": {
|
||||||
|
"provinceData": {
|
||||||
|
**OIL_PAYLOAD["data"]["provinceData"],
|
||||||
|
"START_DATE": "not-a-date",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = environment.parse_oil_payload(payload)
|
||||||
|
|
||||||
|
assert result["gas_92"] == Decimal("8.58")
|
||||||
|
assert result["effective_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def _oil_client(handler):
|
||||||
|
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_oil_runs_session_switch_then_init_with_cookies():
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
calls.append((request.method, request.url.path))
|
||||||
|
if request.url.path == environment.OIL_MAIN_PATH:
|
||||||
|
return httpx.Response(
|
||||||
|
200, headers={"set-cookie": "SESSION=abc123; Path=/"}
|
||||||
|
)
|
||||||
|
if request.url.path == environment.OIL_SWITCH_PATH:
|
||||||
|
assert request.content == b'{"provinceId":"33"}'
|
||||||
|
return httpx.Response(200, json={"success": True})
|
||||||
|
if request.url.path == environment.OIL_INIT_PATH:
|
||||||
|
assert request.headers.get("cookie") == "SESSION=abc123"
|
||||||
|
assert request.headers.get("referer", "").endswith(environment.OIL_MAIN_PATH)
|
||||||
|
return httpx.Response(200, json=OIL_PAYLOAD)
|
||||||
|
raise AssertionError(f"unexpected request: {request.url}")
|
||||||
|
|
||||||
|
async with _oil_client(handler) as client:
|
||||||
|
result = await environment.fetch_oil(client)
|
||||||
|
|
||||||
|
assert calls == [
|
||||||
|
("GET", environment.OIL_MAIN_PATH),
|
||||||
|
("POST", environment.OIL_SWITCH_PATH),
|
||||||
|
("GET", environment.OIL_INIT_PATH),
|
||||||
|
]
|
||||||
|
assert result["gas_92"] == Decimal("8.58")
|
||||||
|
assert result["source"] == "中国石化"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_oil_requires_session_cookie():
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.url.path == environment.OIL_MAIN_PATH:
|
||||||
|
return httpx.Response(200)
|
||||||
|
raise AssertionError("flow must stop without a session cookie")
|
||||||
|
|
||||||
|
async with _oil_client(handler) as client:
|
||||||
|
with pytest.raises(ValueError, match="session cookie"):
|
||||||
|
await environment.fetch_oil(client)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_environment_extras_collects_sources_concurrently_and_serializes():
|
||||||
|
environment.clear_cache()
|
||||||
|
both_started = asyncio.Event()
|
||||||
|
started = set()
|
||||||
|
|
||||||
|
def record(name):
|
||||||
|
async def fetch():
|
||||||
|
started.add(name)
|
||||||
|
if len(started) == 3:
|
||||||
|
both_started.set()
|
||||||
|
await asyncio.wait_for(both_started.wait(), timeout=0.1)
|
||||||
|
if name == "air":
|
||||||
|
return {
|
||||||
|
"aqi": 36,
|
||||||
|
"level": "优",
|
||||||
|
"pm2_5": 25.5,
|
||||||
|
"pm10": 33.8,
|
||||||
|
"observed_at": "2026-09-16T14:00:00+08:00",
|
||||||
|
"source": "Open-Meteo 空气质量",
|
||||||
|
}
|
||||||
|
if name == "oil":
|
||||||
|
return environment.parse_oil_payload(OIL_PAYLOAD)
|
||||||
|
return environment.parse_sina_index(SINA_QUOTE)
|
||||||
|
|
||||||
|
return fetch
|
||||||
|
|
||||||
|
result = await environment.get_environment_extras(
|
||||||
|
air_fetcher=record("air"),
|
||||||
|
oil_fetcher=record("oil"),
|
||||||
|
ashare_fetcher=record("ashare"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert started == {"air", "oil", "ashare"}
|
||||||
|
assert result["air"]["aqi"] == 36
|
||||||
|
assert result["air"]["stale"] is False
|
||||||
|
assert result["oil"]["gas_92"] == "8.58"
|
||||||
|
assert result["oil"]["effective_at"] == "2026-09-24T00:12:00+08:00"
|
||||||
|
assert result["ashare"]["price"] == "3888.3738"
|
||||||
|
assert result["ashare"]["change_percent"] == -1.22
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_environment_extras_keeps_partial_success_on_source_failure():
|
||||||
|
environment.clear_cache()
|
||||||
|
|
||||||
|
async def air():
|
||||||
|
return {
|
||||||
|
"aqi": 36,
|
||||||
|
"level": "优",
|
||||||
|
"pm2_5": 25.5,
|
||||||
|
"pm10": 33.8,
|
||||||
|
"observed_at": "2026-09-16T14:00:00+08:00",
|
||||||
|
"source": "Open-Meteo 空气质量",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def oil():
|
||||||
|
raise RuntimeError("sinopec down")
|
||||||
|
|
||||||
|
async def ashare():
|
||||||
|
return environment.parse_sina_index(SINA_QUOTE)
|
||||||
|
|
||||||
|
result = await environment.get_environment_extras(
|
||||||
|
air_fetcher=air, oil_fetcher=oil, ashare_fetcher=ashare
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["air"]["aqi"] == 36
|
||||||
|
assert result["oil"] is None
|
||||||
|
assert result["ashare"]["name"] == "上证指数"
|
||||||
|
assert result["errors"] == {"oil": "upstream_unavailable"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extras_sources_have_independent_ttl_and_serve_stale_on_error(monkeypatch):
|
||||||
|
environment.clear_cache()
|
||||||
|
base = datetime(2026, 9, 16, 8, tzinfo=UTC)
|
||||||
|
monkeypatch.setattr(environment, "OIL_TTL", timedelta(hours=6), raising=False)
|
||||||
|
calls = {"air": 0, "oil": 0}
|
||||||
|
|
||||||
|
async def air():
|
||||||
|
calls["air"] += 1
|
||||||
|
if calls["air"] > 1:
|
||||||
|
raise RuntimeError("air down")
|
||||||
|
return {
|
||||||
|
"aqi": 36,
|
||||||
|
"level": "优",
|
||||||
|
"pm2_5": 25.5,
|
||||||
|
"pm10": 33.8,
|
||||||
|
"observed_at": "2026-09-16T14:00:00+08:00",
|
||||||
|
"source": "Open-Meteo 空气质量",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def oil():
|
||||||
|
calls["oil"] += 1
|
||||||
|
return environment.parse_oil_payload(OIL_PAYLOAD)
|
||||||
|
|
||||||
|
async def ashare():
|
||||||
|
return environment.parse_sina_index(SINA_QUOTE)
|
||||||
|
|
||||||
|
await environment.get_environment_extras(
|
||||||
|
now=base, air_fetcher=air, oil_fetcher=oil, ashare_fetcher=ashare
|
||||||
|
)
|
||||||
|
# Air TTL is 1h: at +30min it is still fresh (no refetch), oil is fresh for 6h.
|
||||||
|
after_thirty_minutes = await environment.get_environment_extras(
|
||||||
|
now=base + timedelta(minutes=30), air_fetcher=air, oil_fetcher=oil, ashare_fetcher=ashare
|
||||||
|
)
|
||||||
|
assert calls == {"air": 1, "oil": 1}
|
||||||
|
assert after_thirty_minutes["air"]["stale"] is False
|
||||||
|
|
||||||
|
# Past the air TTL a failing refetch serves the cached value flagged stale.
|
||||||
|
after_two_hours = await environment.get_environment_extras(
|
||||||
|
now=base + timedelta(hours=2), air_fetcher=air, oil_fetcher=oil, ashare_fetcher=ashare
|
||||||
|
)
|
||||||
|
assert calls == {"air": 2, "oil": 1}
|
||||||
|
assert after_two_hours["air"]["stale"] is True
|
||||||
|
assert after_two_hours["errors"] == {"air": "upstream_unavailable"}
|
||||||
Reference in New Issue
Block a user