"""分层边界的测试。 这些断言不测业务,测的是**架构约束本身**。分层的价值全在「换供应商不动上层」, 而这个性质只要有人无意加一行 import 就会悄悄失效——没有测试守着,它迟早会失效。 """ from __future__ import annotations import ast import pathlib APP = pathlib.Path(__file__).resolve().parent.parent / "app" def imports_of(path: pathlib.Path) -> set[str]: tree = ast.parse(path.read_text(encoding="utf-8")) names: set[str] = set() for node in ast.walk(tree): if isinstance(node, ast.Import): names.update(alias.name for alias in node.names) elif isinstance(node, ast.ImportFrom) and node.module: names.add(node.module) return names def modules_under(*parts: str) -> list[pathlib.Path]: return sorted((APP.joinpath(*parts)).rglob("*.py")) class TestDomainIsIndependent: """领域层是「换供应商不动上层」的支点。它一旦依赖厂商或 HTTP,分层就名存实亡。""" def test_domain_imports_no_provider_code(self): for module in modules_under("domain"): offenders = {name for name in imports_of(module) if "providers" in name} assert not offenders, f"{module.name} 不应依赖服务商代码:{offenders}" def test_domain_imports_no_http_library(self): for module in modules_under("domain"): offenders = { name for name in imports_of(module) if name.split(".")[0] in {"httpx", "requests"} } assert not offenders, f"{module.name} 不应依赖 HTTP 库:{offenders}" def test_domain_does_not_read_configuration(self): # 领域对象应当是纯数据;让它自己读配置会使单测必须摆弄环境变量。 for module in modules_under("domain"): offenders = { name for name in imports_of(module) if name in {"os", "app.config"} } assert not offenders, f"{module.name} 不应读取配置:{offenders}" class TestAbstractionDoesNotLeak: def test_provider_base_knows_no_specific_vendor(self): """抽象接口若 import 了火山,新增第二家供应商就得改抽象层。""" offenders = { name for name in imports_of(APP / "providers" / "base.py") if "volcengine" in name } assert not offenders, f"抽象层不应依赖具体服务商:{offenders}" def test_registry_knows_no_specific_vendor(self): offenders = { name for name in imports_of(APP / "providers" / "registry.py") if "volcengine" in name } assert not offenders, f"注册表不应依赖具体服务商:{offenders}" class TestTransportStaysBusinessAgnostic: def test_ark_client_does_not_import_video_business(self): """传输层复用的前提是它不认识「视频」。 一旦 client.py 依赖了 video.py,接火山的图片接口时就没法复用它, 只能再抄一遍鉴权与错误解包——那正是拆分要避免的。 """ offenders = { name for name in imports_of(APP / "providers" / "volcengine" / "client.py") if name.endswith((".video", ".image")) or name.endswith("models") } assert not offenders, f"传输层不应依赖具体业务模块:{offenders}" class TestVendorVocabularyIsContained: def test_only_the_volcengine_package_mentions_ark_paths(self): """厂商特有的路径、字段名只应出现在其适配包内。 `framespersecond` 这种厂商拼法漏到上层,就意味着上层已经知道自己在跟 火山说话——换供应商时会牵连到它。 """ vendor_markers = ("framespersecond", "contents/generations", "ark.cn-beijing") for module in sorted(APP.rglob("*.py")): if "volcengine" in module.parts: continue text = module.read_text(encoding="utf-8") for marker in vendor_markers: assert marker not in text, ( f"{module.relative_to(APP)} 出现了厂商专有词汇 {marker!r}," f"应收拢到 providers/volcengine/ 内" ) class TestDependencyDirectionIsOneWay: """配置 → 适配层,绝不反向。 `config.py` 作为装配根知道有哪家供应商是合理的;反过来,适配层一旦自己去读 配置,依赖注入就失效了——单测又得摆弄环境变量,而且同一进程内没法跑两套 不同配置的适配器实例。 """ def test_providers_do_not_read_config(self): for module in modules_under("providers"): offenders = { name for name in imports_of(module) if name == "app.config" or name == "os" } assert not offenders, ( f"{module.relative_to(APP)} 不应自行读取配置或环境变量:" f"{offenders}。请通过构造参数注入。" )