Skip to content

fix(tests): pin the pytest collection boundary so a root pytest cannot imply coverage it does not have - #111

Merged
JC-000 merged 1 commit into
masterfrom
fix/pytest-collection-boundary
Aug 15, 2026
Merged

fix(tests): pin the pytest collection boundary so a root pytest cannot imply coverage it does not have#111
JC-000 merged 1 commit into
masterfrom
fix/pytest-collection-boundary

Conversation

@JC-000

Copy link
Copy Markdown
Owner

Closes#109.

What was wrong — and it is worse than the report

The report is right that tests/ holds five test_*.py files with no def test_ in them, so pytest walks a directory named exactly the way its discovery convention expects, collects zero, and says nothing about it.

But the reported "green 154 passed" does not reproduce. pytest is available on this bench (/opt/homebrew/bin/pytest 9.0.3, on an interpreter that can import c64_test_harness and cryptography), so this was measured rather than inferred. On a clean origin/master snapshot, run from the repo root:

$ pytest -> 25 passed, 1 warning, 75 errors in 0.14s exit 1
$ pytest tests/ -> no tests ran in 0.04s exit 5

The 75 errors are all fixture 'transport' not found. tools/ is not a pytest suite either. Its test_* functions take positional (transport, labels) supplied by tools/run_all_tests.py, which allocates a VICE instance per suite; there is no conftest.py anywhere in the repo, no plugin provides those fixtures, and the repo declares no pytest dependency at all.

So the real defect is broader than "tests/ contributes zero": nothing in this repo is meaningfully pytest-shaped, and what a bare pytest prints depends entirely on which interpreter and plugins happen to be around. The reporter got a green; this bench gets a red. Both are noise.

Collection breakdown on master (100, not 154 — the libs/ submodules are unpopulated in this worktree, which accounts for the other 54):

PathCollectedOutcome
tools/test_net_test_env.py1414 pass (real unittest.TestCase suite)
tools/test_package_verify.py1211 pass, 1 error
12 other tools/test_*.py7474 fixture errors
tests/0silent
tools/uci/test_*.py0silent (same shape as tests/)

What shipped

Option 1 + 3 from the issue. Both were needed, and neither is sufficient alone:

  1. tests/test_*.py -> tests/rig_*.py. This part had to be a rename. testpaths only takes effect when pytest is invoked from the rootdir, so config alone leaves the directory collectable from anywhere else. A rename holds unconditionally.
  2. pytest.ini pins testpaths to the three modules pytest can genuinely run, and norecursedirs keeps collection out of libs/, ip65/, tests/ and tools/uci/ — every one of which is test_*.py-named and contributes zero.
  3. conftest.py prints the scope of the run in the header and again immediately above the summary line (the header scrolls away on a long run; the summary line is what people read). It imports nothing and defines no fixtures, so it stays inert without pytest — the repo's zero-dependency posture is unchanged.
  4. tools/test_package_verify.py::test_parse_build_info_records took a tmp_lines argument its own main() passed in, which is why an otherwise pure-logic module had one erroring test. Defaulted to a module constant; both runners still work (python3 tools/test_package_verify.py -> 31 passed, unchanged, and make package-verify invokes exactly that).
  5. tools/test_pytest_boundary.py — a pure-AST guard (no VICE, no build, milliseconds) so this cannot silently regress. It asserts tests/ holds no test_*.py, every testpaths entry exists, and testpaths is exactly the set of tools/test_*.py modules pytest can run — both directions, so a new pure-logic suite cannot become invisible to a bare pytest either. It runs under pytest and standalone.
  6. tests/README.md — what each rig script needs, how to run it, and why it is not collectable.

Before / after, measured

Run via subprocess with an explicit cwd (the "before" column is a git archive of origin/master extracted to a scratch dir, so it is a genuinely pristine tree):

InvocationBeforeAfter
pytest (repo root)25 passed, 75 errors, exit 130 passed, exit 0, with the scope note
pytest tests/no tests ran, exit 5, unexplainedno tests ran, exit 5, explained

