366 lines
12 KiB
Python
366 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Manual canary validation helper for TV power-intent Phase 1 server publishing.
|
|
|
|
This script demonstrates expected power-intent payloads and validates the
|
|
computation and publishing logic without requiring a full broker connection.
|
|
|
|
Usage:
|
|
python test_power_intent_canary.py
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
sys.path.insert(0, '/workspace')
|
|
|
|
from scheduler.db_utils import (
|
|
compute_group_power_intent_basis,
|
|
build_group_power_intent_body,
|
|
compute_group_power_intent_fingerprint,
|
|
)
|
|
|
|
|
|
def utc_now():
|
|
"""Get UTC now."""
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def test_scenario_1_no_active_events():
|
|
"""Scenario 1: No active events => OFF intent."""
|
|
print("\n" + "="*70)
|
|
print("SCENARIO 1: No active events => desired_state=OFF")
|
|
print("="*70)
|
|
|
|
now = utc_now()
|
|
events = [] # empty group
|
|
group_id = 1
|
|
|
|
intent_basis = compute_group_power_intent_basis(
|
|
events=events,
|
|
group_id=group_id,
|
|
now_utc=now
|
|
)
|
|
|
|
desired_state = intent_basis["desired_state"]
|
|
reason = intent_basis["reason"]
|
|
|
|
assert desired_state == "off", f"Expected 'off', got '{desired_state}'"
|
|
assert reason == "no_active_event", f"Expected reason 'no_active_event', got '{reason}'"
|
|
|
|
payload_body = build_group_power_intent_body(intent_basis, poll_interval_sec=15)
|
|
|
|
print(f"✓ Desired state: {desired_state}")
|
|
print(f"✓ Reason: {reason}")
|
|
print(f"✓ Event window: {intent_basis.get('event_window_start')} to {intent_basis.get('event_window_end')}")
|
|
print(f"✓ Payload body (pretty-print):")
|
|
print(json.dumps(payload_body, indent=2))
|
|
|
|
# Validate payload structure
|
|
assert "desired_state" in payload_body
|
|
assert payload_body["desired_state"] == "off"
|
|
assert "group_id" in payload_body
|
|
print("✓ Payload structure validated")
|
|
|
|
|
|
def test_scenario_2_single_active_event():
|
|
"""Scenario 2: One active event now => ON intent."""
|
|
print("\n" + "="*70)
|
|
print("SCENARIO 2: One active event now => desired_state=ON")
|
|
print("="*70)
|
|
|
|
now = utc_now()
|
|
start = now - timedelta(seconds=60)
|
|
end = now + timedelta(seconds=300)
|
|
group_id = 1
|
|
|
|
events = [
|
|
{
|
|
"id": 101,
|
|
"group_id": group_id,
|
|
"start": start.isoformat(),
|
|
"end": end.isoformat(),
|
|
"subject": "Morning Announcement",
|
|
"event_type": "message",
|
|
}
|
|
]
|
|
|
|
intent_basis = compute_group_power_intent_basis(
|
|
events=events,
|
|
group_id=group_id,
|
|
now_utc=now
|
|
)
|
|
|
|
desired_state = intent_basis["desired_state"]
|
|
reason = intent_basis["reason"]
|
|
|
|
assert desired_state == "on", f"Expected 'on', got '{desired_state}'"
|
|
assert reason == "active_event", f"Expected reason 'active_event', got '{reason}'"
|
|
|
|
payload_body = build_group_power_intent_body(intent_basis, poll_interval_sec=15)
|
|
|
|
print(f"✓ Desired state: {desired_state}")
|
|
print(f"✓ Reason: {reason}")
|
|
print(f"✓ event_window_start: {intent_basis.get('event_window_start')}")
|
|
print(f"✓ event_window_end: {intent_basis.get('event_window_end')}")
|
|
print(f"✓ Payload body (pretty-print):")
|
|
print(json.dumps(payload_body, indent=2))
|
|
|
|
assert payload_body["desired_state"] == "on"
|
|
print("✓ Payload structure validated")
|
|
|
|
|
|
def test_scenario_3_adjacent_events_no_off_blip():
|
|
"""Scenario 3: Adjacent events (no gap) => no OFF blip between them."""
|
|
print("\n" + "="*70)
|
|
print("SCENARIO 3: Adjacent events => no OFF between them")
|
|
print("="*70)
|
|
|
|
# Event 1: ends at T+300
|
|
# Event 2: starts at T+300 (adjacent, no gap)
|
|
base = utc_now()
|
|
group_id = 2
|
|
|
|
events_at_boundary = [
|
|
{
|
|
"id": 201,
|
|
"group_id": group_id,
|
|
"start": (base + timedelta(seconds=0)).isoformat(),
|
|
"end": (base + timedelta(seconds=300)).isoformat(),
|
|
"subject": "Event 1",
|
|
"event_type": "presentation",
|
|
},
|
|
{
|
|
"id": 202,
|
|
"group_id": group_id,
|
|
"start": (base + timedelta(seconds=300)).isoformat(),
|
|
"end": (base + timedelta(seconds=600)).isoformat(),
|
|
"subject": "Event 2",
|
|
"event_type": "presentation",
|
|
},
|
|
]
|
|
|
|
# Sample times: before, at boundary, and after
|
|
scenarios = [
|
|
("Before boundary (Event 1 active)", base + timedelta(seconds=150)),
|
|
("At boundary (no gap)", base + timedelta(seconds=300)),
|
|
("After boundary (Event 2 active)", base + timedelta(seconds=450)),
|
|
]
|
|
|
|
for label, sample_time in scenarios:
|
|
intent_basis = compute_group_power_intent_basis(
|
|
events=events_at_boundary,
|
|
group_id=group_id,
|
|
now_utc=sample_time
|
|
)
|
|
desired_state = intent_basis["desired_state"]
|
|
reason = intent_basis["reason"]
|
|
|
|
print(f"\n {label}:")
|
|
print(f" Desired state: {desired_state}")
|
|
print(f" Reason: {reason}")
|
|
|
|
assert desired_state == "on", f"Expected 'on' at {label}, got '{desired_state}'"
|
|
|
|
print("\n✓ All boundary times show 'on' => no OFF blip between adjacent events")
|
|
|
|
|
|
def test_scenario_4_gap_between_events():
|
|
"""Scenario 4: Gap between events => OFF when not covered."""
|
|
print("\n" + "="*70)
|
|
print("SCENARIO 4: Gap between events => OFF during gap")
|
|
print("="*70)
|
|
|
|
base = utc_now()
|
|
group_id = 3
|
|
|
|
events_with_gap = [
|
|
{
|
|
"id": 301,
|
|
"group_id": group_id,
|
|
"start": (base + timedelta(seconds=0)).isoformat(),
|
|
"end": (base + timedelta(seconds=300)).isoformat(),
|
|
"subject": "Event 1",
|
|
"event_type": "presentation",
|
|
},
|
|
{
|
|
"id": 302,
|
|
"group_id": group_id,
|
|
"start": (base + timedelta(seconds=600)).isoformat(),
|
|
"end": (base + timedelta(seconds=900)).isoformat(),
|
|
"subject": "Event 2",
|
|
"event_type": "presentation",
|
|
},
|
|
]
|
|
|
|
# Sample during gap: T+450 is between end(300) and start(600)
|
|
gap_time = base + timedelta(seconds=450)
|
|
|
|
intent_basis = compute_group_power_intent_basis(
|
|
events=events_with_gap,
|
|
group_id=group_id,
|
|
now_utc=gap_time
|
|
)
|
|
|
|
desired_state = intent_basis["desired_state"]
|
|
reason = intent_basis["reason"]
|
|
|
|
print(f"Sample time: {gap_time.isoformat()}")
|
|
print(f"Desired state: {desired_state}")
|
|
print(f"Reason: {reason}")
|
|
|
|
assert desired_state == "off", f"Expected 'off' during gap, got '{desired_state}'"
|
|
print("✓ Correctly recognizes gap => OFF")
|
|
|
|
|
|
def test_scenario_5_semantic_fingerprint_stable():
|
|
"""Scenario 5: Semantic fingerprint is stable for unchanged state."""
|
|
print("\n" + "="*70)
|
|
print("SCENARIO 5: Semantic fingerprint stability (transition detection)")
|
|
print("="*70)
|
|
|
|
payload_body_1 = {
|
|
"schema_version": "1.0",
|
|
"group_id": 5,
|
|
"desired_state": "on",
|
|
"reason": "active_event",
|
|
"poll_interval_sec": 15,
|
|
"event_window_start": "2026-03-31T20:15:00Z",
|
|
"event_window_end": "2026-03-31T20:20:00Z",
|
|
"active_event_ids": [501],
|
|
}
|
|
|
|
payload_body_2 = {
|
|
"schema_version": "1.0",
|
|
"group_id": 5,
|
|
"desired_state": "on",
|
|
"reason": "active_event",
|
|
"poll_interval_sec": 15,
|
|
"event_window_start": "2026-03-31T20:15:00Z",
|
|
"event_window_end": "2026-03-31T20:20:00Z",
|
|
"active_event_ids": [501],
|
|
}
|
|
|
|
payload_body_3_different = {
|
|
"schema_version": "1.0",
|
|
"group_id": 5,
|
|
"desired_state": "off", # Changed
|
|
"reason": "no_active_event",
|
|
"poll_interval_sec": 15,
|
|
"event_window_start": None,
|
|
"event_window_end": None,
|
|
"active_event_ids": [],
|
|
}
|
|
|
|
fp1 = compute_group_power_intent_fingerprint(payload_body_1)
|
|
fp2 = compute_group_power_intent_fingerprint(payload_body_2)
|
|
fp3 = compute_group_power_intent_fingerprint(payload_body_3_different)
|
|
|
|
print(f"Payload 1 (on, event X): {fp1}")
|
|
print(f"Payload 2 (on, same event X): {fp2}")
|
|
print(f"Payload 3 (off, no event): {fp3}")
|
|
|
|
assert fp1 == fp2, "Identical payloads should have same fingerprint"
|
|
assert fp1 != fp3, "Different desired_state should have different fingerprint"
|
|
|
|
print("✓ Fingerprint is stable for same state (no spurious transitions)")
|
|
print("✓ Fingerprint changes on semantic transition")
|
|
|
|
|
|
def test_scenario_6_timestamp_format_validation():
|
|
"""Scenario 6: Payload body contains window start/end in UTC Z format."""
|
|
print("\n" + "="*70)
|
|
print("SCENARIO 6: Event window timestamp format validation")
|
|
print("="*70)
|
|
|
|
now = utc_now()
|
|
group_id = 6
|
|
events = [
|
|
{
|
|
"id": 601,
|
|
"group_id": group_id,
|
|
"start": (now - timedelta(seconds=60)).isoformat(),
|
|
"end": (now + timedelta(seconds=300)).isoformat(),
|
|
"subject": "Event",
|
|
"event_type": "message",
|
|
}
|
|
]
|
|
|
|
intent_basis = compute_group_power_intent_basis(
|
|
events=events,
|
|
group_id=group_id,
|
|
now_utc=now
|
|
)
|
|
|
|
window_start = intent_basis.get("event_window_start")
|
|
window_end = intent_basis.get("event_window_end")
|
|
|
|
print(f"Event window start: {window_start}")
|
|
print(f"Event window end: {window_end}")
|
|
|
|
if window_start:
|
|
assert window_start.endswith("Z"), f"event_window_start must end with Z: {window_start}"
|
|
if window_end:
|
|
assert window_end.endswith("Z"), f"event_window_end must end with Z: {window_end}"
|
|
|
|
# Validate they are valid RFC3339 timestamps
|
|
try:
|
|
if window_start:
|
|
dt_start = datetime.fromisoformat(window_start.replace("Z", "+00:00"))
|
|
if window_end:
|
|
dt_end = datetime.fromisoformat(window_end.replace("Z", "+00:00"))
|
|
if window_start:
|
|
assert dt_end > dt_start, "window_end must be after window_start"
|
|
print("✓ Event window timestamps are valid RFC3339 UTC format with Z suffix")
|
|
except Exception as e:
|
|
print(f"✗ Timestamp parsing failed: {e}")
|
|
raise
|
|
|
|
|
|
def main():
|
|
"""Run all scenarios."""
|
|
print("\n" + "="*70)
|
|
print("TV POWER INTENT PHASE-1 SERVER CANARY VALIDATION")
|
|
print("="*70)
|
|
print("\nThis script validates server-side power-intent computation logic")
|
|
print("without requiring an MQTT broker connection.\n")
|
|
|
|
try:
|
|
test_scenario_1_no_active_events()
|
|
test_scenario_2_single_active_event()
|
|
test_scenario_3_adjacent_events_no_off_blip()
|
|
test_scenario_4_gap_between_events()
|
|
test_scenario_5_semantic_fingerprint_stable()
|
|
test_scenario_6_timestamp_format_validation()
|
|
|
|
print("\n" + "="*70)
|
|
print("ALL CANARY SCENARIOS PASSED ✓")
|
|
print("="*70)
|
|
print("\nNext steps for full validation:")
|
|
print("1. Enable POWER_INTENT_PUBLISH_ENABLED=true in scheduler")
|
|
print("2. Subscribe to infoscreen/groups/+/power/intent in MQTT broker")
|
|
print("3. Run scheduler and observe:")
|
|
print(" - ON payload on event start")
|
|
print(" - Same intent_id across heartbeat republishes")
|
|
print(" - OFF payload on event end")
|
|
print(" - No OFF blip between adjacent events")
|
|
print("4. Restart scheduler and verify immediate ON republish")
|
|
print("5. Disconnect MQTT broker and verify reconnect republish")
|
|
print("\nSee TV_POWER_CANARY_VALIDATION_CHECKLIST.md for full validation matrix.")
|
|
|
|
return 0
|
|
except AssertionError as e:
|
|
print(f"\n✗ VALIDATION FAILED: {e}")
|
|
return 1
|
|
except Exception as e:
|
|
print(f"\n✗ UNEXPECTED ERROR: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|