Skip to content

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 645 tests across 18 files (626 test functions, expanded by parametrization).

Source: custom_components/eufy_vacuum/learning/ Architecture reference: 26 — The Learning Record Store through 30 — External Runs

Captured settings the mode cannot apply (CAP, added 2026-08-08)

External (app-started) runs are captured by reading the vacuum's global setting selects per tick — the only window into what the app set, since VA did not dispatch the job and has no payload for it. But a select keeps reporting a value whether or not the current mode uses it.

Confirmed on hardware: the Eufy app disables the suction picker in mop-only mode, and select.<vac>_suction_level went on reading Max throughout. Capturing that recorded the run as having used Max suction — a number never applied, entering Learning Review and the estimate buckets looking exactly like a measurement. Indistinguishable from a real one, which is why it would never have been noticed.

_drop_settings_the_mode_does_not_use removes them, symmetrically:

mode inert, therefore dropped
mop-only fan_speed
vacuum-only water_level, mop_intensity
vacuum+mop nothing — both halves apply

water_level and mop_intensity are dropped together because they are ONE device value under two label sets (upstream maps Quiet/Automatic/Max onto Low/Medium/High); dropping one would leave a phantom reading of the same inert setting.

test_capture_inert_settings.py (9 tests). The two that stop the filter going wrong in opposite directions:

  • CAP-3 — vacuum+mop keeps everything, so a fix cannot "work" by dropping too much.
  • CAP-4 — an unrecognised mode drops NOTHING. Parameterised over a real Eufy protocol mode the X10 lacks (Mopping after sweeping) as well as a recognised alias (Vacuum and mop), so a FUTURE mode arriving cannot silently be treated as "these fields do not matter". Retaining is the conservative branch: discarding a real measurement is worse than keeping an inert one, and review lets the user correct it.

Mirrors the dispatch side, where queue_engine only writes water_level / edge_mopping for mop modes.


Coverage map

Source module Stmts Cov Test file(s) Layer Mocking
utils.py 120 94% tests/unit/test_learning_utils.py unit (pure) clean
estimator.py 535 94% tests/unit/test_learning_estimator.py unit (pure + class) bare x1
history_store.py 794 91% tests/unit/test_learning_history_store.py unit (tmp_path FS) bare x1
stats_rebuilder.py 501 95% tests/unit/test_learning_stats_rebuilder.py unit (tmp_path FS) bare x1
job_finalizer.py 634 93% tests/unit/test_learning_job_finalizer.py + tests/integration/test_learning_services.py unit (pure) + integration bare x8
manager.py 959 94% tests/integration/test_learning_services.py + tests/unit/test_learning_profile_label.py + tests/unit/test_profile_name_filter.py integration clean
services.py 268 91% tests/integration/test_learning_services.py integration clean
external_ingest.py 433 96% tests/unit/test_learning_external_ingest.py unit (pure) bare x1
job_segmenter_engines.py 99 98% tests/unit/test_job_segmenter_engines.py unit (pure) clean
room_attribution_engines.py 148 98% tests/unit/test_room_attribution_engines.py (seam) + tests/adapters/eufy/test_room_attribution.py (classifier) unit (pure) clean
counter_segmentation.py 202 96% tests/unit/test_counter_segmentation.py + tests/unit/test_counter_resegmentation.py unit (pure) clean
brand_facts.py 49 92% tests/unit/test_learning_brand_facts.py unit (pure) clean
external_run.py 317 90% tests/integration/test_manager_external_finalize.py + tests/integration/test_manager_lifecycle_status.py integration clean
zone_learning.py 90 83% tests/unit/test_zone_learning.py unit (pure) clean
constants.py 3 100% (exercised via external_run.py / test_manager_external_finalize.py) unit (pure) clean

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 swept_area_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 other 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_time modes 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): estimate error payload vs full result, next_room, reanchor_timeline offset/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_seconds converts a bare-number cleaning_time to seconds by the adapter-declared cleaning_time_unit (Roborock reports minutes — previously stored 60× low); _apply_idle_wall_hold wires the pure idle-wall verdict into the outcome (held via a restorable extreme_idle_wall learning-blocker, never a hard exclude), with _run_had_break_phase supplying its charge/wait exemption — both covered by tests/unit/test_idle_wall_guard.py, which also golden-checks real archive records. That file also covers _run_had_charge_wait_phase: the mid_job_recharge flag now trips on a deliberate native charge_wait step (not only an unplanned deep-low return), keeping that intentional recharge out of the per-config drain buckets — a plain wait phase is never flagged. The forced "interrupted" lifecycle (a stranded run that was force-finalized) is covered by test_collect_inputs_interrupted_outcome (it takes the was_interrupted arm 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 swept_area_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).

