# -*- coding: utf-8 -*-
"""
第八批模块单测 (零外部依赖, 不连库不触网)
==========================================
运行: 在 tradingSystem 仓库根目录执行  python scripts/test_batch8_units.py
覆盖: 上游榜单的版本比对 (app/core/plan_diff.py) —— 名册与指纹、三种比对语义、
      进出榜的尾部闸、档位升降与券商覆盖翻转、名次跳变、持仓票视图。

这一批的重点不是"能不能算出差异", 而是**不报假变化**: 榜单是按 top 截断过的, 尾部的进出
是截断噪音而不是上游观点变化。假警报天天响, 提示就等于没有 —— 与 `_capped` 治的是同一个病。
"""
import os
import sys
import traceback

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from app.core import plan_diff as pd                                           # noqa: E402

RESULTS = []


def case(name):
    def deco(fn):
        RESULTS.append((name, fn))
        return fn
    return deco


# ================================================================ fixture
def _r(code, rank, score, tier="强传导", theme="储能", bucket="main", name=None):
    """名册单行 (plan_diff 内部形态)。"""
    return {"c": code, "n": name or code[:6], "r": rank, "s": score,
            "t": tier, "h": theme, "b": bucket}


def roster(*rows) -> dict:
    return {r["c"]: r for r in rows}


def plan(main=(), observe=(), date="2026-07-30", capped_main=False, capped_obs=False) -> dict:
    """plan_feed.parse_plan() 结果的最小形态。

    `returned` / `truncated` 也要带上 —— 快照落库时靠它们记条数与截断标志, 少了的话
    "上一版坏没坏"这类判据在单测里恒为假, 用例看着过了实际什么都没验。
    """
    def row(x, bucket):
        return {"ts_code": x["c"], "name": x["n"], "rank": x["r"], "score": x["s"],
                "tier": x["t"], "theme": x["h"], "bucket": bucket}
    return {"date": date,
            "main": [row(x, "main") for x in main],
            "observe": [row(x, "observe") for x in observe],
            "returned": {"main": len(main), "observe": len(observe)},
            "truncated": {"main": bool(capped_main), "observe": bool(capped_obs)}}


# ================================================================ [A] 名册与指纹
@case("[A1] roster_of 从计划结构取出名册")
def t_a1():
    ro = pd.roster_of(plan(main=[_r("600418.SH", 1, 242.2)],
                          observe=[_r("600877.SH", 1, 101.1, tier=None, bucket="observe")]))
    assert set(ro) == {"600418.SH", "600877.SH"}
    assert ro["600418.SH"]["b"] == "main" and ro["600877.SH"]["b"] == "observe"
    assert ro["600418.SH"]["t"] == "强传导" and ro["600877.SH"]["t"] is None


@case("[A2] 同一只票两档都出现时以主榜为准")
def t_a2():
    p = plan(main=[_r("600418.SH", 3, 240.0)],
             observe=[_r("600418.SH", 1, 99.0, tier=None, bucket="observe")])
    ro = pd.roster_of(p)
    assert len(ro) == 1
    assert ro["600418.SH"]["b"] == "main", "主榜在前, 观察档那条不该覆盖它"


@case("[A3] 指纹稳定: 同一名册两次算结果一致")
def t_a3():
    a = roster(_r("600000.SH", 1, 10.0), _r("600418.SH", 2, 9.0))
    b = roster(_r("600418.SH", 2, 9.0), _r("600000.SH", 1, 10.0))   # 插入次序不同
    assert pd.digest_of(a) == pd.digest_of(b), "指纹不能依赖 dict 的插入次序"


@case("[A4] 指纹不含 score —— 分数抖动但次序没变, 不算新版本")
def t_a4():
    a = roster(_r("600000.SH", 1, 242.2801))
    b = roster(_r("600000.SH", 1, 242.2799))
    assert pd.digest_of(a) == pd.digest_of(b), (
        "/plan 是实时算的, score 末位天天抖; 算进指纹会让快照表白涨而信息量为零")


@case("[A5] 指纹含 rank —— 次序变了就是新版本")
def t_a5():
    assert pd.digest_of(roster(_r("600000.SH", 1, 10.0))) != \
           pd.digest_of(roster(_r("600000.SH", 2, 10.0)))


@case("[A6] 指纹含 tier / theme / bucket")
def t_a6():
    base = roster(_r("600000.SH", 1, 10.0))
    assert pd.digest_of(base) != pd.digest_of(roster(_r("600000.SH", 1, 10.0, tier="弱传导")))
    assert pd.digest_of(base) != pd.digest_of(roster(_r("600000.SH", 1, 10.0, theme="整机制造")))
    assert pd.digest_of(base) != pd.digest_of(roster(_r("600000.SH", 1, 10.0, bucket="observe")))


