Fix #156: handle LidMalfunctionActivated in ProcessAlarm; add typed guardrail

ProcessAlarm.skip_event() read event.alarmId on every EventClass.ALARM event,
but LidMalfunctionActivated (a sibling of LidAlarmActivated in that class) has
no alarmId, so a malfunction alarm crashed the sync with AttributeError.

- Narrow with isinstance before reading alarmId; malfunction events now upload
  as "Malfunction" as intended, and sync continues.
- Type the alarm handlers against an explicit AlarmEvent union and add an
  assert_never exhaustiveness guard, so a type checker rejects unguarded
  subtype attribute access and flags any newly added ALARM event type.
- Fix a latent None-leak: alarm_to_nsentry now always returns a dict.
- Add mypy as a gradual-typing beachhead (setup.cfg [mypy], CI step, Pipfile
  typecheck script), scoped to process_alarm.py. This configuration fails on
  exactly the #156 class of bug.
- Add regression tests: malfunction processing, mixed alarm batches, the event
  shape, and an AlarmEvent/EventClass.ALARM sync guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LpMTd5LzkqFLZTUKd3H8em
This commit is contained in:
Claude
2026-07-20 20:49:43 -04:00
committed by James Woglom
parent c95fe424ab
commit ddeaa79ded
6 changed files with 131 additions and 8 deletions
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
import json
import unittest
from tconnectsync.eventparser.generic import Event
from tconnectsync.eventparser import events as eventtypes
from tconnectsync.eventparser.raw_event import RawEvent
class TestLidMalfunctionActivated(unittest.TestCase):
maxDiff = None
def setUp(self):
self.fixture = {
"deviceAssignmentId": "4ff6bebc-d4d6-4423-b123-eecfcf5a4238",
"eventCode": 6,
"sequenceGroup": 0,
"sequenceNumber": 500123,
"pumpDateTime": "2026-05-16T00:07:00",
"eventProperties": {"malfId": 7, "faultLocatorData": 8311, "param1": 42, "param2": 0},
"estimatedDateTime": "2026-05-16T00:07:00Z",
}
def test_dispatches_to_correct_class(self):
self.assertIsInstance(Event(self.fixture), eventtypes.LidMalfunctionActivated)
self.assertNotIsInstance(Event(self.fixture), RawEvent)
def test_has_no_alarmid_attribute(self):
ev = Event(self.fixture)
self.assertFalse(hasattr(ev, 'alarmId'))
self.assertEqual(ev.malfIdRaw, 7)
def test_envelope_fields(self):
ev = Event(self.fixture)
self.assertEqual(ev.eventId, 6)
self.assertEqual(ev.seqNum, 500123)
def test_timestamp_preserves_wall_clock(self):
ev = Event(self.fixture)
self.assertEqual(ev.eventTimestamp.format('YYYY-MM-DDTHH:mm:ss'), "2026-05-16T00:07:00")
def test_plain_fields(self):
ev = Event(self.fixture)
self.assertEqual(ev.faultLocatorData, 8311)
self.assertEqual(ev.param1, 42)
self.assertEqual(ev.param2, 0)
def test_todict_is_json_serializable(self):
ev = Event(self.fixture)
d = ev.todict()
json.dumps(d) # must not raise
self.assertEqual(d["id"], 6)
self.assertEqual(d["name"], "LID_MALFUNCTION_ACTIVATED")
self.assertEqual(d["malfIdRaw"], 7)
if __name__ == "__main__":
unittest.main()
@@ -92,6 +92,16 @@ ALARM_RESUME = {
"eventProperties": {"alarmId": 18, "faultLocatorData": 8311, "param1": 5228339, "param2": 0},
}
MALFUNCTION = {
"deviceAssignmentId": "00000000-0000-0000-0000-000000000000",
"eventCode": 6,
"sequenceGroup": 0,
"sequenceNumber": 500123,
"pumpDateTime": "2026-05-16T00:07:00",
"estimatedDateTime": "2026-05-16T00:07:00Z",
"eventProperties": {"malfId": 7, "faultLocatorData": 8311, "param1": 42, "param2": 0},
}
class TestProcessAlarmJson(unittest.TestCase):
maxDiff = None
@@ -127,6 +137,41 @@ class TestProcessAlarmJson(unittest.TestCase):
p = self.process.process(list(Events([dict(ALARM_RESUME)])), None, None)
self.assertEqual(p, [])
def test_malfunction_alarm_uploaded(self):
event = Event(dict(MALFUNCTION))
self.assertEqual(type(event), eventtypes.LidMalfunctionActivated)
self.assertFalse(hasattr(event, 'alarmId'))
p = self.process.process([event], None, None)
self.assertEqual(len(p), 1)
self.assertDictEqual(p[0], {
'eventType': 'Alarm',
'reason': 'Malfunction',
'notes': 'Malfunction',
'created_at': '2026-05-16 00:07:00-04:00',
'enteredBy': 'Pump (tconnectsync)',
'pump_event_id': '500123'
})
def test_alarm_and_malfunction_mixed(self):
# A batch mixing both ALARM-class event types must not crash and must
# emit an entry for each.
p = self.process.process(list(Events([dict(ALARM_PUMP_RESET), dict(MALFUNCTION)])), None, None)
self.assertEqual(len(p), 2)
reasons = {entry['reason'] for entry in p}
self.assertEqual(reasons, {'PumpResetAlarm', 'Malfunction'})
class TestAlarmOrMalfunctionUnion(unittest.TestCase):
def test_union_matches_eventclass(self):
from typing import get_args
from tconnectsync.sync.tandemsource.process_alarm import AlarmOrMalfunction
from tconnectsync.domain.tandemsource.event_class import EventClass
self.assertEqual(set(get_args(AlarmOrMalfunction)), set(EventClass.ALARM))
if __name__ == '__main__':
unittest.main()