"""Tests for Samsung air-conditioner support (issue #17). These stay HA-free like the rest of the suite: they exercise the registry, discovery/flatten, and the CLIMATE capability's write contract. The composite climate entity itself lives in climate.py (imports homeassistant) and is not importable here -- consistent with how the other HA platform files are untested. """ from custom_components.localthings.registry.adapter import flatten from custom_components.localthings.registry.by_type import for_device, for_device_by_model from custom_components.localthings.registry.capabilities import airconditioner from custom_components.localthings.registry.discovery import discover from custom_components.localthings.registry.entities import ClimateDesc from tests.conftest import _load_device def _ac(): resources = _load_device('airconditioner') info = resources['/information/vs/0'] reg = for_device_by_model( info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'], ) return reg, resources def _resolve(name): """Mirror the coordinator's detection order: oneUiVersion first, modelNum fallback second (needed for issue #37's board, which reports neither oneUiVersion nor a '_PRAC_' modelNum token).""" resources = _load_device(name) otn = resources.get('/otninformation/vs/0', {}) one_ui = otn.get('swVersionInfo', {}).get('oneUiVersion', '') info = resources['/information/vs/0'] reg = for_device(one_ui) if one_ui else None if reg is None: reg = for_device_by_model( info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'], ) return reg, resources def _bound(): reg, resources = _ac() return discover(resources, reg.capabilities, reg.pattern_capabilities), resources def _state(): bound, resources = _bound() return flatten(bound, resources) def test_ac_model_resolves_to_airconditioner_registry(): reg, _ = _ac() assert reg is not None and reg.name == 'airconditioner' def test_no_unbound_hrefs(): """Every resource in the issue #17 dump binds or is covered -- clears the coverage-gap repair.""" reg, resources = _ac() unbound = [] discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) assert unbound == [] def test_climate_entity_is_bound(): """The composite climate entity binds the primary /mode/vs/0 resource.""" bound, _ = _bound() climate = [b for b in bound if isinstance(b.desc, ClimateDesc)] assert len(climate) == 1 assert climate[0].href == '/mode/vs/0' def test_expected_state_keys_present(): state = _state() for key in ('climate', 'air_purify', 'auto_clean', 'air_filter_status', 'air_filter_usage', 'diagnosis_status', 'alarm_code', 'energy_kwh'): assert key in state, key def test_power_and_convenient_folded_into_climate(): """On/off is the climate entity's HVACMode.OFF and convenient mode is its preset_mode -- neither surfaces as a standalone switch/select.""" state = _state() assert 'power_switch' not in state assert 'convenient_mode' not in state def test_air_filter_usage_is_percentage_of_capacity(): """filterUsage is a raw count in the capacity unit (100 of 500), surfaced as a percentage rather than the misleading raw value.""" assert _state()['air_filter_usage'] == 20 def test_climate_write_targets(): """The CLIMATE write_fn maps each (kind, value) command to the right OCF POST target and body. `value` is already the raw device code.""" write = airconditioner.CLIMATE.entities[0].write_fn assert write(('power', True), {}) == (['power', '0'], {'value': True}) assert write(('power', False), {}) == (['power', '0'], {'value': False}) assert write(('mode', 'Heat'), {}) == ( ['mode', 'vs', '0'], {'x.com.samsung.da.modes': ['Heat']}) assert write(('temperature', 23.6), {}) == ( ['temperature', 'desired', '0'], {'temperature': 24}) assert write(('fan', '2'), {}) == ( ['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': '2'}) assert write(('swing', 'All'), {}) == ( ['wind', 'direction', 'vs', '0'], {'x.com.samsung.da.modes': 'All'}) assert write(('preset', 'Sleep'), {}) == ( ['mode', 'convenient', 'vs', '0'], {'x.com.samsung.da.modes': 'Sleep'}) assert write(('bogus', 1), {}) is None def test_climate_consumed_hrefs_declared_as_coverage(): """The climate-consumed and ambiguous hrefs are declared in the AC registry (as no-entity coverage caps) so they don't leak as gaps -- but produce no standalone entities.""" reg, _ = _ac() for href in ('/power/0', '/power/vs/0', '/temperature/desired/0', '/wind/strength/vs/0', '/mode/convenient/vs/0', '/temperatures/vs/0', '/sensors/vs/0', '/humidity/0'): caps = reg.capabilities.get(href) assert caps, href assert all(c.entities == () for c in caps), href # --------------------------------------------------------------------------- # TP1X_DA-AC-RAC-01011 (oneUiVersion "7.0 Air conditioner", Tizen Lite) -- a # newer model class than the ARTIK051_PRAC dump above. It has no OCF-standard # /temperature/current+desired pair (temperature lives on the vendor # /temperatures/vs/0 items[] resource), exposes a /light/vs/0 display light, and # carries extra vendor housekeeping hrefs. Issue #17 for this class (PR #36). # --------------------------------------------------------------------------- def _ac_tp1x(): resources = _load_device('airconditioner_tp1x_da_ac_rac_01011') one_ui = resources['/otninformation/vs/0']['swVersionInfo']['oneUiVersion'] return for_device(one_ui), resources def test_tp1x_resolves_to_airconditioner_registry(): reg, _ = _ac_tp1x() assert reg is not None and reg.name == 'airconditioner' def test_tp1x_no_unbound_hrefs(): """Every resource in the TP1X dump binds or is covered -- including /temperatures/vs/0, /light/vs/0 and the housekeeping hrefs absent from the ARTIK051 dump. Clears the coverage-gap repair.""" reg, resources = _ac_tp1x() unbound = [] discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) assert unbound == [] def test_tp1x_display_light_switch_present(): """/light/vs/0 (mode On/Off) surfaces as the display-light switch.""" reg, resources = _ac_tp1x() state = flatten( discover(resources, reg.capabilities, reg.pattern_capabilities), resources) assert state.get('display_light') is True # device reports mode == 'On' def test_tp1x_vendor_temperature_and_light_covered(): """The vendor temperature resource (read by the climate entity) and the display-light resource both resolve in the registry -- no gap.""" reg, _ = _ac_tp1x() assert reg.capabilities.get('/temperatures/vs/0'), '/temperatures/vs/0' assert reg.capabilities.get('/light/vs/0'), '/light/vs/0' def test_tp1x_climate_entity_is_bound(): """The composite climate entity still binds the primary /mode/vs/0.""" reg, resources = _ac_tp1x() bound = discover(resources, reg.capabilities, reg.pattern_capabilities) climate = [b for b in bound if isinstance(b.desc, ClimateDesc)] assert len(climate) == 1 and climate[0].href == '/mode/vs/0' def test_tp2x_rac_20k_model_resolves_via_model_fallback(): """TP2X_RAC_20K (issue #37) reports no oneUiVersion and no '_PRAC_' token -- resolved via the '_RAC_' modelNum fallback added for this device.""" reg, _ = _resolve('airconditioner_tp2x_rac_20k') assert reg is not None and reg.name == 'airconditioner' def test_tp2x_rac_20k_no_unbound_hrefs(): reg, resources = _resolve('airconditioner_tp2x_rac_20k') unbound = [] discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) assert unbound == [] def test_tp1x_rac_model_resolves_via_one_ui_version(): """TP1X_DA-AC-RAC-01001_0000 (issue #38) self-reports oneUiVersion '7.0 Air conditioner' -- resolved via for_device(), not the modelNum fallback.""" reg, _ = _resolve('airconditioner_tp1x_rac') assert reg is not None and reg.name == 'airconditioner' def test_tp1x_rac_no_unbound_hrefs(): reg, resources = _resolve('airconditioner_tp1x_rac') unbound = [] discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) assert unbound == [] def test_tp1x_rac_expected_state_keys_present(): reg, resources = _resolve('airconditioner_tp1x_rac') bound = discover(resources, reg.capabilities, reg.pattern_capabilities) state = flatten(bound, resources) for key in ('climate', 'display_light', 'mute_once', 'selfcheck_status', 'selfcheck_result', 'current_limit_enabled', 'current_limit_level'): assert key in state, key def test_caww_tp2_model_resolves_via_model_fallback(): """A-CAWW-TP2-20-COMMON (issue #52, System AC) reports no oneUiVersion and no '_RAC_'/'_PRAC_' token -- resolved via the '-CAWW-' modelNum fallback added for this device.""" reg, _ = _resolve('airconditioner_caww_tp2') assert reg is not None and reg.name == 'airconditioner' def test_caww_tp2_no_unbound_hrefs(): """Every resource in the issue #52 dump binds or is ignored -- clears the coverage-gap repair. Only new href beyond the existing RAC/PRAC surface is /sac/installationinfo/vs/0 (opaque SAC installation topology, ignored).""" reg, resources = _resolve('airconditioner_caww_tp2') unbound = [] discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) assert unbound == [] def test_caww_tp2_sac_installationinfo_is_ignored(): ignored_hrefs = {cap.href for cap in airconditioner.COVERAGE} assert '/sac/installationinfo/vs/0' in ignored_hrefs def test_mute_once_write_target(): write = airconditioner.MUTE_ONCE.entities[0].write_fn assert write('On', {}) == (['option', 'muteonce', 'vs', '0'], {'muteonce': 'On'}) assert write('Off', {}) == (['option', 'muteonce', 'vs', '0'], {'muteonce': 'Off'}) def test_display_light_write_target(): write = airconditioner.DISPLAY_LIGHT.entities[0].write_fn assert write('On', {}) == (['light', 'vs', '0'], {'mode': 'On'}) assert write('Off', {}) == (['light', 'vs', '0'], {'mode': 'Off'}) def test_current_limit_is_read_only(): """Meaning/write contract for the current-limit levels isn't confirmed from the dump alone -- exposed as read-only diagnostic sensors rather than a guessed writable control.""" for desc in airconditioner.CURRENT_LIMIT.entities: assert getattr(desc, 'write_fn', None) is None