@case("[A7] 名册 ↔ 落库行 往返不丢字段")
def t_a7():
    ro = roster(_r("600418.SH", 7, 233.3, tier="弱传导", theme="整车"))
    back = pd.roster_from_rows(pd.roster_rows(ro))
    assert back == ro
    assert pd.digest_of(back) == pd.digest_of(ro)


@case("[A8] 落库行里的坏数据跳过而不是整份废掉")
def t_a8():
    back = pd.roster_from_rows([{"c": "600000.SH", "r": 1}, "坏行", None, {}, {"r": 5}])
    assert list(back) == ["600000.SH"]


@case("[A9] 名册行按代码序输出 —— 落库文本稳定")
def t_a9():
    rows = pd.roster_rows(roster(_r("600418.SH", 2, 9.0), _r("600000.SH", 1, 10.0)))
    assert [r["c"] for r in rows] == ["600000.SH", "600418.SH"]


# ================================================================ [B] 档位序
@case("[B1] 档位强弱: 强传导 > 弱传导 > 无传导")
def t_b1():
    assert pd.tier_rank("强传导") > pd.tier_rank("弱传导") > pd.tier_rank("无传导")


@case("[B2] 未知档位返回 None (判不了就不判)")
def t_b2():
    assert pd.tier_rank("超强传导") is None and pd.tier_rank(None) is None


@case("[B3] 未知档位不参与升降判定 —— 宁可不报, 不能报反")
def t_b3():
    d = pd.diff(roster(_r("600000.SH", 1, 10.0, tier="超强传导")),
                roster(_r("600000.SH", 1, 10.0, tier="强传导")),
                prev_date="2026-07-29", curr_date="2026-07-30")
    assert d["counts"]["tier_up"] == 0 and d["counts"]["tier_down"] == 0


# ================================================================ [C] 首次
@case("[C1] 没有上一版时一条变化都不报")
def t_c1():
    d = pd.diff(None, roster(*[_r(f"60000{i}.SH", i, 100 - i) for i in range(1, 9)]),
                curr_date="2026-07-30")
    assert d["kind"] == pd.KIND_FIRST
    assert sum(d["counts"].values()) == 0, "首次落快照若报'新进榜', 会是整整一榜的假变化"
    assert d["roster_size"]["curr"] == 8


@case("[C2] 首次的 note 说明从下一次开始才有比对")
def t_c2():
    d = pd.diff({}, roster(_r("600000.SH", 1, 10.0)))
    assert "首次" in d["note"] and d["prev_broken"] is False


@case("[C3] 上一版读不出来要说成故障, 不能长得跟'首次'一样")
def t_c3():
    # 快照的 roster_json 坏了 → 解析成空。若与"首次"同形, 一条真实变化都不报却显示正常。
    d = pd.diff({}, roster(_r("600000.SH", 1, 10.0)),
                prev_date="2026-07-29", curr_date="2026-07-30", prev_broken=True)
    assert d["prev_broken"] is True
    assert "读不出来" in d["note"] and "比不了" in d["note"]
    assert "首次" not in d["note"]


@case("[C4] 首次也要回显调用方给的参数, 不能写死默认值")
def t_c4():
    d = pd.diff(None, roster(_r("600000.SH", 1, 10.0)), rank_jump=80, tail_guard=0.2)
    assert d["params"] == {"rank_jump": 80, "tail_guard": 0.2}, "写死会让页面显示错的口径"


# ================================================================ [D] 三种比对语义
@case("[D1] 计划日不同 = 跨日的正常升降档")
def t_d1():
    d = pd.diff(roster(_r("600000.SH", 1, 10.0)), roster(_r("600000.SH", 1, 10.0)),
                prev_date="2026-07-29", curr_date="2026-07-30")
    assert d["kind"] == pd.KIND_CROSS_DAY


@case("[D2] 同一计划日的不同版本要单独成一类")
def t_d2():
    d = pd.diff(roster(_r("600000.SH", 1, 10.0)), roster(_r("600000.SH", 2, 10.0)),
                prev_date="2026-07-30", curr_date="2026-07-30")
    assert d["kind"] == pd.KIND_REVISION


@case("[D3] 同日新版本的结论要点明'候选池与此前不是同一份'")
def t_d3():
    d = pd.diff(roster(_r("600000.SH", 1, 10.0)), roster(_r("600418.SH", 1, 11.0)),
                prev_date="2026-07-30", curr_date="2026-07-30")
    assert "重算" in d["note"] and "候选池" in d["note"]


