This repository has been archived on 2026-10-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
NeteaseUrl/tests/test_resolve_episode_url.py
T
snowy-yang 4d3204213e refactor: resolve_episode_url 阶梯构造拆解(58 行 / 圈 19 / 嵌套 3 → 36 行 / 圈 12 / 嵌套 2)
为什么:播客单集直链解析的唯一入口,前 20 行是降级阶梯的嵌套构造
(if 套 for 套 if),与尝试循环混在同一个函数里,改音质口径要通读全函数。

拆出:
- _falldown_order(模块级纯函数):降级尝试序列——auto→通用阶梯原序 /
  已知音质→向下后缀 / 未知音质→该音质优先+通用阶梯,去重保序收敛为
  dict.fromkeys;_FALLDOWN_LADDER 从函数内上移模块级
- resolve_episode_url 只剩尝试循环;song_id 的 int 防御上提循环外
  (循环不变量:原实现首轮即判、失败直接 None,url_v1 从未收到过坏 sid)

先写测试后动代码:新增 tests/test_resolve_episode_url.py 11 例,在未改动
实现上一次跑绿,钉住核心口径——「只向下不向上」(standard 绝不回退到
exhigh,历史坑)、未知音质前置、垃圾 song_id 零请求、APIException 降级、
data[0] 无 url 降级、结果映射缺省值。
门禁:ruff 全过 · pyright 0/0 · pytest 370 passed(359→370)。
2026-09-27 11:00:50 +08:00

122 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""resolve_episode_url 的行为契约测试(沙箱,不触真实上游)。
为什么:播客单集直链解析的唯一入口(音质降级阶梯 + 尝试循环),
此前零直接测试。降级阶梯的口径是核心可观察行为:
- auto → QUALITY_LADDER 原序
- 显式音质 → 只向下不向上(standard 绝不回退到更高的 exhigh)
- 未知音质 → 该音质优先 + 通用阶梯
- song_id 垃圾值 → None 且不发任何请求
替身边界:monkeypatch ``podcast.url_v1``(模块级名字,podcast.py 从
.compat 导入后的绑定),按 level 返回结果或抛异常,并记录调用序列。
"""
import pytest
from music_api import APIException
from music_api import podcast as podcast_mod
from music_api.podcast import resolve_episode_url
@pytest.fixture()
def url_calls(monkeypatch):
calls: list[tuple[int, str]] = []
state: dict = {'results': {}}
def _fake_url_v1(sid, level, cookies):
calls.append((sid, level))
r = state['results'].get(level)
if isinstance(r, Exception):
raise r
return r
monkeypatch.setattr(podcast_mod, 'url_v1', _fake_url_v1)
state['calls'] = calls
return state
def _ok(level: str = 'standard') -> dict:
return {'data': [{'url': 'https://x/1.mp3', 'level': level, 'type': 'mp3',
'size': '123', 'br': 128000, 'md5': 'abc'}]}
async def test_auto_tries_quality_ladder_in_order(url_calls):
"""auto:按 QUALITY_LADDER 原序尝试,全失败返回 None。"""
assert resolve_episode_url(101, {}, 'auto') is None
assert url_calls['calls'] == [(101, 'exhigh'), (101, 'standard')]
async def test_explicit_level_falls_down_only(url_calls):
"""显式 lossless:向后降级 exhigh/standard,绝不向上尝试 hires。"""
assert resolve_episode_url(101, {}, 'lossless') is None
assert url_calls['calls'] == [(101, 'lossless'), (101, 'exhigh'), (101, 'standard')]
async def test_standard_never_falls_up(url_calls):
"""显式 standard:只试一次(历史坑:原实现会回退到更高的 exhigh)。"""
assert resolve_episode_url(101, {}, 'standard') is None
assert url_calls['calls'] == [(101, 'standard')]
async def test_unknown_level_prepends_generic_ladder(url_calls):
"""未知音质:先试该音质,再退通用阶梯(去重保序)。"""
assert resolve_episode_url(101, {}, 'sky') is None
assert url_calls['calls'] == [(101, 'sky'), (101, 'exhigh'), (101, 'standard')]
async def test_first_hit_returns_mapped_result(url_calls):
url_calls['results']['exhigh'] = _ok('hires')
out = resolve_episode_url(101, {}, 'auto')
assert out == {'url': 'https://x/1.mp3', 'level': 'hires', 'type': 'mp3',
'size': 123, 'br': 128000, 'md5': 'abc', 'requested_level': 'exhigh'}
async def test_level_defaults_to_requested_when_missing(url_calls):
url_calls['results']['exhigh'] = {'data': [{'url': 'https://x/1.mp3'}]}
out = resolve_episode_url(101, {}, 'auto')
assert out is not None
assert out['level'] == 'exhigh'
assert out['type'] == 'mp3'
assert out['md5'] == ''
assert out['size'] is None
async def test_api_exception_tries_next_level(url_calls):
url_calls['results']['exhigh'] = APIException('无版权')
url_calls['results']['standard'] = _ok()
out = resolve_episode_url(101, {}, 'auto')
assert out is not None
assert out['requested_level'] == 'standard'
assert url_calls['calls'] == [(101, 'exhigh'), (101, 'standard')]
async def test_missing_url_tries_next_level(url_calls):
"""data[0] 无 url:视同失败,继续降级。"""
url_calls['results']['exhigh'] = {'data': [{'url': None}]}
url_calls['results']['standard'] = _ok()
out = resolve_episode_url(101, {}, 'auto')
assert out is not None
assert out['requested_level'] == 'standard'
async def test_empty_data_tries_next_level(url_calls):
url_calls['results']['exhigh'] = None # res 为 None → data 为 []
url_calls['results']['standard'] = _ok()
out = resolve_episode_url(101, {}, 'auto')
assert out is not None
assert out['requested_level'] == 'standard'
async def test_bad_song_id_returns_none_without_requests(url_calls):
"""song_id 垃圾值:None,且不发任何请求(防御原 TypeError 500)。"""
for bad in (None, 'abc', ''):
assert resolve_episode_url(bad, {}, 'auto') is None
assert url_calls['calls'] == []
async def test_numeric_string_song_id_accepted(url_calls):
url_calls['results']['exhigh'] = _ok()
out = resolve_episode_url(' 101 ', {}, 'auto')
assert out is not None
assert url_calls['calls'][0] == (101, 'exhigh')