feat(loaders): extend Foundry loader with feat/spell extraction

Map Foundry feat strings to AbilitySpec constants (Power Attack, Iron Will,
Alertness, Point-Blank Shot, Precise Shot, Dodge, Weapon Focus (X)).
Extract spell names from spellcasting.spells into Combatant.spells.
Wire features and spells into load_sheet_detailed return.
Add 41 tests (unit + golden on 8 real sheets).

Gate: 377 tests, ruff clean, basedpyright clean.
This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 828afc15c0
commit e9e8c26d50
3 changed files with 324 additions and 5 deletions
+244
View File
@@ -0,0 +1,244 @@
"""Tests for feat and spell extraction from Foundry VTT sheets."""
from __future__ import annotations
from pathlib import Path
from pf1e_simulator.abilities import (
ALERTNESS,
DODGE,
GREAT_FORTITUDE,
IRON_WILL,
LIGHTNING_REFLEXES,
POINT_BLANK_SHOT,
POWER_ATTACK,
PRECISE_SHOT,
AbilitySpec,
)
from pf1e_simulator.loaders.foundry import (
_extract_features,
_extract_spells,
_match_feat,
load_sheet,
load_sheet_detailed,
)
_SHEETS = Path(__file__).resolve().parent.parent / "fiches_personnages"
class TestMatchFeat:
"""Given: a feat name string from a Foundry sheet
When: _match_feat is called
Then: returns the matching AbilitySpec or None."""
def test_power_attack(self) -> None:
assert _match_feat("Power Attack") == POWER_ATTACK
def test_iron_will(self) -> None:
assert _match_feat("Iron Will") == IRON_WILL
def test_great_fortitude(self) -> None:
assert _match_feat("Great Fortitude") == GREAT_FORTITUDE
def test_lightning_reflexes(self) -> None:
assert _match_feat("Lightning Reflexes") == LIGHTNING_REFLEXES
def test_alertness(self) -> None:
assert _match_feat("Alertness") == ALERTNESS
def test_point_blank_shot(self) -> None:
assert _match_feat("Point-Blank Shot") == POINT_BLANK_SHOT
def test_precise_shot(self) -> None:
assert _match_feat("Precise Shot") == PRECISE_SHOT
def test_dodge(self) -> None:
assert _match_feat("Dodge") == DODGE
def test_weapon_focus_pistol(self) -> None:
result = _match_feat("Weapon Focus (Pistol)")
assert result is not None
assert result.name == "Weapon Focus (Pistol)"
assert result.effects[0].weapon_filter == "Pistol"
def test_weapon_focus_longbow(self) -> None:
result = _match_feat("Weapon Focus (Longbow)")
assert result is not None
assert result.name == "Weapon Focus (Longbow)"
assert result.effects[0].weapon_filter == "Longbow"
def test_case_insensitive(self) -> None:
assert _match_feat("power attack") == POWER_ATTACK
assert _match_feat("IRON WILL") == IRON_WILL
def test_unknown_feat_returns_none(self) -> None:
assert _match_feat("Summon Good Monster") is None
assert _match_feat("Sacred Summons") is None
assert _match_feat("Selective Channeling") is None
def test_empty_string_returns_none(self) -> None:
assert _match_feat("") is None
assert _match_feat(" ") is None
class TestExtractFeatures:
"""Given: a feats section from a Foundry sheet
When: _extract_features is called
Then: returns a deduplicated list of matched AbilitySpecs."""
def test_empty_dict(self) -> None:
assert _extract_features({}) == []
def test_none(self) -> None:
assert _extract_features(None) == []
def test_no_matching_feats(self) -> None:
feats = {"feats": ["Summon Good Monster", "Sacred Summons"], "traits": ["Bitter"]}
assert _extract_features(feats) == []
def test_matching_feats(self) -> None:
feats = {"feats": ["Power Attack", "Iron Will"], "traits": [], "racial": []}
result = _extract_features(feats)
assert len(result) == 2
names = {f.name for f in result}
assert names == {"Power Attack", "Iron Will"}
def test_deduplication(self) -> None:
feats = {"feats": ["Power Attack", "Power Attack"], "traits": ["Power Attack"]}
result = _extract_features(feats)
assert len(result) == 1
assert result[0] == POWER_ATTACK
def test_traits_and_racial_scanned(self) -> None:
feats = {"feats": [], "traits": ["Alertness"], "racial": ["Dodge"]}
result = _extract_features(feats)
assert len(result) == 2
names = {f.name for f in result}
assert names == {"Alertness", "Dodge"}
def test_non_string_items_skipped(self) -> None:
feats = {"feats": ["Power Attack", 42, None, True]}
result = _extract_features(feats)
assert len(result) == 1
assert result[0] == POWER_ATTACK
def test_non_list_values_skipped(self) -> None:
feats = {"feats": "Power Attack", "traits": 42, "racial": None}
assert _extract_features(feats) == []
class TestExtractSpells:
"""Given: a spellcasting section from a Foundry sheet
When: _extract_spells is called
Then: returns a deduplicated list of spell names."""
def test_empty_dict(self) -> None:
assert _extract_spells({}) == []
def test_none(self) -> None:
assert _extract_spells(None) == []
def test_no_spells_key(self) -> None:
assert _extract_spells({"spellbooks": {}}) == []
def test_extracts_spell_names(self) -> None:
sc = {"spells": [
{"name": "Fireball", "level": 3},
{"name": "Magic Missile", "level": 1},
]}
assert _extract_spells(sc) == ["Fireball", "Magic Missile"]
def test_deduplication(self) -> None:
sc = {"spells": [
{"name": "Fireball", "level": 3},
{"name": "Fireball", "level": 3},
]}
assert _extract_spells(sc) == ["Fireball"]
def test_skips_non_dict_entries(self) -> None:
sc = {"spells": ["Fireball", 42, None, {"name": "Cure Light Wounds", "level": 1}]}
assert _extract_spells(sc) == ["Cure Light Wounds"]
def test_skips_empty_names(self) -> None:
sc = {"spells": [{"name": "", "level": 1}, {"name": "Fireball", "level": 3}]}
assert _extract_spells(sc) == ["Fireball"]
class TestRealSheetsFeatures:
"""Given: the 8 real Foundry sheets
When: loaded with load_sheet_detailed
Then: features and spells are extracted correctly."""
def test_esha_has_three_features(self) -> None:
combatant, _ = load_sheet_detailed(_SHEETS / "esha_sheet.json")
names = {f.name for f in combatant.features}
assert "Point-Blank Shot" in names
assert "Precise Shot" in names
assert "Alertness" in names
assert len(combatant.features) == 3
def test_esha_has_one_spell(self) -> None:
combatant, _ = load_sheet_detailed(_SHEETS / "esha_sheet.json")
assert "Unseen Servant" in combatant.spells
assert len(combatant.spells) == 1
def test_harvie_has_no_features_no_spells(self) -> None:
combatant, _ = load_sheet_detailed(_SHEETS / "harvie_sheet.json")
assert combatant.features == []
assert combatant.spells == []
def test_ierlieth_has_power_attack(self) -> None:
combatant, _ = load_sheet_detailed(_SHEETS / "ierlieth_sheet.json")
names = {f.name for f in combatant.features}
assert "Power Attack" in names
assert combatant.spells == []
def test_jeanne_has_alertness(self) -> None:
combatant, _ = load_sheet_detailed(_SHEETS / "jeanne_sheet.json")
names = {f.name for f in combatant.features}
assert "Alertness" in names
def test_tammara_has_weapon_focus_pistol(self) -> None:
combatant, _ = load_sheet_detailed(_SHEETS / "tammara_sheet.json")
names = {f.name for f in combatant.features}
assert "Weapon Focus (Pistol)" in names
assert "Point-Blank Shot" in names
assert "Precise Shot" in names
assert len(combatant.features) == 3
def test_tammara_has_spells(self) -> None:
combatant, _ = load_sheet_detailed(_SHEETS / "tammara_sheet.json")
assert len(combatant.spells) > 0
assert "Cure Light Wounds" in combatant.spells
def test_oni_has_spells(self) -> None:
combatant, _ = load_sheet_detailed(_SHEETS / "oni_sheet.json")
assert len(combatant.spells) == 20
assert "Cure Light Wounds" in combatant.spells
assert "Guidance" in combatant.spells
def test_misty_has_spells(self) -> None:
combatant, _ = load_sheet_detailed(_SHEETS / "misty_sheet.json")
assert len(combatant.spells) == 24
def test_nairda_has_spells(self) -> None:
combatant, _ = load_sheet_detailed(_SHEETS / "nairda_sheet.json")
assert len(combatant.spells) == 32
def test_all_sheets_features_are_ability_specs(self) -> None:
for path in sorted(_SHEETS.glob("*.json")):
combatant, _ = load_sheet_detailed(path)
for feat in combatant.features:
assert isinstance(feat, AbilitySpec)
def test_all_sheets_spells_are_strings(self) -> None:
for path in sorted(_SHEETS.glob("*.json")):
combatant, _ = load_sheet_detailed(path)
for spell in combatant.spells:
assert isinstance(spell, str)
assert spell
def test_load_sheet_returns_features_and_spells(self) -> None:
combatant = load_sheet(_SHEETS / "esha_sheet.json")
assert len(combatant.features) == 3
assert len(combatant.spells) == 1