# ================================================================ [E] 进出榜与尾部闸
@case("[E1] 新进榜与掉榜各归各位")
def t_e1():
    prev = roster(_r("600000.SH", 1, 10.0), _r("600418.SH", 2, 9.0))
    curr = roster(_r("600000.SH", 1, 10.0), _r("600519.SH", 2, 9.5))
    d = pd.diff(prev, curr, prev_date="2026-07-29", curr_date="2026-07-30")
    assert [x["ts_code"] for x in d["entered"]] == ["600519.SH"]
    assert [x["ts_code"] for x in d["exited"]] == ["600418.SH"]


@case("[E2] 掉榜那条带的是**上一版**的排名与档位")
def t_e2():
    prev = roster(_r("600418.SH", 7, 233.3, tier="弱传导"))
    d = pd.diff(prev, roster(_r("600000.SH", 1, 10.0)),
                prev_date="2026-07-29", curr_date="2026-07-30")
    x = d["exited"][0]
    assert x["rank"] == 7 and x["tier"] == "弱传导", "掉榜的票在这一版没有数据, 只能示上一版"


@case("[E3] 两版都没吃满 top 时不设尾部闸 —— 消失就是真消失")
def t_e3():
    prev = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)])
    curr = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 10)])   # 第 10 名没了
    d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30",
                prev_capped=False, curr_capped=False)
    assert d["counts"]["exited"] == 1 and d["tail_churn"]["exited"] == 0
    # tail_guarded 报的是各档闸位在第几名, None = 该档没设闸 (让"为什么这条没报"可查)
    assert d["tail_guarded"] == {"entered": {"main": None, "observe": None},
                                 "exited": {"main": None, "observe": None}}


@case("[E4] 这一版吃满 top 时, 榜尾掉榜归 tail_churn 不列名")
def t_e4():
    prev = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)])
    curr = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 10)])   # 原第 10 名没了
    d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30",
                curr_capped=True)                    # 榜长 9, 闸位 4.5, 上一版 rank 10 在闸外
    assert d["counts"]["exited"] == 0
    assert d["tail_churn"]["exited"] == 1, "它可能只是掉到我们要的条数之外, 不是上游摘了它"


@case("[E5] 吃满 top 也照报榜首掉榜")
def t_e5():
    prev = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)])
    curr = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(2, 11)])   # 榜首没了
    d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30", curr_capped=True)
    assert [x["ts_code"] for x in d["exited"]] == ["600001.SH"]
    assert d["tail_churn"]["exited"] == 0


@case("[E6] 新进榜的尾部闸看的是**上一版**吃没吃满")
def t_e6():
    prev = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)])
    curr = dict(prev)
    curr["600099.SH"] = _r("600099.SH", 9, 91.5)     # 新面孔排在榜尾
    d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30", prev_capped=True)
    assert d["counts"]["entered"] == 0
    assert d["tail_churn"]["entered"] == 1, "上一版吃满了, 这张脸当时可能就在榜上只是没露面"


@case("[E7] 上一版吃满时, 榜首的新面孔照报")
def t_e7():
    prev = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)])
    curr = dict(prev)
    curr["600099.SH"] = _r("600099.SH", 1, 200.0)    # 新面孔直接冲到第一
    d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30", prev_capped=True)
    assert [x["ts_code"] for x in d["entered"]] == ["600099.SH"]


@case("[E8] 两道闸互不干扰: 这一版截断不该影响新进榜的判定")
def t_e8():
    prev = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)])
    curr = dict(prev)
    curr["600099.SH"] = _r("600099.SH", 9, 91.5)
    d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30",
                prev_capped=False, curr_capped=True)
    assert d["counts"]["entered"] == 1, "上一版没吃满, 那这张新面孔就是真的新"
    assert d["tail_churn"]["entered"] == 0


@case("[E9] 整档消失不被尾部闸吞掉")
def t_e9():
    prev = roster(_r("600877.SH", 1, 101.0, tier=None, bucket="observe"),
                  _r("600000.SH", 1, 10.0))
    curr = roster(_r("600000.SH", 1, 10.0))          # 观察档整档没了
    d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30", curr_capped=True)
    assert [x["ts_code"] for x in d["exited"]] == ["600877.SH"], (
        "这一版观察档一条都没有, 谈不上被 top 截断 —— 整档消失本身就是要报的大事")


