"""ThermoWorks RFX cloud source tests. The load-bearing behaviour here is staleness. ThermoWorks Cloud returns a probe's last known value with a perfectly healthy HTTP 200 long after the probe has gone away, so "the request succeeded" says nothing about whether the temperature is current. These tests pin the backdating that makes the fail-cold policy actually work for a cloud source. """ from __future__ import annotations from datetime import datetime, timedelta, timezone from types import SimpleNamespace import pytest from smokescreen.sources.thermoworks import ( _normalise_temp, channel_to_reading, ) NOW = datetime(2026, 8, 16, 12, 0, 0, tzinfo=timezone.utc) def device(**kwargs): return SimpleNamespace( **{ "serial": "RFX123", "label": "Brisket", "device_name": "RFX MEAT", "battery": 88, "signal_strength": -62, **kwargs, } ) def channel(age_s: float = 0.0, **kwargs): return SimpleNamespace( **{ "value": 165.0, "units": "F", "number": "1", "label": "Internal", "last_telemetry_saved": NOW - timedelta(seconds=age_s), "last_seen": NOW - timedelta(seconds=age_s), **kwargs, } ) def convert(dev, chan, **kwargs): return channel_to_reading( dev, chan, now_utc=NOW, monotonic_now=1000.0, **kwargs ) @pytest.mark.parametrize( "value,units,expected", [ (212.0, "F", 100.0), (32.0, "F", 0.0), (100.0, "C", 100.0), (100.0, "°C", 100.0), (165.0, "f", 73.888), ], ) def test_units_are_converted_to_celsius(value, units, expected): assert _normalise_temp(value, units) == pytest.approx(expected, abs=0.01) @pytest.mark.parametrize( "units", [None, "", "kelvin", "rankine", "?", "furlongs", "cubits", "F/C"] ) def test_unknown_units_are_refused_not_guessed(units): """Assuming F when it was C would feed 225C to a grill told 225F. 'furlongs' and 'cubits' are regression cases: a prefix match on F/C silently converted them rather than rejecting them. """ assert _normalise_temp(100.0, units) is None def test_fahrenheit_channel_converts(): reading = convert(device(), channel()) assert reading.tip_c == pytest.approx(73.89, abs=0.01) assert reading.probe_id == "thermoworks:RFX123:1" assert reading.display_name == "Internal" assert reading.battery_pct == 88 def test_reading_is_backdated_by_cloud_age(): """A 90s-old cloud value must arrive already 90s old, not brand new.""" reading = convert(device(), channel(age_s=90.0)) assert reading.timestamp == pytest.approx(1000.0 - 90.0) assert reading.age_s(now=1000.0) == pytest.approx(90.0) def test_fresh_reading_is_not_backdated(): reading = convert(device(), channel(age_s=0.0)) assert reading.age_s(now=1000.0) == pytest.approx(0.0) def test_cloud_data_past_the_age_limit_is_dropped(): assert convert(device(), channel(age_s=3600.0), max_cloud_age_s=300.0) is None def test_age_limit_boundary_is_inclusive(): assert convert(device(), channel(age_s=299.0), max_cloud_age_s=300.0) is not None def test_channel_with_no_value_is_dropped(): assert convert(device(), channel(value=None)) is None def test_channel_with_unknown_units_is_dropped(): assert convert(device(), channel(units="furlongs")) is None def test_naive_timestamps_are_treated_as_utc(): naive = (NOW - timedelta(seconds=60)).replace(tzinfo=None) reading = convert(device(), channel(last_telemetry_saved=naive, last_seen=naive)) assert reading.age_s(now=1000.0) == pytest.approx(60.0) def test_falls_back_to_last_seen_when_telemetry_timestamp_missing(): chan = channel(age_s=45.0, last_telemetry_saved=None) assert convert(device(), chan).age_s(now=1000.0) == pytest.approx(45.0) def test_missing_timestamps_are_treated_as_current(): chan = channel(last_telemetry_saved=None, last_seen=None) assert convert(device(), chan).age_s(now=1000.0) == pytest.approx(0.0) def test_multiple_channels_get_distinct_probe_ids(): ids = { convert(device(), channel(number=str(n))).probe_id for n in (1, 2) } assert ids == {"thermoworks:RFX123:1", "thermoworks:RFX123:2"} def test_channel_label_falls_back_to_device_label(): reading = convert(device(), channel(label=None)) assert reading.display_name == "Brisket"