ci/woodpecker/push/main Pipeline failed
Per finance ruling 2026-10-05 (project-20261005-dupont-oob-window-fix): - R1: no intersection with available month domain => explicit error (endpoint 400), no window fields - R2: with intersection => intersection window, months>=1 (kills inversion and months<=0) - R3: quarter/half/... suffix kept only when returned window == nominal window (clipped flag) - R4: symmetric lower-bound clip note; M2: TTM nominal window = [anchor-11, anchor] (12 months) - M1: docstring correction (lower-bound overflow is clipped, not 400) No push / no deploy / no restart / no DB write.
126 lines
4.7 KiB
Python
126 lines
4.7 KiB
Python
# -*- 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
|
||
|
||
# M2(2026-10-05 裁定 §4):TTM 名义区间 = [anchor−11, anchor] = **12 个月**。
|
||
# 历史断言写的是 13 个月(anchor 的「去年同月」),即被裁定为缺陷的口径,见
|
||
# test_dupont_oob_window_ruling_20261005.py / 本文件 §裁定回归。
|
||
ttm = rw("ttm", anchor="2026-10")
|
||
assert ttm["period_key"] == "ttm"
|
||
assert (ttm["period_start"], ttm["period_end"]) == ("2025-11", "2026-10")
|
||
assert ttm["nominal_months"] == 12
|
||
assert ttm["months"] == 12
|
||
|
||
|
||
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)
|