Files
cma-management/backend/tests/test_strategy_board_20261011.py
Hermes CI Fix 75a48f55d2 feat(strategy-map): live board reading map_objectives + kpi_targets (read-only)
Task: finance-20261011-strategy-map-frontend-fullstack (P2, read-only integration)

- backend/app/utils/strategy_board.py: single source of truth for the level
  caliber (layer colors, inverse-kpi explicit whitelist, status class by
  unit, zero guard, period ranking, latest-actual picking). Pure functions.
- backend/app/api/maps.py: GET /api/cma/maps/{map_id}/strategy-board
  - nodes straight from map_objectives (sorted by sort_order, id)
  - node<->kpi binding with 4-step fallback, binding_source echoed back
  - targets from kpi_targets (target_type compatible: yearly + annual),
    deduped per (kpi_id, period) with yearly preferred
  - actuals from kpi_values actual-only scope, latest period
  - causality edges from kpi_causality, out-of-map endpoints dropped+counted
  - entity_id taken from the token only (no cross-tenant reads)
  - zero writes; nothing is persisted by this endpoint
- frontend/src/views/StrategyMap.vue: /strategy-map live board (4 swim lanes
  bottom-up, node cards, 20 causality arrows in SVG, period switch, A3 print).
  No emoji, null guards everywhere (no toFixed on null).
- api/index.ts + router/index.ts + permission.ts: route + menu registration
  (/strategy-map, zone A read-only, roles ceo/finance/business/it).
- backend/tests/test_strategy_board_20261011.py: 33 cases (spec section 4 /
  4.1 / 4.2 / 4.3 + section 7 fixtures + threshold boundaries + period rank).
- backend/tests/test_map_objectives_kpis_form_20261011.py: 14 cases for the
  four kpis column shapes (list, json string, object list, NULL/[]/weird).

Not included: push + backend restart (production write, separate gate).

(cherry picked from commit c675bde6b4beaf1d8a9d02a80f5434203a53f348)
2026-10-18 16:31:52 +08:00

