Routes moved to thin parse-args/jsonify handlers; logic now lives in three focused modules under core/automation/. 436 lines deleted from web_server.py; 53 added back as wrappers. Module split: - core/automation/api.py — CRUD + run + history helpers. Each function takes (database, automation_engine, ...) explicitly and returns (response_body, http_status). Includes signal cycle detection preflight checks for create + update. - core/automation/progress.py — owns the in-memory progress state dict + lock (mirroring the original web_server.py globals as module-level shared state so all callers see one view), init/update/history helpers, and the WebSocket emit loop. - core/automation/signals.py — collect_known_signals for the builder autocomplete. Out of scope (deferred): - _register_automation_handlers — the 23+ action handler closures stay in web_server.py because each one is tightly coupled to feature- specific implementations (wishlist, watchlist, library scan, etc.). - Worker functions (_process_wishlist_automatically, etc.) — belong with their feature lifts. - _run_sync_task / _run_playlist_discovery_worker — sync + discovery PRs. Behavior preserved 1:1: - Same route response shapes + status codes - Same JSON field hydration (trigger_config, action_config, notify_config, last_result, then_actions) - Same backward-compat: empty then_actions + notify_type set → synthesize then_actions from notify_type/notify_config - Same signal cycle detection behavior on create + update - Same system-automation protection on delete + duplicate - Same reschedule/cancel logic on toggle + bulk-toggle + update - Same progress state shape (status, progress, phase, current_item, log capped at 50, started_at/finished_at, action_type) - Same emit-on-finish socketio push from update_progress - Same emit loop semantics (1s tick, snapshot active states, reap finished after window) Pre-existing bugs preserved (will fix in follow-up PRs): - emit_progress_loop uses naive datetime.now() against tz-aware started_at/finished_at, so the timeout-zombie check raises TypeError → caught → never fires, and the cleanup-after-window check raises → caught → state is reaped on FIRST tick regardless of the window. Tests document this behavior so the next PR can flip them to the corrected expectation. Tests: 72 new under tests/automation/ (signals 10, progress 24, api 38). Full suite: 861 passing (was 789). Ruff clean.
92 lines
3.2 KiB
Python
92 lines
3.2 KiB
Python
"""Tests for core/automation/signals.py — known signal collection."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from core.automation import signals
|
|
|
|
|
|
class _FakeDB:
|
|
def __init__(self, automations):
|
|
self._autos = automations
|
|
|
|
def get_automations(self, profile_id=None):
|
|
return self._autos
|
|
|
|
|
|
def test_no_automations_returns_empty():
|
|
db = _FakeDB([])
|
|
assert signals.collect_known_signals(db) == []
|
|
|
|
|
|
def test_signal_received_trigger_collected():
|
|
db = _FakeDB([
|
|
{'trigger_type': 'signal_received', 'trigger_config': json.dumps({'signal_name': 'job_done'}), 'then_actions': '[]'},
|
|
])
|
|
assert signals.collect_known_signals(db) == ['job_done']
|
|
|
|
|
|
def test_fire_signal_then_action_collected():
|
|
db = _FakeDB([
|
|
{'trigger_type': 'schedule', 'trigger_config': '{}',
|
|
'then_actions': json.dumps([{'type': 'fire_signal', 'config': {'signal_name': 'cleanup_done'}}])},
|
|
])
|
|
assert signals.collect_known_signals(db) == ['cleanup_done']
|
|
|
|
|
|
def test_collected_signals_are_sorted_and_deduped():
|
|
db = _FakeDB([
|
|
{'trigger_type': 'signal_received', 'trigger_config': json.dumps({'signal_name': 'zebra'}), 'then_actions': '[]'},
|
|
{'trigger_type': 'signal_received', 'trigger_config': json.dumps({'signal_name': 'apple'}), 'then_actions': '[]'},
|
|
{'trigger_type': 'signal_received', 'trigger_config': json.dumps({'signal_name': 'apple'}), 'then_actions': '[]'},
|
|
])
|
|
assert signals.collect_known_signals(db) == ['apple', 'zebra']
|
|
|
|
|
|
def test_empty_signal_name_skipped():
|
|
db = _FakeDB([
|
|
{'trigger_type': 'signal_received', 'trigger_config': json.dumps({'signal_name': ''}), 'then_actions': '[]'},
|
|
{'trigger_type': 'signal_received', 'trigger_config': json.dumps({'signal_name': ' '}), 'then_actions': '[]'},
|
|
])
|
|
assert signals.collect_known_signals(db) == []
|
|
|
|
|
|
def test_malformed_trigger_config_swallowed():
|
|
db = _FakeDB([
|
|
{'trigger_type': 'signal_received', 'trigger_config': 'not json', 'then_actions': '[]'},
|
|
])
|
|
assert signals.collect_known_signals(db) == []
|
|
|
|
|
|
def test_malformed_then_actions_swallowed():
|
|
db = _FakeDB([
|
|
{'trigger_type': 'schedule', 'trigger_config': '{}', 'then_actions': 'not json'},
|
|
])
|
|
assert signals.collect_known_signals(db) == []
|
|
|
|
|
|
def test_db_failure_returns_empty():
|
|
class _BrokenDB:
|
|
def get_automations(self, profile_id=None):
|
|
raise RuntimeError("db dead")
|
|
assert signals.collect_known_signals(_BrokenDB()) == []
|
|
|
|
|
|
def test_mixed_trigger_and_action_signals_merged():
|
|
db = _FakeDB([
|
|
{'trigger_type': 'signal_received', 'trigger_config': json.dumps({'signal_name': 'sig_a'}),
|
|
'then_actions': json.dumps([{'type': 'fire_signal', 'config': {'signal_name': 'sig_b'}}])},
|
|
])
|
|
assert signals.collect_known_signals(db) == ['sig_a', 'sig_b']
|
|
|
|
|
|
def test_non_signal_then_action_ignored():
|
|
db = _FakeDB([
|
|
{'trigger_type': 'schedule', 'trigger_config': '{}',
|
|
'then_actions': json.dumps([
|
|
{'type': 'discord', 'config': {'webhook_url': 'http://x'}},
|
|
{'type': 'fire_signal', 'config': {'signal_name': 'real_sig'}},
|
|
])},
|
|
])
|
|
assert signals.collect_known_signals(db) == ['real_sig']
|