piphi-runtime-testkit-python 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. {piphi_runtime_testkit_python-0.1.2 → piphi_runtime_testkit_python-0.1.4}/PKG-INFO +23 -1
  2. {piphi_runtime_testkit_python-0.1.2 → piphi_runtime_testkit_python-0.1.4}/README.md +22 -0
  3. {piphi_runtime_testkit_python-0.1.2 → piphi_runtime_testkit_python-0.1.4}/pyproject.toml +2 -2
  4. {piphi_runtime_testkit_python-0.1.2 → piphi_runtime_testkit_python-0.1.4}/src/piphi_runtime_testkit_python/__init__.py +8 -0
  5. {piphi_runtime_testkit_python-0.1.2 → piphi_runtime_testkit_python-0.1.4}/src/piphi_runtime_testkit_python/assertions.py +10 -5
  6. piphi_runtime_testkit_python-0.1.4/src/piphi_runtime_testkit_python/behavior_contract.py +268 -0
  7. {piphi_runtime_testkit_python-0.1.2 → piphi_runtime_testkit_python-0.1.4}/src/piphi_runtime_testkit_python/mock_core.py +30 -3
  8. {piphi_runtime_testkit_python-0.1.2 → piphi_runtime_testkit_python-0.1.4}/tests/test_assertions.py +24 -2
  9. piphi_runtime_testkit_python-0.1.4/tests/test_behavior_contract.py +137 -0
  10. {piphi_runtime_testkit_python-0.1.2 → piphi_runtime_testkit_python-0.1.4}/LICENSE +0 -0
  11. {piphi_runtime_testkit_python-0.1.2 → piphi_runtime_testkit_python-0.1.4}/src/piphi_runtime_testkit_python/builders.py +0 -0
  12. {piphi_runtime_testkit_python-0.1.2 → piphi_runtime_testkit_python-0.1.4}/src/piphi_runtime_testkit_python/pytest_plugin.py +0 -0
  13. {piphi_runtime_testkit_python-0.1.2 → piphi_runtime_testkit_python-0.1.4}/tests/__init__.py +0 -0
  14. {piphi_runtime_testkit_python-0.1.2 → piphi_runtime_testkit_python-0.1.4}/tests/test_builders.py +0 -0
  15. {piphi_runtime_testkit_python-0.1.2 → piphi_runtime_testkit_python-0.1.4}/tests/test_fastapi_runtime_example.py +0 -0
  16. {piphi_runtime_testkit_python-0.1.2 → piphi_runtime_testkit_python-0.1.4}/tests/test_mock_core.py +0 -0
  17. {piphi_runtime_testkit_python-0.1.2 → piphi_runtime_testkit_python-0.1.4}/tests/test_pytest_plugin.py +0 -0
  18. {piphi_runtime_testkit_python-0.1.2 → piphi_runtime_testkit_python-0.1.4}/tests/test_runtime_config_contract.py +0 -0
  19. {piphi_runtime_testkit_python-0.1.2 → piphi_runtime_testkit_python-0.1.4}/tests/test_runtime_flow.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: piphi-runtime-testkit-python
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Pytest-first helpers for testing PiPhi runtime integrations
5
5
  Keywords: piphi,pytest,testing,runtime,iot
6
6
  Author-Email: KelvinSan <support@piphi.network>
@@ -69,6 +69,7 @@ It includes:
69
69
  - a local mock Core server that captures outbound HTTP requests
70
70
  - builder functions for runtime headers, `/config` payloads, and `/config/sync` snapshots
71
71
  - readable assertion helpers for telemetry and event delivery
72
+ - a behavior/telemetry conformance gate that catches unusable automation conditions
72
73
  - a pytest plugin so the fixtures are available automatically
73
74
 
74
75
  It does not try to replace:
