"""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()