Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions tests/conformance/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Architecture and schema conformance tests for python-roborock."""
88 changes: 88 additions & 0 deletions tests/conformance/discovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Shared discovery utilities for repository conformance tests."""

from __future__ import annotations

import importlib
import inspect
import pkgutil
import types
from collections.abc import Callable, Iterator
from dataclasses import is_dataclass
from functools import lru_cache
from typing import Any

import pytest


@lru_cache(maxsize=8)
def walk_modules(package: types.ModuleType) -> list[types.ModuleType]:
"""Recursively walk and import all modules within a package, caching the result."""
modules: list[types.ModuleType] = [package]
if not hasattr(package, "__path__"):
return modules
for _, modname, _ in pkgutil.walk_packages(package.__path__, package.__name__ + "."):
modules.append(importlib.import_module(modname))
return modules


def discover_classes(
package: types.ModuleType,
predicate: Callable[[type], bool],
module_prefix: str | None = None,
) -> list[type]:
"""Discover all unique classes within a package that satisfy a given predicate."""
prefix = module_prefix or package.__name__
seen: set[type] = set()
result: list[type] = []
for mod in walk_modules(package):
if not mod.__name__.startswith(prefix):
continue
for _, obj in inspect.getmembers(mod, inspect.isclass):
if not obj.__module__.startswith(prefix):
continue
if obj in seen:
continue
if predicate(obj):
seen.add(obj)
result.append(obj)
return result


def discover_subclasses(
package: types.ModuleType,
base_class: type | tuple[type, ...],
exclude: tuple[type, ...] = (),
module_prefix: str | None = None,
) -> list[type]:
"""Discover all unique subclasses of base_class in a package, excluding specific classes."""
return discover_classes(
package,
predicate=lambda cls: issubclass(cls, base_class) and cls not in exclude,
module_prefix=module_prefix,
)


def discover_dataclasses(
package: types.ModuleType,
module_prefix: str | None = None,
) -> list[type]:
"""Discover all unique dataclasses defined within a package."""
return discover_classes(
package,
predicate=is_dataclass,
module_prefix=module_prefix,
)


def to_pytest_params(
classes: list[type],
marks_by_fqn: dict[str, pytest.MarkDecorator] | None = None,
) -> Iterator[Any]:
"""Convert a list of classes to parametrized pytest.param objects with fully-qualified IDs."""
marks_map = marks_by_fqn or {}
for cls in classes:
fqn = f"{cls.__module__}.{cls.__name__}"
if fqn in marks_map:
yield pytest.param(cls, id=fqn, marks=marks_map[fqn])
else:
yield pytest.param(cls, id=fqn)
163 changes: 163 additions & 0 deletions tests/conformance/test_enum_conformance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""Conformance tests for Roborock enum fallback resilience.

