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.py — BatteryHealthManager (prefix BM, integration)¶
Two layers, against the real manager fixture:
- Record management — ensure_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__.py — battery_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 import — job_metrics.
2. tmp_path — store.
3. Mock manager — sensors: the entities only read manager.get_record(),
so a MagicMock with a canned record exercises every property without hass.
4. Real manager + crafted samples — manager: 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_sampletests must beasync def— the method callshass.async_add_executor_jobfor 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¶
- Drain math / new metric?
job_metricsunit test — pure. - A new sensor? Add a
BStest with the mock-manager record. - New sample-pipeline behavior? Add a
BMasynctest with a crafted_feed(...)sequence and assert onget_record(...).