Skip to content

Add pytest suite (stacked on #150) - #151

Draft
shauneccles wants to merge 16 commits into
HarvsG:masterfrom
shauneccles:test/coordinator-suite
Draft

Add pytest suite (stacked on #150)#151
shauneccles wants to merge 16 commits into
HarvsG:masterfrom
shauneccles:test/coordinator-suite

Conversation

@shauneccles

@shauneccles shauneccles commented Jun 23, 2026

Copy link
Copy Markdown

Summary

Adds the integration's automated test suite for the DataUpdateCoordinator
conversion in #150, rebased onto the current #150 and rebuilt around a
dynamic, profile-driven model so any router model/firmware can be tested.

Draft for comment, stacked on #150. Once #150 merges to master, GitHub
recomputes the merge-base and this diff collapses to the tests + tooling.

What's here

  • Per-profile fixtures. tests/fixtures/<profile>/ each hold a profile.json
    manifest (model, firmware, capability flags, expected counts) plus the captured
    endpoint responses. conftest discovers every profile and parametrizes the
    whole suite over it — each test runs once per profile, and a new captured
    profile drops in with no code changes. Ships mt6000 (real, sanitised
    MT6000 capture) plus derived edge profiles: no-WireGuard, no-Tailscale, a
    different model (MT3000), and a WiFi7/MLO client.
  • syrupy snapshots. tests/test_snapshots.py snapshots every platform's
    entity registry + states per profile via HA's snapshot_platform, clock frozen
    for a deterministic uptime timestamp. Behavioural tests keep only what
    snapshots can't express (uptime stability, sensor filtering, tracker discovery,
    toggle-calls-API, lifecycle, reauth) and read expectations from the manifest.
  • Committed capture tooling. scripts/capture_fixtures.py captures + sanitises
    a live router (MACs/IPs/SSIDs/hostnames/secrets) into a new profile, guarded by
    tests/test_capture_fixtures.py so it can't leak PII. scripts/synthesize_profiles.py
    regenerates the derived profiles. scripts/test / scripts/capture-fixtures
    wrappers; coverage in CI; CONTRIBUTING.md documents the workflow.
  • Tooling so uv run/pre-commit resolve and pass: dev homeassistant-stubs
    aligned to the test group's HA pin, mypy explicit_package_bases + source
    scope, pylint reconciled with ruff's isort, /Scripts/ .gitignore rule dropped.

Relationship to #150

The config-flow reauth flow and the strict-typing fixes it surfaced now live
in #150 (where the ConfigEntryAuthFailed path and the entity typing originate);
this branch is rebased on top, so its tests assert that behaviour against the base.

Bugfix coverage

Beyond the coordinator conversion, the suite now covers the bugfixes ported into
#150: tests/test_interface_type.py for the iface/integer interface resolver,
a wifi7_mlo_client matrix profile (an MLO client that used to crash setup),
test_load_average_zero_is_reported for the falsy-0 fix, and the memory
buffers/cache change via the per-profile snapshots. Fixture iface placeholders
were restored to realistic values so the resolver's string path is exercised.

Note

The wifi7_mlo_client profile previously documented a real bug
(an unbounded DeviceInterfaceType index crashing on WiFi7/MLO clients); that
fix now lives in #150 and the profile asserts it resolves cleanly.

🤖 Generated with Claude Code

shauneccles and others added 2 commits June 23, 2026 17:05
Replace the hand-rolled GLinetRouter data layer (manual
async_track_time_interval timer, ad-hoc dispatcher signals, per-entity
async_update, should_poll=True) with a single DataUpdateCoordinator.

- coordinator.py: GLinetUpdateCoordinator owns the gli4py client and device
  identity and produces a GLinetData snapshot each SCAN_INTERVAL.
  _async_update_data raises UpdateFailed when the router is unreachable and
  ConfigEntryAuthFailed on token failure, so entities go unavailable / reauth
  triggers idiomatically. Ports async_init/renew_token/_update_platform and
  the update_* fetch bodies.
- models.py: shared data classes (DeviceInterfaceType, WifiInterface,
  WireGuardClient, ClientDevInfo) moved out of router.py.
- const.py: SCAN_INTERVAL moved here.
- All platforms become CoordinatorEntity reading coordinator.data; drop
  should_poll and async_update. Switch toggles call the API then
  async_request_refresh(). Device trackers discover new clients via a
  coordinator listener (replaces signal_device_new/update dispatchers).
- sensor.py also folds in the uptime stabilisation (PR HarvsG#148) and the
  list-comprehension setup filter (PR HarvsG#149), now driven by the coordinator.
- __init__.py uses async_config_entry_first_refresh; HA owns the poll timer
  so the previous reload timer leak is gone.

router.py is removed. Verified with py_compile + ruff; not yet runtime-tested
against live HA.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-ups on the coordinator conversion:
- update_tailscale_state guards the unwrapped tailscale_configured() call so a
  transient API error can't abort the whole refresh or escape uncaught.
- GLinetDevice overrides available -> True so trackers keep reporting home/away
  across a failed poll instead of going unavailable (CoordinatorEntity would
  otherwise tie availability to last_update_success, a presence regression).
- Drop the misleading "immutable" claim from the GLinetData docstring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@HarvsG

HarvsG commented Jun 23, 2026

Copy link
Copy Markdown
Owner

Very excited for this PR. I know it's still in draft but tests are failing - probably because it's waiting on #150

shauneccles and others added 3 commits June 26, 2026 09:35
The coordinator declared a read-only `name` property, but
DataUpdateCoordinator.__init__ assigns `self.name = name`, which raised
`AttributeError: property 'name' has no setter` and aborted entry setup
entirely. Rename the device-display property to `device_name` (its only
consumer is `device_info`), freeing `self.name` for the base class.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Fail the whole refresh (UpdateFailed) when any call hits a transport
  error this cycle, instead of publishing a snapshot with stale data
- Rebuild the wifi/wireguard collections fresh each poll so a removed
  interface/client no longer lingers in the snapshot
- Drop the dead update_options() path (the update listener was never
  registered)
- Add a GlinetConfigEntry type alias for typed entry.runtime_data
- Remove the section-banner comment clutter (review feedback) and add a
  TODO to lean on ConfigEntryAuthFailed/UpdateFailed in future

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…port

- RebootButton is now a CoordinatorEntity so it tracks availability with
  the coordinator
- Import ScannerEntity from homeassistant.components.device_tracker
  (the .config_entry path is deprecated)
- Type entry.runtime_data via the GlinetConfigEntry alias across
  __init__ and all platforms

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@shauneccles
shauneccles force-pushed the test/coordinator-suite branch from 38b1cb4 to 63df810 Compare June 26, 2026 04:40
shauneccles and others added 2 commits June 26, 2026 14:48
The coordinator raises ConfigEntryAuthFailed when the stored token is rejected,
which drives Home Assistant's reauth flow - but there was no async_step_reauth,
so every auth failure dead-ended with an UnknownStep error in the event loop.

Add a minimal, standard async_step_reauth/async_step_reauth_confirm that
re-prompts for the password and refreshes the stored token in place, with the
matching strings/translations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mypy strict reported `no-any-return` in three entity properties because
`self.coordinator` was inferred as Any (the homeassistant stubs don't propagate
the CoordinatorEntity generic to the attribute) and `dt_util.utcnow()` is
untyped.

- Bind the concrete coordinator type on the switch base so
  `self.coordinator.data` is GLinetData, not Any.
- Pin the uptime sensor's `now` to datetime before subtracting.
- Correct GLinetData.wireguard_clients to dict[int, ...] (it is keyed by the
  integer peer_id at runtime) - the previous dict[str, ...] only type-checked
  because the coordinator access was Any.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@shauneccles
shauneccles force-pushed the test/coordinator-suite branch from 63df810 to b0f87b8 Compare June 26, 2026 04:52
shauneccles and others added 9 commits June 26, 2026 15:22
The old `list(DeviceInterfaceType)[dev_info["type"]]` lookup crashed with an
IndexError on Wi-Fi 7 / MLO clients (newer routers report higher type codes),
and the duplicate UNKNOWN2 enum alias silently shifted every index past 8.

Resolve the interface from the router's self-describing `iface` string first
("2.4G", "5G", "6G", "MLO", "cable", guest variants), falling back to a
bounds-checked integer `type` map. Resolving from the string survives firmware
that renumbers the integer codes; the fallback never raises and unknown
interfaces become UNKNOWN (so the device is still tracked) and are logged once
with model/firmware context for adding support over time.

Supersedes HarvsG#145, whose fix targets router.py (removed by this refactor); folds
in the iface-string angle raised in that PR's review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Load averages of exactly 0 made the sensor go unavailable: the old
  `... and la[i]` short-circuits on the falsy 0. Use an explicit conditional so
  a genuine 0.0 is reported (HarvsG#152).
- Treat buffer/cache memory as available when computing memory usage, matching
  how Linux reports free memory, and expose memory_buff_cache / memory_available
  / memory_used attributes (HarvsG#111).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Scaffold the integration's first automated tests using
pytest-homeassistant-custom-component, with the GLinet API client mocked by
fixtures captured (and sanitised) from a live MT6000.

- tests/fixtures/*.json: real router responses with MACs/IPs/SSIDs/names and
  keys scrubbed (see scripts/capture_fixtures.py, a local git-ignored helper).
  WireGuard is absent on the reference router, so those two fixtures are
  hand-authored to the known shape.
- conftest.py: enable_custom_integrations, a MockConfigEntry, a mock GLinet
  client wired to the fixtures, and an init_integration fixture.
- test_init: setup/unload, ConfigEntryNotReady -> retry when unreachable,
  reauth on token auth failure.
- test_coordinator: GLinetData snapshot is populated; identity from
  router_info; UpdateFailed marks the refresh unsuccessful.
- test_sensor: system sensor values; uptime stays flat across polls within
  tolerance (regression for the original flapping) and re-anchors on reboot;
  unavailable-value filter.
- test_switch / test_device_tracker / test_button / test_config_flow: state
  from coordinator.data, API calls + refresh on toggle, dynamic tracker
  discovery, reboot, and the user config flow (success/cannot_connect/
  invalid_auth).
- pyproject: add a `test` dependency group (pthcc pinned to HA 2026.2.0) and
  pytest config (asyncio auto, pythonpath).
- .github/workflows/pytest.yml: run the suite on Linux via pip.

The HA test stack can't build on the Windows dev box, so the suite is
verified in CI, not locally; fixtures and lint were validated here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run the suite via `uv run --no-project --with ...` so CI is pure-uv (matching
the other workflows), needs no project build, and leaves uv.lock untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Reframe the auth-failure test to assert setup aborts (SETUP_ERROR); the
  reauth-flow assertion is removed because the config flow has no
  async_step_reauth yet (noted as a follow-up).
- Move CONF_CONSIDER_HOME to the entry options (where the coordinator reads
  it) instead of data.
- Add test_unnamed_client_excluded so the device-tracker name filter is
  actually exercised.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rebasing onto the current HarvsG#150 (which now adds the config-flow reauth flow and
fixes its strict-typing debt) lets this branch carry only the test + tooling
changes:

- test_init: the auth-failure test asserts that ConfigEntryAuthFailed starts the
  reauth flow (now implemented in HarvsG#150) rather than dead-ending.
- test_device_tracker: ScannerEntity trackers are disabled by default, so assert
  discovery against the entity registry and presence via the coordinator
  snapshot rather than live states.
- Tooling so `uv run` and the pre-commit hooks resolve and pass: align
  homeassistant-stubs (dev) to the test group's HA pin, mypy explicit_package_bases
  + scope to source, pylint disables reconciled with ruff's isort, and drop the
  stale /Scripts/ .gitignore rule so scripts/ is tracked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restructure the fixtures from a flat MT6000-only set into per-profile
directories, each with a profile.json manifest (model, firmware, capability
flags, expected counts). conftest discovers every profile and parametrizes the
suite over it, so each test runs against every model/firmware and a new
captured profile drops in with no code changes.

- tests/fixtures/<profile>/ - mt6000 (the real sanitised capture) plus derived
  edge profiles: no-WireGuard, no-Tailscale, a different model (MT3000), and a
  WiFi7/MLO client that reproduces a real interface-index crash.
- build_mock_api coerces omitted endpoints to the real client's return type
  ([]/{}/False), so feature-absent profiles exercise the real absent-feature
  paths instead of truthy mocks.
- test_snapshots.py snapshots every platform's entity registry + states per
  profile via snapshot_platform (HA convention), with the clock frozen so the
  uptime sensor's derived boot time is deterministic. The behavioural tests keep
  only what snapshots can't express (uptime stability, sensor filtering, tracker
  discovery, toggle-calls-API, lifecycle) and read expectations from the
  manifest; feature-specific switch tests are gated on profile capabilities.
- A dedicated xfail(strict) regression documents the WiFi7/MLO crash.

Scripts (now committed):
- scripts/capture_fixtures.py - capture + deterministically sanitise a live
  router into a profile (MACs/IPs/SSIDs/hostnames/secrets), covered by
  tests/test_capture_fixtures.py so it can't leak PII into a committed fixture.
- scripts/synthesize_profiles.py - regenerate the derived edge profiles.

Keep prettier off tests/fixtures and tests/snapshots: those are generated by the
capture/synthesize scripts and syrupy, and own their formatting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- pytest workflow: report coverage (pytest-cov) and document why it stays a
  single job (profiles are parametrized in-process) and omits --snapshot-update
  (so committed snapshot drift fails CI).
- scripts/test and scripts/capture-fixtures: thin wrappers over the pinned
  ephemeral uv environment, matching CI.
- CONTRIBUTING.md: the profile model, running tests, capturing a new router,
  snapshots, and the HA-version-bump procedure. README gains a short Testing
  pointer; tick the now-done "add tests" and reauth TODOs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Covers the bugfixes ported into HarvsG#150:

- tests/test_interface_type.py exercises the resolver directly: iface-string
  resolution (incl. guest/MLO), integer-code fallback, and that out-of-range /
  non-numeric / missing codes resolve to UNKNOWN without raising.
- The fixtures' sanitised `iface` placeholders are restored to realistic, type-
  consistent values ("2.4G"/"5G"/"cable") so the iface path is actually
  exercised; capture_fixtures.py already preserves iface for real captures.
- wifi7_mlo_client is now a normal matrix profile (an MLO client with iface
  "MLO" and an out-of-range integer type). The dedicated regression test drops
  its xfail and asserts setup loads and the client is labelled MLO.
- test_load_average_zero_is_reported guards the falsy-0 fix; the memory change
  is captured by the regenerated per-profile snapshots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@shauneccles
shauneccles force-pushed the test/coordinator-suite branch from b0f87b8 to 227592e Compare June 26, 2026 05:31
shauneccles added a commit to shauneccles/ha-glinet4-integration that referenced this pull request Jun 26, 2026
`dev` is the rolling fork line (folds in HarvsG#150 + HarvsG#151) for rapid development
against a live MT6000; HarvsG#150/HarvsG#151 stay clean for upstream. Point dev/test/CI and
the script wrappers at our gli4py fork's `dev` branch so the integration and the
library iterate together (issue #12). Not for HACS distribution, so a git ref is
fine - no PyPI fork package needed. The fork is still version 0.0.17b3 (matches
manifest.json); bump the manifest pin in lockstep if the fork's version changes.

scripts/sync-upstream keeps `master` a clean mirror of upstream for tidy PRs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to 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.

2 participants