From e801af9ab70edcb0389f8c1e230e494b57213148 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sun, 28 Jun 2026 22:35:17 -0500 Subject: [PATCH] feat: add by_type/ registry package with for_device() and DeviceRegistry --- .../registry/by_type/__init__.py | 49 ++++++++++++++ samsung_appliance/registry/by_type/_base.py | 43 ++++++++++++ .../registry/by_type/dishwasher.py | 14 ++++ .../registry/by_type/refrigerator.py | 14 ++++ tests/test_by_type.py | 65 +++++++++++++++++++ 5 files changed, 185 insertions(+) create mode 100644 samsung_appliance/registry/by_type/__init__.py create mode 100644 samsung_appliance/registry/by_type/_base.py create mode 100644 samsung_appliance/registry/by_type/dishwasher.py create mode 100644 samsung_appliance/registry/by_type/refrigerator.py create mode 100644 tests/test_by_type.py diff --git a/samsung_appliance/registry/by_type/__init__.py b/samsung_appliance/registry/by_type/__init__.py new file mode 100644 index 0000000..f91b193 --- /dev/null +++ b/samsung_appliance/registry/by_type/__init__.py @@ -0,0 +1,49 @@ +"""Per-device-type registries.""" +from typing import Optional + +from ._base import DeviceRegistry +from . import dishwasher, refrigerator + +__all__ = ['DeviceRegistry', '_type_key', 'for_device'] + + +_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { + 'dishwasher': dishwasher.REGISTRY, + 'refrigerator': refrigerator.REGISTRY, +} + + +def _type_key(one_ui_version: str) -> str: + """Convert oneUiVersion string to registry key. + + Args: + one_ui_version: String like '7.0 Dishwasher' or 'Oven'. + + Returns: + Lowercase key with version prefix stripped and spaces/hyphens converted to underscores. + + Examples: + '7.0 Dishwasher' -> 'dishwasher' + '7.0 French Door Refrigerator' -> 'french_door_refrigerator' + 'Oven' -> 'oven' + """ + if ' ' in one_ui_version: + # Strip version prefix: everything before and including the first space + suffix = one_ui_version.split(' ', 1)[-1] + else: + suffix = one_ui_version + + return suffix.lower().replace(' ', '_').replace('-', '_') + + +def for_device(one_ui_version: str) -> Optional[DeviceRegistry]: + """Return the DeviceRegistry for the given oneUiVersion string, or None if unknown. + + Args: + one_ui_version: Device's oneUiVersion string (e.g., '7.0 Dishwasher'). + + Returns: + DeviceRegistry if a matching registry exists, None otherwise. + """ + key = _type_key(one_ui_version) + return _REGISTRY_BY_KEY.get(key) diff --git a/samsung_appliance/registry/by_type/_base.py b/samsung_appliance/registry/by_type/_base.py new file mode 100644 index 0000000..0ea8c45 --- /dev/null +++ b/samsung_appliance/registry/by_type/_base.py @@ -0,0 +1,43 @@ +"""Base DeviceRegistry dataclass and builder.""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from ..capability import Capability + + +@dataclass(frozen=True) +class DeviceRegistry: + """Registry of capabilities for a specific device type.""" + name: str + capabilities: dict[str, list[Capability]] + pattern_capabilities: list[Capability] = field(default_factory=list) + + +def _build(caps: list[Capability]) -> dict[str, list[Capability]]: + """Build a capabilities dict from a list of Capability objects. + + Args: + caps: List of Capability objects to organize by href. + + Returns: + A dict mapping href to list of Capability objects sharing that href. + + Raises: + ValueError: If any capability has href=None (use pattern_capabilities instead), + or if multiple caps share an href without all having rt_filter or match_fn. + """ + out: dict[str, list[Capability]] = {} + + for cap in caps: + if cap.href is None: + raise ValueError(f"Use pattern_capabilities for href=None caps") + out.setdefault(cap.href, []).append(cap) + + # Validate that multi-cap hrefs have proper discrimination + for href, cs in out.items(): + if len(cs) > 1 and any(c.rt_filter is None and c.match_fn is None for c in cs): + raise ValueError( + f"href {href!r} has multiple caps but at least one lacks rt_filter and match_fn") + + return out diff --git a/samsung_appliance/registry/by_type/dishwasher.py b/samsung_appliance/registry/by_type/dishwasher.py new file mode 100644 index 0000000..adc8c57 --- /dev/null +++ b/samsung_appliance/registry/by_type/dishwasher.py @@ -0,0 +1,14 @@ +"""Dishwasher device registry.""" +from ..capabilities import common, operational +from ._base import DeviceRegistry, _build + +# Placeholder: Task 4 will expand with full capability list. +# For now, include common capabilities to verify the import chain. +REGISTRY = DeviceRegistry( + name='dishwasher', + capabilities=_build([ + common.POWER, + common.KIDS_LOCK, + common.REMOTE_CONTROL, + ]), +) diff --git a/samsung_appliance/registry/by_type/refrigerator.py b/samsung_appliance/registry/by_type/refrigerator.py new file mode 100644 index 0000000..c2f01d4 --- /dev/null +++ b/samsung_appliance/registry/by_type/refrigerator.py @@ -0,0 +1,14 @@ +"""Refrigerator device registry.""" +from ..capabilities import common, fridge +from ._base import DeviceRegistry, _build + +# Placeholder: Task 4 will expand with full capability list. +# For now, include common capabilities to verify the import chain. +REGISTRY = DeviceRegistry( + name='refrigerator', + capabilities=_build([ + common.POWER, + common.KIDS_LOCK, + common.REMOTE_CONTROL, + ]), +) diff --git a/tests/test_by_type.py b/tests/test_by_type.py new file mode 100644 index 0000000..4663b74 --- /dev/null +++ b/tests/test_by_type.py @@ -0,0 +1,65 @@ +"""Tests for samsung_appliance/registry/by_type.""" +import pytest +from samsung_appliance.registry.by_type import _type_key, for_device, DeviceRegistry + + +class TestTypeKey: + """Tests for _type_key() function.""" + + def test_type_key_strips_version_prefix(self): + """'7.0 Dishwasher' -> 'dishwasher'""" + assert _type_key("7.0 Dishwasher") == "dishwasher" + + def test_type_key_preserves_spaces_as_underscores(self): + """'7.0 French Door Refrigerator' -> 'french_door_refrigerator'""" + assert _type_key("7.0 French Door Refrigerator") == "french_door_refrigerator" + + def test_type_key_no_space_returns_lowercase(self): + """'Oven' -> 'oven' (no space in string)""" + assert _type_key("Oven") == "oven" + + +class TestForDevice: + """Tests for for_device() function.""" + + def test_for_device_returns_dishwasher_registry(self): + """for_device("7.0 Dishwasher") returns a non-None DeviceRegistry.""" + registry = for_device("7.0 Dishwasher") + assert registry is not None + assert isinstance(registry, DeviceRegistry) + assert registry.name == "dishwasher" + + def test_for_device_unknown_returns_none(self): + """for_device("7.0 Toaster") returns None for unknown device type.""" + registry = for_device("7.0 Toaster") + assert registry is None + + +class TestDeviceRegistries: + """Tests for device registries themselves.""" + + def test_dishwasher_registry_has_no_dup_hrefs(self): + """All caps in dishwasher registry have unique hrefs (or meet disambiguation rule).""" + registry = for_device("7.0 Dishwasher") + assert registry is not None + + # Each href should map to exactly one cap (or multiple with rt_filter/match_fn) + for href, caps in registry.capabilities.items(): + if len(caps) > 1: + # If multiple caps share an href, all must have rt_filter or match_fn + for cap in caps: + assert cap.rt_filter is not None or cap.match_fn is not None, \ + f"href {href!r} has multiple caps but {cap!r} lacks rt_filter and match_fn" + + def test_refrigerator_registry_has_no_dup_hrefs(self): + """All caps in refrigerator registry have unique hrefs (or meet disambiguation rule).""" + registry = for_device("7.0 Refrigerator") + assert registry is not None + + # Each href should map to exactly one cap (or multiple with rt_filter/match_fn) + for href, caps in registry.capabilities.items(): + if len(caps) > 1: + # If multiple caps share an href, all must have rt_filter or match_fn + for cap in caps: + assert cap.rt_filter is not None or cap.match_fn is not None, \ + f"href {href!r} has multiple caps but {cap!r} lacks rt_filter and match_fn"