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.
206 lines
11 KiB
Python
206 lines
11 KiB
Python
# -*- 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)
|