J.A.R.V.I.S-py/tests/test_wave59_handlers.py
Bossiara13 633850d0f7
Some checks failed
Python CI / pytest (ubuntu-latest) (push) Failing after 5s
Python CI / ruff (push) Failing after 5s
Python CI / pytest (windows-latest) (push) Has been cancelled
feat: Python parity for wol / banter / conversation / ddg_answer + 11 tests
New `wave59_handlers.py` (~250 lines) mirroring four Rust-only packs:

- wol: pure-Python magic packet via socket + SO_BROADCAST. No PowerShell
  shellout (unlike the Rust pack). Reads MAC from memory_store under
  'wol_<alias>' or JARVIS_WOL_TARGET env. Same MAC format tolerance
  (colons / dashes / dots / bare) as the Rust normaliser.

- banter: pause/resume state + fire-one. Python edition doesn't run a
  background banter thread (the Rust daemon already does), so this is
  just the user-facing surface — same voice phrases work.

- conversation: do_conversation_summary calls Groq with last 12 turns
  from main.py's `message_log`. set_history_ref() injects the live list
  reference at startup so the handler reads up-to-date history without
  a circular import. Offline-safe: GROQ_TOKEN missing → speak the last
  user message instead. do_conversation_repeat re-speaks the last
  assistant turn.

- ddg_answer: urllib + DuckDuckGo Instant Answer JSON. Picks
  AbstractText → Answer → Definition → RelatedTopics[0]. Opens
  duckduckgo.com search fallback when nothing instant.

extensions.py / main.py / commands.yaml: 7 new dispatch entries +
proxy fns. commands.yaml now at 207 entries.

tests/test_wave59_handlers.py: 11 unit tests for MAC normalisation,
magic packet shape, WOL error paths, banter state machine, DDG trigger
stripping, conversation history handling. Network calls deliberately
NOT exercised here — they belong to integration tests.

Tests: 104 → 115 (+11).
2026-05-24 23:12:04 +03:00

152 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for `wave59_handlers.py` — Python parity for the Rust-only
wol / banter / conversation / ddg_answer packs.
Focus is on the testable pure logic — MAC normalisation, magic-packet
construction, trigger stripping, banter state machine. Network calls
(DDG, LLM) are skipped here; they're integration concerns covered by
the Rust pack tests.
"""
import importlib
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import wave59_handlers
def _rewire(isolated_state, *modules):
importlib.reload(wave59_handlers)
out = []
for name in modules:
mod = isolated_state(name, ['_PATH', '_PROFILES_DIR'])
setattr(wave59_handlers, name, mod)
out.append(mod)
return out
def _capture():
captured = []
wave59_handlers.set_speak(captured.append)
return captured
# ── WOL ─────────────────────────────────────────────────────────────────
def test_normalise_mac_accepts_common_formats():
n = wave59_handlers._normalise_mac
assert n("AA:BB:CC:DD:EE:FF") == "AABBCCDDEEFF"
assert n("aa-bb-cc-dd-ee-ff") == "AABBCCDDEEFF"
assert n("AABB.CCDD.EEFF") == "AABBCCDDEEFF"
assert n("AABBCCDDEEFF") == "AABBCCDDEEFF"
assert n("garbage") is None
assert n("") is None
assert n("AABBCCDD") is None # too short
def test_build_magic_packet_shape():
p = wave59_handlers._build_magic_packet("AABBCCDDEEFF")
# 6 × 0xFF + 16 × MAC = 102 bytes
assert len(p) == 102
assert p[:6] == b"\xff" * 6
# First repeat of MAC starts at byte 6
assert p[6:12] == bytes.fromhex("AABBCCDDEEFF")
# Last repeat at offset 96
assert p[96:102] == bytes.fromhex("AABBCCDDEEFF")
def test_wol_speaks_help_when_mac_not_set(isolated_state, monkeypatch):
(mem,) = _rewire(isolated_state, 'memory_store')
monkeypatch.delenv("JARVIS_WOL_TARGET", raising=False)
spoken = _capture()
wave59_handlers.do_wol()
assert "MAC" in spoken[0]
def test_wol_speaks_malformed_when_mac_invalid(isolated_state, monkeypatch):
(mem,) = _rewire(isolated_state, 'memory_store')
monkeypatch.delenv("JARVIS_WOL_TARGET", raising=False)
mem.remember("wol_server", "totally-not-a-mac")
spoken = _capture()
wave59_handlers.do_wol()
assert "подозрительно" in spoken[0]
# ── banter ──────────────────────────────────────────────────────────────
def test_banter_pause_then_fire_says_quiet(isolated_state):
importlib.reload(wave59_handlers)
spoken = _capture()
wave59_handlers.do_banter_pause()
wave59_handlers.do_banter_fire()
# Two utterances captured: "Молчу..." then a "без комментариев" reply
assert any("Молчу" in s for s in spoken)
assert any("без комментариев" in s.lower() for s in spoken)
def test_banter_resume_clears_pause(isolated_state):
importlib.reload(wave59_handlers)
spoken = _capture()
wave59_handlers.do_banter_pause()
wave59_handlers.do_banter_resume()
wave59_handlers.do_banter_fire()
# After resume, fire should return one of the known pool lines.
assert any(s in wave59_handlers._BANTER_LINES for s in spoken)
# ── conversation ────────────────────────────────────────────────────────
def test_conversation_repeat_with_no_history(isolated_state):
importlib.reload(wave59_handlers)
wave59_handlers.set_history_ref(None)
spoken = _capture()
wave59_handlers.do_conversation_repeat()
assert "ещё ничего не говорил" in spoken[0]
def test_conversation_repeat_speaks_last_assistant(isolated_state):
importlib.reload(wave59_handlers)
history = [
{"role": "system", "content": "you are jarvis"},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "Слушаю, сэр."},
{"role": "user", "content": "what time"},
{"role": "assistant", "content": "Восемь утра, сэр."},
]
wave59_handlers.set_history_ref(history)
spoken = _capture()
wave59_handlers.do_conversation_repeat()
assert spoken[0] == "Восемь утра, сэр."
def test_conversation_summary_empty_history(isolated_state):
importlib.reload(wave59_handlers)
wave59_handlers.set_history_ref([{"role": "system", "content": "x"}])
spoken = _capture()
wave59_handlers.do_conversation_summary()
assert "пока ещё ни о чём" in spoken[0]
# ── DDG trigger stripping ───────────────────────────────────────────────
def test_strip_ddg_trigger_known_prefixes():
s = wave59_handlers._strip_ddg_trigger
assert s("что такое квантовая запутанность") == "квантовая запутанность"
assert s("кто такой никола тесла") == "никола тесла"
assert s("what is the speed of light") == "the speed of light"
assert s("tell me about jupiter") == "jupiter"
# No trigger → phrase passes through unchanged
assert s("просто текст без триггера") == "просто текст без триггера"
assert s("") == ""
def test_ddg_answer_with_empty_query_says_so(isolated_state):
importlib.reload(wave59_handlers)
spoken = _capture()
wave59_handlers.do_ddg_answer(voice="что такое")
assert "Что искать" in spoken[0]