soulsync/tests/automation/test_automation_blocks.py
BoulderBadgeDad 91eae710b4 video automations: post-download scan chain (parity with music's batch→scan→update)
Mirrors the music flow: a finished download batch → refresh the media server → (after
it indexes) pull the new media into the DB — so a downloaded movie/episode shows as
owned without waiting for the 6h scheduled scan.

Two event-based system automations (owned_by='video'):
  - 'Auto-Scan Video After Downloads' (video_batch_complete → video_scan_server)
  - 'Auto-Update Video Database After Scan' (video_library_scan_completed → video_update_database)

Pieces:
- core/video/download_events.py: a callback registry (core/video can't import the
  engine — isolation). The monitor publishes batch-complete; web_server bridges it to
  automation_engine.emit('video_batch_complete', …), like music's web_scan_manager.
- download_monitor: fires the batch-complete event once, when the last in-flight
  download finishes (none queued/downloading/searching left).
- video_scan_server handler (stage 1): refresh server, wait a debounce for indexing,
  then emit 'video_library_scan_completed' (mirrors music's time-based completion).
- video_update_database handler (stage 2): incremental read (newest-first, stop after
  25 consecutive known — same as music).
- blocks: 2 video triggers + 2 video actions (scope='video'); registration; seeds.

kettui: seam tests for both handlers (refresh/wait/emit, incremental read, error paths),
the event registry (idempotent register, isolated failures), and the monitor (fires once
on last completion, never while work remains). 298 automation tests + isolation green.
2026-06-20 14:47:00 -07:00

146 lines
5.8 KiB
Python

"""Tests for core/automation/blocks.py — static block definitions for the builder UI.
Catches accidental schema regressions in the builder block list (missing
`type`/`label`, malformed config_fields options, etc.).
"""
from __future__ import annotations
from core.automation import blocks
def _shape_check(items, allowed_types):
"""Every item has type+label+description, plus type-specific shape rules."""
seen_types = set()
for item in items:
assert 'type' in item, item
assert 'label' in item, item
assert isinstance(item.get('available'), bool), item
# No duplicate types within a list
assert item['type'] not in seen_types, f"Duplicate type {item['type']!r}"
seen_types.add(item['type'])
if 'config_fields' in item:
for field in item['config_fields']:
assert 'key' in field
assert 'type' in field
assert field['type'] in allowed_types, f"Unknown field type {field['type']!r} in {item['type']}"
if field['type'] == 'select':
assert 'options' in field
for opt in field['options']:
assert 'value' in opt
assert 'label' in opt
_FIELD_TYPES = {
'number', 'select', 'time', 'multi_select', 'checkbox', 'text',
'mirrored_playlist_select', 'personalized_playlist_select',
'signal_input', 'script_select',
}
def test_triggers_shape():
_shape_check(blocks.TRIGGERS, _FIELD_TYPES)
def test_actions_shape():
_shape_check(blocks.ACTIONS, _FIELD_TYPES)
def test_notifications_shape():
_shape_check(blocks.NOTIFICATIONS, _FIELD_TYPES)
def test_signal_received_trigger_present():
types = {t['type'] for t in blocks.TRIGGERS}
assert 'signal_received' in types
def test_fire_signal_notification_present():
types = {n['type'] for n in blocks.NOTIFICATIONS}
assert 'fire_signal' in types
def test_run_script_in_both_actions_and_notifications():
"""run_script can be either an action or a then-action — both lists own it."""
action_types = {a['type'] for a in blocks.ACTIONS}
notif_types = {n['type'] for n in blocks.NOTIFICATIONS}
assert 'run_script' in action_types
assert 'run_script' in notif_types
def test_schedule_trigger_default_unit_is_hours():
schedule = next(t for t in blocks.TRIGGERS if t['type'] == 'schedule')
unit_field = next(f for f in schedule['config_fields'] if f['key'] == 'unit')
assert unit_field['default'] == 'hours'
def test_event_triggers_with_conditions_have_condition_fields():
for t in blocks.TRIGGERS:
if t.get('has_conditions'):
assert 'condition_fields' in t, f"{t['type']} marked has_conditions but no condition_fields"
assert isinstance(t['condition_fields'], list)
assert len(t['condition_fields']) > 0
# ── scope filtering (music vs the isolated video builder) ────────────────
def test_video_only_block_hidden_from_music_builder():
"""The video action must never appear on the music builder."""
music = blocks.blocks_for_scope('music')
assert 'video_scan_library' not in {a['type'] for a in music['actions']}
def test_video_builder_gets_video_block_plus_generics():
video = blocks.blocks_for_scope('video')
action_types = {a['type'] for a in video['actions']}
# its own action…
assert 'video_scan_library' in action_types
# …plus the generic (scope='both') ones it shares with music…
assert 'notify_only' in action_types
assert 'run_script' in action_types
# …but NOT music-only actions.
assert 'process_wishlist' not in action_types
assert 'scan_library' not in action_types
def test_generic_blocks_appear_on_both_sides():
"""Every scope='both' block shows on music AND video."""
music = blocks.blocks_for_scope('music')
video = blocks.blocks_for_scope('video')
for key in ('triggers', 'actions', 'notifications'):
both = {b['type'] for b in getattr(blocks, key.upper()) if b.get('scope') == 'both'}
assert both, f"expected at least one scope='both' {key}"
assert both <= {b['type'] for b in music[key]}, f"music missing a 'both' {key}"
assert both <= {b['type'] for b in video[key]}, f"video missing a 'both' {key}"
def test_music_scope_matches_legacy_full_lists_minus_video():
"""scope='music' must reproduce the pre-scope behaviour: everything that
isn't explicitly video-only. Guards against accidentally hiding a music
block when new scope tags are added."""
music = blocks.blocks_for_scope('music')
for key in ('triggers', 'actions', 'notifications'):
expected = {b['type'] for b in getattr(blocks, key.upper()) if b.get('scope') != 'video'}
assert {b['type'] for b in music[key]} == expected
def test_post_download_chain_blocks_are_video_scoped():
music = blocks.blocks_for_scope('music')
video = blocks.blocks_for_scope('video')
trig = {t['type'] for t in video['triggers']}
act = {a['type'] for a in video['actions']}
assert {'video_batch_complete', 'video_library_scan_completed'} <= trig
assert {'video_scan_server', 'video_update_database'} <= act
mtrig = {t['type'] for t in music['triggers']}
mact = {a['type'] for a in music['actions']}
assert not ({'video_batch_complete', 'video_library_scan_completed'} & mtrig)
assert not ({'video_scan_server', 'video_update_database'} & mact)
def test_video_scan_library_block_shape():
action = next(a for a in blocks.ACTIONS if a['type'] == 'video_scan_library')
assert action['scope'] == 'video'
mode = next(f for f in action['config_fields'] if f['key'] == 'mode')
assert {o['value'] for o in mode['options']} == {'full', 'incremental', 'deep'}
assert mode['default'] == 'full'