"""处理链模块分层约束测试。""" import ast from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[1] CHAIN_ROOT = PROJECT_ROOT / "app" / "chain" LEGACY_MUSIC_SCAN_ROOTS = ( PROJECT_ROOT / "app", PROJECT_ROOT / "scripts", ) MUSIC_SOURCE_CHAIN_FILES = ( "acoustid.py", "douban.py", "listenbrainz.py", "lrclib.py", "musicbrainz.py", "theaudiodb.py", ) def _imported_modules(path: Path) -> set[str]: """解析源码中的导入模块,包含函数内部的延迟导入。""" tree = ast.parse(path.read_text(encoding="utf-8-sig")) modules: set[str] = set() for node in ast.walk(tree): if isinstance(node, ast.ImportFrom) and node.module: modules.add(node.module) elif isinstance(node, ast.Import): modules.update(alias.name for alias in node.names) return modules def _inherited_recognize_calls(path: Path) -> list[tuple[int, str]]: """查找业务链通过 self 隐式调用媒体识别入口的位置。""" tree = ast.parse(path.read_text(encoding="utf-8")) calls: list[tuple[int, str]] = [] for node in ast.walk(tree): if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): continue owner = node.func.value if ( isinstance(owner, ast.Name) and owner.id == "self" and node.func.attr in {"recognize_media", "async_recognize_media"} ): calls.append((node.lineno, node.func.attr)) return calls def test_chain_base_does_not_import_concrete_chains() -> None: """基础链不得反向导入任何具体处理链。""" imports = _imported_modules(CHAIN_ROOT / "__init__.py") # 下划线前缀的内部模块(_messaging/_recognition 等)是 ChainBase 的 # 功能域 mixin,不是具体处理链,允许导入 assert not { module for module in imports if module.startswith("app.chain.") and not module.removeprefix("app.chain.").startswith("_") } def test_legacy_music_chain_is_removed() -> None: """聚合全部音乐职责的旧 MusicChain 文件和导入不得重新出现。""" assert not (CHAIN_ROOT / "music.py").exists() violations = { str(path.relative_to(PROJECT_ROOT)): sorted( module for module in _imported_modules(path) if module == "app.chain.music" ) for root in LEGACY_MUSIC_SCAN_ROOTS for path in root.rglob("*.py") if "plugins" not in path.parts # 插件目录由插件仓自治,跳过 if "app.chain.music" in _imported_modules(path) } assert not violations def test_music_source_chains_do_not_depend_on_public_orchestration_chains() -> None: """音乐数据源链不得反向依赖识别、刮削、搜索或推荐编排链。""" forbidden = { "app.chain.media", "app.chain.recommend", "app.chain.scraping", "app.chain.search", } violations = { filename: sorted(_imported_modules(CHAIN_ROOT / filename).intersection(forbidden)) for filename in MUSIC_SOURCE_CHAIN_FILES if _imported_modules(CHAIN_ROOT / filename).intersection(forbidden) } assert not violations def test_media_chain_excludes_scraping_and_music_exploration_methods() -> None: """MediaChain 只保留公共识别与详情路由,不得重新承接刮削或音乐探索职责。""" path = CHAIN_ROOT / "media.py" tree = ast.parse(path.read_text(encoding="utf-8")) method_names = { node.name for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) } forbidden_methods = { "async_get_doubaninfo_by_bangumiid", "async_get_doubaninfo_by_tmdbid", "async_get_tmdbinfo_by_bangumiid", "async_get_tmdbinfo_by_doubanid", "scrape_metadata", "scrape_metadata_event", "scrape_music_metadata", "get_doubaninfo_by_bangumiid", "get_doubaninfo_by_tmdbid", "get_music_lyrics", "get_tmdbinfo_by_bangumiid", "get_tmdbinfo_by_doubanid", "async_get_music_lyrics", "music_chart", "async_music_chart", "music_discover", "async_music_discover", "async_music_fresh_releases", } assert not method_names.intersection(forbidden_methods) assert "app.chain.scraping" not in _imported_modules(path) def test_business_chains_delegate_recognition_to_media_chain() -> None: """搜索、订阅、下载和转移链必须显式委托媒体识别编排层。""" violations = { name: calls for name in ("search.py", "subscribe.py", "download.py", "transfer.py") if (calls := _inherited_recognize_calls(CHAIN_ROOT / name)) } assert not violations