Skip to content

08 — Battery — Subsystem Test Map

The battery subsystem tracks cell wear: it accumulates battery samples into charge cycles, summarizes charge sessions, derives a CC/CV regime health proxy vs. an install baseline, and records per-job drain metrics. Covered by 130 tests across the 4 core files, plus a service-level test for battery_rebaseline.

Source: custom_components/eufy_vacuum/battery/ Architecture reference: 16 — The Battery Record


Coverage map

Source module Stmts Cov Test file Layer Mocking
job_metrics.py 81 98% tests/unit/test_battery_metrics.py unit (pure) clean
store.py 40 100% tests/unit/test_battery_store.py unit (tmp_path) clean
sensors.py 170 97% tests/unit/test_battery_sensors.py unit (mock manager) bare x1
manager.py 547 94% tests/integration/test_battery_manager.py integration bare x2
__init__.py (service) 100% tests/integration/test_init_battery_rebaseline_service.py integration (service)

What's tested

job_metrics.py — per-job drain computation (pure)

The drain-rate math (per-minute / per-hour / per-m², single-bucket detection, weighting). Pure functions, near-fully covered.

store.py — JSONL sample + session CSV store (tmp_path)

append_sample / append_session file round-trips, header creation, and the read helpers, against an isolated tmp_path config dir.

sensors.py — sensor entities (prefix BS, mock manager)

The build_battery_sensors factory and all entity classes (ChargeCycles, ChargeRate overall/low/high, LastChargeDuration, BatteryHealth, RegimeChargeSpeed cc/cv, LastJobMetric ×3, MidJobRecharge) — native_value, extra_state_attributes, the _bucket_means projection, None handling, and unique_id/suggested_object_id derivation. Tested with a MagicMock manager whose get_record returns a crafted record.

manager.pyBatteryHealthManager (prefix BM, integration)

Two layers, against the real manager fixture: - Record managementensure_record (create + repair), listeners, rebaseline, record_job_metrics (last-job + aggregates + single-bucket), _update_aggregate_bucket. - The _process_sample pipeline — driven by crafted (level, charging, dt) sample sequences: cycle counting, the MAX_DELTA_PCT rejection guard, the overall/low-zone/high-zone charge rates, session open→accumulate→close (including the "full" close at 100%), the 50→90 health-proxy baseline anchor (CC + CV regimes), and out-of-range rejection. - Ratio populations (C54)avg_rate_per_min is the mean of the rates actually observed, so it can never sit below min_rate_per_min. The assertion is worth naming because it is not a restatement of the arithmetic: a mean below its own minimum is impossible, so the test cannot pass on a wrong denominator. Covered on a clean constant-rate charge (every session opens with samples 1 and rate_sum 0.0, so the defect was universal), on the ragged 60→70% case, and on a session in flight across the upgrade, which closes None rather than falling back to samples and reinstating the value being removed.

__init__.pybattery_rebaseline service (prefix INIT-REBASE, service)

The eufy_vacuum.battery_rebaseline service handler registered during setup. Boots the integration through the real config-entry path, swaps in a spy battery manager, then drives the service via hass.services.async_call and asserts the handler read vacuum_entity_id from the call data and delegated exactly once to bm.rebaseline(...) — including the if not ok "no record found" branch when rebaseline returns False.


How it's tested

Five patterns: 1. Pure importjob_metrics. 2. tmp_pathstore. 3. Mock managersensors: the entities only read manager.get_record(), so a MagicMock with a canned record exercises every property without hass. 4. Real manager + crafted samplesmanager: construct BatteryHealthManager(hass, runtime_manager=manager) and call _process_sample(...) directly with explicit battery_level / charging / ts. This drives the whole cycle/rate/session/health state machine deterministically without real battery sensors. 5. Service through real setup__init__.py: boot the integration via the config-entry path, swap in a spy battery manager, and call the registered battery_rebaseline service to assert the handler's delegation contract.

The _process_sample tests must be async def — the method calls hass.async_add_executor_job for the JSONL append, which needs a running event loop. Record-management tests can stay sync.


Known gaps

manager.py (93%, grown from 455 to 532 statements this campaign — the recharge-derivation work noted in 16 — The Battery Record) is mostly covered, including the HA wiring and the charging/session-classification paths that earlier revisions of this doc listed as gaps. The HA-wiring path (start/stop, _wire_vacuum, _on_state_event, _sample_now, and the _is_charging substring fallback) is exercised by test_wire_and_state_event [BM-18] and test_is_charging_delegates_and_fallback [BM-14]; _classify_session_kind and _attach_post_job_charge_if_pending by [BM-17]/[BM-19]/[BM-20].

What's left is still, on spot-check, the same defensive-by-design shape as before — the except ValueError in the listener-unsub, the _wire_vacuum already-wired re-entry guard, the _has_active_job non-dict guard, sanity-timeout stale-session handling, HISTORY_LIMIT ring-buffer trims, and _parse_iso/dedup non-dict guards — but the file's growth has shifted every line number and likely added/removed some specific arms, so the exact list below should be treated as a fresh coverage snapshot, not a re-verified item-by-item breakdown: missing lines 347-348, 544, 608, 857-859, 982, 991, 1011, 1022, 1109, 1112, 1117, 1171-1172, 1212, 1276, 1373, 1490-1491, 1495, 1601, 1606-1607 (--cov-report=term-missing for the current mapping).

Other modules are near the ceiling: job_metrics.py (98%) — only the (TypeError, ValueError) est-parse guard (156-157, unchanged); sensors.py (97%) — the no-hass write-state guard (161) and the non-dict bucket skip (500); store.py and __init__.py at 100%. The core wear/health/session math is fully covered.


Extending

  1. Drain math / new metric? job_metrics unit test — pure.
  2. A new sensor? Add a BS test with the mock-manager record.
  3. New sample-pipeline behavior? Add a BM async test with a crafted _feed(...) sequence and assert on get_record(...).