Adding tests of character schema
This commit is contained in:
parent
dba8bb315a
commit
b1d7639a62
|
@ -27,6 +27,9 @@ wtforms-alchemy = "^0.18.0"
|
||||||
sqlalchemy-serializer = "^1.4.1"
|
sqlalchemy-serializer = "^1.4.1"
|
||||||
|
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
pytest = "^8.1.1"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core"]
|
requires = ["poetry-core"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
42
test/conftest.py
Normal file
42
test/conftest.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ttfrog.db import schema
|
||||||
|
from ttfrog.db.manager import db as _db
|
||||||
|
|
||||||
|
|
||||||
|
FIXTURE_PATH = Path(__file__).parent / 'fixtures'
|
||||||
|
|
||||||
|
|
||||||
|
def load_fixture(db, fixture_name):
|
||||||
|
with db.transaction():
|
||||||
|
data = json.loads((FIXTURE_PATH / f"{fixture_name}.json").read_text())
|
||||||
|
for schema_name in data:
|
||||||
|
for record in data[schema_name]:
|
||||||
|
print(f"Loading {schema_name} {record = }")
|
||||||
|
obj = getattr(schema, schema_name)(**record)
|
||||||
|
db.session.add(obj)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def db(monkeypatch):
|
||||||
|
monkeypatch.setattr('ttfrog.db.manager.database', MagicMock(return_value=""))
|
||||||
|
monkeypatch.setenv('DATABASE_URL', "sqlite:///:memory:")
|
||||||
|
monkeypatch.setenv('DEBUG', '1')
|
||||||
|
_db.init()
|
||||||
|
return _db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def classes(db):
|
||||||
|
load_fixture(db, 'classes')
|
||||||
|
return dict((rec.name, rec) for rec in db.session.query(schema.CharacterClass).all())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ancestries(db):
|
||||||
|
load_fixture(db, 'ancestry')
|
||||||
|
return dict((rec.name, rec) for rec in db.session.query(schema.Ancestry).all())
|
19
test/fixtures/ancestry.json
vendored
Normal file
19
test/fixtures/ancestry.json
vendored
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"Ancestry": [
|
||||||
|
{"id": 1, "name": "human", "creature_type": "humanoid"},
|
||||||
|
{"id": 2, "name": "dragonborn", "creature_type": "humanoid"},
|
||||||
|
{"id": 3, "name": "tiefling", "creature_type": "humanoid"},
|
||||||
|
{"id": 4, "name": "elf", "creature_type": "humanoid"}
|
||||||
|
],
|
||||||
|
"AncestryTrait": [
|
||||||
|
{"id": 1, "name": "+1 to All Ability Scores"},
|
||||||
|
{"id": 2, "name": "Breath Weapon"},
|
||||||
|
{"id": 3, "name": "Darkvision"}
|
||||||
|
],
|
||||||
|
"AncestryTraitMap": [
|
||||||
|
{"ancestry_id": 1, "ancestry_trait_id": 1, "level": 1},
|
||||||
|
{"ancestry_id": 2, "ancestry_trait_id": 2, "level": 1},
|
||||||
|
{"ancestry_id": 2, "ancestry_trait_id": 2, "level": 1},
|
||||||
|
{"ancestry_id": 3, "ancestry_trait_id": 3, "level": 1}
|
||||||
|
]
|
||||||
|
}
|
7
test/fixtures/ancestry_trait.json
vendored
Normal file
7
test/fixtures/ancestry_trait.json
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"Ancestry": [
|
||||||
|
{"id": 1, "name": "+1 to All Ability Scores"},
|
||||||
|
{"id": 2, "name": "Breath Weapon"},
|
||||||
|
{"id": 3, "name": "Darkvision"}
|
||||||
|
]
|
||||||
|
}
|
20
test/fixtures/classes.json
vendored
Normal file
20
test/fixtures/classes.json
vendored
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"CharacterClass": [
|
||||||
|
{
|
||||||
|
"name": "fighter",
|
||||||
|
"hit_dice": "1d10",
|
||||||
|
"hit_dice_stat": "CON",
|
||||||
|
"proficiencies": "all armor, all shields, simple weapons, martial weapons",
|
||||||
|
"saving_throws": ["STR, CON"],
|
||||||
|
"skills": ["Acrobatics", "Animal Handling", "Athletics", "History", "Insight", "Intimidation", "Perception", "Survival"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rogue",
|
||||||
|
"hit_dice": "1d8",
|
||||||
|
"hit_dice_stat": "DEX",
|
||||||
|
"proficiencies": "simple weapons, hand crossbows, longswords, rapiers, shortswords",
|
||||||
|
"saving_throws": ["DEX", "INT"],
|
||||||
|
"skills": ["Acrobatics", "Athletics", "Deception", "Insight", "Intimidation", "Investigation", "Perception", "Performance", "Persuasion", "Sleight of Hand", "Stealth"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
7
test/fixtures/multiclass.json
vendored
Normal file
7
test/fixtures/multiclass.json
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"name": "multiclass_pc",
|
||||||
|
"classes": [
|
||||||
|
"fighter": 5,
|
||||||
|
"rogue": 3
|
||||||
|
]
|
||||||
|
}
|
56
test/test_schema.py
Normal file
56
test/test_schema.py
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
from ttfrog.db import schema
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_character(db, classes, ancestries):
|
||||||
|
with db.transaction():
|
||||||
|
darkvision = db.session.query(schema.AncestryTrait).filter_by(name='Darkvision')[0]
|
||||||
|
|
||||||
|
# create a human character (the default)
|
||||||
|
char = schema.Character(name='Test Character')
|
||||||
|
db.add(char)
|
||||||
|
assert char.id == 1
|
||||||
|
assert char.armor_class == 10
|
||||||
|
assert char.name == 'Test Character'
|
||||||
|
assert char.ancestry.name == 'human'
|
||||||
|
assert darkvision not in char.traits
|
||||||
|
|
||||||
|
# switch ancestry to tiefling
|
||||||
|
char.ancestry = ancestries['tiefling']
|
||||||
|
db.add(char)
|
||||||
|
char = db.session.get(schema.Character, 1)
|
||||||
|
assert char.ancestry.name == 'tiefling'
|
||||||
|
assert darkvision in char.traits
|
||||||
|
|
||||||
|
# assign a class and level
|
||||||
|
char.add_class(classes['fighter'], level=1)
|
||||||
|
db.add(char)
|
||||||
|
assert char.levels == {'fighter': 1}
|
||||||
|
assert char.level == 1
|
||||||
|
assert char.class_attributes == []
|
||||||
|
|
||||||
|
# level up
|
||||||
|
char.add_class(classes['fighter'], level=2)
|
||||||
|
db.add(char)
|
||||||
|
assert char.levels == {'fighter': 2}
|
||||||
|
assert char.level == 2
|
||||||
|
assert char.class_attributes == []
|
||||||
|
|
||||||
|
# multiclass
|
||||||
|
char.add_class(classes['rogue'], level=1)
|
||||||
|
db.add(char)
|
||||||
|
assert char.level == 3
|
||||||
|
assert char.levels == {'fighter': 2, 'rogue': 1}
|
||||||
|
|
||||||
|
# remove a class
|
||||||
|
char.remove_class(classes['rogue'])
|
||||||
|
db.add(char)
|
||||||
|
assert char.levels == {'fighter': 2}
|
||||||
|
assert char.level == 2
|
||||||
|
|
||||||
|
# remove all remaining classes
|
||||||
|
char.remove_class(classes['fighter'])
|
||||||
|
db.add(char)
|
||||||
|
|
||||||
|
# ensure we're not persisting any orphan records in the map table
|
||||||
|
dump = db.dump()
|
||||||
|
assert dump['class_map'] == []
|
|
@ -1,3 +1,4 @@
|
||||||
|
import os
|
||||||
import transaction
|
import transaction
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
@ -24,7 +25,7 @@ class SQLDatabaseManager:
|
||||||
"""
|
"""
|
||||||
@cached_property
|
@cached_property
|
||||||
def url(self):
|
def url(self):
|
||||||
return f"sqlite:///{database()}"
|
return os.environ.get('DATABASE_URL', f"sqlite:///{database()}")
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def engine(self):
|
def engine(self):
|
||||||
|
@ -70,6 +71,12 @@ class SQLDatabaseManager:
|
||||||
init_sqlalchemy(self.engine)
|
init_sqlalchemy(self.engine)
|
||||||
self.metadata.create_all(self.engine)
|
self.metadata.create_all(self.engine)
|
||||||
|
|
||||||
|
def dump(self):
|
||||||
|
results = {}
|
||||||
|
for (table_name, table) in self.tables.items():
|
||||||
|
results[table_name] = [row for row in self.query(table).all()]
|
||||||
|
return results
|
||||||
|
|
||||||
def __getattr__(self, name: str):
|
def __getattr__(self, name: str):
|
||||||
try:
|
try:
|
||||||
return self.tables[name]
|
return self.tables[name]
|
||||||
|
|
|
@ -27,6 +27,7 @@ def class_map_creator(fields):
|
||||||
return fields
|
return fields
|
||||||
return CharacterClassMap(**fields)
|
return CharacterClassMap(**fields)
|
||||||
|
|
||||||
|
|
||||||
def attr_map_creator(fields):
|
def attr_map_creator(fields):
|
||||||
if isinstance(fields, CharacterClassAttributeMap):
|
if isinstance(fields, CharacterClassAttributeMap):
|
||||||
return fields
|
return fields
|
||||||
|
@ -35,8 +36,9 @@ def attr_map_creator(fields):
|
||||||
|
|
||||||
class AncestryTraitMap(BaseObject):
|
class AncestryTraitMap(BaseObject):
|
||||||
__tablename__ = "trait_map"
|
__tablename__ = "trait_map"
|
||||||
ancestry_id = Column(Integer, ForeignKey("ancestry.id"), primary_key=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
ancestry_trait_id = Column(Integer, ForeignKey("ancestry_trait.id"), primary_key=True)
|
ancestry_id = Column(Integer, ForeignKey("ancestry.id"))
|
||||||
|
ancestry_trait_id = Column(Integer, ForeignKey("ancestry_trait.id"))
|
||||||
trait = relationship("AncestryTrait", lazy='immediate')
|
trait = relationship("AncestryTrait", lazy='immediate')
|
||||||
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20})
|
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20})
|
||||||
|
|
||||||
|
@ -64,6 +66,9 @@ class AncestryTrait(BaseObject, IterableMixin):
|
||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
description = Column(Text)
|
description = Column(Text)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class CharacterClassMap(BaseObject, IterableMixin):
|
class CharacterClassMap(BaseObject, IterableMixin):
|
||||||
__tablename__ = "class_map"
|
__tablename__ = "class_map"
|
||||||
|
@ -71,9 +76,13 @@ class CharacterClassMap(BaseObject, IterableMixin):
|
||||||
character_id = Column(Integer, ForeignKey("character.id"))
|
character_id = Column(Integer, ForeignKey("character.id"))
|
||||||
character_class_id = Column(Integer, ForeignKey("character_class.id"))
|
character_class_id = Column(Integer, ForeignKey("character_class.id"))
|
||||||
mapping = UniqueConstraint(character_id, character_class_id)
|
mapping = UniqueConstraint(character_id, character_class_id)
|
||||||
|
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}, default=1)
|
||||||
|
|
||||||
character_class = relationship("CharacterClass", lazy='immediate')
|
character_class = relationship("CharacterClass", lazy='immediate')
|
||||||
level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}, default=1)
|
character = relationship("Character", uselist=False, viewonly=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"{self.character.name}, {self.character_class.name}, level {self.level}"
|
||||||
|
|
||||||
|
|
||||||
class CharacterClassAttributeMap(BaseObject, IterableMixin):
|
class CharacterClassAttributeMap(BaseObject, IterableMixin):
|
||||||
|
@ -87,6 +96,14 @@ class CharacterClassAttributeMap(BaseObject, IterableMixin):
|
||||||
class_attribute = relationship("ClassAttribute", lazy='immediate')
|
class_attribute = relationship("ClassAttribute", lazy='immediate')
|
||||||
option = relationship("ClassAttributeOption", lazy='immediate')
|
option = relationship("ClassAttributeOption", lazy='immediate')
|
||||||
|
|
||||||
|
character_class = relationship(
|
||||||
|
"CharacterClass",
|
||||||
|
secondary="class_map",
|
||||||
|
primaryjoin="CharacterClassAttributeMap.character_id == CharacterClassMap.character_id",
|
||||||
|
secondaryjoin="CharacterClass.id == CharacterClassMap.character_class_id",
|
||||||
|
viewonly=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Character(*Bases, SavingThrowsMixin, SkillsMixin):
|
class Character(*Bases, SavingThrowsMixin, SkillsMixin):
|
||||||
__tablename__ = "character"
|
__tablename__ = "character"
|
||||||
|
@ -113,3 +130,32 @@ class Character(*Bases, SavingThrowsMixin, SkillsMixin):
|
||||||
|
|
||||||
ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default='1')
|
ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False, default='1')
|
||||||
ancestry = relationship("Ancestry", uselist=False)
|
ancestry = relationship("Ancestry", uselist=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def traits(self):
|
||||||
|
return [mapping.trait for mapping in self.ancestry.traits]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def level(self):
|
||||||
|
return sum(mapping.level for mapping in self.class_map)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def levels(self):
|
||||||
|
return dict([(mapping.character_class.name, mapping.level) for mapping in self.class_map])
|
||||||
|
|
||||||
|
def add_class(self, newclass, level=1):
|
||||||
|
if level == 0:
|
||||||
|
return self.remove_class(newclass)
|
||||||
|
level_in_class = [mapping for mapping in self.class_map if mapping.character_class_id == newclass.id]
|
||||||
|
if level_in_class:
|
||||||
|
level_in_class = level_in_class[0]
|
||||||
|
level_in_class.level = level
|
||||||
|
return
|
||||||
|
self.classes.append(CharacterClassMap(
|
||||||
|
character_id=self.id,
|
||||||
|
character_class_id=newclass.id,
|
||||||
|
level=level
|
||||||
|
))
|
||||||
|
|
||||||
|
def remove_class(self, target):
|
||||||
|
self.class_map = [m for m in self.class_map if m.id != target.id]
|
||||||
|
|
Loading…
Reference in New Issue
Block a user