Add smart alert profiles and recording timeline (Tasks 4-5)
Task 4 — Alert Profiles: presence-aware + time-of-day alert routing. Profiles match household state (EMPTY/KIDS_HOME/ADULTS_HOME/ALL_HOME) and time windows (sleep hours). Per-detection-type rules control push/record/quiet behavior with role-based recipients (all vs adults). Task 5 — Recording Timeline: canvas-based 24h timeline per camera with color-coded segments (person=red, vehicle=blue, motion=gray). Click-to-play, date picker, detection type filters, hour markers. Timeline API endpoint returns segments for a camera+date. All 5 daily-use feature tasks complete. 140 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
77
tests/unit/test_profiles.py
Normal file
77
tests/unit/test_profiles.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Tests for smart alert profiles."""
|
||||
|
||||
from vigilar.alerts.profiles import match_profile, is_in_time_window, get_action_for_event
|
||||
from vigilar.config import AlertProfileConfig, AlertProfileRule
|
||||
from vigilar.constants import HouseholdState
|
||||
|
||||
|
||||
def _make_profile(name, states, time_window="", rules=None):
|
||||
return AlertProfileConfig(
|
||||
name=name,
|
||||
presence_states=states,
|
||||
time_window=time_window,
|
||||
rules=rules or [],
|
||||
)
|
||||
|
||||
|
||||
class TestTimeWindow:
|
||||
def test_no_window_always_matches(self):
|
||||
assert is_in_time_window("", "14:30") is True
|
||||
|
||||
def test_inside_window(self):
|
||||
assert is_in_time_window("23:00-06:00", "02:30") is True
|
||||
|
||||
def test_outside_window(self):
|
||||
assert is_in_time_window("23:00-06:00", "14:30") is False
|
||||
|
||||
def test_at_start(self):
|
||||
assert is_in_time_window("23:00-06:00", "23:00") is True
|
||||
|
||||
def test_daytime_window(self):
|
||||
assert is_in_time_window("06:00-23:00", "14:30") is True
|
||||
|
||||
|
||||
class TestProfileMatching:
|
||||
def test_match_by_presence(self):
|
||||
profiles = [
|
||||
_make_profile("Away", ["EMPTY"]),
|
||||
_make_profile("Home", ["ADULTS_HOME", "ALL_HOME"]),
|
||||
]
|
||||
result = match_profile(profiles, HouseholdState.EMPTY, "14:00")
|
||||
assert result is not None
|
||||
assert result.name == "Away"
|
||||
|
||||
def test_no_match(self):
|
||||
profiles = [_make_profile("Away", ["EMPTY"])]
|
||||
result = match_profile(profiles, HouseholdState.ALL_HOME, "14:00")
|
||||
assert result is None
|
||||
|
||||
def test_time_window_narrows(self):
|
||||
profiles = [
|
||||
_make_profile("Night", ["ALL_HOME"], "23:00-06:00"),
|
||||
_make_profile("Day", ["ALL_HOME"], "06:00-23:00"),
|
||||
]
|
||||
result = match_profile(profiles, HouseholdState.ALL_HOME, "02:00")
|
||||
assert result is not None
|
||||
assert result.name == "Night"
|
||||
|
||||
|
||||
class TestActionForEvent:
|
||||
def test_person_gets_push(self):
|
||||
rules = [AlertProfileRule(detection_type="person", action="push_and_record", recipients="all")]
|
||||
profile = _make_profile("Away", ["EMPTY"], rules=rules)
|
||||
action, recipients = get_action_for_event(profile, "person", "front_door")
|
||||
assert action == "push_and_record"
|
||||
assert recipients == "all"
|
||||
|
||||
def test_motion_gets_record_only(self):
|
||||
rules = [AlertProfileRule(detection_type="motion", action="record_only", recipients="none")]
|
||||
profile = _make_profile("Home", ["ALL_HOME"], rules=rules)
|
||||
action, recipients = get_action_for_event(profile, "motion", "front_door")
|
||||
assert action == "record_only"
|
||||
|
||||
def test_no_matching_rule_defaults_quiet(self):
|
||||
profile = _make_profile("Home", ["ALL_HOME"], rules=[])
|
||||
action, recipients = get_action_for_event(profile, "person", "front_door")
|
||||
assert action == "quiet_log"
|
||||
assert recipients == "none"
|
||||
39
tests/unit/test_timeline.py
Normal file
39
tests/unit/test_timeline.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Tests for recording timeline."""
|
||||
|
||||
import time
|
||||
|
||||
from vigilar.storage.queries import get_timeline_data, insert_recording
|
||||
|
||||
|
||||
class TestTimelineQuery:
|
||||
def test_returns_recordings_for_day(self, test_db):
|
||||
now = int(time.time())
|
||||
insert_recording(test_db, camera_id="cam1", started_at=now, ended_at=now+60,
|
||||
file_path="/tmp/a.mp4", trigger="MOTION", detection_type="person")
|
||||
insert_recording(test_db, camera_id="cam1", started_at=now+100, ended_at=now+130,
|
||||
file_path="/tmp/b.mp4", trigger="MOTION", detection_type="motion")
|
||||
|
||||
results = get_timeline_data(test_db, "cam1", now - 3600, now + 3600)
|
||||
assert len(results) == 2
|
||||
assert results[0]["detection_type"] == "person"
|
||||
assert results[1]["detection_type"] == "motion"
|
||||
|
||||
def test_filters_by_camera(self, test_db):
|
||||
now = int(time.time())
|
||||
insert_recording(test_db, camera_id="cam1", started_at=now, ended_at=now+60,
|
||||
file_path="/tmp/a.mp4", trigger="MOTION")
|
||||
insert_recording(test_db, camera_id="cam2", started_at=now, ended_at=now+60,
|
||||
file_path="/tmp/b.mp4", trigger="MOTION")
|
||||
|
||||
results = get_timeline_data(test_db, "cam1", now - 3600, now + 3600)
|
||||
assert len(results) == 1
|
||||
|
||||
def test_filters_by_time_range(self, test_db):
|
||||
now = int(time.time())
|
||||
insert_recording(test_db, camera_id="cam1", started_at=now - 7200, ended_at=now - 7100,
|
||||
file_path="/tmp/old.mp4", trigger="MOTION")
|
||||
insert_recording(test_db, camera_id="cam1", started_at=now, ended_at=now+60,
|
||||
file_path="/tmp/new.mp4", trigger="MOTION")
|
||||
|
||||
results = get_timeline_data(test_db, "cam1", now - 3600, now + 3600)
|
||||
assert len(results) == 1
|
||||
Reference in New Issue
Block a user