@case("[E10] 尾部闸按档各算各的 (闸位取自各自那一档的长度)")
def t_e10():
    prev = roster(*([_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)]
                    + [_r("600877.SH", 1, 101.0, tier=None, bucket="observe")]))
    curr = roster(*([_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 10)]
                    + [_r(f"6009{i:02d}.SH", i, 90 - i, tier=None, bucket="observe")
                       for i in range(1, 11)]))
    d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30",
                curr_capped={"main": True, "observe": True})
    # 主榜榜尾那只走 churn; 观察档那只 rank=1、这一版观察档有 10 条(闸位 5), 在闸内 → 照报
    assert [x["ts_code"] for x in d["exited"]] == ["600877.SH"]
    assert d["tail_churn"]["exited"] == 1


@case("[E11] 主榜吃满不代表观察档也吃满 —— 别拿主榜的截断解释观察档的摘牌")
def t_e11():
    # 上游两个独立参数: top=10 吃满了, obs_top=100 只回了 4 条 (没吃满)。
    # 观察档那只真被摘了 —— 若两档共用主榜那一个标志, 它会被记成"截断噪音"永远看不见。
    prev = roster(*([_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)]
                    + [_r(f"6009{i:02d}.SH", i, 90 - i, tier=None, bucket="observe")
                       for i in range(1, 5)]))
    curr = roster(*([_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)]
                    + [_r(f"6009{i:02d}.SH", i, 90 - i, tier=None, bucket="observe")
                       for i in range(1, 4)]))          # 观察档第 4 名被摘
    d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30",
                curr_capped={"main": True, "observe": False})
    assert [x["ts_code"] for x in d["exited"]] == ["600904.SH"], (
        "观察档没吃满就不该设闸 —— 拿主榜的截断解释观察档, 会吞掉一次真实摘牌")
    assert d["tail_churn"]["exited"] == 0
    # 反过来: 两档都按吃满算, 这条就会被吞掉 (证明上面那条断言不是白写的)
    d2 = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30", curr_capped=True)
    assert d2["counts"]["exited"] == 0 and d2["tail_churn"]["exited"] == 1


@case("[E12] 持仓票不受榜尾闸约束 —— 漏报一条就是一个仓位")
def t_e12():
    prev = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)])
    curr = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 10)])
    # 非持仓: 上一版 rank 10 在闸外 → 吞掉
    d0 = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30", curr_capped=True)
    assert d0["counts"]["exited"] == 0
    # 同一只票是持仓: 照报, 且记一笔豁免
    d1 = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30",
                 curr_capped=True, held=["600010.SH"])
    assert [x["ts_code"] for x in d1["exited"]] == ["600010.SH"]
    assert d1["tail_churn"]["exited"] == 0 and d1["tail_churn"]["held_exempt"] == 1
    assert d1["held"]["n_watch"] == 1


@case("[E13] rank 判不出来时照报, 不当噪音吞掉")
def t_e13():
    prev = roster(_r("600000.SH", None, 10.0), _r("600001.SH", 1, 99.0))
    curr = roster(_r("600001.SH", 1, 99.0))
    d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30", curr_capped=True)
    assert [x["ts_code"] for x in d["exited"]] == ["600000.SH"], (
        "这个函数的'是'代表吞掉一条变化, 所以拿不准时必须放行")


# ================================================================ [F] 档位与券商覆盖
@case("[F1] 升档与降档")
def t_f1():
    prev = roster(_r("600000.SH", 1, 10.0, tier="弱传导"), _r("600418.SH", 2, 9.0, tier="强传导"))
    curr = roster(_r("600000.SH", 1, 10.0, tier="强传导"), _r("600418.SH", 2, 9.0, tier="弱传导"))
    d = pd.diff(prev, curr, prev_date="2026-07-29", curr_date="2026-07-30")
    assert [x["ts_code"] for x in d["tier_up"]] == ["600000.SH"]
    assert d["tier_up"][0]["tier_from"] == "弱传导" and d["tier_up"][0]["tier_to"] == "强传导"
    assert [x["ts_code"] for x in d["tier_down"]] == ["600418.SH"]


@case("[F2] 主榜 ↔ 观察档 互换 = 券商覆盖翻转, 单独一段")
def t_f2():
    prev = roster(_r("600000.SH", 1, 10.0))
    curr = roster(_r("600000.SH", 1, 10.0, tier=None, bucket="observe"))
    d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30")
    assert len(d["bucket_moved"]) == 1
    m = d["bucket_moved"][0]
    assert m["moved_from"] == "main" and m["moved_to"] == "observe"
    assert d["counts"]["exited"] == 0, "换档不是掉榜 —— 票还在, 只是没券商覆盖了"


@case("[F3] 换了档就不算名次跳变 —— 两档的 rank 不可比")
def t_f3():
    prev = roster(_r("600000.SH", 300, 10.0))
    curr = roster(_r("600000.SH", 1, 10.0, tier=None, bucket="observe"))
    d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30", rank_jump=10)
    assert d["counts"]["rank_jump"] == 0
    assert d["counts"]["bucket_moved"] == 1