As defined in AGENTS.md, all wire/firmware enums representing device status,
error codes, and protocol integer codes MUST inherit from RoborockEnum
(defining a lowercase unknown member) or RoborockModeEnum (using from_code_optional)
so unknown codes from firmware updates never crash consumers.
"""

from __future__ import annotations

import enum
import inspect

import pytest

import roborock
from roborock.data.code_mappings import RoborockEnum, RoborockModeEnum
from tests.conformance.discovery import discover_subclasses, to_pytest_params, walk_modules

# Baseline inventory of legacy RoborockEnum classes that do not yet define an
# explicit `unknown` member. When newer firmware emits an undocumented code,
# RoborockEnum._missing_ currently falls back to `next(item for item in cls)`,
# which arbitrarily defaults to the first member.
# As these enums are remediated, remove them from this baseline.
KNOWN_ENUMS_MISSING_UNKNOWN = {
"roborock.data.dyad.dyad_code_mappings.DyadBrushSpeed",
"roborock.data.dyad.dyad_code_mappings.DyadCleanMode",
"roborock.data.dyad.dyad_code_mappings.DyadCleanser",
"roborock.data.dyad.dyad_code_mappings.DyadError",
"roborock.data.dyad.dyad_code_mappings.DyadMode",
"roborock.data.dyad.dyad_code_mappings.DyadSelfCleanLevel",
"roborock.data.dyad.dyad_code_mappings.DyadSelfCleanMode",
"roborock.data.dyad.dyad_code_mappings.DyadSuction",
"roborock.data.dyad.dyad_code_mappings.DyadWarmLevel",
"roborock.data.dyad.dyad_code_mappings.DyadWaterLevel",
"roborock.data.v1.v1_code_mappings.CleanFluidStatus",
"roborock.data.v1.v1_code_mappings.ClearWaterBoxStatus",
"roborock.data.v1.v1_code_mappings.DirtyWaterBoxStatus",
"roborock.data.v1.v1_code_mappings.DustBagStatus",
"roborock.data.v1.v1_code_mappings.RoborockCleanType",
"roborock.data.v1.v1_code_mappings.RoborockDockErrorCode",
"roborock.data.v1.v1_code_mappings.RoborockDssCodes",
"roborock.data.v1.v1_code_mappings.RoborockErrorCode",
"roborock.data.v1.v1_code_mappings.RoborockFanPowerCode",
"roborock.data.v1.v1_code_mappings.RoborockFanSpeedE2",
"roborock.data.v1.v1_code_mappings.RoborockFanSpeedV1",
"roborock.data.v1.v1_code_mappings.RoborockFanSpeedV2",
"roborock.data.v1.v1_code_mappings.RoborockFanSpeedV3",
"roborock.data.v1.v1_code_mappings.RoborockFinishReason",
"roborock.data.v1.v1_code_mappings.RoborockInCleaning",
"roborock.data.v1.v1_code_mappings.RoborockMopIntensityCode",
"roborock.data.v1.v1_code_mappings.RoborockMopIntensityV2",
"roborock.data.v1.v1_code_mappings.RoborockStartType",
"roborock.data.zeo.zeo_code_mappings.ZeoDetergentExpansionType",
"roborock.data.zeo.zeo_code_mappings.ZeoDetergentType",
"roborock.data.zeo.zeo_code_mappings.ZeoDirtDetectionStatus",
"roborock.data.zeo.zeo_code_mappings.ZeoDryAndCare",
"roborock.data.zeo.zeo_code_mappings.ZeoDryerStartError",
"roborock.data.zeo.zeo_code_mappings.ZeoDryingMethod",
"roborock.data.zeo.zeo_code_mappings.ZeoDryingMode",
"roborock.data.zeo.zeo_code_mappings.ZeoError",
"roborock.data.zeo.zeo_code_mappings.ZeoFeatureBits",
"roborock.data.zeo.zeo_code_mappings.ZeoMode",
"roborock.data.zeo.zeo_code_mappings.ZeoProgram",
"roborock.data.zeo.zeo_code_mappings.ZeoRinse",
"roborock.data.zeo.zeo_code_mappings.ZeoSoak",
"roborock.data.zeo.zeo_code_mappings.ZeoSoftenerExpansionType",
"roborock.data.zeo.zeo_code_mappings.ZeoSoftenerType",
"roborock.data.zeo.zeo_code_mappings.ZeoSpin",
"roborock.data.zeo.zeo_code_mappings.ZeoState",
"roborock.data.zeo.zeo_code_mappings.ZeoSteamVolume",
"roborock.data.zeo.zeo_code_mappings.ZeoTemperature",
"roborock.roborock_message.RoborockB01Protocol",
"roborock.roborock_message.RoborockDataProtocol",
"roborock.roborock_message.RoborockDyadDataProtocol",
"roborock.roborock_message.RoborockMessageProtocol",
"roborock.roborock_message.RoborockMowerDataProtocol",
"roborock.roborock_message.RoborockZeoProtocol",
}

_XFAIL_MARKS = {
fqn: pytest.mark.xfail(
reason=f"{fqn} lacks an explicit 'unknown' member; defaults to first item on unknown codes",
strict=True,
)
for fqn in KNOWN_ENUMS_MISSING_UNKNOWN
}

_ALL_ROBOROCK_ENUMS = discover_subclasses(roborock, RoborockEnum, exclude=(RoborockEnum,))
_ALL_MODE_ENUMS = discover_subclasses(roborock, RoborockModeEnum, exclude=(RoborockModeEnum,))


# Enums in wire code mapping modules that intentionally remain standard Enum/StrEnum/IntEnum
# (e.g., domain categories, product nicknames, or outgoing command identifiers).
ALLOWED_NON_RESILIENT_CODE_MAPPING_ENUMS = {
"roborock.data.b01_q10.b01_q10_code_mappings.RemoteCommand",
"roborock.data.code_mappings.RoborockCategory",
"roborock.data.code_mappings.RoborockProductNickname",
"roborock.data.v1.v1_code_mappings.RoborockDockState",
}


def _discover_code_mapping_enums() -> list[type[enum.Enum]]:
enums: list[type[enum.Enum]] = []
for mod in walk_modules(roborock):
if "code_mapping" in mod.__name__:
for _, obj in inspect.getmembers(mod, inspect.isclass):
if (
obj.__module__ == mod.__name__
and issubclass(obj, enum.Enum)
and obj not in (enum.Enum, enum.IntEnum, enum.StrEnum, RoborockEnum, RoborockModeEnum)
):
enums.append(obj)
return sorted(enums, key=lambda c: f"{c.__module__}.{c.__name__}")


_ALL_CODE_MAPPING_ENUMS = _discover_code_mapping_enums()


@pytest.mark.parametrize("enum_cls", to_pytest_params(_ALL_ROBOROCK_ENUMS, marks_by_fqn=_XFAIL_MARKS))
def test_roborock_enum_has_unknown_fallback(enum_cls: type[RoborockEnum]) -> None:
"""All RoborockEnum subclasses must define an explicit 'unknown' member."""
assert hasattr(enum_cls, "unknown"), (
f"{enum_cls.__module__}.{enum_cls.__name__} must define an 'unknown' member to prevent "
"crashing or defaulting to arbitrary states on new firmware."
)
# Derive an integer sentinel guaranteed to not exist in the enum
sentinel = max(item.value for item in enum_cls) + 1 if list(enum_cls) else 99999
assert enum_cls(sentinel) == enum_cls.unknown


@pytest.mark.parametrize("mode_enum_cls", to_pytest_params(_ALL_MODE_ENUMS))
def test_roborock_mode_enum_handles_unknown_code(mode_enum_cls: type[RoborockModeEnum]) -> None:
"""RoborockModeEnum subclasses must return None when an unknown code is provided."""
sentinel = max(member.code for member in mode_enum_cls) + 1 if list(mode_enum_cls) else 99999
assert mode_enum_cls.from_code_optional(sentinel) is None


@pytest.mark.parametrize("enum_cls", to_pytest_params(_ALL_CODE_MAPPING_ENUMS))
def test_code_mapping_enums_inherit_resilient_bases(enum_cls: type[enum.Enum]) -> None:
"""Enums in wire code mapping modules must inherit from RoborockEnum or RoborockModeEnum."""
fqn = f"{enum_cls.__module__}.{enum_cls.__name__}"
if fqn in ALLOWED_NON_RESILIENT_CODE_MAPPING_ENUMS:
return
assert issubclass(enum_cls, (RoborockEnum, RoborockModeEnum)), (
f"{fqn} in a code mapping module does not inherit from RoborockEnum or RoborockModeEnum. "
"Wire status and error code enums must use resilient enum bases to handle unknown firmware codes."
)


def test_known_missing_unknown_baseline_inventory() -> None:
"""Ensure the inventory of legacy unresilient enums remains strictly synchronized."""
current_missing = {f"{cls.__module__}.{cls.__name__}" for cls in _ALL_ROBOROCK_ENUMS if not hasattr(cls, "unknown")}
new_untracked = current_missing - KNOWN_ENUMS_MISSING_UNKNOWN
assert not new_untracked, (
f"New RoborockEnum classes without 'unknown' member detected: {new_untracked}. "
"All new wire enums must define an 'unknown' member (e.g. unknown = -1 or 0)."
)
stale_in_baseline = KNOWN_ENUMS_MISSING_UNKNOWN - current_missing
assert not stale_in_baseline, (
f"Enums in KNOWN_ENUMS_MISSING_UNKNOWN are no longer missing 'unknown': {stale_in_baseline}. "
"Please remove them from KNOWN_ENUMS_MISSING_UNKNOWN."
)
26 changes: 26 additions & 0 deletions tests/conformance/test_exception_conformance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Conformance tests for exception hierarchy.

As defined in AGENTS.md, all custom exceptions in python-roborock MUST inherit
from `roborock.exceptions.RoborockException` so downstream consumers (such as
Home Assistant Core) can reliably catch and handle library errors without crashing.
"""

