From 4fe956fcf91a70f44b15f4800fe5cadbc27ba0a9 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Sun, 4 Oct 2026 18:34:05 +0800 Subject: [PATCH] fix(dupont): resolve YYYY-Qn / YYYY-Hn period windows (500 on quarter/half) Root cause: resolve_window() matched quarter/half with p[4] == "q" / "h" (always '-' for "2026-q2"/"2026-h1"), and the branches sat after the single-month branch -> dead code. Real input fell into the month branch, so months_between() raised ValueError on int('q2') -> HTTP 500 for all 18 quarter/half options in the period selector. Fix: judge on p[5] in ("q","h") with a digit guard on p[6], evaluated before the single-month branch; month branch tightened to p[5:].isdigit(). Existing error-branch wording unchanged. Tests: backend/tests/test_dupont_period_options.py (18 defect options, case equivalence, month/year/ytd/ttm/custom non-regression, malformed input returns error dict instead of raising). --- backend/app/utils/dupont_basis.py | 10 +- backend/tests/test_dupont_period_options.py | 121 ++++++++++++++++++++ 2 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 backend/tests/test_dupont_period_options.py diff --git a/backend/app/utils/dupont_basis.py b/backend/app/utils/dupont_basis.py index 60fb5896..c448a557 100644 --- a/backend/app/utils/dupont_basis.py +++ b/backend/app/utils/dupont_basis.py @@ -197,17 +197,17 @@ def resolve_window(period: Optional[str], start: Optional[str], end: Optional[st return {"error": "自定义区间须同时给出 start 与 end(YYYY-MM)"} s, e = start, end key = "custom" - elif len(p) == 7 and p[4] == "-": # 2026-08 → 单月 - s = e = p - key = "month" - elif len(p) == 7 and p[4] == "q": # 2026-q3 + elif len(p) == 7 and p[5] == "q" and p[6].isdigit(): # 2026-q3 y, q = int(p[:4]), int(p[6]) s, e = f"{y}-{(q - 1) * 3 + 1:02d}", f"{y}-{q * 3:02d}" key = "quarter" - elif len(p) == 7 and p[4] == "h": # 2026-h1 + elif len(p) == 7 and p[5] == "h" and p[6].isdigit(): # 2026-h1 y, h = int(p[:4]), int(p[6]) s, e = f"{y}-{(h - 1) * 6 + 1:02d}", f"{y}-{h * 6:02d}" key = "half" + elif len(p) == 7 and p[5:].isdigit(): # 2026-08 → 单月 + s = e = p + key = "month" elif len(p) == 4 and p.isdigit(): # 2026 → 全年 s, e = f"{p}-01", f"{p}-12" key = "year" diff --git a/backend/tests/test_dupont_period_options.py b/backend/tests/test_dupont_period_options.py new file mode 100644 index 00000000..4669a569 --- /dev/null +++ b/backend/tests/test_dupont_period_options.py @@ -0,0 +1,121 @@ +# -*- 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)