# ================================================================ [G] 名次跳变
@case("[G1] 跳变到阈值才报")
def t_g1():
    prev = roster(_r("600000.SH", 100, 10.0), _r("600418.SH", 20, 20.0))
    curr = roster(_r("600000.SH", 20, 30.0), _r("600418.SH", 15, 22.0))
    d = pd.diff(prev, curr, prev_date="2026-07-29", curr_date="2026-07-30", rank_jump=50)
    assert [x["ts_code"] for x in d["rank_jump"]] == ["600000.SH"]
    assert d["rank_jump"][0]["rank_from"] == 100 and d["rank_jump"][0]["rank_delta"] == -80


@case("[G2] 按跳变幅度降序")
def t_g2():
    prev = roster(_r("600000.SH", 200, 1.0), _r("600418.SH", 60, 2.0))
    curr = roster(_r("600000.SH", 100, 5.0), _r("600418.SH", 1, 9.0))
    d = pd.diff(prev, curr, prev_date="2026-07-29", curr_date="2026-07-30", rank_jump=50)
    assert [x["ts_code"] for x in d["rank_jump"]] == ["600000.SH", "600418.SH"]


@case("[G3] rank 缺失时不算跳变")
def t_g3():
    prev = roster(_r("600000.SH", None, 1.0))
    curr = roster(_r("600000.SH", 1, 9.0))
    d = pd.diff(prev, curr, prev_date="2026-07-29", curr_date="2026-07-30", rank_jump=1)
    assert d["counts"]["rank_jump"] == 0


# ================================================================ [H] 持仓票视图
@case("[H1] 持仓票掉榜进 n_watch")
def t_h1():
    prev = roster(_r("600000.SH", 1, 10.0), _r("600418.SH", 2, 9.0))
    curr = roster(_r("600418.SH", 1, 9.0))
    d = pd.diff(prev, curr, prev_date="2026-07-29", curr_date="2026-07-30",
                held=["600000.SH"])
    assert [x["ts_code"] for x in d["held"]["exited"]] == ["600000.SH"]
    assert d["held"]["n_watch"] == 1
    assert d["exited"][0]["held"] is True


@case("[H2] 持仓票降档也进 n_watch")
def t_h2():
    d = pd.diff(roster(_r("600000.SH", 1, 10.0, tier="强传导")),
                roster(_r("600000.SH", 1, 10.0, tier="弱传导")),
                prev_date="2026-07-29", curr_date="2026-07-30", held=["600000.SH"])
    assert d["held"]["n_watch"] == 1 and len(d["held"]["tier_down"]) == 1


@case("[H2b] 持仓票掉进观察档 = 丢了券商覆盖, 也要进 n_watch")
def t_h2b():
    # 观察档的行没有 tier, 所以这既不算 exited 也不算 tier_down —— 不显式算进来的话,
    # 一只持仓票丢了估值锚, 页面红条不亮、探活脚本还会打一句"没被摘也没降档"。
    d = pd.diff(roster(_r("600000.SH", 3, 240.0, tier="强传导")),
                roster(_r("600000.SH", 5, 99.0, tier=None, bucket="observe")),
                prev_date="2026-07-29", curr_date="2026-07-30", held=["600000.SH"])
    assert d["counts"]["exited"] == 0 and d["counts"]["tier_down"] == 0
    assert [x["ts_code"] for x in d["held"]["coverage_lost"]] == ["600000.SH"]
    assert d["held"]["n_watch"] == 1
    assert "券商覆盖" in d["note"]


@case("[H2c] 掉进主榜(拿到券商覆盖)不算要盯的事")
def t_h2c():
    d = pd.diff(roster(_r("600000.SH", 5, 99.0, tier=None, bucket="observe")),
                roster(_r("600000.SH", 3, 240.0, tier="强传导")),
                prev_date="2026-07-29", curr_date="2026-07-30", held=["600000.SH"])
    assert d["counts"]["bucket_moved"] == 1
    assert d["held"]["coverage_lost"] == [] and d["held"]["n_watch"] == 0


@case("[H3] 持仓票升档只进 n_total, 不进 n_watch")
def t_h3():
    d = pd.diff(roster(_r("600000.SH", 1, 10.0, tier="弱传导")),
                roster(_r("600000.SH", 1, 10.0, tier="强传导")),
                prev_date="2026-07-29", curr_date="2026-07-30", held=["600000.SH"])
    assert d["held"]["n_watch"] == 0 and d["held"]["n_total"] == 1


