feat: 今天环境条新增下一个倒数日、浙江油价、上证指数、降雨/AQI
ci / gitleaks (push) Successful in 11s
ci / docker (push) Successful in 3m52s

- 后端新增三源:中石化浙江油价(3步会话)、新浪上证行情、Open-Meteo 空气质量(国标AQI换算),按源独立超时与状态
- 天气行改为 降雨概率% · AQI 等级(无雨无AQI时保留观测时间),stale 补缓存标记
- 前端 strip 六格两行(桌面)/四行(移动),倒数日取本地缓存最近一条,超12字截断
- 修复两处 AppSheet 动画竞态存量 e2e 失败(modal contract footer 时序、scrollable 重开残留 dialog)
This commit is contained in:
2026-09-25 09:50:32 +08:00
parent 4c145a2621
commit 273cd7aac7
11 changed files with 1212 additions and 34 deletions
+134 -4
View File
@@ -125,7 +125,11 @@ async def test_fetch_weather_uses_fixed_haishu_location_and_current_conditions()
"temperature_2m": 28.4,
"apparent_temperature": 30.1,
"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)
@@ -136,6 +140,7 @@ async def test_fetch_weather_uses_fixed_haishu_location_and_current_conditions()
"latitude": 29.88,
"longitude": 121.55,
"current": "temperature_2m,apparent_temperature,weather_code",
"hourly": "precipitation_probability",
"timezone": "Asia/Shanghai",
},
}
@@ -143,6 +148,7 @@ async def test_fetch_weather_uses_fixed_haishu_location_and_current_conditions()
"temperature_c": 28.4,
"apparent_temperature_c": 30.1,
"weather_code": 2,
"precipitation_probability": 75,
"observed_at": "2026-09-16T14:15:00+08:00",
"source": "Open-Meteo",
}
@@ -171,6 +177,102 @@ async def test_fetch_weather_rejects_invalid_contract(current):
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
async def test_fetch_weather_rejects_wrong_response_timezone():
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,
"weather_code": 1,
},
"hourly": {
"time": ["2026-09-16T14:00"],
"precipitation_probability": [10],
},
}
@property
@@ -774,7 +880,7 @@ def test_today_environment_endpoint_returns_aggregated_payload(client, monkeypat
"/api/v1/setup/initialize",
json={"username": "owner", "password": "correct horse battery staple"},
)
payload = {
environment_payload = {
"date": {
"solar_date": "2026-09-16",
"weekday": "星期三",
@@ -785,12 +891,36 @@ def test_today_environment_endpoint_returns_aggregated_payload(client, monkeypat
"gold": None,
"errors": {"weather": "unavailable", "gold": "unavailable"},
}
extras_payload = {
"air": None,
"oil": None,
"ashare": None,
"errors": {"air": "unavailable", "oil": "unavailable", "ashare": "unavailable"},
}
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_extras", aggregate_extras)
response = client.get("/api/v1/today/environment")
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",
},
}
+406
View File
@@ -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"}