mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
背景(本机实测事故 2026-09-16):面板 08:58 起,直到用户 09:35:33 手动开启才有行情 ⇒ 开盘后 6 分钟实时评估全空。根因 = 旧版 `stop()` 把 `realtime_quotes_enabled` 写成 False (lifespan shutdown 也调 `stop`)⇒ 次日 `boot_check()` 读到 False 就**静默不启动**, 且**一行日志都不打** ⇒ 排查只能靠排除法。 ⭐ 为什么只补 `boot_check`(而不是把本机整套测试原样搬来): · 根因那一半上游**已有**测试:`tests/test_realtime_preference_persistence.py` 断言 「`stop()` 不写偏好 / `disable()` 才写 False」—— 与本机那份同向 ⇒ ⛔ 本提交**不重复造** · `boot_check()` 本身上游**没有任何测试**(`test_data_integrity.py:537` 那个同名 `boot_check` 是**数据修复**用的,⛔ 不是本函数)⇒ 缺口正是「**启动决策** + **有没有留下痕迹**」 新增 `tests/test_quote_service_boot_check.py`(**4 例 + 1 xfail**): 1. 偏好开 + 档位允许 ⇒ **必须** `start()`(⭐ 反向对照:防守卫写成"永远不启动") 2. 偏好关 + 档位允许 ⇒ **不得** `start()` 3. 档位 none ⇒ 不得 `start()` **且必须留日志**(含「未启动」) 4. 档位 none + 偏好为开 ⇒ 必须把偏好**同步写 False**(防 UI 误显示"已开启") 5. ⚠️ `xfail(strict=True)`:偏好关 + 档位允许时**期望**留日志;上游该分支当前**仍静默** ⇒ 用它编码**期望行为**:上游修好那天会 XPASS ⇒ strict 让它 FAIL 以提示更新测试 (⛔ 本提交**不改被测代码**;若维护者不接受这种用法,删掉这一例即可,其余 4 例完全独立) 验证(隔离 worktree,基于 `origin/main` 30fcade): · **P1** 新文件 ⇒ `4 passed, 1 xfailed` · ⭐ **P2 判别力**(4 个**单变量**变异,每个都**只**让对应用例 FAIL,且源码**逐字节还原**): M1 删 tier-none 的 `logger.info` ⇒ `test_logs_and_does_not_start_when_tier_disallows` FAIL M2 删 tier-none 的 `_save_enabled(False)` ⇒ `test_demotes_preference_when_tier_disallows` FAIL M3 `boot_check` 永不 `start` ⇒ `test_starts_when_preference_enabled` FAIL M4 `boot_check` 无条件 `start` ⇒ `test_does_not_start_when_preference_disabled` FAIL · **P3** 零回归:`tests/test_realtime_preference_persistence.py` + `tests/test_realtime_mode.py` ⇒ **5 passed**(与改动前一致) · **P4** 改动面:分支相对 `origin/main` **只新增 1 个文件**;工作树干净;`git diff --check` rc=0 · **P5** `ruff check tests/test_quote_service_boot_check.py` ⇒ **All checks passed** ⚠️ **自曝(当轮修,留痕)**:初版把**中文全角标点**写进 docstring ⇒ 上游当前 ruff 配置 (未忽略 RUF00x)报 **43 条 RUF002**。实测上游 `tests/` 的惯例是 **ASCII 标点** (288 个文件里 **271 个**无 RUF00x 告警)⇒ 已把文档串/注释改为 ASCII 标点、强调引号改用「」 ⇒ 复测 **0 告警**。⚠️ 这与「上游应否忽略 RUF00x」是**两个问题**:本文件按**现状惯例**写, ⛔ 不依赖任何"改 lint 配置"的提交。 ⚠️ 边界(⛔ 不含糊):`start` 被 monkeypatch 成**记录器** ⇒ 验的是「**决策**」, ⛔ 不能据此说"实时行情真能跑";日志断言用**子串**匹配(含「未启动」)⇒ 上游改文案会 FAIL —— 这是**故意**的:那行日志正是本事故的**可观测性承诺**。 ⛔ 未推送。回滚 = `worktree remove` + `branch -D`。 (由 DSH agent 在用户授权下完成。) Co-authored-by: JackChan83 <JackChan83@users.noreply.github.com>
93 lines
5.0 KiB
Python
93 lines
5.0 KiB
Python
"""`QuoteService.boot_check()` 的**启动决策 + 可观测性**回归测试。
|
|
|
|
背景(本机实测事故 2026-09-16): 面板 08:58 起, 直到用户 09:35:33 手动开启才有行情
|
|
⇒ 开盘后 6 分钟实时评估全空。根因 = 旧版 `stop()` 把 `realtime_quotes_enabled` 写成 False
|
|
(lifespan shutdown 也调 `stop`) ⇒ 次日 `boot_check()` 读到 False 就**静默不启动**,
|
|
且**一行日志都不打** ⇒ 排查只能靠排除法。
|
|
|
|
⚠️ 与本文件的分工:
|
|
* `tests/test_realtime_preference_persistence.py` 管**根因**(`stop()` 不持久化 / `disable()` 才持久化)
|
|
—— 上游已有该文件, 本文件**不重复**它的断言。
|
|
* 本文件管 `boot_check()` **拿这个偏好做了什么决策**、**有没有留下痕迹**
|
|
—— 上游此前对 `boot_check()` 本身**没有任何测试**
|
|
(`test_data_integrity.py` 里那个同名 `boot_check` 是数据修复用的, ⛔ 不是本函数)。
|
|
|
|
⚠️ 已知差异(本文件用 xfail 显式记录, ⛔ 不改被测代码):
|
|
当**档位允许**但**偏好为关**时, 上游 `boot_check()` 分支走到底: **不启动、也不打日志**。
|
|
本机口径要求此处**必须留一行日志**(「静默不启动」正是 2026-09-16 事故难排查的原因)
|
|
⇒ 用 `xfail(strict=True)` 编码**期望行为**: 上游修好那天它会变 XPASS ⇒ strict 会让它 FAIL
|
|
⇒ 提示「该更新测试了」。(不喜欢这种用法的话, 删掉 `test_..._leaves_a_log` 即可, 其余 4 例独立。)
|
|
|
|
⚠️ 标点惯例: 本仓 `tests/` 共 288 个文件, 其中 271 个不含 RUF00x(全角标点)告警 ⇒ 本文件随多数派
|
|
在**文档串/注释**里用 ASCII 标点(`(` `)` `,` `:` `;`), 以保持 `ruff check tests` 零新增。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
import pytest
|
|
|
|
from app.services import preferences
|
|
from app.services.quote_service import QuoteService
|
|
|
|
|
|
def _make(monkeypatch, *, tier_allowed: bool, pref_enabled: bool):
|
|
"""构造一个不真启动线程的 QuoteService, 并返回 (qs, started 记录器)。"""
|
|
monkeypatch.setattr(QuoteService, "is_realtime_allowed", classmethod(lambda cls: tier_allowed))
|
|
monkeypatch.setattr(preferences, "get_realtime_quotes_enabled", lambda: pref_enabled)
|
|
qs = QuoteService()
|
|
started: list = []
|
|
monkeypatch.setattr(qs, "start", lambda *a, **k: started.append(1))
|
|
return qs, started
|
|
|
|
|
|
def test_starts_when_preference_enabled(monkeypatch):
|
|
"""⭐ 反向对照: 偏好为开且档位允许 ⇒ **必须**启动(防止把守卫写成「永远不启动」)。"""
|
|
qs, started = _make(monkeypatch, tier_allowed=True, pref_enabled=True)
|
|
qs.boot_check()
|
|
assert started == [1], "偏好为开且档位允许时必须启动"
|
|
|
|
|
|
def test_does_not_start_when_preference_disabled(monkeypatch):
|
|
"""偏好为关 ⇒ 不得启动(这正是 09-16 事故的表象; 根因已由 stop() 修掉)。"""
|
|
qs, started = _make(monkeypatch, tier_allowed=True, pref_enabled=False)
|
|
qs.boot_check()
|
|
assert started == [], "偏好为关时不应启动"
|
|
|
|
|
|
def test_logs_and_does_not_start_when_tier_disallows(monkeypatch, caplog):
|
|
"""档位 none ⇒ 不启动 **且必须留日志**(09-16 事故的教训: 静默不可排查)。"""
|
|
qs, started = _make(monkeypatch, tier_allowed=False, pref_enabled=True)
|
|
with caplog.at_level(logging.INFO, logger="app.services.quote_service"):
|
|
qs.boot_check()
|
|
assert started == [], "档位 none 时不应启动"
|
|
msgs = [r.getMessage() for r in caplog.records]
|
|
assert any("未启动" in m for m in msgs), f"档位不允许时必须留下可解析的日志行, 实际={msgs}"
|
|
|
|
|
|
def test_demotes_preference_when_tier_disallows(monkeypatch):
|
|
"""档位 none 且偏好为开 ⇒ 必须把偏好**同步写 False**(否则 UI 会误显示「已开启」)。"""
|
|
saved: list[dict] = []
|
|
monkeypatch.setattr(preferences, "save", lambda updates: saved.append(updates))
|
|
qs, started = _make(monkeypatch, tier_allowed=False, pref_enabled=True)
|
|
qs.boot_check()
|
|
assert started == [], "档位 none 时不应启动"
|
|
assert saved == [{"realtime_quotes_enabled": False}], f"应把偏好降级写回, 实际={saved}"
|
|
|
|
|
|
@pytest.mark.xfail(
|
|
strict=True,
|
|
reason="上游此分支仍静默: 档位允许但偏好为关时不打日志(本机已加一行) -- 修好后本用例会 XPASS, 届时删除 xfail",
|
|
)
|
|
def test_preference_disabled_leaves_a_log(monkeypatch, caplog):
|
|
"""⚠️ **期望**行为(上游当前未满足): 偏好为关而档位允许时也应留下日志。
|
|
|
|
「不启动」本身没有错, 错的是一句话都不说 —— 那让「没行情」与「没权限/没数据/没启动」无法区分。
|
|
"""
|
|
qs, started = _make(monkeypatch, tier_allowed=True, pref_enabled=False)
|
|
with caplog.at_level(logging.INFO, logger="app.services.quote_service"):
|
|
qs.boot_check()
|
|
assert started == []
|
|
msgs = [r.getMessage() for r in caplog.records]
|
|
assert any("未启动" in m for m in msgs), f"偏好为关时也应留日志, 实际={msgs}"
|