Skip to content

Release v5.5.0-beta - #38

Merged
tis24dev merged 7 commits into
mainfrom
dev
Jun 28, 2026
Merged

Release v5.5.0-beta#38
tis24dev merged 7 commits into
mainfrom
dev

Conversation

@tis24dev

@tis24dev tis24dev commented Jun 28, 2026

Copy link
Copy Markdown
Owner

Automated release PR for v5.5.0-beta.

Summary by CodeRabbit

  • New Features
    • Added writable washer/dryer “program option” controls (select, switch, and numeric delay/option values) for supported models.
    • Changes are buffered and automatically applied when you start the selected program.
  • Bug Fixes
    • Improved validation and error handling for option values (prevents off-grid/invalid selections).
    • Pending options are now applied reliably and only cleared after a successful start.
  • Documentation
    • Updated English and Italian translations for the new program-option controls.

Greptile Summary

This PR adds buffered washer and dryer program options for the v5.5.0 beta release. The main changes are:

  • New select, switch, and number entities for supported start-program options.
  • Shared option buffering and apply-on-start helpers.
  • Pending option clearing when the selected program changes.
  • Duplicate dry-level label handling for dryer schemas.
  • English and Italian translations for the new entities.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
custom_components/addhon/button.py Applies pending options to the active start command and clears sent values after a successful start.
custom_components/addhon/select.py Adds program-option selects and clears buffered options when the selected program changes.
custom_components/addhon/number.py Adds delayed-start option support with live range validation.
custom_components/addhon/program_options.py Introduces shared helpers for option discovery, normalization, buffering, and application.
custom_components/addhon/switch.py Adds schema-gated switch entities for boolean program options.

Reviews (3): Last reviewed commit: "test(#35): PR #38 #8 — assert catalog ke..." | Re-trigger Greptile

tis24dev and others added 2 commits June 28, 2026 18:46
Washers/dryers exposed only a program select + start/stop/pause. This adds the
configurable program options users asked for (spin speed, temperature, delay time,
dry level, temperature level, extra rinses, acqua plus, prewash/hygiene/anti-crease/
good-night, sterilization, tumbling, anti-crease time), as number/select/switch
entities for WM, WD and TD.

Schema-driven, capability-gated: an entity is created only when the device's
startProgram command actually declares the param with >= 2 settable values (not
fixed). This is the deliberate improvement over the static per-type superset of
andre0512/hon, which renders a wall of unavailable controls; here a model that fixes
a param simply gets no entity. Values/ranges are read from the device schema at
runtime (never hardcoded); const maps supply labels only. dryLevel is type-gated
(WM/WD vs TD value semantics differ) and the 0/11 sentinels are hidden.

Write model mirrors the existing program flow: option entities BUFFER their value in
a per-appliance PROGRAM_PENDING_OPTIONS store (no immediate send); the start button
applies the program (category swap), then the buffered options onto the post-swap
command, then sends one startProgram bundle, clearing the buffer on success.

- new program_options.py: the startProgram gate, range materializer, the
  apply-on-start helper, and the shared HonProgramOptionEntity mixin
- switch/select/number: option entity classes + wash-group setup gating
- const.py: PROGRAM_PENDING_OPTIONS + label maps (decomp-derived) + sentinels
- en/it translations (parity) incl. select state blocks
- tests: gate, buffer-no-send, read precedence, apply-on-start (post-swap),
  capability-gate, type-gate, stop-ignores, failure-keeps-buffer; reality fixtures
  built from a real WM (Candy TCA286TM5-S) + TD (Haier HD90-A3959) schema

Built via 3 convergent constructors + 3-lens refuter pool to HOLD. The live WRITE
test on a real appliance remains the release gate (no blind writes); reads/buffering
ship on schema confirmation.
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jun 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@tis24dev, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 43 minutes and 8 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 31e74e21-966f-4cbc-b64b-b8b3a9993d21

📥 Commits

Reviewing files that changed from the base of the PR and between d1eaff0 and 1ba0483.

📒 Files selected for processing (1)
  • tests/test_wash_option_params.py
📝 Walkthrough

Walkthrough

Adds buffered writable wash program options for switches, selects, and numbers, applies them during startProgram, and extends translations, manifest version, and tests for option gating, catalog pinning, and translation coverage.

Changes

Writable wash program options

Layer / File(s) Summary
program_options.py: resolution, gating, buffering
custom_components/addhon/program_options.py
Defines startProgram resolution, option range and choice helpers, settable-option gating, pending-option application, and the shared buffered option entity mixin.
const.py: pending options and label maps
custom_components/addhon/const.py
Adds the pending-options store key and the label-map and sentinel constants used by program-option entities.
switch.py: program option switches
custom_components/addhon/switch.py
Adds the program-option switch catalog, entity description, setup wiring, and token-based on/off entity behavior.
select.py: program option selects
custom_components/addhon/select.py
Adds the program-option select catalog, entity description, setup wiring, program buffering, and raw-to-display option mapping.
number.py: program option numbers
custom_components/addhon/number.py
Adds the program-option number catalog, entity description, setup wiring, live range derivation, and buffered numeric validation.
button.py: apply pending options on startProgram
custom_components/addhon/button.py
Snapshots pending options on startProgram press, applies them after any program swap, and clears sent option values after a successful send.
Translations and manifest version
custom_components/addhon/translations/en.json, custom_components/addhon/translations/it.json, custom_components/addhon/manifest.json
Adds translation entries for the new switch, select, and number entities in both languages and bumps the manifest version.
Test stubs and program-option behavior tests
tests/conftest.py, tests/test_program_options.py
Adds shared Home Assistant stubs and a large unittest suite covering gating, buffering, read precedence, apply-on-start behavior, setup gating, dry-level mapping, duplicate-label handling, dependency behavior, and post-send clearing.
Catalog pinning, translation keys, and redaction tests
tests/test_entity_translation_keys.py, tests/test_wash_option_params.py, tests/test_translations.py, tests/test_log_identity_redaction.py
Adds real-device catalog pin tests, translation-key coverage for program-option entities, select-state translation validation, a translation string regression check, and log-redaction coverage for program_options.py.

Sequence Diagram(s)

sequenceDiagram
  participant UI as HA UI
  participant Entity as HonProgramOptionEntity
  participant Store as Coordinator store
  participant Button as HonProgramCommandButton
  participant Command as startProgram command

  UI->>Entity: set switch/select/number value
  Entity->>Store: buffer pending option value
  UI->>Button: press startProgram
  Button->>Store: snapshot pending_program + pending_options
  Button->>Command: swap active command
  Button->>Command: apply_pending_options()
  Command-->>Button: applied option names
  Button->>Command: send()
  Button->>Store: clear sent pending options
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • tis24dev/addhOn#31: Shares the same HonProgramCommandButton.async_press startProgram flow that now applies buffered pending options.
  • tis24dev/addhOn#36: Touches the same log-identity redaction test area that now includes program_options.py.

