Files
cma-management/backend/tests/test_dupont_period_options.py
T
wecom-fullstack 4f9bab6bc0
ci/woodpecker/push/main Pipeline failed
fix(dupont): reject out-of-domain window + bidirectional clip + suffix discipline (R1-R4/M2)
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.
2026-10-05 02:02:21 +08:00

126 lines
4.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)