Conversation
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 reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
|
Warning Review limit reached
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 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughAdds buffered writable wash program options for switches, selects, and numbers, applies them during ChangesWritable wash program options
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (2)
tests/fixtures/td_haier_hd90/startprogram_params.jsonis excluded by!tests/fixtures/**tests/fixtures/wm_candy_tca286/startprogram_params.jsonis excluded by!tests/fixtures/**
📒 Files selected for processing (13)
custom_components/addhon/button.pycustom_components/addhon/const.pycustom_components/addhon/manifest.jsoncustom_components/addhon/number.pycustom_components/addhon/program_options.pycustom_components/addhon/select.pycustom_components/addhon/switch.pycustom_components/addhon/translations/en.jsoncustom_components/addhon/translations/it.jsontests/test_entity_translation_keys.pytests/test_log_identity_redaction.pytests/test_program_options.pytests/test_wash_option_params.py
| 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() |
There was a problem hiding this comment.
🩺 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.
| 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.
| 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, | ||
| ) | ||
|
|
There was a problem hiding this comment.
🩺 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.
| 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.
…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.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/test_program_options.py (1)
406-409: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAssert 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 thatcoordinator.pending_optionsstill contains the previously buffered"240"(and ideallysend_callsis still0) 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
📒 Files selected for processing (11)
custom_components/addhon/button.pycustom_components/addhon/const.pycustom_components/addhon/number.pycustom_components/addhon/program_options.pycustom_components/addhon/select.pycustom_components/addhon/translations/en.jsoncustom_components/addhon/translations/it.jsontests/conftest.pytests/test_program_options.pytests/test_translations.pytests/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.
Automated release PR for
v5.5.0-beta.Summary by CodeRabbit
Greptile Summary
This PR adds buffered washer and dryer program options for the v5.5.0 beta release. The main changes are:
Confidence Score: 5/5
This looks safe to merge.
Important Files Changed
Reviews (3): Last reviewed commit: "test(#35): PR #38 #8 — assert catalog ke..." | Re-trigger Greptile