# -*- coding: utf-8 -*- """杜邦区间选择器 —— 季度(YYYY-Qn)/半年(YYYY-Hn) 解析回归用例。 缺陷背景(project-20261004-ask08-dupont-quarter-half-500-fix): `resolve_window()` 的季度/半年分支判据写成 `p[4] == "q" / "h"`(应为 `p[5]`), 且排在单月分支(`p[4] == "-"`)之后 ⇒ 死代码;真实入参 `2026-q2` / `2026-h1` (端点内 `.lower()`)落进单月分支 → `months_between()` 的 `int(end[5:7])` 抛 `ValueError: invalid literal for int() ... 'q2'` → HTTP 500。 本用例只测纯函数 `resolve_window`(不连库、不写盘、不碰服务), 口径与前端 `frontend/src/views/DupontAnalysis.vue::periodOptions` 对齐。 """ import pytest from app.utils.dupont_basis import resolve_window YEARS = (2024, 2025, 2026) AVAIL = [f"{y}-{m:02d}" for y in YEARS for m in range(1, 13)] def rw(period, **kw): kw.setdefault("today", "2026-10") return resolve_window(period, None, None, AVAIL, **kw) # ── 正例:季度 / 半年(缺陷面 18 项) ─────────────────────────────────────── @pytest.mark.parametrize("year,q,s,e", [ (2026, 1, "2026-01", "2026-03"), (2026, 2, "2026-04", "2026-06"), (2026, 3, "2026-07", "2026-09"), (2026, 4, "2026-10", "2026-12"), (2025, 2, "2025-04", "2025-06"), (2024, 4, "2024-10", "2024-12"), ]) def test_quarter_parses(year, q, s, e): w = rw(f"{year}-Q{q}") assert "error" not in w assert w["period_key"] == "quarter" assert (w["period_start"], w["period_end"]) == (s, e) assert w["months"] == 3 @pytest.mark.parametrize("year,h,s,e", [ (2026, 1, "2026-01", "2026-06"), (2026, 2, "2026-07", "2026-12"), (2025, 1, "2025-01", "2025-06"), (2024, 2, "2024-07", "2024-12"), ]) def test_half_parses(year, h, s, e): w = rw(f"{year}-H{h}") assert "error" not in w assert w["period_key"] == "half" assert (w["period_start"], w["period_end"]) == (s, e) assert w["months"] == 6 def test_all_18_defect_options_parse(): """前端选择器同一区间内的 3 年 × (2 半年 + 4 季度) = 18 项全部可解析。""" bad = [] for y in YEARS: for h in (1, 2): w = rw(f"{y}-H{h}") if w.get("period_key") != "half" or w.get("months") != 6: bad.append(f"{y}-H{h} -> {w}") for q in (1, 2, 3, 4): w = rw(f"{y}-Q{q}") if w.get("period_key") != "quarter" or w.get("months") != 3: bad.append(f"{y}-Q{q} -> {w}") assert bad == [], bad def test_lower_and_upper_case_equivalent(): """端点内 `.lower()` 后入参为小写;大小写必须同解且区间一致。""" assert rw("2026-Q2") == rw("2026-q2") assert rw("2026-H1") == rw("2026-h1") # ── 反例:单月 / 全年 / YTD / TTM / 自定义 口径不得被季度半年分支改动 ────── @pytest.mark.parametrize("month", ["2026-01", "2026-08", "2026-12"]) def test_single_month_not_swallowed_by_quarter_half(month): w = rw(month) assert "error" not in w assert w["period_key"] == "month", w assert w["period_start"] == w["period_end"] == month assert w["months"] == 1 def test_full_year_still_year(): w = rw("2026") assert w["period_key"] == "year" assert (w["period_start"], w["period_end"]) == ("2026-01", "2026-12") assert w["months"] == 12 def test_ytd_ttm_unchanged(): ytd = rw("ytd", anchor="2026-10") assert ytd["period_key"] == "ytd" assert (ytd["period_start"], ytd["period_end"]) == ("2026-01", "2026-10") assert ytd["months"] == 10 ttm = rw("ttm", anchor="2026-10") assert ttm["period_key"] == "ttm" assert (ttm["period_start"], ttm["period_end"]) == ("2025-10", "2026-10") assert ttm["months"] == 13 def test_custom_unchanged(): w = resolve_window("custom", "2026-01", "2026-06", AVAIL, today="2026-10") assert w["period_key"] == "custom" assert (w["period_start"], w["period_end"]) == ("2026-01", "2026-06") assert w["months"] == 6 @pytest.mark.parametrize("junk", ["2026-qx", "2026-hx", "2026-x3", "q2", "2026-1"]) def test_malformed_period_returns_error_dict_not_raise(junk): """无法识别的区间参数必须回落 error dict(端点转 4xx),绝不抛异常。""" w = rw(junk) assert isinstance(w, dict) assert w.get("error"), (junk, w)