@case("[H4] 非持仓票不进持仓视图")
def t_h4():
    d = pd.diff(roster(_r("600000.SH", 1, 10.0)), roster(_r("600418.SH", 1, 10.0)),
                prev_date="2026-07-29", curr_date="2026-07-30", held=["600519.SH"])
    assert d["held"]["n_total"] == 0 and d["counts"]["exited"] == 1


@case("[H5] 持仓票掉榜要在结论句里显眼")
def t_h5():
    d = pd.diff(roster(_r("600000.SH", 1, 10.0)), roster(_r("600418.SH", 1, 10.0)),
                prev_date="2026-07-29", curr_date="2026-07-30", held=["600000.SH"])
    assert "持仓票" in d["note"] and "掉榜" in d["note"]


# ================================================================ [I] 健壮性
@case("[I1] 空对空不炸")
def t_i1():
    d = pd.diff({}, {}, prev_date="2026-07-30", curr_date="2026-07-30")
    assert d["kind"] == pd.KIND_FIRST and sum(d["counts"].values()) == 0


@case("[I2] 名册全没了也报得出来")
def t_i2():
    d = pd.diff(roster(_r("600000.SH", 1, 10.0)), {},
                prev_date="2026-07-29", curr_date="2026-07-30")
    assert d["counts"]["exited"] == 1 and d["roster_size"]["curr"] == 0


@case("[I3] tail_guard 越界值被夹回 [0,1]")
def t_i3():
    prev = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11)])
    curr = roster(*[_r(f"6000{i:02d}.SH", i, 100 - i) for i in range(1, 11) if i != 5])
    for given, clamped, reported in ((5.0, 1.0, 1), (-1.0, 0.0, 0)):
        d = pd.diff(prev, curr, prev_date="2026-07-30", curr_date="2026-07-30",
                    curr_capped=True, tail_guard=given)
        assert d["params"]["tail_guard"] == clamped
        # 闸开到底(1.0): 榜内的第 5 名掉了要报; 闸关到底(0.0): 一律当截断噪音
        assert d["counts"]["exited"] == reported


@case("[I4] counts 与各段长度始终一致")
def t_i4():
    prev = roster(_r("600000.SH", 1, 10.0, tier="强传导"), _r("600418.SH", 200, 2.0))
    curr = roster(_r("600000.SH", 1, 10.0, tier="弱传导"), _r("600519.SH", 3, 8.0))
    d = pd.diff(prev, curr, prev_date="2026-07-29", curr_date="2026-07-30")
    for k, n in d["counts"].items():
        assert len(d[k]) == n, f"{k} 的 counts 与实际条数对不上"


# ================================================================ [J] 服务层落库往返
# 纯逻辑之外唯一容易藏 bug 的地方: 名册 → JSON 落库 → 读回来 → 比对 这条往返。
# 单测不连库 —— 拿内存桩顶掉 pms_repo 与 param_store (plan_feed 是函数内 import, 装在
# sys.modules 上就生效; 与 test_batch7 用假 requests 是同一套路)。
def _with_fake_repo(params=None):
    import json as _j
    import types
    store = {"rows": [], "seq": 0}

    repo = types.ModuleType("app.repo.pms_repo")

    def insert_plan_snapshot(*, plan_date, digest, roster, meta=None, n_main=0, n_observe=0,
                             capped_main=False, capped_obs=False, fetched_at=None):
        # 与生产同一条判据: 只跟**最新那行**比, 不是"曾经出现过的任何一行"。
        # 早先桩里写的是后者 (模拟唯一键), 于是 A→B→A 这个 case 在桩上永远复现不出来 ——
        # 桩比生产更严, 单测就成了给错实现背书。
        last = max(store["rows"], key=lambda x: x["id"]) if store["rows"] else None
        if last and last["plan_date"] == plan_date and last["digest"] == digest:
            return 0
        store["seq"] += 1
        store["rows"].append({
            "id": store["seq"], "plan_date": plan_date, "digest": digest,
            "fetched_ymd": 20260731, "fetched_at": f"t{store['seq']}",
            "n_main": n_main, "n_observe": n_observe,
            "capped_main": bool(capped_main), "capped_obs": bool(capped_obs),
            # 真表是 MEDIUMTEXT, 落进去的是 JSON 文本、读回来才转对象。桩必须照做 ——
            # 否则"名册里塞了不可序列化的东西"这类事故单测里根本看不见。
            "roster_json": _j.dumps(roster, ensure_ascii=False), "meta": meta or {}})
        return 1

    def latest_plan_snapshots(limit=2):
        out = []
        for r in sorted(store["rows"], key=lambda x: -x["id"])[:limit]:
            r2 = dict(r)
            r2["roster"] = _j.loads(r2.pop("roster_json"))
            out.append(r2)
        return out

    def list_plan_snapshots(*, plan_date=None, limit=50):
        rows = [dict(r) for r in sorted(store["rows"], key=lambda x: -x["id"])]
        if plan_date:
            rows = [r for r in rows if r["plan_date"] == plan_date]
        for r in rows:
            r.pop("roster_json", None)
        return rows[:limit]

    repo.insert_plan_snapshot = insert_plan_snapshot
    repo.latest_plan_snapshots = latest_plan_snapshots
    repo.list_plan_snapshots = list_plan_snapshots
    repo.prune_plan_snapshots = lambda keep=200: 0
    repo.list_positions = lambda only_open=False: []

    ps = types.ModuleType("app.services.param_store")
    vals = dict(params or {})
    ps.get = lambda k, d=None: vals.get(k, d)
    ps.get_int = lambda k, d=0: int(vals.get(k, d))
    ps.get_float = lambda k, d=0.0: float(vals.get(k, d))
    ps.get_bool = lambda k, d=False: bool(vals.get(k, d))
    ps.get_list = lambda k, d=(): list(vals.get(k, d))

    saved = {n: sys.modules.get(n) for n in ("app.repo.pms_repo", "app.services.param_store")}
    sys.modules["app.repo.pms_repo"] = repo
    sys.modules["app.services.param_store"] = ps

    def restore():
        for n, m in saved.items():
            if m is None:
                sys.modules.pop(n, None)
            else:
                sys.modules[n] = m
    return store, restore


