405 lines
15 KiB
Python
405 lines
15 KiB
Python
"""Regression tests for the correctness fixes applied to Javis.
|
|
|
|
These target concrete, previously-broken behaviours and use only
|
|
dependency-light modules (no torch / audio / MCP) so they run fast.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from javis.core.config import Config, PathsConfig, find_project_root
|
|
from javis.llm.conversation import Conversation, count_tokens
|
|
from javis.voices.profile import VoiceProfile
|
|
|
|
|
|
class FindProjectRootTests(unittest.TestCase):
|
|
def test_finds_pyproject_marker(self) -> None:
|
|
root = find_project_root()
|
|
# The repository root holds pyproject.toml.
|
|
self.assertTrue((root / "pyproject.toml").is_file())
|
|
|
|
def test_falls_back_to_cwd_without_marker(self) -> None:
|
|
with tempfile.TemporaryDirectory() as td:
|
|
deep = Path(td) / "a" / "b"
|
|
deep.mkdir(parents=True)
|
|
# No pyproject.toml anywhere up the temp tree → cwd fallback, no raise.
|
|
result = find_project_root(deep)
|
|
self.assertIsInstance(result, Path)
|
|
|
|
|
|
class VoiceProfileCorruptionTests(unittest.TestCase):
|
|
def test_corrupt_profile_json_does_not_raise(self) -> None:
|
|
with tempfile.TemporaryDirectory() as td:
|
|
d = Path(td)
|
|
(d / "profile.json").write_text("{ this is not valid json", encoding="utf-8")
|
|
prof = VoiceProfile.from_dir(d) # must not raise
|
|
self.assertEqual(prof.name, d.name)
|
|
self.assertIsNone(prof.speed)
|
|
|
|
def test_non_numeric_speed_falls_back_to_none(self) -> None:
|
|
with tempfile.TemporaryDirectory() as td:
|
|
d = Path(td)
|
|
(d / "profile.json").write_text(
|
|
json.dumps({"name": "x", "speed": "fast"}), encoding="utf-8"
|
|
)
|
|
prof = VoiceProfile.from_dir(d)
|
|
self.assertIsNone(prof.speed)
|
|
|
|
def test_valid_speed_is_parsed(self) -> None:
|
|
with tempfile.TemporaryDirectory() as td:
|
|
d = Path(td)
|
|
(d / "profile.json").write_text(
|
|
json.dumps({"name": "x", "speed": 1.25}), encoding="utf-8"
|
|
)
|
|
prof = VoiceProfile.from_dir(d)
|
|
self.assertEqual(prof.speed, 1.25)
|
|
|
|
|
|
class ConversationTests(unittest.TestCase):
|
|
def test_history_property_is_a_readonly_snapshot(self) -> None:
|
|
conv = Conversation(system_prompt="sys", max_turns=20)
|
|
conv.add_user("hello")
|
|
snap = conv.history
|
|
snap.append({"role": "user", "content": "mutated"})
|
|
# Mutating the snapshot must not affect the real history.
|
|
self.assertEqual(len(conv.history), 1)
|
|
|
|
def test_trim_keeps_tool_pairs_and_bounds_size(self) -> None:
|
|
conv = Conversation(system_prompt="sys", max_turns=2, max_chars=200)
|
|
for i in range(10):
|
|
conv.add_user("u" * 50)
|
|
conv.add_assistant("a" * 50)
|
|
total = sum(len(m.get("content") or "") for m in conv.history)
|
|
self.assertLessEqual(total, 200)
|
|
# History must never start with an orphaned tool/assistant message.
|
|
if conv.history:
|
|
self.assertEqual(conv.history[0]["role"], "user")
|
|
|
|
def test_count_tokens_fallback_is_nonzero_for_text(self) -> None:
|
|
self.assertEqual(count_tokens(""), 0)
|
|
self.assertGreater(count_tokens("hello world this is text"), 0)
|
|
|
|
|
|
class QuickEngineTests(unittest.TestCase):
|
|
def test_load_skips_malformed_command_but_keeps_valid(self) -> None:
|
|
from javis.agent.quick_engine import QuickEngine
|
|
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "quick_commands.json"
|
|
path.write_text(json.dumps({
|
|
"version": 1,
|
|
"commands": {
|
|
"good": {
|
|
"id": "good", "trigger": "lights on", "match_mode": "exact",
|
|
"actions": [{"tool": "homey__x", "args": {}}],
|
|
},
|
|
"bad": {
|
|
"id": "bad", "trigger": "broken", "match_mode": "exact",
|
|
"actions": [{"not_a_valid_field": 1}],
|
|
},
|
|
},
|
|
}), encoding="utf-8")
|
|
eng = QuickEngine(path)
|
|
eng.load() # must not raise
|
|
ids = {c.id for c in eng.list_all()}
|
|
self.assertIn("good", ids)
|
|
self.assertNotIn("bad", ids)
|
|
|
|
|
|
class DeviceStoreDebounceTests(unittest.TestCase):
|
|
def test_touch_last_seen_debounces_disk_writes(self) -> None:
|
|
from javis.api.devices import SlaveDeviceStore
|
|
|
|
with tempfile.TemporaryDirectory() as td:
|
|
store = SlaveDeviceStore(Path(td) / "slaves.json")
|
|
store.register("dev1", "Device One")
|
|
with patch.object(store, "_save", wraps=store._save) as spy:
|
|
# Same IP, rapid calls → at most one flush within the interval.
|
|
for _ in range(5):
|
|
store.touch_last_seen("dev1", ip_address="10.0.0.1")
|
|
self.assertLessEqual(spy.call_count, 1)
|
|
|
|
def test_touch_last_seen_flushes_on_ip_change(self) -> None:
|
|
from javis.api.devices import SlaveDeviceStore
|
|
|
|
with tempfile.TemporaryDirectory() as td:
|
|
store = SlaveDeviceStore(Path(td) / "slaves.json")
|
|
store.register("dev1", "Device One")
|
|
store.touch_last_seen("dev1", ip_address="10.0.0.1")
|
|
with patch.object(store, "_save", wraps=store._save) as spy:
|
|
store.touch_last_seen("dev1", ip_address="10.0.0.2") # IP changed
|
|
self.assertEqual(spy.call_count, 1)
|
|
|
|
|
|
class TimerPersistenceTests(unittest.IsolatedAsyncioTestCase):
|
|
async def asyncNoop(self, timer_id: str, label: str) -> None:
|
|
return None
|
|
|
|
async def test_set_persists_and_cancel_updates_file(self) -> None:
|
|
from javis.core.timer import TimerService
|
|
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "timers.json"
|
|
svc = TimerService(self.asyncNoop, persist_path=path)
|
|
tid = svc.set_timer("test", 3600)
|
|
try:
|
|
self.assertTrue(path.exists())
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
self.assertEqual(len(data["timers"]), 1)
|
|
self.assertTrue(svc.cancel_timer(tid))
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
self.assertEqual(len(data["timers"]), 0)
|
|
finally:
|
|
svc.stop()
|
|
|
|
async def test_restore_reschedules_pending_and_drops_expired(self) -> None:
|
|
import time as _time
|
|
from javis.core.timer import TimerService
|
|
|
|
with tempfile.TemporaryDirectory() as td:
|
|
path = Path(td) / "timers.json"
|
|
now = _time.time()
|
|
path.write_text(json.dumps({
|
|
"version": 1,
|
|
"timers": [
|
|
{"id": "pending", "label": "future", "duration_s": 3600,
|
|
"fire_at": now + 3600},
|
|
{"id": "expired", "label": "past", "duration_s": 60,
|
|
"fire_at": now - 10},
|
|
],
|
|
}), encoding="utf-8")
|
|
svc = TimerService(self.asyncNoop, persist_path=path)
|
|
await svc.restore()
|
|
try:
|
|
active = svc.list_timers()
|
|
self.assertEqual(len(active), 1)
|
|
self.assertEqual(active[0]["id"], "pending")
|
|
finally:
|
|
svc.stop()
|
|
|
|
|
|
class FinetuneSignatureTests(unittest.IsolatedAsyncioTestCase):
|
|
async def test_run_pipeline_receives_profile_dir_not_config(self) -> None:
|
|
from javis.voices.manager import VoiceManager
|
|
import javis.voices.piper_train as pt
|
|
|
|
with tempfile.TemporaryDirectory() as td:
|
|
config = Config()
|
|
config.paths = PathsConfig(base_dir=Path(td))
|
|
config.paths.ensure()
|
|
mgr = VoiceManager(config)
|
|
prof = mgr.create("tv")
|
|
assert prof.dir is not None
|
|
(prof.dir / "training").mkdir(parents=True, exist_ok=True)
|
|
(prof.dir / "training" / "metadata.csv").write_text(
|
|
"clip_0|hallo|hallo\n", encoding="utf-8")
|
|
|
|
captured: dict = {}
|
|
|
|
def fake_run_pipeline(voice_dir, **kwargs):
|
|
captured["voice_dir"] = voice_dir
|
|
captured["kwargs"] = kwargs
|
|
return 0
|
|
|
|
from javis.voices import finetune
|
|
with patch.object(pt, "run_pipeline", fake_run_pipeline):
|
|
rc = await finetune.finetune_voice(config, "tv", language="de")
|
|
|
|
self.assertEqual(rc, 0)
|
|
self.assertEqual(captured["voice_dir"], prof.dir)
|
|
# The old bug passed the Config as voice_dir and voice_name as language.
|
|
self.assertNotIsInstance(captured["voice_dir"], Config)
|
|
self.assertEqual(captured["kwargs"].get("language"), "de")
|
|
|
|
|
|
# ── wake-word barge-in (interrupt TTS playback with the wake phrase) ──────
|
|
|
|
|
|
class BargeInConfigTests(unittest.TestCase):
|
|
"""The new WakeWordConfig.barge_in_enabled flag defaults to True."""
|
|
|
|
def test_barge_in_enabled_defaults_true(self) -> None:
|
|
from javis.core.config import WakeWordConfig
|
|
|
|
self.assertTrue(WakeWordConfig().barge_in_enabled)
|
|
|
|
|
|
class BargeInListenerTests(unittest.IsolatedAsyncioTestCase):
|
|
"""_barge_in_listener: on wake-word trigger it cancels playback, resets
|
|
the detector, sets the barge-in flag, and publishes the event."""
|
|
|
|
async def test_trigger_cancels_playback_and_sets_flag(self) -> None:
|
|
from javis.agent.orchestrator import Orchestrator
|
|
from javis.core.state import AgentState
|
|
|
|
orch = object.__new__(Orchestrator)
|
|
|
|
class FakeWake:
|
|
def __init__(self) -> None:
|
|
self.feed_calls = 0
|
|
self.reset_calls = 0
|
|
|
|
def feed(self, chunk):
|
|
self.feed_calls += 1
|
|
return 0.9 if self.feed_calls == 1 else 0.1
|
|
|
|
def triggered(self, score):
|
|
return score >= 0.5
|
|
|
|
def reset(self):
|
|
self.reset_calls += 1
|
|
|
|
class FakeMic:
|
|
def __init__(self) -> None:
|
|
self.drain_calls = 0
|
|
|
|
def drain(self):
|
|
self.drain_calls += 1
|
|
|
|
async def chunks(self, stale_timeout=None):
|
|
yield b"\x00"
|
|
|
|
class FakeBus:
|
|
def __init__(self) -> None:
|
|
self.events = []
|
|
|
|
async def publish(self, name, payload=None):
|
|
self.events.append((name, payload))
|
|
|
|
orch.wake = FakeWake()
|
|
orch.mic = FakeMic()
|
|
orch.bus = FakeBus()
|
|
orch._stop = asyncio.Event()
|
|
orch._barge_in_requested = False
|
|
orch._barge_in_task = None
|
|
|
|
interrupt_calls = []
|
|
state_calls = []
|
|
with patch.object(Orchestrator, "interrupt",
|
|
lambda self: interrupt_calls.append(1)), \
|
|
patch.object(Orchestrator, "set_state",
|
|
lambda self, s: state_calls.append(s) or asyncio.sleep(0)), \
|
|
patch.object(Orchestrator, "_play_wake_chime",
|
|
lambda self: asyncio.sleep(0)):
|
|
await orch._barge_in_listener()
|
|
|
|
# Trigger fired on the first chunk: listener must have exited.
|
|
self.assertTrue(orch._barge_in_requested)
|
|
self.assertEqual(len(interrupt_calls), 1)
|
|
self.assertEqual(state_calls, [AgentState.WAKE])
|
|
# reset() is called once at arming and once after the trigger.
|
|
self.assertEqual(orch.wake.reset_calls, 2)
|
|
self.assertEqual(orch.mic.drain_calls, 1)
|
|
self.assertIn(("barge_in", 0.9), orch.bus.events)
|
|
# Chime was scheduled via ensure_future — let it finish.
|
|
await asyncio.sleep(0)
|
|
|
|
|
|
class BargeInRunConsumptionTests(unittest.IsolatedAsyncioTestCase):
|
|
"""run(): when _barge_in_requested is set, the main loop must skip
|
|
_wait_for_wake, drain the mic tail, and start the turn with followup
|
|
grace instead of the default 1500 ms."""
|
|
|
|
async def test_run_consumes_barge_in_flag(self) -> None:
|
|
from javis.agent.orchestrator import Orchestrator
|
|
|
|
orch = object.__new__(Orchestrator)
|
|
orch.stt = object()
|
|
orch.mic = object()
|
|
orch.wake = object()
|
|
orch._stop = asyncio.Event()
|
|
orch._turn_lock = asyncio.Lock()
|
|
orch._barge_in_requested = True
|
|
orch._barge_in_task = None
|
|
orch._followup_pending = False
|
|
orch.config = Config()
|
|
orch.profiler = None
|
|
|
|
class FakeMic:
|
|
def __init__(self) -> None:
|
|
self.drain_calls = 0
|
|
|
|
def drain(self):
|
|
self.drain_calls += 1
|
|
|
|
orch.mic = FakeMic()
|
|
|
|
class FakeBus:
|
|
def __init__(self) -> None:
|
|
self.events = []
|
|
|
|
async def publish(self, name, payload=None):
|
|
self.events.append((name, payload))
|
|
|
|
orch.bus = FakeBus()
|
|
|
|
wake_calls = []
|
|
turn_calls = []
|
|
|
|
async def fake_handle_turn(self, initial_grace_ms=1500):
|
|
turn_calls.append(initial_grace_ms)
|
|
self._stop.set() # end the run() loop after the first turn
|
|
|
|
with patch.object(Orchestrator, "_wait_for_wake",
|
|
lambda self: wake_calls.append(1)), \
|
|
patch.object(Orchestrator, "_handle_turn", fake_handle_turn), \
|
|
patch.object(Orchestrator, "stop",
|
|
lambda self: asyncio.sleep(0)), \
|
|
patch.object(Orchestrator, "set_state",
|
|
lambda self, s: asyncio.sleep(0)):
|
|
await orch.run()
|
|
|
|
self.assertFalse(orch._barge_in_requested)
|
|
self.assertEqual(orch.mic.drain_calls, 1)
|
|
self.assertEqual(wake_calls, [])
|
|
self.assertEqual(turn_calls, [int(orch.config.wake_word.followup_grace_s * 1000)])
|
|
self.assertIn(("barge_in_listen", {}), orch.bus.events)
|
|
|
|
|
|
class BargeInEmptyUtteranceTests(unittest.IsolatedAsyncioTestCase):
|
|
"""_handle_turn: an empty capture must publish stt_done(empty) + stt_empty
|
|
and reset the state to IDLE (previously the GUI stayed stuck on
|
|
'listening')."""
|
|
|
|
async def test_empty_utterance_resets_to_idle(self) -> None:
|
|
from javis.agent.orchestrator import Orchestrator
|
|
from javis.core.state import AgentState
|
|
|
|
orch = object.__new__(Orchestrator)
|
|
orch.stt = object()
|
|
orch.mic = object()
|
|
orch.vad = object()
|
|
orch.config = Config()
|
|
orch._last_response_is_question = False
|
|
orch.profiler = None
|
|
|
|
class FakeBus:
|
|
def __init__(self) -> None:
|
|
self.events = []
|
|
|
|
async def publish(self, name, payload=None):
|
|
self.events.append((name, payload))
|
|
|
|
orch.bus = FakeBus()
|
|
|
|
state_calls = []
|
|
with patch("javis.agent.orchestrator.record_utterance",
|
|
new=lambda *a, **kw: asyncio.sleep(0, result=None)), \
|
|
patch.object(Orchestrator, "set_state",
|
|
lambda self, s: state_calls.append(s) or asyncio.sleep(0)):
|
|
await orch._handle_turn(initial_grace_ms=1500)
|
|
|
|
self.assertIn(("stt_done", {"text": "", "audio_s": 0.0, "latency_s": 0.0, "empty": True}),
|
|
orch.bus.events)
|
|
self.assertIn(("stt_empty", {}), orch.bus.events)
|
|
self.assertEqual(state_calls, [AgentState.LISTENING, AgentState.IDLE])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|