Files
dodo/tests/test_today_environment_extras.py
T
bboysoul 273cd7aac7
ci / gitleaks (push) Successful in 11s
ci / docker (push) Successful in 3m52s
feat: 今天环境条新增下一个倒数日、浙江油价、上证指数、降雨/AQI
- 后端新增三源:中石化浙江油价(3步会话)、新浪上证行情、Open-Meteo 空气质量(国标AQI换算),按源独立超时与状态
- 天气行改为 降雨概率% · AQI 等级(无雨无AQI时保留观测时间),stale 补缓存标记
- 前端 strip 六格两行(桌面)/四行(移动),倒数日取本地缓存最近一条,超12字截断
- 修复两处 AppSheet 动画竞态存量 e2e 失败(modal contract footer 时序、scrollable 重开残留 dialog)
2026-09-25 09:50:32 +08:00

407 lines
13 KiB
Python

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"}