Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ htmlcov/
junit.xml
.pytest_cache/
.ruff_cache/
mutants/

# Local-only workflow aids (never commit)
CLAUDE.md
Expand Down
17 changes: 17 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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> # the exact change that got away
Comment thread
funkadelic marked this conversation as resolved.
Outdated
```

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 a change to any copied file throws the cache away rather than reusing it.

## Adding a new device model

Follow the pattern in `custom_components/rainpoint/api/decoders.py`:
Expand Down
53 changes: 53 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,59 @@ 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 also_copy files 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 a copied file changes, README.md
# included, which is the cheaper of the two failure modes.
[tool.mutmut]
source_paths = ["custom_components/"]
also_copy = [
"tests/",
"scripts/",
"pyproject.toml",
"README.md",
".github/ISSUE_TEMPLATE/",
]
do_not_mutate = ["*/debug.py"]
on_dependency_change = "rerun"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Expand Down