from __future__ import annotations

import pytest

import roborock
from roborock.exceptions import RoborockException
from tests.conformance.discovery import discover_subclasses, to_pytest_params


@pytest.mark.parametrize(
"exception_cls",
to_pytest_params(discover_subclasses(roborock, BaseException, exclude=(BaseException, Exception))),
)
def test_all_custom_exceptions_inherit_from_roborock_exception(exception_cls: type[BaseException]) -> None:
"""Every custom exception defined in roborock must inherit from RoborockException."""
assert issubclass(exception_cls, RoborockException), (
f"{exception_cls.__module__}.{exception_cls.__name__} does not inherit from RoborockException. "
"All library exceptions must inherit from RoborockException per AGENTS.md."
)
26 changes: 26 additions & 0 deletions tests/conformance/test_model_conformance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Conformance tests for data model inheritance.

As defined in AGENTS.md, structured domain and wire models in `roborock.data`
MUST inherit from `RoborockBase` to ensure standard `as_dict()` serialization
and `from_dict()` deserialization across consumers.
"""

from __future__ import annotations

import pytest

import roborock.data
from roborock.data.containers import RoborockBase
from tests.conformance.discovery import discover_dataclasses, to_pytest_params


@pytest.mark.parametrize(
"model_cls",
to_pytest_params(discover_dataclasses(roborock.data)),
)
def test_data_model_subclasses_roborock_base(model_cls: type) -> None:
"""All domain dataclasses in roborock.data must inherit from RoborockBase."""
assert issubclass(model_cls, RoborockBase), (
f"{model_cls.__module__}.{model_cls.__name__} is a dataclass but does not inherit from RoborockBase. "
"Per AGENTS.md, domain containers must subclass RoborockBase for serialization."
)
87 changes: 87 additions & 0 deletions tests/conformance/test_trait_boundary_conformance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Conformance tests for trait boundary purity.

As defined in AGENTS.md, Traits receive only abstract communication channels
(e.g., `Channel`, `RpcChannel`) or domain models/feature flags. They must NEVER
accept raw transport sockets, IP addresses, credentials, local keys, or AES encryption keys.
"""

from __future__ import annotations

import asyncio
import inspect
import socket
import typing

import pytest

import roborock.devices.traits
from roborock.devices.traits import Trait
from roborock.devices.traits.v1.common import V1TraitMixin
from roborock.devices.transport.local_channel import LocalChannel, LocalChannelParams
from roborock.devices.transport.mqtt_channel import MqttParams, MqttSession
from tests.conformance.discovery import discover_classes, to_pytest_params

FORBIDDEN_PARAMS = {
"local_key",
"token",
"ip",
"ip_address",
"host",
"socket",
"transport",
"aes_key",
"password",
"secret",
"key",
}

FORBIDDEN_TYPES = (
socket.socket,
asyncio.BaseTransport,
LocalChannel,
LocalChannelParams,
MqttParams,
MqttSession,
)


def _extract_types(annotation: typing.Any) -> set[type]:
"""Recursively extract underlying concrete types from type hints and unions."""
if isinstance(annotation, type):
return {annotation}
args = typing.get_args(annotation)
types_found: set[type] = set()
for arg in args:
types_found |= _extract_types(arg)
return types_found


def _is_trait_class(cls: type) -> bool:
if cls in (Trait, V1TraitMixin):
return False
return issubclass(cls, (Trait, V1TraitMixin)) or cls.__name__.endswith(("Trait", "Api"))


@pytest.mark.parametrize(
"trait_cls",
to_pytest_params(discover_classes(roborock.devices.traits, _is_trait_class)),
)
def test_trait_constructor_does_not_leak_transport_or_credentials(trait_cls: type) -> None:
"""Trait constructor parameters must never include transport sockets, IPs, or credentials."""
sig = inspect.signature(trait_cls)
for param_name, param in sig.parameters.items():
if param_name in ("self", "args", "kwargs"):
continue

assert param_name not in FORBIDDEN_PARAMS, (
f"{trait_cls.__module__}.{trait_cls.__name__}.__init__ accepts forbidden transport/credential "
f"parameter '{param_name}'. Per AGENTS.md, traits must receive only abstract channels or domain models."
)

types_found = _extract_types(param.annotation)
for t in types_found:
assert not issubclass(t, FORBIDDEN_TYPES), (
f"{trait_cls.__module__}.{trait_cls.__name__}.__init__ accepts parameter '{param_name}' "
f"typed with forbidden low-level transport/credential class {t.__name__}. "
"Traits must depend only on abstract channels (e.g. Channel, RpcChannel)."
)
Loading