feat: Python parity for Wave 6-8 — memory admin / cooking / leftoff / trivia / routines
New module `wave68_handlers.py` (~270 lines) covering the Rust packs that
were Rust-only after Waves 6-8:
- memory_admin: do_memory_count/list/wipe (with full RU plural decl.)
- cooking: do_cooking + reusable match_recipe(phrase) for the 15-dish
preset table. Schedules a "ready!" reminder via scheduler_store.
- leftoff: do_leftoff stitches memory + scheduled tasks + last reply
into a one-paragraph recap. Offline.
- trivia: do_trivia random one-liner from RU/EN MCU pools.
- routines: do_good_night (sleep profile + cancel one-shots),
do_good_morning (default profile + agenda hint),
do_coffee_break (5-min check-in via scheduler).
memory_store: new clear_all() returning removed count, atomic via _save.
extensions.py: imports + set_speak wiring + 9 proxy do_* fns.
main.py: 9 new dispatch elifs.
commands.yaml: 9 new entries (now 196 total).
tests/test_wave68_handlers.py: 15 unit tests covering each handler.
Test helper `_rewire()` re-imports state modules via the existing
isolated_state fixture then re-binds the wave68_handlers module-level
refs — fixes the gotcha where module-level imports cache the OLD
memory_store / scheduler_store instances from process startup.
Tests: 81 → 96 (+15).
This commit is contained in:
parent
5265cb0fbe
commit
94edb73f26
7 changed files with 644 additions and 0 deletions
236
tests/test_wave68_handlers.py
Normal file
236
tests/test_wave68_handlers.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue