From 565c7d0613c8ad765e1e701264fc5a41801e45e7 Mon Sep 17 00:00:00 2001 From: Harry Coureau Date: Thu, 17 Sep 2026 09:35:25 -0700 Subject: [PATCH] fix: preserve immutable Q10 points in model conformance checks --- AGENTS.md | 2 +- tests/conformance/test_model_conformance.py | 7 +++++++ tests/data/b01_q10/test_b01_q10_containers.py | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index d81bd0a06..474bdfe90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/tests/conformance/test_model_conformance.py b/tests/conformance/test_model_conformance.py index 52979d662..634df643b 100644 --- a/tests/conformance/test_model_conformance.py +++ b/tests/conformance/test_model_conformance.py @@ -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 @@ -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." diff --git a/tests/data/b01_q10/test_b01_q10_containers.py b/tests/data/b01_q10/test_b01_q10_containers.py index f355e06e2..b5d406000 100644 --- a/tests/data/b01_q10/test_b01_q10_containers.py +++ b/tests/data/b01_q10/test_b01_q10_containers.py @@ -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 @@ -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