Skip to content

Commit 1949391

Browse files
committed
fixup! Implement ReceiveComponentDataStream method
Add tests for Sample
1 parent 60f3f2a commit 1949391

File tree

1 file changed

+390
-0
lines changed

1 file changed

+390
-0
lines changed

tests/metrics/test_sample.py

Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for the Sample class and related classes."""
5+
6+
from dataclasses import dataclass, field
7+
from datetime import datetime, timezone
8+
from typing import Any
9+
from unittest.mock import Mock, patch
10+
11+
import pytest
12+
from frequenz.api.common.v1.metrics import bounds_pb2, metric_sample_pb2
13+
from google.protobuf.timestamp_pb2 import Timestamp
14+
15+
from frequenz.client.microgrid.metrics import (
16+
AggregatedMetricValue,
17+
AggregationMethod,
18+
Bounds,
19+
Metric,
20+
MetricSample,
21+
)
22+
from frequenz.client.microgrid.metrics._sample_proto import (
23+
aggregated_metric_sample_from_proto,
24+
metric_sample_from_proto,
25+
)
26+
27+
28+
@dataclass(frozen=True, kw_only=True)
29+
class AggregatedValueTestCase:
30+
"""Test case for AggregatedMetricValue protobuf conversion."""
31+
32+
name: str
33+
"""The description of the test case."""
34+
35+
avg_value: float
36+
"""The average value to set."""
37+
38+
has_min: bool = True
39+
"""Whether to include min value."""
40+
41+
has_max: bool = True
42+
"""Whether to include max value."""
43+
44+
min_value: float | None = None
45+
"""The minimum value to set."""
46+
47+
max_value: float | None = None
48+
"""The maximum value to set."""
49+
50+
raw_values: list[float] = field(default_factory=list)
51+
"""The raw values to include."""
52+
53+
54+
@dataclass(frozen=True, kw_only=True)
55+
class MetricSampleTestCase:
56+
"""Test case for MetricSample protobuf conversion."""
57+
58+
name: str
59+
"""The description of the test case."""
60+
61+
has_simple_value: bool
62+
"""Whether to include a simple value."""
63+
64+
has_aggregated_value: bool
65+
"""Whether to include an aggregated value."""
66+
67+
has_source: bool
68+
"""Whether to include a source connection."""
69+
70+
metric: Metric | int
71+
"""The metric to set."""
72+
73+
expect_warning: bool = False
74+
"""Whether to expect a warning during conversion."""
75+
76+
77+
@pytest.fixture
78+
def now() -> datetime:
79+
"""Get the current time."""
80+
return datetime.now(timezone.utc)
81+
82+
83+
def test_aggregation_method_values() -> None:
84+
"""Test that AggregationMethod enum has the expected values."""
85+
assert AggregationMethod.AVG.value == "avg"
86+
assert AggregationMethod.MIN.value == "min"
87+
assert AggregationMethod.MAX.value == "max"
88+
89+
90+
def test_aggregated_metric_value() -> None:
91+
"""Test AggregatedMetricValue creation and string representation."""
92+
# Test with full data
93+
value = AggregatedMetricValue(
94+
avg=5.0,
95+
min=1.0,
96+
max=10.0,
97+
raw_values=[1.0, 5.0, 10.0],
98+
)
99+
assert value.avg == 5.0
100+
assert value.min == 1.0
101+
assert value.max == 10.0
102+
assert list(value.raw_values) == [1.0, 5.0, 10.0]
103+
assert str(value) == "avg:5.0<min:1.0 max:10.0 num_raw:3>"
104+
105+
# Test with minimal data (only avg required)
106+
value = AggregatedMetricValue(
107+
avg=5.0,
108+
min=None,
109+
max=None,
110+
raw_values=[],
111+
)
112+
assert value.avg == 5.0
113+
assert value.min is None
114+
assert value.max is None
115+
assert not value.raw_values
116+
assert str(value) == "avg:5.0"
117+
118+
119+
def test_metric_sample_creation(now: datetime) -> None:
120+
"""Test MetricSample creation with different value types."""
121+
bounds = [Bounds(lower=-10.0, upper=10.0)]
122+
123+
# Test with float value
124+
sample = MetricSample(
125+
sampled_at=now,
126+
metric=Metric.AC_ACTIVE_POWER,
127+
value=5.0,
128+
bounds=bounds,
129+
)
130+
assert sample.sampled_at == now
131+
assert sample.metric == Metric.AC_ACTIVE_POWER
132+
assert sample.value == 5.0
133+
assert sample.bounds == bounds
134+
assert sample.connection is None
135+
136+
# Test with AggregatedMetricValue
137+
agg_value = AggregatedMetricValue(
138+
avg=5.0,
139+
min=1.0,
140+
max=10.0,
141+
raw_values=[1.0, 5.0, 10.0],
142+
)
143+
sample = MetricSample(
144+
sampled_at=now,
145+
metric=Metric.AC_ACTIVE_POWER,
146+
value=agg_value,
147+
bounds=bounds,
148+
connection="dc_battery_0",
149+
)
150+
assert sample.sampled_at == now
151+
assert sample.metric == Metric.AC_ACTIVE_POWER
152+
assert sample.value == agg_value
153+
assert sample.bounds == bounds
154+
assert sample.connection == "dc_battery_0"
155+
156+
# Test with None value
157+
sample = MetricSample(
158+
sampled_at=now,
159+
metric=Metric.AC_ACTIVE_POWER,
160+
value=None,
161+
bounds=bounds,
162+
)
163+
assert sample.value is None
164+
165+
166+
def test_metric_sample_as_single_value(now: datetime) -> None:
167+
"""Test MetricSample.as_single_value with different value types and methods."""
168+
bounds = [Bounds(lower=-10.0, upper=10.0)]
169+
test_cases: list[tuple[Any, dict[AggregationMethod, float | None]]] = [
170+
# (value, {method: expected_result})
171+
(
172+
5.0,
173+
{
174+
AggregationMethod.AVG: 5.0,
175+
AggregationMethod.MIN: 5.0,
176+
AggregationMethod.MAX: 5.0,
177+
},
178+
),
179+
(
180+
AggregatedMetricValue(
181+
avg=5.0,
182+
min=1.0,
183+
max=10.0,
184+
raw_values=[1.0, 5.0, 10.0],
185+
),
186+
{
187+
AggregationMethod.AVG: 5.0,
188+
AggregationMethod.MIN: 1.0,
189+
AggregationMethod.MAX: 10.0,
190+
},
191+
),
192+
(
193+
None,
194+
{
195+
AggregationMethod.AVG: None,
196+
AggregationMethod.MIN: None,
197+
AggregationMethod.MAX: None,
198+
},
199+
),
200+
]
201+
202+
for value, method_results in test_cases:
203+
sample = MetricSample(
204+
sampled_at=now,
205+
metric=Metric.AC_ACTIVE_POWER,
206+
value=value,
207+
bounds=bounds,
208+
)
209+
210+
for method, expected in method_results.items():
211+
assert sample.as_single_value(aggregation_method=method) == expected
212+
213+
214+
def test_metric_sample_multiple_bounds(now: datetime) -> None:
215+
"""Test MetricSample creation with multiple bounds."""
216+
bounds = [
217+
Bounds(lower=-10.0, upper=-5.0),
218+
Bounds(lower=5.0, upper=10.0),
219+
]
220+
sample = MetricSample(
221+
sampled_at=now,
222+
metric=Metric.AC_ACTIVE_POWER,
223+
value=7.0,
224+
bounds=bounds,
225+
)
226+
assert sample.bounds == bounds
227+
228+
229+
@pytest.mark.parametrize(
230+
"case",
231+
[
232+
AggregatedValueTestCase(
233+
name="complete data",
234+
avg_value=5.0,
235+
min_value=1.0,
236+
max_value=10.0,
237+
raw_values=[1.0, 5.0, 10.0],
238+
),
239+
AggregatedValueTestCase(
240+
name="minimal data",
241+
avg_value=5.0,
242+
has_min=False,
243+
has_max=False,
244+
),
245+
AggregatedValueTestCase(
246+
name="only min additional",
247+
avg_value=5.0,
248+
has_max=False,
249+
min_value=1.0,
250+
),
251+
AggregatedValueTestCase(
252+
name="only max additional",
253+
avg_value=5.0,
254+
has_min=False,
255+
max_value=10.0,
256+
),
257+
],
258+
ids=lambda case: case.name,
259+
)
260+
def test_aggregated_metric_value_from_proto(case: AggregatedValueTestCase) -> None:
261+
"""Test conversion from protobuf message to AggregatedMetricValue.
262+
263+
Args:
264+
case: Test case parameters
265+
266+
Raises:
267+
AssertionError: If the conversion doesn't match expected values
268+
"""
269+
proto = metric_sample_pb2.AggregatedMetricValue(
270+
avg_value=case.avg_value,
271+
)
272+
if case.has_min and case.min_value is not None:
273+
proto.min_value = case.min_value
274+
if case.has_max and case.max_value is not None:
275+
proto.max_value = case.max_value
276+
if case.raw_values:
277+
proto.raw_values.extend(case.raw_values)
278+
279+
value = aggregated_metric_sample_from_proto(proto)
280+
281+
assert value.avg == case.avg_value
282+
assert value.min == (case.min_value if case.has_min else None)
283+
assert value.max == (case.max_value if case.has_max else None)
284+
assert list(value.raw_values) == case.raw_values
285+
286+
287+
@pytest.mark.parametrize(
288+
"case",
289+
[
290+
MetricSampleTestCase(
291+
name="simple value",
292+
has_simple_value=True,
293+
has_aggregated_value=False,
294+
has_source=False,
295+
metric=Metric.AC_ACTIVE_POWER,
296+
),
297+
MetricSampleTestCase(
298+
name="aggregated value",
299+
has_simple_value=False,
300+
has_aggregated_value=True,
301+
has_source=True,
302+
metric=Metric.AC_ACTIVE_POWER,
303+
),
304+
MetricSampleTestCase(
305+
name="no value",
306+
has_simple_value=False,
307+
has_aggregated_value=False,
308+
has_source=False,
309+
metric=Metric.AC_ACTIVE_POWER,
310+
),
311+
MetricSampleTestCase(
312+
name="unrecognized metric",
313+
has_simple_value=True,
314+
has_aggregated_value=False,
315+
has_source=False,
316+
metric=999,
317+
expect_warning=True,
318+
),
319+
],
320+
ids=lambda case: case.name,
321+
)
322+
@patch("frequenz.client.microgrid.metrics._sample_proto.bounds_from_proto")
323+
@patch("frequenz.client.microgrid.metrics._sample_proto.enum_from_proto")
324+
@patch("frequenz.client.base.conversion.to_datetime")
325+
def test_metric_sample_from_proto(
326+
mock_to_datetime: Mock,
327+
mock_enum_from_proto: Mock,
328+
mock_bounds_from_proto: Mock,
329+
case: MetricSampleTestCase,
330+
) -> None:
331+
"""Test conversion from protobuf message to MetricSample.
332+
333+
Args:
334+
mock_to_datetime: Mock for timestamp conversion
335+
mock_enum_from_proto: Mock for enum conversion
336+
mock_bounds_from_proto: Mock for bounds conversion
337+
case: Test case parameters
338+
339+
Raises:
340+
AssertionError: If the conversion doesn't match expected values
341+
"""
342+
now = datetime.now(timezone.utc)
343+
mock_to_datetime.return_value = now
344+
345+
if isinstance(case.metric, Metric):
346+
mock_enum_from_proto.return_value = case.metric
347+
else:
348+
mock_enum_from_proto.return_value = case.metric
349+
350+
mock_bounds_from_proto.return_value = bounds_pb2.Bounds(lower=-10.0, upper=10.0)
351+
352+
timestamp = Timestamp()
353+
timestamp.FromDatetime(now)
354+
355+
metric_value = case.metric.value if isinstance(case.metric, Metric) else case.metric
356+
proto = metric_sample_pb2.MetricSample(
357+
sampled_at=timestamp,
358+
metric=metric_value, # type: ignore[arg-type]
359+
)
360+
361+
if case.has_simple_value:
362+
proto.value.simple_metric.value = 5.0
363+
elif case.has_aggregated_value:
364+
proto.value.aggregated_metric.avg_value = 5.0
365+
proto.value.aggregated_metric.min_value = 1.0
366+
proto.value.aggregated_metric.max_value = 10.0
367+
368+
if case.has_source:
369+
proto.source = "dc_battery_0"
370+
371+
proto.bounds.append(bounds_pb2.Bounds(lower=-10.0, upper=10.0))
372+
373+
sample = metric_sample_from_proto(proto)
374+
375+
assert sample.sampled_at == now
376+
assert sample.metric == case.metric
377+
if case.has_simple_value:
378+
assert isinstance(sample.value, float)
379+
assert sample.value == 5.0
380+
elif case.has_aggregated_value:
381+
assert isinstance(sample.value, AggregatedMetricValue)
382+
assert sample.value.avg == 5.0
383+
assert sample.value.min == 1.0
384+
assert sample.value.max == 10.0
385+
else:
386+
assert sample.value is None
387+
388+
mock_to_datetime.assert_called_once()
389+
mock_enum_from_proto.assert_called_once()
390+
mock_bounds_from_proto.assert_called_once()

0 commit comments

Comments
 (0)