Files
kota-technician-tool/tests/test_influx.py
KrzyszurandClaude Opus 5.5 bb933ff748 Serial monitor redesign: Test box button, InfluxDB row in the checklist
- Test box: restart + start-up capture + online InfluxDB check (data newer
  than the restart), with a status strip; checklist and findings are the
  main view, live log / guided troubleshooting / box details are tabs,
  rarely used actions in a More menu
- light palette on all systems (macOS dark mode made the colours unreadable)
- new rule influx.invalid_value: -inf from a silent microphone makes
  InfluxDB reject every upload (HTTP 400); guide end names it
- exclusive serial port on macOS/Linux: two tool windows no longer split
  the log between them
- InfluxDB check: `since` filter, neutral wording when nothing new arrives

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 00:06:48 +02:00

155 lines
5.8 KiB
Python

"""InfluxDB check: query text, CSV parsing, classification, failures (no network)."""
import datetime as dt
import urllib.error
from kota_tech.diag.guides import INFLUX_OUTCOMES
from kota_tech.service import influx
NOW = dt.datetime(2026, 9, 30, 12, 0, tzinfo=dt.timezone.utc)
UTC = dt.timezone.utc
# Real answer shape of /api/v2/query (Accept: application/csv).
CSV = (
",result,table,_measurement,_time\r\n"
",_result,0,as7341_adc,2026-09-30T11:55:01Z\r\n"
",_result,0,bme688,2026-09-30T11:55:02.123456789Z\r\n"
",_result,0,device_status,2026-09-30T11:58:00Z\r\n"
",_result,0,microphone,2026-09-30T11:58:10Z\r\n"
",_result,0,sen66,2026-09-30T11:56:00Z\r\n"
"\r\n"
)
def _t(minutes_ago):
return NOW - dt.timedelta(minutes=minutes_ago)
def test_outcomes_match_guide_runner():
assert influx.OUTCOMES == INFLUX_OUTCOMES
def test_query_is_for_one_box_and_keeps_only_time():
q = influx.build_query("sensorbox", "A4CB8FC9473C")
assert 'from(bucket: "sensorbox")' in q and 'r.device == "A4CB8FC9473C"' in q
# _value must be dropped before grouping (string and float fields clash -> HTTP 400)
assert q.index("keep(") < q.index("group(columns")
def test_parse_csv_newest_per_measurement():
latest = influx.parse_csv(CSV)
assert latest["bme688"] == dt.datetime(2026, 9, 30, 11, 55, 2, 123456, tzinfo=UTC)
assert set(latest) == {"as7341_adc", "bme688", "device_status", "microphone", "sen66"}
assert influx.parse_csv("") == {}
def test_all_sensors_fresh_is_ok():
c = influx.classify(influx.parse_csv(CSV), NOW)
assert c.outcome == influx.OK and not c.missing
def test_status_without_sensors_is_sensors_missing():
# box A4CB8FC9473C: RTC stopped, only status and microphone ever arrived
c = influx.classify({"device_status": _t(1), "microphone": _t(1)}, NOW)
assert c.outcome == influx.SENSORS_MISSING
assert c.missing == list(influx.SENSOR_GROUPS)
assert any("never started" in d for d in c.details)
def test_one_sensor_missing():
latest = {"device_status": _t(1), "as7341_adc": _t(2), "bme688": _t(2), "sen66": _t(300)}
c = influx.classify(latest, NOW)
assert c.outcome == influx.SENSORS_MISSING and c.missing == ["SEN66 (air quality)"]
def test_offline_box_whose_sensors_stopped_before_status():
# box A4CB8FC946F8: status 29 h ago, sensors 21 days ago
latest = {"device_status": _t(29 * 60), "microphone": _t(29 * 60),
"as7341_adc": _t(21 * 1440), "bme688": _t(21 * 1440), "sen66": _t(21 * 1440)}
c = influx.classify(latest, NOW)
assert c.outcome == influx.SENSORS_MISSING and "Nothing new" in c.summary
assert any("stopped" in d for d in c.details)
assert any("21 days ago" in d for d in c.details)
def test_offline_box_with_complete_history_is_silent():
# box A4CB8FC739EC: everything stopped together 11 days ago
latest = {"device_status": _t(11 * 1440), "microphone": _t(11 * 1440),
"as7341_adc": _t(11 * 1440 + 3), "bme688": _t(11 * 1440 + 3), "sen66": _t(11 * 1440 + 2)}
c = influx.classify(latest, NOW)
assert c.outcome == influx.SILENT
assert not any("stopped" in d for d in c.details)
def test_no_data_is_never():
assert influx.classify({}, NOW).outcome == influx.NEVER
def test_future_timestamps_are_flagged():
latest = {"device_status": _t(1), "microphone": _t(1), "as7341_adc": _t(-120), "bme688": _t(-120),
"sen66": _t(-120)}
c = influx.classify(latest, NOW)
assert any("future" in d for d in c.details)
def test_check_device_without_token_or_mac():
assert influx.check_device(None, "A4CB8FC9473C").outcome == influx.UNAVAILABLE
cfg = influx.InfluxConfig("t")
assert influx.check_device(cfg, "").outcome == influx.UNAVAILABLE
def test_check_device_errors_never_raise(monkeypatch):
cfg = influx.InfluxConfig("secret-token")
def rejected(*_a, **_k):
raise urllib.error.HTTPError("u", 401, "Unauthorized", {}, None)
monkeypatch.setattr(influx.urllib.request, "urlopen", rejected)
c = influx.check_device(cfg, "a4:cb:8f:c9:47:3c")
assert c.outcome == influx.UNAVAILABLE and "rejected" in c.summary
assert "secret-token" not in c.summary
def offline(*_a, **_k):
raise urllib.error.URLError("nodename nor servname provided")
monkeypatch.setattr(influx.urllib.request, "urlopen", offline)
assert "not reachable" in influx.check_device(cfg, "A4CB8FC9473C").summary
def test_check_device_sends_token_header_and_normalized_mac(monkeypatch):
seen = {}
class Resp:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return CSV.encode()
def fake_urlopen(req, timeout):
seen["req"] = req
return Resp()
monkeypatch.setattr(influx.urllib.request, "urlopen", fake_urlopen)
c = influx.check_device(influx.InfluxConfig("tok"), "a4:cb:8f:c9:47:3c", now=NOW)
req = seen["req"]
assert c.outcome == influx.OK
assert req.get_header("Authorization") == "Token tok"
assert "tok" not in req.full_url and req.full_url.endswith("/api/v2/query?org=kota")
assert b'"A4CB8FC9473C"' in req.data
def test_since_ignores_data_from_before_the_test():
# healthy a few minutes ago, but nothing since the box was restarted by the test
latest = {"device_status": _t(5), "microphone": _t(5), "as7341_adc": _t(5), "bme688": _t(5), "sen66": _t(5)}
assert influx.classify(latest, NOW).outcome == influx.OK
c = influx.classify(latest, NOW, since=_t(2))
assert c.outcome == influx.SILENT and "since the test started" in c.summary
latest.update(device_status=_t(0), microphone=_t(0))
assert influx.classify(latest, NOW, since=_t(2)).outcome == influx.SENSORS_MISSING
latest.update(as7341_adc=_t(0), bme688=_t(0), sen66=_t(2.5)) # within the clock slack
assert influx.classify(latest, NOW, since=_t(2)).outcome == influx.OK