Files
cma-management/backend/tests/test_dupont_oob_window_ruling_20261005.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

206 lines
11 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 -*-
"""杜邦区间窗口「完全越出可用月域」回归用例(2026-10-05 口径裁定 R1–R4 + M2)。
裁定件(财物侧口径,执行前必读):
/srv/hermes/mailbox/finance/pending-tasks/_reports/project-20261005-dupont-oob-ruling-finance.md
施工单: project-20261005-dupont-oob-window-fix-fullstack.md
缺陷(改前实测,部署树 HEAD 3ed9fa0):
· `?period=2026-Q4` → 200 / period_start=2026-10 > period_end=2026-09 / months=0 /
label「2026年10-9月(季度)」/ 四因子全 None(**窗口倒挂 + 名号不符 + 静默空数据**);
· `?period=2026-H2` → 裁至 2026-07..09(实 3 个月)却仍标「(半年)」。
裁定后口径(本文件逐条断言):
· R1 整区间与可用月域 D 无交集 ⇒ `error`(端点转 400),**不返回任何窗口/数值字段**;
· R2 有交集 ⇒ 窗口 = 交集(双向裁剪),`months` = 实际月数 ≥ 1(不倒挂);
· R3 后缀((季度)/(半年)…)仅在「返回窗口 == 名义窗口」时保留 ⇒ 由 `clipped` 判;
· R4 裁剪留痕:上界沿用既有文案;下界为对称文案;
· M2 TTM 名义区间 = [anchor−11, anchor] = 12 个月。
可用月域取裁定 §2 场景:D = 2026-01 ~ 2026-09(latest=2026-09)。
本文件只测纯函数(`resolve_window` / `apply_period_suffix`),不连库、不写盘、不碰服务。
"""
import pytest
from app.utils import dupont_basis as _du
from app.utils.dupont_basis import months_between, resolve_window
def apply_period_suffix(label, period_key, clipped=False):
"""R3 判据入口(经模块属性取,便于负对照)。
改前件(origin/main 3ed9fa0)**没有** `apply_period_suffix` ⇒ 走显式断言失败
(AssertionError,而不是 import 期 ImportError 导致整个用例文件收集失败),
这样「把实现换回改前版本」时这些断言会以**可读的失败**呈现。
"""
fn = getattr(_du, "apply_period_suffix", None)
assert fn is not None, ("resolve_window/dupont_basis 未落地 R3 后缀纪律(clipped 判据)"
"—— 改前件预期失败")
return fn(label, period_key, clipped)
#: 裁定 §2/§4 场景可用月域(latest = 2026-09,博海账套实测形态)
D_AVAIL = [f"2026-{m:02d}" for m in range(1, 10)]
#: 整区间无交集的统一 400 文案(端点直接把它作为 `detail`,前端零改动可正确呈现)
Q4_DETAIL = "请求区间 2026-10~2026-12 与可用月域 2026-01~2026-09 无交集,无法计算区间指标"
def rwd(period, start=None, end=None, **kw):
kw.setdefault("today", "2026-10")
return resolve_window(period, start, end, D_AVAIL, **kw)
# ── R1:整区间与可用月域无交集 ⇒ 显式拒绝(不返回窗口/数值字段) ──────────────
def test_q4_out_of_domain_rejected_with_exact_detail():
"""`?period=2026-Q4`(A=[2026-10,2026-12] 与 D 无交集)⇒ error,文案逐字符合裁定 §2.1。"""
w = rwd("2026-Q4")
assert w.get("error") == Q4_DETAIL, w
# 不得以 null/空串占位:窗口/月数/标签/裁剪留痕一律**不存在**
for k in ("period_start", "period_end", "months", "months_label", "label",
"period", "clip_note", "nominal_months", "clipped"):
assert k not in w, (k, w)
@pytest.mark.parametrize("period,start,end", [
("2026-Q4", None, None), # 未来侧整季度越界
("2026-10", None, None), # 未来侧单月越界
("2027", None, None), # 未来侧整年越界
("2025-Q1", None, None), # 历史侧整季度(域前)
("2025-Q4", None, None), # 历史侧整季度(域前)
("custom", "2026-10", "2026-12"), # custom 未来侧整段
("custom", "2026-11", "2026-11"), # custom 未来侧单月(改前 months=-1)
("custom", "2025-01", "2025-12"), # custom 历史侧整段
])
def test_all_no_intersection_forms_rejected(period, start, end):
w = rwd(period, start, end)
assert w.get("error"), (period, start, end, w)
assert "无交集" in w["error"]
assert "period_start" not in w and "months" not in w
def test_nominal_inverted_custom_never_returns_inverted_window():
"""名义区间自身颠倒(custom start > end)⇒ 拒绝,绝不产出倒挂窗口。"""
w = rwd("custom", "2026-05", "2026-03")
assert w.get("error"), w
assert "period_start" not in w
# ── R2 + R3 + R4:有交集 ⇒ 双向裁剪 + 月数/标签如实 ──────────────────────────
def test_h2_clips_upper_bound_label_and_months():
"""`?period=2026-H2` ⇒ 窗口 [2026-07,2026-09]、months=3、标签无「(半年)」、上界留痕。"""
w = rwd("2026-H2")
assert "error" not in w
assert w["period_key"] == "half" # 请求形态不变(向后兼容)
assert (w["period_start"], w["period_end"]) == ("2026-07", "2026-09")
assert w["months"] == 3
assert w["label"] == "2026年7-9月"
assert w["nominal_start"] == "2026-07" and w["nominal_end"] == "2026-12"
assert w["nominal_months"] == 6
assert w["clipped"] is True
assert w["clip_note"] == "上界 2026-12 超出可用月域(最新 2026-09),已裁至可用域"
# R3:发生裁剪 ⇒ 去后缀(裁定 §2.2 期望标签「2026年7-9月」,不得带「(半年)」)
assert apply_period_suffix(w["label"], w["period_key"], w["clipped"]) == "2026年7-9月"
assert "半年" not in apply_period_suffix(w["label"], w["period_key"], w["clipped"])
def test_h2_window_equals_q3_window():
"""强判据(裁定 §2.2):同一返回窗口 [2026-07,2026-09] ⇒ H2 与 Q3 窗口逐项相同。"""
h2, q3 = rwd("2026-H2"), rwd("2026-Q3")
assert "error" not in h2 and "error" not in q3
for k in ("period_start", "period_end", "months", "label"):
assert h2[k] == q3[k], (k, h2[k], q3[k])
def test_full_year_clips_lower_upper_and_label():
"""`?period=2026`(全年)⇒ [2026-01,2026-09]、months=9、标签无「(全年)」。"""
w = rwd("2026")
assert (w["period_start"], w["period_end"], w["months"]) == ("2026-01", "2026-09", 9)
assert w["label"] == "2026年1-9月"
assert w["clipped"] is True
assert apply_period_suffix(w["label"], w["period_key"], w["clipped"]) == "2026年1-9月"
def test_custom_lower_bound_clip_note_symmetric():
"""R4:下界越界(域起点晚于名义起点)⇒ 裁到 avail_start + 对称留痕 + 去后缀。"""
w = rwd("custom", "2025-01", "2026-06")
assert (w["period_start"], w["period_end"], w["months"]) == ("2026-01", "2026-06", 6)
assert w["clipped"] is True
assert w["clip_note"] == "下界 2025-01 早于可用月域起点(2026-01),已裁至可用域"
assert apply_period_suffix(w["label"], w["period_key"], w["clipped"]) == "2026年1-6月"
def test_ttm_nominal_12_months_and_lower_clip():
"""M2:TTM 名义 12 个月;域内 ⇒ 下界裁剪 + 去「(TTM 滚动 12 月)」后缀。"""
nom = resolve_window("ttm", None, None, [f"2025-{m:02d}" for m in range(1, 13)]
+ [f"2026-{m:02d}" for m in range(1, 13)], anchor="2026-08")
assert (nom["period_start"], nom["period_end"], nom["nominal_months"]) == (
"2025-09", "2026-08", 12)
w = rwd("ttm", anchor="2026-08")
assert (w["period_start"], w["period_end"], w["months"]) == ("2026-01", "2026-08", 8)
assert w["clipped"] is True
assert w["clip_note"] == "下界 2025-09 早于可用月域起点(2026-01),已裁至可用域"
assert apply_period_suffix(w["label"], w["period_key"], w["clipped"]) == "2026年1-8月"
def test_ytd_unclipped_keeps_suffix():
"""未裁剪(返回窗口 == 名义窗口)⇒ 后缀保留(R3 的另一半)。"""
w = rwd("ytd", anchor="2026-08")
assert w["clipped"] is False
assert w["clip_note"] is None
assert apply_period_suffix(w["label"], w["period_key"], w["clipped"]) == "2026年1-8月(YTD 年初至今)"
# ── R3 后缀纪律(纯函数) ─────────────────────────────────────────────────────
@pytest.mark.parametrize("label,key,unclipped,clipped", [
("2026年1-6月", "half", "2026年1-6月(半年)", "2026年1-6月"),
("2026年1-3月", "quarter", "2026年1-3月(季度)", "2026年1-3月"),
("2026年1-12月", "year", "2026年1-12月(全年)", "2026年1-12月"),
("2026年8月", "month", "2026年8月(单月)", "2026年8月"),
("2026年1-8月", "ytd", "2026年1-8月(YTD 年初至今)", "2026年1-8月"),
("2025年9月-2026年8月", "ttm", "2025年9月-2026年8月(TTM 滚动 12 月)", "2025年9月-2026年8月"),
("2026年6-9月", "custom", "2026年6-9月(自定义区间)", "2026年6-9月"),
])
def test_apply_period_suffix_discipline(label, key, unclipped, clipped):
"""R3:未裁剪 ⇒ 保留后缀;发生任何裁剪 ⇒ 去后缀。"""
assert apply_period_suffix(label, key, False) == unclipped
assert apply_period_suffix(label, key, True) == clipped
# ── 全形态矩阵:任意入参下不得倒挂、months 与标签自洽 ────────────────────────
@pytest.mark.parametrize("period,start,end", [
("2026-Q1", None, None), ("2026-Q3", None, None), ("2026-Q4", None, None),
("2026-H1", None, None), ("2026-H2", None, None),
("2026-08", None, None), ("2026-10", None, None),
("2026", None, None), ("2026-01", None, None), ("2027", None, None),
("2025-Q1", None, None), ("2025-Q4", None, None),
("ytd", None, None), ("ttm", None, None),
("custom", "2026-06", "2026-12"), ("custom", "2026-10", "2026-12"),
("custom", "2026-11", "2026-11"), ("custom", "2025-01", "2026-06"),
])
def test_matrix_no_inversion_and_self_consistency(period, start, end):
w = rwd(period, start, end, anchor="2026-08")
if w.get("error"):
assert "无交集" in w["error"] or "无可用" in w["error"], w
return
assert w["period_start"] <= w["period_end"], w
assert w["months"] == months_between(w["period_start"], w["period_end"]), w
assert w["months"] >= 1, w
assert D_AVAIL[0] <= w["period_start"] <= w["period_end"] <= D_AVAIL[-1], w
assert w["clipped"] is (w["nominal_months"] != w["months"]), w
def test_clip_note_present_iff_clipped():
for period, start, end in [("2026-Q3", None, None), ("2026-H2", None, None),
("2026", None, None), ("2025-Q1", None, None),
("custom", "2025-01", "2026-06")]:
w = rwd(period, start, end)
if w.get("error"):
assert "clip_note" not in w
continue
assert (w["clip_note"] is not None) is w["clipped"], (period, w)