Adds first formal test suite for Python fork — 42 tests covering memory_store,
profiles_store, scheduler_store, macros_store, llm_backend. All pass.
Tests (tests/, ~290 lines)
conftest.py — `isolated_state` fixture: rewires _PATH/_PROFILES_DIR etc to
a per-test tmp_path so concurrent runs don't touch real data.
test_memory_store.py — 9 tests: remember/recall/forget round-trip,
search by key/value, limit/empty query,
persistence across reload, build_llm_context
test_profiles_store.py — 7 tests: seeded defaults, set_active persistence,
allows_command logic (default/work/driving)
test_scheduler_store.py — 12 tests: parser (daily/at/every/in, ru units,
bad input), add/remove/clear/remove_by_text,
_next_fire for each schedule kind
test_macros_store.py — 8 tests: is_macro_control filter, start/save
round-trip, control-phrase filtering, list_names
sorted, replay unknown raises
test_llm_backend.py — 6 tests: parse_backend aliases (ru + en),
persist round-trip, auto-detect logic
Run: cd /c/Jarvis/python && python -m pytest tests/
Three new fun packs (commands.yaml: 151 → 154)
now_playing — Windows Media Session API via PowerShell
"что играет" / "что за песня" / "какой трек"
Works with Spotify, YouTube, Foobar, Yandex Music, anything
that exposes SMTC (Win10 1803+).
world_clock — worldtimeapi.org (free, no key)
"сколько времени в Токио" / "время в Лондоне"
21 Russian + world cities pre-mapped to IANA timezones.
daily_quote — zenquotes.io (free, no key) + LLM translation
"цитата дня" / "вдохнови меня"
Fetches English quote, translates to Russian via active LLM
(Groq or Ollama), speaks "text — author."
Pack count: 151 → 154. Tests: 42 pytest + ast.parse + yaml.safe_load.
109 lines
3.4 KiB
Python
109 lines
3.4 KiB
Python
"""Tests for scheduler_store: parser + add/remove + serde."""
|
|
|
|
import time
|
|
import pytest
|
|
|
|
|
|
def test_parse_daily(isolated_state):
|
|
s = isolated_state('scheduler_store', ['_PATH'])
|
|
schedule = s.parse_schedule("daily 09:00")
|
|
assert schedule == {'kind': 'daily', 'hour': 9, 'minute': 0}
|
|
|
|
|
|
def test_parse_at_today_or_tomorrow(isolated_state):
|
|
s = isolated_state('scheduler_store', ['_PATH'])
|
|
schedule = s.parse_schedule("at 23:59")
|
|
assert schedule['kind'] == 'once'
|
|
assert schedule['at'] > time.time()
|
|
|
|
|
|
def test_parse_every_minutes(isolated_state):
|
|
s = isolated_state('scheduler_store', ['_PATH'])
|
|
schedule = s.parse_schedule("every 30 minutes")
|
|
assert schedule == {'kind': 'interval', 'seconds': 1800}
|
|
|
|
|
|
def test_parse_every_russian(isolated_state):
|
|
s = isolated_state('scheduler_store', ['_PATH'])
|
|
schedule = s.parse_schedule("every 2 часа")
|
|
assert schedule == {'kind': 'interval', 'seconds': 7200}
|
|
|
|
|
|
def test_parse_in_minutes_is_once(isolated_state):
|
|
s = isolated_state('scheduler_store', ['_PATH'])
|
|
schedule = s.parse_schedule("in 5 minutes")
|
|
assert schedule['kind'] == 'once'
|
|
# Should be roughly 300 seconds in the future
|
|
delta = schedule['at'] - time.time()
|
|
assert 290 <= delta <= 310
|
|
|
|
|
|
def test_parse_bad_unit_raises(isolated_state):
|
|
s = isolated_state('scheduler_store', ['_PATH'])
|
|
with pytest.raises(ValueError):
|
|
s.parse_schedule("every 5 weeks")
|
|
|
|
|
|
def test_parse_bad_time_raises(isolated_state):
|
|
s = isolated_state('scheduler_store', ['_PATH'])
|
|
with pytest.raises(ValueError):
|
|
s.parse_schedule("daily 25:00")
|
|
with pytest.raises(ValueError):
|
|
s.parse_schedule("at 12:99")
|
|
|
|
|
|
def test_add_remove(isolated_state):
|
|
s = isolated_state('scheduler_store', ['_PATH'])
|
|
tid = s.add({
|
|
'name': 'Test',
|
|
'schedule': 'in 1 hour',
|
|
'action': {'type': 'speak', 'text': 'hello'},
|
|
})
|
|
assert tid
|
|
tasks = s.list_all()
|
|
assert len(tasks) == 1
|
|
assert tasks[0]['name'] == 'Test'
|
|
|
|
assert s.remove(tid) is True
|
|
assert s.list_all() == []
|
|
assert s.remove(tid) is False # idempotent
|
|
|
|
|
|
def test_remove_by_text(isolated_state):
|
|
s = isolated_state('scheduler_store', ['_PATH'])
|
|
s.add({
|
|
'name': 'Water',
|
|
'schedule': 'every 2 hours',
|
|
'action': {'type': 'speak', 'text': 'Попей воды.'},
|
|
})
|
|
s.add({
|
|
'name': 'Stretch',
|
|
'schedule': 'every 50 minutes',
|
|
'action': {'type': 'speak', 'text': 'Размяться.'},
|
|
})
|
|
|
|
removed = s.remove_by_text("воды")
|
|
assert removed == 1
|
|
assert len(s.list_all()) == 1
|
|
|
|
|
|
def test_clear(isolated_state):
|
|
s = isolated_state('scheduler_store', ['_PATH'])
|
|
s.add({'name': 'a', 'schedule': 'in 1 hour', 'action': {'type': 'speak', 'text': 'x'}})
|
|
s.add({'name': 'b', 'schedule': 'in 2 hours', 'action': {'type': 'speak', 'text': 'y'}})
|
|
assert s.clear() == 2
|
|
assert s.list_all() == []
|
|
|
|
|
|
def test_next_fire_once(isolated_state):
|
|
s = isolated_state('scheduler_store', ['_PATH'])
|
|
schedule = {'kind': 'once', 'at': 500}
|
|
assert s._next_fire(schedule, None, 1000) == 500
|
|
# Already fired
|
|
assert s._next_fire(schedule, 500, 1000) is None
|
|
|
|
|
|
def test_next_fire_interval(isolated_state):
|
|
s = isolated_state('scheduler_store', ['_PATH'])
|
|
schedule = {'kind': 'interval', 'seconds': 60}
|
|
assert s._next_fire(schedule, 100, 1000) == 160
|