Skip to content

Commit 6e9231e

Browse files
authored
pyhon(feat): Add more funcs to test results context managers (#440)
1 parent 6da335e commit 6e9231e

2 files changed

Lines changed: 333 additions & 14 deletions

File tree

python/lib/sift_client/_tests/util/test_test_results_utils.py

Lines changed: 217 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
from datetime import datetime, timezone
22

3+
import numpy as np
4+
import pandas as pd
35
import pytest
46

57
from sift_client.sift_types.test_report import (
8+
NumericBounds,
69
TestMeasurementCreate,
710
TestMeasurementType,
811
TestMeasurementUpdate,
@@ -141,16 +144,220 @@ def test_measurement_update(self, report_context):
141144
new_step.measure(name="Test Measurement 2", value="string value", bounds="string value")
142145
new_step.measure(name="Test Measurement 3", value=True, bounds="true")
143146

144-
assert len(test_step.measurements) == 3
145-
assert test_step.measurements[0].name == "Test Measurement"
146-
assert test_step.measurements[0].numeric_value == 10
147-
assert test_step.measurements[0].measurement_type == TestMeasurementType.DOUBLE
148-
assert test_step.measurements[1].name == "Test Measurement 2"
149-
assert test_step.measurements[1].string_value == "string value"
150-
assert test_step.measurements[1].measurement_type == TestMeasurementType.STRING
151-
assert test_step.measurements[2].name == "Test Measurement 3"
152-
assert test_step.measurements[2].boolean_value == True
153-
assert test_step.measurements[2].measurement_type == TestMeasurementType.BOOLEAN
147+
measurements = test_step.measurements
148+
assert len(measurements) == 3
149+
assert measurements[0].name == "Test Measurement"
150+
assert measurements[0].numeric_value == 10
151+
assert measurements[0].measurement_type == TestMeasurementType.DOUBLE
152+
assert measurements[1].name == "Test Measurement 2"
153+
assert measurements[1].string_value == "string value"
154+
assert measurements[1].measurement_type == TestMeasurementType.STRING
155+
assert measurements[2].name == "Test Measurement 3"
156+
assert measurements[2].boolean_value == True
157+
assert measurements[2].measurement_type == TestMeasurementType.BOOLEAN
158+
159+
def test_measure_avg_list_within_bounds(self, step):
160+
"""Test measure_avg with a list of values where average is within bounds."""
161+
result = step.measure_avg(
162+
name="Avg Temperature",
163+
values=[10.0, 20.0, 30.0], # avg = 20.0
164+
bounds={"min": 15.0, "max": 25.0},
165+
)
166+
assert result == True
167+
assert step.current_step.measurements[0].name == "Avg Temperature"
168+
assert step.current_step.measurements[0].numeric_value == 20.0
169+
assert step.current_step.measurements[0].passed == True
170+
171+
def test_measure_avg_list_outside_bounds(self, report_context, step):
172+
"""Test measure_avg with a list where average is outside bounds."""
173+
# Capture initial state to restore after test
174+
current_step_path = step.current_step.step_path
175+
initial_open_step_result = report_context.open_step_results.get(current_step_path, True)
176+
initial_any_failures = report_context.any_failures
177+
178+
result = step.measure_avg(
179+
name="Avg Temperature Fail",
180+
values=[50.0, 60.0, 70.0], # avg = 60.0
181+
bounds={"min": 15.0, "max": 25.0},
182+
)
183+
assert result == False
184+
assert step.current_step.measurements[0].numeric_value == 60.0
185+
assert step.current_step.measurements[0].passed == False
186+
187+
# Restore state
188+
if initial_open_step_result:
189+
report_context.open_step_results[current_step_path] = True
190+
if not initial_any_failures:
191+
report_context.any_failures = False
192+
193+
def test_measure_avg_numpy_array(self, step):
194+
"""Test measure_avg with a numpy array."""
195+
result = step.measure_avg(
196+
name="Avg Pressure",
197+
values=np.array([100.0, 200.0, 300.0]), # avg = 200.0
198+
bounds={"min": 150.0, "max": 250.0},
199+
)
200+
assert result == True
201+
assert step.current_step.measurements[0].numeric_value == 200.0
202+
assert step.current_step.measurements[0].passed == True
203+
204+
def test_measure_avg_pandas_series(self, step):
205+
"""Test measure_avg with a pandas Series."""
206+
series = pd.Series([5.0, 10.0, 15.0]) # avg = 10.0
207+
result = step.measure_avg(
208+
name="Avg Voltage",
209+
values=series,
210+
bounds={"min": 5.0, "max": 15.0},
211+
)
212+
assert result == True
213+
assert step.current_step.measurements[0].numeric_value == 10.0
214+
assert step.current_step.measurements[0].passed == True
215+
216+
def test_measure_avg_with_numeric_bounds_object(self, step):
217+
"""Test measure_avg with NumericBounds object instead of dict."""
218+
result = step.measure_avg(
219+
name="Avg Current",
220+
values=[1.0, 2.0, 3.0], # avg = 2.0
221+
bounds=NumericBounds(min=1.0, max=3.0),
222+
)
223+
assert result == True
224+
assert step.current_step.measurements[0].numeric_value == 2.0
225+
assert step.current_step.measurements[0].passed == True
226+
227+
def test_measure_avg_invalid_type(self, step):
228+
"""Test measure_avg raises ValueError for invalid value type."""
229+
with pytest.raises(ValueError, match="Invalid value type"):
230+
step.measure_avg(
231+
name="Invalid",
232+
values="not a list", # type: ignore
233+
bounds={"min": 0.0, "max": 10.0},
234+
)
235+
236+
def test_measure_avg_with_integers(self, step):
237+
"""Test measure_avg with integer values in list."""
238+
result = step.measure_avg(
239+
name="Avg Count",
240+
values=[1, 2, 3, 4, 5], # avg = 3.0
241+
bounds={"min": 2.0, "max": 4.0},
242+
)
243+
assert result == True
244+
assert step.current_step.measurements[0].numeric_value == 3.0
245+
assert step.current_step.measurements[0].passed == True
246+
247+
def test_measure_all_list_within_bounds(self, step):
248+
"""Test measure_all with a list of values all within bounds."""
249+
result = step.measure_all(
250+
name="All Temperatures",
251+
values=[10.0, 15.0, 20.0],
252+
bounds={"min": 5.0, "max": 25.0},
253+
)
254+
assert result == True
255+
256+
def test_measure_all_list_some_outside_bounds(self, report_context, step):
257+
"""Test measure_all with a list where some values are outside bounds."""
258+
# Capture initial state to restore after test
259+
current_step_path = step.current_step.step_path
260+
initial_open_step_result = report_context.open_step_results.get(current_step_path, True)
261+
initial_any_failures = report_context.any_failures
262+
263+
result = step.measure_all(
264+
name="temp",
265+
values=[10.0, 50.0, 20.0, -1.0], # 50.0 and -1.0 are outside
266+
bounds={"min": 5.0, "max": 25.0},
267+
unit="C",
268+
)
269+
assert result == False
270+
test_step = step.current_step
271+
measurements = test_step.measurements
272+
measurements.sort(key=lambda x: x.numeric_value)
273+
assert len(measurements) == 2
274+
assert measurements[0].numeric_value == -1.0
275+
assert measurements[0].passed == False
276+
assert measurements[1].numeric_value == 50.0
277+
assert measurements[1].passed == False
278+
279+
# Restore state
280+
if initial_open_step_result:
281+
report_context.open_step_results[current_step_path] = True
282+
if not initial_any_failures:
283+
report_context.any_failures = False
284+
285+
def test_measure_all_numpy_array(self, step):
286+
"""Test measure_all with a numpy array."""
287+
result = step.measure_all(
288+
name="All Pressures",
289+
values=np.array([100.0, 150.0, 200.0]),
290+
bounds={"min": 50.0, "max": 250.0},
291+
)
292+
assert result == True
293+
294+
def test_measure_all_pandas_series(self, step):
295+
"""Test measure_all with a pandas Series."""
296+
series = pd.Series([5.0, 10.0, 15.0])
297+
result = step.measure_all(
298+
name="All Voltages",
299+
values=series,
300+
bounds={"min": 0.0, "max": 20.0},
301+
)
302+
assert result == True
303+
304+
def test_measure_all_with_numeric_bounds_object(self, step):
305+
"""Test measure_all with NumericBounds object instead of dict."""
306+
result = step.measure_all(
307+
name="All Currents",
308+
values=[1.0, 2.0, 3.0],
309+
bounds=NumericBounds(min=0.0, max=5.0),
310+
)
311+
assert result == True
312+
313+
def test_measure_all_invalid_type(self, step):
314+
"""Test measure_all raises ValueError for invalid value type."""
315+
with pytest.raises(ValueError, match="Invalid value type"):
316+
step.measure_all(
317+
name="Invalid",
318+
values="not a list", # type: ignore
319+
bounds={"min": 0.0, "max": 10.0},
320+
)
321+
322+
def test_measure_all_no_bounds(self, step):
323+
"""Test measure_all raises ValueError when no bounds provided."""
324+
with pytest.raises(ValueError, match="No bounds provided"):
325+
step.measure_all(
326+
name="No Bounds",
327+
values=[1.0, 2.0, 3.0],
328+
bounds={}, # Empty bounds dict
329+
)
330+
331+
def test_measure_all_min_only(self, step):
332+
"""Test measure_all with only minimum bound."""
333+
result = step.measure_all(
334+
name="Min Only",
335+
values=[10.0, 20.0, 30.0],
336+
bounds={"min": 5.0},
337+
)
338+
assert result == True
339+
340+
def test_measure_all_max_only(self, step):
341+
"""Test measure_all with only maximum bound."""
342+
result = step.measure_all(
343+
name="Max Only",
344+
values=[10.0, 20.0, 30.0],
345+
bounds={"max": 50.0},
346+
)
347+
assert result == True
348+
349+
def test_report_outcome(self, report_context, step):
350+
# Capture current state of report context's failures so we can keep things passed at a high level if the test's induced failures happen as expected.
351+
current_step_path = step.current_step.step_path
352+
initial_open_step_result = report_context.open_step_results.get(current_step_path, True)
353+
initial_any_failures = report_context.any_failures
354+
assert step.report_outcome("Test Pass Outcome", True, "Test Pass Description") == True
355+
assert step.report_outcome("Test Fail Outcome", False, "Test Failure Description") == False
356+
# If this test was successful, mark that at a high level.
357+
if initial_open_step_result:
358+
report_context.open_step_results[current_step_path] = True
359+
if not initial_any_failures:
360+
report_context.any_failures = False
154361

155362
def test_bad_assert(self, report_context, step):
156363
# Capture current state of report context's failures so we can keep things passed at a high level if the test's induced failures happen as expected.

python/lib/sift_client/util/test_results/context_manager.py

Lines changed: 116 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
from datetime import datetime, timezone
99
from typing import TYPE_CHECKING
1010

11+
import numpy as np
12+
import pandas as pd
13+
1114
from sift_client.sift_types.test_report import (
1215
ErrorInfo,
1316
NumericBounds,
14-
TestMeasurement,
1517
TestMeasurementCreate,
1618
TestReport,
1719
TestReportCreate,
@@ -25,6 +27,8 @@
2527
)
2628

2729
if TYPE_CHECKING:
30+
from numpy.typing import NDArray
31+
2832
from sift_client.client import SiftClient
2933

3034

@@ -138,10 +142,10 @@ def create_step(self, name: str, description: str | None = None) -> TestStep:
138142

139143
return step
140144

141-
def report_measurement(self, measurement: TestMeasurement, step: TestStep):
145+
def record_step_outcome(self, outcome: bool, step: TestStep):
142146
"""Report a failure to the report context."""
143147
# Failures will be propogated when the step exits.
144-
if not measurement.passed:
148+
if not outcome:
145149
self.open_step_results[step.step_path] = False
146150
self.any_failures = True
147151

@@ -303,10 +307,118 @@ def measure(
303307
)
304308
evaluate_measurement_bounds(create, value, bounds)
305309
measurement = self.client.test_results.create_measurement(create)
306-
self.report_context.report_measurement(measurement, self.current_step)
310+
self.report_context.record_step_outcome(measurement.passed, self.current_step)
307311

308312
return measurement.passed
309313

314+
def measure_avg(
315+
self,
316+
*,
317+
name: str,
318+
values: list[float | int] | NDArray[np.float64] | pd.Series,
319+
bounds: dict[str, float] | NumericBounds,
320+
timestamp: datetime | None = None,
321+
unit: str | None = None,
322+
) -> bool:
323+
"""Calculate the average of a list of values, measure the average against given bounds, and return the result.
324+
325+
Args:
326+
name: The name of the measurement.
327+
values: The list of values to measure the average of.
328+
bounds: The bounds to compare the value to.
329+
timestamp: [Optional] The timestamp of the measurement. Defaults to the current time.
330+
unit: [Optional] The unit of the measurement.
331+
332+
returns: The true if the average of the values is within the bounds, false otherwise.
333+
"""
334+
timestamp = timestamp if timestamp else datetime.now(timezone.utc)
335+
np_array = None
336+
if isinstance(values, list):
337+
np_array = np.array(values)
338+
elif isinstance(values, np.ndarray):
339+
np_array = values
340+
elif isinstance(values, pd.Series):
341+
np_array = values.to_numpy()
342+
else:
343+
raise ValueError(f"Invalid value type: {type(values)}")
344+
avg = float(np.mean(np_array))
345+
result = self.measure(name=name, value=avg, bounds=bounds, timestamp=timestamp, unit=unit)
346+
assert self.current_step is not None
347+
self.report_context.record_step_outcome(result, self.current_step)
348+
349+
return result
350+
351+
def measure_all(
352+
self,
353+
*,
354+
name: str,
355+
values: list[float | int] | NDArray[np.float64] | pd.Series,
356+
bounds: dict[str, float] | NumericBounds,
357+
timestamp: datetime | None = None,
358+
unit: str | None = None,
359+
) -> bool:
360+
"""Ensure that all values in a list are within bounds and return the result. Records measurements for all values outside the bounds.
361+
362+
Note: Measurements will only be recorded for values outside the bounds. To record measurements for all values, just call measure for each value.
363+
364+
Args:
365+
name: The name of the measurement.
366+
values: The list of values to measure the average of.
367+
bounds: The bounds to compare the value to.
368+
timestamp: [Optional] The timestamp of the measurement. Defaults to the current time.
369+
unit: [Optional] The unit of the measurement.
370+
371+
returns: The true if all values are within the bounds, false otherwise.
372+
"""
373+
timestamp = timestamp if timestamp else datetime.now(timezone.utc)
374+
np_array = None
375+
if isinstance(values, list):
376+
np_array = np.array(values)
377+
elif isinstance(values, np.ndarray):
378+
np_array = values
379+
elif isinstance(values, pd.Series):
380+
np_array = values.to_numpy()
381+
else:
382+
raise ValueError(f"Invalid value type: {type(values)}")
383+
384+
numeric_bounds = bounds
385+
if isinstance(numeric_bounds, dict):
386+
numeric_bounds = NumericBounds(min=bounds.get("min"), max=bounds.get("max")) # type: ignore
387+
388+
# Construct a mask of the values that are outside the bounds.
389+
mask = None
390+
if numeric_bounds.min is not None:
391+
mask = np_array < numeric_bounds.min
392+
if numeric_bounds.max is not None:
393+
val_above_max = np_array > numeric_bounds.max
394+
mask = mask | val_above_max if mask is not None else val_above_max
395+
if mask is None:
396+
raise ValueError("No bounds provided")
397+
398+
rows_outside_bounds = np_array[mask]
399+
for row in rows_outside_bounds:
400+
self.measure(name=name, value=row, bounds=bounds, timestamp=timestamp, unit=unit)
401+
402+
result = rows_outside_bounds.size == 0
403+
assert self.current_step is not None
404+
self.report_context.record_step_outcome(result, self.current_step)
405+
406+
return result
407+
408+
def report_outcome(self, name: str, result: bool, reason: str | None = None) -> bool:
409+
"""Report an outcome from some action or measurement. Creates a substep that is pass/fail with the optional reason as the description.
410+
411+
Args:
412+
name: The name of the substep.
413+
result: True if the action or measurement passed, False otherwise.
414+
reason: [Optional] The context to include in the description of the substep.
415+
416+
returns: The given result so the function can be used in line.
417+
"""
418+
with self.substep(name=name, description=reason) as substep:
419+
self.report_context.record_step_outcome(result, substep.current_step)
420+
return result
421+
310422
def substep(self, name: str, description: str | None = None) -> NewStep:
311423
"""Alias to return a new step context manager from the current step. The ReportContext will manage nesting of steps."""
312424
return self.report_context.new_step(name=name, description=description)

0 commit comments

Comments
 (0)