brand_facts.py — the BrandFacts contract learning reads instead of the adapter

EufyBrandFacts surfaces each adapter fact faithfully (entity ids, alias maps, mid-run + cancel vocab, engine specs — BF-1); cancel_service_exclusion_states is normalized, stripped/lowered, matching the caller that used to do it inline (BF-2); empty/absent adapter config degrades to safe defaults — None / {} / empty set (BF-3); brand_facts_for() resolves an adapter-registry-backed EufyBrandFacts (BF-4). This is the seam a host re-hosting the learning engine swaps to port a new brand without learning ever importing the adapter directly — see docs/dev/10-learning-system.md §9.3.

external_run.py — app-started (external) run capture + review, on the manager

ExternalRunManager owns the whole external-run lifecycle: detect + open/close the capture slot, the grace-timer machinery that defers finalize while the robot stays docked mid-run (task_status-driven re-checks), booking a mid-run dock as overhead, segmenting the buffered samples into a pending review record, and the review-wizard server side (confirm / list-pending / discard / resegment). It was extracted from core/manager.py, which keeps thin delegators for every moved method, so its tests live under the manager's own suites: tests/integration/test_manager_external_finalize.py drives the grace-timer finalize end-to-end (EXT-FIN-1), the 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); test_manager_lifecycle_status.py covers the capture-open path. EXTERNAL_FINALIZE_GRACE_S / EXTERNAL_GRACE_MAX_RECHECKS (constants.py) size the grace window and its recheck cap.

zone_learning.py — saved-zone TIME learning (pure, ZL-1..ZL-10)

A deliberately tiny parallel to room learning: a saved zone has a stable zone_id, a deterministic (never-learned) area, and cleans in one uninterrupted pass, so the only thing learned is a wall-clock time average keyed by (zone_id, clean_mode) (the "mop"/"vacuum" bucket). collect_zone_observations pulls a single-zone phase's timing into one observation and skips multi-zone steps (time isn't attributable to one zone_id) and non-zone/malformed input; update_learned_zone seeds avg=wall, count=1 on the first sample then runs a running mean, keeping mop and vacuum (and different zone ids) in separate buckets; record_observations folds a batch and returns the applied count; estimate_zone_seconds prefers the learned average once a sample exists, falls back to area × a per-mode rate before any sample, and clamps the estimate to a sane band. Store lives on the map bucket (map_bucket["learned_zones"]), persisted with the map.

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. The build_segments freeze/stall wall-carve (a gap longer than stall_wall_s between cleaning_time ticks is dead time, subtracted from that segment's time_wall_s) is covered in test_counter_segmentation.py: a carved interior freeze, a sub-threshold gap left intact (normal runs stay byte-identical), and a freeze that becomes a boundary landing in overhead rather than inflating a room — external-run robustness Item 2.


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_finalizer cancel-detection sub-branches — the early_return_likely_cancelled return (~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 (the floor_time_too_short return, ~1222-1235) is similarly conditional.
  • Defensive except / # pragma: no cover blocks across all modules — e.g. job_finalizer 1354-1359 (incomplete-run-log write) and 1449-1453 (trouble-rooms write), and the per-room estimate_failed handler logic in manager. Reachable only by injecting malformed data; intentionally skipped.
  • manager accuracy-normalization guards (800, 805, 808) — scattered defensive branches in the accuracy-stats normalization loop: the else: accuracy_entries = [] shape fallback and the non-dict / empty-slug continue guards. (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.
  • manager direct 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_boundary gap (an inert trace→room-boundary derivation in job_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:

  1. Pure logic? Add to the matching unit file (utils, estimator, history_store, stats_rebuilder, or the job_finalizer helper tests). Use tmp_path if it touches the store.
  2. A new service or finalize path? Add a coverage target (LS-n) to test_learning_services.py, seed with the existing helpers, and start from recipe D.
  3. Reader/writer of a stats file? Seed through the real writer so the shape stays canonical (gotchas §3).
  4. Re-measure with all six files together to get the true module number.