@@ -209,6 +210,27 @@ mock_core.assert_event_sent(config_id="sensor-1", event_type="device.configured"
209
210
 
210
211
  That makes the test much easier to understand at a glance.
211
212
 
213
+ ### Behavior and Telemetry Contract Gate
214
+
215
+ After a representative runtime test has emitted telemetry, validate that every
216
+ state-backed condition advertised by `behaviors.json` can read a real metric:
217
+
218
+ ```python
219
+ import json
220
+ from pathlib import Path
221
+
222
+
223
+ def test_behavior_fields_match_runtime_telemetry(mock_core):
224
+ # Configure the runtime and wait for representative telemetry first.
225
+ behaviors = json.loads(Path("src/behaviors.json").read_text())
226
+ mock_core.assert_behavior_conditions_match_telemetry(behaviors)
227
+ ```
228
+
229
+ The assertion checks condition fields, declared aliases, value types, and
230
+ template conditions. Parameter-driven dynamic conditions must be named with
231
+ `dynamic_condition_ids` and covered by a separate focused test; they are never
232
+ silently skipped.
233
+
212
234
  ## Install
213
235
 
214
236
  ### Install from PyPI
@@ -47,6 +47,7 @@ It includes:
47
47
  - a local mock Core server that captures outbound HTTP requests
48
48
  - builder functions for runtime headers, `/config` payloads, and `/config/sync` snapshots
49
49
  - readable assertion helpers for telemetry and event delivery
50
+ - a behavior/telemetry conformance gate that catches unusable automation conditions
50
51
  - a pytest plugin so the fixtures are available automatically
51
52
 
52
53
  It does not try to replace:
@@ -187,6 +188,27 @@ mock_core.assert_event_sent(config_id="sensor-1", event_type="device.configured"
187
188
 
188
189
  That makes the test much easier to understand at a glance.
189
190
 
191
+ ### Behavior and Telemetry Contract Gate
192
+
193
+ After a representative runtime test has emitted telemetry, validate that every
194
+ state-backed condition advertised by `behaviors.json` can read a real metric:
195
+
196
+ ```python
197
+ import json
198
+ from pathlib import Path
199
+
200
+
201
+ def test_behavior_fields_match_runtime_telemetry(mock_core):
202
+ # Configure the runtime and wait for representative telemetry first.
203
+ behaviors = json.loads(Path("src/behaviors.json").read_text())
204
+ mock_core.assert_behavior_conditions_match_telemetry(behaviors)
205
+ ```
206
+
207
+ The assertion checks condition fields, declared aliases, value types, and
208
+ template conditions. Parameter-driven dynamic conditions must be named with
209
+ `dynamic_condition_ids` and covered by a separate focused test; they are never
210
+ silently skipped.
211
+
190
212
  ## Install
191
213
 
192
214
  ### Install from PyPI
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "piphi-runtime-testkit-python"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "Pytest-first helpers for testing PiPhi runtime integrations"
5
5
  authors = [
6
6
  { name = "KelvinSan", email = "support@piphi.network" },
@@ -55,7 +55,7 @@ release-version = "python scripts/release.py"
55
55
  dev = [
56
56
  "fastapi>=0.115,<1.0",
57
57
  "httpx>=0.27,<1.0",
58
- "piphi-runtime-kit-python[fastapi]==0.4.5",
58
+ "piphi-runtime-kit-python[fastapi]==0.7.1",
59
59
  "pytest>=8.3,<9.0",
60
60
  "pytest-asyncio>=0.24,<1.0",
61
61
  "twine>=6.2.0",
@@ -7,17 +7,25 @@ from .builders import (
7
7
  build_core_config_row,
8
8
  build_runtime_headers,
9
9
  )
10
+ from .behavior_contract import (
11
+ BehaviorTelemetryIssue,
12
+ assert_behavior_conditions_match_telemetry,
13
+ find_behavior_telemetry_issues,
14
+ )
10
15
  from .mock_core import CapturedRequest, MockCoreServer, RUNTIME_CONFIG_FETCH_PATH
11
16
 
12
17
  __all__ = [
13
18
  "CapturedRequest",
19
+ "BehaviorTelemetryIssue",
14
20
  "MockCoreServer",
15
21
  "RUNTIME_CONFIG_FETCH_PATH",
16
22
  "assert_entities_response",
23
+ "assert_behavior_conditions_match_telemetry",
17
24
  "assert_event_sent",
18
25
  "assert_telemetry_sent",
19
26
  "build_config_payload",
20
27
  "build_config_snapshot",
21
28
  "build_core_config_row",
22
29
  "build_runtime_headers",
30
+ "find_behavior_telemetry_issues",
23
31
  ]
@@ -79,10 +79,10 @@ def _find_request(
79
79
  if body_event_type is None and isinstance(body, dict):
80
80
  body_event_type = body.get("type")
81
81
  if device_id is not None:
82
- if isinstance(body, dict) and body.get("device_id") != device_id:
82
+ if isinstance(body, dict) and _pick_alias(body, "device_id", "deviceId") != device_id:
83
83
  continue
84
84
  if config_id is not None:
85
- if isinstance(body, dict) and body.get("config_id") != config_id:
85
+ if isinstance(body, dict) and _pick_alias(body, "config_id", "configId") != config_id:
86
86
  continue
87
87
  if event_type is not None:
88
88
  if body_event_type != event_type:
@@ -95,18 +95,23 @@ def assert_telemetry_sent(
95
95
  mock_core: MockCoreServer,
96
96
  *,
97
97
  device_id: str | None = None,
98
+ config_id: str | None = None,
98
99
  ) -> CapturedRequest:
99
100
  """Assert that at least one telemetry request was captured."""
100
101
 
101
102
  if not mock_core.telemetry_requests:
102
103
  raise AssertionError("Expected telemetry to be sent to mock Core, but no telemetry requests were captured.")
103
104
 
104
- if device_id is None:
105
+ if device_id is None and config_id is None:
105
106
  return mock_core.telemetry_requests[-1]
106
107
 
107
- request = _find_request(mock_core.telemetry_requests, device_id=device_id)
108
+ request = _find_request(mock_core.telemetry_requests, device_id=device_id, config_id=config_id)
108
109
  if request is None:
109
- raise AssertionError(f"Expected telemetry for device_id={device_id!r}, but captured device_ids were {mock_core.captured_telemetry_device_ids()!r}.")
110
+ raise AssertionError(
111
+ "Expected telemetry matching filters, but none were captured. "
112
+ f"device_id={device_id!r} config_id={config_id!r} "
113
+ f"captured_device_ids={mock_core.captured_telemetry_device_ids()!r}"
114
+ )
110
115
  return request
111
116
 
112
117
 
@@ -0,0 +1,268 @@
1
+ """Conformance checks between automation behaviors and emitted telemetry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class BehaviorTelemetryIssue:
12
+ """One behavior condition that cannot be evaluated from runtime telemetry."""
13
+
14
+ device_id: str
15
+ condition_id: str
16
+ field: str
17
+ aliases: tuple[str, ...]
18
+ reason: str
19
+ template_id: str | None = None
20
+
21
+ def describe(self) -> str:
22
+ owner = (
23
+ f"template {self.template_id!r}"
24
+ if self.template_id
25
+ else f"device {self.device_id!r}"
26
+ )
27
+ candidates = ", ".join(repr(item) for item in (self.field, *self.aliases))
28
+ return (
29
+ f"{owner} condition {self.condition_id!r}: {self.reason}; "
30
+ f"expected one of [{candidates}]"
31
+ )
32
+
33
+
34
+ def _as_mapping(value: Any) -> Mapping[str, Any]:
35
+ return value if isinstance(value, Mapping) else {}
36
+
37
+
38
+ def _as_list(value: Any) -> list[Any]:
39
+ return list(value) if isinstance(value, (list, tuple)) else []
40
+
41
+
42
+ def _metric_samples(
43
+ telemetry_samples: Iterable[Mapping[str, Any]] | Mapping[str, Any],
44
+ ) -> list[Mapping[str, Any]]:
45
+ raw_samples = (
46
+ [telemetry_samples]
47
+ if isinstance(telemetry_samples, Mapping)
48
+ else list(telemetry_samples)
49
+ )
50
+ samples: list[Mapping[str, Any]] = []
51
+ for sample in raw_samples:
52
+ payload = _as_mapping(sample)
53
+ metrics = payload.get("metrics")
54
+ samples.append(_as_mapping(metrics) if isinstance(metrics, Mapping) else payload)
55
+ return samples
56
+
57
+
58
+ def _field_aliases(runtime: Mapping[str, Any]) -> tuple[str, ...]:
59
+ raw_aliases = runtime.get("fieldAliases", runtime.get("field_aliases"))
60
+ return tuple(
61
+ dict.fromkeys(
62
+ str(alias or "").strip()
63
+ for alias in _as_list(raw_aliases)
64
+ if str(alias or "").strip()
65
+ )
66
+ )
67
+
68
+
69
+ def _value_matches_type(value: Any, condition_type: str) -> bool:
70
+ normalized_type = str(condition_type or "").strip().lower()
71
+ if not normalized_type:
72
+ return True
73
+ if normalized_type == "boolean":
74
+ return isinstance(value, bool)
75
+ if normalized_type == "number":
76
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
77
+ if normalized_type in {"text", "enum"}:
78
+ return isinstance(value, str)
79
+ return True
80
+
81
+
82
+ def _condition_issue(
83
+ condition: Mapping[str, Any],
84
+ *,
85
+ device_id: str,
86
+ samples: list[Mapping[str, Any]],
87
+ dynamic_condition_ids: set[str],
88
+ template_id: str | None = None,
89
+ ) -> BehaviorTelemetryIssue | None:
90
+ condition_id = str(condition.get("id") or condition.get("field") or "condition").strip()
91
+ runtime = _as_mapping(condition.get("runtime"))
92
+ source = str(runtime.get("source") or condition.get("source") or "state").strip().lower()
93
+ if source not in {"", "state"}:
94
+ return None
95
+
96
+ if condition_id in dynamic_condition_ids:
97
+ return None
98
+
99
+ dynamic_field_param = str(
100
+ runtime.get("fieldFromParam") or runtime.get("field_from_param") or ""
101
+ ).strip()
102
+ if dynamic_field_param:
103
+ return BehaviorTelemetryIssue(
104
+ device_id=device_id,
105
+ condition_id=condition_id,
106
+ field=dynamic_field_param,
107
+ aliases=(),
108
+ reason="dynamic condition needs an explicit test exemption",
109
+ template_id=template_id,
110
+ )
111
+
112
+ field = str(runtime.get("field") or condition.get("field") or "").strip()
113
+ if not field:
114
+ return BehaviorTelemetryIssue(
115
+ device_id=device_id,
116
+ condition_id=condition_id,
117
+ field="<missing>",
118
+ aliases=(),
119
+ reason="state-backed condition does not declare a runtime field",
120
+ template_id=template_id,
121
+ )
122
+
123
+ aliases = _field_aliases(runtime)
124
+ candidates = (field, *aliases)
125
+ observed = [
126
+ sample[candidate]
127
+ for sample in samples
128
+ for candidate in candidates
129
+ if candidate in sample
130
+ ]
131
+ if not observed:
132
+ return BehaviorTelemetryIssue(
133
+ device_id=device_id,
134
+ condition_id=condition_id,
135
+ field=field,
136
+ aliases=aliases,
137
+ reason="field was not emitted by any representative telemetry sample",
138
+ template_id=template_id,
139
+ )
140
+
141
+ condition_type = str(condition.get("type") or runtime.get("type") or "").strip()
142
+ if condition_type and not any(
143
+ _value_matches_type(value, condition_type) for value in observed
144
+ ):
145
+ return BehaviorTelemetryIssue(
146
+ device_id=device_id,
147
+ condition_id=condition_id,
148
+ field=field,
149
+ aliases=aliases,
150
+ reason=f"emitted value does not match declared type {condition_type!r}",
151
+ template_id=template_id,
152
+ )
153
+ return None
154
+
155
+
156
+ def _condition_leaves(node: Any) -> Iterable[Mapping[str, Any]]:
157
+ current = _as_mapping(node)
158
+ if not current:
159
+ return
160
+ children = _as_list(current.get("children"))
161
+ if children:
162
+ for child in children:
163
+ yield from _condition_leaves(child)
164
+ return
165
+ yield current
166
+
167
+
168
+ def find_behavior_telemetry_issues(
169
+ behaviors: Mapping[str, Any],
170
+ telemetry_samples: Iterable[Mapping[str, Any]] | Mapping[str, Any],
171
+ *,
172
+ dynamic_condition_ids: Iterable[str] = (),
173
+ ) -> list[BehaviorTelemetryIssue]:
174
+ """Return conditions that representative runtime telemetry cannot evaluate.
175
+
176
+ ``telemetry_samples`` may contain raw metric mappings or Core telemetry
177
+ payloads with a ``metrics`` object. Parameter-driven dynamic conditions must
178
+ be named explicitly in ``dynamic_condition_ids`` and tested separately.
179
+ """
180
+
181
+ samples = _metric_samples(telemetry_samples)
182
+ dynamic_ids = {
183
+ str(condition_id or "").strip()
184
+ for condition_id in dynamic_condition_ids
185
+ if str(condition_id or "").strip()
186
+ }
187
+ devices = [
188
+ _as_mapping(device)
189
+ for device in _as_list(behaviors.get("devices"))
190
+ if isinstance(device, Mapping)
191
+ ]
192
+ devices_by_id = {
193
+ str(device.get("id") or "").strip(): device
194
+ for device in devices
195
+ if str(device.get("id") or "").strip()
196
+ }
197
+ options_by_device = {
198
+ device_id: {
199
+ str(condition.get("id") or "").strip(): condition
200
+ for condition in _as_list(device.get("conditions"))
201
+ if isinstance(condition, Mapping)
202
+ }
203
+ for device_id, device in devices_by_id.items()
204
+ }
205
+
206
+ issues: list[BehaviorTelemetryIssue] = []
207
+ for device_id, device in devices_by_id.items():
208
+ for raw_condition in _as_list(device.get("conditions")):
209
+ condition = _as_mapping(raw_condition)
210
+ issue = _condition_issue(
211
+ condition,
212
+ device_id=device_id,
213
+ samples=samples,
214
+ dynamic_condition_ids=dynamic_ids,
215
+ )
216
+ if issue:
217
+ issues.append(issue)
218
+
219
+ for raw_template in _as_list(behaviors.get("templates")):
220
+ template = _as_mapping(raw_template)
221
+ template_id = str(template.get("id") or "template").strip()
222
+ device_id = str(
223
+ template.get("deviceKey") or template.get("device_key") or ""
224
+ ).strip()
225
+ condition_options = options_by_device.get(device_id, {})
226
+ config = _as_mapping(template.get("config"))
227
+ condition_tree = config.get("conditionTree", config.get("condition_tree"))
228
+ for leaf in _condition_leaves(condition_tree):
229
+ option_key = str(
230
+ _as_mapping(leaf.get("sourceRef")).get("optionKey")
231
+ or _as_mapping(leaf.get("source_ref")).get("option_key")
232
+ or leaf.get("id")
233
+ or ""
234
+ ).strip()
235
+ option = condition_options.get(option_key, {})
236
+ merged = {**option, **leaf}
237
+ if option.get("runtime") and not leaf.get("runtime"):
238
+ merged["runtime"] = option["runtime"]
239
+ issue = _condition_issue(
240
+ merged,
241
+ device_id=device_id,
242
+ samples=samples,
243
+ dynamic_condition_ids=dynamic_ids,
244
+ template_id=template_id,
245
+ )
246
+ if issue:
247
+ issues.append(issue)
248
+ return issues
249
+
250
+
251
+ def assert_behavior_conditions_match_telemetry(
252
+ behaviors: Mapping[str, Any],
253
+ telemetry_samples: Iterable[Mapping[str, Any]] | Mapping[str, Any],
254
+ *,
255
+ dynamic_condition_ids: Iterable[str] = (),
256
+ ) -> None:
257
+ """Fail a contract test when advertised conditions lack runtime telemetry."""
258
+
259
+ issues = find_behavior_telemetry_issues(
260
+ behaviors,
261
+ telemetry_samples,
262
+ dynamic_condition_ids=dynamic_condition_ids,
263
+ )
264
+ if issues:
265
+ details = "\n".join(f"- {issue.describe()}" for issue in issues)
266
+ raise AssertionError(
267
+ "Behavior conditions do not match emitted telemetry:\n" + details
268
+ )
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Iterable, Mapping
3
4
  from dataclasses import dataclass
4
5
  import json
5
6
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -169,7 +170,7 @@ class MockCoreServer:
169
170
 
170
171
  def captured_telemetry_device_ids(self) -> list[Any]:
171
172
  return [
172
- request.json_body.get("device_id")
173
+ request.json_body.get("device_id", request.json_body.get("deviceId"))
173
174
  for request in self.telemetry_requests
174
175
  if isinstance(request.json_body, dict)
175
176
  ]
@@ -179,10 +180,15 @@ class MockCoreServer:
179
180
  self._server.server_close()
180
181
  self._thread.join(timeout=2)
181
182
 
182
- def assert_telemetry_sent(self, *, device_id: str | None = None) -> CapturedRequest:
183
+ def assert_telemetry_sent(
184
+ self,
185
+ *,
186
+ device_id: str | None = None,
187
+ config_id: str | None = None,
188
+ ) -> CapturedRequest:
183
189
  from .assertions import assert_telemetry_sent
184
190
 
185
- return assert_telemetry_sent(self, device_id=device_id)
191
+ return assert_telemetry_sent(self, device_id=device_id, config_id=config_id)
186
192
 
187
193
  def assert_event_sent(
188
194
  self,
@@ -199,3 +205,24 @@ class MockCoreServer:
199
205
  config_id=config_id,
200
206
  event_type=event_type,
201
207
  )
208
+
209
+ def assert_behavior_conditions_match_telemetry(
210
+ self,
211
+ behaviors: Mapping[str, Any],
212
+ *,
213
+ dynamic_condition_ids: Iterable[str] = (),
214
+ ) -> None:
215
+ """Validate behaviors against telemetry captured by this mock Core."""
216
+
217
+ from .behavior_contract import assert_behavior_conditions_match_telemetry
218
+
219
+ samples = [
220
+ request.json_body
221
+ for request in self.telemetry_requests
222
+ if isinstance(request.json_body, dict)
223
+ ]
224
+ assert_behavior_conditions_match_telemetry(
225
+ behaviors,
226
+ samples,
227
+ dynamic_condition_ids=dynamic_condition_ids,
228
+ )
@@ -23,15 +23,37 @@ def make_request(path: str, json_body: dict) -> CapturedRequest:
23
23
  def test_assert_telemetry_sent_returns_matching_request():
24
24
  mock_core = MockCoreServer()
25
25
  try:
26
- mock_core.telemetry_requests.append(make_request("/api/v2/integrations/telemetry", {"device_id": "plug-1"}))
26
+ mock_core.telemetry_requests.append(
27
+ make_request(
28
+ "/api/v2/integrations/telemetry",
29
+ {"device_id": "plug-1", "config_id": "config-1"},
30
+ )
31
+ )
27
32
 
28
- request = assert_telemetry_sent(mock_core, device_id="plug-1")
33
+ request = assert_telemetry_sent(mock_core, device_id="plug-1", config_id="config-1")
29
34
 
30
35
  assert request.json_body["device_id"] == "plug-1"
31
36
  finally:
32
37
  mock_core.shutdown()
33
38
 
34
39
 
40
+ def test_assert_telemetry_sent_matches_camel_case_payload():
41
+ mock_core = MockCoreServer()
42
+ try:
43
+ mock_core.telemetry_requests.append(
44
+ make_request(
45
+ "/api/v2/integrations/telemetry",
46
+ {"deviceId": "plug-2", "configId": "config-2"},
47
+ )
48
+ )
49
+
50
+ request = mock_core.assert_telemetry_sent(device_id="plug-2", config_id="config-2")
51
+
52
+ assert request.json_body["configId"] == "config-2"
53
+ finally:
54
+ mock_core.shutdown()
55
+
56
+
35
57
  def test_assert_event_sent_returns_matching_request():
36
58
  mock_core = MockCoreServer()
37
59
  try:
@@ -0,0 +1,137 @@
1
+ import pytest
2
+
3
+ from piphi_runtime_testkit_python import (
4
+ CapturedRequest,
5
+ MockCoreServer,
6
+ assert_behavior_conditions_match_telemetry,
7
+ find_behavior_telemetry_issues,
8
+ )
9
+
10
+
11
+ def _behaviors() -> dict:
12
+ return {
13
+ "behaviorSchemaVersion": "integration.behaviors.v2",
14
+ "devices": [
15
+ {
16
+ "id": "light",
17
+ "name": "Light",
18
+ "conditions": [
19
+ {
20
+ "id": "is_on",
21
+ "label": "Power is on",
22
+ "type": "boolean",
23
+ "runtime": {
24
+ "source": "state",
25
+ "field": "is_on",
26
+ "fieldAliases": ["power_state"],
27
+ },
28
+ },
29
+ {
30
+ "id": "brightness_above",
31
+ "label": "Brightness is above",
32
+ "type": "number",
33
+ "runtime": {"source": "state", "field": "brightness"},
34
+ },
35
+ ],
36
+ }
37
+ ],
38
+ "templates": [
39
+ {
40
+ "id": "bright_light",
41
+ "label": "Bright light",
42
+ "deviceKey": "light",
43
+ "config": {
44
+ "conditionTree": {
45
+ "id": "is_on",
46
+ "sourceRef": {"optionKey": "is_on"},
47
+ "field": "is_on",
48
+ "operator": "eq",
49
+ "value": True,
50
+ }
51
+ },
52
+ }
53
+ ],
54
+ }
55
+
56
+
57
+ def test_behavior_conditions_accept_fields_aliases_types_and_templates() -> None:
58
+ assert_behavior_conditions_match_telemetry(
59
+ _behaviors(),
60
+ {"metrics": {"power_state": True, "brightness": 42}},
61
+ )
62
+
63
+
64
+ def test_behavior_conditions_report_missing_field_and_type_mismatch() -> None:
65
+ issues = find_behavior_telemetry_issues(
66
+ _behaviors(),
67
+ [{"is_on": "yes"}],
68
+ )
69
+
70
+ assert [(issue.condition_id, issue.reason) for issue in issues] == [
71
+ ("is_on", "emitted value does not match declared type 'boolean'"),
72
+ (
73
+ "brightness_above",
74
+ "field was not emitted by any representative telemetry sample",
75
+ ),
76
+ ("is_on", "emitted value does not match declared type 'boolean'"),
77
+ ]
78
+
79
+
80
+ def test_behavior_conditions_fail_with_actionable_message() -> None:
81
+ with pytest.raises(AssertionError, match="brightness_above"):
82
+ assert_behavior_conditions_match_telemetry(_behaviors(), {"is_on": True})
83
+
84
+
85
+ def test_dynamic_conditions_require_explicit_exemption() -> None:
86
+ behaviors = _behaviors()
87
+ behaviors["devices"][0]["conditions"] = [
88
+ {
89
+ "id": "feature_equals",
90
+ "label": "Feature equals",
91
+ "runtime": {
92
+ "source": "state",
93
+ "fieldFromParam": "feature_id",
94
+ },
95
+ }
96
+ ]
97
+ behaviors["templates"] = []
98
+
99
+ issues = find_behavior_telemetry_issues(behaviors, {})
100
+ assert issues[0].reason == "dynamic condition needs an explicit test exemption"
101
+
102
+ assert_behavior_conditions_match_telemetry(
103
+ behaviors,
104
+ {},
105
+ dynamic_condition_ids={"feature_equals"},
106
+ )
107
+
108
+
109
+ def test_non_state_conditions_are_outside_telemetry_gate() -> None:
110
+ behaviors = _behaviors()
111
+ behaviors["devices"][0]["conditions"] = [
112
+ {
113
+ "id": "weekday",
114
+ "label": "It is Monday",
115
+ "runtime": {"source": "calendar", "field": "weekday"},
116
+ }
117
+ ]
118
+ behaviors["templates"] = []
119
+
120
+ assert find_behavior_telemetry_issues(behaviors, {}) == []
121
+
122
+
123
+ def test_mock_core_validates_behaviors_against_captured_telemetry() -> None:
124
+ mock_core = MockCoreServer()
125
+ try:
126
+ mock_core.telemetry_requests.append(
127
+ CapturedRequest(
128
+ method="POST",
129
+ path="/api/v2/integrations/telemetry",
130
+ headers={},
131
+ body=b"{}",
132
+ json_body={"metrics": {"is_on": True, "brightness": 42}},
133
+ )
134
+ )
135
+ mock_core.assert_behavior_conditions_match_telemetry(_behaviors())
136
+ finally:
137
+ mock_core.shutdown()