Files
dcalc/backend/tests/unit/test_skill_tree.py
T
2026-09-14 14:53:05 +08:00

133 lines
5.0 KiB
Python

import base64
import struct
import zlib
from contextlib import nullcontext
import pytest
from core.open import skillTree
from api.utils.skill_tree import load_skill_tree_map
from core.open.skillTree import decode_skill_hash, get_openapi_skill_tree_info, get_skill_tree_info
@pytest.fixture(autouse=True)
def no_live_redis(monkeypatch):
monkeypatch.setattr(skillTree, 'get_redis_client', lambda: nullcontext(None))
def encode(skills=(), job=(0, 3), remaining=b''):
header = bytearray(14)
header[0] = 0xF3
header[2:4] = bytes(job)
header[5] = 115
header[8] = ord('K')
data = struct.pack('<H', len(skills) << 8)
for index, level in skills:
data += struct.pack('<HH', (index & 255) << 8, (level << 8) | (index >> 8))
# 无进化/强化,两个快捷栏:鬼斩、空槽。
data += bytes([0, 0, 0, 2, 5, 0, 0, 0]) + remaining
return base64.b64encode(zlib.compress(header + data)).decode()
def test_decode_adds_missing_auto_skills_without_overwriting_encoded_levels():
result = decode_skill_hash(encode([(5, 7)]))
assert result['skills'][0]['level'] == 7
automatic = {item['skill_index']: item['level'] for item in result['auto_skills']}
tree = load_skill_tree_map('swordman_male', 'berserker')
assert automatic == {
int(key): item['autoLevel'] for key, item in tree.items()
if 'autoLevel' in item and key != '5'
}
assert automatic[174] == 115
assert 'arrangements' not in result
def test_openapi_result_does_not_create_character(monkeypatch):
import core.basic.character as character
def fail(*args, **kwargs):
raise AssertionError('must not create a character')
monkeypatch.setattr(character, 'createCharacter', fail)
result = get_openapi_skill_tree_info(encode())
assert result['skills']['5']['lv'] == 1
assert result['shortcuts'] == [
{'slot': 0, 'skill_id': 5, 'skill_name': '鬼斩'},
{'slot': 1, 'skill_id': None, 'skill_name': None},
]
def test_calculator_receives_automatic_levels_and_shortcuts():
result = get_skill_tree_info(encode())
skills = {item['name']: item for item in result['skills'].values()}
assert skills['鬼斩']['lv'] == 1
assert skills['基础精通']['lv'] == 115
assert result['shortcuts'][0]['skill_name'] == '鬼斩'
def test_dark_knight_arrangements_and_remaining_are_preserved():
remaining = bytes([1, 118, 0, 1, 60, 0])
result = decode_skill_hash(encode(job=(9, 0), remaining=remaining))
assert result['arrangements'] == [{'slot_index': 118, 'skills': [60]}]
assert result['section2_remaining'] == remaining
assert result['auto_skills']
def test_context_uses_separate_redis_map_cache(monkeypatch):
from config.main import config
monkeypatch.setattr(config, 'DEBUG_MODE', False)
class FakeRedis:
value = None
keys = []
def get(self, key):
self.keys.append(key)
return self.value
def set(self, key, value):
self.value = value
redis = FakeRedis()
monkeypatch.setattr(skillTree, 'get_redis_client', lambda: nullcontext(redis))
header = {'job_bytes': (0, 3)}
_, original = skillTree._get_skill_tree_context(header)
def fail(*args):
raise AssertionError('cache hit must not load local data')
monkeypatch.setattr(skillTree, 'load_skill_tree_map', fail)
_, cached = skillTree._get_skill_tree_context(header)
assert cached == original
assert cached['174']['autoLevel'] == 115
assert redis.keys == ['openapi:skillTreeMap:v1:swordman_male:berserker:map'] * 2
@pytest.mark.parametrize(('code', 'interval_ms'), [
('eJytjb9OwnAABu9CmpQG0MUYjBYHaCRi8ytQW/7sDiQ46OxLuMArsTCwM/lGvICBZ+Cbbrjcd4o6UeOXzQqk1Qo/26/P+Jt1O3ykLI1c2HZuk5mxtXdW3vvuLaUPTtWJN47tUpgZTM199c3c0YUSX0wYGnxSMnWg9JVHE/bIDumpqXpEDsREuRZaayWlNEZSSS2dc3ro1MyZE/8srA0uXJ7VIzugLzw7cM/1Znl5qoTTP4KaGC0=', 0),
('eJytjT9OwnAARt8LaVIb/iXGEI1WB2gkQvOrUFuE3cFEBp25hIvewLOwMLgzcSMuQOAMfNMbXt63j9pR45ufd5BmM6x+P5fxFx+t8JayMHJuy1cvmBlbe2Vlzxe7lN44VSd2fPaawsxgau6TY3NHJ0p8NGFo8E7J1IHSV25N2CBr5F5N1S3yT0yUa6G1VlJKYySV1NI+podOzZw5cWdhbXDu4qhuWQN94cGBG843y9NT5d/l/gCEWBjI', 5000),
])
def test_confirmed_skill_combo_samples(code, interval_ms):
result = decode_skill_hash(code)
assert result['skill_combo'] == {
'skills': [309, 305, 312, 311], 'interval_ms': interval_ms,
}
raw = zlib.decompress(base64.b64decode(code))
assert result['section2_remaining'] == raw[-103:] + b'\x00'
def test_skill_combo_preserves_empty_slots_and_rejects_truncated_layout():
remaining = bytearray(103)
remaining[92:100] = struct.pack('<4H', 98, 114, 93, 0)
remaining[102] = 0xF3
original = bytes(remaining)
assert skillTree._parse_skill_combo(original) == {
'skills': [98, 114, 93, None], 'interval_ms': 0,
}
assert bytes(remaining) == original
assert skillTree._parse_skill_combo(original[:102]) is None
assert skillTree._parse_skill_combo(bytes(103)) is None
assert 'skill_combo' not in decode_skill_hash(encode(job=(9, 0)))