From b3956468636d9a9149e14278593989ab2cb67a7a Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:09:22 -0700 Subject: [PATCH 1/7] docs: guidance on what to keep current when the code changes AGENTS.md, for any maintainer or coding agent: the rule that an unpublished value is None, never a constant; the tables a new snapshot field or adapter read must reach (field metadata and discovery, conformance, the public API and the adapter fidelity harness); how the harness rules, pinned drops and capture cells work; the expected-failure registry; and the byte-pinned captures and the reference tree. It points to DEVELOPMENT.md and RELEASE.md for procedures. CLAUDE.md imports it. --- AGENTS.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 60 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..85bba3d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# Working on span-panel-api + +Guidance for anyone changing this repository, person or coding agent. It lists what has to be kept up when the code changes; the procedures themselves live in [DEVELOPMENT.md](DEVELOPMENT.md) (setup, tests, captures, conformance) and +[RELEASE.md](RELEASE.md) (versions, floors, tags, pre-releases). Read those before changing a capture, a dependency floor or a version. + +## The rule every field follows + +A value the panel did not publish is `None` (or unknown), never a constant. A `0.0`, `False`, `""` or empty collection in a snapshot field is a reading the panel published, or an answer the library can derive from what the panel declared, and the docstring +of the field says which. Where a field is not yet optional, the adapter fidelity harness records its absence value as an explicit, reasoned exception rather than letting it pass unnoticed. + +## When you add, rename or change a snapshot field or an adapter read + +Every read the schema-1 adapter makes is accounted for in more than one place, and each place fails the suite if it is left behind: + +- **Field metadata and discovery** (`packages/schema-1/src/span_panel_api_schema_1/field_metadata.py`). A reading with a unit gets a `_PROPERTY_FIELD_MAP` row, or a row in a lugs table; a property read into the snapshot without one goes in + `_CONSUMED_WITHOUT_A_ROW`. Otherwise it is reported as discovered and becomes an extension reading. `tests/test_schema_one_discovery.py` proves each entry by perturbing it against the reference tree; an entry the tree does not declare goes in + `_ABSENT_FROM_THE_CAPTURE` with a synthetic experiment of its own. +- **Conformance** (`tests/test_schema_one_conformance.py`). A property no vendored catalog defines goes in `_SPAN_EXTENSIONS`, and one the pinned emitter's reference tree never declares goes in `_NOT_EXERCISED_BY_THE_EMITTER`, each with its reason. +- **Public API** (`tests/test_public_api_unchanged.py`). A new export is added to `__all__` and to the expected set, with a dated note. +- **The adapter fidelity harness** (`tests/adapter_fidelity/`), below. + +## The adapter fidelity harness + +`harness.py` replays a captured device tree through the real adapter, perturbs every declared property, and classifies each one: mapped, an extension reading, intentionally dropped by code that says why, or silently dropped. It then holds every value to a +documented rule: + +- **Transforms** (`rules.TRANSFORMS`): for each row (a device role and a `node/property`) and each snapshot field it moves, how the field follows from the wire value: identity, negation, integer, literal, boolean, or a derivation with the rule stated where + it is simple. Every cell shares them, and `test_every_mapped_field_has_a_documented_transform` requires one for every field-metadata row. Change the transform when you change a read, and give a device whose frame differs its own role rather than bending + a shared row. +- **Derivations**: a field that holds a value no published property moves on its own, such as one decided by a declaration, or a documented elimination. Each cell carries only the derivations its captures need. `test_every_derivation_is_needed` refuses one + that no cell uses, because an unused justification is how a list of reasons becomes an allowlist. `rules.py` holds the derivations for the two captures in `tests/fixtures/`, and `rules_captured.py` holds those for the reference captures. +- **Drops** (`DropReason`): the three kinds of intentional drop, each citing the code. A cell pins every row it drops _with a value_, each with a reviewed reason (`dropped_with_a_value` in `test_main32_fidelity.py`, `DROPPED_WITH_A_VALUE` in + `test_capture_fidelity.py`), so a drop rule that newly covers a published property waits for review. Rows the library drops today but should not are listed in `deferred` with the reason, and a deferral that no longer holds fails like a new silent drop. + +`test_capture_fidelity.py` measures every capture in `tests/fixtures/captures/` and pins by row, never by capture, so a capture added to that directory is covered without editing the tests. `test_harness.py` plants each kind of defect and checks the +harness names it. + +## The expected-failure registry + +`tests/expected_failures.py` lists, by `::`, the acceptance tests this line does not pass yet. `tests/conftest.py` marks each one `xfail(strict=True)`, so: + +- a `PENDING` row is behaviour a later change brings, and that change deletes the row: a listed test that starts passing fails the run until it is; +- a `DIVERGENT` row is behaviour this library deliberately does not have, and stays until the test or the decision changes; +- a row naming anything but a test function of its collected module stops the run. + +Ported acceptance tests are never edited. A strict xfail stops at its first failing line, so a companion file (`*_companions.py`) asserts what each divergent test checks after its divergent line. + +## Vendored captures + +Captures are byte copies and must stay that way: + +- `tests/fixtures/captures/` holds the reference captures with their upstream `LICENSE` and a `README.md` naming the upstream repository, path and commit; `SHA256SUMS` pins every file, and `tests/test_captures_unchanged.py` checks it. The two captures in + `tests/fixtures/` are pinned the same way by `tests/adapter_fidelity/test_main32_fidelity.py`. +- `.pre-commit-config.yaml` excludes them from every hook that rewrites files. Keep any new capture path in those excludes. +- A capture is replaced only by copying it again from its upstream commit and updating the pins, never by editing it. + +The schema-1 reference tree (`packages/schema-1/src/span_panel_api_schema_1/reference/parent_child_tree.json`) is package data, produced by `scripts/capture_parent_child_reference.py` from `scripts/reference_panel.yaml` with the pinned emitter; +DEVELOPMENT.md, "Regenerating a vendored capture", has the procedure. Change the manifest, never the output, and keep every circuit inside the position range `PANEL_POSITIONS_BY_MODEL` gives the panel's model: the adapter reports the table's range and +warns once for a panel whose circuits fall outside it. A changed capture is a release of the adapter that ships it (RELEASE.md). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From 649845b256fdf6f3d39e24bfcfdd85cf00e98508 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:12:05 -0700 Subject: [PATCH 2/7] chore(release): span-panel-api 3.8.0b1 and schema-1 1.4.0b1 span-panel-api 3.8.0b1 and span-panel-api-schema-1 1.4.0b1; schema-0 stays at 1.3.0b1, as on main. schema-1 floors on span-panel-api 3.8.0b1, whose snapshot fields it fills, and the core schema-1 extra floors on 1.4.0b1. The changelogs carry the one line for this release under [Unreleased], and schema-1's records the moved reference tree. --- CHANGELOG.md | 1 + packages/schema-1/CHANGELOG.md | 7 ++++++- packages/schema-1/pyproject.toml | 8 ++++---- pyproject.toml | 6 ++++-- uv.lock | 6 +++--- 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09036d1..4c585be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Snapshot fields that held a fabricated constant can now be unknown, and with `sp ### Added +- **spanos3/r202639/03 batch 2: reads more of the data this firmware publishes.** - **`V2StatusInfo.hardware_version` reports the hardware version string from `GET /api/v2/status`.** ### Changed diff --git a/packages/schema-1/CHANGELOG.md b/packages/schema-1/CHANGELOG.md index 81849b7..ffc8358 100644 --- a/packages/schema-1/CHANGELOG.md +++ b/packages/schema-1/CHANGELOG.md @@ -11,10 +11,15 @@ Pre-releases are not listed separately. A beta is a step towards the next public ## [Unreleased] -Requires `span-panel-api` **3.7.0 or newer**, whose snapshot fields accept the unknown values this parser now reports. +Requires `span-panel-api` **3.8.0 or newer**, whose snapshot fields accept the unknown values this parser now reports and carry the fields it now fills. + +### Added + +- **spanos3/r202639/03 batch 2: reads more of the data this firmware publishes.** ### Changed +- The shipped reference tree is recaptured with every circuit inside the panel's breaker positions. - **`proximity_proven` is `None`**, where it was a hard-coded `True`, because the tree publishes no proximity property. - **`uptime_s` is `None`**, where it was always `0`, because no device publishes an uptime and this parser keeps no readiness clock to measure one. diff --git a/packages/schema-1/pyproject.toml b/packages/schema-1/pyproject.toml index 8f8fac1..c5f434c 100644 --- a/packages/schema-1/pyproject.toml +++ b/packages/schema-1/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api-schema-1" -version = "1.3.0b1" +version = "1.4.0b1" description = "Parent/child schema (data-model-version 1.x) parser for span-panel-api" authors = [ {name = "SpanPanel"} @@ -31,9 +31,9 @@ dependencies = [ # Raised to 3.7.0b1, whose snapshot fields accept the unknown values this # parser now reports: against an earlier one, the update times it leaves # unset read as a constant 0. - # To rise to 3.8.0b1 when that release is cut: this parser fills snapshot - # fields new in it and imports `device_id_order` from its models. - "span-panel-api>=3.7.0b1,<4.0", + # Raised to 3.8.0b1: this parser fills snapshot fields new in it and imports + # `device_id_order` from its models. + "span-panel-api>=3.8.0b1,<4.0", # Only this distribution depends on the eBus SDK. The bootstrap and # schema-0 stay clean, so a flat-panel install never pulls it in — which is # what bounds the release coupling this dependency introduces to panels on diff --git a/pyproject.toml b/pyproject.toml index f2af8a6..caab61c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "span-panel-api" -version = "3.7.0b1" +version = "3.8.0b1" description = "A client library for SPAN Panel API" authors = [ {name = "SpanPanel"} @@ -69,7 +69,9 @@ schema-0 = ["span-panel-api-schema-0>=1.3.0b1"] # Raised to 1.2.2 for 3.6.2: the readiness gate every snapshot passes asks that # adapter what is still missing, and 1.2.2 restarts its feed grace correctly. # Raised to 1.3.0b1 for 3.7.0b1, for the same reason as schema-0's. -schema-1 = ["span-panel-api-schema-1>=1.3.0b1"] +# Raised to 1.4.0b1 for 3.8.0b1: that adapter fills the snapshot fields this +# bootstrap adds. +schema-1 = ["span-panel-api-schema-1>=1.4.0b1"] [project.urls] Homepage = "https://github.com/SpanPanel/span-panel-api" diff --git a/uv.lock b/uv.lock index ea51535..042fa28 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.14, <4.0" [manifest] @@ -974,7 +974,7 @@ wheels = [ [[package]] name = "span-panel-api" -version = "3.7.0b1" +version = "3.8.0b1" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -1057,7 +1057,7 @@ requires-dist = [{ name = "span-panel-api", editable = "." }] [[package]] name = "span-panel-api-schema-1" -version = "1.3.0b1" +version = "1.4.0b1" source = { editable = "packages/schema-1" } dependencies = [ { name = "ebus-sdk" }, From e7e5127f5733fe88f2b89f9263e56a89663f5209 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:37:13 -0700 Subject: [PATCH 3/7] docs(models): first_position is the model table's range The docstring still described the range as widened to occupied spaces outside the model's range. It is the table's range, and the adapter warns once for a panel whose circuits fall outside it. --- src/span_panel_api/models.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/span_panel_api/models.py b/src/span_panel_api/models.py index faf022e..6228aea 100644 --- a/src/span_panel_api/models.py +++ b/src/span_panel_api/models.py @@ -1291,11 +1291,12 @@ class SpanPanelSnapshot: first_position: int | None = None """The panel's first breaker position. v1.0 only. - From the reported model where its range is known, otherwise the lowest - occupied space, and widened to any occupied space outside the model's - range. `None` on the flat schema and where neither is known. Together with - `last_position` it says which positions exist, occupied or not; no circuit - is made up for an empty one. + The reported model's range as the adapter's model table gives it, where + the table knows the model, otherwise the lowest occupied space. An occupied + space outside a known model's range does not move it: the adapter warns + once for that panel instead. `None` on the flat schema and where neither is + known. Together with `last_position` it says which positions exist, + occupied or not; no circuit is made up for an empty one. """ last_position: int | None = None """The panel's last breaker position. v1.0 only; see `first_position`.""" From 2d70be3fd02c8b02960269bfcdad2e797f980960 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:40:04 -0700 Subject: [PATCH 4/7] docs(changelog): state the adapter floors the extras now require The schema-1 extra floors on 1.4.0, not 1.3.0; the schema-0 extra stays on 1.3.0. --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c585be..a794e94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,8 @@ Snapshot fields that held a fabricated constant can now be unknown, and with `sp - **BREAKING FOR CONSUMERS: `SpanPanelSnapshot.uptime_s` becomes `int | None`**, and with those parsers it is `None` on a parent/child panel and before a flat panel first reports ready, where it was a constant `0`. - **BREAKING FOR CONSUMERS: `SpanCircuitSnapshot.instant_power_update_time_s` and `energy_accum_update_time_s` become `int | None` and default to `None`**, and with those parsers they are `None` until the reading has arrived and always on a parent/child panel, where they were `0`. -- **The `schema-0` and `schema-1` extras require `span-panel-api-schema-0` and `span-panel-api-schema-1` 1.3.0 or newer**, the adapters that report these values as unknown. +- **The `schema-0` extra requires `span-panel-api-schema-0` 1.3.0 or newer and the `schema-1` extra requires `span-panel-api-schema-1` 1.4.0 or newer**, the adapters that report these values as unknown; schema-1 1.4.0 also fills the snapshot fields this + release adds. ### Removed From da6240815e04f0e3b35e1561477edf011790db56 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:45:47 -0700 Subject: [PATCH 5/7] docs(agents): say exactly what the capture pins cover SHA256SUMS pins the captures and the LICENSE; README.md and SHA256SUMS themselves are records the check does not pin. --- AGENTS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 85bba3d..9794ab4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,8 +49,9 @@ Ported acceptance tests are never edited. A strict xfail stops at its first fail Captures are byte copies and must stay that way: -- `tests/fixtures/captures/` holds the reference captures with their upstream `LICENSE` and a `README.md` naming the upstream repository, path and commit; `SHA256SUMS` pins every file, and `tests/test_captures_unchanged.py` checks it. The two captures in - `tests/fixtures/` are pinned the same way by `tests/adapter_fidelity/test_main32_fidelity.py`. +- `tests/fixtures/captures/` holds the reference captures with their upstream `LICENSE` and a `README.md` naming the upstream repository, path and commit; `SHA256SUMS` pins each capture and the `LICENSE` by SHA-256. `tests/test_captures_unchanged.py` + checks every pin, requires a pin for every capture and the directory to hold nothing but the pinned files, `README.md` and `SHA256SUMS`; those two are this repository's records and are not pinned. The two captures in `tests/fixtures/` are pinned the same + way by `tests/adapter_fidelity/test_main32_fidelity.py`. - `.pre-commit-config.yaml` excludes them from every hook that rewrites files. Keep any new capture path in those excludes. - A capture is replaced only by copying it again from its upstream commit and updating the pins, never by editing it. From 8bc780b92c3de6fe9bb75ebba49a0db6d3191132 Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:52:24 -0700 Subject: [PATCH 6/7] test: a registry row naming a missing file or test stops every run The stale-row check judged only rows whose file the run had collected, so a renamed or deleted test file left its rows marking nothing. It now reads each named file from disk and requires the test to be defined there, at module level or on a Test class, before any test runs; a partial run judges every row exactly as a full run does, with no false alarm while the rows hold. Tests pin a missing file, a missing test, a non-test and a method off a Test class as stale, a partial run stopping on one, and the registry itself as clean. AGENTS.md and the registry docstring describe it. --- AGENTS.md | 3 +- tests/conftest.py | 60 +++++++++------- tests/expected_failures.py | 4 +- tests/test_expected_failures_registry.py | 89 ++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 27 deletions(-) create mode 100644 tests/test_expected_failures_registry.py diff --git a/AGENTS.md b/AGENTS.md index 9794ab4..b78ff28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,8 @@ harness names it. - a `PENDING` row is behaviour a later change brings, and that change deletes the row: a listed test that starts passing fails the run until it is; - a `DIVERGENT` row is behaviour this library deliberately does not have, and stays until the test or the decision changes; -- a row naming anything but a test function of its collected module stops the run. +- a row naming a file that does not exist, or a test function that file does not define (at module level or on a `Test` class), stops the run. The check reads the files rather than the tests a run collected, so a partial run (`-k`, one file) judges every + row exactly as a full run does, and a renamed or deleted test file cannot orphan its rows. Ported acceptance tests are never edited. A strict xfail stops at its first failing line, so a companion file (`*_companions.py`) asserts what each divergent test checks after its divergent line. diff --git a/tests/conftest.py b/tests/conftest.py index acb1703..58712cb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,12 +2,13 @@ from __future__ import annotations +import ast import asyncio import itertools import json import os from pathlib import Path -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Iterable from unittest.mock import MagicMock, patch import paho.mqtt.client as paho @@ -50,44 +51,55 @@ def _load_dotenv() -> None: _load_dotenv() -def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: """Mark every test `expected_failures.py` lists as a strict expected failure. - A row naming anything but a test function of its collected module is a typo, - a rename or a helper that would silently mark nothing, so it stops the run. + A row naming a file that does not exist, or a test that file does not + define, is a typo, a rename or a deletion that would silently mark nothing, + so it stops the run. The check reads the files themselves rather than what + this run collected, so a partial run (`-k`, one file) judges every row the + same way a full one does. """ - modules: dict[str, object] = {} + stale = stale_rows(EXPECTED_FAILURES, config.rootpath) + if stale: + raise pytest.UsageError(f"expected_failures.py names tests that do not exist: {', '.join(stale)}") for item in items: if not isinstance(item, pytest.Function): continue path = item.nodeid.partition("::")[0] - modules[path] = item.module reason = EXPECTED_FAILURES.get(f"{path}::{item.originalname}") if reason is not None: item.add_marker(pytest.mark.xfail(strict=True, reason=reason)) - stale = [ - key - for key in EXPECTED_FAILURES - if (path := key.partition("::")[0]) in modules and not _is_test_function(modules[path], key.partition("::")[2]) - ] - if stale: - raise pytest.UsageError(f"expected_failures.py names tests that do not exist: {', '.join(sorted(stale))}") +def stale_rows(rows: Iterable[str], root: Path) -> list[str]: + """The rows, sorted, whose file under `root` is missing or defines no such test.""" + defined: dict[str, frozenset[str]] = {} + stale: list[str] = [] + for key in rows: + path, _, name = key.partition("::") + if path not in defined: + defined[path] = _test_functions(root / path) + if name not in defined[path]: + stale.append(key) + return sorted(stale) -def _is_test_function(module: object, name: str) -> bool: - """Whether `name` is a function pytest collects from `module` under the default `test` prefix. - At module level or as a method of one of the module's `Test` classes. +def _test_functions(path: Path) -> frozenset[str]: + """The test functions `path` defines under the default `test` prefix, or none if it is not a file. + + At module level or as a method of one of its `Test` classes, read from the + source, so nothing is imported to answer. """ - if not name.startswith("test"): - return False - if callable(getattr(module, name, None)): - return True - return any( - isinstance(owner, type) and owner_name.startswith("Test") and callable(getattr(owner, name, None)) - for owner_name, owner in vars(module).items() - ) + if not path.is_file(): + return frozenset() + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + functions = (ast.FunctionDef, ast.AsyncFunctionDef) + names = {node.name for node in tree.body if isinstance(node, functions)} + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name.startswith("Test"): + names.update(member.name for member in node.body if isinstance(member, functions)) + return frozenset(name for name in names if name.startswith("test")) @pytest.fixture(autouse=True) diff --git a/tests/expected_failures.py b/tests/expected_failures.py index 6945800..031ccb8 100644 --- a/tests/expected_failures.py +++ b/tests/expected_failures.py @@ -3,8 +3,8 @@ Keyed by pytest node id without parameters or class (`::`), so every parametrized case of a listed function is expected to fail. `conftest.py` marks each one `xfail(strict=True)`: a listed test that starts passing fails the run -until its row is deleted, and a row naming anything but a test function of its -module stops the run. +until its row is deleted, and a row naming a file that does not exist, or a test +function that file does not define, stops the run, partial runs included. `PENDING` rows assert behaviour a later change brings; that change deletes them. `DIVERGENT` rows assert behaviour this library deliberately does not diff --git a/tests/test_expected_failures_registry.py b/tests/test_expected_failures_registry.py new file mode 100644 index 0000000..7f4cddc --- /dev/null +++ b/tests/test_expected_failures_registry.py @@ -0,0 +1,89 @@ +"""The expected-failure registry stops the run on a row that names nothing. + +A row whose file was renamed or deleted, or whose test no longer exists, would +otherwise mark nothing and stay in the registry unnoticed. The check reads the +files, not what a run collected, so a partial run judges the rows the same way +a full one does and raises no false alarm. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +import conftest +from conftest import stale_rows +from expected_failures import EXPECTED_FAILURES + +_ROOT = Path(__file__).resolve().parent.parent + +_SOURCE = ''' +def test_module_level(): + pass + + +async def test_async(): + pass + + +def helper(): + pass + + +class TestGroup: + def test_method(self): + pass + + +class Helpers: + def test_not_collected(self): + pass +''' + + +def _tree(tmp_path: Path) -> Path: + (tmp_path / "tests").mkdir() + (tmp_path / "tests" / "test_sample.py").write_text(_SOURCE, encoding="utf-8") + return tmp_path + + +def test_the_registry_names_only_tests_that_exist() -> None: + assert stale_rows(EXPECTED_FAILURES, _ROOT) == [] + + +def test_tests_at_module_level_and_on_test_classes_are_found(tmp_path: Path) -> None: + root = _tree(tmp_path) + rows = [f"tests/test_sample.py::{name}" for name in ("test_module_level", "test_async", "test_method")] + + assert stale_rows(rows, root) == [] + + +@pytest.mark.parametrize( + "row", + [ + "tests/test_renamed.py::test_module_level", + "tests/test_sample.py::test_gone", + "tests/test_sample.py::helper", + "tests/test_sample.py::test_not_collected", + ], + ids=["missing-file", "missing-test", "not-a-test", "not-on-a-test-class"], +) +def test_a_row_naming_nothing_is_stale(tmp_path: Path, row: str) -> None: + assert stale_rows([row], _tree(tmp_path)) == [row] + + +def test_a_stale_row_stops_even_a_run_that_collected_nothing_of_it( + monkeypatch: pytest.MonkeyPatch, pytestconfig: pytest.Config +) -> None: + """A partial run collects none of the registry's files, and still sees a stale row.""" + stale = "tests/test_does_not_exist.py::test_anything" + monkeypatch.setattr(conftest, "EXPECTED_FAILURES", {**EXPECTED_FAILURES, stale: "planted"}) + + with pytest.raises(pytest.UsageError, match="test_does_not_exist"): + conftest.pytest_collection_modifyitems(pytestconfig, []) + + +def test_a_partial_run_raises_no_false_alarm(pytestconfig: pytest.Config) -> None: + """With every row naming a test that exists, a run that collected none of them proceeds.""" + conftest.pytest_collection_modifyitems(pytestconfig, []) From c83279884ca1dc04778579a4721b61828667982b Mon Sep 17 00:00:00 2001 From: cayossarian <23534755+cayossarian@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:58:59 -0700 Subject: [PATCH 7/7] docs(changelog): record is_240v and lugs_at_service_entrance becoming optional Both snapshot fields become bool | None for consumers: is_240v is None where the pole count is unpublished, and lugs_at_service_entrance while the upstream lugs declare their feed but leave it unpublished. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a794e94..ae47522 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ Snapshot fields that held a fabricated constant can now be unknown, and with `sp - **BREAKING FOR CONSUMERS: `SpanPanelSnapshot.uptime_s` becomes `int | None`**, and with those parsers it is `None` on a parent/child panel and before a flat panel first reports ready, where it was a constant `0`. - **BREAKING FOR CONSUMERS: `SpanCircuitSnapshot.instant_power_update_time_s` and `energy_accum_update_time_s` become `int | None` and default to `None`**, and with those parsers they are `None` until the reading has arrived and always on a parent/child panel, where they were `0`. +- **BREAKING FOR CONSUMERS: `SpanCircuitSnapshot.is_240v` becomes `bool | None`**, and with `span-panel-api-schema-1` 1.4.0 or newer it is `None` where the pole count is unpublished. +- **BREAKING FOR CONSUMERS: `SpanPanelSnapshot.lugs_at_service_entrance` becomes `bool | None`**, and with `span-panel-api-schema-1` 1.4.0 or newer it is `None` while the upstream lugs declare their feed but leave it unpublished. - **The `schema-0` extra requires `span-panel-api-schema-0` 1.3.0 or newer and the `schema-1` extra requires `span-panel-api-schema-1` 1.4.0 or newer**, the adapters that report these values as unknown; schema-1 1.4.0 also fills the snapshot fields this release adds.