Files
patbond-ai/tests/test_layering.py
T
lixi db87d66403
CI / ai-gates (push) Successful in 31s
fix(ai): 修 ruff lint 三处错误——CI 由红转绿
`4762358` 与 `86d63ab` 两次 CI 均因 `ruff check` 失败而红。

**根因是我的门禁自查方法错了**:用 `ruff check . | tail -1; echo $?` 读到的是
`tail` 的退出码而不是 ruff 的,于是把一次真实的 lint 失败读成了通过——先前声称
的「四道门禁全绿」是基于这个坏掉的检查得出的。已在 ci.yml 的 Gates 步骤就地注明
此坑,并改为逐条分开跑读各自退出码。

三处都是实际问题,非误报:
- `client.py:138` PYI034:`__enter__` 的返回注解硬编码为类名,应为 `Self`
  (子类化后注解会失准)
- `test_video.py:58` C408:`dict()` 调用改字面量
- `test_layering.py:85` PIE810:两次 `endswith` 合并为一次元组调用

修复后在与 CI 同构的隔离 venv(同镜像源、同 requirements-dev.txt,实测 ruff
版本与本地一致 0.16.8)逐道复验:secret-scan / format / lint / pytest 四道
退出码均为 0,100 测试全过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:36:17 +08:00

128 lines
5.0 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.
"""分层边界的测试。
这些断言不测业务,测的是**架构约束本身**。分层的价值全在「换供应商不动上层」,
而这个性质只要有人无意加一行 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", "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}。请通过构造参数注入。"
)