01 — Core — Subsystem Test Map¶
The core package is the orchestrator: EufyVacuumManager ties every subsystem
together and owns the live read surfaces the dashboard polls (lifecycle, job
progress, start-status), plus the storage layer, capability cache, error-tracker
latch, the post-job water amendment, and the brand-agnostic charging /
low-battery-return reads. Covered by 354 tests across 16 files.
Source: custom_components/eufy_vacuum/core/
Architecture reference: 33 — The Orchestrator, 35 — The Fault Tracker
vacuum_identity.py — per-vacuum state needs a real vacuum (added 2026-08-08)¶
Per-vacuum records are created by setdefault(vacuum_entity_id, ...) in a dozen
places, and none of them asked whether the id referred to anything. Two records
reached a live install as a result:
| record | how |
|---|---|
onboarding["vacuum.your_vacuum"] |
the CARD'S OWN PLACEHOLDER (src/main.js), persisted because a status QUERY created the record it was asking about — a read that writes |
setup_progress["vacuum.iv"] |
a truncated entity id, carrying a full completed_steps / room_drift_history record |
Neither corrupted anything, which is why they sat there. The defect is that the set only ever grew: every typo, placeholder and renamed entity left a record nothing reaps.
Three parts: reads stopped creating (OnboardingManager._get_map_onboarding(create=False)),
creation is guarded by is_real_vacuum, and a one-shot sweep clears what is already
stored. test_vacuum_identity.py (12 tests) pins the shape:
| id | what it holds |
|---|---|
VI-1 |
a vacuum HA knows is REAL even with no stored record — a NEW vacuum must be addable |
VI-2 |
a vacuum with a stored record is REAL even if HA forgot it — a rename must not delete history |
VI-3 |
a placeholder or typo is not real |
VI-4 |
the sweep removes exactly the orphans, nothing else |
VI-5 |
the sweep can never empty vacuums — presence there IS the proof of realness |
VI-6 |
it runs once; a deliberate re-creation is not re-reaped |
VI-7 |
planning is pure, so a deletion can be reviewed before it happens |
VI-8 |
a malformed bucket is skipped rather than taking setup down on boot |
VI-1 and VI-2 are the pair that matters. The discriminator is deliberately not "already known to the manager" — that would make adding a vacuum impossible — nor "HA has the entity", which would delete a renamed vacuum's history. Either proof suffices, and VI-5 guards the catastrophic case: if a stored record ever stopped counting as proof, this sweep would delete every vacuum on the install.
battery_aggregates_migration.py — replaying a ratio's history (added 2026-08-23)¶
test_battery_aggregates_migration.py (13 tests) covers the one-shot replay of drain
aggregates accumulated before the C17 partnered-numerator repair.
The repair it completes is not the repair itself. C17 gave each drain mean its own
numerator, gated on both halves of the ratio being present, and is correct for a bucket
that starts empty — which is what all three of its own tests construct, and why none of
them could see what happens on an install that was already running. The partnered
numerators are NEW KEYS: an upgraded bucket keeps a duration_min_sum holding all-time
data while its numerator restarts at 0.0, so the first job folded in afterwards
publishes roughly one job's drain over every job's duration. Measured on the live
install on 2026-08-23, before C17 had been deployed to it: the next run would have
published 0.0 and 0.0066 %/min against honest figures of 0.292 and 0.4613.
[BAM-1] drives that collapse through the real _update_aggregate_bucket rather than
asserting the migration's own bookkeeping. It is the only test here that describes the
harm; the other twelve exist because of it.
The rest weight the REFUSALS and the retries, because this rewrites a user's own derived history and cannot be undone from anything else on disk:
- [BAM-3] an empty pre-C17 bucket is NOT a target. The first draft of this test used a
freshly built bucket, which already carries the partnered keys — so it passed against
a deliberately broken guard and proved nothing. The shape that bites is a vacuum added
before C17 that has never run: old format, no partnered keys, all sums at zero.
vacuum.robinwas in exactly that state on the live install. - [BAM-4] a numerator PRESENT and 0.0 is not a target either. That is what an honest run of drain-less jobs produces under the new rule; the arithmetic looks identical to the broken case, so detection keys on absence rather than on the value.
- [BAM-10] a missing subsystem DEFERS and does not latch — reachable rather than
theoretical, because
async_at_startedfires immediately on a config-entry reload. - [BAM-11] one unreadable archive does not abort the others, and does not latch.
- [BAM-9] the previous aggregates are snapshotted first.
pause_timeout_migration.py — lifting a timeout nobody chose (added 2026-08-09)¶
test_pause_timeout_migration.py (7 tests) covers the one-shot repair that raises a
stored pause_timeout_minutes_default of 0 to 15.
The bug it repairs is two-part. The default was 0 — the timeout OFF — and no adapter
declared otherwise; and get_pause_timeout_settings PERSISTED that fallback on read,
so the first time anything asked, a hard 0 was stamped into the store and "never
configured" became indistinguishable from "deliberately disabled". The consequence is
a paused run that nothing ever closes: the pause reaper owns it, and the pause reaper
was off. Found on a live install on 2026-08-09.
The tests weight the REFUSALS as heavily as the repair, because this migration knowingly overwrites a value a user could have set through Developer Tools:
- [PTM-2] a configured non-zero value survives — a repair that rewrote every vacuum to 15 would satisfy the happy path and silently discard every real preference;
- [PTM-3] an ABSENT value stays absent, so the vacuum keeps inheriting the computed default. Stamping 15 here would recreate exactly the read-side write-back the repair exists to undo — the bug reintroduced by its own fix;
- [PTM-4] the latch holds, so a user who sets it back to 0 afterwards keeps 0;
- [PTM-7] malformed store buckets do not raise, since this runs at startup and a throw takes the integration down with it.
Unlike the room-vocabulary repair it needs no adapter declaration to judge a target, so it cannot be deferred by a provider that has not finished setting up and latches unconditionally.
Coverage map¶
| Source module | Stmts | Cov | Test files | Layer | Mocking |
|---|---|---|---|---|---|
manager.py |
2309 | 93% | test_manager_lifecycle_status.py, test_manager_progress.py, test_manager_delegation.py, test_manager_start_selected.py, test_manager_external_finalize.py, test_manager_init_migrations.py, test_core_manager_registry.py, test_manager_compare_sources.py, test_manager_live_pose.py, test_core_manager_helpers.py (unit) |
int + unit | bare x10 |
error_tracker.py |
442 | 91% | test_core_error_tracker.py |
integration | bare x1 |
capabilities.py |
337 | 91% | test_core_capabilities.py |
integration | clean |
charging.py |
42 | 100% | test_charging.py (unit) |
unit | clean |
run_state.py |
15 | 100% | test_core_run_state.py (unit) |
unit | clean |
water_amendment.py |
127 | 92% | test_core_water_amendment.py |
integration | clean |
storage.py |
21 | 100% | test_core_storage.py |
integration | clean |
manager.py is the single largest module in the codebase; most of its public
surface delegates to a subsystem (see the delegation seam),
so its own tests target the logic that genuinely lives in the orchestrator.
What's tested¶
- Lifecycle + error overlay (
LS) —get_lifecycle_statefolds the ErrorTracker active-run latch into the user-visible message: a livecurrent_messagewins; a blank current message with error history derives a "Run had N error(s); last: …" summary. - Start-status gates (
LS) —get_start_statusblocks a new run on a paused job (job_paused), incomplete floor-type onboarding (onboarding_required), and every-selected-room-blocked (all_selected_rooms_blocked, reached by building a complete access graph so rule-bearing rooms clear the graph gate). - Live job progress (
PR) —get_job_progress_snapshot: idle terminal snapshot, started timeline (estimate / reanchored), completed-room reanchor, and run-anomaly detection. The anomaly logic + event emission live inActiveJobTracker.detect_run_anomalies(jobs/active_job.py); the snapshot composer calls it (self.active_job.detect_run_anomalies(...)) and folds the returned fields in. Three anomalies are covered: stall (bounds gate blocking and the room stuck ≥ 2× its estimate — firesEVENT_STALL_DETECTEDonce per room per job), running_long (the soft band 1.5×–2× below stall, no event), and skipped (a queued room advanced past but not completed — firesEVENT_ROOM_SKIPPEDonce per room per job).test_manager_progress.pyexercises all three through the manager. - Finalize bridge (
PR) —finalize_learning_for_active_job: no-learning → None, missingstarted_at→ not finalized, full job →completed_job. - Room-history ingest —
_ingest_jobs_index_entry_into_room_history(newer-wins merge, bad-row skips). - Pure helpers (
CMH, unit) —_safe_float,_safe_int,_normalize_path_block_action,_hours_text,_display_label,_settings_profile_display. - Delegation seams (
MD) — every thin forwarder (return self.<sub>.x(...)) is smoke-tested through the manager so a delegation lost in a refactor fails loudly. This is the #11 / #13 bug-class net (a forwarder that went missing while a listener still called it). - ErrorTracker (
ET) — latch lifecycle, secondary-error detection. - Run-state predicate (
RS, unit) —run_state.py's brand-agnostic "is the robot cleaning right now?" read, sibling ofcharging.py, feeding the current-room non-cleaning accumulator: HA-standard non-cleaning states recognized for any brand; fails open on unknown/unavailable/empty/None; paused/idle/error deliberately excluded; an adapter's declared vocabulary extends the standard set (never replaces it), matched case/whitespace-insensitively, with a malformed declaration falling back to the standard set. - Capabilities / storage / water amendment — capability resolution + cache, the persistent store round-trip, and the post-job water patch.
- Maintenance source resolution (
CAP-5,CAP-5b,CAP-5c,CAP-5d) — which entity ends up counting each component. The order isuser override > own sensor > proxy > picked clock, and each rung is pinned separately because each has its own way of going wrong.CAP-5b: one picked clock fills every gap without displacing a component that counts itself.CAP-5c:proxy_foroutranks the clock but never an own counter — a proxy is the adapter's explicit statement about one component, the clock a generic per-vacuum fallback, and inverting either boundary silently swaps a real counter for a borrowed one.CAP-5d: an unresolvable borrow falls through to the clock rather than pinning a dead derived id.
proxy_for was deleted on 2026-09-12 and restored on 2026-09-14; the failure that removed it
is now pinned as UAC-20 in 09 — maintenance, and the rule that a
borrowed counter must never drive a Replacement row is pinned across all three brands in
tests/adapters/test_adapter_contract.py — that one bites the exact shape v2.1.0 shipped,
where Eufy's Swivel Wheel reported the filter's 360-hour service life as its own.
How it's tested¶
Driven against the real manager fixture (tests/integration/conftest.py). The
error overlay uses a recording _FakeErrorTracker registered at
hass.data[DOMAIN][DATA_ERROR_TRACKER]. Progress tests wire a real
LearningManager and seed an active job with _seed_job(...). The delegation
smoke test calls each seam with minimal valid args and asserts it forwards
without AttributeError.
Known gaps¶
manager.py (94%, 2113 total statements this campaign, up from a
1850-statement baseline — a ~14% growth) — the external-run
capture/review orchestration (maybe_handle_external_run /
_external_grace_finalize / confirm / resegment /
get_external_pending_runs / discard), the room-history ingest helpers, the
registry-model backfill, the init migrations, the job/dock delegations, and
the progress snapshot are covered (the EXT-, CMR-, init-migration,
delegation, and progress suites). What's left is 89 missed statements + 74
partial branches (--cov-report=term-missing for the current line list) —
still mostly the defensive tail (# pragma: no cover best-effort log-only
excepts, malformed-input guards, the recorder-dependent return-overhead
extraction skipped when no recorder is configured under test), but the file's
growth means this list has not been re-itemized line-by-line since the count
last stood at ~16 lines; treat the qualitative description as directional, not
exhaustive.
The remaining per-module gaps are within the held ceiling: error_tracker.py
(87%) and water_amendment.py (92%) are short of the others only on defensive
except/guard paths; capabilities.py (95%), charging.py (100%),
run_state.py (100%) and storage.py (100%) are effectively complete.