After, at the repo root:

============================= test session starts ==============================
c64-https: pytest runs ONLY the pure-logic host-side modules pinned in
pytest.ini `testpaths`. It does NOT run the C64 suites (those need VICE:
`python3 tools/run_all_tests.py`) and it does NOT run the live-rig
scripts in tests/ (manual, sudo + network rig: see tests/README.md).
A green run here says nothing about either.
configfile: pytest.ini
testpaths: tools/test_net_test_env.py, tools/test_package_verify.py, tools/test_pytest_boundary.py
collected 30 items
tools/test_net_test_env.py .............. [ 46%]
tools/test_package_verify.py ............ [ 86%]
tools/test_pytest_boundary.py .... [100%]
============================== scope of this run ===============================
c64-https: pytest runs ONLY the pure-logic host-side modules pinned in
[...]
============================== 30 passed in 0.14s ==============================

pytest tests/ now ends with:

pytest collected nothing from the paths you gave it.
If that was `pytest tests/`: tests/ holds manual live-rig scripts
(tests/rig_*.py, main() programs needing sudo and a network rig), not
pytest tests. See tests/README.md for how to run them.
============================ no tests ran in 0.00s =============================

One honest limitation, measured rather than glossed:testpaths applies only at the rootdir, so pytest from a subdirectory collects that subdirectory instead — from tools/ it is 30 passed + 74 fixture errors, exit 1. That is left as-is deliberately. It is loud, and it is correct: those modules really cannot run under pytest. Suppressing it with collect_ignore_glob would trade a true red for a manufactured green. README and CLAUDE.md both state this rather than claiming determinism the config does not provide.

Guard verified by probe, not by assertion

$ printf 'x = 1\n' > tests/test_bogus_guard_probe.py; python3 tools/test_pytest_boundary.py
FAIL test_tests_dir_holds_no_pytest_named_files
tests/ contains pytest-named files ['test_bogus_guard_probe.py'] ...
$ printf 'def test_probe() -> None:\n assert True\n' > tools/test_bogus_guard_probe.py
FAIL test_testpaths_lists_every_runnable_tools_module
these tools/ modules are pytest-runnable but absent from pytest.ini
testpaths: ['tools/test_bogus_guard_probe.py'] ...

Both probes removed afterwards. The guard also correctly classifies tools/test_net_test_env.py as runnable (it is a unittest.TestCase module whose extra method arguments come from @mock.patch, not from fixtures) — an earlier, cruder version of the rule got that wrong and the probe caught it.

Why not option 2 (pytest wrappers + pytest.skip())

Two reasons, and the second is the load-bearing one:

  • It would introduce a pytest dependency the repo does not declare. requirements.txt is cryptography plus a comment; the documented runner is python3 tools/run_all_tests.py. Wrapper modules that import pytest would make a currently-optional tool mandatory for a test tree that pytest cannot run anyway.
  • A rig test reported as skipped is exactly the signal this repo has been removing. fix(package): close the vacuous pass in the release gate (#98 landed only 1 of 4 commits) #101 fixed a release gate that said RELEASE ARTIFACTS VERIFIED having checked nothing. "0 network tests ran" and "5 network tests skipped for a reason nobody read" are the same fact with different amounts of reassurance attached. The rename plus an unmissable scope banner says it once, in plain language, on every single run.

Reference sweep

grep -rn 'tests/test_' is clean. Updated: README.md (x5 plus a new "pytest is not the runner here" subsection), CLAUDE.md (x4 plus a boundary subsection under Smoke tests, and the C64_SKIP_BUILD grep recipe now globs tests/rig_*.py), docs/library-ingestion-architecture.md, tools/https_e2e/__init__.py, tools/https_e2e/certs/README, and 6 docstring/comment references inside the rig scripts themselves.

No Makefile target, no CI (the repo has no .github/), and no reference anywhere in the sibling c64-test-harness repo (grepped the whole tree; zero hits). tests/rig_phase1_dhcp.py was executed post-rename and still resolves its own paths correctly, skipping loudly: SKIP: missing prerequisites: ip not on PATH; iptables not on PATH; sudo requires a password.

Scope

Python and docs only — no .s, no .cfg, no Makefile, so the build-evidence bar does not apply and no PRG changes. .gitignore gains .pytest_cache/.

Adjacent, deliberately not fixed here:tools/uci/test_*.py have the identical shape (rig scripts named test_*.py collecting zero). norecursedirs closes the misleading path for the default invocation, but the honest fix is the same rig_ rename, and that blast radius (CLAUDE.md documents those scripts extensively) belongs in its own PR.

🤖 Generated with Claude Code

…onger implies coverage it does not have
Closes#109.
tests/ held five live-rig scripts named test_*.py with no `def test_` in
them, so pytest walked the directory, collected zero, and reported
nothing about it. The pass count a bare `pytest` printed came entirely
from tools/ and read like whole-project coverage.
Measured on master (pytest 9.0.3, run from the repo root):
$ pytest -> 25 passed, 75 errors exit 1
$ pytest tests/ -> no tests ran exit 5
The 75 errors are `fixture 'transport' not found`: tools/test_*.py is not
a pytest suite either. Its `test_*` functions take positional
(transport, labels) supplied by tools/run_all_tests.py, and the repo has
no conftest.py and declares no pytest dependency. So the true defect is
wider than reported — nothing here is meaningfully pytest-shaped, and
what a bare `pytest` prints depends on which interpreter and plugins
happen to be around.
Fixed by making the boundary explicit rather than accidental:
- tests/test_*.py renamed to tests/rig_*.py. A rename holds from any
working directory; testpaths only applies at the rootdir, so config
alone could not do this.
- pytest.ini pins testpaths to the three modules pytest can genuinely
run, and keeps collection out of libs/, ip65/, tests/ and tools/uci/
(all test_*.py-named, all collecting zero).
- conftest.py states the scope of the run in the header and again
above the summary line. No skips: a vague skip reads like coverage,
which is the thing being removed.
- tools/test_package_verify.py::test_parse_build_info_records took a
`tmp_lines` argument its own main() supplied, so it was the one
erroring test in an otherwise pure-logic module. Defaulted.
- tools/test_pytest_boundary.py guards both drift directions: a
pure-logic module missing from testpaths, or a test_*.py file
reappearing in tests/. Verified by probe in both directions.
After:
$ pytest -> 30 passed exit 0
$ pytest tests/ -> no tests ran exit 5, now explained
Every reference to the renamed scripts updated (README, CLAUDE.md,
docs/, tools/https_e2e/, and their own docstrings); `grep -rn 'tests/test_'`
is clean. No .s, .cfg or Makefile change, so no build evidence applies.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JC-000
JC-000 merged commit 3a43f61 into masterAug 15, 2026
JC-000 added a commit that referenced this pull request Aug 15, 2026
…st measurement
Two deferred follow-ups, both left with reasons recorded by earlier lanes.
PR #111 renamed tests/test_*.py -> tests/rig_*.py because those files wear
pytest's discovery convention while collecting zero tests, so a bare `pytest`
overstates coverage. It explicitly deferred the identical problem in
tools/uci/, whose blast radius runs through CLAUDE.md. This is that change:
six scripts renamed, following #111's precedent exactly.
`norecursedirs` already closed the default-invocation path, but the rename is
what holds from an arbitrary working directory, since `testpaths` only applies
at the rootdir. Both halves are now pinned by the guard rather than one.
tools/test_pytest_boundary.py grows a RIG_DIRS tuple covering both directories
and a new test_norecursedirs_covers_every_rig_dir. Probed in both directions:
a stray tools/uci/test_*.py fails test_rig_dirs_hold_no_pytest_named_files, and
dropping tools/uci from norecursedirs fails the new check. Bare `pytest` is now
31 passed / exit 0; `pytest tests/` and `pytest tools/uci/` both exit 5 with an
explanation naming the right README.
New tools/uci/README.md mirrors tests/README.md. Reference sweep covers
CLAUDE.md, README.md, conftest.py, pytest.ini, tests/README.md, both phase_f
docs, tools/https_e2e/, tools/package/listener/, and the cross-invocations
(rig_https_print_body and rig_https_local_p384 both delegate to
rig_https_local; import-checked after the rename). Zero references exist in
c64-test-harness or any other sibling c64-* repo, and no open issue or PR in
the org names any of the six.
README was audited claim-by-claim in #104; CLAUDE.md never was. Every item
below was confirmed by running a command, not by reading:
- The ip65 blob section contradicted itself and the Makefile. It claimed
`ip65-blob` is phony with "no rule connecting the two" and that a fresh
clone dies at the `.incbin`. Measured: deleting the blob and running plain
`make` rebuilds it byte-identically (6,951 B, cf1a5ff7...) and links the
usual 47,105 B PRG. The real fresh-clone blocker is the missing ip65 .lib
archives, which fail at the blob's ld65 step. `make ip65-blob` is not a
required step. Fixed here and in README.
- "the committed blob" / `touch ip65-build/ip65-c64.bin` advice: the blob is
gitignored, so on a fresh clone there is nothing to touch.
- Fence macro: 17 bytes per site, not 14 (counted from the macro body).
26 sites, not 24 — 11 write + 14 read + 1 pre-loop settle at net.s:199.
- uci_drain_resp/uci_drain_status: 22 call sites in net.s, not 13. All 22
still `bcs` out, so that half was right.
- CIA1 TOD read order is HOUR -> TENTHS; MIN and SEC are never read and have
no equate in uci_cmd.s.
- UCI memory table: NET_CODE is $2000-$3B65 and NET_BSS_TAIL $3B66-$41FF
(cfg grew NET_CODE by $40 for the C64U WiFi iface-fallback loop).
UCI_BSS_REGION is size 0, so quoting a 512 B span contradicted its own
label and overlapped NET_BSS_TAIL.
- src/exports.s no longer exports ip65_init/ip65_process; those moved to
src/net/ip65/exports.s. Named the real backend-agnostic set instead.
- KEEP_DEBUG_ON_PASS is the env var; UCI_DEBUG_KEEP_ON_PASS is only the
Python variable name, so the documented spelling did nothing.
- rig_https_local_p384.py defaults to 90 minutes (5400 s), not 30.
- C64_SKIP_BUILD: 15 scripts, not 14 (ran the recipe the file itself quotes).
- EMBED_P256_OVERLAY's stated mutual exclusions have no $(error) guard: one
is a silent auto-disable, the other is unguarded. Only the
USE_NISTCURVES_ONCHIP exclusions are hard errors.
- "All in-tree VICE-driven tests go through default_vice_config()" is false:
8 suites do, 15 other files still build ViceConfig directly — including
run_all_tests.py, which hand-spells the REU flags.
- Smoke-test list is 8 entries, not 7, and the 97/97 total predates
test_finished_verify.py joining it.
- pytest counts 30 -> 31; src/boot.s:107-114 -> 110-117.
Wall-clock figures are labelled rather than re-measured, per instruction: a
pin-vs-commit table resolves 2ceb5b1 / f0127a0 / cb6eab4 to libs/nistcurves
v0.6.0 (verified with git ls-tree), every table states the pin it was taken
at, and "at HEAD" captions that no longer refer to HEAD are gone. No hardware
was used and no benchmark was re-run.
Deliberately NOT included: the x25519 sibling failure. It is measured and
understood (both backends exit 2 on a duplicate reu_mul_tables_init export,
not the overflow CLAUDE.md records), but PR #113 rewrites the same spans and
fixes the underlying collision, so the prose belongs there. Details handed to
that lane. docs/library-ingestion-architecture.md's two stale references are
owned and taken by the contract lane.
Python and docs only — no .s, no .cfg, no Makefile, so no PRG changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tests/ contains no pytest-collectable tests — 'pytest' at repo root reports 154 passed while the five rig tests contribute zero

1 participant

@JC-000