You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/tests/automation/test_automation_progress.py

291 lines
10 KiB

"""Tests for core/automation/progress.py — progress state lifecycle + emit loop."""
from __future__ import annotations
import time
from datetime import datetime, timedelta, timezone
import pytest
from core.automation import progress
@pytest.fixture(autouse=True)
def reset_state():
"""Each test gets a clean progress state dict."""
progress.progress_states.clear()
yield
progress.progress_states.clear()
# ---------------------------------------------------------------------------
# init_progress
# ---------------------------------------------------------------------------
def test_init_progress_seeds_running_state():
progress.init_progress(7, 'My Automation', 'process_wishlist')
state = progress.progress_states[7]
assert state['status'] == 'running'
assert state['action_type'] == 'process_wishlist'
assert state['progress'] == 0
assert state['phase'] == 'Starting...'
assert state['log'][0] == {'type': 'info', 'text': 'Starting My Automation'}
assert state['started_at'] is not None
assert state['finished_at'] is None
def test_init_progress_overwrites_existing_state():
progress.init_progress(7, 'First', 'a')
progress.init_progress(7, 'Second', 'b')
assert progress.progress_states[7]['action_type'] == 'b'
# ---------------------------------------------------------------------------
# update_progress
# ---------------------------------------------------------------------------
def test_update_progress_writes_simple_fields():
progress.init_progress(1, 'A', 'x')
progress.update_progress(1, progress=42, phase='Working')
assert progress.progress_states[1]['progress'] == 42
assert progress.progress_states[1]['phase'] == 'Working'
def test_update_progress_log_line_appends_with_type():
progress.init_progress(1, 'A', 'x')
progress.update_progress(1, log_line='hello', log_type='success')
log = progress.progress_states[1]['log']
assert log[-1] == {'type': 'success', 'text': 'hello'}
def test_update_progress_log_caps_at_50_entries():
progress.init_progress(1, 'A', 'x')
for i in range(60):
progress.update_progress(1, log_line=f'line {i}')
assert len(progress.progress_states[1]['log']) == 50
assert progress.progress_states[1]['log'][-1]['text'] == 'line 59'
def test_update_progress_log_type_not_stored_as_field():
progress.init_progress(1, 'A', 'x')
progress.update_progress(1, log_line='hi', log_type='warning')
assert 'log_type' not in progress.progress_states[1]
def test_update_progress_finish_sets_finished_at_and_emits():
progress.init_progress(1, 'A', 'x')
emitted = []
def _emit(event, data):
emitted.append((event, data))
progress.update_progress(1, status='finished', socketio_emit=_emit)
assert progress.progress_states[1]['finished_at'] is not None
assert emitted[0][0] == 'automation:progress'
assert '1' in emitted[0][1]
def test_update_progress_error_status_also_emits():
progress.init_progress(1, 'A', 'x')
emitted = []
progress.update_progress(1, status='error', socketio_emit=lambda e, d: emitted.append(e))
assert emitted == ['automation:progress']
def test_update_progress_running_status_does_not_emit():
progress.init_progress(1, 'A', 'x')
emitted = []
progress.update_progress(1, status='running', progress=50, socketio_emit=lambda e, d: emitted.append(e))
assert emitted == []
def test_update_progress_emit_failure_swallowed():
progress.init_progress(1, 'A', 'x')
def _bad(event, data):
raise RuntimeError('socket dead')
# Should NOT raise
progress.update_progress(1, status='finished', socketio_emit=_bad)
assert progress.progress_states[1]['finished_at'] is not None
def test_update_progress_none_id_is_noop():
progress.update_progress(None, progress=99) # no exception
def test_update_progress_unknown_id_is_noop():
progress.update_progress(999, progress=99)
assert 999 not in progress.progress_states
# ---------------------------------------------------------------------------
# get_running_progress
# ---------------------------------------------------------------------------
def test_get_running_progress_returns_running_finished_error():
progress.init_progress(1, 'A', 'x')
progress.init_progress(2, 'B', 'y')
progress.init_progress(3, 'C', 'z')
progress.update_progress(2, status='finished')
progress.update_progress(3, status='error')
progress.progress_states[4] = {'status': 'unknown', 'log': []}
snapshot = progress.get_running_progress()
assert set(snapshot.keys()) == {'1', '2', '3'}
def test_get_running_progress_copies_log_list():
progress.init_progress(1, 'A', 'x')
progress.update_progress(1, log_line='first')
snapshot = progress.get_running_progress()
snapshot['1']['log'].append({'type': 'info', 'text': 'mutated'})
# Original state should not be affected
assert len(progress.progress_states[1]['log']) == 2 # init + 'first'
# ---------------------------------------------------------------------------
# record_history
# ---------------------------------------------------------------------------
class _FakeDB:
def __init__(self):
self.calls = []
def insert_automation_run_history(self, **kw):
self.calls.append(kw)
def test_record_history_uses_progress_log_when_available():
progress.init_progress(1, 'A', 'wishlist')
progress.update_progress(1, log_line='did stuff', log_type='success')
progress.update_progress(1, status='finished', socketio_emit=None)
db = _FakeDB()
progress.record_history(1, {'status': 'completed'}, db)
assert len(db.calls) == 1
call = db.calls[0]
assert call['automation_id'] == 1
assert call['status'] == 'completed'
assert call['summary'] == 'did stuff'
assert call['duration_seconds'] is not None
def test_record_history_status_mapping():
db = _FakeDB()
progress.record_history(1, {'status': 'error'}, db)
assert db.calls[-1]['status'] == 'error'
progress.record_history(2, {'status': 'skipped'}, db)
assert db.calls[-1]['status'] == 'skipped'
progress.record_history(3, {'status': 'timeout'}, db)
assert db.calls[-1]['status'] == 'timeout'
progress.record_history(4, {'status': 'completed'}, db)
assert db.calls[-1]['status'] == 'completed'
def test_record_history_underscore_keys_stripped_from_result_json():
progress.init_progress(1, 'A', 'x')
progress.update_progress(1, status='finished', socketio_emit=None)
db = _FakeDB()
progress.record_history(1, {'status': 'completed', '_internal': 'x', 'visible': 'y'}, db)
import json as _json
parsed = _json.loads(db.calls[0]['result_json'])
assert '_internal' not in parsed
assert parsed.get('visible') == 'y'
def test_record_history_falls_back_to_result_summary_when_no_log():
db = _FakeDB()
progress.record_history(1, {'status': 'error', 'reason': 'bad config'}, db)
assert db.calls[0]['summary'] == 'bad config'
def test_record_history_no_progress_state_uses_now_for_times():
db = _FakeDB()
progress.record_history(99, {'status': 'completed'}, db)
call = db.calls[0]
assert call['started_at'] is not None
assert call['finished_at'] is not None
def test_record_history_db_failure_swallowed():
class _BrokenDB:
def insert_automation_run_history(self, **kw):
raise RuntimeError('db dead')
progress.init_progress(1, 'A', 'x')
# Should NOT raise
progress.record_history(1, {'status': 'completed'}, _BrokenDB())
# ---------------------------------------------------------------------------
# emit_progress_loop
# ---------------------------------------------------------------------------
class _FakeSocket:
def __init__(self):
self.emitted = []
self._sleep_count = 0
self._max_sleeps = 1
def sleep(self, seconds):
self._sleep_count += 1
def emit(self, event, data):
self.emitted.append((event, data))
def _shutdown_after(n_sleeps, sock):
"""Build an is_shutting_down predicate that returns True after n loops."""
def _check():
return sock._sleep_count >= n_sleeps
return _check
def test_emit_loop_emits_running_state():
progress.init_progress(1, 'A', 'x')
progress.update_progress(1, progress=50)
sock = _FakeSocket()
progress.emit_progress_loop(sock, is_shutting_down=_shutdown_after(1, sock), poll_interval=0)
assert len(sock.emitted) == 1
assert sock.emitted[0][0] == 'automation:progress'
assert '1' in sock.emitted[0][1]
def test_emit_loop_attempts_timeout_check_with_naive_datetime():
"""Documents pre-existing bug: started_at is tz-aware, now is naive,
so subtraction raises TypeError → caught → timeout never fires.
Lift preserves the bug; fix lives in a separate PR.
"""
progress.init_progress(1, 'A', 'x')
state = progress.progress_states[1]
state['started_at'] = (datetime.now(timezone.utc) - timedelta(hours=3)).isoformat()
sock = _FakeSocket()
progress.emit_progress_loop(sock, is_shutting_down=_shutdown_after(1, sock),
poll_interval=0, timeout_seconds=7200)
# Bug: timeout doesn't fire because datetime.now() is naive but
# started_at is tz-aware → subtraction raises → except → fall through
# to the normal "running" branch. State stays 'running'.
assert progress.progress_states[1]['status'] == 'running'
def test_emit_loop_reaps_finished_states_due_to_naive_aware_mismatch():
"""Documents pre-existing bug: finished_at is tz-aware, now is naive,
so the cleanup math raises → caught → state is reaped on FIRST tick
regardless of `cleanup_after_seconds`. Lift preserves the bug.
"""
progress.init_progress(1, 'A', 'x')
progress.update_progress(1, status='finished', socketio_emit=None)
sock = _FakeSocket()
progress.emit_progress_loop(sock, is_shutting_down=_shutdown_after(1, sock),
poll_interval=0, cleanup_after_seconds=300)
# Bug: should be kept (300s window, just finished), but the TypeError
# in the cleanup math is caught with a fall-through that ALSO reaps.
assert 1 not in progress.progress_states
def test_emit_loop_no_active_states_no_emit():
sock = _FakeSocket()
progress.emit_progress_loop(sock, is_shutting_down=_shutdown_after(1, sock), poll_interval=0)
assert sock.emitted == []