175 lines
8.0 KiB
Python
Raw Permalink 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 -*-
"""战略地图活看板色阶口径回归(任务 finance-20261011-strategy-map-frontend-fullstack)
覆盖件 §四/§4.1/§4.2/§4.3 与 §七「可直接复用的对照值」(2026-01~08 现取)。
纯函数测试:不连库、不依赖 FastAPI。
"""
import pytest
from app.utils.strategy_board import (
INVERSE_KPI_IDS, LAYER_META, LEVEL_COLORS, STATUS_UNIT,
compute_level, is_inverse_kpi, is_status_kpi, layer_meta, period_rank, pick_latest,
)
# ── §七 对照值(前端自测用,逐条断言期望色)──────────────────────────
@pytest.mark.parametrize("name,kpi_id,unit,current,target,expect", [
("营业收入", 1, "万元", 808.80, 1050, "danger"), # r=0.77
("CM 率", 503, "%", 16.83, 43, "danger"),
("CCC(逆)", 504, "天", 206.4, 150, "danger"), # r=0.727 取倒数
("货币资金", 505, "万元", 5.31, 60, "danger"),
("高贡献线收入占比", 506, "%", 47.81, 55, "warning"), # r=0.87
])
def test_spec_section7_fixtures(name, kpi_id, unit, current, target, expect):
got = compute_level(kpi_id=kpi_id, unit=unit, current=current, target=target)
assert got["level"] == expect, f"{name}: {got}"
def test_inverse_ccc_rate_is_reciprocal():
"""§4.1 CCC 206.4 → 目标 150:r = 150/206.4 = 0.7267(不是 206.4/150=1.376 的绿灯)"""
got = compute_level(kpi_id=504, unit="天", current=206.4, target=150)
assert got["mode"] == "inverse"
assert got["rate"] == pytest.approx(0.7267, abs=1e-4)
assert got["level"] == "danger"
# 若误按正向算会得到 success —— 这正是本回归要拦住的假绿
assert compute_level(kpi_id=9999, unit="天", current=206.4, target=150)["level"] == "success"
def test_inverse_whitelist_is_explicit_not_direction_based():
"""§4.1 逆指标=显式白名单;#230/#23 库内 direction 为 NULL 也不得漏"""
assert INVERSE_KPI_IDS == frozenset({504, 511, 230, 23})
for kid in (504, 511, 230, 23):
assert is_inverse_kpi(kid) is True
for kid in (1, 11, 502, 503, 505, 506, 507, 508, 509, 510, 512, 513, 514, 515, 516):
assert is_inverse_kpi(kid) is False
# 短期借款 #230:unit=元、direction=NULL ⇒ 逆指标(不能因 direction 缺失判成状态类/正向)
got = compute_level(kpi_id=230, unit="元", current=211.5, target=211.5)
assert got["mode"] == "inverse"
assert got["level"] == "success"
# ── §4.2 状态类:按 unit 判,1=绿 / 0=NULL=红,且绝不做除法 ──────────
@pytest.mark.parametrize("kpi_id,current,expect", [
(512, 1, "success"),
(512, 0, "danger"),
(512, None, "danger"), # 件 §4.2 逐字:0 / NULL = 未达成 ⇒ 红
(513, 1, "success"),
(515, 1, "success"),
(516, 0, "danger"),
])
def test_status_class_by_unit(kpi_id, current, expect):
unit = STATUS_UNIT # '状态'
got = compute_level(kpi_id=kpi_id, unit=unit, current=current, target=1)
assert got["mode"] == "status"
assert got["level"] == expect
assert got["rate"] is None, "状态类不得产出达成率"
def test_status_class_never_divides():
"""状态类 + 目标 0/缺失 ⇒ 依然按 1/0 判色,不触发除零"""
for target in (None, 0):
got = compute_level(kpi_id=512, unit="状态", current=0, target=target)
assert got["level"] == "danger" and got["rate"] is None
assert is_status_kpi("状态") is True
assert is_status_kpi("状态数") is False # 精确匹配,不用 startswith
assert is_status_kpi(None) is False
def test_status_not_inferred_from_null_direction():
"""`direction IS NULL` 不等于状态类:#230/#23 同为 NULL 但不走状态分支"""
for kid, unit in ((230, "元"), (23, "%")):
assert compute_level(kpi_id=kid, unit=unit, current=10, target=10)["mode"] == "inverse"
for kid in (512, 513, 515, 516):
assert compute_level(kpi_id=kid, unit=STATUS_UNIT, current=1, target=1)["mode"] == "status"
# ── §4.3 零点保护 / 空值 ────────────────────────────────────────────
@pytest.mark.parametrize("current,target,reason", [
(None, 150, "无实际值"),
(206.4, None, "目标值缺失或为 0"),
(206.4, 0, "目标值缺失或为 0"),
(0, 150, "逆指标现值为 0(倒数无定义)"),
])
def test_zero_guard_returns_info(current, target, reason):
got = compute_level(kpi_id=504, unit="天", current=current, target=target)
assert got["level"] == "info"
assert got["color"] == LEVEL_COLORS["info"]
assert got["rate"] is None
assert got["reason"] == reason
def test_never_nan_or_inf_or_raise():
"""历史教训:`{{ current.cover.toFixed(1) }}` 对 null 无守卫崩卡 —— 后端必须不产出 NaN/Inf"""
bad_inputs = [
(None, None), (None, 0), ("", ""), ("abc", "def"), (float("nan"), 100),
(100, float("nan")), (float("inf"), 100), (100, float("inf")),
("206.4", "150"), (False, 1), ([], {}),
]
for cur, tgt in bad_inputs:
got = compute_level(kpi_id=504, unit="天", current=cur, target=tgt)
assert got["rate"] is None or got["rate"] == got["rate"]
assert got["rate"] not in (float("inf"), float("-inf"))
assert got["level"] in LEVEL_COLORS
# 字符串数字可按数值处理(Excel/JSON 常见)
assert compute_level(kpi_id=1, unit="万元", current="808.80", target="1050")["rate"] == pytest.approx(0.7703, abs=1e-4)
# ── 阈值边界 ────────────────────────────────────────────────────────
@pytest.mark.parametrize("current,target,expect", [
(100, 100, "success"), (120, 100, "success"),
(80, 100, "warning"), (99.9, 100, "warning"),
(79.99, 100, "danger"), (0.01, 100, "danger"),
])
def test_threshold_boundaries(current, target, expect):
assert compute_level(kpi_id=1, unit="万元", current=current, target=target)["level"] == expect
# ── 期间排序 / 取最新(实测区取值口径)─────────────────────────────
@pytest.mark.parametrize("a,b", [
("2026-08", "2026-H1"), ("2026-Q4", "2026-09"), ("2027", "2026-H2"),
("2026-12", "2026-01"), ("2026-Q3", "2026-Q2"),
])
def test_period_rank_order(a, b):
assert period_rank(a) > period_rank(b)
def test_period_rank_unparsable_is_zero():
for bad in (None, "", "未知", "abc", "2026-13"):
assert period_rank(bad) == 0
class _Row:
def __init__(self, period, data_status, actual_value, id):
self.period, self.data_status, self.actual_value, self.id = period, data_status, actual_value, id
def test_pick_latest_prefers_newer_period_then_verified():
rows = [
_Row("2026-01", "verified", 180.87, 1),
_Row("2026-08", "error", 14.45, 9),
_Row("2026-08", "verified", 14.45, 8),
_Row("2026-H1", "verified", 713.27, 10),
]
got = pick_latest(rows)
assert got.period == "2026-08"
assert got.data_status == "verified", "同期间内必须取 verified(不被 error 行顶掉)"
assert pick_latest([]) is None
# ── 层色规范(§三,固定)─────────────────────────────────────────────
def test_layer_colors_fixed():
assert LAYER_META["finance"]["color"] == "#F56C6C"
assert LAYER_META["customer"]["color"] == "#409EFF"
assert LAYER_META["process"]["color"] == "#67C23A"
assert LAYER_META["learning"]["color"] == "#E6A23C"
# 未知/缺失 dimension_key 不抛错、不丢节点(置灰占位)
assert layer_meta(None)["color"] == LEVEL_COLORS["info"]
assert layer_meta("mystery")["color"] == LEVEL_COLORS["info"]
def test_level_colors_fixed():
assert LEVEL_COLORS["success"] == "#16A34A"
assert LEVEL_COLORS["warning"] == "#EAB308"
assert LEVEL_COLORS["danger"] == "#DC2626"
assert LEVEL_COLORS["info"] == "#94A3B8"