piphi-runtime-testkit-python 0.1.3__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.
- {piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/PKG-INFO +23 -1
- {piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/README.md +22 -0
- {piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/pyproject.toml +2 -2
- {piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/src/piphi_runtime_testkit_python/__init__.py +8 -0
- {piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/src/piphi_runtime_testkit_python/assertions.py +10 -5
- piphi_runtime_testkit_python-0.1.4/src/piphi_runtime_testkit_python/behavior_contract.py +268 -0
- {piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/src/piphi_runtime_testkit_python/mock_core.py +30 -3
- {piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/tests/test_assertions.py +24 -2
- piphi_runtime_testkit_python-0.1.4/tests/test_behavior_contract.py +137 -0
- {piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/LICENSE +0 -0
- {piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/src/piphi_runtime_testkit_python/builders.py +0 -0
- {piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/src/piphi_runtime_testkit_python/pytest_plugin.py +0 -0
- {piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/tests/__init__.py +0 -0
- {piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/tests/test_builders.py +0 -0
- {piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/tests/test_fastapi_runtime_example.py +0 -0
- {piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/tests/test_mock_core.py +0 -0
- {piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/tests/test_pytest_plugin.py +0 -0
- {piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/tests/test_runtime_config_contract.py +0 -0
- {piphi_runtime_testkit_python-0.1.3 → 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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
+
)
|
{piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/tests/test_assertions.py
RENAMED
|
@@ -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(
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/tests/test_builders.py
RENAMED
|
File without changes
|
|
File without changes
|
{piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/tests/test_mock_core.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{piphi_runtime_testkit_python-0.1.3 → piphi_runtime_testkit_python-0.1.4}/tests/test_runtime_flow.py
RENAMED
|
File without changes
|