From 273cd7aac7072c8c6dce6c475e15fc3d55bb04a3 Mon Sep 17 00:00:00 2001 From: bboysoul Date: Fri, 25 Sep 2026 09:50:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=8A=E5=A4=A9=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E6=9D=A1=E6=96=B0=E5=A2=9E=E4=B8=8B=E4=B8=80=E4=B8=AA=E5=80=92?= =?UTF-8?q?=E6=95=B0=E6=97=A5=E3=80=81=E6=B5=99=E6=B1=9F=E6=B2=B9=E4=BB=B7?= =?UTF-8?q?=E3=80=81=E4=B8=8A=E8=AF=81=E6=8C=87=E6=95=B0=E3=80=81=E9=99=8D?= =?UTF-8?q?=E9=9B=A8/AQI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增三源:中石化浙江油价(3步会话)、新浪上证行情、Open-Meteo 空气质量(国标AQI换算),按源独立超时与状态 - 天气行改为 降雨概率% · AQI 等级(无雨无AQI时保留观测时间),stale 补缓存标记 - 前端 strip 六格两行(桌面)/四行(移动),倒数日取本地缓存最近一条,超12字截断 - 修复两处 AppSheet 动画竞态存量 e2e 失败(modal contract footer 时序、scrollable 重开残留 dialog) --- backend/main.py | 10 +- backend/today_environment.py | 321 +++++++++++++- frontend/e2e/mobile-ui.spec.ts | 7 + frontend/e2e/today-plain-list.spec.ts | 67 ++- frontend/src/App.vue | 31 +- frontend/src/TodayEnvironment.test.ts | 16 +- .../components/TodayEnvironmentStrip.test.ts | 104 ++++- .../src/components/TodayEnvironmentStrip.vue | 139 +++++- frontend/src/style.css | 7 +- tests/test_today_environment.py | 138 +++++- tests/test_today_environment_extras.py | 406 ++++++++++++++++++ 11 files changed, 1212 insertions(+), 34 deletions(-) create mode 100644 tests/test_today_environment_extras.py diff --git a/backend/main.py b/backend/main.py index 6772eb7..475f689 100644 --- a/backend/main.py +++ b/backend/main.py @@ -74,6 +74,7 @@ from .schemas import ( UserUpdate, ) from .today_environment import get_environment as get_today_environment +from .today_environment import get_environment_extras as get_today_environment_extras @asynccontextmanager @@ -216,7 +217,14 @@ async def me(user: User = Depends(current_user)): @app.get("/api/v1/today/environment") 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) diff --git a/backend/today_environment.py b/backend/today_environment.py index 671e169..99e794e 100644 --- a/backend/today_environment.py +++ b/backend/today_environment.py @@ -23,16 +23,34 @@ GOLD_HISTORY_URL = ( "https://www.sge.com.cn/sjzx/quotation_daily_new" "?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" 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 = ("星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日") SOURCE_TIMEOUT_SECONDS = 3.0 GOLD_SOURCE_TIMEOUT_SECONDS = 6.0 +OIL_SOURCE_TIMEOUT_SECONDS = 6.0 TOTAL_TIMEOUT_SECONDS = 6.5 WEATHER_TTL = timedelta(minutes=15) WEATHER_STALE_TTL = timedelta(hours=6) GOLD_TTL = timedelta(minutes=5) 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__) @@ -149,6 +167,7 @@ async def fetch_weather(request: Callable[..., Awaitable[dict[str, Any]]]) -> di "latitude": 29.88, "longitude": 121.55, "current": "temperature_2m,apparent_temperature,weather_code", + "hourly": "precipitation_probability", "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) else: observed_at = observed_at.replace(tzinfo=SHANGHAI_TZ) + precipitation_probability = _precipitation_probability(payload, observed_at) return { "temperature_c": temperature, "apparent_temperature_c": apparent_temperature, "weather_code": weather_code, + "precipitation_probability": precipitation_probability, "observed_at": observed_at.isoformat(), "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]: html = await request(GOLD_URL) 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]: response = await client.get(url, **kwargs) response.raise_for_status() @@ -232,19 +376,134 @@ async def _request_text(client: httpx.AsyncClient, url: str, **kwargs: Any) -> s 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]: 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( name: str, fetcher: Callable[[], Awaitable[dict[str, Any]]], fetched_at: datetime, generation: int, ) -> dict[str, Any]: - value = await asyncio.wait_for( - fetcher(), timeout=GOLD_SOURCE_TIMEOUT_SECONDS if name == "gold" else SOURCE_TIMEOUT_SECONDS - ) + value = await asyncio.wait_for(fetcher(), timeout=_source_timeout(name)) with _state_guard: if generation == _generation: 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)) +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( *, now: datetime | None = None, @@ -366,3 +649,35 @@ async def get_environment( "gold": gold, "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} diff --git a/frontend/e2e/mobile-ui.spec.ts b/frontend/e2e/mobile-ui.spec.ts index 8902452..3bbbed1 100644 --- a/frontend/e2e/mobile-ui.spec.ts +++ b/frontend/e2e/mobile-ui.spec.ts @@ -59,6 +59,8 @@ test('desktop task composer stays compact, centered, and scrollable on short scr expect(metrics.footerBottom).toBeLessThanOrEqual(metrics.viewport.height + 1) 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 }) dialog = await openTaskComposer(page) 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) => { await expect(dialog).toBeVisible() 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 panel = element as HTMLElement const header = panel.querySelector('.app-sheet__header, header') diff --git a/frontend/e2e/today-plain-list.spec.ts b/frontend/e2e/today-plain-list.spec.ts index eb85791..db97852 100644 --- a/frontend/e2e/today-plain-list.spec.ts +++ b/frontend/e2e/today-plain-list.spec.ts @@ -20,25 +20,39 @@ test('Today plain-list layout matches the approved responsive geometry', async ( expect(bootstrap.ok(), await bootstrap.text()).toBeTruthy() const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox) expect(inbox).toBeTruthy() - const today = new Intl.DateTimeFormat('sv-SE', { + const formatShanghai = (date: Date) => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '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 }) expect(taskResponse.ok(), await taskResponse.text()).toBeTruthy() 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' }) expect(habitResponse.ok(), await habitResponse.text()).toBeTruthy() 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({ status: 200, contentType: 'application/json', body: JSON.stringify({ 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' }, + 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('/') @@ -61,7 +75,7 @@ test('Today plain-list layout matches the approved responsive geometry', async ( })).toEqual({ media720: width <= 720, 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-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('switch', { name: '显示已完成' })).toHaveCount(1) 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 rect = (selector: string) => document.querySelector(selector)!.getBoundingClientRect() const environment = document.querySelector('.today-environment')! const calendar = document.querySelector('.today-environment__calendar')! const weather = document.querySelector('.today-environment__weather')! const gold = document.querySelector('.today-environment__gold')! + const countdown = document.querySelector('.today-environment__countdown')! + const oil = document.querySelector('.today-environment__oil')! + const ashare = document.querySelector('.today-environment__ashare')! const title = document.querySelector('.today-page-title')! const remaining = document.querySelector('.today-remaining')! const filter = document.querySelector('.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')) .map(node => Math.round(node.getBoundingClientRect().top)) 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() 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 }, menuButton: { left: menuButton.getBoundingClientRect().left, width: menuButton.getBoundingClientRect().width }, environment: { ...environment.getBoundingClientRect().toJSON(), scrollWidth: environment.scrollWidth, clientWidth: environment.clientWidth }, - directions: { calendar: getComputedStyle(calendar).flexDirection, weather: getComputedStyle(weather).flexDirection, gold: getComputedStyle(gold).flexDirection }, - itemWidths: [calendar, weather, gold].map((element) => element.getBoundingClientRect().width), - itemHeights: [calendar, weather, gold].map((element) => element.getBoundingClientRect().height), + 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, countdown, oil, ashare].map((element) => element.getBoundingClientRect().width), + itemHeights: [calendar, weather, gold, countdown, oil, ashare].map((element) => element.getBoundingClientRect().height), uniqueRows, collisions, - collisionRects: [calendar, weather, gold].map(element => element.getBoundingClientRect().toJSON()), + collisionRects: [calendar, weather, gold, countdown, oil, ashare].map(element => element.getBoundingClientRect().toJSON()), secondary, ordered, orderedPairs, @@ -148,6 +169,9 @@ test('Today plain-list layout matches the approved responsive geometry', async ( insideContent, weatherText: weather.innerText, goldText: gold.innerText, + oilText: oil.innerText, + ashareText: ashare.innerText, + countdownText: countdown.innerText, goldLabel: gold.querySelector('.today-environment__gold-date')?.getAttribute('aria-label') ?? '', summaries, styles: { @@ -208,6 +232,10 @@ test('Today plain-list layout matches the approved responsive geometry', async ( expect(metrics.insideContent).toBeTruthy() expect(metrics.orderedPairs.every(pair => pair.separated)).toBeTruthy() 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.secondary.every(line => line.visible && line.scrollWidth <= line.clientWidth)).toBeTruthy() 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).toBeLessThanOrEqual(metrics.ordered[1].bottom - metrics.styles.filterHeight) 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.fabIconWidth).toBeCloseTo(30, 0) 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(Math.max(...metrics.itemWidths) - Math.min(...metrics.itemWidths)).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') } 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].left).toBeCloseTo(metrics.content.left, 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[2].right).toBeCloseTo(metrics.content.right, 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.weather).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) { expect(metrics.content.width).toBeCloseTo(width - 56, 0) expect(metrics.content.left).toBeCloseTo(28, 0) expect(metrics.styles.mainPaddingLeft).toBe('28px') 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) { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index bd69b83..d27391e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -6,7 +6,7 @@ import { Settings, Trash2, X, Repeat2, StickyNote, TimerReset, } 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 { 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 { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion' 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 CalendarPicker from './components/CalendarPicker.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 AppDialog, { type AppDialogOptions } from './components/AppDialog.vue' import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock' @@ -126,6 +126,7 @@ const todayTaskCompleted = ref(0) const todayHabitTotal = ref(0) const todayHabitCompleted = ref(0) const todayEnvironment = ref(null) +const todayCountdown = ref(null) const todayEnvironmentLoading = ref(false) const todayEnvironmentError = ref(false) const todayEnvironmentDateKey = ref('') @@ -617,10 +618,34 @@ async function loadTodayTaskSummary() { todayTaskTotal.value = overdue + open + completed } catch { /* 概览统计失败不阻断今天页 */ } } +type CachedCountdown = { title?: unknown; days?: unknown; day_text?: unknown; pinned?: unknown } +function pickNextTodayCountdown(): TodayCountdown | null { + const cache = readCountdownCache() + 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() { const token = ++todayEnvironmentLoadToken todayEnvironmentLoading.value = true todayEnvironmentError.value = false + void refreshTodayCountdown(token) try { const data = await api('/today/environment') as TodayEnvironment if (token !== todayEnvironmentLoadToken || activeView.value !== 'today') return @@ -1838,7 +1863,7 @@ onUnmounted(() => { diff --git a/frontend/src/style.css b/frontend/src/style.css index 3060726..abcec58 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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}} .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: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 .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} .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: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:380px){.today-environment__weather,.today-environment__gold{height:40px;padding-top:var(--space-4);padding-bottom:var(--space-4)}} +@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,.today-environment__oil,.today-environment__ashare{height:40px;padding-top:var(--space-4);padding-bottom:var(--space-4)}} /* Approved Settings 01: continuous paper ledger. */ main:has(>.mvp-view .settings-sections){background:var(--surface-raised)} diff --git a/tests/test_today_environment.py b/tests/test_today_environment.py index 24ed390..d66bb51 100644 --- a/tests/test_today_environment.py +++ b/tests/test_today_environment.py @@ -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", + }, + } diff --git a/tests/test_today_environment_extras.py b/tests/test_today_environment_extras.py new file mode 100644 index 0000000..a2ca4fb --- /dev/null +++ b/tests/test_today_environment_extras.py @@ -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"}