15 — Adapters — Subsystem Test Map¶
The adapter subsystem is the brand-abstraction boundary: a registry maps each
vacuum entity to an adapter config (entities, vocabulary, water/upkeep models,
maintenance components), loaded from storage and validated against a schema. The
one concrete adapter (adapters/eufy/) lives behind this boundary and has its
own focused suite in tests/adapters/eufy/. Covered by 31 framework tests
across 2 files (test_adapters.py + the brand-agnostic
test_adapter_contract.py conformance harness), plus 160 Eufy-adapter tests.
Source: custom_components/eufy_vacuum/adapters/
Architecture reference: docs/dev/21-adapter-system.md, docs/dev/22-adapter-config-reference.md
Coverage map¶
| Source module | Stmts | Cov | Test files | Layer |
|---|---|---|---|---|
registry.py |
154 | 93% | test_adapters.py |
integration |
config_loader.py |
33 | 100% | test_adapters.py |
integration |
config_schema.py |
2 | 100% | test_adapters.py |
integration |
eufy/segmentor.py |
866 | 91% | tests/adapters/eufy/ |
adapter |
eufy/adapter.py |
52 | 85% | tests/adapters/eufy/ |
adapter |
eufy/discovery.py |
54 | 100% | test_discovery.py |
adapter |
eufy/entities.py |
28 | 100% | test_buttons_entities.py |
adapter |
eufy/lifecycle.py |
21 | 100% | test_lifecycle.py |
adapter |
eufy/constants.py |
15 | 100% | tests/adapters/eufy/ |
adapter |
eufy/model_catalog.py |
12 | 100% | test_model_catalog.py |
adapter |
eufy/vocabulary.py |
12 | 100% | tests/adapters/eufy/ |
adapter |
eufy/const.py |
8 | 100% | tests/adapters/eufy/ |
adapter |
eufy/buttons.py |
4 | 100% | test_buttons_entities.py |
adapter |
eufy/upkeep_catalog.py |
3 | 100% | tests/adapters/eufy/ |
adapter |
eufy/water_config.py |
3 | 100% | tests/adapters/eufy/ |
adapter |
eufy/maintenance_components.py |
1 | 100% | tests/adapters/eufy/ |
adapter |
eufy/upkeep_guides.py |
1 | 100% | tests/adapters/eufy/ |
adapter |
The Eufy adapter also pins two pluggable engine seams that live under
learning/ (the adapter declares the engine; the engine itself is
brand-agnostic — see 06 — learning):
Engine seam (under learning/) |
Test file | Layer |
|---|---|---|
room_attribution_engines.py (EufyAnchorWindingAttributor) |
test_room_attribution.py |
adapter |
job_segmenter_engines.py (EufyCounterSegmenter) |
test_job_segmenter_config.py |
adapter |
(Adapter-config services are in 17 — services via
test_services_adapter_config.py.)
What's tested¶
- Registry — register / get adapter config, the module-level shims
(
get_adapter_config,get_adapter_value), coordinator wiring, and the all-configs accessor. - Config loader — loading stored adapter configs from
hass_storageand registering them (incl. the per-config skip-one-on-error resilience). - Eufy adapter (separate suite,
tests/adapters/eufy/) —model_catalogresolution (code + hint matching),discoveryandlifecyclehelpers, thebuttons/entitiescandidate-data shape, and the CVsegmentorwrapper + splitter helpers. (Charging reads are brand-agnostic now and tested intests/unit/test_charging.py— see 01 — core.) - Eufy engine seams (also in
tests/adapters/eufy/) — the two pluggable engines the Eufy adapter declares.test_room_attribution.pypins the portedEufyAnchorWindingAttributor(learning/room_attribution_engines.py) against the 3 adversarial external-run fixtures (the 9/9 dwell + spread + winding + swept-area attribution, dock-trap exclusion included).test_job_segmenter_config.pyasserts the Eufy adapter declaresjob_segmenter.engine = "eufy_counter_v1", that itsjob_segmenter.tuningequalsEufyCounterSegmenter.DEFAULT_TUNING(no threshold drift after the move out oflive_transition), and that the declared engine resolves and validates clean. - Brand-aware diagnostics self_check (
DIAG-*, integration,tests/integration/test_diagnostics.py) —_self_checkreads a native-integration brand (Roborock: rooms from its own integration, noactive_mapsensor, no Eufysegmentsattribute) as rooms/map WORKING and brand-named, driven by theroborock_geometry_driftdecode-drift block in the dump, rather than the Eufy-shaped "unknown / unavailable / no" the transport-only heuristic produced (DIAG-9); and degrades to a generic "native integration" + map-"pending" summary when the raw map hasn't decoded yet and the brand string is absent (DIAG-10).
The adapter coverage boundary¶
adapters/eufy/* is counted in the coverage number — we always test the
adapters we ship, so the figure includes them. The Eufy adapter is well covered
(160 tests in tests/adapters/eufy/): model_catalog, discovery, lifecycle,
and the buttons/entities data shape sit at or near 100%. The CV segmentor
is now 91% — the splitter helpers, recovery / scoring / issue-tag paths, and
(via two map fixtures) the localized-bins SPLIT + child-handling are all covered;
its remaining tail is the splitter-internal alternative sub-branches (see Known
gaps), the natural place a second-brand effort would invest. adapter.py (95%) is missing
only line 110 — the return None guard in the small helper _build_button_block
when a button key is absent from both candidates and tokens maps. See
01 — overview for the three-layer split.
Known gaps¶
registry.py (94%) leaves only defensive validator arms uncovered — the
append-an-issue branches that reject a malformed stored adapter config
(room_profiles / dispatch block-shape checks, missing registry.py lines
305, 309, 322, 409, 436, 442). The job_segmenter engine-validation arms
(not-a-dict / missing / unknown engine) are now covered — test_adapters.py
asserts that contract so an unknown engine can't silently fall back. The rest are
error paths for invalid storage, not real behavior holes. adapter.py (95%)
is missing one line (110), the return None guard in _build_button_block
for a component with no reset button — likewise defensive.
The one remaining thin spot is CV segmentor depth (91%, up from 70% — first
the splitter / recovery / scoring / issue-tag tests and the _prune_localized_siblings
extraction ([SP-prune]), then two map fixtures that drive the full pipeline). The
localized-bins SPLIT is the deepest tier, and it took two fixtures to pin: a dense
over-segmented synthetic map ([ECV-8], adversarial_map.png) covers the
classification / scoring / overlap-dedup paths (~1357–1385, ~1427–1429), but it can
only make localized-bins run-and-reject — the accept gate is a narrow hue window.
The one input that reaches localized accept plus its child-handling (reclaim /
rank / prune of recovered room pockets) is a real map run exactly as the integration
runs it — dark primary + light assist — where adjacent rooms fuse with the blue
background into a single >120k-px component ([ECV-9], localized_map_*.png);
diagnostic-confirmed as the only input that hits accept, and it exercises 502/866
statements in one pass. What's genuinely left is the splitter-internal alternative
sub-branches the accepted path skips (assist-hue / colour-distance / erosion variants,
~475–520, ~605–648, ~807–828, partial ~1351–1365), the env-gated scipy-absent guard
(943), and defensive continues (1056-1057, 1100, 1112, 1150) — each geometry-sensitive
or best-effort. Tested in test_segmentor.py + test_segmentor_splitters.py; held
here on purpose, a known thin spot rather than a framework miss.