- 后端新增三源:中石化浙江油价(3步会话)、新浪上证行情、Open-Meteo 空气质量(国标AQI换算),按源独立超时与状态 - 天气行改为 降雨概率% · AQI 等级(无雨无AQI时保留观测时间),stale 补缓存标记 - 前端 strip 六格两行(桌面)/四行(移动),倒数日取本地缓存最近一条,超12字截断 - 修复两处 AppSheet 动画竞态存量 e2e 失败(modal contract footer 时序、scrollable 重开残留 dialog)
407 lines
13 KiB
Python
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"}
|