Files
dcalc/backend/tests/unit/test_skill_refs.py

236 lines
11 KiB
Python

"""技能引用生成的身份隔离、完整性及失败保护。"""
import importlib
import json
from pathlib import Path
import runpy
import pytest
from core.character.refs.base import SkillRef, SkillReferences
from core.character.refs.common import CommonSkillRefs
from core.character.refs.extra.creator import CreatorSkillRef
from core.character.refs.swordman_male.berserker import BerserkerSkillRef
from core.character.refs.swordman_male.weapon_master import WeaponMasterSkillRef
ROOT = Path(__file__).resolve().parents[3]
GENERATOR = runpy.run_path(str(ROOT / 'scripts/generate_skill_refs.py'))
def write_tree(root, rows, *, include_common=True):
if include_common:
rows = [{'code': code, 'name': name} for code, name in GENERATOR['COMMON_SKILLS'].items()] + [
{'code': code, 'name': labels[0]} for code, labels in GENERATOR['AWAKENING_SKILLS']['1'].items()
] + rows
path = root / 'extra/creator/skill_tree.json'
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(rows, ensure_ascii=False), encoding='utf-8')
def test_refs_preserve_codes_and_profession_scope():
assert CreatorSkillRef.CREATESPACE.code == 'createspace'
assert CreatorSkillRef.CREATESPACE.profession == 'extra.creator'
assert CreatorSkillRef.CREATESPACE.qualified_key == 'extra.creator:createspace'
assert WeaponMasterSkillRef.BLADEMASTERY != BerserkerSkillRef.BLADEMASTERY
assert WeaponMasterSkillRef.BLADEMASTERY != 'blademastery'
assert len({WeaponMasterSkillRef.BLADEMASTERY, BerserkerSkillRef.BLADEMASTERY}) == 2
def test_committed_refs_match_all_source_trees():
source = ROOT / 'backend/openapi/static/data/cn'
output = ROOT / 'backend/core/character/refs'
assert GENERATOR['generate'](source, output, check=True) == []
for tree in source.glob('*/*/skill_tree.json'):
job, profession, _ = tree.relative_to(source).parts
module = importlib.import_module(f'core.character.refs.{job}.{profession}')
class_name = ''.join(part.capitalize() for part in profession.split('_')) + 'SkillRef'
refs = getattr(module, class_name)
assert {ref.code for ref in refs.members()} == {row['code'] for row in json.loads(tree.read_text())}
assert all(ref.profession in (None, f'{job}.{profession}') for ref in refs.members())
assert all(name not in vars(refs) for name in CommonSkillRefs.__members__)
@pytest.mark.parametrize(('code', 'member'), [('apg-63', 'APG_63'), ('1000hands1000eyes', 'SKILL_1000HANDS1000EYES')])
def test_non_identifier_codes_keep_original_values(tmp_path, code, member):
source, output = tmp_path / 'source', tmp_path / 'refs'
write_tree(source, [{'code': code, 'name': '中文名称"与换行\n说明'}])
GENERATOR['generate'](source, output)
refs = runpy.run_path(str(output / 'extra/creator.py'))['CreatorSkillRef']
assert getattr(refs, member).code == code
assert GENERATOR['generate'](source, output, check=True) == []
@pytest.mark.parametrize('rows', [
[{'code': 'a-b', 'name': '甲'}, {'code': 'a_b', 'name': '乙'}],
[{'code': 'same', 'name': '甲'}, {'code': 'same', 'name': '乙'}],
[{'name': '缺少 code'}],
])
def test_invalid_source_does_not_partially_write(tmp_path, rows):
source, output = tmp_path / 'source', tmp_path / 'refs'
write_tree(source, rows)
with pytest.raises(ValueError):
GENERATOR['generate'](source, output)
assert not output.exists()
def test_check_is_read_only_and_manual_files_are_protected(tmp_path):
source, output = tmp_path / 'source', tmp_path / 'refs'
write_tree(source, [{'code': 'createspace', 'name': '创世'}])
assert GENERATOR['generate'](source, output, check=True)
assert not output.exists()
target = output / 'extra/creator.py'
target.parent.mkdir(parents=True)
target.write_text('# 手工扩展\n')
with pytest.raises(ValueError, match='拒绝覆盖'):
GENERATOR['generate'](source, output)
assert target.read_text() == '# 手工扩展\n'
assert not (target.parent / '__init__.py').exists()
def test_common_members_are_inherited_and_queries_include_them():
assert CreatorSkillRef.BACKSTEP is CommonSkillRefs.BACKSTEP
assert CreatorSkillRef.BACKSTEP is WeaponMasterSkillRef.BACKSTEP
assert CreatorSkillRef.BACKSTEP.profession is None
assert CreatorSkillRef.BACKSTEP.qualified_key == 'common:backstep'
assert CreatorSkillRef.from_code('backstep') is CreatorSkillRef.BACKSTEP
assert CreatorSkillRef.from_code('createspace') is CreatorSkillRef.CREATESPACE
assert set(CommonSkillRefs.members()).issubset(CreatorSkillRef.members())
assert len(CommonSkillRefs.members()) == 7
with pytest.raises(KeyError):
CreatorSkillRef.from_code('missing')
def test_refs_and_namespaces_are_read_only():
with pytest.raises(AttributeError):
CreatorSkillRef.BACKSTEP.code = 'changed'
with pytest.raises(AttributeError):
CreatorSkillRef.BACKSTEP = SkillRef('changed')
with pytest.raises(AttributeError):
del CreatorSkillRef.CREATESPACE
with pytest.raises(TypeError):
CreatorSkillRef.__members__['BACKSTEP'] = SkillRef('changed')
with pytest.raises(TypeError):
CreatorSkillRef()
def test_class_definition_rejects_overrides_duplicates_and_wrong_scope():
with pytest.raises(ValueError, match='覆盖继承'):
class Override(CommonSkillRefs):
BACKSTEP = SkillRef('other')
with pytest.raises(ValueError, match='重复技能 code'):
class Alias(CommonSkillRefs):
ANOTHER_BACKSTEP = SkillRef('backstep')
with pytest.raises(ValueError, match='职业范围不一致'):
class WrongProfession(CommonSkillRefs):
__profession__ = 'extra.creator'
OTHER = SkillRef('other', 'extra.dark_knight')
with pytest.raises(ValueError, match='覆盖公共查询接口'):
class OverrideMethod(CommonSkillRefs):
members = None
with pytest.raises(ValueError, match='其他职业'):
class WrongParent(CreatorSkillRef):
__profession__ = 'extra.dark_knight'
def test_missing_or_renamed_common_skill_stops_generation(tmp_path):
rows = [{'code': code, 'name': name} for code, name in GENERATOR['COMMON_SKILLS'].items()]
source, output = tmp_path / 'source', tmp_path / 'refs'
write_tree(source, rows[:-1], include_common=False)
with pytest.raises(ValueError, match='缺少公共技能'):
GENERATOR['generate'](source, output)
rows[0]['name'] = '不同名称'
write_tree(source, rows, include_common=False)
with pytest.raises(ValueError, match='公共技能名称不一致'):
GENERATOR['generate'](source, output)
assert not output.exists()
def test_equal_values_are_stable_hashable_identities():
assert SkillRef('backstep') == CommonSkillRefs.BACKSTEP
assert SkillRef('createspace', 'extra.creator') == CreatorSkillRef.CREATESPACE
assert SkillRef('backstep', 'extra.creator') != CommonSkillRefs.BACKSTEP
assert len({SkillRef('backstep'), CommonSkillRefs.BACKSTEP}) == 1
assert SkillReferences.members() == ()
def test_additional_common_skills_keep_real_codes_and_coverage():
from core.character.refs.common import Awakening1SkillRefs, ComboSkillRefs
from core.character.refs.extra.dark_knight import DarkKnightSkillRef
from core.character.refs.archer.hunter import HunterSkillRef
assert CreatorSkillRef.ARMORMASTERYETC is CommonSkillRefs.ARMORMASTERYETC
assert CreatorSkillRef.CLUEOFAWAKENING1 is Awakening1SkillRefs.CLUEOFAWAKENING1
assert CreatorSkillRef.INSTANTREALIZATION1 is Awakening1SkillRefs.INSTANTREALIZATION1
assert WeaponMasterSkillRef.SKILLPRESETTYPE1 is ComboSkillRefs.SKILLPRESETTYPE1
assert HunterSkillRef.from_code('clueofawakening3').profession is None
for refs in (CreatorSkillRef, DarkKnightSkillRef):
assert not hasattr(refs, 'SKILLPRESETTYPE1')
with pytest.raises(KeyError):
refs.from_code('skillpresettype1')
assert not hasattr(refs, 'CLUEOFAWAKENING5')
for refs in (CreatorSkillRef, WeaponMasterSkillRef, HunterSkillRef):
for member, ref in refs.__members__.items():
if ref.code.startswith(('clueofawakening', 'instantrealization')) or ref.code in ('armormasteryetc', 'skillpresettype1'):
assert member not in vars(refs)
assert ref.profession is None
@pytest.mark.parametrize('extra', [
[{'code': 'skillpresettype1', 'name': '技能连招'}],
[{'code': 'clueofawakening2', 'name': '觉醒之抉择'}],
[{'code': 'instantrealization2', 'name': '卓越之力'}],
])
def test_common_group_mismatch_is_rejected_before_writing(tmp_path, extra):
source, output = tmp_path / 'source', tmp_path / 'refs'
write_tree(source, extra)
with pytest.raises(ValueError):
GENERATOR['generate'](source, output)
assert not output.exists()
def test_bound_refs_survive_display_rename_and_remain_character_local(minimal_calc_config):
from core.basic.character import createCharacter
from core.character.refs.extra.creator import CreatorSkillRef as Ref
first = createCharacter('GF.extra.creator')
second = createCharacter('GF.extra.creator')
first.calc(minimal_calc_config)
second.calc(minimal_calc_config)
source = first.GetSkill(Ref.CREATESPACE)
tree = first.GetSkillByRef(Ref.THETREEOFSEFIROT)
assert source.code == 'createspace'
assert first.GetSkillByCode('createspace') is source
assert first.GetSkill('创世') is source
source.name = '显示名称已修改'
first.skills_dict = {s.name: s for s in first.skills}
assert first.GetSkill('创世') is None
assert first.GetSkill(Ref.CREATESPACE) is source
source.lv = 5
assert tree.lv == source.lv == 5
assert first.GetSkill(Ref.BACKSTEP) is not second.GetSkill(Ref.BACKSTEP)
with pytest.raises(ValueError, match='职业不匹配'):
first.GetSkill(WeaponMasterSkillRef.LIGHTSWORDMASTERY)
def test_bind_rejects_duplicate_codes_and_foreign_refs():
from types import SimpleNamespace
from core.character.refs.base import bind_skill_refs
char = SimpleNamespace(skills=[SimpleNamespace(name='甲', ref=SkillRef('a')), SimpleNamespace(name='乙', ref=SkillRef('a', 'extra.creator'))])
with pytest.raises(ValueError, match='重复技能引用绑定'):
bind_skill_refs(char, 'extra.creator')
char.skills = [SimpleNamespace(name='错误职业', ref=WeaponMasterSkillRef.LIGHTSWORDMASTERY)]
with pytest.raises(ValueError, match='职业不匹配'):
bind_skill_refs(char, 'extra.creator')
def test_names_are_display_metadata_not_identity():
assert SkillRef('a', name='旧名称') == SkillRef('a', name='新名称')
assert hash(SkillRef('a', name='旧名称')) == hash(SkillRef('a', name='新名称'))
assert CreatorSkillRef.ARMORMASTERYETC.name == '防具精通'