"""断路器单元测试(task: Phase 2 可靠性增强)。 测试 check_circuit_breaker 纯函数——不启动整个下载流程, 直接构造 recent_results 列表验证触发逻辑。 覆盖场景: 1. 35% 失败率 → 触发 2. 25% 失败率 → 不触发 3. 北交所 920xxx 的 fail 不计入 → 不触发 4. 不足窗口 → 不触发 5. 恰好 30% → 不触发(阈值是 > 0.30,不含等于) """ import os import sys import pytest _SCRIPT_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "scripts", "data_platform") _SCRIPT_DIR = os.path.abspath(_SCRIPT_DIR) if _SCRIPT_DIR not in sys.path: sys.path.insert(0, _SCRIPT_DIR) from raw_redownload import ( # noqa: E402 CIRCUIT_BREAKER_FAIL_RATE, CIRCUIT_BREAKER_WINDOW, KNOWN_UNSUPPORTED_PREFIX, check_circuit_breaker, ) def _make_results(n_ok: int, n_fail: int, fail_prefix: str = "600") -> list: """构造 recent_results 列表:n_ok 个 ok + n_fail 个 fail。 fail_prefix 控制失败码的前缀(用于测试北交所排除)。 """ ok_list = [("600001", True)] * n_ok fail_list = [(f"{fail_prefix}9999", False)] * n_fail return ok_list + fail_list class TestCircuitBreakerTrigger: """断路器触发阈值测试。""" def test_35_percent_fail_triggers(self): """200 只里 70 只 fail(35%)→ 触发。""" results = _make_results(n_ok=130, n_fail=70) assert len(results) == 200 assert check_circuit_breaker(results) is True def test_25_percent_fail_no_trigger(self): """200 只里 50 只 fail(25%)→ 不触发。""" results = _make_results(n_ok=150, n_fail=50) assert len(results) == 200 assert check_circuit_breaker(results) is False def test_exactly_30_percent_no_trigger(self): """恰好 30%(60/200)→ 不触发(阈值是 > 0.30,不含等于)。""" results = _make_results(n_ok=140, n_fail=60) assert len(results) == 200 fail_rate = 60 / 200 assert fail_rate == pytest.approx(CIRCUIT_BREAKER_FAIL_RATE) assert check_circuit_breaker(results) is False def test_31_percent_triggers(self): """31% 失败率 → 触发(刚过阈值)。""" results = _make_results(n_ok=138, n_fail=62) assert len(results) == 200 assert check_circuit_breaker(results) is True class TestCircuitBreakerBjExclusion: """北交所码不计入断路器分母测试。""" def test_bj_fails_excluded_from_denominator(self): """北交所 920xxx 的 fail 不计入 → 不触发。 200 只非北交所全部 ok + 100 只北交所全部 fail: 有效分母=200,有效失败=0 → 0% → 不触发。 """ ok_list = [("600001", True)] * 200 bj_fail_list = [("920001", False)] * 100 results = ok_list + bj_fail_list assert check_circuit_breaker(results) is False def test_bj_fails_do_not_inflate_rate(self): """北交所 fail 混在 200 窗口内不抬高失败率。 150 只非北交所 ok + 50 只北交所 fail = 200 只总数: 有效分母=150(< 窗口 200)→ 不足窗口,不触发。 """ ok_list = [("600001", True)] * 150 bj_fail_list = [("920001", False)] * 50 results = ok_list + bj_fail_list assert check_circuit_breaker(results) is False def test_mixed_bj_and_normal_fails(self): """混合场景:200 非北交所(60 fail=30%)+ 50 北交所 fail。 有效:200 非北交所,60 fail = 30%,恰好阈值(> 0.30 不含等于)→ 不触发。 北交所 50 fail 被排除,不影响计算。 """ ok_normal = [("600001", True)] * 140 fail_normal = [("600002", False)] * 60 fail_bj = [("920001", False)] * 50 results = ok_normal + fail_normal + fail_bj assert check_circuit_breaker(results) is False def test_all_bj_prefixes_excluded(self): """所有已知不支持前缀都排除:920/921/83/87。""" ok_list = [("600001", True)] * 200 bj_fails = ( [("920001", False)] * 30 + [("921002", False)] * 30 + [("830003", False)] * 30 + [("870004", False)] * 30 ) results = ok_list + bj_fails assert check_circuit_breaker(results) is False class TestCircuitBreakerWindow: """窗口大小边界测试。""" def test_insufficient_window_no_trigger(self): """不足 200 只 → 不触发(即使全部 fail)。""" results = [("600001", False)] * 199 assert check_circuit_breaker(results) is False def test_exactly_window_triggers_if_high_fail(self): """恰好 200 只且失败率超阈值 → 触发。""" results = _make_results(n_ok=100, n_fail=100) assert len(results) == 200 assert check_circuit_breaker(results) is True def test_empty_results_no_trigger(self): """空列表 → 不触发。""" assert check_circuit_breaker([]) is False def test_all_ok_no_trigger(self): """200 只全部 ok → 不触发。""" results = [("600001", True)] * 200 assert check_circuit_breaker(results) is False class TestCircuitBreakerConstants: """常量值校验(防止意外修改)。""" def test_window_is_200(self): assert CIRCUIT_BREAKER_WINDOW == 200 def test_fail_rate_is_030(self): assert CIRCUIT_BREAKER_FAIL_RATE == 0.30 def test_known_unsupported_prefixes(self): assert KNOWN_UNSUPPORTED_PREFIX == ("920", "921", "83", "87")