Poem

🐇 I hop through wash cycles with a twitch,
options buffered, then flipped like a switch.
StartProgram thumps, and the settings all bloom,
spin, dry, and steam dance out of the gloom.
✨ Fresh laundry magic, with nary a glitch!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the primary change: this PR is a v5.5.0-beta release bundle.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread custom_components/addhon/button.py
Comment thread custom_components/addhon/select.py
Comment thread custom_components/addhon/number.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@custom_components/addhon/button.py`:
- Around line 140-143: The success path in `custom_components/addhon/button.py`
is deleting the full per-appliance entry even though `pending_options` was only
a snapshot taken before the executor job, so newer buffered option writes can be
lost. Update the start-program flow around `pending_options` and the later
success cleanup to preserve any options written after the snapshot by only
removing the values that were actually sent, leaving newer `options_store`
entries intact. Apply the same fix in the corresponding success cleanup for the
related start/option handling path referenced by the `startProgram` logic.

In `@custom_components/addhon/const.py`:
- Around line 88-90: The dry-level sentinel list in DRY_LEVEL_SENTINELS is
missing the empty-string value, so blank dry-level codes can still be treated as
selectable and affect gating in the dry-level select setup. Update
DRY_LEVEL_SENTINELS in const.py to include the empty-string sentinel alongside
the existing values, and ensure the select logic in select.py continues to use
that shared tuple for both dry-level selects.

In `@custom_components/addhon/program_options.py`:
- Around line 132-143: Fix the uneven-range handling in the range-choice
generation logic and in is_settable_option(). The loop that builds choices from
rng should not rely on current <= hi + step / 2, since that can emit values past
hi; instead only include values that are actually within the declared bounds
before appending to out. Then update is_settable_option() so drop-free ranges
are only considered settable when there is more than one reachable choice after
applying the step and bounds, not just because max > min.

In `@custom_components/addhon/translations/en.json`:
- Around line 642-658: The translation entry for anti_crease_time duplicates the
label used by anticrease, so the two switches are indistinguishable in Home
Assistant. Update the anti_crease_time.name value in the translations JSON to a
time-specific label that clearly differentiates it from anticrease, keeping
anticrease unchanged.

In `@custom_components/addhon/translations/it.json`:
- Around line 642-658: The Italian translation currently uses the same label for
both anticrease and anti_crease_time, making the UI ambiguous. Update the
translation entry for anti_crease_time in the it.json dictionary to a
time-specific Italian label, while leaving the anticrease entry unchanged; use
the anti_crease_time key to locate the affected string.

In `@tests/test_program_options.py`:
- Around line 39-123: The Home Assistant test fakes installed by
_install_stubs() are leaking into sys.modules for the whole test session. Move
this setup in tests/test_program_options.py to be module- or fixture-scoped so
only the tests in this module use the stubbed homeassistant.* objects, and
ensure cached custom_components.addhon.* imports are not reused across later
tests. Use _install_stubs and the module-level import setup as the key places to
constrain the stubs.

In `@tests/test_wash_option_params.py`:
- Around line 267-277: The pinning checks in test_switch_catalog_pinned,
test_select_catalog_pinned, and test_number_catalog_pinned currently collapse
entries by key or key/param pair, so duplicate keys can still pass unnoticed.
Update these tests to assert key uniqueness explicitly, and extend
test_catalog_keys_and_params_unique_within_type() so it validates both unique
params and unique keys (including the unique-id suffix collision case) for each
catalog type. Use the existing _PROGRAM_OPTION_SWITCHES,
_PROGRAM_OPTION_SELECTS, and _PROGRAM_OPTION_NUMBERS collections as the source
of truth when adding the duplicate-key assertion.
- Around line 47-144: The test module is mutating sys.modules at import time via
_install_stubs(), which can leak fake homeassistant modules into later tests and
make collection order-dependent. Move the stub installation and the imports of
custom_components.addhon, HonCommand, and program_options into
setUpModule()/tearDownModule() or a local helper, and restore the original
sys.modules entries afterward so the stubs stay isolated to this test module.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: da88c3d7-1ab5-475e-8a08-4151c7073c7e

📥 Commits

Reviewing files that changed from the base of the PR and between 3f292d4 and ab1c68b.

⛔ Files ignored due to path filters (2)
  • tests/fixtures/td_haier_hd90/startprogram_params.json is excluded by !tests/fixtures/**
  • tests/fixtures/wm_candy_tca286/startprogram_params.json is excluded by !tests/fixtures/**
📒 Files selected for processing (13)
  • custom_components/addhon/button.py
  • custom_components/addhon/const.py
  • custom_components/addhon/manifest.json
  • custom_components/addhon/number.py
  • custom_components/addhon/program_options.py
  • custom_components/addhon/select.py
  • custom_components/addhon/switch.py
  • custom_components/addhon/translations/en.json
  • custom_components/addhon/translations/it.json
  • tests/test_entity_translation_keys.py
  • tests/test_log_identity_redaction.py
  • tests/test_program_options.py
  • tests/test_wash_option_params.py

Comment thread custom_components/addhon/button.py
Comment thread custom_components/addhon/const.py Outdated
Comment thread custom_components/addhon/program_options.py
Comment thread custom_components/addhon/translations/en.json Outdated
Comment thread custom_components/addhon/translations/it.json Outdated
Comment on lines +39 to +123
def _install_stubs() -> None:
ha = _mod("homeassistant")

ce = _mod("homeassistant.config_entries")
ce.ConfigEntry = getattr(ce, "ConfigEntry", type("ConfigEntry", (), {}))

core = _mod("homeassistant.core")
core.HomeAssistant = getattr(core, "HomeAssistant", type("HomeAssistant", (), {}))

exc = _mod("homeassistant.exceptions")
base_err = getattr(exc, "HomeAssistantError", type("HomeAssistantError", (Exception,), {}))
exc.HomeAssistantError = base_err
exc.ConfigEntryNotReady = getattr(exc, "ConfigEntryNotReady", type("ConfigEntryNotReady", (base_err,), {}))
exc.ConfigEntryAuthFailed = getattr(exc, "ConfigEntryAuthFailed", type("ConfigEntryAuthFailed", (base_err,), {}))

helpers = _mod("homeassistant.helpers")
entity = _mod("homeassistant.helpers.entity")
entity.DeviceInfo = getattr(entity, "DeviceInfo", dict)
dr = _mod("homeassistant.helpers.device_registry")
dr.DeviceEntryType = getattr(dr, "DeviceEntryType", type("DeviceEntryType", (), {"SERVICE": "service"}))
ep = _mod("homeassistant.helpers.entity_platform")
ep.AddEntitiesCallback = getattr(ep, "AddEntitiesCallback", object)
er = _mod("homeassistant.helpers.entity_registry")
er.async_get = getattr(er, "async_get", lambda hass: None)
er.async_entries_for_config_entry = getattr(
er, "async_entries_for_config_entry", lambda registry, entry_id: []
)
uc = _mod("homeassistant.helpers.update_coordinator")

class CoordinatorEntity:
def __init__(self, coordinator) -> None:
self.coordinator = coordinator
self.hass = getattr(coordinator, "hass", None)

def async_write_ha_state(self) -> None:
self.state_writes = getattr(self, "state_writes", 0) + 1

uc.CoordinatorEntity = getattr(uc, "CoordinatorEntity", CoordinatorEntity)
uc.DataUpdateCoordinator = getattr(uc, "DataUpdateCoordinator", type("DataUpdateCoordinator", (), {}))
uc.UpdateFailed = getattr(uc, "UpdateFailed", type("UpdateFailed", (Exception,), {}))

const = _mod("homeassistant.const")
for unit_cls in ("UnitOfTemperature", "UnitOfTime"):
if not hasattr(const, unit_cls):
setattr(const, unit_cls, type(unit_cls, (), {"CELSIUS": "C", "MINUTES": "min", "SECONDS": "s"}))
const.EntityCategory = getattr(
const, "EntityCategory", type("EntityCategory", (), {"CONFIG": "config", "DIAGNOSTIC": "diagnostic"})
)

components = _mod("homeassistant.components")
_mod("homeassistant.components.switch").SwitchEntity = type("SwitchEntity", (), {})
_mod("homeassistant.components.select").SelectEntity = type("SelectEntity", (), {})
_mod("homeassistant.components.button").ButtonEntity = type("ButtonEntity", (), {})
number_mod = _mod("homeassistant.components.number")
import dataclasses

@dataclasses.dataclass(frozen=True, kw_only=True)
class NumberEntityDescription:
key: str
name: str | None = None
translation_key: str | None = None
icon: str | None = None
device_class: object | None = None
native_unit_of_measurement: str | None = None
mode: object | None = None

number_mod.NumberEntityDescription = getattr(number_mod, "NumberEntityDescription", NumberEntityDescription)
number_mod.NumberEntity = getattr(number_mod, "NumberEntity", type("NumberEntity", (), {}))
number_mod.NumberDeviceClass = getattr(number_mod, "NumberDeviceClass", type("NumberDeviceClass", (), {"TEMPERATURE": "temperature"}))
number_mod.NumberMode = getattr(number_mod, "NumberMode", type("NumberMode", (), {"AUTO": "auto", "BOX": "box", "SLIDER": "slider"}))

ha.config_entries = ce
ha.core = core
ha.exceptions = exc
ha.helpers = helpers
ha.const = const
ha.components = components
helpers.entity = entity
helpers.entity_platform = ep
helpers.entity_registry = er
helpers.update_coordinator = uc
helpers.device_registry = dr


_install_stubs()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Scope the Home Assistant stubs to this module.

Installing these fakes into sys.modules at import time leaks into the rest of the test session. Later tests can end up importing the stubbed homeassistant.* modules — and even cached custom_components.addhon.* modules that were loaded against those stubs — so suite results become order-dependent.

Possible fix
-_install_stubs()
+_ORIGINAL_HA_MODULES = {
+    name: module
+    for name, module in sys.modules.items()
+    if name == "homeassistant" or name.startswith("homeassistant.")
+}
+
+
+def setUpModule() -> None:
+    _install_stubs()
+
+
+def tearDownModule() -> None:
+    for name in list(sys.modules):
+        if name == "homeassistant" or name.startswith("homeassistant."):
+            sys.modules.pop(name, None)
+        if name == "custom_components.addhon" or name.startswith("custom_components.addhon."):
+            sys.modules.pop(name, None)
+    sys.modules.update(_ORIGINAL_HA_MODULES)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _install_stubs() -> None:
ha = _mod("homeassistant")
ce = _mod("homeassistant.config_entries")
ce.ConfigEntry = getattr(ce, "ConfigEntry", type("ConfigEntry", (), {}))
core = _mod("homeassistant.core")
core.HomeAssistant = getattr(core, "HomeAssistant", type("HomeAssistant", (), {}))
exc = _mod("homeassistant.exceptions")
base_err = getattr(exc, "HomeAssistantError", type("HomeAssistantError", (Exception,), {}))
exc.HomeAssistantError = base_err
exc.ConfigEntryNotReady = getattr(exc, "ConfigEntryNotReady", type("ConfigEntryNotReady", (base_err,), {}))
exc.ConfigEntryAuthFailed = getattr(exc, "ConfigEntryAuthFailed", type("ConfigEntryAuthFailed", (base_err,), {}))
helpers = _mod("homeassistant.helpers")
entity = _mod("homeassistant.helpers.entity")
entity.DeviceInfo = getattr(entity, "DeviceInfo", dict)
dr = _mod("homeassistant.helpers.device_registry")
dr.DeviceEntryType = getattr(dr, "DeviceEntryType", type("DeviceEntryType", (), {"SERVICE": "service"}))
ep = _mod("homeassistant.helpers.entity_platform")
ep.AddEntitiesCallback = getattr(ep, "AddEntitiesCallback", object)
er = _mod("homeassistant.helpers.entity_registry")
er.async_get = getattr(er, "async_get", lambda hass: None)
er.async_entries_for_config_entry = getattr(
er, "async_entries_for_config_entry", lambda registry, entry_id: []
)
uc = _mod("homeassistant.helpers.update_coordinator")
class CoordinatorEntity:
def __init__(self, coordinator) -> None:
self.coordinator = coordinator
self.hass = getattr(coordinator, "hass", None)
def async_write_ha_state(self) -> None:
self.state_writes = getattr(self, "state_writes", 0) + 1
uc.CoordinatorEntity = getattr(uc, "CoordinatorEntity", CoordinatorEntity)
uc.DataUpdateCoordinator = getattr(uc, "DataUpdateCoordinator", type("DataUpdateCoordinator", (), {}))
uc.UpdateFailed = getattr(uc, "UpdateFailed", type("UpdateFailed", (Exception,), {}))
const = _mod("homeassistant.const")
for unit_cls in ("UnitOfTemperature", "UnitOfTime"):
if not hasattr(const, unit_cls):
setattr(const, unit_cls, type(unit_cls, (), {"CELSIUS": "C", "MINUTES": "min", "SECONDS": "s"}))
const.EntityCategory = getattr(
const, "EntityCategory", type("EntityCategory", (), {"CONFIG": "config", "DIAGNOSTIC": "diagnostic"})
)
components = _mod("homeassistant.components")
_mod("homeassistant.components.switch").SwitchEntity = type("SwitchEntity", (), {})
_mod("homeassistant.components.select").SelectEntity = type("SelectEntity", (), {})
_mod("homeassistant.components.button").ButtonEntity = type("ButtonEntity", (), {})
number_mod = _mod("homeassistant.components.number")
import dataclasses
@dataclasses.dataclass(frozen=True, kw_only=True)
class NumberEntityDescription:
key: str
name: str | None = None
translation_key: str | None = None
icon: str | None = None
device_class: object | None = None
native_unit_of_measurement: str | None = None
mode: object | None = None
number_mod.NumberEntityDescription = getattr(number_mod, "NumberEntityDescription", NumberEntityDescription)
number_mod.NumberEntity = getattr(number_mod, "NumberEntity", type("NumberEntity", (), {}))
number_mod.NumberDeviceClass = getattr(number_mod, "NumberDeviceClass", type("NumberDeviceClass", (), {"TEMPERATURE": "temperature"}))
number_mod.NumberMode = getattr(number_mod, "NumberMode", type("NumberMode", (), {"AUTO": "auto", "BOX": "box", "SLIDER": "slider"}))
ha.config_entries = ce
ha.core = core
ha.exceptions = exc
ha.helpers = helpers
ha.const = const
ha.components = components
helpers.entity = entity
helpers.entity_platform = ep
helpers.entity_registry = er
helpers.update_coordinator = uc
helpers.device_registry = dr
_install_stubs()
def _install_stubs() -> None:
ha = _mod("homeassistant")
ce = _mod("homeassistant.config_entries")
ce.ConfigEntry = getattr(ce, "ConfigEntry", type("ConfigEntry", (), {}))
core = _mod("homeassistant.core")
core.HomeAssistant = getattr(core, "HomeAssistant", type("HomeAssistant", (), {}))
exc = _mod("homeassistant.exceptions")
base_err = getattr(exc, "HomeAssistantError", type("HomeAssistantError", (Exception,), {}))
exc.HomeAssistantError = base_err
exc.ConfigEntryNotReady = getattr(exc, "ConfigEntryNotReady", type("ConfigEntryNotReady", (base_err,), {}))
exc.ConfigEntryAuthFailed = getattr(exc, "ConfigEntryAuthFailed", type("ConfigEntryAuthFailed", (base_err,), {}))
helpers = _mod("homeassistant.helpers")
entity = _mod("homeassistant.helpers.entity")
entity.DeviceInfo = getattr(entity, "DeviceInfo", dict)
dr = _mod("homeassistant.helpers.device_registry")
dr.DeviceEntryType = getattr(dr, "DeviceEntryType", type("DeviceEntryType", (), {"SERVICE": "service"}))
ep = _mod("homeassistant.helpers.entity_platform")
ep.AddEntitiesCallback = getattr(ep, "AddEntitiesCallback", object)
er = _mod("homeassistant.helpers.entity_registry")
er.async_get = getattr(er, "async_get", lambda hass: None)
er.async_entries_for_config_entry = getattr(
er, "async_entries_for_config_entry", lambda registry, entry_id: []
)
uc = _mod("homeassistant.helpers.update_coordinator")
class CoordinatorEntity:
def __init__(self, coordinator) -> None:
self.coordinator = coordinator
self.hass = getattr(coordinator, "hass", None)
def async_write_ha_state(self) -> None:
self.state_writes = getattr(self, "state_writes", 0) + 1
uc.CoordinatorEntity = getattr(uc, "CoordinatorEntity", CoordinatorEntity)
uc.DataUpdateCoordinator = getattr(uc, "DataUpdateCoordinator", type("DataUpdateCoordinator", (), {}))
uc.UpdateFailed = getattr(uc, "UpdateFailed", type("UpdateFailed", (Exception,), {}))
const = _mod("homeassistant.const")
for unit_cls in ("UnitOfTemperature", "UnitOfTime"):
if not hasattr(const, unit_cls):
setattr(const, unit_cls, type(unit_cls, (), {"CELSIUS": "C", "MINUTES": "min", "SECONDS": "s"}))
const.EntityCategory = getattr(
const, "EntityCategory", type("EntityCategory", (), {"CONFIG": "config", "DIAGNOSTIC": "diagnostic"})
)
components = _mod("homeassistant.components")
_mod("homeassistant.components.switch").SwitchEntity = type("SwitchEntity", (), {})
_mod("homeassistant.components.select").SelectEntity = type("SelectEntity", (), {})
_mod("homeassistant.components.button").ButtonEntity = type("ButtonEntity", (), {})
number_mod = _mod("homeassistant.components.number")
import dataclasses
`@dataclasses.dataclass`(frozen=True, kw_only=True)
class NumberEntityDescription:
key: str
name: str | None = None
translation_key: str | None = None
icon: str | None = None
device_class: object | None = None
native_unit_of_measurement: str | None = None
mode: object | None = None
number_mod.NumberEntityDescription = getattr(number_mod, "NumberEntityDescription", NumberEntityDescription)
number_mod.NumberEntity = getattr(number_mod, "NumberEntity", type("NumberEntity", (), {}))
number_mod.NumberDeviceClass = getattr(number_mod, "NumberDeviceClass", type("NumberDeviceClass", (), {"TEMPERATURE": "temperature"}))
number_mod.NumberMode = getattr(number_mod, "NumberMode", type("NumberMode", (), {"AUTO": "auto", "BOX": "box", "SLIDER": "slider"}))
ha.config_entries = ce
ha.core = core
ha.exceptions = exc
ha.helpers = helpers
ha.const = const
ha.components = components
helpers.entity = entity
helpers.entity_platform = ep
helpers.entity_registry = er
helpers.update_coordinator = uc
helpers.device_registry = dr
_ORIGINAL_HA_MODULES = {
name: module
for name, module in sys.modules.items()
if name == "homeassistant" or name.startswith("homeassistant.")
}
def setUpModule() -> None:
_install_stubs()
def tearDownModule() -> None:
for name in list(sys.modules):
if name == "homeassistant" or name.startswith("homeassistant."):
sys.modules.pop(name, None)
if name == "custom_components.addhon" or name.startswith("custom_components.addhon."):
sys.modules.pop(name, None)
sys.modules.update(_ORIGINAL_HA_MODULES)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_program_options.py` around lines 39 - 123, The Home Assistant test
fakes installed by _install_stubs() are leaking into sys.modules for the whole
test session. Move this setup in tests/test_program_options.py to be module- or
fixture-scoped so only the tests in this module use the stubbed homeassistant.*
objects, and ensure cached custom_components.addhon.* imports are not reused
across later tests. Use _install_stubs and the module-level import setup as the
key places to constrain the stubs.