@case("[J1] 落库往返: 名册经 JSON 存取后指纹不变")
def t_j1():
    import json as _j
    from app.services import plan_feed as pf
    store, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
    try:
        p = plan(main=[_r("600418.SH", 1, 242.24, theme="整车"),
                       _r("600000.SH", 2, 241.55, tier="弱传导", theme=None)],
                 observe=[_r("600877.SH", 1, 101.1, tier=None, bucket="observe")],
                 date="2026-07-30")
        out = pf.snapshot(p)
        assert out["stored"] is True and out["rows"] == 3
        back = pd.roster_from_rows(_j.loads(store["rows"][0]["roster_json"]))
        assert pd.digest_of(back) == out["digest"], "存取一轮指纹就变了, 比对全废"
        assert back["600000.SH"]["h"] is None, "theme 为空的票也要原样存回来"
    finally:
        restore()


@case("[J2] 同一份榜重复落只留一行")
def t_j2():
    from app.services import plan_feed as pf
    store, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
    try:
        p = plan(main=[_r("600418.SH", 1, 242.24)], date="2026-07-30")
        assert pf.snapshot(p)["stored"] is True
        assert pf.snapshot(p)["stored"] is False, "内容没变还新增一行, 表会白涨"
        assert len(store["rows"]) == 1
    finally:
        restore()


@case("[J3] score 变了但次序没变, 不算新版本")
def t_j3():
    from app.services import plan_feed as pf
    store, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
    try:
        pf.snapshot(plan(main=[_r("600418.SH", 1, 242.2801)], date="2026-07-30"))
        pf.snapshot(plan(main=[_r("600418.SH", 1, 242.2799)], date="2026-07-30"))
        assert len(store["rows"]) == 1
    finally:
        restore()


@case("[J4] 只读预览不写库, 且认得出'与库里那份相同'")
def t_j4():
    from app.services import plan_feed as pf
    store, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
    try:
        p = plan(main=[_r("600418.SH", 1, 242.24)], date="2026-07-30")
        pf.snapshot(p)
        n = len(store["rows"])
        out = pf.preview_changes(p)
        assert out["ok"] and out["same_as_stored"] is True
        assert len(store["rows"]) == n, "preview 必须只读 —— 探活脚本的只读契约靠它"
    finally:
        restore()


@case("[J5] 只读预览能比出手上这份与库里那份的差异")
def t_j5():
    from app.services import plan_feed as pf
    store, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
    try:
        pf.snapshot(plan(main=[_r("600418.SH", 1, 242.24)], date="2026-07-30"))
        out = pf.preview_changes(plan(main=[_r("600519.SH", 1, 250.0)], date="2026-07-30"))
        d = out["diff"]
        assert d["kind"] == pd.KIND_REVISION
        assert [x["ts_code"] for x in d["entered"]] == ["600519.SH"]
        assert [x["ts_code"] for x in d["exited"]] == ["600418.SH"]
        assert len(store["rows"]) == 1
    finally:
        restore()


