# pyright: reportMissingImports=false, reportAttributeAccessIssue=false # 说明:LSP 的 Python 环境看不到本项目包,会把包内 import 误报为缺失模块。 """因果链切集的回归测试。 对应 t3 契约:间隔 >2 年断开;短链只合并、不设总量下限;过长按预算拆上下集; 每集预算 800–1500 字;输出含集号/年份范围/事件条数/主角与对手。 """ from __future__ import annotations import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from collections import Counter from dfannals import episodes from dfannals.legends import Event, Figure, World from dfannals.threads import Thread def ev(eid: int, year: int, etype: str = "add hf hf link") -> Event: return Event(id=eid, year=year, seconds72=0, type=etype, fields={"hfid": ["1"], "hfid_target": ["2"]}) def fake_thread(events: list[Event]) -> Thread: return Thread( members=[1, 2], interactions=len(events), turns=0, span=(events[-1].year - events[0].year) if events else 0, first_year=events[0].year if events else 0, last_year=events[-1].year if events else 0, types=Counter(), races=Counter(), non_person_races=[], events=events, ) def test_gap_breaks_chain() -> None: """间隔 >2 年必须断开成两条链。""" events = [ev(i, y) for i, y in enumerate([10, 11, 12, 20, 21, 22])] chains = episodes._split_chains(events, gap_years=2) assert len(chains) == 2, [len(c) for c in chains] assert [len(c) for c in chains] == [3, 3] def test_small_gap_does_not_break() -> None: """间隔恰好 2 年不该断开。""" chains = episodes._split_chains([ev(1, 10), ev(2, 12)], gap_years=2) assert len(chains) == 1 def test_long_chain_splits_into_budgeted_parts() -> None: """过长链路必须拆成上/下集,且每段都不跌破下限。""" events = [ev(i, 100 + i) for i in range(30)] parts = episodes._split_long(events) assert len(parts) >= 2, "30 条事件没有拆集" for p in parts: assert episodes._min_events() <= len(p) <= episodes._max_events(), [len(x) for x in parts] assert sum(len(p) for p in parts) == 30 def test_split_never_creates_undersized_fragments() -> None: """回归:14 条事件曾被均分成 7+7,两段都跌破 800 字下限。""" events = [ev(i, 200 + i) for i in range(14)] eps = episodes.plan_episodes(fake_thread(events)) assert len(eps) == 1, f"14 条事件被拆成了 {len(eps)} 集" assert episodes.MIN_CHARS <= eps[0].estimated_chars <= episodes.MAX_CHARS def test_short_chain_is_merged_not_dropped() -> None: """短链只合并,不设线索总量下限:2 条事件也要能成一集。""" eps = episodes.plan_episodes(fake_thread([ev(1, 50), ev(2, 51)])) assert len(eps) == 1 assert len(eps[0].events) == 2 def test_episode_key_marks_parts() -> None: events = [ev(i, 300 + i) for i in range(30)] eps = episodes.plan_episodes(fake_thread(events)) assert len(eps) >= 2 assert all(e.parts == len(eps) for e in eps) assert eps[0].key.endswith("-p1") and eps[1].key.endswith("-p2") assert eps[0].key != eps[1].key def test_plan_reports_heroes_and_others() -> None: """输出必须能区分主角与对手。""" world = World(name="test") for fid, name in ((1, "Hero"), (2, "Ally"), (9, "Rival")): world.figures[fid] = Figure(id=fid, name=name) events = [ev(i, 400 + i) for i in range(3)] events.append(Event(id=99, year=403, seconds72=0, type="hf wounded", fields={"woundee_hfid": ["1"], "wounder_hfid": ["9"]})) thread = fake_thread(events) planned = episodes.plan_episodes(thread) heroes, others = episodes.cast_of(world, thread, planned[0]) assert 1 in heroes and 2 in heroes assert 9 in others and 9 not in heroes text = episodes.render_plan(world, thread, planned) assert "主角" in text and "对手/外人" in text and "Rival" in text def _main() -> int: tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] failed = 0 for fn in tests: try: fn() print(f" ✓ {fn.__name__}") except AssertionError as exc: failed += 1 print(f" ✗ {fn.__name__}: {exc}") print(f"\n{len(tests) - failed}/{len(tests)} 通过") return 1 if failed else 0 if __name__ == "__main__": sys.exit(_main())