Comment on lines +47 to +144
def _install_stubs() -> None:
ha = _mod("homeassistant")

ce = _mod("homeassistant.config_entries")
ce.ConfigEntry = getattr(ce, "ConfigEntry", type("ConfigEntry", (), {}))

core = _mod("homeassistant.core")
core.HomeAssistant = getattr(core, "HomeAssistant", type("HomeAssistant", (), {}))

exc = _mod("homeassistant.exceptions")
base_err = getattr(exc, "HomeAssistantError", type("HomeAssistantError", (Exception,), {}))
exc.HomeAssistantError = base_err
exc.ConfigEntryNotReady = getattr(exc, "ConfigEntryNotReady", type("ConfigEntryNotReady", (base_err,), {}))
exc.ConfigEntryAuthFailed = getattr(exc, "ConfigEntryAuthFailed", type("ConfigEntryAuthFailed", (base_err,), {}))

helpers = _mod("homeassistant.helpers")
entity = _mod("homeassistant.helpers.entity")
entity.DeviceInfo = getattr(entity, "DeviceInfo", dict)
dr = _mod("homeassistant.helpers.device_registry")
dr.DeviceEntryType = getattr(dr, "DeviceEntryType", type("DeviceEntryType", (), {"SERVICE": "service"}))
ep = _mod("homeassistant.helpers.entity_platform")
ep.AddEntitiesCallback = getattr(ep, "AddEntitiesCallback", object)
er = _mod("homeassistant.helpers.entity_registry")
er.async_get = getattr(er, "async_get", lambda hass: None)
er.async_entries_for_config_entry = getattr(
er, "async_entries_for_config_entry", lambda registry, entry_id: []
)
uc = _mod("homeassistant.helpers.update_coordinator")

