| from __future__ import annotations |
|
|
| import json |
|
|
| import pytest |
| from pydantic import ValidationError |
|
|
| from tiny_trigger.automation import RuleEngine, document_labels, evaluate_video_detections, load_automation_text, rule_labels |
| from tiny_trigger.models import Detection, FrameSample |
|
|
|
|
| def detection( |
| label: str, |
| box: tuple[float, float, float, float], |
| frame: int = 0, |
| time: float = 0.0, |
| track_id: int | None = None, |
| ) -> Detection: |
| return Detection( |
| frame_index=frame, |
| timestamp_sec=time, |
| label=label, |
| confidence=0.9, |
| bbox_xyxy=(0.0, 0.0, 10.0, 10.0), |
| bbox_xyxy_norm=box, |
| track_id=track_id, |
| ) |
|
|
|
|
| def test_presence_near_and_cooldown_fire_once_per_window() -> None: |
| document = load_automation_text( |
| json.dumps( |
| { |
| "rules": [ |
| { |
| "name": "feed-cat", |
| "when": { |
| "all": [ |
| {"present": {"label": "cat", "min_count": 1}}, |
| {"near": {"a": "cat", "b": "feeder robot", "max_distance": 0.35}}, |
| {"cooldown": {"key": "feed-cat", "seconds": 60}}, |
| ] |
| }, |
| "trigger": {"on": "while"}, |
| "then": [{"type": "simulate", "name": "feed cat"}], |
| } |
| ] |
| } |
| ) |
| ) |
| engine = RuleEngine(document.rules) |
| frame = [ |
| detection("cat", (0.10, 0.10, 0.20, 0.20), time=0.0), |
| detection("feeder robot", (0.25, 0.12, 0.35, 0.22), time=0.0), |
| ] |
|
|
| assert len(engine.evaluate_frame(frame, frame_index=0, timestamp_sec=0.0)) == 1 |
| assert engine.evaluate_frame(frame, frame_index=1, timestamp_sec=30.0) == [] |
| assert len(engine.evaluate_frame(frame, frame_index=2, timestamp_sec=61.0)) == 1 |
|
|
|
|
| def test_count_condition() -> None: |
| document = load_automation_text( |
| json.dumps( |
| { |
| "rules": [ |
| { |
| "name": "two-packages", |
| "when": {"all": [{"count": {"label": "package", "min": 2}}]}, |
| "then": [{"type": "simulate", "name": "notify"}], |
| } |
| ] |
| } |
| ) |
| ) |
| engine = RuleEngine(document.rules) |
| one = [detection("package", (0.1, 0.1, 0.2, 0.2))] |
| two = [*one, detection("package", (0.4, 0.4, 0.5, 0.5))] |
|
|
| assert engine.evaluate_frame(one, frame_index=0, timestamp_sec=0.0) == [] |
| assert len(engine.evaluate_frame(two, frame_index=1, timestamp_sec=1.0)) == 1 |
|
|
|
|
| def test_default_trigger_is_enter_not_every_frame() -> None: |
| document = load_automation_text( |
| json.dumps( |
| { |
| "rules": [ |
| { |
| "name": "guitar-amplifier-on", |
| "when": {"all": [{"present": {"label": "guitar"}}]}, |
| "then": [{"type": "simulate", "name": "turn on amplifier"}], |
| } |
| ] |
| } |
| ) |
| ) |
| engine = RuleEngine(document.rules) |
| frame = [detection("guitar", (0.1, 0.1, 0.2, 0.2))] |
|
|
| assert [event.action for event in engine.evaluate_frame(frame, frame_index=0, timestamp_sec=0.0)] == [ |
| "turn on amplifier" |
| ] |
| assert engine.evaluate_frame(frame, frame_index=1, timestamp_sec=1.0) == [] |
|
|
|
|
| def test_rule_labels_include_condition_labels() -> None: |
| document = load_automation_text( |
| json.dumps( |
| { |
| "rules": [ |
| { |
| "name": "person-near-monitor", |
| "when": { |
| "all": [ |
| {"present": {"label": "person"}}, |
| {"near": {"a": "person", "b": "monitor"}}, |
| {"far": {"a": "cat", "b": "door"}}, |
| ] |
| }, |
| "then": [{"type": "simulate", "name": "notify"}], |
| } |
| ] |
| } |
| ) |
| ) |
|
|
| assert rule_labels(document.rules[0]) == ["person", "monitor", "cat", "door"] |
| assert document_labels(document) == ["person", "monitor", "cat", "door"] |
|
|
|
|
| def test_moving_condition_uses_tracked_centroid_history() -> None: |
| document = load_automation_text( |
| json.dumps( |
| { |
| "rules": [ |
| { |
| "name": "car-moving", |
| "when": { |
| "all": [ |
| {"moving": {"label": "car", "min_displacement_ratio": 0.15, "window_frames": 3}} |
| ] |
| }, |
| "then": [{"type": "simulate", "name": "notify"}], |
| } |
| ] |
| } |
| ) |
| ) |
| engine = RuleEngine(document.rules) |
|
|
| first = engine.evaluate_frame( |
| [detection("car", (0.10, 0.10, 0.20, 0.20), frame=0, track_id=1)], |
| frame_index=0, |
| timestamp_sec=0.0, |
| ) |
| second = engine.evaluate_frame( |
| [detection("car", (0.11, 0.10, 0.21, 0.20), frame=1, time=1.0, track_id=1)], |
| frame_index=1, |
| timestamp_sec=1.0, |
| ) |
| third = engine.evaluate_frame( |
| [detection("car", (0.20, 0.10, 0.30, 0.20), frame=2, time=2.0, track_id=1)], |
| frame_index=2, |
| timestamp_sec=2.0, |
| ) |
|
|
| assert first == [] |
| assert second == [] |
| assert [event.action for event in third] == ["notify"] |
|
|
|
|
| def test_moving_condition_ignores_box_jitter_on_stationary_object() -> None: |
| document = load_automation_text( |
| json.dumps( |
| { |
| "rules": [ |
| { |
| "name": "person-moving", |
| "when": { |
| "all": [ |
| {"moving": {"label": "person", "min_displacement_ratio": 0.15, "window_frames": 3}} |
| ] |
| }, |
| "then": [{"type": "simulate", "name": "notify"}], |
| } |
| ] |
| } |
| ) |
| ) |
| engine = RuleEngine(document.rules) |
|
|
| |
| |
| |
| |
| frames = [ |
| (0.45, 0.50, 0.55, 0.80), |
| (0.46, 0.42, 0.56, 0.80), |
| (0.44, 0.34, 0.54, 0.80), |
| ] |
| events = [] |
| for index, box in enumerate(frames): |
| events += engine.evaluate_frame( |
| [detection("person", box, frame=index, time=float(index), track_id=1)], |
| frame_index=index, |
| timestamp_sec=float(index), |
| ) |
|
|
| assert events == [] |
|
|
|
|
| def test_moving_condition_migrates_short_window_to_three() -> None: |
| document = load_automation_text( |
| json.dumps( |
| { |
| "rules": [ |
| { |
| "name": "legacy-moving", |
| "when": {"all": [{"moving": {"label": "car", "window_frames": 2}}]}, |
| "then": [{"type": "simulate", "name": "notify"}], |
| } |
| ] |
| } |
| ) |
| ) |
|
|
| moving = document.rules[0].when.all_conditions[0].moving |
| assert moving is not None |
| assert moving.window_frames == 3 |
|
|
|
|
| def test_moving_condition_does_not_reenter_after_one_missed_frame() -> None: |
| document = load_automation_text( |
| json.dumps( |
| { |
| "rules": [ |
| { |
| "name": "car-moving", |
| "when": { |
| "all": [ |
| { |
| "moving": { |
| "label": "car", |
| "min_displacement_ratio": 0.15, |
| "window_frames": 3, |
| "max_missing_frames": 1, |
| } |
| } |
| ] |
| }, |
| "then": [{"type": "simulate", "name": "notify"}], |
| } |
| ] |
| } |
| ) |
| ) |
| engine = RuleEngine(document.rules) |
|
|
| assert engine.evaluate_frame( |
| [detection("car", (0.10, 0.10, 0.20, 0.20), frame=0, track_id=1)], |
| frame_index=0, |
| timestamp_sec=0.0, |
| ) == [] |
| assert engine.evaluate_frame( |
| [detection("car", (0.14, 0.10, 0.24, 0.20), frame=1, time=1.0, track_id=1)], |
| frame_index=1, |
| timestamp_sec=1.0, |
| ) == [] |
| first_alert = engine.evaluate_frame( |
| [detection("car", (0.18, 0.10, 0.28, 0.20), frame=2, time=2.0, track_id=1)], |
| frame_index=2, |
| timestamp_sec=2.0, |
| ) |
| missed_frame = engine.evaluate_frame([], frame_index=3, timestamp_sec=3.0) |
| same_track_returns = engine.evaluate_frame( |
| [detection("car", (0.22, 0.10, 0.32, 0.20), frame=4, time=4.0, track_id=1)], |
| frame_index=4, |
| timestamp_sec=4.0, |
| ) |
|
|
| assert [event.action for event in first_alert] == ["notify"] |
| assert missed_frame == [] |
| assert same_track_returns == [] |
|
|
|
|
| def test_gate_cooldown_fire_once_per_window() -> None: |
| document = load_automation_text( |
| json.dumps( |
| { |
| "rules": [ |
| { |
| "name": "turn-on-pc", |
| "when": { |
| "all": [ |
| {"present": {"label": "person"}}, |
| {"near": {"a": "person", "b": "steering wheel", "max_distance": 0.45}}, |
| ] |
| }, |
| "trigger": {"on": "while"}, |
| "gate": {"enabled": True, "cooldown": {"key": "turn-on-pc", "seconds": 60}}, |
| "then": [{"type": "simulate", "name": "turn on pc"}], |
| } |
| ] |
| } |
| ) |
| ) |
| engine = RuleEngine(document.rules) |
| frame = [ |
| detection("person", (0.10, 0.10, 0.20, 0.20), time=0.0), |
| detection("steering wheel", (0.25, 0.12, 0.35, 0.22), time=0.0), |
| ] |
|
|
| assert len(engine.evaluate_frame(frame, frame_index=0, timestamp_sec=0.0)) == 1 |
| assert engine.evaluate_frame(frame, frame_index=1, timestamp_sec=30.0) == [] |
| assert len(engine.evaluate_frame(frame, frame_index=2, timestamp_sec=61.0)) == 1 |
|
|
|
|
| def test_disabled_gate_blocks_rule() -> None: |
| document = load_automation_text( |
| json.dumps( |
| { |
| "rules": [ |
| { |
| "name": "disabled", |
| "when": {"all": [{"present": {"label": "person"}}]}, |
| "gate": {"enabled": False}, |
| "then": [{"type": "simulate", "name": "turn on pc"}], |
| } |
| ] |
| } |
| ) |
| ) |
| engine = RuleEngine(document.rules) |
|
|
| assert engine.evaluate_frame([detection("person", (0.1, 0.1, 0.2, 0.2))], frame_index=0, timestamp_sec=0.0) == [] |
|
|
|
|
| def test_evaluate_video_detections_returns_updated_last_fired() -> None: |
| document = load_automation_text( |
| json.dumps( |
| { |
| "rules": [ |
| { |
| "name": "turn-on-pc", |
| "when": {"all": [{"present": {"label": "person"}}]}, |
| "trigger": {"on": "while"}, |
| "gate": {"cooldown": {"key": "turn-on-pc", "seconds": 60}}, |
| "then": [{"type": "simulate", "name": "turn on pc"}], |
| } |
| ] |
| } |
| ) |
| ) |
| events, last_fired = evaluate_video_detections( |
| document.rules, |
| [detection("person", (0.1, 0.1, 0.2, 0.2), frame=2, time=12.0)], |
| ) |
| blocked_events, unchanged_last_fired = evaluate_video_detections( |
| document.rules, |
| [detection("person", (0.1, 0.1, 0.2, 0.2), frame=3, time=30.0)], |
| last_fired=last_fired, |
| ) |
|
|
| assert len(events) == 1 |
| assert last_fired == {"turn-on-pc": 12.0} |
| assert blocked_events == [] |
| assert unchanged_last_fired == last_fired |
|
|
|
|
| def test_video_cooldowns_are_fresh_without_persisted_last_fired() -> None: |
| document = load_automation_text( |
| json.dumps( |
| { |
| "rules": [ |
| { |
| "name": "turn-on-pc", |
| "when": {"all": [{"present": {"label": "person"}}]}, |
| "trigger": {"on": "while"}, |
| "gate": {"cooldown": {"key": "turn-on-pc", "seconds": 60}}, |
| "then": [{"type": "simulate", "name": "turn on pc"}], |
| } |
| ] |
| } |
| ) |
| ) |
| detections = [detection("person", (0.1, 0.1, 0.2, 0.2), frame=2, time=12.0)] |
|
|
| first_events, first_last_fired = evaluate_video_detections(document.rules, detections) |
| second_events, second_last_fired = evaluate_video_detections(document.rules, detections) |
|
|
| assert len(first_events) == 1 |
| assert len(second_events) == 1 |
| assert first_last_fired == second_last_fired == {"turn-on-pc": 12.0} |
|
|
|
|
| def test_change_trigger_fires_enter_and_exit() -> None: |
| document = load_automation_text( |
| json.dumps( |
| { |
| "rules": [ |
| { |
| "name": "monitor-lights", |
| "when": { |
| "all": [ |
| {"near": {"a": "person", "b": "monitor", "max_gap_percent": 8}}, |
| ] |
| }, |
| "trigger": {"on": "change"}, |
| "then": { |
| "enter": [{"type": "simulate", "name": "turn on lights"}], |
| "exit": [{"type": "simulate", "name": "turn off lights"}], |
| }, |
| } |
| ] |
| } |
| ) |
| ) |
| engine = RuleEngine(document.rules) |
| matching = [ |
| detection("person", (0.10, 0.10, 0.20, 0.20)), |
| detection("monitor", (0.23, 0.10, 0.33, 0.20)), |
| ] |
|
|
| enter_events = engine.evaluate_frame(matching, frame_index=0, timestamp_sec=0.0) |
| while_events = engine.evaluate_frame(matching, frame_index=1, timestamp_sec=1.0) |
| exit_events = engine.evaluate_frame([], frame_index=2, timestamp_sec=2.0) |
|
|
| assert [event.action for event in enter_events] == ["turn on lights"] |
| assert while_events == [] |
| assert [event.action for event in exit_events] == ["turn off lights"] |
|
|
|
|
| def test_exit_trigger_accepts_plain_then_actions() -> None: |
| document = load_automation_text( |
| json.dumps( |
| { |
| "rules": [ |
| { |
| "name": "person-leaves-turn-off-lights", |
| "when": {"all": [{"present": {"label": "person", "min_count": 1}}]}, |
| "trigger": {"on": "exit"}, |
| "gate": {"enabled": True}, |
| "then": [{"type": "simulate", "name": "turn off lights"}], |
| } |
| ] |
| } |
| ) |
| ) |
| engine = RuleEngine(document.rules) |
|
|
| first_events = engine.evaluate_frame( |
| [detection("person", (0.1, 0.1, 0.2, 0.2))], |
| frame_index=0, |
| timestamp_sec=0.0, |
| ) |
| exit_events = engine.evaluate_frame([], frame_index=1, timestamp_sec=1.0) |
|
|
| assert first_events == [] |
| assert [event.action for event in exit_events] == ["turn off lights"] |
|
|
|
|
| def test_video_evaluation_uses_empty_frames_for_exit_triggers() -> None: |
| document = load_automation_text( |
| json.dumps( |
| { |
| "rules": [ |
| { |
| "name": "person-presence", |
| "when": {"all": [{"present": {"label": "person"}}]}, |
| "trigger": {"on": "change"}, |
| "then": { |
| "enter": [{"type": "simulate", "name": "arrived"}], |
| "exit": [{"type": "simulate", "name": "left"}], |
| }, |
| } |
| ] |
| } |
| ) |
| ) |
|
|
| events, _last_fired = evaluate_video_detections( |
| document.rules, |
| [detection("person", (0.1, 0.1, 0.2, 0.2), frame=0, time=0.0)], |
| frames=[ |
| FrameSample(frame_index=0, timestamp_sec=0.0), |
| FrameSample(frame_index=1, timestamp_sec=1.0), |
| ], |
| ) |
|
|
| assert [event.action for event in events] == ["arrived", "left"] |
|
|
|
|
| def test_invalid_rule_rejected() -> None: |
| with pytest.raises(ValidationError): |
| load_automation_text( |
| json.dumps( |
| { |
| "rules": [ |
| { |
| "name": "bad", |
| "when": {"all": [{"present": {"label": "cat"}, "near": {"a": "cat", "b": "bowl"}}]}, |
| "then": [{"type": "simulate", "name": "bad"}], |
| } |
| ] |
| } |
| ) |
| ) |
|
|