v1.0
This commit is contained in:
1 parent
0246cbb6ba
commit
0ca4b4bf7a
89 files changed
+7042
-2480
No files matched your search
@@ -79,6 +79,12 @@ def _probe(win) -> None:
|
||||
|
||||
print("\n[probe] OK — retheme registry is theme-reactive (dark->light->dark round-trip).")
|
||||
|
||||
# GUI-01 regression: voice-profile cards used undefined names.
|
||||
card = win._make_voice_card(
|
||||
{"name": "Alice", "clip_count": 3, "enrolled_at": "2026-01-01T00:00:00"})
|
||||
assert card.layout() is not None and card.layout().count() == 2, "voice card layout broken"
|
||||
print("[probe] OK — voice-profile card renders.")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
"""Regression tests for the fixes listed in docs/AUDIT.md."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from javis.core.config import Config, PathsConfig, load_config
|
||||
|
||||
|
||||
class SchemaBridgeTests(unittest.TestCase):
|
||||
"""MCP-01 / MCP-03."""
|
||||
|
||||
def test_input_schema_read_from_snake_and_camel_case(self) -> None:
|
||||
from javis.mcp_client.schema_bridge import mcp_tool_to_ollama
|
||||
|
||||
schema = {"type": "object", "properties": {"q": {"type": "string"}}}
|
||||
v2 = SimpleNamespace(name="search", description="d", input_schema=schema)
|
||||
v1 = SimpleNamespace(name="search", description="d", inputSchema=schema)
|
||||
for tool in (v2, v1):
|
||||
entry = mcp_tool_to_ollama("ddg", tool)
|
||||
self.assertEqual(entry["function"]["parameters"], schema)
|
||||
self.assertEqual(entry["function"]["name"], "ddg__search")
|
||||
|
||||
def test_real_mcp_tool_schema_is_forwarded(self) -> None:
|
||||
import mcp.types as t
|
||||
from javis.mcp_client.schema_bridge import mcp_tool_to_ollama
|
||||
|
||||
schema = {"type": "object", "properties": {"a": {"type": "string"}}}
|
||||
fields = t.Tool.model_fields
|
||||
key = "input_schema" if "input_schema" in fields else "inputSchema"
|
||||
tool = t.Tool(name="x", **{key: schema})
|
||||
self.assertEqual(mcp_tool_to_ollama("s", tool)["function"]["parameters"], schema)
|
||||
|
||||
def test_bare_server_name_is_not_mapped_to_a_tool(self) -> None:
|
||||
from javis.mcp_client.schema_bridge import validate_tool_name
|
||||
|
||||
self.assertEqual(validate_tool_name("homey", {"homey": ["delete_all"]}), ("", "homey"))
|
||||
self.assertEqual(validate_tool_name("homey__find_devices"), ("homey", "find_devices"))
|
||||
|
||||
|
||||
class ResultToTextTests(unittest.TestCase):
|
||||
def test_is_error_detected_for_both_sdk_generations(self) -> None:
|
||||
from javis.mcp_client.host import _result_to_text
|
||||
|
||||
content = [SimpleNamespace(text="boom")]
|
||||
self.assertEqual(_result_to_text(SimpleNamespace(content=content, is_error=True)), "ERROR: boom")
|
||||
self.assertEqual(_result_to_text(SimpleNamespace(content=content, isError=True)), "ERROR: boom")
|
||||
self.assertEqual(_result_to_text(SimpleNamespace(content=content, is_error=False)), "boom")
|
||||
|
||||
|
||||
def _host_with_fake_server(approval: str = "ask"):
|
||||
from javis.mcp_client.host import ConnectedServer, MCPHost, ServerSpec
|
||||
|
||||
calls: list[tuple[str, dict]] = []
|
||||
|
||||
class FakeSession:
|
||||
async def call_tool(self, tool, arguments):
|
||||
calls.append((tool, arguments))
|
||||
return SimpleNamespace(content=[SimpleNamespace(text="ok")], is_error=False)
|
||||
|
||||
host = MCPHost(Config())
|
||||
spec = ServerSpec(name="fs", command="x", approval=approval)
|
||||
host.servers["fs"] = ConnectedServer(spec=spec, session=FakeSession(),
|
||||
tools=[SimpleNamespace(name="write")])
|
||||
return host, calls
|
||||
|
||||
|
||||
class ApprovalTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""MCP-02: "Allow once" must not become "Always allow"."""
|
||||
|
||||
async def test_allow_once_lasts_only_for_the_turn(self) -> None:
|
||||
host, calls = _host_with_fake_server()
|
||||
asked: list[str] = []
|
||||
|
||||
async def approve(server, tool, args):
|
||||
asked.append(tool)
|
||||
return True
|
||||
|
||||
host.approval_cb = approve
|
||||
self.assertEqual(await host.call("fs__write", {"p": 1}), "ok")
|
||||
self.assertEqual(await host.call("fs__write", {"p": 2}), "ok")
|
||||
self.assertEqual(len(asked), 1) # same turn: not asked again
|
||||
host.clear_denials() # next turn
|
||||
await host.call("fs__write", {"p": 3})
|
||||
self.assertEqual(len(asked), 2)
|
||||
self.assertEqual(len(calls), 3)
|
||||
|
||||
async def test_always_allow_persists(self) -> None:
|
||||
host, _ = _host_with_fake_server()
|
||||
asked: list[str] = []
|
||||
|
||||
async def approve(server, tool, args):
|
||||
asked.append(tool)
|
||||
return True
|
||||
|
||||
host.approval_cb = approve
|
||||
host.allow_tool("fs", "write")
|
||||
host.clear_denials()
|
||||
await host.call("fs__write", {})
|
||||
self.assertEqual(asked, [])
|
||||
|
||||
async def test_bare_server_name_does_not_execute_anything(self) -> None:
|
||||
host, calls = _host_with_fake_server(approval="allow")
|
||||
result = await host.call("fs", {"p": 1})
|
||||
self.assertTrue(result.startswith("ERROR:"))
|
||||
self.assertIn("fs__write", result)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
async def test_protocol_error_is_not_retried(self) -> None:
|
||||
from javis.mcp_client import host as host_mod
|
||||
|
||||
host, _ = _host_with_fake_server(approval="allow")
|
||||
attempts = []
|
||||
|
||||
class FailingSession:
|
||||
async def call_tool(self, tool, arguments):
|
||||
attempts.append(tool)
|
||||
raise host_mod._MCPError(-32602, "invalid params")
|
||||
|
||||
host.servers["fs"].session = FailingSession()
|
||||
|
||||
async def no_reconnect(*a, **kw): # pragma: no cover - must not run
|
||||
raise AssertionError("protocol errors must not trigger a reconnect")
|
||||
|
||||
with patch.object(host_mod.MCPHost, "_reconnect", no_reconnect):
|
||||
result = await host.call("fs__write", {})
|
||||
self.assertTrue(result.startswith("ERROR:"))
|
||||
self.assertEqual(attempts, ["write"])
|
||||
|
||||
async def test_destructive_tool_asks_even_on_allow_server(self) -> None:
|
||||
host, calls = _host_with_fake_server(approval="allow")
|
||||
host.servers["fs"].tools = [
|
||||
SimpleNamespace(name="write", annotations=SimpleNamespace(destructive_hint=True)),
|
||||
SimpleNamespace(name="read", annotations=SimpleNamespace(
|
||||
read_only_hint=True, destructive_hint=True)),
|
||||
]
|
||||
asked: list[str] = []
|
||||
|
||||
async def deny(server, tool, args):
|
||||
asked.append(tool)
|
||||
return False
|
||||
|
||||
host.approval_cb = deny
|
||||
self.assertTrue((await host.call("fs__write", {})).startswith("ERROR:"))
|
||||
self.assertEqual(await host.call("fs__read", {}), "ok") # read-only wins
|
||||
self.assertEqual(asked, ["write"])
|
||||
self.assertEqual(calls, [("read", {})])
|
||||
|
||||
host.config.mcp.confirm_destructive = False
|
||||
host.clear_denials()
|
||||
self.assertEqual(await host.call("fs__write", {}), "ok")
|
||||
self.assertEqual(asked, ["write"])
|
||||
|
||||
|
||||
class ConversationTrimTests(unittest.TestCase):
|
||||
"""LLM-02: the in-progress turn is never trimmed away."""
|
||||
|
||||
def test_current_turn_survives_oversized_tool_results(self) -> None:
|
||||
from javis.llm.conversation import Conversation
|
||||
|
||||
conv = Conversation(system_prompt="s" * 100, max_turns=20, max_chars=500)
|
||||
conv.add_user("old question")
|
||||
conv.add_assistant("old answer")
|
||||
conv.add_user("current question")
|
||||
conv.add_assistant("", tool_calls=[{"function": {"name": "a__b", "arguments": {}}}])
|
||||
conv.add_tool_result("a__b", "x" * 2000)
|
||||
roles = [m["role"] for m in conv.history]
|
||||
self.assertEqual(roles, ["user", "assistant", "tool"])
|
||||
self.assertEqual(conv.history[0]["content"], "current question")
|
||||
|
||||
|
||||
class QuickEngineValidationTests(unittest.TestCase):
|
||||
def test_invalid_regex_rejected_and_never_breaks_matching(self) -> None:
|
||||
from javis.agent.quick_engine import QuickAction, QuickCommand, QuickEngine
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
q = QuickEngine(Path(d) / "q.json")
|
||||
with self.assertRaises(ValueError):
|
||||
q.add("([unclosed", "regex", [{"tool": "a__b", "args": {}}])
|
||||
with self.assertRaises(ValueError):
|
||||
q.add("x", "fuzzy", [{"tool": "a__b", "args": {}}])
|
||||
# A bad pattern already on disk must be skipped, not raise.
|
||||
q._commands["bad"] = QuickCommand("bad", "([", "regex", [QuickAction("a__b")])
|
||||
q.add("licht an", "contains", [{"tool": "homey__on", "args": {}}])
|
||||
self.assertIsNotNone(q.match("licht an"))
|
||||
|
||||
|
||||
class QuickPrivilegeTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""AGT-01: quick commands honour speaker privileges."""
|
||||
|
||||
async def test_denied_action_skips_quick_path(self) -> None:
|
||||
from javis.agent.orchestrator import Orchestrator
|
||||
from javis.agent.quick_engine import QuickEngine
|
||||
from javis.stt.voice_profiler import SpeakerPrivileges
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
orch = object.__new__(Orchestrator)
|
||||
orch.quick = QuickEngine(Path(d) / "q.json")
|
||||
orch.quick.add("licht an", "contains", [{"tool": "homey__on", "args": {}}])
|
||||
executed = []
|
||||
|
||||
class FakeMCP:
|
||||
async def call(self, name, args):
|
||||
executed.append(name)
|
||||
return "ok"
|
||||
|
||||
orch.mcp = FakeMCP()
|
||||
guest = SpeakerPrivileges(denied_tools=["homey__*"])
|
||||
ran = await orch._try_quick_response("licht an", privileges=guest)
|
||||
self.assertFalse(ran)
|
||||
self.assertEqual(executed, [])
|
||||
|
||||
|
||||
class StopIdempotentTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""AGT-03: concurrent stop() calls tear down exactly once."""
|
||||
|
||||
async def test_concurrent_stop_runs_teardown_once(self) -> None:
|
||||
from javis.agent.orchestrator import Orchestrator
|
||||
|
||||
orch = object.__new__(Orchestrator)
|
||||
orch._stop = asyncio.Event()
|
||||
orch._stop_lock = asyncio.Lock()
|
||||
orch._stopped = False
|
||||
orch.mic = None
|
||||
orch._barge_in_task = None
|
||||
counts = {"mcp": 0, "llm": 0}
|
||||
|
||||
class Timers:
|
||||
async def aclose(self):
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
class MCP:
|
||||
async def stop(self):
|
||||
counts["mcp"] += 1
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
class LLM:
|
||||
async def aclose(self):
|
||||
counts["llm"] += 1
|
||||
|
||||
orch.timer_service = Timers()
|
||||
orch.mcp = MCP()
|
||||
orch.llm = orch.memory_llm = LLM()
|
||||
await asyncio.gather(orch.stop(), orch.stop(), orch.stop())
|
||||
self.assertEqual(counts, {"mcp": 1, "llm": 1})
|
||||
|
||||
|
||||
class PcmWriterTests(unittest.IsolatedAsyncioTestCase):
|
||||
"""AGT-06: cancelling playback stops the writer thread before returning."""
|
||||
|
||||
async def test_cancel_waits_for_writer_thread(self) -> None:
|
||||
import threading
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from javis.audio_io.stream import write_pcm_blocks
|
||||
|
||||
writes = []
|
||||
closed = threading.Event()
|
||||
|
||||
class FakeStream:
|
||||
def write(self, block):
|
||||
if closed.is_set():
|
||||
raise AssertionError("write after close")
|
||||
time.sleep(0.01)
|
||||
writes.append(len(block))
|
||||
|
||||
task = asyncio.ensure_future(
|
||||
write_pcm_blocks(FakeStream(), np.zeros((16000, 1), np.float32), 160))
|
||||
await asyncio.sleep(0.05)
|
||||
task.cancel()
|
||||
with self.assertRaises(asyncio.CancelledError):
|
||||
await task
|
||||
closed.set() # caller would close the stream now
|
||||
n = len(writes)
|
||||
await asyncio.sleep(0.05)
|
||||
self.assertEqual(len(writes), n)
|
||||
self.assertLess(n, 100)
|
||||
|
||||
|
||||
class ConsoleLaunchTests(unittest.TestCase):
|
||||
"""GUI-04: every argument is quoted for cmd.exe."""
|
||||
|
||||
def test_metacharacters_are_quoted(self) -> None:
|
||||
from javis.gui.main_window import _launch_in_console
|
||||
|
||||
with patch("subprocess.Popen") as popen:
|
||||
_launch_in_console(["C:\\Py thon\\python.exe", "--voice", "a&calc"], cwd=".")
|
||||
line = popen.call_args.args[0]
|
||||
self.assertEqual(line, 'cmd.exe /k ""C:\\Py thon\\python.exe" "--voice" "a&calc""')
|
||||
with self.assertRaises(ValueError):
|
||||
_launch_in_console(['x"y'], cwd=".")
|
||||
|
||||
|
||||
class VoiceImportTests(unittest.TestCase):
|
||||
"""VOI-01: pickle checkpoints are not imported by default."""
|
||||
|
||||
def test_checkpoints_skipped(self) -> None:
|
||||
from javis.voices.io import import_profile
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
arc = Path(d) / "v.javisvoice"
|
||||
with zipfile.ZipFile(arc, "w") as zf:
|
||||
zf.writestr("MANIFEST.json", json.dumps({"kind": "javis.voice_profile", "name": "v"}))
|
||||
zf.writestr("profile.json", "{}")
|
||||
zf.writestr("refs/a.wav", b"RIFF")
|
||||
zf.writestr("training/piper_lightning/last.ckpt", b"\x80\x04evil")
|
||||
zf.writestr("training/base_checkpoint.txt", "C:/x.ckpt")
|
||||
res = import_profile(arc, Path(d) / "voices")
|
||||
self.assertTrue((res.path / "refs" / "a.wav").exists())
|
||||
self.assertFalse((res.path / "training" / "piper_lightning" / "last.ckpt").exists())
|
||||
self.assertFalse((res.path / "training" / "base_checkpoint.txt").exists())
|
||||
|
||||
|
||||
class VoiceExportConfigTests(unittest.TestCase):
|
||||
"""An exported model needs its .onnx.json even after piper_config.json was deleted."""
|
||||
|
||||
def test_voice_config_rebuilt_from_training_run(self) -> None:
|
||||
try:
|
||||
from piper.config import PiperConfig
|
||||
except ImportError:
|
||||
self.skipTest("piper-tts not installed")
|
||||
from javis.voices.piper_train import _rebuild_voice_config
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
run = Path(d) / "lightning_logs" / "version_0"
|
||||
run.mkdir(parents=True)
|
||||
(run / "config.yaml").write_text(
|
||||
"data:\n espeak_voice: de\n sample_rate: 22050\n num_symbols: 256\n"
|
||||
" phoneme_type: null\n phonemes_path: null\n vowel_clusters: null\n",
|
||||
encoding="utf-8")
|
||||
dest = Path(d) / "piper_model.onnx.json"
|
||||
self.assertTrue(_rebuild_voice_config(Path(d), dest))
|
||||
cfg = PiperConfig.from_dict(json.loads(dest.read_text(encoding="utf-8")))
|
||||
self.assertEqual((cfg.espeak_voice, cfg.sample_rate, cfg.num_symbols), ("de", 22050, 256))
|
||||
self.assertIn("a", cfg.phoneme_id_map)
|
||||
|
||||
(run / "config.yaml").write_text(
|
||||
"data:\n espeak_voice: de\n phonemes_path: custom.json\n", encoding="utf-8")
|
||||
self.assertFalse(_rebuild_voice_config(Path(d), dest))
|
||||
|
||||
|
||||
class ConfigValidationTests(unittest.TestCase):
|
||||
def test_sample_rate_and_threshold_are_validated(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
p = Path(d) / "config.yaml"
|
||||
p.write_text(
|
||||
f"paths: {{base_dir: '{Path(d).as_posix()}'}}\n"
|
||||
"audio: {sample_rate: 48000}\nspeaker_id: {threshold: 3}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
cfg = load_config(p)
|
||||
self.assertEqual(cfg.audio.sample_rate, 16000)
|
||||
self.assertEqual(cfg.speaker_id.threshold, 0.65)
|
||||
|
||||
def test_non_mapping_root_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
p = Path(d) / "config.yaml"
|
||||
p.write_text("- just\n- a list\n", encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
load_config(p)
|
||||
|
||||
|
||||
class ApiBodyLimitTests(unittest.TestCase):
|
||||
"""API-01: oversized / unsized bodies are rejected before parsing."""
|
||||
|
||||
def test_body_size_guard(self) -> None:
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from javis.api.devices import SlaveDeviceStore
|
||||
from javis.api.pairing import PairingCodeManager
|
||||
from javis.api.server import build_app
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
cfg = Config(paths=PathsConfig(base_dir=Path(d)))
|
||||
cfg.api.max_upload_bytes = 1000
|
||||
app = build_app(cfg, orchestrator=None, device_store=SlaveDeviceStore(Path(d) / "s.json"),
|
||||
pairing_mgr=PairingCodeManager())
|
||||
client = TestClient(app)
|
||||
big = client.post("/api/v1/turn", content=b"x" * 200_000,
|
||||
headers={"content-type": "application/octet-stream"})
|
||||
self.assertEqual(big.status_code, 413)
|
||||
unauth = client.post("/api/v1/turn", files={"audio": ("a.wav", b"RIFF", "audio/wav")})
|
||||
self.assertEqual(unauth.status_code, 401)
|
||||
self.assertEqual(client.get("/api/v1/health").status_code, 200)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Tests for duckduckgo-mcp (run with duckduckgo-mcp/.venv: needs mcp 1.x FastMCP).
|
||||
|
||||
duckduckgo-mcp\\.venv\\Scripts\\python.exe -m pytest tests/test_duckduckgo_mcp.py -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("mcp.server.fastmcp")
|
||||
pytest.importorskip("duckduckgo_mcp")
|
||||
|
||||
from mcp.server.fastmcp.exceptions import ToolError # noqa: E402
|
||||
|
||||
from duckduckgo_mcp import server as srv # noqa: E402
|
||||
|
||||
|
||||
class FakeDDGS:
|
||||
def __init__(self, fail: Exception | None = None):
|
||||
self.fail = fail
|
||||
self.threads: list[str] = []
|
||||
|
||||
def text(self, query, **kwargs):
|
||||
self.threads.append(threading.current_thread().name)
|
||||
if self.fail:
|
||||
raise self.fail
|
||||
return [{"title": "T", "href": "https://example.org", "body": "B"}]
|
||||
|
||||
def news(self, query, **kwargs):
|
||||
return self.text(query, **kwargs)
|
||||
|
||||
|
||||
def test_search_runs_off_the_event_loop(monkeypatch):
|
||||
fake = FakeDDGS()
|
||||
monkeypatch.setattr(srv, "_get_ddgs", lambda: fake)
|
||||
out = asyncio.run(srv.mcp.call_tool("ddg_search", {"query": "python"}))
|
||||
assert "example.org" in str(out)
|
||||
assert fake.threads and fake.threads[0] != threading.main_thread().name
|
||||
|
||||
|
||||
def test_failures_are_tool_errors(monkeypatch):
|
||||
monkeypatch.setattr(srv, "_get_ddgs", lambda: FakeDDGS(RuntimeError("Ratelimit")))
|
||||
with pytest.raises(ToolError, match="Ratelimit"):
|
||||
asyncio.run(srv.mcp.call_tool("ddg_search", {"query": "python"}))
|
||||
with pytest.raises(ToolError, match="Ratelimit"):
|
||||
asyncio.run(srv.mcp.call_tool("ddg_answers", {"query": "python"}))
|
||||
|
||||
|
||||
def test_input_validation(monkeypatch):
|
||||
monkeypatch.setattr(srv, "_get_ddgs", lambda: FakeDDGS())
|
||||
with pytest.raises(ToolError, match="empty"):
|
||||
asyncio.run(srv.mcp.call_tool("ddg_news", {"query": " "}))
|
||||
with pytest.raises(ToolError, match="safesearch"):
|
||||
asyncio.run(srv.mcp.call_tool("ddg_search", {"query": "x", "safesearch": "strict"}))
|
||||
|
||||
|
||||
def test_tool_annotations():
|
||||
for t in asyncio.run(srv.mcp.list_tools()):
|
||||
assert t.annotations.readOnlyHint is True and t.annotations.openWorldHint is True, t.name
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Tests for emby-mcp (run with emby-mcp/.venv: needs mcp 1.x FastMCP).
|
||||
|
||||
emby-mcp\\.venv\\Scripts\\python.exe -m pytest tests/test_emby_mcp.py -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("mcp.server.fastmcp")
|
||||
pytest.importorskip("emby_mcp")
|
||||
|
||||
import httpx # noqa: E402
|
||||
|
||||
from emby_mcp import server as srv # noqa: E402
|
||||
from emby_mcp.client import EmbyClient # noqa: E402
|
||||
from emby_mcp.config import Settings # noqa: E402
|
||||
|
||||
SESSIONS = [
|
||||
{"Id": "s-tv", "DeviceName": "Living Room TV", "Client": "Emby Theater", "SupportsRemoteControl": True},
|
||||
{"Id": "s-srv", "DeviceName": "Tracearr Server", "Client": "Tracearr", "SupportsRemoteControl": False},
|
||||
]
|
||||
|
||||
|
||||
def _client(handler) -> tuple[EmbyClient, list[httpx.Request]]:
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def _record(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return handler(request)
|
||||
|
||||
client = EmbyClient(Settings(host="emby.local:8096", api_key="k"))
|
||||
client._http = httpx.AsyncClient(base_url=client.settings.base_url,
|
||||
transport=httpx.MockTransport(_record))
|
||||
return client, seen
|
||||
|
||||
|
||||
def _ctx(client: EmbyClient):
|
||||
return types.SimpleNamespace(
|
||||
request_context=types.SimpleNamespace(lifespan_context=srv.AppContext(emby=client)))
|
||||
|
||||
|
||||
def test_base_url_defaults_to_http_and_accepts_ports_and_urls():
|
||||
assert Settings(host="10.0.0.5:9096", api_key="k").base_url == "http://10.0.0.5:9096"
|
||||
assert Settings(host="10.0.0.5", port=8096, api_key="k").base_url == "http://10.0.0.5:8096"
|
||||
assert Settings(host="10.0.0.5:9096", port=443, api_key="k").base_url == "http://10.0.0.5:9096"
|
||||
assert Settings(host="https://emby.example.org/", api_key="k").base_url == "https://emby.example.org"
|
||||
assert Settings(host="emby.example.org", scheme="https", api_key="k").base_url == "https://emby.example.org"
|
||||
|
||||
|
||||
def test_ids_cannot_reach_other_endpoints():
|
||||
client, seen = _client(lambda r: httpx.Response(200, json={}))
|
||||
|
||||
async def run():
|
||||
await client.get_user("../System/Restart")
|
||||
await client.get_playback_info("123/Delete")
|
||||
await client.play_on_session("s/../../System/Shutdown", ["1"])
|
||||
await client.aclose()
|
||||
|
||||
asyncio.run(run())
|
||||
paths = [r.url.raw_path.decode().split("?")[0] for r in seen]
|
||||
assert paths == ["/Users/..%2FSystem%2FRestart", "/Items/123%2FDelete/PlaybackInfo",
|
||||
"/Sessions/s%2F..%2F..%2FSystem%2FShutdown/Playing"]
|
||||
|
||||
|
||||
def test_search_is_server_side():
|
||||
client, seen = _client(lambda r: httpx.Response(200, json={"Items": [
|
||||
{"Id": "1", "Name": "Stranger Things", "Type": "Series", "ProductionYear": 2016,
|
||||
"RunTimeTicks": 30_000_000_000}]}))
|
||||
hits = asyncio.run(srv.search_items(_ctx(client), "Stranger Things"))
|
||||
params = seen[0].url.params
|
||||
assert params["SearchTerm"] == "Stranger Things"
|
||||
assert "Movie" in params["IncludeItemTypes"] and "Person" not in params["IncludeItemTypes"]
|
||||
assert hits[0].name == "Stranger Things" and hits[0].year == 2016 and hits[0].runtime_minutes == 50
|
||||
|
||||
|
||||
def test_get_item_uses_items_query():
|
||||
client, seen = _client(lambda r: httpx.Response(200, json={"Items": [{"Id": "42", "Name": "X"}]}))
|
||||
item = asyncio.run(srv.get_item(_ctx(client), "42"))
|
||||
assert item["Name"] == "X"
|
||||
assert seen[0].url.path == "/Items" and seen[0].url.params["Ids"] == "42"
|
||||
empty, _ = _client(lambda r: httpx.Response(200, json={"Items": []}))
|
||||
with pytest.raises(LookupError):
|
||||
asyncio.run(srv.get_item(_ctx(empty), "nope"))
|
||||
|
||||
|
||||
def test_play_item_targets_a_controllable_session():
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/Sessions":
|
||||
return httpx.Response(200, json=SESSIONS)
|
||||
return httpx.Response(204)
|
||||
|
||||
client, seen = _client(handler)
|
||||
result = asyncio.run(srv.play_item(_ctx(client), "42"))
|
||||
assert result["session_id"] == "s-tv"
|
||||
play = seen[-1]
|
||||
assert play.method == "POST" and play.url.path == "/Sessions/s-tv/Playing"
|
||||
assert play.url.params["ItemIds"] == "42" and play.url.params["PlayCommand"] == "PlayNow"
|
||||
|
||||
with pytest.raises(ValueError, match="No matching"):
|
||||
asyncio.run(srv.play_item(_ctx(client), "42", device="Kitchen"))
|
||||
with pytest.raises(ValueError, match="No matching"):
|
||||
asyncio.run(srv.play_item(_ctx(client), "42", session_id="s-srv"))
|
||||
|
||||
|
||||
def test_list_devices_unwraps_items():
|
||||
client, _ = _client(lambda r: httpx.Response(200, json={"Items": [{"Id": "d", "Name": "Phone"}]}))
|
||||
assert asyncio.run(srv.list_devices(_ctx(client))) == [
|
||||
{"id": "d", "name": "Phone", "app": None, "last_user": None, "last_active": None}]
|
||||
|
||||
|
||||
def test_playback_url_has_no_credentials():
|
||||
client, _ = _client(lambda r: httpx.Response(200, json={"Items": [
|
||||
{"Id": "7", "Name": "Song", "MediaType": "Audio"}]}))
|
||||
out = asyncio.run(srv.playback_url(_ctx(client), "7"))
|
||||
assert out["url"] == "http://emby.local:8096/Audio/7/stream?static=true"
|
||||
assert "api_key" not in out["url"] and "k" not in out["url"].split("/")[-1]
|
||||
|
||||
|
||||
def test_tool_annotations():
|
||||
tools = {t.name: t for t in asyncio.run(srv.mcp.list_tools())}
|
||||
assert tools["play_item"].annotations.readOnlyHint is False
|
||||
for name in ("search_items", "get_item", "list_sessions", "playback_url"):
|
||||
assert tools[name].annotations.readOnlyHint is True, name
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Tests for homey-mcp (run with homey-mcp/.venv: needs mcp 1.x FastMCP).
|
||||
|
||||
homey-mcp\\.venv\\Scripts\\python.exe -m pytest tests/test_homey_mcp.py -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("mcp.server.fastmcp")
|
||||
pytest.importorskip("homey_mcp")
|
||||
|
||||
import httpx # noqa: E402
|
||||
from starlette.testclient import TestClient # noqa: E402
|
||||
|
||||
from homey_mcp import server as srv # noqa: E402
|
||||
from homey_mcp.config import Settings # noqa: E402
|
||||
from homey_mcp.homey_client import HomeyClient, event_entry # noqa: E402
|
||||
|
||||
ZONES = {
|
||||
"z1": {"id": "z1", "name": "Wohnzimmer Ecke"},
|
||||
"z2": {"id": "z2", "name": "Wohnzimmer"},
|
||||
"z3": {"id": "z3", "name": "Esszimmer"},
|
||||
}
|
||||
DEVICES = {
|
||||
"d1": {"id": "d1", "name": "Deckenlicht", "zone": "z3", "class": "light",
|
||||
"capabilities": ["onoff", "dim"]},
|
||||
"d2": {"id": "d2", "name": "Licht Fenster", "zone": "z2", "class": "light",
|
||||
"capabilities": ["onoff"]},
|
||||
"d3": {"id": "d3", "name": "Heizung", "zone": "z3", "class": "thermostat",
|
||||
"capabilities": ["target_temperature"]},
|
||||
}
|
||||
|
||||
|
||||
def _client(handler) -> tuple[HomeyClient, list[httpx.Request]]:
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def _record(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return handler(request)
|
||||
|
||||
client = HomeyClient(Settings(host="homey.local", token="t", realtime=False))
|
||||
client._http = httpx.AsyncClient(base_url=client.settings.base_url,
|
||||
transport=httpx.MockTransport(_record))
|
||||
return client, seen
|
||||
|
||||
|
||||
def _ctx(client: HomeyClient):
|
||||
return types.SimpleNamespace(
|
||||
request_context=types.SimpleNamespace(lifespan_context=srv.AppContext(homey=client)))
|
||||
|
||||
|
||||
def _api(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/api/manager/zones/zone":
|
||||
return httpx.Response(200, json=ZONES)
|
||||
if request.url.path == "/api/manager/devices/device":
|
||||
return httpx.Response(200, json=DEVICES)
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
|
||||
def test_ids_cannot_escape_their_path_segment():
|
||||
client, seen = _client(lambda r: httpx.Response(200, json={}))
|
||||
|
||||
async def run():
|
||||
await client.trigger_flow("../../system/reboot#")
|
||||
await client.set_capability("x/../../../system/reboot?", "onoff", True)
|
||||
await client.aclose()
|
||||
|
||||
asyncio.run(run())
|
||||
paths = [r.url.raw_path.decode() for r in seen]
|
||||
assert paths[0] == "/api/manager/flow/flow/..%2F..%2Fsystem%2Freboot%23/trigger"
|
||||
assert paths[1].startswith("/api/manager/devices/device/x%2F..%2F")
|
||||
assert all("/system/reboot" not in p for p in paths)
|
||||
|
||||
|
||||
def test_zone_resolution_prefers_exact_match():
|
||||
assert srv._resolve_zone(ZONES, "wohnzimmer") == ("z2", "Wohnzimmer")
|
||||
assert srv._resolve_zone(ZONES, "Wohnzimmer E") == ("z1", "Wohnzimmer Ecke")
|
||||
assert srv._resolve_zone(ZONES, "ess") == ("z3", "Esszimmer")
|
||||
assert srv._resolve_zone(ZONES, "zimmer") == ("z3", "Esszimmer") # shortest substring hit
|
||||
assert srv._resolve_zone(ZONES, "Bad") is None
|
||||
|
||||
|
||||
def test_find_devices_requires_all_words_first():
|
||||
client, _ = _client(_api)
|
||||
hits = asyncio.run(srv.find_devices(_ctx(client), "esszimmer licht"))
|
||||
assert [d.id for d in hits] == ["d1"] # not every light, not every Esszimmer device
|
||||
fallback = asyncio.run(srv.find_devices(_ctx(client), "licht garage"))
|
||||
assert {d.id for d in fallback} == {"d1", "d2"}
|
||||
|
||||
|
||||
def test_control_zone_uses_resolved_zone_only():
|
||||
puts: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.method == "PUT":
|
||||
puts.append(request.url.path)
|
||||
return httpx.Response(200, json={})
|
||||
return _api(request)
|
||||
|
||||
client, _ = _client(handler)
|
||||
result = asyncio.run(srv.control_zone(_ctx(client), "Wohnzimmer", "onoff", "off"))
|
||||
assert result["zone"] == "Wohnzimmer" and result["devices"] == ["Licht Fenster"]
|
||||
assert puts == ["/api/manager/devices/device/d2/capability/onoff"]
|
||||
|
||||
|
||||
def test_control_all_refuses_unscoped_capability():
|
||||
client, _ = _client(_api)
|
||||
with pytest.raises(ValueError, match="Refusing"):
|
||||
asyncio.run(srv.control_all(_ctx(client), "target_temperature", 30))
|
||||
|
||||
|
||||
def test_event_entry_is_compact():
|
||||
e = event_entry("homey:device:abc", "capability", {"capabilityId": "onoff", "value": True})
|
||||
assert e["kind"] == "capability" and e["device_id"] == "abc"
|
||||
big = {"id": "d1", "name": "Lamp", "zone": "z1", "available": True, "capabilitiesObj": {"x": 1}}
|
||||
assert event_entry("homey:manager:devices", "device.update", big)["data"] == {
|
||||
"id": "d1", "name": "Lamp", "zone": "z1", "available": True}
|
||||
|
||||
|
||||
def test_bearer_mode_protects_every_mcp_route():
|
||||
app = srv.build_lan_app("sse", token="s3cret", auth_mode="bearer", allowed_hosts=None)
|
||||
c = TestClient(app)
|
||||
assert c.get("/").status_code == 200 # discovery stays public
|
||||
assert c.get("/sse").status_code == 401 # was unauthenticated before
|
||||
assert c.post("/messages/?session_id=x", json={}).status_code == 401
|
||||
assert c.get("/api/zones").status_code == 401
|
||||
ok = c.post("/messages/?session_id=x", json={}, headers={"Authorization": "Bearer s3cret"})
|
||||
assert ok.status_code != 401
|
||||
wrong = c.post("/messages/?session_id=x", json={},
|
||||
headers={"Authorization": "Bearer nöpe".encode()})
|
||||
assert wrong.status_code == 401 # non-ASCII token no longer crashes compare_digest
|
||||
|
||||
http_app = srv.build_lan_app("streamable-http", token="s3cret", auth_mode="bearer",
|
||||
allowed_hosts=None)
|
||||
assert TestClient(http_app).post("/", json={}).status_code == 401
|
||||
|
||||
|
||||
def test_host_guard_blocks_dns_rebinding():
|
||||
hosts = srv._allowed_hosts("10.0.0.5", "")
|
||||
app = srv.build_lan_app("streamable-http", token="", auth_mode="none", allowed_hosts=hosts)
|
||||
c = TestClient(app)
|
||||
assert c.get("/", headers={"Host": "evil.example:8080"}).status_code == 421
|
||||
assert c.get("/", headers={"Host": "10.0.0.5:8080"}).status_code == 200
|
||||
assert c.get("/", headers={"Host": "localhost:8080"}).status_code == 200
|
||||
assert srv._allowed_hosts("0.0.0.0", "") is None
|
||||
assert srv._allowed_hosts("0.0.0.0", "homey.lan") >= {"homey.lan", "localhost"}
|
||||
assert srv._allowed_hosts("10.0.0.5", "*") is None
|
||||
|
||||
|
||||
def test_tool_annotations():
|
||||
tools = {t.name: t for t in asyncio.run(srv.mcp.list_tools())}
|
||||
for name in ("control_all", "control_zone", "trigger_flow"):
|
||||
assert tools[name].annotations.destructiveHint is True, name
|
||||
for name in ("list_devices", "find_devices", "get_zone_status", "list_recent_events"):
|
||||
assert tools[name].annotations.readOnlyHint is True, name
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Regression tests for the javis-slave audit fixes (docs/AUDIT_SLAVE.md)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
SLAVE_SRC = Path(__file__).resolve().parents[1] / "javis-slave" / "src"
|
||||
if str(SLAVE_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(SLAVE_SRC))
|
||||
|
||||
from javis_slave import config as slave_config # noqa: E402
|
||||
from javis_slave.api_client import MasterClient, MasterError # noqa: E402
|
||||
from javis_slave.config import SlaveConfig, ensure_control_token # noqa: E402
|
||||
|
||||
TOKEN = "t" * 32
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cfg_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("JAVIS_SLAVE_CONFIG_DIR", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
# ── SLV-07: config loading ───────────────────────────────────────────────────
|
||||
|
||||
def test_corrupt_config_is_kept_aside(cfg_dir):
|
||||
(cfg_dir / "slave_config.json").write_text("{not json", encoding="utf-8")
|
||||
cfg = slave_config.load_config()
|
||||
assert not cfg.paired
|
||||
assert (cfg_dir / "slave_config.json.corrupt").read_text(encoding="utf-8") == "{not json"
|
||||
assert not (cfg_dir / "slave_config.json").exists()
|
||||
|
||||
|
||||
def test_config_type_mismatch_keeps_default(cfg_dir):
|
||||
(cfg_dir / "slave_config.json").write_text(
|
||||
json.dumps({"master_port": "abc", "input_gain": 2, "api_key": "k", "master_host": "h"}),
|
||||
encoding="utf-8")
|
||||
cfg = slave_config.load_config()
|
||||
assert cfg.master_port == 8765
|
||||
assert cfg.input_gain == 2.0 and isinstance(cfg.input_gain, float)
|
||||
assert cfg.paired
|
||||
|
||||
|
||||
def test_control_token_roundtrip(cfg_dir):
|
||||
cfg = SlaveConfig()
|
||||
assert ensure_control_token(cfg) is True
|
||||
assert len(cfg.control_token) >= 24
|
||||
assert ensure_control_token(cfg) is False
|
||||
slave_config.save_config(cfg)
|
||||
assert slave_config.load_config().control_token == cfg.control_token
|
||||
|
||||
|
||||
# ── SLV-01: control API authentication ───────────────────────────────────────
|
||||
|
||||
def _app(token: str = TOKEN):
|
||||
from javis_slave.http_server import build_http_app
|
||||
cfg = SlaveConfig(control_token=token)
|
||||
return build_http_app(cfg, lambda: None, lambda: "idle", lambda: None, lambda: (0.0, 0.0))
|
||||
|
||||
|
||||
def test_control_api_requires_token_from_lan():
|
||||
from fastapi.testclient import TestClient
|
||||
remote = TestClient(_app(), client=("192.168.1.50", 50000))
|
||||
assert remote.get("/mcp/v1/health").status_code == 200
|
||||
assert remote.get("/mcp/v1/status").status_code == 401
|
||||
# Rejected by the middleware before the handler runs: nothing is executed.
|
||||
assert remote.post("/mcp/v1/power/cancel").status_code == 401
|
||||
assert remote.get("/mcp/v1/status", headers={"Authorization": "Bearer wrong"}).status_code == 401
|
||||
# The loopback header is not a credential for remote callers.
|
||||
assert remote.get("/mcp/v1/status", headers={"X-Javis-Control": "1"}).status_code == 401
|
||||
ok = remote.get("/mcp/v1/status", headers={"Authorization": f"Bearer {TOKEN}"})
|
||||
assert ok.status_code == 200 and ok.json()["audio_state"] == "idle"
|
||||
assert remote.get("/docs").status_code in (401, 404)
|
||||
|
||||
|
||||
def test_control_api_loopback_needs_custom_header():
|
||||
from fastapi.testclient import TestClient
|
||||
for host in ("127.0.0.1", "::1", "::ffff:127.0.0.1"):
|
||||
local = TestClient(_app(), client=(host, 50000))
|
||||
# Plain loopback request (e.g. a browser form post / CSRF) is refused.
|
||||
assert local.get("/mcp/v1/status").status_code == 401, host
|
||||
assert local.get("/mcp/v1/status", headers={"X-Javis-Control": "1"}).status_code == 200, host
|
||||
|
||||
|
||||
def test_empty_token_never_authorizes():
|
||||
from fastapi.testclient import TestClient
|
||||
remote = TestClient(_app(token=""), client=("10.0.0.2", 50000))
|
||||
assert remote.get("/mcp/v1/status", headers={"Authorization": "Bearer "}).status_code == 401
|
||||
|
||||
|
||||
# ── SLV-03: no blind retry of a non-idempotent turn after a read timeout ────
|
||||
|
||||
def _client_with(handler) -> MasterClient:
|
||||
cfg = SlaveConfig(master_host="master.invalid", api_key="k")
|
||||
client = MasterClient(cfg)
|
||||
client._backoff_base = 0.0
|
||||
client._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
return client
|
||||
|
||||
|
||||
def test_send_turn_does_not_retry_after_read_timeout():
|
||||
calls = []
|
||||
|
||||
def handler(request):
|
||||
calls.append(request.url.path)
|
||||
raise httpx.ReadTimeout("slow", request=request)
|
||||
|
||||
async def run():
|
||||
client = _client_with(handler)
|
||||
try:
|
||||
with pytest.raises(MasterError, match="Zeitüberschreitung"):
|
||||
await client.send_turn(b"RIFF")
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
asyncio.run(run())
|
||||
assert calls == ["/api/v1/turn"]
|
||||
|
||||
|
||||
def test_send_turn_retries_connect_error_once():
|
||||
calls = []
|
||||
|
||||
def handler(request):
|
||||
calls.append(1)
|
||||
raise httpx.ConnectError("refused", request=request)
|
||||
|
||||
async def run():
|
||||
client = _client_with(handler)
|
||||
try:
|
||||
with pytest.raises(MasterError):
|
||||
await client.send_turn(b"RIFF")
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
asyncio.run(run())
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
# ── SLV-04 / SLV-06: audio engine ────────────────────────────────────────────
|
||||
|
||||
def test_engine_start_failure_releases_mic(monkeypatch):
|
||||
from javis_slave import audio_engine
|
||||
|
||||
class FakeMic:
|
||||
instances: list = []
|
||||
|
||||
def __init__(self, **kw):
|
||||
self.stopped = False
|
||||
FakeMic.instances.append(self)
|
||||
|
||||
async def start(self):
|
||||
pass
|
||||
|
||||
async def stop(self):
|
||||
self.stopped = True
|
||||
|
||||
def broken_vad(**kw):
|
||||
raise RuntimeError("vad model missing")
|
||||
|
||||
monkeypatch.setattr(audio_engine, "MicStream", FakeMic)
|
||||
monkeypatch.setattr(audio_engine, "make_vad", broken_vad)
|
||||
|
||||
async def run():
|
||||
eng = audio_engine.AudioEngine(SlaveConfig(), client=None)
|
||||
with pytest.raises(RuntimeError, match="vad model missing"):
|
||||
await eng.start()
|
||||
return eng
|
||||
|
||||
eng = asyncio.run(run())
|
||||
assert FakeMic.instances and FakeMic.instances[0].stopped
|
||||
assert eng.mic is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["", ".", "..", "../evil", "sub/model", "..\\evil", "C:\\x\\model"])
|
||||
def test_wake_model_name_cannot_address_a_path(name):
|
||||
from javis_slave.audio_engine import AudioEngine
|
||||
|
||||
class NoDownload:
|
||||
async def download_wake_model(self, dest): # pragma: no cover - must not be reached
|
||||
raise AssertionError(f"download attempted for {dest}")
|
||||
|
||||
async def run():
|
||||
eng = AudioEngine(SlaveConfig(), client=NoDownload())
|
||||
with pytest.raises(RuntimeError, match="invalid wake word model name"):
|
||||
await eng._load_wake(name, 0.5)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# ── SLV-05: playback writer stops before cancellation completes ─────────────
|
||||
|
||||
def test_write_pcm_blocks_cancel_stops_writer_thread():
|
||||
from javis_slave.audio_io.stream import write_pcm_blocks
|
||||
|
||||
class SlowStream:
|
||||
writes = 0
|
||||
|
||||
def write(self, block):
|
||||
time.sleep(0.01)
|
||||
SlowStream.writes += 1
|
||||
|
||||
async def run():
|
||||
task = asyncio.ensure_future(
|
||||
write_pcm_blocks(SlowStream(), np.zeros(16000 * 5, dtype=np.float32), 160))
|
||||
await asyncio.sleep(0.05)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
done = SlowStream.writes
|
||||
await asyncio.sleep(0.05)
|
||||
return done
|
||||
|
||||
done = asyncio.run(run())
|
||||
assert 0 < done < 500
|
||||
assert SlowStream.writes == done # no write after the stream may be closed
|
||||
|
||||
|
||||
def test_write_pcm_blocks_cancel_event():
|
||||
from javis_slave.audio_io.stream import write_pcm_blocks
|
||||
|
||||
class Stream:
|
||||
def write(self, block):
|
||||
pass
|
||||
|
||||
async def run():
|
||||
ev = asyncio.Event()
|
||||
ev.set()
|
||||
return await write_pcm_blocks(Stream(), np.zeros(1600, dtype=np.float32), 160, ev)
|
||||
|
||||
assert asyncio.run(run()) is True
|
||||
|
||||
|
||||
# ── SLV-02 / SLV-09: Windows system control ─────────────────────────────────
|
||||
|
||||
def test_volume_controller_uses_current_pycaw_api(monkeypatch):
|
||||
import types
|
||||
|
||||
from javis_slave import system_control
|
||||
|
||||
class Endpoint:
|
||||
level = 0.25
|
||||
|
||||
def GetMasterVolumeLevelScalar(self):
|
||||
return Endpoint.level
|
||||
|
||||
def SetMasterVolumeLevelScalar(self, level, ctx):
|
||||
Endpoint.level = level
|
||||
|
||||
def GetMute(self):
|
||||
return 1
|
||||
|
||||
class Device: # pycaw >= 2024 AudioDevice: has .EndpointVolume, no .Activate
|
||||
EndpointVolume = Endpoint()
|
||||
|
||||
fake = types.ModuleType("pycaw.pycaw")
|
||||
fake.AudioUtilities = types.SimpleNamespace(GetSpeakers=lambda: Device())
|
||||
fake.IAudioEndpointVolume = object
|
||||
monkeypatch.setitem(sys.modules, "pycaw", types.ModuleType("pycaw"))
|
||||
monkeypatch.setitem(sys.modules, "pycaw.pycaw", fake)
|
||||
monkeypatch.setattr(system_control.platform, "system", lambda: "Windows")
|
||||
|
||||
vol = system_control.VolumeController()
|
||||
assert vol.get_volume() == 0.25
|
||||
assert vol.set_volume(1.7) and Endpoint.level == 1.0
|
||||
assert vol.is_muted() is True
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="Windows exit-code handling")
|
||||
def test_windows_power_runner_checks_exit_codes():
|
||||
from javis_slave.system_control import _run_win
|
||||
assert _run_win(["cmd", "/c", "exit 0"]) is True
|
||||
assert _run_win(["cmd", "/c", "exit 5"]) is False
|
||||
assert _run_win(["cmd", "/c", "exit 1116"], ok_codes=(0, 1116)) is True
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for javis-slave-mcp (needs mcp 1.x with FastMCP — run with javis-slave-mcp/.venv).
|
||||
|
||||
javis-slave-mcp\\.venv\\Scripts\\python.exe -m pytest tests/test_slave_mcp.py -q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("mcp.server.fastmcp")
|
||||
|
||||
import httpx # noqa: E402
|
||||
|
||||
MCP_SRC = Path(__file__).resolve().parents[1] / "javis-slave-mcp" / "src"
|
||||
if str(MCP_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(MCP_SRC))
|
||||
|
||||
from mcp.server.fastmcp.exceptions import ToolError # noqa: E402
|
||||
|
||||
from javis_slave_mcp import server as srv # noqa: E402
|
||||
|
||||
REAL_ID = "3f6c1a7e-0000-4000-8000-000000000001"
|
||||
|
||||
|
||||
def test_env_parsing_skips_bad_entries():
|
||||
slaves = srv._parse_env_devices(
|
||||
" a = 10.0.0.1:8766 , bad, b=tok-123@host:9000, c=host2,,d=:1", "default-tok")
|
||||
assert set(slaves) == {"a", "b", "c"}
|
||||
assert (slaves["a"].host, slaves["a"].port, slaves["a"].token) == ("10.0.0.1", 8766, "default-tok")
|
||||
assert (slaves["b"].host, slaves["b"].port, slaves["b"].token) == ("host", 9000, "tok-123")
|
||||
assert (slaves["c"].host, slaves["c"].port) == ("host2", srv.DEFAULT_PORT)
|
||||
assert "tok-123" not in repr(slaves["b"])
|
||||
|
||||
|
||||
def _install(handler, token: str = "") -> list[httpx.Request]:
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def _record(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return handler(request)
|
||||
|
||||
client = srv.SlaveClient(device_id="localhost", host="127.0.0.1", token=token)
|
||||
client._http = httpx.AsyncClient(transport=httpx.MockTransport(_record),
|
||||
base_url=client.base_url(), headers=client.headers())
|
||||
srv._slaves = {"localhost": client}
|
||||
return seen
|
||||
|
||||
|
||||
def _status_json() -> dict:
|
||||
return {"device_id": REAL_ID, "device_name": "Kueche", "paired": True,
|
||||
"engine_running": True, "audio_state": "idle"}
|
||||
|
||||
|
||||
def test_tools_run_inside_the_server_loop_and_resolve_real_id():
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/mcp/v1/status":
|
||||
return httpx.Response(200, json=_status_json())
|
||||
if request.url.path == f"/mcp/v1/master/slaves/{REAL_ID}/ip":
|
||||
return httpx.Response(200, json={"device_id": REAL_ID, "ip_address": "10.0.0.7"})
|
||||
return httpx.Response(404, json={"detail": "nope"})
|
||||
|
||||
seen = _install(handler, token="secret-token")
|
||||
# FastMCP awaits the tool on its running loop — the old asyncio.run() failed here.
|
||||
result = asyncio.run(srv.mcp.call_tool("slave_get_ip_address", {"device_id": "localhost"}))
|
||||
assert "10.0.0.7" in str(result)
|
||||
assert [r.url.path for r in seen] == ["/mcp/v1/status", f"/mcp/v1/master/slaves/{REAL_ID}/ip"]
|
||||
assert all(r.headers["X-Javis-Control"] == "1" for r in seen)
|
||||
assert all(r.headers["Authorization"] == "Bearer secret-token" for r in seen)
|
||||
|
||||
|
||||
def test_unauthorized_becomes_tool_error():
|
||||
_install(lambda request: httpx.Response(401, json={"detail": "unauthorized"}))
|
||||
with pytest.raises(ToolError, match="control token"):
|
||||
asyncio.run(srv.mcp.call_tool("slave_get_status", {"device_id": "localhost"}))
|
||||
|
||||
|
||||
def test_unknown_device_is_tool_error():
|
||||
_install(lambda request: httpx.Response(200, json=_status_json()))
|
||||
with pytest.raises(ToolError, match="not found"):
|
||||
asyncio.run(srv.mcp.call_tool("slave_get_status", {"device_id": "nope"}))
|
||||
|
||||
|
||||
def test_tool_annotations():
|
||||
tools = {t.name: t for t in asyncio.run(srv.mcp.list_tools())}
|
||||
for name in ("slave_shutdown", "slave_restart_system", "slave_sleep"):
|
||||
assert tools[name].annotations.destructiveHint is True, name
|
||||
for name in ("slave_get_status", "slave_get_volume", "slave_list_devices"):
|
||||
assert tools[name].annotations.readOnlyHint is True, name
|
||||
assert tools["slave_set_volume"].annotations.idempotentHint is True
|
||||
assert tools["slave_increase_volume"].annotations.idempotentHint is False
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Design-token checks for src/javis/gui/theme.py (WCAG 2.2 AA contrast)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from javis.gui import theme
|
||||
|
||||
# (foreground, background) pairs rendered as text: need 4.5:1 (SC 1.4.3).
|
||||
TEXT_PAIRS = [
|
||||
("text", "bg"), ("text", "surface"), ("text", "surface2"), ("text", "input"),
|
||||
("muted", "bg"), ("muted", "surface"), ("muted", "surface2"),
|
||||
("accent", "bg"), ("accent", "surface"),
|
||||
("on_accent", "primary"), ("on_accent", "primary_hover"),
|
||||
("success", "bg"), ("error", "bg"), ("error", "error_bg"), ("warn_fg", "bg"),
|
||||
("tool_fg", "tool_bg"), ("tool_muted", "tool_bg"), ("code_fg", "code_bg"),
|
||||
("link", "bg"),
|
||||
]
|
||||
# Control boundaries and focus indicators: need 3:1 (SC 1.4.11).
|
||||
UI_PAIRS = [
|
||||
("stroke_strong", "bg"), ("stroke_strong", "surface"),
|
||||
("focus", "bg"), ("focus", "surface"), ("primary", "bg"), ("on_accent", "primary"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(theme.THEMES))
|
||||
@pytest.mark.parametrize("fg,bg", TEXT_PAIRS)
|
||||
def test_text_contrast(name, fg, bg):
|
||||
t = theme.THEMES[name]
|
||||
assert theme.contrast_ratio(t[fg], t[bg]) >= 4.5, f"{name}: {fg} on {bg}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(theme.THEMES))
|
||||
@pytest.mark.parametrize("fg,bg", UI_PAIRS)
|
||||
def test_ui_contrast(name, fg, bg):
|
||||
t = theme.THEMES[name]
|
||||
assert theme.contrast_ratio(t[fg], t[bg]) >= 3.0, f"{name}: {fg} vs {bg}"
|
||||
|
||||
|
||||
def test_palettes_share_keys():
|
||||
assert set(theme.PAL_DARK) == set(theme.PAL_LIGHT)
|
||||
|
||||
|
||||
def test_accent_hue_kept():
|
||||
# The redesign must keep the brand colours of the dark theme.
|
||||
assert theme.PAL_DARK["accent"].lower() == "#da7756"
|
||||
assert theme.PAL_DARK["bg"] == "#1e1e1e"
|
||||
|
||||
|
||||
def test_contrast_math():
|
||||
assert theme.contrast_ratio("#000000", "#ffffff") == pytest.approx(21.0)
|
||||
assert theme.readable_on("#DA7756") == "#1e1e1e"
|
||||
assert theme.readable_on("#3c3c3c") == "#ffffff"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(theme.THEMES))
|
||||
def test_qss_is_well_formed(name):
|
||||
qss = theme.build_qss(theme.THEMES[name], "Segoe UI")
|
||||
assert qss.count("{") == qss.count("}")
|
||||
# every palette value used in the sheet is a full hex colour
|
||||
for hex_color in re.findall(r"#[0-9a-fA-F]+\b", qss):
|
||||
assert len(hex_color) in (4, 7), hex_color
|
||||
assert "variant=\"primary\"" in qss
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Voice training pipeline: warm start/resume, piper compatibility fixes, data filters."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
HIGH_ARCH = {
|
||||
"resblock": "1", "resblock_kernel_sizes": (3, 7, 11),
|
||||
"resblock_dilation_sizes": ((1, 3, 5), (1, 3, 5), (1, 3, 5)),
|
||||
"upsample_rates": (8, 8, 2, 2), "upsample_initial_channel": 512,
|
||||
"upsample_kernel_sizes": (16, 16, 4, 4),
|
||||
}
|
||||
|
||||
|
||||
def _voice_dir(root: Path) -> Path:
|
||||
training = root / "v" / "training"
|
||||
(training / "wavs").mkdir(parents=True)
|
||||
(training / "metadata.csv").write_text("clip_00000|hallo|hallo\n", encoding="utf-8")
|
||||
return root / "v"
|
||||
|
||||
|
||||
class TrainingStartTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
try:
|
||||
import torch # noqa: F401
|
||||
except ImportError:
|
||||
self.skipTest("torch not installed")
|
||||
from javis.voices import piper_train
|
||||
self.pt = piper_train
|
||||
self.cmds: list[list[str]] = []
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
self.cmds.append(cmd)
|
||||
return 1 # stop after stage 1
|
||||
|
||||
patches = [patch.object(piper_train, "_run", fake_run),
|
||||
patch.object(piper_train, "MIN_FREE_BYTES", 0)]
|
||||
for p in patches:
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def _ckpt(self, path: Path, **hp) -> Path:
|
||||
import torch
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
torch.save({"state_dict": {"w": torch.zeros(1)}, "hyper_parameters": hp}, path)
|
||||
return path
|
||||
|
||||
def test_first_run_warm_starts_from_base_with_its_architecture(self) -> None:
|
||||
import yaml
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
voice = _voice_dir(Path(d))
|
||||
self._ckpt(voice / "training" / "piper_base" / "epoch=2665-step=1.ckpt",
|
||||
dataset=[Path("x")], **HIGH_ARCH)
|
||||
self.pt.run_pipeline(voice)
|
||||
cmd = self.cmds[0]
|
||||
self.assertIn("javis.voices.piper_compat", cmd)
|
||||
self.assertIn("--model.warmstart_ckpt", cmd)
|
||||
self.assertNotIn("--ckpt_path", cmd)
|
||||
arch = yaml.safe_load(Path(cmd[cmd.index("--config") + 1]).read_text(encoding="utf-8"))
|
||||
self.assertEqual(arch["model"]["resblock"], "1")
|
||||
self.assertEqual(arch["model"]["upsample_rates"], [8, 8, 2, 2])
|
||||
self.assertFalse((voice / "training" / "warmstart.pt").exists()) # removed after fit
|
||||
|
||||
def test_later_runs_resume_own_checkpoint_and_ignore_base(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
voice = _voice_dir(Path(d))
|
||||
base = self._ckpt(voice / "training" / "piper_base" / "epoch=1.ckpt", **HIGH_ARCH)
|
||||
last = self._ckpt(voice / "training" / "piper_lightning" / "lightning_logs"
|
||||
/ "version_0" / "checkpoints" / "last.ckpt", **HIGH_ARCH)
|
||||
self.pt.run_pipeline(voice, base_checkpoint=base)
|
||||
cmd = self.cmds[0]
|
||||
self.assertEqual(cmd[cmd.index("--ckpt_path") + 1], str(last))
|
||||
self.assertNotIn("--model.warmstart_ckpt", cmd)
|
||||
|
||||
def test_linux_checkpoint_with_posixpath_loads(self) -> None:
|
||||
import pathlib
|
||||
import torch
|
||||
|
||||
class FakePosixPath: # pickled as pathlib.PosixPath, like a Linux checkpoint
|
||||
def __init__(self, p):
|
||||
self.p = p
|
||||
|
||||
def __reduce__(self):
|
||||
return (FakePosixPath, (self.p,))
|
||||
|
||||
FakePosixPath.__module__ = "pathlib"
|
||||
FakePosixPath.__name__ = FakePosixPath.__qualname__ = "PosixPath"
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
p = Path(d) / "linux.ckpt"
|
||||
with patch.object(pathlib, "PosixPath", FakePosixPath):
|
||||
torch.save({"hyper_parameters": {"dataset": [FakePosixPath("/ssd/x")]}}, p)
|
||||
ckpt = self.pt._load_checkpoint(p)
|
||||
self.assertEqual(str(ckpt["hyper_parameters"]["dataset"][0]), "/ssd/x")
|
||||
|
||||
|
||||
class PiperCompatTests(unittest.TestCase):
|
||||
def test_int_resblock_builds_high_quality_blocks(self) -> None:
|
||||
try:
|
||||
import javis.voices.piper_compat # noqa: F401 — applies the fixes
|
||||
from piper.train.vits import models, modules
|
||||
from pysilero_vad import SileroVoiceActivityDetector
|
||||
except ImportError:
|
||||
self.skipTest("piper training extras not installed")
|
||||
g = models.Generator(192, 1, (3, 7, 11), ((1, 3, 5),) * 3, (8, 8, 2, 2), 64, (16, 16, 4, 4))
|
||||
self.assertIsInstance(g.resblocks[0], modules.ResBlock1)
|
||||
self.assertTrue(hasattr(SileroVoiceActivityDetector, "process_array"))
|
||||
|
||||
|
||||
class TranscriptGateTests(unittest.TestCase):
|
||||
def test_unreliable_transcripts_are_rejected(self) -> None:
|
||||
from javis.voices.audiobook_ingest import _transcript_ok
|
||||
|
||||
def tr(**kw):
|
||||
return SimpleNamespace(**{"avg_logprob": -0.2, "no_speech_prob": 0.01,
|
||||
"compression_ratio": 1.4, **kw})
|
||||
self.assertTrue(_transcript_ok(tr()))
|
||||
self.assertTrue(_transcript_ok(tr(avg_logprob=None, no_speech_prob=None,
|
||||
compression_ratio=None)))
|
||||
self.assertFalse(_transcript_ok(tr(avg_logprob=-1.3)))
|
||||
self.assertFalse(_transcript_ok(tr(no_speech_prob=0.9)))
|
||||
self.assertFalse(_transcript_ok(tr(compression_ratio=3.1)))
|
||||
|
||||
|
||||
class NarratorFilterTests(unittest.TestCase):
|
||||
def test_dominant_speaker_scores_high(self) -> None:
|
||||
from javis.voices.dataset_clean import narrator_scores
|
||||
|
||||
rng = np.random.default_rng(0)
|
||||
a, b = np.eye(8)[0], np.eye(8)[1]
|
||||
embs = np.array([v + rng.normal(0, 0.1, 8) for v in [a] * 70 + [b] * 30])
|
||||
embs /= np.linalg.norm(embs, axis=1, keepdims=True)
|
||||
s = narrator_scores(embs)
|
||||
self.assertGreater(s[:70].min(), 0.8)
|
||||
self.assertLess(s[70:].max(), 0.4)
|
||||
|
||||
def test_clean_dataset_filters_and_keeps_new_imports(self) -> None:
|
||||
import soundfile as sf
|
||||
from javis.voices.dataset_clean import clean_dataset
|
||||
|
||||
def embed(audio, sr):
|
||||
# The first sample marks the speaker: 0.5 = narrator, else character voice.
|
||||
return np.eye(4)[0] if audio[0] > 0.4 else np.eye(4)[1]
|
||||
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
training = Path(d)
|
||||
(training / "wavs").mkdir()
|
||||
|
||||
def add(stem: str, narrator: bool) -> str:
|
||||
sf.write(str(training / "wavs" / f"{stem}.wav"),
|
||||
np.full(100, 0.5 if narrator else 0.1, dtype=np.float32), 22050)
|
||||
return f"{stem}|text {stem}|text {stem}\n"
|
||||
|
||||
lines = [add(f"clip_{i:05d}", narrator=i % 4 != 0) for i in range(8)]
|
||||
(training / "metadata.csv").write_text("".join(lines), encoding="utf-8")
|
||||
|
||||
report = clean_dataset(training, embed, dry_run=True)
|
||||
self.assertEqual((report["kept"], report["dropped"]), (6, 2))
|
||||
self.assertEqual(len((training / "metadata.csv").read_text().splitlines()), 8)
|
||||
|
||||
clean_dataset(training, embed)
|
||||
self.assertEqual(len((training / "metadata.csv").read_text().splitlines()), 6)
|
||||
self.assertEqual(len((training / "metadata.unfiltered.csv").read_text().splitlines()), 8)
|
||||
|
||||
# A later import appends to metadata.csv; re-filtering must keep it.
|
||||
with open(training / "metadata.csv", "a", encoding="utf-8") as f:
|
||||
f.write(add("clip_00100", narrator=True))
|
||||
report = clean_dataset(training, embed)
|
||||
self.assertEqual((report["clips"], report["kept"]), (9, 7))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(unittest.main())
|
||||
Reference in new issue
Block a user