class CoordinatorEntity:
def __init__(self, coordinator) -> None:
self.coordinator = coordinator

uc.CoordinatorEntity = getattr(uc, "CoordinatorEntity", CoordinatorEntity)
uc.DataUpdateCoordinator = getattr(uc, "DataUpdateCoordinator", type("DataUpdateCoordinator", (), {}))
uc.UpdateFailed = getattr(uc, "UpdateFailed", type("UpdateFailed", (Exception,), {}))

const = _mod("homeassistant.const")
for unit_cls in ("UnitOfTemperature", "UnitOfEnergy", "UnitOfTime", "UnitOfVolume", "UnitOfMass"):
if not hasattr(const, unit_cls):
setattr(const, unit_cls, type(unit_cls, (), {
"CELSIUS": "C", "KILO_WATT_HOUR": "kWh", "MINUTES": "min", "LITERS": "L",
"GRAMS": "g", "KILOGRAMS": "kg", "SECONDS": "s",
}))
const.EntityCategory = getattr(
const, "EntityCategory", type("EntityCategory", (), {"CONFIG": "config", "DIAGNOSTIC": "diagnostic"})
)

components = _mod("homeassistant.components")
_mod("homeassistant.components.switch").SwitchEntity = type("SwitchEntity", (), {})
_mod("homeassistant.components.select").SelectEntity = type("SelectEntity", (), {})

