Ctrl K
ComunidadGuías…

tests/test_source_assertion_ratchet.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""源码文本断言棘轮:「某段字符串在某个仓库文件里」这类断言只许减少,不许增加。

这类断言读出 `web/app.js`、样式表或文档的原文,再 `assertIn` 一段写法。它守住的是
「这几个字符还在」,不是「功能是对的」:换一种等价写法就红,把条件写反了照样绿。
2026-08-01 到 2026-09-23,`tests/test_web_ui.py` 被 670 个提交改过,净增约 1.3 万行,
几乎每次界面修复都在同步改这些断言。改一处样式要同时改它的测试,测试就只是在复述实现。

要守的东西换成能跑的验收:
- 行为(点了提交什么、状态怎么变、算出什么值)写 vitest(`frontend/test/`)或
  `tests/test_web_js.py`,后端写调用真函数或真接口的测试;
- 用户定过的设计决定写 `frontend/e2e/design-*.test.ts`,读 `getComputedStyle`;
- 布局与运行期不变量进 `frontend/e2e/smoke.test.ts`;
- 全仓都该成立的写法约定写成扫描器或 lint(如 `test_complexity_ratchet.py`),
  一条规则一个检查,而不是一处写法一条断言。

计数是纯 AST:一个断言调用点算 1——`assertIn`/`assertNotIn` 的被查对象、
`assertRegex`/`assertNotRegex` 的文本、`assertTrue(x in 文本)` 的右侧,只要来自
`read_text()` 读出的仓库文件就算;模块里自己定义的、内部读了这种文本的 `assert*`
辅助方法(如 `assertPageContains`)每次调用也算 1。仓库文件指路径表达式里出现
`__file__` 的那些,临时目录里造的文件不算。

基线只能降:删掉一条就把基线改小,降到 0 就把那一行删掉。一个文件的计数超过基线
时,别改写成别的断言绕过去,按上面的去向换成行为验收。
"""
import ast
import pathlib
import unittest
from types import MappingProxyType

TESTS = pathlib.Path(__file__).resolve().parent

#: 键是测试文件名,值是这个文件里源码文本断言的调用点数。
BASELINE: dict[str, int] = {
    "test_agency_entity.py": 2,
    "test_buildinfo.py": 3,
    "test_cloudflared_packaging.py": 2,
    "test_dependency_policy.py": 25,
    "test_desktop_settings.py": 5,
    "test_face_detect.py": 3,
    "test_fastapi_api.py": 4,
    "test_follow_assets.py": 1,
    "test_follow_web.py": 1,
    "test_frontend_build.py": 20,
    "test_job_status.py": 1,
    "test_metadata_library.py": 4,
    "test_repo_hygiene.py": 1,
    "test_scripts.py": 11,
    "test_social_avatar_harvest.py": 2,
    "test_studio_icon_variants.py": 2,
    "test_studio_site_harvest.py": 1,
    "test_subprocess_encoding.py": 2,
    "test_tray.py": 3,
    "test_web_js.py": 1,
    "test_web_ui.py": 14,
    "test_windows_update.py": 3,
}

MEMBERSHIP = {"assertIn", "assertNotIn"}
PATTERN = {"assertRegex", "assertNotRegex"}
TRUTH = {"assertTrue", "assertFalse"}
READS = {"read_text", "read_bytes"}


def _outermost_functions(body):
    for node in body:
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            yield node
        elif isinstance(node, ast.ClassDef):
            yield from _outermost_functions(node.body)


class _Taint:
    """一个模块里的污点表:哪些名字是仓库路径、哪些是读出来的仓库文本。"""

    def __init__(self, tree: ast.Module):
        self.path_attrs: set[str] = set()
        self.text_attrs: set[str] = set()
        self.helpers: set[str] = set()
        self.assert_helpers: set[str] = set()
        # 只取最外层的函数与方法;嵌套函数随外层一起遍历,单列会把它的断言数两遍。
        self.functions = list(_outermost_functions(tree.body))
        # 模块级只看顶层语句:函数里的局部名字不是模块级的仓库路径。
        top = [node for node in tree.body
               if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))]
        self.envs: dict[ast.AST, tuple[set[str], set[str]]] = {}
        for _ in range(10):
            before = (len(self.path_attrs), len(self.text_attrs), len(self.helpers),
                      len(self.assert_helpers))
            self.module_env = self._env(top, ({"__file__"}, set()))
            for function in self.functions:
                env = self._env(function.body, self.module_env)
                self.envs[function] = env
                self._learn(function, env)
            if before == (len(self.path_attrs), len(self.text_attrs), len(self.helpers),
                          len(self.assert_helpers)):
                break

    def _self_attr(self, node: ast.AST) -> str | None:
        if (isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name)
                and node.value.id in {"self", "cls"}):
            return node.attr
        return None

    def is_path(self, node: ast.AST, env) -> bool:
        paths, _ = env
        for child in ast.walk(node):
            if isinstance(child, ast.Name) and child.id in paths:
                return True
            if isinstance(child, ast.Attribute) and child.attr == "__file__":
                return True
            if self._self_attr(child) in self.path_attrs:
                return True
        return False

    def is_text(self, node: ast.AST, env) -> bool:
        _, texts = env
        for child in ast.walk(node):
            if isinstance(child, ast.Name) and child.id in texts:
                return True
            if self._self_attr(child) in self.text_attrs:
                return True
            if isinstance(child, ast.Call):
                func = child.func
                if (isinstance(func, ast.Attribute) and func.attr in READS
                        and self.is_path(func.value, env)):
                    return True
                if isinstance(func, ast.Name) and func.id in self.helpers:
                    return True
                if self._self_attr(func) in self.helpers:
                    return True
        return False

    def _env(self, body, base):
        """在 `body` 里按赋值、循环与推导式传播污点,直到不再变化。"""
        paths, texts = set(base[0]), set(base[1])
        bindings = []
        for statement in body:
            for node in ast.walk(statement):
                if isinstance(node, ast.Assign):
                    bindings.extend((target, node.value) for target in node.targets)
                elif isinstance(node, (ast.AnnAssign, ast.AugAssign)) and node.value:
                    bindings.append((node.target, node.value))
                elif isinstance(node, (ast.For, ast.AsyncFor, ast.comprehension)):
                    bindings.append((node.target, node.iter))
                elif isinstance(node, ast.withitem) and node.optional_vars is not None:
                    bindings.append((node.optional_vars, node.context_expr))
        changed = True
        while changed:
            changed = False
            env = (paths, texts)
            for target, value in bindings:
                path, text = self.is_path(value, env), self.is_text(value, env)
                if not (path or text):
                    continue
                for name in ast.walk(target):
                    if isinstance(name, ast.Name):
                        if path and name.id not in paths:
                            paths.add(name.id)
                            changed = True
                        if text and name.id not in texts:
                            texts.add(name.id)
                            changed = True
        return paths, texts

    def _learn(self, function, env):
        """从一个函数里学到:它写进 self/cls 的属性、它是不是返回文本的辅助函数。"""
        for node in ast.walk(function):
            if isinstance(node, ast.Assign):
                for target in node.targets:
                    attr = self._self_attr(target)
                    if attr is None:
                        continue
                    if self.is_text(node.value, env):
                        self.text_attrs.add(attr)
                    elif self.is_path(node.value, env):
                        self.path_attrs.add(attr)
            elif (isinstance(node, ast.Return) and node.value is not None
                  and self.is_text(node.value, env)):
                self.helpers.add(function.name)
        if function.name.startswith("assert") and any(
                isinstance(node, ast.expr) and self.is_text(node, env)
                for node in ast.walk(function)):
            self.assert_helpers.add(function.name)

    def count(self) -> int:
        total = 0
        for function in self.functions:
            if function.name in self.assert_helpers:
                continue
            env = self.envs[function]
            for node in ast.walk(function):
                if isinstance(node, ast.Call) and self._counts(node, env):
                    total += 1
        return total

    def _counts(self, call: ast.Call, env) -> bool:
        name = self._self_attr(call.func)
        if name is None:
            return False
        if name in self.assert_helpers:
            return True
        args = call.args
        if name in MEMBERSHIP and len(args) >= 2:
            return self.is_text(args[1], env)
        if name in PATTERN and args:
            return self.is_text(args[0], env)
        if name in TRUTH and args and isinstance(args[0], ast.Compare):
            compare = args[0]
            return (any(isinstance(op, (ast.In, ast.NotIn)) for op in compare.ops)
                    and any(self.is_text(side, env) for side in compare.comparators))
        return False


def source_assertions(source: str) -> int:
    """一段测试源码里源码文本断言的调用点数。"""
    return _Taint(ast.parse(source)).count()


def survey() -> dict[str, int]:
    found = {}
    for path in sorted(TESTS.glob("test_*.py")):
        count = source_assertions(path.read_text(encoding="utf-8"))
        if count:
            found[path.name] = count
    return found


class SourceAssertionRatchetTests(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.current = MappingProxyType(survey())

    def test_the_counter_tells_repository_text_from_runtime_output(self):
        source = (
            "import pathlib, unittest\n"
            "ROOT = pathlib.Path(__file__).resolve().parents[1]\n"
            "def stylesheet():\n"
            "    return (ROOT / 'web' / 'a.css').read_text()\n"
            "class T(unittest.TestCase):\n"
            "    @classmethod\n"
            "    def setUpClass(cls):\n"
            "        cls.page = (ROOT / 'web' / 'app.js').read_text()\n"
            "    def assertPageContains(self, needle):\n"
            "        self.assertTrue(needle in self.page)\n"
            "    def test_source(self):\n"
            "        app = (ROOT / 'web' / 'app.js').read_text()\n"
            "        body = app[app.index('a'):]\n"
            "        self.assertIn('x', body)\n"                      # 1:切片仍是源码
            "        self.assertNotIn('y', stylesheet())\n"           # 2:辅助函数返回源码
            "        self.assertRegex(self.page, 'z')\n"              # 3:setUpClass 读的属性
            "        self.assertPageContains('w')\n"                  # 4:自带的源码断言辅助
            "        for key in ('a', 'b'):\n"
            "            self.assertIn(key, app)\n"                   # 5:调用点只算一次
            "        joined = ''.join(p.read_text() for p in (ROOT / 'web').glob('*.js'))\n"
            "        self.assertTrue('q' in joined)\n"                # 6:推导式读的也算
            "    def test_behaviour(self, tmp='t'):\n"
            "        out = pathlib.Path(tmp) / 'x.txt'\n"
            "        self.assertIn('x', out.read_text())\n"           # 临时文件不算
            "        self.assertIn('ok', run(ROOT / 'scripts' / 's.py'))\n"  # 跑出来的输出不算
        )
        self.assertEqual(source_assertions(source), 6)

    def test_no_test_file_grew_past_its_baseline(self):
        grown = sorted(f"{name}: {count}(基线 {BASELINE.get(name, 0)})"
                       for name, count in self.current.items() if count > BASELINE.get(name, 0))
        self.assertEqual(grown, [],
                         "这些测试文件的源码文本断言多了。别改写成别的断言绕过,按本文件开头的"
                         "去向换成行为验收:\n  " + "\n  ".join(grown))

    def test_the_baseline_only_ratchets_down(self):
        current = self.current
        stale = sorted(f"{name}: 现在 {current.get(name, 0)},基线 {value}"
                       for name, value in BASELINE.items() if current.get(name, 0) < value)
        self.assertEqual(stale, [],
                         "基线落后于测试:删掉断言后把这里改小,降到 0 就删掉那一行,"
                         "不给将来的增长留余量:\n  " + "\n  ".join(stale))


if __name__ == "__main__":
    unittest.main()