diff --git a/.gitignore b/.gitignore index 71939898..1a91f06f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ htmlcov/ junit.xml .pytest_cache/ .ruff_cache/ +mutants/ # Local-only workflow aids (never commit) CLAUDE.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e02d5876..16db7c6a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,6 +50,23 @@ CI runs the same `pytest` invocation plus `hassfest` and HACS validation on ever Every action in `.github/workflows/` is pinned to a full commit SHA with the version in a trailing comment, e.g. `uses: actions/checkout@3d3c42e... # v7.0.1`. A tag can be repointed at new code without review, so a new `uses:` line needs a SHA rather than `@v4`. Dependabot reads the trailing comment and bumps both parts together. Two actions have no usable release and track a branch commit instead, `hacs/action` and `home-assistant/actions/hassfest`; Dependabot cannot bump those, so refresh them by hand. +## Mutation testing (optional) + +```bash +uv pip install --group mutation # or: pip install --group mutation +mutmut run --max-children 4 'custom_components.rainpoint.api.trust.*' # one module +mutmut results # what survived +mutmut show MUTANT_NAME # one of those names, and its exact change +``` + +Scope it to a module while you work on that module. A whole-tree `mutmut run` covers over fifteen thousand mutants and takes hours, though results are cached, so a later run picks up where the last one stopped. `mutants/` is the working copy mutmut builds; it is gitignored and safe to delete. + +Pass `--max-children`, and pick a number below your core count. It defaults to one worker per core, and every worker is a forked copy of a process that has already imported Home Assistant and the whole test suite, so a default run saturates the machine and costs a few hundred MB per worker. That is enough to leave a laptop, or a WSL session, unresponsive until the run finishes. Half your cores is a reasonable ceiling, and prefixing the command with `nice -n 19` keeps the rest of your shell usable. + +A surviving mutant is a question, not a defect: it names a change to the source that no test objects to. Sometimes that means a missing assertion, sometimes it means the line genuinely doesn't matter. + +Configuration lives in `pyproject.toml` under `[tool.mutmut]`, with comments explaining why coverage is switched off for those runs, why one digest-pinning test is deselected, and why editing one of the files the tests open by path throws the cache away rather than reusing it. + ## Adding a new device model Follow the pattern in `custom_components/rainpoint/api/decoders.py`: diff --git a/pyproject.toml b/pyproject.toml index e0ab2d7c..be152f0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,79 @@ addopts = [ "--cov-report=html", ] +# Declared here rather than in requirements-test.txt so CI, which installs that +# file on every pull request, never pays to download a tool no job runs. The +# major is capped because mutmut 3 changed its configuration format from mutmut +# 2, so a 4.x could invalidate the [tool.mutmut] block below without failing +# loudly. +[dependency-groups] +mutation = ["mutmut>=3.7,<4"] + +# Mutation testing, run by hand rather than in CI. mutmut copies the source and +# everything listed in also_copy into ./mutants/, mutates the copy, and runs the +# suite from there, so anything the tests open by path has to be copied too: +# README.md and scripts/ are read directly by tests, and pyproject.toml carries +# the pytest configuration the suite needs to run at all. +# +# --no-cov is what keeps the results honest. This project's pytest run enables +# coverage with a fail_under gate, and a mutant only ever exercises part of the +# tree, so every run would exit non-zero on the gate and every mutant would be +# reported killed whether the tests noticed it or not. +# +# debug.py is left alone for the same reason it is left out of coverage: nothing +# imports it in a shipped build. +# +# The one deselected test pins api/decoders.py by whole-file digest. mutmut works +# by rewriting every source file with its mutants inlined, so that digest can +# never match under it, and the test would fail against the unmutated baseline +# before any mutant is even tried. It still runs, and still guards the file, in +# every ordinary pytest run. +# +# on_dependency_change defaults to "warn", which keeps a cache built under the +# previous config when one of the copied inputs changes. That cache is what +# feeds the test-to-function map, and a stale one can end up emptied, at which +# point every mutant is skipped as uncovered and the run finishes green at +# 0.00 mutations/second having tested nothing. "rerun" throws the cache away +# instead. It costs a full re-run whenever one of those inputs changes, which is +# the cheaper of the two failure modes. +# +# also_copy only copies; it registers nothing for change detection, and neither +# of mutmut's two detectors sees these files on its own. The git detector skips +# every .py file, because the per-function source hashes are meant to cover +# those, but those hashes only span source_paths, so scripts/ is invisible to +# both. Markdown is then dropped as documentation noise, which hides README.md. +# cache_invalidation_files is the escape hatch: a registered path is hashed every +# run and is never dropped as noise. Keep the globs one level deep. mutmut +# matches them with fnmatch as well as glob, and fnmatch's "*" already spans +# separators, so "scripts/**/*" silently matches nothing there. +# +# tests/ is deliberately left unregistered. mutmut re-collects stats for tests it +# has not seen before, so a new test is picked up without discarding anything, +# and registering the directory would throw the whole cache away on every +# assertion added while chasing a surviving mutant. +[tool.mutmut] +source_paths = ["custom_components/"] +also_copy = [ + "tests/", + "scripts/", + "pyproject.toml", + "README.md", + ".github/ISSUE_TEMPLATE/", +] +cache_invalidation_files = [ + "scripts/*", + "README.md", + ".github/ISSUE_TEMPLATE/*", +] +do_not_mutate = ["*/debug.py"] +on_dependency_change = "rerun" +pytest_add_cli_args = [ + "--no-cov", + "--deselect", + "tests/api/test_generic_decoder.py::TestAsciiFramingNonRegression::test_decoders_py_is_byte_identical_to_the_phase_base", +] +pytest_add_cli_args_test_selection = ["tests/"] + [tool.ruff] target-version = "py313" line-length = 130