number_mod = _mod("homeassistant.components.number")
import dataclasses

@dataclasses.dataclass(frozen=True, kw_only=True)
class NumberEntityDescription:
key: str
name: str | None = None
translation_key: str | None = None
icon: str | None = None
device_class: object | None = None
native_unit_of_measurement: str | None = None
mode: object | None = None

number_mod.NumberEntityDescription = getattr(number_mod, "NumberEntityDescription", NumberEntityDescription)
number_mod.NumberEntity = getattr(number_mod, "NumberEntity", type("NumberEntity", (), {}))
number_mod.NumberDeviceClass = getattr(number_mod, "NumberDeviceClass", type("NumberDeviceClass", (), {"TEMPERATURE": "temperature"}))
number_mod.NumberMode = getattr(number_mod, "NumberMode", type("NumberMode", (), {"AUTO": "auto", "BOX": "box", "SLIDER": "slider"}))

ha.config_entries = ce
ha.core = core
ha.exceptions = exc
ha.helpers = helpers
ha.const = const
ha.components = components
helpers.entity = entity
helpers.entity_platform = ep
helpers.entity_registry = er
helpers.update_coordinator = uc
helpers.device_registry = dr
components.number = number_mod


_install_stubs()

from custom_components.addhon import number, select, switch # noqa: E402
from custom_components.addhon.client.engine.commands import HonCommand # noqa: E402
from custom_components.addhon.const import ( # noqa: E402
APPLIANCE_TD,
APPLIANCE_WD,
APPLIANCE_WM,
)
from custom_components.addhon.program_options import ( # noqa: E402
is_settable_option,
startprogram_option_param,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Avoid patching sys.modules during test-module import.

Line 131 installs fake homeassistant.* modules globally before collection finishes, so later-imported tests can resolve against these stubs instead of the real package. That makes the suite order-dependent. Move the stub setup and subject imports into setUpModule()/tearDownModule() (or a local loader) and restore sys.modules afterward.

Suggested direction
-_install_stubs()
-
-from custom_components.addhon import number, select, switch  # noqa: E402
-from custom_components.addhon.client.engine.commands import HonCommand  # noqa: E402
-from custom_components.addhon.const import (  # noqa: E402
-    APPLIANCE_TD,
-    APPLIANCE_WD,
-    APPLIANCE_WM,
-)
-from custom_components.addhon.program_options import (  # noqa: E402
-    is_settable_option,
-    startprogram_option_param,
-)
+_ORIGINAL_MODULES: dict[str, types.ModuleType | None] = {}
+
+number = select = switch = None
+HonCommand = None
+APPLIANCE_TD = APPLIANCE_WD = APPLIANCE_WM = None
+is_settable_option = startprogram_option_param = None
+
+def setUpModule() -> None:
+    global number, select, switch
+    global HonCommand, APPLIANCE_TD, APPLIANCE_WD, APPLIANCE_WM
+    global is_settable_option, startprogram_option_param
+
+    for name in (
+        "homeassistant",
+        "homeassistant.config_entries",
+        "homeassistant.core",
+        "homeassistant.exceptions",
+        "homeassistant.helpers",
+        "homeassistant.helpers.entity",
+        "homeassistant.helpers.device_registry",
+        "homeassistant.helpers.entity_platform",
+        "homeassistant.helpers.entity_registry",
+        "homeassistant.helpers.update_coordinator",
+        "homeassistant.const",
+        "homeassistant.components",
+        "homeassistant.components.switch",
+        "homeassistant.components.select",
+        "homeassistant.components.number",
+    ):
+        _ORIGINAL_MODULES[name] = sys.modules.get(name)
+
+    _install_stubs()
+
+    from custom_components.addhon import number as _number, select as _select, switch as _switch
+    from custom_components.addhon.client.engine.commands import HonCommand as _HonCommand
+    from custom_components.addhon.const import APPLIANCE_TD as _APPLIANCE_TD, APPLIANCE_WD as _APPLIANCE_WD, APPLIANCE_WM as _APPLIANCE_WM
+    from custom_components.addhon.program_options import is_settable_option as _is_settable_option, startprogram_option_param as _startprogram_option_param
+
+    number, select, switch = _number, _select, _switch
+    HonCommand = _HonCommand
+    APPLIANCE_TD, APPLIANCE_WD, APPLIANCE_WM = _APPLIANCE_TD, _APPLIANCE_WD, _APPLIANCE_WM
+    is_settable_option, startprogram_option_param = _is_settable_option, _startprogram_option_param
+
+def tearDownModule() -> None:
+    for name, original in _ORIGINAL_MODULES.items():
+        if original is None:
+            sys.modules.pop(name, None)
+        else:
+            sys.modules[name] = original
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _install_stubs() -> None:
ha = _mod("homeassistant")
ce = _mod("homeassistant.config_entries")
ce.ConfigEntry = getattr(ce, "ConfigEntry", type("ConfigEntry", (), {}))
core = _mod("homeassistant.core")
core.HomeAssistant = getattr(core, "HomeAssistant", type("HomeAssistant", (), {}))
exc = _mod("homeassistant.exceptions")
base_err = getattr(exc, "HomeAssistantError", type("HomeAssistantError", (Exception,), {}))
exc.HomeAssistantError = base_err
exc.ConfigEntryNotReady = getattr(exc, "ConfigEntryNotReady", type("ConfigEntryNotReady", (base_err,), {}))
exc.ConfigEntryAuthFailed = getattr(exc, "ConfigEntryAuthFailed", type("ConfigEntryAuthFailed", (base_err,), {}))
helpers = _mod("homeassistant.helpers")
entity = _mod("homeassistant.helpers.entity")
entity.DeviceInfo = getattr(entity, "DeviceInfo", dict)
dr = _mod("homeassistant.helpers.device_registry")
dr.DeviceEntryType = getattr(dr, "DeviceEntryType", type("DeviceEntryType", (), {"SERVICE": "service"}))
ep = _mod("homeassistant.helpers.entity_platform")
ep.AddEntitiesCallback = getattr(ep, "AddEntitiesCallback", object)
er = _mod("homeassistant.helpers.entity_registry")
er.async_get = getattr(er, "async_get", lambda hass: None)
er.async_entries_for_config_entry = getattr(
er, "async_entries_for_config_entry", lambda registry, entry_id: []
)
uc = _mod("homeassistant.helpers.update_coordinator")
class CoordinatorEntity:
def __init__(self, coordinator) -> None:
self.coordinator = coordinator
uc.CoordinatorEntity = getattr(uc, "CoordinatorEntity", CoordinatorEntity)
uc.DataUpdateCoordinator = getattr(uc, "DataUpdateCoordinator", type("DataUpdateCoordinator", (), {}))
uc.UpdateFailed = getattr(uc, "UpdateFailed", type("UpdateFailed", (Exception,), {}))
const = _mod("homeassistant.const")
for unit_cls in ("UnitOfTemperature", "UnitOfEnergy", "UnitOfTime", "UnitOfVolume", "UnitOfMass"):
if not hasattr(const, unit_cls):
setattr(const, unit_cls, type(unit_cls, (), {
"CELSIUS": "C", "KILO_WATT_HOUR": "kWh", "MINUTES": "min", "LITERS": "L",
"GRAMS": "g", "KILOGRAMS": "kg", "SECONDS": "s",
}))
const.EntityCategory = getattr(
const, "EntityCategory", type("EntityCategory", (), {"CONFIG": "config", "DIAGNOSTIC": "diagnostic"})
)
components = _mod("homeassistant.components")
_mod("homeassistant.components.switch").SwitchEntity = type("SwitchEntity", (), {})
_mod("homeassistant.components.select").SelectEntity = type("SelectEntity", (), {})
number_mod = _mod("homeassistant.components.number")
import dataclasses
@dataclasses.dataclass(frozen=True, kw_only=True)
class NumberEntityDescription:
key: str
name: str | None = None
translation_key: str | None = None
icon: str | None = None
device_class: object | None = None
native_unit_of_measurement: str | None = None
mode: object | None = None
number_mod.NumberEntityDescription = getattr(number_mod, "NumberEntityDescription", NumberEntityDescription)
number_mod.NumberEntity = getattr(number_mod, "NumberEntity", type("NumberEntity", (), {}))
number_mod.NumberDeviceClass = getattr(number_mod, "NumberDeviceClass", type("NumberDeviceClass", (), {"TEMPERATURE": "temperature"}))
number_mod.NumberMode = getattr(number_mod, "NumberMode", type("NumberMode", (), {"AUTO": "auto", "BOX": "box", "SLIDER": "slider"}))
ha.config_entries = ce
ha.core = core
ha.exceptions = exc
ha.helpers = helpers
ha.const = const
ha.components = components
helpers.entity = entity
helpers.entity_platform = ep
helpers.entity_registry = er
helpers.update_coordinator = uc
helpers.device_registry = dr
components.number = number_mod
_install_stubs()
from custom_components.addhon import number, select, switch # noqa: E402
from custom_components.addhon.client.engine.commands import HonCommand # noqa: E402
from custom_components.addhon.const import ( # noqa: E402
APPLIANCE_TD,
APPLIANCE_WD,
APPLIANCE_WM,
)
from custom_components.addhon.program_options import ( # noqa: E402
is_settable_option,
startprogram_option_param,
)
def _install_stubs() -> None:
ha = _mod("homeassistant")
ce = _mod("homeassistant.config_entries")
ce.ConfigEntry = getattr(ce, "ConfigEntry", type("ConfigEntry", (), {}))
core = _mod("homeassistant.core")
core.HomeAssistant = getattr(core, "HomeAssistant", type("HomeAssistant", (), {}))
exc = _mod("homeassistant.exceptions")
base_err = getattr(exc, "HomeAssistantError", type("HomeAssistantError", (Exception,), {}))
exc.HomeAssistantError = base_err
exc.ConfigEntryNotReady = getattr(exc, "ConfigEntryNotReady", type("ConfigEntryNotReady", (base_err,), {}))
exc.ConfigEntryAuthFailed = getattr(exc, "ConfigEntryAuthFailed", type("ConfigEntryAuthFailed", (base_err,), {}))
helpers = _mod("homeassistant.helpers")
entity = _mod("homeassistant.helpers.entity")
entity.DeviceInfo = getattr(entity, "DeviceInfo", dict)
dr = _mod("homeassistant.helpers.device_registry")
dr.DeviceEntryType = getattr(dr, "DeviceEntryType", type("DeviceEntryType", (), {"SERVICE": "service"}))
ep = _mod("homeassistant.helpers.entity_platform")
ep.AddEntitiesCallback = getattr(ep, "AddEntitiesCallback", object)
er = _mod("homeassistant.helpers.entity_registry")
er.async_get = getattr(er, "async_get", lambda hass: None)
er.async_entries_for_config_entry = getattr(
er, "async_entries_for_config_entry", lambda registry, entry_id: []
)
uc = _mod("homeassistant.helpers.update_coordinator")
class CoordinatorEntity:
def __init__(self, coordinator) -> None:
self.coordinator = coordinator
uc.CoordinatorEntity = getattr(uc, "CoordinatorEntity", CoordinatorEntity)
uc.DataUpdateCoordinator = getattr(uc, "DataUpdateCoordinator", type("DataUpdateCoordinator", (), {}))
uc.UpdateFailed = getattr(uc, "UpdateFailed", type("UpdateFailed", (Exception,), {}))
const = _mod("homeassistant.const")
for unit_cls in ("UnitOfTemperature", "UnitOfEnergy", "UnitOfTime", "UnitOfVolume", "UnitOfMass"):
if not hasattr(const, unit_cls):
setattr(const, unit_cls, type(unit_cls, (), {
"CELSIUS": "C", "KILO_WATT_HOUR": "kWh", "MINUTES": "min", "LITERS": "L",
"GRAMS": "g", "KILOGRAMS": "kg", "SECONDS": "s",
}))
const.EntityCategory = getattr(
const, "EntityCategory", type("EntityCategory", (), {"CONFIG": "config", "DIAGNOSTIC": "diagnostic"})
)
components = _mod("homeassistant.components")
_mod("homeassistant.components.switch").SwitchEntity = type("SwitchEntity", (), {})
_mod("homeassistant.components.select").SelectEntity = type("SelectEntity", (), {})
number_mod = _mod("homeassistant.components.number")
import dataclasses
`@dataclasses.dataclass`(frozen=True, kw_only=True)
class NumberEntityDescription:
key: str
name: str | None = None
translation_key: str | None = None
icon: str | None = None
device_class: object | None = None
native_unit_of_measurement: str | None = None
mode: object | None = None
number_mod.NumberEntityDescription = getattr(number_mod, "NumberEntityDescription", NumberEntityDescription)
number_mod.NumberEntity = getattr(number_mod, "NumberEntity", type("NumberEntity", (), {}))
number_mod.NumberDeviceClass = getattr(number_mod, "NumberDeviceClass", type("NumberDeviceClass", (), {"TEMPERATURE": "temperature"}))
number_mod.NumberMode = getattr(number_mod, "NumberMode", type("NumberMode", (), {"AUTO": "auto", "BOX": "box", "SLIDER": "slider"}))
ha.config_entries = ce
ha.core = core
ha.exceptions = exc
ha.helpers = helpers
ha.const = const
ha.components = components
helpers.entity = entity
helpers.entity_platform = ep
helpers.entity_registry = er
helpers.update_coordinator = uc
helpers.device_registry = dr
components.number = number_mod
_ORIGINAL_MODULES: dict[str, types.ModuleType | None] = {}
number = select = switch = None
HonCommand = None
APPLIANCE_TD = APPLIANCE_WD = APPLIANCE_WM = None
is_settable_option = startprogram_option_param = None
def setUpModule() -> None:
global number, select, switch
global HonCommand, APPLIANCE_TD, APPLIANCE_WD, APPLIANCE_WM
global is_settable_option, startprogram_option_param
for name in (
"homeassistant",
"homeassistant.config_entries",
"homeassistant.core",
"homeassistant.exceptions",
"homeassistant.helpers",
"homeassistant.helpers.entity",
"homeassistant.helpers.device_registry",
"homeassistant.helpers.entity_platform",
"homeassistant.helpers.entity_registry",
"homeassistant.helpers.update_coordinator",
"homeassistant.const",
"homeassistant.components",
"homeassistant.components.switch",
"homeassistant.components.select",
"homeassistant.components.number",
):
_ORIGINAL_MODULES[name] = sys.modules.get(name)
_install_stubs()
from custom_components.addhon import number as _number, select as _select, switch as _switch
from custom_components.addhon.client.engine.commands import HonCommand as _HonCommand
from custom_components.addhon.const import APPLIANCE_TD as _APPLIANCE_TD, APPLIANCE_WD as _APPLIANCE_WD, APPLIANCE_WM as _APPLIANCE_WM
from custom_components.addhon.program_options import is_settable_option as _is_settable_option, startprogram_option_param as _startprogram_option_param
number, select, switch = _number, _select, _switch
HonCommand = _HonCommand
APPLIANCE_TD, APPLIANCE_WD, APPLIANCE_WM = _APPLIANCE_TD, _APPLIANCE_WD, _APPLIANCE_WM
is_settable_option, startprogram_option_param = _is_settable_option, _startprogram_option_param
def tearDownModule() -> None:
for name, original in _ORIGINAL_MODULES.items():
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_wash_option_params.py` around lines 47 - 144, The test module is
mutating sys.modules at import time via _install_stubs(), which can leak fake
homeassistant modules into later tests and make collection order-dependent. Move
the stub installation and the imports of custom_components.addhon, HonCommand,
and program_options into setUpModule()/tearDownModule() or a local helper, and
restore the original sys.modules entries afterward so the stubs stay isolated to
this test module.

Comment thread tests/test_wash_option_params.py
tis24dev added 4 commits June 28, 2026 22:26
…integrity

Three reviewer findings on the writable program-option controls (#35), all in the
buffer/apply flow:

- Greptile P1 (stale options cross programs): selecting a different program left the
  buffered options in place, so options chosen for program A could silently apply to
  program B at Start. HonProgramSelect.async_select_option now clears this appliance's
  pending options, but ONLY on an actual program change (re-selecting the same program
  keeps the user's options).
- Greptile P2 (cached range outlives program): the option number validated against the
  merged-across-categories range cached at setup, so a value valid for the superset but
  not the selected program passed the UI then failed at Start. HonProgramOptionNumber
  now reads the range from the ACTIVE startProgram command (new mixin helper
  _active_option_param), falling back to the cached merged param then the static range.
  Still cheap: no available_settings on the hot path.
- CodeRabbit Major (clear wipes newer writes): the post-send clear popped the whole
  per-appliance entry, erasing an option the user (re)wrote between the start snapshot
  and the clear. It now clears only the keys actually sent whose value is unchanged,
  popping the entry only once empty.

Built via 3 convergent constructors + 3-lens refuter pool to HOLD; the two refuter
follow-ups (guard the clear on actual change; isolate the cached-range fallback test)
are applied and mutation-proven. 901 passed, 1 skipped (local + CI-clean).
…els + harden test stub

- Greptile P2 (duplicate labels lose codes): HonProgramOptionSelect built a reverse
  label->raw map that collapsed when two exposed raw codes shared a label
  (DRY_LEVEL_LABELS_TD maps e.g. 1 & 12 both to "iron_dry"), making one code
  unreachable and breaking the select round-trip on a device exposing both tiers.
  __init__ is now collision-aware: only the colliding labels are suffixed with their
  raw code, so every exposed code stays selectable and the reverse map is injective.
  Non-colliding labels are untouched, so the common case (every real WM + erpayo's TD
  dryLevel[12,13,14]) is byte-for-byte unchanged and keeps its translatable state keys.
- CodeRabbit (#8): behavioral tests that a device exposing label-colliding codes keeps
  both selectable and round-trippable, plus a no-collision guard (mutation-proven).
- CodeRabbit (#9/#10, partial): completed test_wash_option_params.py's HA stub
  (force-assign a complete CoordinatorEntity with hass/async_write_ha_state/available,
  mirroring test_ac_write_path.py) so the module is collection-order-robust and never
  poisons other entity-constructing modules. The repo-wide conftest cleanup is left as
  a separate follow-up.

Built via 3 convergent constructors + 3-lens refuter pool to HOLD. 904 passed, 1
skipped (local + CI-clean). The duplicate-label fix is theoretical on known models
(no real device exposes both dryLevel tiers); shipped as forward robustness.
…ges, #7 label)

- CodeRabbit #5: add the empty-string dryLevel sentinel to DRY_LEVEL_SENTINELS
  ('' / '0' / '11' are all "no dry level" per the app's hasDryLevelValue), so a blank
  code is dropped from the select options and does not count toward the gate.
- CodeRabbit #6: the range materializer no longer overshoots the max. option_choices
  used `current <= hi + step/2`, which could emit a value beyond the declared max on a
  step that overshoots (0..10 step 20 -> ["0","20"]); it now uses a tight `+1e-9` bound
  (float-drift only). is_settable_option for a drop-free range now requires
  `lo + step <= hi` (>= 2 reachable values) instead of `max > min`, so a single-real-
  value range is not offered as a control. (A defensive step<=0 guard is added too;
  param_range already coerces 0->1 and rejects negatives, so it is unreachable in
  practice.) No real Haier range is affected (all integer, on-grid max).
- CodeRabbit #7: give anti_crease_time a label distinct from anticrease
  (en "Anti-crease time", it "Tempo antipiega") so a WD exposing both does not show two
  identical switches.

Tests added (mutation-proven): uneven-range no-overshoot + gate, '' sentinel drop,
anti_crease_time/anticrease label distinctness. 3-lens refuter pool: HOLD. 907 passed,
1 skipped (local + CI-clean).
…ort-order fragility

The entity test suite installed homeassistant.* stubs into sys.modules at import time
with a first-wins `getattr(...)` idiom. base_entity.py binds
`class HonBaseEntity(CoordinatorEntity)` at import, so whatever CoordinatorEntity is
present when base_entity is first imported becomes the permanent base. Several modules
installed an INCOMPLETE CoordinatorEntity (no async_write_ha_state/available/hass) and
some const stubs omitted symbols the platforms import (e.g. UnitOfTime), so a partial /
filtered / shuffled collection order whose first entity module installed an incomplete
stub poisoned every entity built afterwards (AttributeError) or failed at import
(ImportError). The full suite was green only because test_ac_write_path.py
(alphabetically first) force-assigns a complete stub.

conftest.py is imported before any test module, so it now installs the complete shared
set once: a complete CoordinatorEntity (+ DataUpdateCoordinator/UpdateFailed), the full
const symbol set (UnitOfTemperature/UnitOfTime/UnitOfEnergy/UnitOfVolume/UnitOfMass/
EntityCategory), and DeviceInfo/DeviceEntryType. Everything is getattr/hasattr-guarded
(first-wins), so the per-file stubs reuse it and the 4 force-assign files still override
with their own complete class. Per-file stubs are deliberately left intact (c-min, lowest
blast radius); the broader stub de-duplication is a separate follow-up.

Designed via 3 convergent design agents + 3-lens refuter pool to HOLD. Test-harness only,
no production code. 907 passed, 1 skipped, unchanged across seeds and -p no:randomly; the
two previously order-fragile reproducers now pass.
Comment thread custom_components/addhon/number.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/test_program_options.py (1)

406-409: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the buffered value survives the rejected write.

Line 406 says the off-grid write leaves the buffer unchanged, but the test only checks translation_key. Add an assertion that coordinator.pending_options still contains the previously buffered "240" (and ideally send_calls is still 0) so this regression is actually locked in.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_program_options.py` around lines 406 - 409, The rejected off-grid
write in async_set_native_value is only verifying the error key, so the test
does not prove the buffer stayed unchanged. Update the test around
entity.async_set_native_value to also assert that coordinator.pending_options
still contains the previously buffered "240" after the HomeAssistantError, and
ideally that send_calls remains 0, so the existing buffered state is preserved
by the failed write.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/test_program_options.py`:
- Around line 406-409: The rejected off-grid write in async_set_native_value is
only verifying the error key, so the test does not prove the buffer stayed
unchanged. Update the test around entity.async_set_native_value to also assert
that coordinator.pending_options still contains the previously buffered "240"
after the HomeAssistantError, and ideally that send_calls remains 0, so the
existing buffered state is preserved by the failed write.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: de009295-c50a-4b3e-a0d8-b18b6883dac5

📥 Commits

Reviewing files that changed from the base of the PR and between ab1c68b and d1eaff0.

📒 Files selected for processing (11)
  • custom_components/addhon/button.py
  • custom_components/addhon/const.py
  • custom_components/addhon/number.py
  • custom_components/addhon/program_options.py
  • custom_components/addhon/select.py
  • custom_components/addhon/translations/en.json
  • custom_components/addhon/translations/it.json
  • tests/conftest.py
  • tests/test_program_options.py
  • tests/test_translations.py
  • tests/test_wash_option_params.py
✅ Files skipped from review due to trivial changes (1)
  • custom_components/addhon/translations/en.json
🚧 Files skipped from review as they are similar to previous changes (6)
  • custom_components/addhon/const.py
  • custom_components/addhon/translations/it.json
  • custom_components/addhon/button.py
  • custom_components/addhon/select.py
  • tests/test_wash_option_params.py
  • custom_components/addhon/program_options.py

CodeRabbit: test_catalog_keys_and_params_unique_within_type guarded only param uniqueness, though its comment also claimed to cover unique_id-suffix collisions. The option entity unique_id is f"{appliance_id}_opt_{key}" scoped per platform, so two controls of the same platform sharing a key collide. Add a per-platform key uniqueness assertion (cross-platform key reuse is fine -- different entity domains). Mutation-proven: a duplicate key with a distinct param fails the key check while the param check still passes. 907 passed, 1 skipped.
@tis24dev
tis24dev merged commit aaae337 into main Jun 28, 2026
8 checks passed
This was referenced Jun 29, 2026
This was referenced Jul 6, 2026
@qodo-code-review qodo-code-review Bot mentioned this pull request Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant