Skip to content
Merged
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Evaluate every change against the repository's three core layers in strict prior
## Detailed Engineering Conventions

### 1. Typing & Data Models
- **Subclass `RoborockBase`**: Define structured domain and wire data models as `@dataclass` subclassing `RoborockBase` (`from_dict`, `as_dict`). Avoid `TypedDict` or loose dicts. (Binary protocol packets, transport message envelopes, and map layers are exempt).
- **Subclass `RoborockBase`**: Define structured domain and wire data models as `@dataclass` subclassing `RoborockBase` (`from_dict`, `as_dict`). Avoid `TypedDict` or loose dicts. (Binary protocol packets, transport message envelopes, and map layers are exempt). The existing frozen `Q10RoborockPoint` coordinate value is also exempt: it must retain immutability and hashability, and Python disallows frozen dataclass inheritance from the non-frozen `RoborockBase`. This exception does not extend to other domain models.
- **Enum Fallback Resilience**: All enums representing device status, firmware modes, error codes, and wire protocol integer codes MUST inherit from `RoborockEnum` (defining a lowercase `unknown = -1` or `0` member) or `RoborockModeEnum` (using `from_code_optional()`). Internal enums not decoding unknown firmware codes remain standard `Enum`/`StrEnum`.
- **Strongly Type What You Know; Contain `Any` to the Wire**: Public trait APIs, method signatures, properties, and domain models MUST declare concrete types. `Any` is accepted only where the underlying wire protocol is dynamic or polymorphic (Tuya DPS maps, low-level RPC dispatch, serialization helpers, evolving cloud schemas).
- **Avoid Forward References & `TYPE_CHECKING`**: Avoid stringified forward references (`"ClassName"`) and `if typing.TYPE_CHECKING:` guards wherever possible. They typically indicate circular dependencies or coupling that should be refactored by extracting shared models.
Expand Down
7 changes: 7 additions & 0 deletions tests/conformance/test_model_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import pytest

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

Expand All @@ -20,6 +21,12 @@
)
def test_data_model_subclasses_roborock_base(model_cls: type) -> None:
"""All domain dataclasses in roborock.data must inherit from RoborockBase."""
# This immutable coordinate value predates the conformance suite. Frozen
# dataclasses cannot inherit from the non-frozen RoborockBase dataclass.
# Keep this exception explicit; other models must satisfy the normal rule.
if model_cls is Q10RoborockPoint:
assert model_cls.__dataclass_params__.frozen # type: ignore[attr-defined]
return
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."
Expand Down
14 changes: 14 additions & 0 deletions tests/data/b01_q10/test_b01_q10_containers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for Q10 data containers."""

from dataclasses import FrozenInstanceError

import pytest

from roborock.data.b01_q10.b01_q10_containers import Q10RoborockPoint
Expand Down Expand Up @@ -47,3 +49,15 @@ def test_q10_roborock_point_rejects_invalid_vector_coordinates(
"""Outbound vector coordinates must fit the signed wire grid exactly."""
with pytest.raises(ValueError):
point.to_vector()


@pytest.mark.parametrize("field", ["x", "y"])
def test_roborock_point_preserves_immutable_value_contract(field: str) -> None:
point = Q10RoborockPoint(25500, 25500)
original_hash = hash(point)
with pytest.raises(FrozenInstanceError):
setattr(point, field, 0)
with pytest.raises(FrozenInstanceError):
delattr(point, field)
assert point == Q10RoborockPoint(25500, 25500)
assert hash(point) == original_hash
Loading