@case("[J6] 快照关掉时 changes 明说不可用, 而不是装作没变化")
def t_j6():
    from app.services import plan_feed as pf
    _, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": False})
    try:
        out = pf.changes()
        assert out["ok"] is False and out["diff"] is None
        assert "PMS_PLAN_SNAPSHOT" in out["hint"], "得说清是关掉了, 不是没有变化"
    finally:
        restore()


@case("[J7] 快照落库失败不阻断候选池, 但要把错带出来")
def t_j7():
    from app.services import plan_feed as pf
    _, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
    try:
        def _boom(**kw):
            raise RuntimeError("DB 挂了")
        sys.modules["app.repo.pms_repo"].insert_plan_snapshot = _boom
        out = pf._snapshot_quiet(plan(main=[_r("600418.SH", 1, 1.0)], date="2026-07-30"))
        assert out["stored"] is False and "DB 挂了" in out["error"]
    finally:
        restore()


@case("[J7b] 上游 A→B→A 改回去时, 比对方向不能反")
def t_j7b():
    # 曾经用 (plan_date, digest) 唯一键去重, 于是第三次的 A 落不下去、库里最新仍是 B,
    # 页面把 B→A 这次真实回退显示成 A→B —— 方向整个反了, 还顺带诬告一句"落库失败"。
    from app.services import plan_feed as pf
    store, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
    try:
        a = plan(main=[_r("600418.SH", 1, 242.24)], date="2026-07-30")
        b = plan(main=[_r("600519.SH", 1, 250.00)], date="2026-07-30")
        pf.snapshot(a)
        pf.snapshot(b)
        assert pf.snapshot(a)["stored"] is True, "改回去也是一次真实变化, 必须落得下去"
        assert len(store["rows"]) == 3
        out = pf.preview_changes(a)
        assert out["same_as_stored"] is True
        d = pf.changes()["diff"]
        assert [x["ts_code"] for x in d["entered"]] == ["600418.SH"]
        assert [x["ts_code"] for x in d["exited"]] == ["600519.SH"]
    finally:
        restore()


@case("[J7c] 上一版名册坏了要报故障, 不能显示成'首次'")
def t_j7c():
    import json as _j
    from app.services import plan_feed as pf
    store, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
    try:
        pf.snapshot(plan(main=[_r("600418.SH", 1, 1.0)], date="2026-07-30"))
        store["rows"][0]["roster_json"] = _j.dumps([])      # 名册没了, n_main 还写着 1
        out = pf.preview_changes(plan(main=[_r("600519.SH", 1, 2.0)], date="2026-07-31"))
        assert out["diff"]["prev_broken"] is True
        assert "读不出来" in out["hint"]
    finally:
        restore()


@case("[J7d] 持仓代码是前缀式也要认得出来")
def t_j7d():
    from app.services import plan_feed as pf
    _, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
    try:
        sys.modules["app.repo.pms_repo"].list_positions = \
            lambda only_open=False: [{"ts_code": "SH600418"}]      # 前缀式
        pf.snapshot(plan(main=[_r("600418.SH", 1, 1.0)], date="2026-07-30"))
        out = pf.preview_changes(plan(main=[_r("600519.SH", 1, 2.0)], date="2026-07-31"))
        assert out["diff"]["held"]["n_watch"] == 1, "不归一的后果是持仓视图静默为空"
    finally:
        restore()


@case("[J8] 快照台账数得出同一计划日有几版")
def t_j8():
    from app.services import plan_feed as pf
    _, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
    try:
        pf.snapshot(plan(main=[_r("600418.SH", 1, 1.0)], date="2026-07-30"))
        pf.snapshot(plan(main=[_r("600418.SH", 2, 1.0)], date="2026-07-30"))
        pf.snapshot(plan(main=[_r("600418.SH", 1, 1.0)], date="2026-07-29"))
        log = pf.snapshot_log()
        assert log["ok"] and log["versions_per_date"]["2026-07-30"] == 2
        assert log["revised"] == {"2026-07-30": 2}, "同一 date 多版是 §7.3 那个现象, 要数出来"
    finally:
        restore()


def main():
    import logging
    logging.disable(logging.CRITICAL)
    passed, failed = 0, 0
    for name, fn in RESULTS:
        try:
            fn()
            print(f"  PASS  {name}")
            passed += 1
        except Exception:
            print(f"  FAIL  {name}")
            traceback.print_exc()
            failed += 1
    print("-" * 60)
    if failed:
        print(f"FAILED: {failed} / {passed + failed}")
        sys.exit(1)
    print(f"ALL PASS ({passed} cases)")


if __name__ == "__main__":
    main()
