fix(dupont): resolve YYYY-Qn / YYYY-Hn period windows (500 on quarter/half)
ci/woodpecker/push/main Pipeline failed
ci/woodpecker/push/main Pipeline failed
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).
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user