"""Golden tests for the Foundry VTT pf1-sheet/v1 loader on the 8 real sheets.""" from pathlib import Path import pytest from pf1e_simulator.dice import DiceExpr from pf1e_simulator.loaders import LoadReport, load_sheet, load_sheet_detailed SHEETS_DIR = Path(__file__).resolve().parent.parent / "fiches_personnages" # Golden pins — each tuple follows the parametrize argument order below. _GOLDENS = [ ("harvie_sheet.json", "qeqv1DtvWsApYj3e", "Harvie", 2, "Small", 15, 10, 80, 2, 0), ("esha_sheet.json", "9ZRe6qCM2Jw2vQGd", "Esha Randu", 4, "Medium", 32, 25, None, 3, 0), ("ierlieth_sheet.json", "xsyBSseBE2avH4bP", "Ierlieth", 4, "Tiny", 29, 15, None, 2, 0), ("jeanne_sheet.json", "FACxIckfWhKKC086", "Jeanne", 4, "Large", 31, 40, None, 2, 0), ("misty_sheet.json", "1E9HfSar4ac6ryJ5", "Misty", 4, "Medium", 31, 30, None, 5, 0), ( "nairda_sheet.json", "oyyhwcAfinGasDoH", "Nairda Guisenda", 4, "Medium", 31, 30, None, 2, 0, ), ("oni_sheet.json", "7Ga6fQh1NrqC3NbG", "Oni Triumvir", 4, "Medium", 36, 30, None, 3, 3), ( "tammara_sheet.json", "k8sPaX8AuMnn6jXc", "Tammara Cailean", 4, "Medium", 36, 30, None, 5, 0, ), ] def _sheet(filename: str) -> Path: return SHEETS_DIR / filename class TestAllSheetsLoad: @pytest.mark.parametrize( ( "filename", "sheet_id", "name", "level", "size", "hp_max", "land", "fly", "kept", "skipped", ), _GOLDENS, ids=[g[0] for g in _GOLDENS], ) def test_sheet_golden( self, filename: str, sheet_id: str, name: str, level: int, size: str, hp_max: int, land: int, fly: int | None, kept: int, skipped: int, ) -> None: # Given a real Foundry sheet export # When it is loaded with its report combatant, report = load_sheet_detailed(_sheet(filename)) # Then identity, core stats, and kept/skipped counts match the sheet assert combatant.id == sheet_id assert combatant.name == name assert combatant.level == level assert combatant.size == size assert combatant.hp_max == hp_max assert combatant.speed_land_ft == land assert combatant.speed_fly_ft == fly assert len(combatant.attacks) == kept assert report.path == str(_sheet(filename)) assert report.kept == kept assert len(report.skipped) == skipped def test_load_sheet_delegates_and_discards_report(self) -> None: # Given a real sheet # When loaded through the simple entry point combatant = load_sheet(_sheet("harvie_sheet.json")) detailed, report = load_sheet_detailed(_sheet("harvie_sheet.json")) # Then the result is identical to the detailed load assert combatant == detailed assert isinstance(report, LoadReport) def test_attack_ids_are_indexed_over_kept_attacks(self) -> None: # Given Oni's sheet (3 kept out of 6) # When loaded combatant, _report = load_sheet_detailed(_sheet("oni_sheet.json")) # Then ids are indexed over the KEPT attacks only assert [a.id for a in combatant.attacks] == [ "7Ga6fQh1NrqC3NbG:0", "7Ga6fQh1NrqC3NbG:1", "7Ga6fQh1NrqC3NbG:2", ] class TestHarviePins: def test_core_stats(self) -> None: # Given Harvie's sheet # When loaded harvie = load_sheet(_sheet("harvie_sheet.json")) # Then the pinned core stats hold assert harvie.hp_max == 15 assert harvie.ac.total == 14 assert harvie.ac.touch == 13 assert harvie.ac.flat_footed == 12 assert harvie.initiative_mod == 2 assert harvie.saves == harvie.saves.model_validate({"fort": 4, "ref": 5, "will": 2}) def test_talons_count_prefix(self) -> None: # Given the "2x Talons" attackName quirk # When loaded harvie = load_sheet(_sheet("harvie_sheet.json")) talons = harvie.attacks[0] # Then the prefix becomes count=2, the bonus stays as printed (no secondary -5) assert talons.count == 2 assert talons.attack_bonus == 2 assert "2x" not in talons.name assert talons.kind == "melee" assert talons.damage[0].formula == DiceExpr(count=1, sides=4) assert talons.damage[0].types == ["slashing"] assert talons.reach_ft == 5 def test_bite_secondary_baked_in(self) -> None: # Given the Bite entry whose -5 secondary penalty is already baked in # When loaded harvie = load_sheet(_sheet("harvie_sheet.json")) bite = harvie.attacks[1] # Then the printed -3 is kept verbatim assert bite.attack_bonus == -3 assert bite.count == 1 def test_dict_speeds_use_total(self) -> None: # Given land/fly encoded as {base, total} dicts # When loaded harvie = load_sheet(_sheet("harvie_sheet.json")) # Then the effective totals are used assert harvie.speed_land_ft == 10 assert harvie.speed_fly_ft == 80 class TestEshaPins: def test_lance_reach_and_size_roll(self) -> None: # Given the Lance with formula "sizeRoll(1, 8, @size)" and units "reach" # When loaded esha = load_sheet(_sheet("esha_sheet.json")) lance = next(a for a in esha.attacks if "Lance" in a.name) # Then the Medium sizeRoll resolves to 1d8 and reach is 10 ft assert lance.kind == "melee" assert lance.reach_ft == 10 assert lance.damage[0].formula == DiceExpr(count=1, sides=8) assert lance.damage_bonus == 4 assert lance.crit_mult == 3 def test_shortbow_range_increment(self) -> None: # Given the Composite Shortbow with units "ft" value "70" # When loaded esha = load_sheet(_sheet("esha_sheet.json")) bow = next(a for a in esha.attacks if "Shortbow" in a.name) # Then it is a ranged attack with a 70 ft increment and 1d6 damage assert bow.kind == "ranged" assert bow.range_increment_ft == 70 assert bow.damage[0].formula == DiceExpr(count=1, sides=6) assert bow.damage_bonus == 3 def test_missing_crit_range_defaults_to_20(self) -> None: # Given the Earth Breaker entry which has no critRange key at all # When loaded esha = load_sheet(_sheet("esha_sheet.json")) breaker = next(a for a in esha.attacks if "Earth Breaker" in a.name) # Then crit_range defaults to 20 assert breaker.crit_range == 20 assert breaker.damage[0].formula == DiceExpr(count=2, sides=6) class TestOtherSheetPins: def test_misty_has_keen_crit(self) -> None: # Given Misty's sheet (MWK Rapier 18-20) # When loaded misty = load_sheet(_sheet("misty_sheet.json")) # Then at least one attack threatens on 18 assert any(a.crit_range == 18 for a in misty.attacks) def test_misty_thrown_dagger_keeps_melee_kind_with_increment(self) -> None: # Given the Dagger: actionType mwak but range units "ft" # When loaded misty = load_sheet(_sheet("misty_sheet.json")) dagger = next(a for a in misty.attacks if "Dagger" in a.name) # Then kind comes from actionType and the increment is still recorded assert dagger.kind == "melee" assert dagger.range_increment_ft == 10 def test_tammara_has_x4_crit(self) -> None: # Given Tammara's pistols (x4 crit) # When loaded tammara = load_sheet(_sheet("tammara_sheet.json")) # Then at least one attack multiplies by 4 assert any(a.crit_mult == 4 for a in tammara.attacks) def test_tammara_touch_attack_kind(self) -> None: # Given the Alchemist's Fire with actionType twak # When loaded tammara = load_sheet(_sheet("tammara_sheet.json")) fire = next(a for a in tammara.attacks if "Alchemist" in a.name) # Then it maps to a touch attack assert fire.kind == "touch" assert fire.damage[0].types == ["fire"] def test_oni_keeps_exactly_three_attacks(self) -> None: # Given Oni's sheet with 3 invalid entries among 6 # When loaded oni, report = load_sheet_detailed(_sheet("oni_sheet.json")) # Then exactly 3 attacks are kept assert len(oni.attacks) == 3 assert report.kept == 3 def test_oni_skipped_entries_have_reasons(self) -> None: # Given Oni's sheet # When loaded _oni, report = load_sheet_detailed(_sheet("oni_sheet.json")) skipped = {entry.weapon: entry.reason for entry in report.skipped} # Then each skipped entry is recorded with its reason assert set(skipped) == {"Channel Energy", "une Pioche", "Weapon"} assert "other" in skipped["Channel Energy"] assert "damage" in skipped["une Pioche"] assert "damage" in skipped["Weapon"] def test_jeanne_count_prefix_and_dict_speed(self) -> None: # Given Jeanne ("2x Hooves", land {base 50, total 40}) # When loaded jeanne = load_sheet(_sheet("jeanne_sheet.json")) hooves = jeanne.attacks[0] # Then the count prefix and the effective speed are applied assert hooves.count == 2 assert hooves.attack_bonus == 8 assert jeanne.speed_land_ft == 40 def test_ierlieth_land_dict_uses_total_not_base(self) -> None: # Given Ierlieth's land speed {base 20, total 15} # When loaded ierlieth = load_sheet(_sheet("ierlieth_sheet.json")) # Then the effective value wins assert ierlieth.speed_land_ft == 15 assert ierlieth.size == "Tiny" def test_nairda_negative_damage_bonus_preserved(self) -> None: # Given Nairda's unarmed strike with damageBonus -1 # When loaded nairda = load_sheet(_sheet("nairda_sheet.json")) # Then the negative bonus is preserved (low STR) assert nairda.attacks[0].damage_bonus == -1