"""Tests for `wave68_handlers.py` — Python parity for Wave 6-8 Rust packs. Each test captures speech via `set_speak(callback)` so we can assert on what J.A.R.V.I.S. would have said. Scheduler / memory state lives in tmp_path via the `isolated_state` fixture from conftest.py. Implementation gotcha: `isolated_state` re-imports `memory_store` / `scheduler_store` etc with `_PATH` pointing at the per-test tmpdir, but the already-imported `wave68_handlers` keeps a reference to the OLD modules. `_rewire(...)` below re-binds those refs so handlers hit the tmp stores. """ import importlib import os import sys # Make project root importable. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import wave68_handlers def _rewire(isolated_state, *modules): """Re-import each named state module via `isolated_state`, then rebind the wave68_handlers reference so the handler picks up the tmp_path instance. Returns the list of (re-imported) modules. """ importlib.reload(wave68_handlers) out = [] for name in modules: mod = isolated_state(name, ['_PATH', '_PROFILES_DIR']) setattr(wave68_handlers, name, mod) out.append(mod) return out def _capture_speak(): captured = [] wave68_handlers.set_speak(captured.append) return captured # ── memory admin ───────────────────────────────────────────────────────── def test_memory_count_uses_russian_plural(isolated_state): (mem,) = _rewire(isolated_state, 'memory_store') spoken = _capture_speak() wave68_handlers.do_memory_count() assert "0 фактов" in spoken[0] mem.remember('любимый чай', 'улун') spoken.clear() wave68_handlers.do_memory_count() assert "1 факт" in spoken[0] assert "факта" not in spoken[0] # singular, not "2 факта" mem.remember('питомец', 'собака') mem.remember('город', 'москва') spoken.clear() wave68_handlers.do_memory_count() assert "3 факта" in spoken[0] # 2-4 → "факта" for i in range(2): mem.remember(f'k{i}', f'v{i}') spoken.clear() wave68_handlers.do_memory_count() assert "5 фактов" in spoken[0] # 5+ → "фактов" def test_memory_list_speaks_empty_state(isolated_state): _rewire(isolated_state, 'memory_store') spoken = _capture_speak() wave68_handlers.do_memory_list() assert "пуст" in spoken[0].lower() def test_memory_list_shows_recent_first(isolated_state): (mem,) = _rewire(isolated_state, 'memory_store') # Inject pre-aged record directly so timestamps are distinct without # waiting on the clock — `remember` uses wall-clock seconds and two # back-to-back calls in tests routinely tie. mem._load() mem._store['старый'] = { 'key': 'старый', 'value': 'факт', 'created_at': 100, 'last_used_at': 100, 'use_count': 0, } mem._store['новый'] = { 'key': 'новый', 'value': 'факт', 'created_at': 200, 'last_used_at': 200, 'use_count': 0, } spoken = _capture_speak() wave68_handlers.do_memory_list() text = spoken[0] # Both must appear, with новый (last_used_at=200) before старый (=100) assert 'новый' in text assert 'старый' in text assert text.index('новый') < text.index('старый') def test_memory_wipe_returns_count(isolated_state): (mem,) = _rewire(isolated_state, 'memory_store') mem.remember('a', '1') mem.remember('b', '2') mem.remember('c', '3') spoken = _capture_speak() wave68_handlers.do_memory_wipe() assert "3" in spoken[0] assert len(mem.all_facts()) == 0 def test_memory_wipe_on_empty_is_polite(isolated_state): _rewire(isolated_state, 'memory_store') spoken = _capture_speak() wave68_handlers.do_memory_wipe() assert "пуста" in spoken[0] # ── cooking ───────────────────────────────────────────────────────────── def test_match_recipe_finds_substrings(): # Direct unit test on the matcher — no scheduler side effects. assert wave68_handlers.match_recipe("варю макароны") == (10, "макароны") assert wave68_handlers.match_recipe("ставлю чайник") == (4, "чайник") assert wave68_handlers.match_recipe("жарю омлет") == (5, "омлет") assert wave68_handlers.match_recipe("какая сегодня погода") is None assert wave68_handlers.match_recipe("") is None def test_cooking_schedules_a_task(isolated_state): (sched,) = _rewire(isolated_state, 'scheduler_store') spoken = _capture_speak() wave68_handlers.do_cooking(voice="варю пасту 200 грамм") tasks = sched.list_all() assert len(tasks) == 1 assert "паст" in tasks[0]['name'].lower() assert "10" in spoken[0] def test_cooking_unmatched_phrase_says_so(isolated_state): _rewire(isolated_state, 'scheduler_store') spoken = _capture_speak() wave68_handlers.do_cooking(voice="какая погода в москве") assert "не понял" in spoken[0].lower() # ── leftoff ───────────────────────────────────────────────────────────── def test_leftoff_pulls_memory_and_tasks(isolated_state): mem, sched = _rewire(isolated_state, 'memory_store', 'scheduler_store') mem.remember('текущий проект', 'jarvis рефакторинг') sched.add({ "name": "Daily standup", "schedule": sched.parse_schedule("daily 09:00"), "action": {"type": "speak", "text": "standup"}, }) spoken = _capture_speak() wave68_handlers.do_leftoff(last_assistant_reply="Готовлю кофе, сэр.") text = spoken[0] assert "текущий проект" in text assert "Daily standup" in text assert "Готовлю кофе" in text def test_leftoff_empty_is_polite(isolated_state): _rewire(isolated_state, 'memory_store', 'scheduler_store') spoken = _capture_speak() wave68_handlers.do_leftoff() assert "ничего" in spoken[0].lower() # ── trivia ────────────────────────────────────────────────────────────── def test_trivia_returns_one_known_line(): spoken = _capture_speak() wave68_handlers.do_trivia(lang="ru") assert spoken[0] in wave68_handlers._TRIVIA_RU def test_trivia_english_pool(): spoken = _capture_speak() wave68_handlers.do_trivia(lang="en") assert spoken[0] in wave68_handlers._TRIVIA_EN # ── routines ──────────────────────────────────────────────────────────── def test_good_night_cancels_one_shot_timers(isolated_state): sched, _prof = _rewire(isolated_state, 'scheduler_store', 'profiles_store') # Pre-populate: one one-shot ("Таймер:") and one daily (should stay) sched.add({ "name": "Таймер: яичница", "schedule": sched.parse_schedule("in 5 minutes"), "action": {"type": "speak", "text": "ready"}, }) sched.add({ "name": "Daily briefing", "schedule": sched.parse_schedule("daily 09:00"), "action": {"type": "speak", "text": "morning"}, }) spoken = _capture_speak() wave68_handlers.do_good_night() remaining = [t['name'] for t in sched.list_all()] assert "Daily briefing" in remaining assert not any("Таймер:" in n for n in remaining) assert "1" in spoken[0] # cancelled-count appears in message def test_good_morning_reports_clear_or_busy_schedule(isolated_state): sched, _prof = _rewire(isolated_state, 'scheduler_store', 'profiles_store') spoken = _capture_speak() wave68_handlers.do_good_morning() assert "чистое" in spoken[0].lower() or "clear" in spoken[0].lower() sched.add({ "name": "Task", "schedule": sched.parse_schedule("daily 10:00"), "action": {"type": "speak", "text": "x"}, }) spoken.clear() wave68_handlers.do_good_morning() assert "1" in spoken[0] def test_coffee_break_creates_5min_checkin(isolated_state): (sched,) = _rewire(isolated_state, 'scheduler_store') spoken = _capture_speak() wave68_handlers.do_coffee_break() tasks = sched.list_all() assert len(tasks) == 1 assert "Чек-ин" in tasks[0]['name'] assert spoken[0] # non-empty