06 — Learning — Subsystem Test Map¶
This is the template. Learning is the most completely tested subsystem, so its test map is the model for documenting the others. Copy this structure (coverage map → what's tested → how it's tested → gaps → extending) when you write the test map for another subsystem.
The learning subsystem records cleaning runs, rebuilds per-room/per-profile stats, estimates ETAs with a confidence model, and finalizes completed jobs. It is exercised by 440 tests across 13 files (429 test functions, expanded by parametrization).
Source: custom_components/eufy_vacuum/learning/
Architecture reference: docs/dev/10-learning-system.md
Coverage map¶
| Source module | Stmts | Cov | Test file(s) | Layer |
|---|---|---|---|---|
utils.py |
80 | 98% | tests/unit/test_learning_utils.py |
unit (pure) |
estimator.py |
409 | 94% | tests/unit/test_learning_estimator.py |
unit (pure + class) |
history_store.py |
476 | 93% | tests/unit/test_learning_history_store.py |
unit (tmp_path FS) |
stats_rebuilder.py |
469 | 93% | tests/unit/test_learning_stats_rebuilder.py |
unit (tmp_path FS) |
job_finalizer.py |
539 | 93% | tests/unit/test_learning_job_finalizer.py + tests/integration/test_learning_services.py |
unit (pure) + integration |
manager.py |
733 | 95% | tests/integration/test_learning_services.py + tests/unit/test_learning_profile_label.py |
integration |
services.py |
257 | 92% | tests/integration/test_learning_services.py |
integration |
external_ingest.py |
410 | 95% | tests/unit/test_learning_external_ingest.py |
unit (pure) |
job_segmenter_engines.py |
99 | 98% | tests/unit/test_job_segmenter_engines.py |
unit (pure) |
room_attribution_engines.py |
148 | 98% | tests/unit/test_room_attribution_engines.py (seam) + tests/adapters/eufy/test_room_attribution.py (classifier) |
unit (pure) |
counter_segmentation.py |
165 | 96% | tests/unit/test_counter_segmentation.py + tests/unit/test_counter_resegmentation.py |
unit (pure) |
counter_segmentation.py lives at the package root (not under learning/) — it
is the shared counter-plateau segmentation primitive used here and by the jobs
subsystem's live rollover, tabled here alongside the engine that wraps it.
¹ room_attribution_engines.py reads low in the learning run because the real
eufy_anchor_winding_v1 classifier (winding/swept-area math) is exercised in
tests/adapters/eufy/test_room_attribution.py (Eufy-specific, run with the
adapter suite, not these 11 files); the learning-run number only reflects the
seam-level registry/tuning tests in tests/unit/test_room_attribution_engines.py.
Numbers are line coverage with branch coverage enabled, measured by running all these files together (see running-tests §per-file vs combined).
What's tested¶
utils.py — pure coercion + normalization helpers¶
_safe_int, _safe_float, _safe_bool, _room_profile_key, plus the 1.8.0
area/time normalization, sanity, and idle-wall helpers. Sentinel strings
("", "unknown", "unavailable"), None defaults, float-string parsing,
non-numeric fallback. Grouped by function (no ID prefix).
Coverage details:
- _safe_int: rejects sentinels and None, converts via int(float(value)) (raises ValueError on NaN/inf), returns default on any exception
- _safe_float: rejects sentinels and None, returns float(value) as-is (does NOT reject NaN/inf; float('nan') succeeds), returns default on ValueError only
- _safe_bool: rejects sentinels and None, truthy strings {"true", "on", "yes", "1"} (case-insensitive, stripped), falsy otherwise; returns bool(value) for non-strings with exception fallback
Area/time normalization + sanity (1.8.0):
- cleaning_area_to_m2 (test_cleaning_area_*): normalizes a raw cleaning_area
reading to canonical m² by the sensor's live unit_of_measurement via _AREA_TO_M2
(ft²→0.09290304). ft² converts, m² passes through, an absent/unknown unit is left
as-is (assumed already m² — we never guess a factor), blank/bad → None; unit lookup
is case- and space-insensitive.
- area_sanity (test_area_sanity_*): the device run total is the upper bound —
attributed_sum > sensor_total * 1.10 (default AREA_OVER_ATTRIBUTION_TOLERANCE) sets
the area_over_attributed flag; under-bound, exact, and within-tolerance sums pass, and
it returns None when there is no usable sensor total.
- evaluate_idle_wall_hold + the IDLE_WALL_HOLD_* constants: the pure cold-start
idle-wall decision — holds an otherwise-eligible completed run whose wall time exceeds
active cleaning by ≥ the 20-min floor with no charge/wait break-phase or logged error
(reason == "extreme_idle_wall"). Exercised by tests/unit/test_idle_wall_guard.py
alongside its finalizer wiring (below).
estimator.py — compute helpers + LearningEstimator (prefix LE)¶
- Confidence model (LE-1..LE-7): score→breakpoint mapping, clamping, the learned-base + sample-bonus + variance/intensity/accuracy-drift penalties, the default-source 0.20 base.
- Velocity (LE-8..LE-9): runs-to-tier for new vs already-trusted vacuums.
- Overhead (LE-10..LE-12): startup/transition/return always present; mop-wash
cycles scaling with projected mop minutes; non-
by_timemodes yielding zero. - Wash-frequency normalization (LE-13..LE-14): alias lookup, empty→
unknown. - Room matching (LE-15..LE-17): exact match, ignore-intensity fallback pass, no-match.
- Estimator surface (LE-18..LE-23):
estimateerror payload vs full result,next_room,reanchor_timelineoffset/marking.
history_store.py — file-backed store + helpers¶
_vacuum_slug, path construction, JSON read/write round-trips, completed-job
build/save/load, jobs-index payloads, accuracy/room/job stats load, the
build_completed_job_payload outcome logic (learning blockers,
used_for_learning flip). Grouped by function/method. The "interrupted" outcome
of a stranded/force-finalized run is blocked from learning
(test_build_completed_job_payload_interrupted_blocks_learning: job_interrupted
blocker, used_for_learning False), and the jobs-index build carries the 1.8.0
marker keys (origin, has_attribution_disagreement, area_over_attributed) the
self-heal detector keys on.
stats_rebuilder.py — pure builders + LearningStatsRebuilder¶
_room_key, _room_baseline_key, _stddev, and the rebuild paths that turn
archived completed jobs into room/profile/job stats + the jobs index.
job_finalizer.py — split across two layers¶
- Unit (pure helpers):
_parse_iso_to_utc,_compute_total_error_seconds(interval merge, unresolved-bound-by-next, zero-duration skip),_apply_water_actuals(dock-water totals, wash-overhead split, unexpected cycles). - Integration (the pipeline): cancel-likely detection branches (LS-40..LS-47), error-tracker latch harvest, error-seconds adjustment, incomplete-run/trouble-room logs, battery-metrics handoff (LS-53).
- 1.8.0 additions:
_duration_state_to_secondsconverts a bare-numbercleaning_timeto seconds by the adapter-declaredcleaning_time_unit(Roborock reports minutes — previously stored 60× low);_apply_idle_wall_holdwires the pure idle-wall verdict into the outcome (held via a restorableextreme_idle_walllearning-blocker, never a hard exclude), with_run_had_break_phasesupplying its charge/wait exemption — both covered bytests/unit/test_idle_wall_guard.py, which also golden-checks real archive records. The forced"interrupted"lifecycle (a stranded run that was force-finalized) is covered bytest_collect_inputs_interrupted_outcome(it takes thewas_interruptedarm and skips the cancel-likely probe rather than misclassifying a truncated run as completed).
manager.py + services.py — orchestration + HA services (prefix LS)¶
LS-1..LS-53 in test_learning_services.py: service registration, the
read/snapshot services (history snapshot, metrics snapshot, room estimates),
exclude/restore round-trips, finalize variants (forced status, cancelled,
completed-clears-incomplete), accuracy recording and the trust-metrics path, the
old-format jobs-index rebuild, and async_preload_learning_stats guards.
test_learning_profile_label.py additionally covers manager._settings_profile_label
(SPL-1/SPL-2) as a focused unit.
The 1.8.0 learning-processing toggle (set_learning_processing /
process_pending_runs — collect-always, process-on-demand, with a pending-count
display) is covered by tests/integration/test_learning_processing_toggle.py:
default-on + toggle, the pending counter, finalize gating the stats rebuild on the
toggle, off-does-not-catch-up vs on-catches-up, process_pending_runs staying off,
and the dashboard-snapshot exposure. The self-heal jobs-index detector (rebuilds
the index once when the new marker keys origin / has_attribution_disagreement /
area_over_attributed are missing) rides the same snapshot seam. Separately,
tests/integration/test_manager_external_finalize.py drives the external grace-timer
finalize end-to-end (EXT-FIN-1), the W5c pose-only stand-up record when the counter
segmenter finds nothing (EXT-FIN-2), and the slot-clears-on-build-error guard
(EXT-FIN-3, the zombie-external-slot protection), plus the confirm/re-segment
paths that graduate a reviewed run.
external_ingest.py — app-started capture + dispatched reconcile¶
Detection of runs started outside HA, the pending-record build + persistence, the
re-segmentation service, and the confirm path that turns a reviewed run into a
learned job (the v2 samples-saved re-segment plus the v1 fallback). The
pose-based room-attribution path (_resolve_attribution / _attribute /
build_attributed_job) runs the resolved room-attribution engine over a run's
pose stream to pre-fill which managed rooms it cleaned; the engine itself is
tested separately (see the room-attribution seam below), and its
caller-side wiring is covered by tests/unit/test_external_ingest_attribution.py
(the _apply_pose_identity / _dominant_room enrich of counter segments, the
build_attributed_job pose-only stand-up record — the Roborock/noop-segmenter
shape — the sensor-total sanity stamp, engine-error degradation to counter-only,
and a real-capture stale-cleaning_area regression).
Since 1.8.0 the same file also reconciles dispatched runs:
reconcile_dispatched_identity compares the atomic finalize's positional (segment
K → queue room K) identity against the native current-room the pose sampler
buffered and confirms on agreement, rescues when positional_valid is
False, or flags an attribution_disagreement (never silently overriding a
working assignment) — the test_reconcile_* cases cover confirm / rescue / flag
plus the anchor-only, no-pose, and unnamed-window no-ops. Attribution and
reconcile run for external and dispatched runs on both Eufy
(live_pose) and Roborock (native_current_room).
job_segmenter_engines.py — pluggable job-segmenter seam¶
The JobSegmenter registry + the eufy_counter_v1 engine: byte-identical
delegation to counter_segmentation (the fidelity battery), DEFAULT_TUNING
mirroring the module constants, and the Eufy-fallback for an absent/unknown engine.
room_attribution_engines.py — pluggable room-attribution seam¶
The sibling of the job-segmenter seam (it owns which managed room a segment is,
not its time/area boundary). Split across two files:
- Seam tests (tests/unit/test_room_attribution_engines.py): the
get_room_attribution_engine registry — resolves a registered engine, the
Eufy-fallback for an absent/unknown name, the known_names list — plus
DEFAULT_TUNING-by-reference, validate_tuning (partial merge over defaults),
and the NoopRoomAttributor (empty result, key-rejecting validate_tuning).
- Classifier tests (tests/adapters/eufy/test_room_attribution.py, run with
the adapter suite): the real eufy_anchor_winding_v1 rule — segment by
current_room, drop transit by path-winding, separate cleaned-vs-parked-dock by
swept cleaning_area, and the anchor-only fallback when area is absent.
Validated on the three adversarial external runs (9/9 cleaned-room calls).
counter_segmentation.py — counter-plateau segmentation primitives¶
find_candidates / select_active / build_segments — the frame-invariant
plateau detection shared by live rollover, external-run ingest, and learned
history; the engine above delegates to these.
How it's tested¶
Four distinct setups, chosen by what the module needs:
1. Pure helpers — import and call¶
utils, the compute helpers in estimator, and the pure helpers in
job_finalizer. No hass, no fixtures, no filesystem. Fast, no shared state.
from custom_components.eufy_vacuum.learning.utils import _safe_int
def test_safe_int_sentinel(): assert _safe_int("unavailable") == 0
2. Store / rebuilder — MagicMock hass + tmp_path¶
history_store and stats_rebuilder do real file I/O. The unit tests give them
an isolated filesystem by mocking hass and pointing config_dir at
pytest's tmp_path:
def _make_store(tmp_path):
hass = MagicMock()
hass.config.config_dir = str(tmp_path)
return LearningHistoryStore(hass)
This is the key difference from integration tests: each test gets a fresh
config dir, so there is no shared-config_dir accumulation
(gotchas §2). Prefer this style for any
store-backed logic — it is both faster and cleaner than the integration hass.
3. Estimator class — construct with a store, feed seeded stats¶
LearningEstimator is built on a store and exercised by handing it room/accuracy
stats, asserting on the estimate/confidence output.
4. Integration services — learning_services fixture + seeding¶
test_learning_services.py registers learning services on top of the manager
fixture (learning services are not part of async_register_services, so the
file defines its own fixture):
@pytest.fixture
async def learning_services(hass, manager):
await async_register_learning_services(hass)
yield manager
await async_unregister_learning_services(hass)
Plus the per-file seeding helpers _seed_completed_job (via
LearningHistoryStore) and _seed_active_job (into
manager.data["active_jobs"]). Sync methods like finalize_completed_job run
through hass.async_add_executor_job. Because this layer shares config_dir,
assertions use presence/>=, and any file written in a non-canonical shape is
restored in a finally (gotchas §2-3).
Two conventions coexist here: the newer files use ID prefixes (
LE-n,LS-n); the older store/rebuilder/utils files group tests by# --- function ---comment banners. Either is fine — match the file you are editing.
Known gaps (deliberately untested)¶
Coverage is high (89-100% per module); the remainder is mostly defensive guards, inactive code, or paths reachable only by injecting malformed data.
job_finalizercancel-detection sub-branches — theearly_return_likely_cancelledreturn (~line 1277) and the learning-estimate call it depends on (manager._get_learning_manager()/expected_room_minutes, initialized ~1238 and set from the timeline ~1252) need a non-zero learning estimate staged to reach; low value. The floor-time fast-path above it (thefloor_time_too_shortreturn, ~1222-1235) is similarly conditional.- Defensive
except/# pragma: no coverblocks across all modules — e.g.job_finalizer1354-1359 (incomplete-run-log write) and 1449-1453 (trouble-rooms write), and the per-roomestimate_failedhandler logic inmanager. Reachable only by injecting malformed data; intentionally skipped. manageraccuracy-normalization guards (800, 805, 808) — scattered defensive branches in the accuracy-stats normalization loop: theelse: accuracy_entries = []shape fallback and the non-dict / empty-slugcontinueguards. (The percent/confidence-weight derive paths at 813/822 are now covered.) The canonical dict shape is fully covered; the rest are back-compat / malformed-input guards.managerdirect reload path (271-284) — the immediate reload-from-disk helper; integration tests drive the executor-backed preload instead, so this synchronous variant is uncovered. Low value.
These are skipped on purpose (conventions §what not to test).
Retired: the former
_auto_derive_room_boundarygap (an inert trace→room-boundary derivation injob_finalizer) is gone. The learned per-room bounds store was removed in the mapping shelve, and room identity now comes entirely from the device native current-room signal (the room-attribution seam above) — nothing in learning runs in drifting vacuum coordinates anymore.
Extending¶
When you add learning behavior:
- Pure logic? Add to the matching unit file (
utils,estimator,history_store,stats_rebuilder, or thejob_finalizerhelper tests). Usetmp_pathif it touches the store. - A new service or finalize path? Add a coverage target (
LS-n) totest_learning_services.py, seed with the existing helpers, and start from recipe D. - Reader/writer of a stats file? Seed through the real writer so the shape stays canonical (gotchas §3).
- Re-measure with all six files together to get the true module number.