|
| 1 | +"""M2 acceptance tests for tools/synergy_resolver.py. |
| 2 | +
|
| 3 | +Covers the spec's five acceptance criteria: |
| 4 | + 1. Known DWJ CB tune (Maneater/Demytha/Ninja/Geomancer/Venomage): |
| 5 | + Maneater keystone enabler_ok; Ninja(hit) has a satisfied def_break edge; |
| 6 | + NO satisfied def_break edge credited to Venomage's poison engine. |
| 7 | + 2. Mis-ordered comp (slowest + short-duration Dec-DEF provider) -> broken. |
| 8 | + 3. Self-amplifier (Frozen Banshee) covers its own poison_synergy (reason "self"). |
| 9 | + 4. Keystone-enabler compat: Maneater+Pain Keeper -> enabler_ok; a revive |
| 10 | + keystone + buff_extension-only -> NOT satisfied. |
| 11 | + 5. Two HP-burners -> 2nd flagged redundant (slot cap max_hp_burn=1). |
| 12 | +
|
| 13 | +Plus the headline channel rule: a pure-poison engine carrying needs:def_break |
| 14 | +is NEVER credited a Weaken/Dec-DEF edge (the "won't credit Weaken->poison" |
| 15 | +acceptance) -> it lands in notes, not satisfied/broken. |
| 16 | +""" |
| 17 | +from __future__ import annotations |
| 18 | + |
| 19 | +import sys |
| 20 | +from pathlib import Path |
| 21 | + |
| 22 | +import pytest |
| 23 | + |
| 24 | +PROJECT_ROOT = Path(__file__).resolve().parent.parent |
| 25 | +sys.path.insert(0, str(PROJECT_ROOT / "tools")) |
| 26 | + |
| 27 | +import synergy_resolver as sr # noqa: E402 |
| 28 | + |
| 29 | + |
| 30 | +# --------------------------------------------------------------------- helpers |
| 31 | +def _rec(name, **kw): |
| 32 | + """Build a synthetic enriched record with M1-complete safe defaults.""" |
| 33 | + base = { |
| 34 | + "name": name, |
| 35 | + "element": "Void", |
| 36 | + "fraction": "Generic", |
| 37 | + "provides": [], |
| 38 | + "needs": [], |
| 39 | + "amplifier_channel": "none", |
| 40 | + "engine_channel": [], |
| 41 | + "survival_currency": None, |
| 42 | + "enabler": None, |
| 43 | + "keystone_needs_enabler": False, |
| 44 | + "debuffs_control_only": False, |
| 45 | + } |
| 46 | + base.update(kw) |
| 47 | + return base |
| 48 | + |
| 49 | + |
| 50 | +MEN = ["Maneater", "Demytha", "Ninja", "Geomancer", "Venomage"] |
| 51 | + |
| 52 | + |
| 53 | +# ============================================================= acceptance #1 |
| 54 | +def test_known_dwj_tune_channels_and_keystone(): |
| 55 | + res = sr.resolve(MEN, sr.ResolveContext(location="clan_boss")) |
| 56 | + |
| 57 | + # Maneater keystone (unkillable) is enabled (Demytha buff_extension / |
| 58 | + # Ninja+Geo cooldown_reduction are all unkillable-compatible). |
| 59 | + ks = res.keystone_for("Maneater") |
| 60 | + assert ks is not None, "Maneater should appear in keystones[]" |
| 61 | + assert ks["currency"] == "unkillable" |
| 62 | + assert ks["enabler_ok"] is True |
| 63 | + |
| 64 | + # Ninja is a hit engine -> has a satisfied def_break edge (self Dec-DEF). |
| 65 | + ninja_db = res.satisfied_edges(tag="def_break", consumer="Ninja") |
| 66 | + assert ninja_db, "Ninja (hit engine) should have a satisfied def_break edge" |
| 67 | + |
| 68 | + # Venomage is a poison engine -> NO satisfied def_break edge (channel |
| 69 | + # negative). Its needs carry poison_synergy, not def_break, so nothing is |
| 70 | + # credited; the assertion must hold regardless. |
| 71 | + assert res.satisfied_edges(tag="def_break", consumer="Venomage") == [] |
| 72 | + assert not any(e.tag == "def_break" and e.consumer == "Venomage" |
| 73 | + for e in res.broken) |
| 74 | + |
| 75 | + |
| 76 | +def test_channel_negative_wont_credit_weaken_to_poison(): |
| 77 | + """THE acceptance: a pure-poison engine carrying needs:def_break must NOT |
| 78 | + be credited a Weaken/Dec-DEF edge -> notes, not satisfied/broken.""" |
| 79 | + weakener = _rec("Weakener", amplifier_channel="hit", |
| 80 | + provides=["enemy_debuff:Weaken", "enemy_debuff:Decrease DEF"]) |
| 81 | + poison_engine = _rec("PurePoison", amplifier_channel="none", |
| 82 | + engine_channel=["poison"], |
| 83 | + provides=["dot:Poison"], |
| 84 | + needs=["def_break", "poison_synergy"]) |
| 85 | + res = sr.resolve_records([weakener, poison_engine], |
| 86 | + sr.ResolveContext(mode="generic")) |
| 87 | + |
| 88 | + assert res.satisfied_edges(tag="def_break") == [] |
| 89 | + assert not any(e.tag == "def_break" for e in res.broken) |
| 90 | + # The mismatch is explained in notes, not silently dropped. |
| 91 | + assert any("def_break" in n and "channel mismatch" in n |
| 92 | + for n in res.notes) |
| 93 | + |
| 94 | + |
| 95 | +# ============================================================= acceptance #2 |
| 96 | +def test_misordered_def_break_edge_is_broken(): |
| 97 | + slow_provider = _rec("SlowFayne", amplifier_channel="hit", |
| 98 | + provides=["enemy_debuff:Decrease DEF"]) |
| 99 | + fast_nuker = _rec("FastNuker", engine_channel=["hit"], |
| 100 | + needs=["def_break"]) |
| 101 | + ctx = sr.ResolveContext( |
| 102 | + mode="generic", |
| 103 | + speeds={"SlowFayne": 100, "FastNuker": 220}, # provider is slowest |
| 104 | + durations={"SlowFayne": 1}, # short debuff |
| 105 | + cooldowns={"FastNuker": 3}, # needs longer coverage |
| 106 | + ) |
| 107 | + res = sr.resolve_records([slow_provider, fast_nuker], ctx) |
| 108 | + |
| 109 | + assert res.satisfied_edges(tag="def_break") == [] |
| 110 | + broken = [e for e in res.broken if e.tag == "def_break"] |
| 111 | + assert broken, "the late + short-duration Dec-DEF edge should be broken" |
| 112 | + assert broken[0].provider == "SlowFayne" |
| 113 | + assert broken[0].consumer == "FastNuker" |
| 114 | + assert "late_provider" in broken[0].ordering_reason |
| 115 | + |
| 116 | + |
| 117 | +# ============================================================= acceptance #3 |
| 118 | +def test_self_amplifier_frozen_banshee(): |
| 119 | + res = sr.resolve(["Frozen Banshee"], sr.ResolveContext(location="clan_boss")) |
| 120 | + ps = res.satisfied_edges(tag="poison_synergy", consumer="Frozen Banshee") |
| 121 | + assert ps, "Frozen Banshee should self-cover poison_synergy" |
| 122 | + edge = ps[0] |
| 123 | + assert edge.provider == edge.consumer == "Frozen Banshee" |
| 124 | + assert edge.ordering_reason == "self" |
| 125 | + assert edge.channel == "poison" |
| 126 | + |
| 127 | + |
| 128 | +# ============================================================= acceptance #4 |
| 129 | +def test_keystone_enabler_compat_positive(): |
| 130 | + res = sr.resolve(["Maneater", "Pain Keeper"], |
| 131 | + sr.ResolveContext(location="clan_boss")) |
| 132 | + ks = res.keystone_for("Maneater") |
| 133 | + assert ks is not None and ks["enabler_ok"] is True |
| 134 | + # Pain Keeper supplies cooldown_reduction, which is unkillable-compatible. |
| 135 | + assert "cooldown_reduction" in ks["reason"] or ks["enabler"] == "Pain Keeper" |
| 136 | + |
| 137 | + |
| 138 | +def test_keystone_enabler_compat_negative_revive_needs_cdr(): |
| 139 | + revive = _rec("ReviveHero", survival_currency="revive_on_death", |
| 140 | + keystone_needs_enabler=True) |
| 141 | + extender = _rec("Extender", enabler="buff_extension", |
| 142 | + provides=["buff_extension"]) |
| 143 | + res = sr.resolve_records([revive, extender], sr.ResolveContext()) |
| 144 | + ks = res.keystone_for("ReviveHero") |
| 145 | + assert ks is not None |
| 146 | + # revive_on_death is compatible only with cooldown_reduction; a |
| 147 | + # buff_extension-only provider must NOT satisfy it. |
| 148 | + assert ks["enabler_ok"] is False |
| 149 | + |
| 150 | + |
| 151 | +# ============================================================= acceptance #5 |
| 152 | +def test_two_hp_burners_second_redundant(): |
| 153 | + b1 = _rec("Burner1", engine_channel=["hp_burn"], provides=["dot:HP Burn"]) |
| 154 | + b2 = _rec("Burner2", engine_channel=["hp_burn"], provides=["dot:HP Burn"]) |
| 155 | + res = sr.resolve_records([b1, b2], sr.ResolveContext(location="clan_boss")) |
| 156 | + assert any("redundant" in n and "Burner2" in n and "hp_burn" in n |
| 157 | + for n in res.notes), \ |
| 158 | + "the 2nd HP burner should be flagged redundant (slot cap 1)" |
| 159 | + |
| 160 | + |
| 161 | +# ------------------------------------------------------ supporting invariants |
| 162 | +def test_cb_tm_control_is_noop_vs_boss(): |
| 163 | + """tm_control is a no-op vs the CB boss -> notes, never satisfied.""" |
| 164 | + res = sr.resolve(MEN, sr.ResolveContext(location="clan_boss")) |
| 165 | + assert res.satisfied_edges(tag="tm_control") == [] |
| 166 | + assert any("tm_control" in n and "no-op" in n for n in res.notes) |
| 167 | + |
| 168 | + |
| 169 | +def test_missing_m1_fields_tolerated(): |
| 170 | + """A record lacking M1 fields resolves with safe defaults (no crash).""" |
| 171 | + bare = {"name": "LegacyHero", "provides": ["enemy_debuff:Decrease DEF"], |
| 172 | + "needs": ["def_break"]} |
| 173 | + res = sr.resolve_records([bare], sr.ResolveContext(mode="generic")) |
| 174 | + # No engine channel by default -> def_break is a category mismatch, noted. |
| 175 | + assert isinstance(res, sr.ResolveResult) |
| 176 | + assert res.satisfied_edges(tag="def_break") == [] |
| 177 | + |
| 178 | + |
| 179 | +if __name__ == "__main__": |
| 180 | + raise SystemExit(pytest.main([__file__, "-q"])) |
0 commit comments