tiny-trigger / tests /test_automation.py
Javier Montalvo
Decouple tracking from detection; size-relative motion; UI tuning
114ea19
Raw
History Blame Contribute Delete
17.5 kB
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)
# Stationary person; only the box's top edge flickers (arms/occlusion/shadow),
# which swings the geometric center but leaves the foot point essentially put.
# The old center-displacement check fired on this; the foot-point + size-ratio
# check must not.
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"}],
}
]
}
)
)