feat:优化职业技能关联实现
This commit was merged in pull request #11.
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
"""技能引用生成的身份隔离、完整性及失败保护。"""
|
||||
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 == '防具精通'
|
||||
Reference in New Issue
Block a user