Initial commit: Javis project

This commit is contained in:
Javis committed 2026-09-21 21:44:00 +02:00
commit 435c645f36
164 files changed
+35279

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
"""Offscreen functional check for the GUI retheme registry.
Single thread (Qt widgets must be driven on their creating thread):
- Patch the modal startup dialog to return the config immediately.
- Patch ``qasync.QEventLoop.run_forever`` to grab the top-level window and raise
a sentinel BEFORE the GUI loop actually runs.
- Call ``run_gui``, catch the sentinel (window is now fully constructed), then
read a registered themed widget's style in dark, trigger the REAL light/dark
toggle (``_theme_act`` -> ``_on_theme_toggle`` -> ``_apply_theme`` ->
``retheme``), and assert the style changed and round-trips.
Run: QT_QPA_PLATFORM=offscreen .\\.venv\\Scripts\\python.exe tests\\_retheme_check.py
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6 import QtWidgets # noqa: E402
import qasync # noqa: E402
from javis.core.config import load_config # noqa: E402
import javis.gui.main_window as mw # noqa: E402
class _Stop(Exception):
pass
STATE: dict = {"win": None}
def _probe(win) -> None:
"""Drive the window: capture dark, toggle to light, assert it changed."""
if not getattr(win, "_themed", None):
raise AssertionError("retheme registry (_themed) is empty!")
sample = None
for w, factory in win._themed:
try:
factory()
except Exception:
continue
sample = (w, factory)
break
if sample is None:
w, factory = win._themed[0]
sample = (w, factory)
w, factory = sample
app = QtWidgets.QApplication.instance()
app.processEvents()
dark_style = w.styleSheet()
print(f"[probe] themed widget: {type(w).__name__}")
print(f"[probe] dark style = {dark_style!r}")
act = win._theme_act
act.setChecked(True) # -> light
app.processEvents()
light_style = w.styleSheet()
print(f"[probe] light style = {light_style!r}")
assert light_style != dark_style, (
"BUG: toggling theme did not change the registered widget's stylesheet. "
f"dark={dark_style!r} light={light_style!r}"
)
act.setChecked(False) # -> dark
app.processEvents()
back_dark = w.styleSheet()
print(f"[probe] dark (again) = {back_dark!r}")
assert back_dark == dark_style, (
f"BUG: toggling back did not restore the dark style. "
f"{back_dark!r} != {dark_style!r}"
)
print("\n[probe] OK — retheme registry is theme-reactive (dark->light->dark round-trip).")
def main() -> int:
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
config = load_config(Path("config.yaml"))
mw.show_startup_dialog = lambda cfg: cfg # type: ignore[assignment]
def _run_forever(self):
tops = [w for w in QtWidgets.QApplication.topLevelWidgets()
if isinstance(w, QtWidgets.QMainWindow) and getattr(w, "retheme", None)]
if tops:
STATE["win"] = tops[-1]
raise _Stop()
qasync.QEventLoop.run_forever = _run_forever # type: ignore[assignment]
try:
mw.run_gui(config)
except _Stop:
pass
if STATE["win"] is None:
print("[FAIL] could not capture the main window")
return 1
try:
_probe(STATE["win"])
except Exception as e: # noqa: BLE001
print(f"[FAIL] {e!r}")
return 1
print("\nRESULT: PASS")
return 0
if __name__ == "__main__":
sys.exit(main())
+404
View File
@@ -0,0 +1,404 @@
"""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()
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import httpx
from javis.agent.middleware import MemoryMiddleware, TurnContext
from javis.core.config import Config, PathsConfig
from javis.llm.hermes_client import HermesClient
from javis.llm.ollama_client import ChatResponse
class HermesClientTests(unittest.IsolatedAsyncioTestCase):
async def test_chat_uses_bearer_auth_and_openai_payload(self) -> None:
config = Config()
config.memory.provider = "hermes"
config.memory.base_url = "http://hermes.test:30432"
config.memory.model = "hermes-agent"
config.memory.api_key_env = "TEST_HERMES_KEY"
captured: dict = {}
async def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["authorization"] = request.headers.get("Authorization")
captured["payload"] = json.loads(request.content)
return httpx.Response(
200,
json={
"choices": [
{"message": {"role": "assistant", "content": "[]"}}
]
},
)
client = HermesClient(config)
await client._client.aclose()
client._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
try:
with patch.dict(os.environ, {"TEST_HERMES_KEY": "test-secret"}):
response = await client.chat(
[{"role": "user", "content": "Extract memories"}],
temperature=0.0,
)
finally:
await client.aclose()
self.assertEqual(captured["url"], "http://hermes.test:30432/v1/chat/completions")
self.assertEqual(captured["authorization"], "Bearer test-secret")
self.assertEqual(captured["payload"]["model"], "hermes-agent")
self.assertFalse(captured["payload"]["stream"])
self.assertEqual(response.content, "[]")
async def test_list_models_is_authenticated(self) -> None:
config = Config()
config.memory.provider = "hermes"
config.memory.base_url = "http://hermes.test:30432"
config.memory.api_key_env = "TEST_HERMES_KEY"
authorization = ""
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal authorization
authorization = request.headers.get("Authorization", "")
return httpx.Response(200, json={"data": [{"id": "hermes-agent"}]})
client = HermesClient(config)
await client._client.aclose()
client._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
try:
with patch.dict(os.environ, {"TEST_HERMES_KEY": "test-secret"}):
models = await client.list_models()
finally:
await client.aclose()
self.assertEqual(authorization, "Bearer test-secret")
self.assertEqual(models, ["hermes-agent"])
class FakeMemoryClient:
async def chat(self, messages: list[dict], **kwargs) -> ChatResponse:
return ChatResponse(content=json.dumps([
{
"key": "music_preference",
"value": "Florian prefers jazz music",
"category": "preferences",
}
]))
class HermesMemoryMiddlewareTests(unittest.IsolatedAsyncioTestCase):
async def test_short_explicit_request_is_extracted_and_persisted(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
config = Config(paths=PathsConfig(base_dir=Path(temporary_directory)))
config.memory.provider = "hermes"
config.memory.model = "hermes-agent"
middleware = MemoryMiddleware(FakeMemoryClient(), config)
context = TurnContext(turn_id="turn-1", speaker_name="Florian")
await middleware.on_finish(
context,
"Merke dir: Ich mag Jazz.",
"Das merke ich mir.",
)
self.assertEqual(len(context._deferred), 1)
await context._deferred[0]
personal_file = Path(temporary_directory) / "memory_florian.json"
stored = json.loads(personal_file.read_text(encoding="utf-8"))
self.assertEqual(
stored["entries"]["music_preference"]["value"],
"Florian prefers jazz music",
)
if __name__ == "__main__":
unittest.main()