Files
localthings/tests/test_fridge_capabilities.py
T
Marc Billow 8194ea9e87 Fix ARTIK051_DONGLE_REF freezer type detection and door sensor (issue #77)
RZ32M713EWW/EE (an ARTIK051-dongle standalone freezer) reports no
oneUiVersion and a pipe-delimited modelNum
('ARTIK051_DONGLE_REF|<rest>') -- REF is the last underscore segment
before the pipe, not wrapped in underscores on both sides like the
'..._REF_...' shape for_device_by_model's substring check expected, so
the device fell through to 'unknown' with only common capabilities:
no door sensor, no temperature sensors, nothing fridge-specific.

Replace the substring check with a segment-based one
(_model_num_segments splits the pipe-delimited prefix on '_') that
catches both shapes. Same root cause, same fix, and same device family
independently reported and root-caused in issue #83 (which also covers
two further bugs -- config-flow/coordinator serial collisions on a
second symptom of this firmware, 'Nothing(SVC)' as a literal serial --
not needed here since this reporter has a single unit; left for #83).

Once routed to the refrigerator registry, fridge.py's existing pattern
capabilities pick up /temperature/current/freezer/0 and
/temperature/desired/freezer/0 for free -- they were never the actual
problem, just unreachable under the 'unknown' fallback (which never
tries pattern capabilities at all). The door sensor needed one more
fix: /door/onedoorfreezer/vs/0 reports the vendor-prefixed
x.com.samsung.da.openState, not the bare openState DOOR_GENERIC read,
so the entity existed but stayed permanently unavailable. Check both
field names via rep_fn.
2026-07-27 00:39:33 +00:00

353 lines
15 KiB
Python

"""Tests for fridge-specific capabilities."""
from custom_components.localthings.registry.capabilities import fridge
class TestTempCurrentGeneric:
"""Issue #7: unit must be read live from the device, not assumed to be
Fahrenheit -- a TP1X_REF_21K fridge (RL38C6B0CWW/EG) reports the same
resource in Celsius."""
def test_unit_reads_celsius(self):
desc = fridge.TEMP_CURRENT_GENERIC.entities[0]
assert desc.unit_fn({'temperature': 3.0, 'units': 'C'}) == '°C'
def test_unit_reads_fahrenheit(self):
desc = fridge.TEMP_CURRENT_GENERIC.entities[0]
assert desc.unit_fn({'temperature': 5.0, 'units': 'F'}) == '°F'
def test_unit_defaults_to_fahrenheit_when_missing(self):
desc = fridge.TEMP_CURRENT_GENERIC.entities[0]
assert desc.unit_fn({'temperature': 5.0}) == '°F'
class TestDoorGeneric:
"""Issues #77/#83: DOOR_GENERIC used to read only the bare `openState`
field. Most /door/* resources use that, but the ARTIK051_DONGLE_REF
family's /door/onedoorfreezer/vs/0 reports the vendor-prefixed
`x.com.samsung.da.openState` instead -- the pattern capability still
bound the href either way, but the entity's value was always None."""
def test_reads_bare_open_state(self):
desc = fridge.DOOR_GENERIC.entities[0]
assert desc.rep_fn({'openState': 'Open'}) is True
assert desc.rep_fn({'openState': 'Close'}) is False
def test_reads_vendor_prefixed_open_state(self):
desc = fridge.DOOR_GENERIC.entities[0]
assert desc.rep_fn({'x.com.samsung.da.openState': 'Open'}) is True
assert desc.rep_fn({'x.com.samsung.da.openState': 'Close'}) is False
def test_prefers_bare_field_when_both_present(self):
desc = fridge.DOOR_GENERIC.entities[0]
rep = {'openState': 'Open', 'x.com.samsung.da.openState': 'Close'}
assert desc.rep_fn(rep) is True
class TestTempSetpointGeneric:
def test_unit_reads_celsius(self):
desc = fridge.TEMP_SETPOINT_GENERIC.entities[0]
assert desc.unit_fn({'temperature': -19.0, 'units': 'C'}) == '°C'
class TestTemperaturesFallback:
"""Aggregate /temperatures/vs/0 -- per-item unit from
x.com.samsung.da.unit ('Celsius'/'Fahrenheit')."""
def test_freezer_unit_celsius(self):
desc = next(e for e in fridge.TEMPERATURES_FALLBACK.entities
if e.key == 'freezer_temperature')
rep = {'x.com.samsung.da.items': [
{'x.com.samsung.da.description': 'Freezer', 'x.com.samsung.da.current': '-19',
'x.com.samsung.da.unit': 'Celsius'},
{'x.com.samsung.da.description': 'Fridge', 'x.com.samsung.da.current': '3',
'x.com.samsung.da.unit': 'Celsius'},
]}
assert desc.unit_fn(rep) == '°C'
assert desc.value_fn(rep['x.com.samsung.da.items']) == -19
def test_fridge_unit_fahrenheit(self):
desc = next(e for e in fridge.TEMPERATURES_FALLBACK.entities
if e.key == 'fridge_temperature')
rep = {'x.com.samsung.da.items': [
{'x.com.samsung.da.description': 'Fridge', 'x.com.samsung.da.current': '37',
'x.com.samsung.da.unit': 'Fahrenheit'},
]}
assert desc.unit_fn(rep) == '°F'
def test_unit_defaults_to_fahrenheit_when_item_missing(self):
desc = next(e for e in fridge.TEMPERATURES_FALLBACK.entities
if e.key == 'freezer_temperature')
assert desc.unit_fn({'x.com.samsung.da.items': []}) == '°F'
class TestDefrostBlockStatus:
"""DEFROST_BLOCK_ON means the defrost cycle is actively running, not
that defrost is being withheld -- confirmed against live dumps showing
it ON while defrost_delay is off."""
def test_key_and_name_reflect_active_defrosting(self):
desc = fridge.DEFROST_BLOCK_STATUS.entities[0]
assert desc.key == 'defrost_active'
def test_value_fn(self):
desc = fridge.DEFROST_BLOCK_STATUS.entities[0]
assert desc.value_fn(['DEFROST_BLOCK_ON']) is True
assert desc.value_fn(['DEFROST_BLOCK_OFF']) is False
class TestRefrigerationFallback:
"""/refrigeration/0 duplicates two of REFRIGERATION's (/refrigeration/vs/0)
fields under different names (rapidFreeze/rapidCool vs
rapidFreezing/rapidFridge) but also carries one genuinely new field
(defrost) that has no vs-href equivalent."""
def test_href(self):
assert fridge.REFRIGERATION_FALLBACK.href == '/refrigeration/0'
def test_defrost_active_is_fallback_of_defrost_block_status(self):
# Duplicates DEFROST_BLOCK_STATUS's defrost_active
# (/defrost/block/vs/0) -- only a true fallback when that richer
# href is absent (issue #7's device: /refrigeration/0 only).
desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities
if e.key == 'defrost_active')
assert desc.value_fn(True) is True
assert desc.value_fn(False) is False
assert desc.exists_fn({}, {'/refrigeration/0': {}}) is True
assert desc.exists_fn(
{}, {'/refrigeration/0': {}, '/defrost/block/vs/0': {}}) is False
def test_rapid_switches_hidden_when_vs_href_present(self):
for key in ('rapid_fridge', 'rapid_freezing'):
desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities if e.key == key)
assert desc.exists_fn({}, {'/refrigeration/vs/0': {}, '/refrigeration/0': {}}) is False
def test_rapid_switches_shown_when_vs_href_absent(self):
for key in ('rapid_fridge', 'rapid_freezing'):
desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities if e.key == key)
assert desc.exists_fn({}, {'/refrigeration/0': {}}) is True
class TestFlexZone:
"""Issue #32 (surfaced by #26/#27's TP2X/Bespoke dumps): the flex-zone
entry's prefix varies by fridge family (CV_TTYPE_RF9000A_ on RF9000-class,
CV_FDR_ on TP1X/Bespoke-class). The old hardcoded-prefix match left
CV_FDR_-family fridges with an always-unknown select, and a write would
have appended a duplicate flag instead of replacing the existing one."""
def test_reads_rf9000_prefix(self):
rep = {
'x.com.samsung.da.modes': ['CV_TTYPE_RF9000A_BEVERAGE', 'WATERFILTER_DISABLE'],
'x.com.samsung.da.supportedOptions': [
'CV_TTYPE_RF9000A_FREEZE', 'CV_TTYPE_RF9000A_BEVERAGE'],
}
assert fridge._flex_zone_current(rep) == 'CV_TTYPE_RF9000A_BEVERAGE'
def test_reads_cv_fdr_prefix(self):
rep = {
'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT', 'WATERFILTER_ENABLE'],
'x.com.samsung.da.supportedOptions': [
'CV_FDR_WINE', 'CV_FDR_DELI', 'CV_FDR_BEVERAGE', 'CV_FDR_MEAT'],
}
assert fridge._flex_zone_current(rep) == 'CV_FDR_MEAT'
def test_write_replaces_rather_than_duplicates(self):
rep = {
'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT', 'WATERFILTER_ENABLE'],
'x.com.samsung.da.supportedOptions': [
'CV_FDR_WINE', 'CV_FDR_DELI', 'CV_FDR_BEVERAGE', 'CV_FDR_MEAT'],
}
path, payload = fridge._flex_zone_write('CV_FDR_WINE', rep)
assert path == ['mode', 'vs', '0']
modes = payload['x.com.samsung.da.modes']
assert modes.count('CV_FDR_WINE') == 1
assert 'CV_FDR_MEAT' not in modes
assert 'CVN_CONVERTIBLE_ZONE' in modes and 'WATERFILTER_ENABLE' in modes
class TestTp1xNativeDuplicateResources:
"""The US TP1X_REF_21K publishes two native mirrors in addition to the
richer vendor resources. They must count as covered without producing
duplicate entities or guessing unverified write contracts."""
def test_duplicate_capabilities_have_no_entities(self):
assert fridge.DEFROST_DELAY_NATIVE_DUPLICATE.entities == ()
assert fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE.entities == ()
def test_us_fixture_has_complete_coverage(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import refrigerator
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('refrigerator_tp1x_ref_21k_us')
unbound = []
bound = discover(
resources,
refrigerator.REGISTRY.capabilities,
refrigerator.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert state['defrost_delay'] is False
assert 'ice_maker_enabled' not in state
assert state['icemaker_one_enabled'] is True
assert state['icemaker_two_enabled'] is True
assert state['selfcheck_error'] == 'DA_ERROR_NONE'
class TestPantryZone:
"""Cool Select Zone pantry compartment on ARTIK051_REF_17K -- issue #20."""
def test_href(self):
assert fridge.PANTRY_ZONE.href == '/status/pantry/one/vs/0'
def test_write(self):
desc = fridge.PANTRY_ZONE.entities[0]
path, body = desc.write_fn('FDR_WINE', {})
assert path == ['status', 'pantry', 'one', 'vs', '0']
assert body == {'x.com.samsung.da.mode': 'FDR_WINE'}
class TestArtik051AndTp2xFixturesHaveCompleteCoverage:
"""issue #20 (ARTIK051_REF_17K) and #26 (TP2X_REF_20K) both triggered
the incomplete-capability-coverage repair; both must resolve to zero
unbound hrefs now that /diagnosis/vs/0 (dishwasher.DIAGNOSIS, reused for
fridges), /status/pantry/one/vs/0 (PANTRY_ZONE), and the OCF-native
defrost/icemaker mirrors are covered."""
def test_artik051_ref_17k(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import refrigerator
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('refrigerator_artik051_ref_17k')
unbound = []
bound = discover(
resources,
refrigerator.REGISTRY.capabilities,
refrigerator.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert state['pantry_zone_mode'] == 'FDR_DRINKS'
def test_tp2x_ref_20k(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import refrigerator
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('refrigerator_tp2x_ref_20k')
unbound = []
bound = discover(
resources,
refrigerator.REGISTRY.capabilities,
refrigerator.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE'
class TestArtik051DongleRefFixtureCoverage:
"""Issues #77/#83: the ARTIK051_DONGLE_REF standalone freezer resolves
to the refrigerator registry (segment-based modelNum detection) with
zero unbound hrefs, and its door/temperature entities -- which only
bind through pattern capabilities the 'unknown' fallback never tried --
now carry real values."""
def test_no_unbound_hrefs_and_expected_entities(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import (
for_device_by_model, refrigerator,
)
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('refrigerator_artik051_dongle_ref')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
assert reg is not None and reg.name == 'refrigerator'
unbound = []
bound = discover(
resources, refrigerator.REGISTRY.capabilities,
refrigerator.REGISTRY.pattern_capabilities, log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert state['door_onedoorfreezer_open'] is False # rep reports 'Close'
assert state['freezer_temperature'] == -20.0
assert state['freezer_setpoint'] == -20.0
class TestRefrigeratorAiEnergyLevelFixtureCoverage:
"""AI energy-saving level (common.AI_ENERGY_LEVEL, see
test_common_capabilities.py) is exercised end-to-end here against the
refrigerator fixture/registry.
refrigerator_device.json's supportedAiLevel was extended to two entries
(['1', '2']) specifically to exercise the select branch -- the real
captured TP1X_REF_21K_US dump only ever reports one entry (switch
branch), so the select is synthetic-fixture-only for now."""
def test_synthetic_fixture_has_complete_coverage(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import refrigerator
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('refrigerator')
unbound = []
bound = discover(
resources,
refrigerator.REGISTRY.capabilities,
refrigerator.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert state['ai_energy_level'] == '1'
class TestSwitchOffIsNotInverted:
"""Regression: several fridge SwitchDesc write_fns used ``'On' if p else 'Off'``.
The switch platform passes the literal string ``'Off'`` on turn-off (and
``'On'`` on turn-on). ``'Off'`` is a non-empty, truthy string, so
``'On' if p else 'Off'`` always evaluated to ``'On'`` — turning the switch
OFF silently re-sent ``On`` and the switch could never be turned off. The
guard must compare ``p == 'On'`` (as the other capability files already do).
"""
# (switch descriptor, payload key it writes)
CASES = [
(fridge.ICEMAKER_NIGHTTIME.entities[0], 'ice.night.status'),
(fridge.STATUS_LOCK.entities[0], 'x.com.samsung.da.ado.devicecontrol'),
(fridge.STATUS_LOCK.entities[1], 'x.com.samsung.da.device.sound'),
(fridge.DEFROST_DELAY.entities[0], 'x.com.samsung.da.delayDefrost'),
(fridge.WELCOME_LIGHTING.entities[0], 'status'),
(fridge.CABINET_LIGHT.entities[1], 'light.dimming.status'),
(fridge.ICEMAKER_STATUS_FALLBACK.entities[0], 'x.com.samsung.da.iceMaker'),
]
def test_turning_off_sends_off(self):
for desc, key in self.CASES:
_segs, payload = desc.write_fn('Off', {})
assert payload[key] == 'Off', (
f"{desc.key}: OFF must send 'Off', got {payload[key]!r} (inverted)"
)
def test_turning_on_sends_on(self):
for desc, key in self.CASES:
_segs, payload = desc.write_fn('On', {})
assert payload[key] == 'On', (
f"{desc.key}: ON must send 'On', got {payload[key]!r}"
)