# VALVET — testing guide Structured pytest workflow for this repo. **Primary development OS is Windows 11** (daily runs). **Fedora 43** (or GitHub `ubuntu-latest`) remains the Linux regression check for paths, optional Step 3D / VTK, and Hanwha `.mdb` via **mdbtools** vs Windows ODBC — see [hanwha_mdb_editor.md](hanwha_mdb_editor.md). Canonical commands below assume repository root `VALVET/` (the folder containing `src/`, `tests/`, `requirements.txt`). --- ## Level 0 — environment 1. Create a venv at repo root: **Windows** `python -m venv .venv` (or `py -3 -m venv .venv`); **Fedora** `python3 -m venv .venv`. 2. Upgrade pip: `python -m pip install -U pip` (use the venv’s `python`). 3. Install deps: `python -m pip install -r requirements.txt` Dev lint/coverage: `python -m pip install -r requirements-dev.txt` (`pytest-cov`, `pytest-mock`, `pytest-qt`, `pip-audit`, `ruff`, `vulture`). Optional Step 3D viewer tests: `python -m pip install -r requirements-step3d.txt`. 4. **PYTHONPATH=src** is required for `python -m compileall`, `python src/main.py --smoke`, and subprocess tests. Pytest from the repo root already adds `src` via `[tool.pytest.ini_options] pythonpath` in [`pyproject.toml`](../../pyproject.toml); setting the env var locally still matches CI. ### Lint (optional) Install dev tools: `python -m pip install -r requirements-dev.txt` ([`requirements-dev.txt`](../../requirements-dev.txt)). ```bash export PYTHONPATH=src # Windows: $env:PYTHONPATH = "src" python -m ruff format src tests python -m ruff check src tests python -m vulture ``` CI runs `ruff check` (rules `E`/`F`/`I`/`ICN`, ignoring E501/E402/E741) and `ruff format --check` (Black-compatible: 88 columns, double quotes), plus `python tools/version.py check` which fails the build when a tracked file still hardcodes a stale version. Import aliases and block order: [IMPORTS.md](IMPORTS.md). **Vulture is blocking** in GitHub Actions since 0.5.1.1 (Qt slots and dynamic calls still produce false positives at `min_confidence = 80`, so expect to whitelist rather than ignore). Hypothesis and mutmut are **not** part of the default stack. [`pyproject.toml`](../../pyproject.toml) configures Ruff, Vulture, and coverage omit for GUI packages (`src/app/`, `src/ui/`, `src/hanwha_mdb_edit/gui/`). Lower vulture confidence (`--min-confidence 60`) reports many more false positives. **Windows (PowerShell 7):** ```powershell cd VALVET .\.venv\Scripts\python.exe -m pip install -r requirements.txt $env:PYTHONPATH = "src" ``` **Fedora / Bash:** ```bash cd VALVET source .venv/bin/activate python -m pip install -r requirements.txt export PYTHONPATH=src ``` `tests/conftest.py` sets `QT_QPA_PLATFORM=offscreen` before PySide6 imports so headless/CI runs do not need a display. --- ## Invariant rules (code + tests) These apply to new production code **and** to pytest. A silent pass that hides a fallback, a leaked ODBC handle, or a `QThread` poking widgets is a failed review even if CI is green. ### Silent fallback → `logger` A fallback (pygerber→gerbonara, ACE→mdbtools, path→legacy dir, regex after vendor PN) **must** log at `warning` (or `info` if the path is expected and frequent). Tests must spy `logger.warning` / `logger.info` and assert the message names the backend that failed **and** the one that ran. Bare `except: pass` or swallowing `AccessOdbcError` without a log is a defect. Existing: [`tests/test_logger_fallbacks.py`](../../tests/test_logger_fallbacks.py) (`test_gerber_gerbonara_fallback_logs_warning`, `test_hanwha_odbc_fallback_logs_warning`). Spy the logger with `pytest-mock`; do not replace it with a silent stub: ```python warn_spy = mocker.spy(logger, "warning") # … run the fallback path … assert warn_spy.called msg = str(warn_spy.call_args.args[0]).lower() assert "gerbonara" in msg or "pygerber" in msg ``` ### QThread → UI only via Qt signals Worker `run()` must not touch widgets, models, or `QMessageBox`. Results, errors, and progress go out as `Signal` (`result_ready`, `progress`). The GUI slot runs on the GUI thread (`QueuedConnection`). Tests start the thread, pump events / `wait()`, and assert state **after** the slot — not by calling widget methods from the worker. Existing pattern: [`src/app/workers.py`](../../src/app/workers.py), [`tests/test_workers_threads.py`](../../tests/test_workers_threads.py), [`tests/test_cross_check_thread.py`](../../tests/test_cross_check_thread.py), [`tests/test_machine_lib_open_reload.py`](../../tests/test_machine_lib_open_reload.py). House style is `QApplication.processEvents()` plus `thread.wait(…)` (see `_wait_thread`), then `deleteLater()`. `pytest-qt` is in `requirements-dev.txt` but **no test uses `qtbot` today**. If you add `qtbot.waitSignal(thread.result_ready, timeout=…)` around `thread.start()`, that is an allowed alternative — still no `qtbot` clicks inside `QThread.run()`. ### Do not mix bare numbers and objects A `Signal` payload, a preview tuple, or a return value is one contract: either a typed object (`DataFrame`, `FootprintBuildResult`) **or** a documented sentinel (`None` + `str` error), not `0` vs `{}` vs `""` for the same slot. Tests should `isinstance` the success object and treat error as a string. Packed tuples (`payload, image, viewbox`) must keep a fixed length and types — see `GerberLoadThread.result_ready`. ### SI units: `SiQuantity` vs `float` [`src/parsers/si_units.py`](../../src/parsers/si_units.py) returns `SiQuantity` (`quantity_farads`, `quantity_ohms`, `quantity_volts`). Callers that do arithmetic must not add a raw `float` to a `SiQuantity` (and must not pass a quantity into millimetre geometry). Geometry (`um_to_mm`) stays **floats**. Tests: invalid tokens return `None`; successful parses compare `.to("nanofarad").magnitude` (etc.). Existing: `test_si_*` in [`tests/test_parser_p0_vendor_off.py`](../../tests/test_parser_p0_vendor_off.py) and [`tests/test_si_units.py`](../../tests/test_si_units.py). ### natsort on dirty data Natural sort is not in the runtime stack (`natsort` was unused and removed). If a local natsort helper is added later, tests must feed a dirty list (`R1`, `R10`, `""`, `None`, `12`) and expect a stable list — never an uncaught `TypeError`. ### File import validation Loaders (`read_file`, `_load_bom` / `_load_pnp`, Gerber, `.mdb`) must reject empty paths, missing files, and unreadable/unsupported content with a logged error or `SMTProcessorError` / equivalent — not a half-filled table. Tests: `tmp_path` missing file; optional tiny corrupt bytes. Happy path: [`tests/test_bom_pnp_window_load.py`](../../tests/test_bom_pnp_window_load.py) (`force_original=True`). Live `.mdb`: [`tests/mdb_paths.py`](../../tests/mdb_paths.py) `skip_if_mdb_unreadable`. ### pyodbc connections must close Every `connect_mdb` needs `close()` in `finally` (see [`import_mdb_to_cache`](../../src/machine_library/hanwha_sqlite_cache.py)). Tests that mock `pyodbc.connect` / `connect_mdb` should assert `close` was called on the connection mock after the function returns (success **and** mid-loop failure). Existing: [`tests/test_access_odbc.py`](../../tests/test_access_odbc.py), [`tests/test_hanwha_save_odbc.py`](../../tests/test_hanwha_save_odbc.py) (`conn.close.assert_called_once()` after insert failure), [`tests/test_hanwha_sqlite_cache.py`](../../tests/test_hanwha_sqlite_cache.py) (`test_import_mdb_to_cache_closes_odbc_on_mid_loop_failure`). Do not leave pooling on (`pooling = False` in [`access_odbc.py`](../../src/machine_library/access_odbc.py)). ### PySide6: no leftover widgets / cursors / threads Headless tests: `try`/`finally` `win.close()`; if a tab set a wait cursor, `restoreOverrideCursor()`. `QThread`: `wait()` then `deleteLater()` (or equivalent) so the worker is not destroyed while `run()` is live. Do not create a second `QApplication` if one exists (`QApplication.instance()`). Module-scoped `qapp` in [`tests/test_app_startup.py`](../../tests/test_app_startup.py) is the model. ### No hardcoded machine paths Production code uses [`src/app_paths.py`](../../src/app_paths.py) (`user_state_dir`, `hanwha_lib_cache_dir`, platformdirs). Tests use `tmp_path` / `tmp_path_factory` and repo-relative helpers ([`tests/mdb_paths.py`](../../tests/mdb_paths.py) — `examples/UPD.MDB` or `../UPD.MDB`), never `C:\Users\...` or `/home/runner/...`. Monkeypatch `hanwha_lib_cache_dir` when a test would otherwise write under the real profile dir ([`tests/test_machine_lib_open_reload.py`](../../tests/test_machine_lib_open_reload.py)). ### Mocking: `monkeypatch` vs `pytest-mock` - **`monkeypatch.setenv` / `delenv`:** env flags (`VALVET_DEBUG`, `BOOMER_MISSING_TOKENS_LOG`, `QT_QPA_PLATFORM`). - **`monkeypatch.setattr`:** replace a function or module attribute for the duration of one test (backends, `read_pickle`, `QMessageBox`). - **`mocker.spy` / `mocker.patch`:** assert call counts and mock objects (`logger.warning`, `conn.close`). Requires `pytest-mock` from `requirements-dev.txt` (CI installs it; a bare `requirements.txt` venv will error with `fixture 'mocker' not found`). ### Import aliases (PEP 8 + Ruff `I` / `ICN`) Full policy: [IMPORTS.md](IMPORTS.md). Short version: `numpy` as `np`, `pandas` as `pd`; never `from PySide6… import *`; Qt modules stay `QtWidgets` / `QtCore` / `QtGui` (no `qtw`); long first-party modules use the table in IMPORTS.md (`hanwha_cache`, `mdbtools`, `upd_fp`, …); do not alias `platformdirs` to save letters; PyPI `regex` goes through `parsers.regex_api`, not `import regex as re`. Tests must follow the same import blocks so Ruff `I` stays green. --- ## Level 1 — fast smoke (every commit / PR) Expect **0 failed**. Record `passed` / `skipped` / OS / Python version when updating [TODO.md](../TODO.md). ```bash python -m compileall -q src python -m ruff check src tests python -m ruff format --check src tests python tools/version.py check python -m pytest tests -q ``` CI no longer runs one monolithic `pytest tests`. `lint` covers compileall, both Ruff steps, `tools/version.py check`, the core-has-no-PySide6 tests, the smoke run, Vulture (blocking) and pip-audit (advisory). The suite is split into four shards per operating system by the timings committed in `.test_durations`, and coverage is sharded the same way and combined before the report. Locally the single-command form above is still the fastest way to see everything at once. **Regenerate `.test_durations`** after adding or renaming test files, otherwise shard balance drifts silently (a slow test lands in a shard that was short when the timings were recorded): ```bash python -m pytest tests -q --ignore=tests/test_pcb_preview_gerber.py \ --splits 4 --group 1 --durations-path .test_durations --durations-min 0.01 ``` `--group 1` runs one shard but records timings for **every** test. Coverage locally (no fail-under; omit GUI paths via `[tool.coverage.run]`). `[tool.coverage.report] skip_covered = true` is in pyproject. CI uses `--cov-report=term` only, and — unlike earlier revisions — includes the Gerber tests, because excluding them left that pipeline permanently uncovered while the report still looked complete: ```bash python -m pytest tests -q --cov=src --cov-report=term-missing:skip-covered ``` **`--debug` vs the Debug dialog:** `python src/main.py --debug`, `VALVET_DEBUG=1`, and the Project tab **Debug logs** checkbox all call `logger.set_debug_mode` (dated file under `logs/` plus stderr). Unchecking the box turns file logging off. **Debug / advanced…** is a separate dialog (snapshots, fonts, experimental tabs). CLI: `python -m cli --debug …`. Last recorded baseline (re-run after changes): **1265 passed, 7 skipped** on Windows 11 / Python 3.12 / PySide6 6.11.2 for the full `pytest tests -q` run (Gerber included). The skips are the documented optional fixtures in Level 3 — live `UPD.MDB`, `example6`, `textual`. Re-record `passed` / `skipped` after a local run when updating this file. Registered pytest marker (only this one): `slow` — live Hanwha `UPD.MDB` import ([`pyproject.toml`](../../pyproject.toml) `[tool.pytest.ini_options] markers`). CI **does not** pass `-m "not slow"`; it runs the same Level 1 command as above (slow tests skip unless a sample `.mdb` is present). Locally, to skip live MDB import: ```bash python -m pytest tests -q --ignore=tests/test_pcb_preview_gerber.py -m "not slow" ``` There is **no** `@pytest.mark.smoke`. App smoke is `python src/main.py --smoke` (CI step), not a pytest marker. ### Level 1b — app startup (headless PySide6) Automated smoke for **main-window tab construction** and **debug dialogs** (no file dialogs, Hanwha editor, or recovery prompts): ```bash export PYTHONPATH=src # Windows: $env:PYTHONPATH = "src" python -m pytest tests/test_app_startup.py -q ``` Uses `tests/conftest.py` (`QT_QPA_PLATFORM=offscreen`). `MainWindow(settings=…)` accepts an isolated `QSettings` Ini file so tests do not touch the user registry. **PCB Preview is always created.** Step 3D is optional: **8** tabs when `experimental/enable_step_3d` is false; **9** when true. Step 3D tab construction does not require `requirements-step3d.txt`; VTK mesh load is not exercised here. GUI-free service unit tests: `tests/test_services.py` (same Level 1 run). Parser / alert regressions added to Level 1: ```bash python -m pytest tests/test_clean_alerts.py tests/test_pn_vendor_verified_rules.py -q ``` --- ## Level 2 — Gerber core in isolation Since the coverage job dropped the `--ignore` (and Level 1 no longer excludes Gerber either), a plain `pytest tests -q` already runs everything Level 2 used to add. It is kept as a named target because the Gerber suite is the only thing that exercises the `src/pcb_preview/` raster pipeline, so run it on its own after any edit there: ```bash export PYTHONPATH=src # or $env:PYTHONPATH="src" on Windows python -m pytest tests/test_pcb_preview_gerber.py -q ``` Run **on Windows 11 and on Linux** (Fedora 43 locally or `ubuntu-latest` in CI) so Gerber + path behavior stays aligned. --- ## Level 3 — skips and optional deps (CI matrix expectations) | Mechanism | Location / reason | CI expectation | | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `@pytest.mark.skipif` — `UPD.MDB` missing | `tests/test_hanwha_mdbtools.py`, `tests/test_hanwha_mdb_edit_core.py`, `tests/test_hanwha_import_mdb_to_cache.py`, `tests/test_hanwha_upd_geometry_load.py`, `tests/test_machine_lib_open_reload.py` | **Skip** unless a sample `.mdb` is placed where tests expect it; do not treat as failure. Live import is `@pytest.mark.slow`. | | `@pytest.mark.skipif` — example6 xlsx missing | `tests/test_pn_example6.py` | **Skip** if `examples/example6/original_gen3_bom.xlsx` absent. | | `pytest.skip(...)` — example6 / cmp missing | `tests/test_clean_component.py`, `tests/test_smt_processor_formats.py` | **Skip** at runtime if paths missing. | | `pytest.importorskip("PySide6")` | Many Qt tests; `tests/test_qsettings_bom_pnp_persist.py`, `tests/test_working_copy_ui.py`; **one test** in `tests/test_hanwha_column_labels_and_filters.py` and `test_step_3d.py` | Minimal env **without** PySide6: those skip; full `requirements.txt` includes PySide6 — CI should install full requirements. | | `pytest.importorskip("textual")` | `tests/test_tui_app.py` | **Skip** unless `requirements-cli.txt` is installed; default CI uses `requirements.txt` + `requirements-dev.txt` only. | | `pytest.skip(...)` — spreadsheet fixtures | `tests/test_read_file_spreadsheet.py` | **Skip** if `tests/assets/bom.xlsx` / `.xls` / `.ods` are absent; CSV whitespace assets in `tests/assets/` are required. | | `pytest.importorskip("pandas")` / `openpyxl` | Several tests | Use full `requirements.txt` in CI. Spreadsheet loaders use pandas + **calamine** with openpyxl/xlrd/odf fallbacks. | Fedora / Wayland: for **interactive** Step 3D (not pytest), operators may need `QT_QPA_PLATFORM=xcb` — see [README.md](../../README.md). Passing Step3D-related tests on Windows alone is **not** sufficient proof for Linux VTK stacks. --- ## Level 4 — module → pytest mapping When you touch an area in the table below, run the matching tests first. | Area | Suggested pytest targets | | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | | Clean BOM / regex master | `tests/test_clean_component.py`, `tests/test_clean_arbiter.py` | | SMT / merge / XY / cross-check | `tests/test_smt_processor.py`, `tests/test_smt_processor_formats.py`, `tests/test_duplicate/` | | Spreadsheet / whitespace readers | `tests/test_read_file_spreadsheet.py`, `tests/test_read_text_whitespace.py` | | MMD export | `tests/test_mmd_export.py` | | Yamaha `.Tou` / DevLib | `tests/test_yamaha_tou.py`, `tests/test_yamaha_devlib.py` | | Hanwha `.mdb` (Qt-free tools) | `tests/test_hanwha_mdbtools.py` | | Hanwha SQLite cache / import | `tests/test_hanwha_sqlite_cache.py`, `tests/test_hanwha_import_mdb_to_cache.py` | | UPD footprint builder | `tests/test_upd_footprint_builder.py`, `tests/test_hanwha_upd_geometry_load.py` | | Machine Lib tab (headless Open) | `tests/test_machine_lib_open_reload.py` | | Paths / session links | `tests/test_app_paths.py` | | HTML report | `tests/test_report_html.py` | | UI i18n catalogs | `tests/test_ui_i18n.py` | | Step 3D (conversion + optional mesh) | `tests/test_step_3d.py` | | Gerber → SVG core | `tests/test_pcb_preview_gerber.py` | | Vendor PN / example6 | `tests/test_pn_example6.py`, `tests/test_use_vendor_gate.py` | | Clean BOM golden corpus | `tests/test_clean_corpus_golden.py`, `tests/test_clean_corpus_harvest.py`, `tests/test_hanwha_partname_filter.py` | | GUI-free services (`src/services/`) | `tests/test_services.py` | | App startup / tabs (headless Qt) | `tests/test_app_startup.py` | | Silent fallback logging | `tests/test_logger_fallbacks.py` | | Access ODBC (no timeout / close) | `tests/test_access_odbc.py`, `tests/test_hanwha_save_odbc.py` (`close` on save failure) | | Workers / QThread | `tests/test_workers_threads.py` | | Core has no PySide6 (CI step) | `tests/test_no_pyside6_core.py`, `tests/test_cli_no_qt.py` | | `src/main.py --smoke` subprocess | `tests/test_main_smoke.py` | | SI helpers | `tests/test_parser_p0_vendor_off.py` (`test_si_*`), `tests/test_si_units.py` | | BOM/PnP load via MainWindow | `tests/test_bom_pnp_window_load.py`, `tests/test_cli_pipeline.py` | | PCB Preview tab Gerber layer | `tests/test_pcb_preview_tab_gerber.py` | | Working-copy / autosave pickle | `tests/test_working_copy.py`, `tests/test_working_copy_ui.py` | | Cross-check window / thread / Return | `tests/test_cross_check_window.py`, `tests/test_cross_check_thread.py`, `tests/test_merge_cross_check_routing.py` | | Merge layer display vs PCB side | `tests/test_layer_side.py` | | Qt table roles / nested Qt modules | `tests/test_clean_preview_model.py`, `tests/test_qt_nested_module_typos.py` | | Worksheet visibility / sheet picker | `tests/test_read_file_sheets.py`, `tests/test_sheet_picker.py` | | BOM row highlight (visual tint) | `tests/test_bom_highlight.py` | | `valvet.spec` bundled data files | `tests/test_frozen_bundle_data.py` | | Clean comment-join separator | `tests/test_comment_join_separator.py` | | Clean comment role detection | `tests/test_comment_roles.py` | | Version single source of truth | `tests/test_version.py` | | Vendor codecs vs datasheet | `tests/test_pn_vendor_walsin_murata.py`, `tests/test_pn_vendor_verified_rules.py`, `tests/test_parser_generated.py` | **Clean BOM golden corpus** (`tests/fixtures/clean_corpus/`): ```powershell $env:PYTHONPATH='src' python tools/clean_corpus.py mdb-export --mdb examples/UPD.MDB python tools/clean_corpus.py harvest --limit 500 python tools/clean_corpus.py draft python tools/clean_corpus.py test python tools/clean_corpus.py report pytest tests/test_clean_corpus_golden.py -q ``` On failure, `report` prints structured got/want blocks for each `id`. Rebuild Hanwha snapshot after `UPD.MDB` changes. See `tests/fixtures/clean_corpus/README.md`. **Clean BOM preview tracing:** set `BOOMER_CLEAN_PREVIEW_LOG=1` in the environment before launching the app; each **Convert!** run logs one INFO line per preview row (`row`, `type`, `source`, `cleaned`, truncated `Original`). **Missing-token alerts (non-blocking):** - Preview now includes `Alert` column (`missing=...;present=...`), including regex-master mode (`Arbiter`, `Win%`, `Alert`). - Missing-token events are appended to JSONL log: - default: `/logs/missing_tokens.jsonl` - override: env `BOOMER_MISSING_TOKENS_LOG=/absolute/path/missing_tokens.jsonl` - Designed for triage/debug; does not block Clean Apply. Full discovery (every `test_*.py`, not only this table): `python -m pytest tests --collect-only -q`. --- ## Debugging (local) ```bash python -m pytest tests -x --pdb -s # stop at first failure, debugger python -m pytest tests --durations=10 -q # ten slowest tests python -m pytest tests -m slow -q # live UPD.MDB import only python -X dev -m pytest tests/test_app_startup.py -s # extra checks; Qt/C++ aborts still need a debugger ``` `python -X dev` helps with ResourceWarnings and some interpreter checks. It does **not** turn a PySide6 native crash into a Python traceback by itself. --- ## Level 5 — backlog (not required for green pytest today) Tracked in [TODO.md](../TODO.md) in more detail: - **Invariant gaps:** natsort dirty-input fixture (when natural sort is added); missing-file / corrupt import tests for BOM/PnP/Gerber beyond happy path. ODBC `close()` on Hanwha save failure is covered by [`tests/test_hanwha_save_odbc.py`](../../tests/test_hanwha_save_odbc.py). - **Machine library / Yamaha:** Qt-free services + fixtures before large UI wiring (Phase 5). - **Desktop entry `src/main.py` / `src/app/window.py`:** BOM/PnP load, language, and file dialogs still need manual smoke; main tabs and debug dialogs are covered by `tests/test_app_startup.py` (Level 1b). Headless load-by-path: `tests/test_bom_pnp_window_load.py`. Optional future `pytest-qt` for modal file/recovery flows (`pytest-qt` is in `requirements-dev.txt`). `pytest-mock` is in `requirements-dev.txt` for spies; keep `monkeypatch.setenv` for env vars. `src/app_pyside6.py` is a deprecated import shim. --- ## OS reconciliation (Fedora vs Windows) After **large** changes (paths, `app_paths`, `pcb_preview`, `step_3d`, `machine_library`, subprocess/converter templates): 1. Run **Level 1** and **Level 2** on **Windows 11** (primary dev machine). 2. Repeat on **Fedora 43** (or Linux CI job) with the same Python major as CI when possible. 3. Compare `passed` / `skipped` and failure output. If counts differ only due to optional fixtures (`.mdb`, example6), document that next to the numbers in [TODO.md](../TODO.md). 4. If behavior diverges by OS, file a note in [TODO.md](../TODO.md) and/or open an issue with both logs. --- ## GitHub Actions Workflow [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml) runs on push/PR to `main`/`master` as three jobs plus a `concurrency` group that cancels superseded runs on the same ref. **`lint`** (ubuntu-latest, gates everything else): `compileall`, `ruff check`, `ruff format --check`, `tools/version.py check`, **Core has no PySide6** (`tests/test_no_pyside6_core.py`, `tests/test_cli_no_qt.py`), `src/main.py --smoke`, **Vulture (blocking)**, and `pip-audit -r requirements.txt` (advisory, `continue-on-error`). **`test`** (matrix `os` × 4 shards, `needs: lint`): each shard runs the whole suite through `pytest-split`: ```bash python -m pytest tests -q \ --ignore=tests/test_pcb_preview_gerber.py \ --splits 4 --group $N --durations-path .test_durations ``` **`coverage`** (4 ubuntu shards) runs the same sharding **without** the Gerber ignore and with `--cov=src --cov-append --cov-report=`, writing `coverage-shardN` (deliberately not dot-prefixed — `actions/upload-artifact@v4` skips dotfiles and an empty artifact trips `if-no-files-found` instead of merging). **`coverage-report`** needs it, runs `coverage combine` over the four files and prints the report. Regenerate `.test_durations` after adding or renaming tests — see Level 1. ### Release Windows [`.github/workflows/release-windows.yml`](../../.github/workflows/release-windows.yml) is `workflow_dispatch` only. It takes the version from `src/__version__.py` (the dispatch input is a consistency check, never the shipped value), builds the onedir folder, **zips before smoke**, gates the archive contents against 22 forbidden patterns, smoke-tests the *unpacked* zip, attests provenance, uploads the zip plus a 90-day `zip-manifest.txt`, and attaches the zip to a GitHub Release (draft by default). The zip-before-smoke ordering is deliberate: a frozen run recreates `logs/` next to the exe, and a zip built afterwards would ship the user's absolute paths. `tests/test_frozen_bundle_data.py` asserts the spec's `datas` entries and reads the forbidden-pattern list out of the workflow, so a gate that silently stops matching fails the suite.