feat: migrate Mac mini services into Reyna CLI
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from reyna_cli.apple_llm_client import AppleLLMClient, get_apple_llm_session, apple_llm_close, apple_llm_status
|
||||
|
||||
@pytest.fixture
|
||||
def mock_apple_llm():
|
||||
with patch("subprocess.Popen") as mock_popen, \
|
||||
patch("subprocess.run") as mock_run, \
|
||||
patch("pathlib.Path.write_text"), \
|
||||
patch("shutil.rmtree"):
|
||||
|
||||
# Mock the binary build
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
# Mock the process
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = None
|
||||
mock_proc.pid = 1234
|
||||
mock_proc.stdout.readline.side_effect = [
|
||||
'{"id": "0", "ok": true, "text": "Polished text", "ms": 100, "mode": "line"}\\n',
|
||||
'{"id": "1", "ok": true, "text": "Quick reply", "ms": 50, "mode": "quick_reply"}\\n',
|
||||
'{"id": "2", "ok": true, "text": "Chat response", "ms": 200, "mode": "chat"}\\n',
|
||||
'', # EOF
|
||||
]
|
||||
mock_popen.return_value = mock_proc
|
||||
|
||||
client = AppleLLMClient()
|
||||
yield client
|
||||
|
||||
def test_apple_llm_lifecycle(mock_apple_llm):
|
||||
# Test status before start
|
||||
status = apple_llm_status()
|
||||
assert status["active"] is False
|
||||
|
||||
# Start and check status
|
||||
mock_apple_llm.start()
|
||||
status = apple_llm_status()
|
||||
# Note: apple_llm_status uses the global singleton, not the fixture instance.
|
||||
# For the purpose of these tests, we'll mock the global session if needed,
|
||||
# but let's focus on the Client logic first.
|
||||
|
||||
def test_apple_llm_call_success(mock_apple_llm):
|
||||
payload = {"mode": "line", "text": "Hello world"}
|
||||
mock_proc = MagicMock()
|
||||
mock_apple_llm.proc = mock_proc
|
||||
|
||||
def reply_on_write(_: str) -> None:
|
||||
event, result_box = mock_apple_llm.pending["0"]
|
||||
result_box["data"] = {"ok": True, "text": "Polished!"}
|
||||
event.set()
|
||||
|
||||
mock_proc.stdin.write.side_effect = reply_on_write
|
||||
with patch.object(mock_apple_llm, "start"):
|
||||
res = mock_apple_llm.call(payload)
|
||||
|
||||
assert res["ok"] is True
|
||||
assert res["text"] == "Polished!"
|
||||
|
||||
def test_apple_llm_timeout(mock_apple_llm):
|
||||
with patch("threading.Event.wait", return_value=False):
|
||||
res = mock_apple_llm.call({"mode": "line", "text": "test"}, timeout=0.1)
|
||||
assert res["ok"] is False
|
||||
assert "timeout" in res["error"]
|
||||
|
||||
def test_apple_llm_check_mock(mock_apple_llm):
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout='{"ok": true, "available": true, "ping": "ok"}',
|
||||
returncode=0
|
||||
)
|
||||
res = mock_apple_llm.check()
|
||||
assert res["ok"] is True
|
||||
assert res["available"] is True
|
||||
@@ -0,0 +1,142 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from reyna_cli.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_media_execution_local_tts_delegates(monkeypatch, tmp_path):
|
||||
from reyna_cli import media_execution
|
||||
|
||||
target = tmp_path / "speech.wav"
|
||||
|
||||
def fake_synthesize(text, output, **kwargs):
|
||||
output.write_bytes(b"RIFFfake")
|
||||
return output
|
||||
|
||||
monkeypatch.setattr(media_execution, "synthesize_wav", fake_synthesize)
|
||||
result = media_execution.generate_local_tts("hello", target, voice="Alex")
|
||||
assert result["ok"] is True
|
||||
assert result["filePath"] == str(target)
|
||||
assert target.read_bytes() == b"RIFFfake"
|
||||
|
||||
|
||||
def test_media_execution_kokoro_writes_returned_file(monkeypatch, tmp_path):
|
||||
from reyna_cli import media_execution
|
||||
|
||||
source = tmp_path / "source.wav"
|
||||
source.write_bytes(b"wav")
|
||||
target = tmp_path / "target.wav"
|
||||
monkeypatch.setattr(media_execution, "_json_request", lambda *args, **kwargs: {"filePath": str(source), "voice": "af_heart"})
|
||||
result = media_execution.generate_kokoro("hello", target, url="http://127.0.0.1:7332")
|
||||
assert result["ok"] is True
|
||||
assert target.read_bytes() == b"wav"
|
||||
|
||||
|
||||
def test_media_execution_gemini_requires_key(monkeypatch, tmp_path):
|
||||
from reyna_cli import media_execution
|
||||
|
||||
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||
try:
|
||||
media_execution.generate_gemini_image("a tree", tmp_path / "tree.png")
|
||||
except RuntimeError as exc:
|
||||
assert "GEMINI_API_KEY" in str(exc)
|
||||
else:
|
||||
raise AssertionError("missing Gemini key must fail before network")
|
||||
|
||||
|
||||
def test_media_execution_gemini_decodes_image(monkeypatch, tmp_path):
|
||||
from reyna_cli import media_execution
|
||||
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "test-only")
|
||||
monkeypatch.setattr(media_execution, "_json_request", lambda *args, **kwargs: {"id": "i1", "output_image": {"data": "aGVsbG8=", "mime_type": "image/png"}})
|
||||
target = tmp_path / "tree.png"
|
||||
result = media_execution.generate_gemini_image("a tree", target)
|
||||
assert result["path"] == str(target)
|
||||
assert target.read_bytes() == b"hello"
|
||||
|
||||
|
||||
def test_cli_exposes_execution_commands():
|
||||
for args in [
|
||||
["local-services", "speech", "--help"],
|
||||
["local-services", "kokoro", "--help"],
|
||||
["local-services", "voicebox", "--help"],
|
||||
["local-services", "image", "--help"],
|
||||
]:
|
||||
result = runner.invoke(app, args)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert "generate" in result.stdout
|
||||
|
||||
|
||||
def test_cli_image_rejects_unknown_engine():
|
||||
result = runner.invoke(app, ["local-services", "image", "generate", "tree", "--engine", "unknown", "--json"])
|
||||
assert result.exit_code == 1
|
||||
assert "codex or gemini" in result.stdout
|
||||
assert json.loads(result.stdout)["ok"] is False
|
||||
|
||||
|
||||
def test_speech_file_execution_uses_cached_binary(monkeypatch, tmp_path):
|
||||
from reyna_cli import speech_execution
|
||||
|
||||
audio = tmp_path / "sample.wav"
|
||||
audio.write_bytes(b"wav")
|
||||
binary = tmp_path / "transcriber"
|
||||
binary.write_bytes(b"binary")
|
||||
|
||||
monkeypatch.setattr(speech_execution, "_ensure_binary", lambda: binary)
|
||||
monkeypatch.setattr(
|
||||
speech_execution.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: type("Result", (), {"returncode": 0, "stdout": '{"ok":true,"transcript":"hello"}', "stderr": ""})(),
|
||||
)
|
||||
result = speech_execution.transcribe_file(audio, locale="en-US")
|
||||
assert result["transcript"] == "hello"
|
||||
assert result["source"] == "reyna_cli_direct"
|
||||
|
||||
|
||||
def test_speech_file_execution_rejects_missing_audio(tmp_path):
|
||||
from reyna_cli.speech_execution import SpeechExecutionError, transcribe_file
|
||||
|
||||
try:
|
||||
transcribe_file(tmp_path / "missing.wav")
|
||||
except SpeechExecutionError as exc:
|
||||
assert "not found" in str(exc)
|
||||
else:
|
||||
raise AssertionError("missing audio must fail before compilation")
|
||||
|
||||
|
||||
def test_live_session_frames_audio_and_reads_event(monkeypatch, tmp_path):
|
||||
from reyna_cli import speech_live
|
||||
|
||||
class FakeStdin:
|
||||
def __init__(self):
|
||||
self.data = bytearray()
|
||||
def write(self, value):
|
||||
self.data.extend(value)
|
||||
def flush(self):
|
||||
pass
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
class FakeProcess:
|
||||
def __init__(self):
|
||||
self.stdin = FakeStdin()
|
||||
self.stdout = []
|
||||
def poll(self):
|
||||
return None
|
||||
def wait(self, timeout=None):
|
||||
return 0
|
||||
def kill(self):
|
||||
pass
|
||||
|
||||
fake = FakeProcess()
|
||||
monkeypatch.setattr(speech_live, "_ensure_binary", lambda: tmp_path / "live")
|
||||
monkeypatch.setattr(speech_live.subprocess, "Popen", lambda *args, **kwargs: fake)
|
||||
session = speech_live.SpeechLiveSession("en-US")
|
||||
session.events.put({"ok": True, "event": "final", "text": "hello"})
|
||||
result = session.transcribe_chunk(b"wav")
|
||||
assert result["text"] == "hello"
|
||||
assert bytes(fake.stdin.data[:4]) == b"\x00\x00\x00\x03"
|
||||
session.close()
|
||||
+58
-251
@@ -1,268 +1,75 @@
|
||||
"""Tests for Notes deferred status — no Notes integration may exist.
|
||||
"""Regression tests for direct mutable Apple Notes CLI support.
|
||||
|
||||
Assert:
|
||||
- no `notes.` protocol ops
|
||||
- no native_notes wrappers
|
||||
- no CLI Notes authorization/subcommands
|
||||
- AppleEvents usage key forbidden and absent from plist/app policy
|
||||
- no AppKit link/import
|
||||
- docs correctly say deferred + legacy untouched
|
||||
Notes are deliberately implemented in Python through a fixed JXA script, not
|
||||
in the stable Swift privacy host. That preserves the signed launcher binary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import plistlib
|
||||
from pathlib import Path
|
||||
import re
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from reyna_cli.cli import app
|
||||
from reyna_cli.notes_direct import create_note, list_notes, read_note
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_no_notes_protocol_ops_in_privacy_contract():
|
||||
from reyna_cli.privacy_contract import ALLOWED_OPERATIONS, _COMMAND_TO_OPERATION
|
||||
def _success_runner(expected_payload, result):
|
||||
def run(argv, **kwargs):
|
||||
assert argv[:5] == ["/usr/bin/osascript", "-l", "JavaScript", "-e", argv[4]]
|
||||
assert argv[5] == "--"
|
||||
assert json.loads(argv[6]) == expected_payload
|
||||
return SimpleNamespace(returncode=0, stdout=json.dumps(result), stderr="")
|
||||
|
||||
# No operation may start with notes.
|
||||
for op in ALLOWED_OPERATIONS.keys():
|
||||
assert not op.startswith("notes."), f"forbidden notes op {op} still present — Notes is deferred"
|
||||
|
||||
# Explicit strings must not appear
|
||||
forbidden_ops = {"notes.list", "notes.read", "notes.create", "notes.request_access"}
|
||||
for fo in forbidden_ops:
|
||||
assert fo not in ALLOWED_OPERATIONS, f"forbidden {fo} present"
|
||||
|
||||
for cmd, op in _COMMAND_TO_OPERATION.items():
|
||||
assert not op.startswith("notes."), f"command {cmd} maps to forbidden notes op {op}"
|
||||
assert "notes" not in cmd.lower() or "notes" in cmd.lower() and False is False # allow key detection via op only
|
||||
# also forbid notes_* commands mapping
|
||||
assert not cmd.startswith("notes_"), f"forbidden notes command {cmd}"
|
||||
return run
|
||||
|
||||
|
||||
def test_no_native_notes_wrappers():
|
||||
src = (REPO / "src" / "reyna_cli" / "privacy_host.py").read_text()
|
||||
assert "native_notes" not in src, "native_notes wrappers must be removed — Notes deferred"
|
||||
assert "NotesProvider" not in src
|
||||
assert "NotesAuthorization" not in src
|
||||
# generic notes field as parameter name for calendar/reminders is allowed,
|
||||
# but operation names notes.list etc are forbidden — already checked by string search above
|
||||
# Ensure no wrapper defs remain
|
||||
for name in ["native_notes_list", "native_notes_read", "native_notes_create", "native_notes_request_access"]:
|
||||
assert name not in src
|
||||
def test_list_notes_uses_fixed_jxa_and_json_argument():
|
||||
notes = list_notes(
|
||||
query="family",
|
||||
folder="Adolfo",
|
||||
include_preview=True,
|
||||
limit=7,
|
||||
runner=_success_runner(
|
||||
{"query": "family", "folder": "Adolfo", "includePreview": True, "limit": 7},
|
||||
[{"id": "n1", "title": "Family", "folder": "Adolfo"}],
|
||||
),
|
||||
)
|
||||
assert notes == [{"id": "n1", "title": "Family", "folder": "Adolfo"}]
|
||||
|
||||
|
||||
def test_no_cli_notes_subcommands():
|
||||
cli_src = (REPO / "src" / "reyna_cli" / "cli.py").read_text()
|
||||
# macmini notes subcommands must be gone
|
||||
assert "macmini_notes" not in cli_src, "macmini_notes commands must be removed"
|
||||
# CLI must not register a notes typer under macmini
|
||||
# Check that macmini help mentions notes is gone — we test via source: no notes app registration
|
||||
# Allow word 'notes' as parameter name (calendar notes, reminder notes) — but not as subcommand registration
|
||||
# So forbid 'notes' app creation for macmini
|
||||
# Look for macmini_notes typed list/read/create defs
|
||||
assert "def macmini_notes" not in cli_src
|
||||
# Privacy-host notes-authorize must be gone
|
||||
assert "notes-authorize" not in cli_src
|
||||
assert "notes_authorize" not in cli_src
|
||||
# native_notes usage in cli must be gone
|
||||
assert "native_notes" not in cli_src
|
||||
# Ensure NotesProvider strings absent
|
||||
assert "NotesProvider" not in cli_src
|
||||
def test_read_and_create_notes_use_json_arguments_without_script_interpolation():
|
||||
note = read_note(
|
||||
"x-coredata://note-1",
|
||||
runner=_success_runner(
|
||||
{"id": "x-coredata://note-1"},
|
||||
{"id": "x-coredata://note-1", "title": "Existing", "plaintext": "body"},
|
||||
),
|
||||
)
|
||||
assert note["title"] == "Existing"
|
||||
|
||||
created = create_note(
|
||||
"Test <title>",
|
||||
"One & two\nthree",
|
||||
folder="Adolfo",
|
||||
runner=_success_runner(
|
||||
{"title": "Test <title>", "body": "One & two\nthree", "folder": "Adolfo"},
|
||||
{"id": "new-1", "title": "Test <title>", "folder": "Adolfo"},
|
||||
),
|
||||
)
|
||||
assert created["id"] == "new-1"
|
||||
|
||||
|
||||
def test_appleevents_usage_forbidden():
|
||||
from reyna_cli import app_bundle as ab
|
||||
def test_notes_cli_is_available_at_top_level_and_macmini_alias(monkeypatch):
|
||||
monkeypatch.setattr("reyna_cli.cli.list_notes", lambda **_: [{"id": "n1", "title": "Test"}])
|
||||
|
||||
d = ab.build_app_bundle_info_plist_dict()
|
||||
assert "NSAppleEventsUsageDescription" not in d, "AppleEvents usage must be forbidden — Notes deferred"
|
||||
# Also check Xcode source plist
|
||||
xcode_plist = REPO / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "Info.plist"
|
||||
assert xcode_plist.exists()
|
||||
with open(xcode_plist, "rb") as f:
|
||||
xd = plistlib.load(f)
|
||||
assert "NSAppleEventsUsageDescription" not in xd, "Xcode Info.plist must not contain AppleEvents"
|
||||
top_level = runner.invoke(app, ["notes", "list", "--json"])
|
||||
assert top_level.exit_code == 0
|
||||
assert json.loads(top_level.stdout)["notes"][0]["id"] == "n1"
|
||||
|
||||
# App bundle policy: forbidden key must be flagged
|
||||
# Validate that forbidden set includes only Calendar, Contacts, Reminders
|
||||
allowed = {
|
||||
"NSCalendarsFullAccessUsageDescription",
|
||||
"NSCalendarsWriteOnlyAccessUsageDescription",
|
||||
"NSCalendarsUsageDescription",
|
||||
"NSContactsUsageDescription",
|
||||
"NSRemindersFullAccessUsageDescription",
|
||||
}
|
||||
for k in d.keys():
|
||||
if k.startswith("NS") and "UsageDescription" in k:
|
||||
assert k in allowed, f"unexpected usage key {k} — only calendar/contacts/reminders allowed (Notes deferred)"
|
||||
|
||||
|
||||
def test_app_bundle_validator_forbids_appleevents():
|
||||
from reyna_cli import app_bundle as ab
|
||||
import tempfile
|
||||
|
||||
repo_root = Path(tempfile.mkdtemp()) / "repo"
|
||||
bundle = repo_root / "native" / "ReynaCLIHost" / "dist" / "Reyna CLI.app"
|
||||
contents = bundle / "Contents"
|
||||
macos = contents / "MacOS"
|
||||
macos.mkdir(parents=True)
|
||||
exe = macos / "ReynaCLIHost"
|
||||
exe.write_bytes(b"binary")
|
||||
exe.chmod(0o755)
|
||||
|
||||
class Proc:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = "TeamIdentifier=TEAM123\nAuthority=Apple Development: Foo (TEAM123)\n"
|
||||
|
||||
def runner_ok(args, cwd=None, **kwargs):
|
||||
return Proc()
|
||||
|
||||
good = ab.build_app_bundle_info_plist_dict()
|
||||
with open(contents / "Info.plist", "wb") as f:
|
||||
plistlib.dump(good, f)
|
||||
res_ok = ab.validate_app_bundle(repo_root=repo_root, runner=runner_ok)
|
||||
assert res_ok["ok"] is True, f"should accept plist without AppleEvents: {res_ok['errors']}"
|
||||
|
||||
bad = dict(good)
|
||||
bad["NSAppleEventsUsageDescription"] = "Allow automation"
|
||||
with open(contents / "Info.plist", "wb") as f:
|
||||
plistlib.dump(bad, f)
|
||||
res_bad = ab.validate_app_bundle(repo_root=repo_root, runner=runner_ok)
|
||||
assert res_bad["ok"] is False
|
||||
assert any("appleevents" in e.lower() or "unexpected" in e.lower() or "forbidden" in e.lower() for e in res_bad["errors"])
|
||||
|
||||
|
||||
def test_no_appkit_link_or_import():
|
||||
# AppMain and AppEntry must not import AppKit, must not contain AppleEvents
|
||||
app_main = (REPO / "native" / "ReynaCLIHost" / "ReynaCLIHost" / "AppMain.swift").read_text()
|
||||
app_entry = (REPO / "native" / "ReynaCLIHost" / "Sources" / "ReynaCLIHostCore" / "AppEntry.swift").read_text()
|
||||
for txt, label in [(app_main, "AppMain"), (app_entry, "AppEntry")]:
|
||||
assert "AppKit" not in txt, f"{label} must not import AppKit — Notes deferred, headless"
|
||||
assert "NSAppleScript" not in txt
|
||||
assert "NSAppleEvent" not in txt
|
||||
assert "AppleEvents" not in txt
|
||||
|
||||
# Package.swift must not link AppKit
|
||||
pkg = (REPO / "native" / "ReynaCLIHost" / "Package.swift").read_text()
|
||||
assert "AppKit" not in pkg
|
||||
# pbxproj must not contain AppKit, NotesProvider, NotesAuthorization, ForegroundApp, AppleEventsUsageDescription
|
||||
pbx = (REPO / "native" / "ReynaCLIHost" / "ReynaCLIHost.xcodeproj" / "project.pbxproj").read_text()
|
||||
assert "AppKit.framework" not in pbx
|
||||
assert "NotesProvider.swift" not in pbx, "NotesProvider must not be in Xcode project — deferred"
|
||||
assert "NotesAuthorizationProvider.swift" not in pbx
|
||||
assert "ForegroundApp" not in pbx
|
||||
assert "ForegroundAuthorization" not in pbx
|
||||
assert "NSAppleEventsUsageDescription" not in pbx
|
||||
|
||||
# Protocol.swift must not reference notes operations
|
||||
proto = (REPO / "native" / "ReynaCLIHost" / "Sources" / "ReynaCLIHostCore" / "Protocol.swift").read_text()
|
||||
assert "notes.list" not in proto.lower()
|
||||
assert "notes.read" not in proto.lower()
|
||||
assert "notes.create" not in proto.lower()
|
||||
assert "notes.request_access" not in proto.lower()
|
||||
# Generic notes text field for calendar/reminders is allowed, but operation "notes." must not exist
|
||||
# Check for NotesProvider types
|
||||
assert "NotesProvider" not in proto
|
||||
assert "NotesAuthorization" not in proto
|
||||
assert "NoteListItem" not in proto and "NoteDetailItem" not in proto, "Notes data models must be removed"
|
||||
|
||||
|
||||
def test_no_notes_in_swift_sources():
|
||||
core_dir = REPO / "native" / "ReynaCLIHost" / "Sources" / "ReynaCLIHostCore"
|
||||
for p in core_dir.glob("*.swift"):
|
||||
txt = p.read_text()
|
||||
low = txt.lower()
|
||||
# forbid notes. ops
|
||||
assert "notes.list" not in low
|
||||
assert "notes.read" not in low
|
||||
assert "notes.create" not in low
|
||||
assert "notes.request_access" not in low
|
||||
# forbid NSAppleEvents and Notes-type providers
|
||||
assert "nsappleeventsusagedescription" not in low
|
||||
assert "NotesProvider" not in txt
|
||||
assert "NotesAuthorization" not in txt
|
||||
|
||||
|
||||
def test_swift_tests_deleted():
|
||||
tests_dir = REPO / "native" / "ReynaCLIHost" / "Tests" / "ReynaCLIHostTests"
|
||||
forbidden = ["NotesAuthorizationTests.swift", "NotesOperationsTests.swift", "ForegroundAuthorizationTests.swift"]
|
||||
for name in forbidden:
|
||||
assert not (tests_dir / name).exists(), f"{name} must be deleted"
|
||||
|
||||
|
||||
def test_python_notes_tests_deleted():
|
||||
assert not (REPO / "tests" / "test_foreground_notes_ls_authorize.py").exists()
|
||||
assert not (REPO / "tests" / "test_notes_authorization_boundary.py").exists()
|
||||
|
||||
|
||||
def test_docs_deferred_legacy_untouched():
|
||||
matrix_path = REPO / "docs" / "remaining-coverage-matrix.md"
|
||||
assert matrix_path.exists()
|
||||
txt = matrix_path.read_text()
|
||||
low = txt.lower()
|
||||
# Must say Notes deferred
|
||||
assert "notes" in low
|
||||
assert "deferred" in low, "docs must say Notes deferred"
|
||||
# Must say legacy untouched
|
||||
assert "legacy" in low
|
||||
assert "untouched" in low, "docs must say legacy untouched"
|
||||
# Must not say Notes belongs inside host as active work — should be in deferred section
|
||||
# Ensure no active plan to add NotesProvider now
|
||||
# Allow legacy mention but not as A TODO — check that if Notes is mentioned as A belongs, it's qualified as deferred
|
||||
# Simplest: ensure the doc contains explicit deferred banner
|
||||
assert "deferred" in txt, "doc must contain deferred word"
|
||||
# Ensure no forbidden old instructions about adding NotesProvider as immediate work without deferred qualifier
|
||||
# The deferred table should list Notes as deferred, not as Done A
|
||||
# We'll just ensure the word deferred appears near Notes line
|
||||
for line in txt.splitlines():
|
||||
if "notes" in line.lower() and ("list/read/create" in line.lower() or "notes.js" in line.lower()):
|
||||
# in that row, must mention deferred or C or out of scope
|
||||
assert "deferred" in line.lower() or "untouched" in txt.lower(), f"Notes row must mention deferred: {line}"
|
||||
|
||||
|
||||
def test_no_foreground_notes_references():
|
||||
# Search all source/test tree for forbidden patterns — but allow generic param named notes (calendar/reminder text)
|
||||
forbidden_exact = [
|
||||
"NotesProvider",
|
||||
"NotesAuthorization",
|
||||
"ForegroundApp",
|
||||
"foreground-notes",
|
||||
"NSAppleEventsUsageDescription",
|
||||
"notes.request_access",
|
||||
"native_notes",
|
||||
]
|
||||
exclude_dirs = {".venv", "__pycache__", ".git", "build", ".build", "DerivedData", "dist"}
|
||||
for pattern in forbidden_exact:
|
||||
for path in REPO.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
# skip excluded
|
||||
if any(part in exclude_dirs for part in path.parts):
|
||||
continue
|
||||
# skip backup
|
||||
if "backup" in path.parts:
|
||||
continue
|
||||
# Only check relevant extensions
|
||||
if path.suffix not in {".py", ".swift", ".plist", ".md", ".pbxproj", ".toml", ".yaml", ".yml"}:
|
||||
# also check .xcodeproj is dir, pbxproj covered
|
||||
if path.name != "project.pbxproj":
|
||||
continue
|
||||
# Skip this test file itself if pattern is mentioned in test strings — we need to allow self-reference check for patterns inside this file?
|
||||
# For this file we will skip self to avoid false positive on literal search
|
||||
if path.name == "test_notes_deferred.py" or "tests" in path.parts:
|
||||
continue
|
||||
# Skip docs remaining-coverage — allowed to mention pattern but must also say deferred; we already validated
|
||||
# However per task: eliminate only actual Notes integration references (do not remove generic calendar/reminder text fields named notes)
|
||||
# For forbidden patterns search, we strictly forbid integration references in source/test, not docs describing deferred
|
||||
if "docs/" in str(path) and pattern == "NSAppleEventsUsageDescription":
|
||||
continue
|
||||
if path.name == "app_bundle.py" and pattern == "NSAppleEventsUsageDescription":
|
||||
continue
|
||||
try:
|
||||
txt = path.read_text(errors="ignore")
|
||||
except Exception:
|
||||
continue
|
||||
if pattern in txt:
|
||||
# Allow generic reminder/calendar 'notes' param already excluded by exact list above — so any hit is real integration
|
||||
# But also allow mention in backup
|
||||
if "test_notes_deferred" in str(path):
|
||||
continue
|
||||
raise AssertionError(f"forbidden pattern '{pattern}' found in {path}")
|
||||
compatibility_alias = runner.invoke(app, ["macmini", "notes", "list", "--json"])
|
||||
assert compatibility_alias.exit_code == 0
|
||||
assert json.loads(compatibility_alias.stdout)["notes"][0]["title"] == "Test"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from reyna_cli.cli import app
|
||||
from reyna_cli.qwen_tts_direct import (
|
||||
JV_SAMPLE_TEXT,
|
||||
MODEL_ID,
|
||||
Qwen3TTSDirectClient,
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_qwen_defaults_match_gitea_voiceagent():
|
||||
client = Qwen3TTSDirectClient()
|
||||
assert client.model_id == MODEL_ID
|
||||
assert client.model_id == "mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16"
|
||||
assert client.ref_audio.name == "jv_voice_sample.wav"
|
||||
assert client.ref_text == JV_SAMPLE_TEXT
|
||||
assert client.instruct is None
|
||||
|
||||
|
||||
def test_qwen_config_is_offline_and_reports_jv_asset(tmp_path):
|
||||
ref_audio = tmp_path / "jv_voice_sample.wav"
|
||||
ref_audio.write_bytes(b"not-a-real-wav")
|
||||
client = Qwen3TTSDirectClient(ref_audio=ref_audio)
|
||||
|
||||
status = client.config_status()
|
||||
|
||||
assert status["source"] == "direct"
|
||||
assert status["engine"] == "qwen3-tts"
|
||||
assert status["model_id"] == MODEL_ID
|
||||
assert status["ref_audio"] == str(ref_audio)
|
||||
assert status["ref_audio_exists"] is True
|
||||
assert status["ref_text_configured"] is True
|
||||
assert status["offline"] is True
|
||||
|
||||
|
||||
def test_qwen_validation_rejects_missing_text_or_reference(tmp_path):
|
||||
client = Qwen3TTSDirectClient(ref_audio=tmp_path / "missing.wav")
|
||||
|
||||
with pytest.raises(ValueError, match="text required"):
|
||||
client.validate_generate("")
|
||||
with pytest.raises(FileNotFoundError, match="reference audio"):
|
||||
client.validate_generate("hello")
|
||||
|
||||
|
||||
def test_qwen_cli_is_registered_alongside_kokoro():
|
||||
result = runner.invoke(app, ["local-services", "qwen3-tts", "--help"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "generate" in result.output
|
||||
assert "config" in result.output
|
||||
|
||||
|
||||
def test_qwen_cli_exposes_instruct_prompt():
|
||||
result = runner.invoke(app, ["local-services", "qwen3-tts", "generate", "--help"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "--instruct" in result.output
|
||||
@@ -1,11 +1,7 @@
|
||||
"""Tests for remaining coverage migration — fully consolidated no-Notes CLI.
|
||||
"""Tests for current direct-Notes and native-host coverage boundaries.
|
||||
|
||||
This file replaces the old Notes-native tests. Validates:
|
||||
- privacy contract contains only calendar/contacts/reminders/system/speech/apple_llm (no notes)
|
||||
- coverage matrix exists and says Notes deferred + legacy untouched
|
||||
- system info still works via native host
|
||||
- local-services direct wrappers offline safe (no Notes)
|
||||
- docs mention Notes deferred
|
||||
The native privacy contract remains Notes-free. Apple Notes is intentionally a
|
||||
mutable Python CLI route, so it does not require rebuilding the Swift host.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -24,11 +20,9 @@ def test_coverage_matrix_exists():
|
||||
p = Path(__file__).parents[1] / "docs" / "remaining-coverage-matrix.md"
|
||||
assert p.exists(), f"matrix doc missing at {p}"
|
||||
content = p.read_text()
|
||||
# Must mention Notes deferred
|
||||
assert "Notes" in content
|
||||
assert "deferred" in content.lower(), "matrix must say Notes deferred"
|
||||
assert "legacy" in content.lower()
|
||||
assert "untouched" in content.lower()
|
||||
assert "direct mutable reyna cli" in content.lower()
|
||||
assert "fixed jxa" in content.lower()
|
||||
assert "signed bundle" in content.lower()
|
||||
|
||||
|
||||
# ─── Privacy contract — no notes, deferred ────────────────────────────────
|
||||
@@ -88,12 +82,12 @@ def test_cli_system_info_uses_native(monkeypatch):
|
||||
assert payload["ok"] is True
|
||||
|
||||
|
||||
# ─── No Notes CLI ─────────────────────────────────────────────────────────
|
||||
# ─── Direct Notes CLI (outside the native privacy host) ───────────────────
|
||||
|
||||
def test_cli_macmini_no_notes_subcommand():
|
||||
def test_cli_macmini_notes_compatibility_subcommand():
|
||||
result = runner.invoke(app, ["macmini", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "notes" not in result.stdout.lower(), f"macmini must not list notes — Notes deferred, got: {result.stdout}"
|
||||
assert "notes" in result.stdout.lower()
|
||||
|
||||
|
||||
def test_cli_privacy_host_no_notes_authorize():
|
||||
|
||||
+1
-9
@@ -164,14 +164,6 @@ def test_devices_laptop_has_subcommands():
|
||||
assert "battery" in result.stdout
|
||||
|
||||
|
||||
def test_devices_arm_has_subcommands():
|
||||
result = runner.invoke(app, ["devices", "arm", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "state" in result.stdout
|
||||
assert "wave" in result.stdout
|
||||
assert "home" in result.stdout
|
||||
|
||||
|
||||
def test_immich_has_subcommands():
|
||||
result = runner.invoke(app, ["immich", "--help"])
|
||||
assert result.exit_code == 0
|
||||
@@ -217,7 +209,7 @@ def test_macmini_has_subcommands():
|
||||
assert result.exit_code == 0
|
||||
assert "calendar" in result.stdout
|
||||
assert "contacts" in result.stdout
|
||||
assert "notes" not in result.stdout, "Notes deferred — macmini must not list notes"
|
||||
assert "notes" in result.stdout
|
||||
assert "reminders" in result.stdout
|
||||
assert "deco" in result.stdout
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from reyna_cli.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_run_signed_python_invokes_bundle_with_python_and_forwards_args(tmp_path):
|
||||
from reyna_cli.signed_launcher import run_signed_python
|
||||
|
||||
executable = tmp_path / "ReynaCLIHost"
|
||||
executable.write_text("binary")
|
||||
calls = []
|
||||
|
||||
def fake_run(args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return SimpleNamespace(returncode=17)
|
||||
|
||||
result = run_signed_python(["local-services", "speech", "generate", "hello"], executable=executable, runner=fake_run)
|
||||
|
||||
assert result == 17
|
||||
assert calls == [(
|
||||
[str(executable), "--python", "local-services", "speech", "generate", "hello"],
|
||||
{"check": False},
|
||||
)]
|
||||
|
||||
|
||||
def test_run_signed_python_rejects_missing_bundle_executable(tmp_path):
|
||||
from reyna_cli.signed_launcher import SignedLauncherError, run_signed_python
|
||||
|
||||
try:
|
||||
run_signed_python(["doctor"], executable=tmp_path / "missing")
|
||||
except SignedLauncherError as exc:
|
||||
assert "signed Reyna CLI app executable" in str(exc)
|
||||
else:
|
||||
raise AssertionError("missing signed executable must fail")
|
||||
|
||||
|
||||
def test_signed_command_forwards_unknown_arguments(monkeypatch):
|
||||
from reyna_cli import signed_launcher
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_run(args):
|
||||
captured["args"] = args
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(signed_launcher, "run_signed_python", fake_run)
|
||||
|
||||
result = runner.invoke(app, ["signed", "local-services", "speech", "generate", "hello", "--voice", "Alex"])
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert captured["args"] == ["local-services", "speech", "generate", "hello", "--voice", "Alex"]
|
||||
|
||||
|
||||
def test_signed_command_reports_missing_signed_bundle(monkeypatch):
|
||||
from reyna_cli import signed_launcher
|
||||
|
||||
def fake_run(args):
|
||||
raise signed_launcher.SignedLauncherError("signed Reyna CLI app executable is missing")
|
||||
|
||||
monkeypatch.setattr(signed_launcher, "run_signed_python", fake_run)
|
||||
|
||||
result = runner.invoke(app, ["signed", "doctor"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "signed Reyna CLI app executable is missing" in result.stdout
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from reyna_cli.cli import app
|
||||
from reyna_cli.voice_direct import PocketTTSDirectClient, UnifiedVoiceClient
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_unified_voice_config_keeps_three_named_engines_separate(tmp_path):
|
||||
status = UnifiedVoiceClient(pocket_voice_state=tmp_path / "jv_pocket.pt").config_status()
|
||||
|
||||
assert set(status["engines"]) == {"kokoro", "pocket", "qwen3-tts"}
|
||||
assert status["engines"]["pocket"]["voice_state"] == str(tmp_path / "jv_pocket.pt")
|
||||
assert status["engines"]["qwen3-tts"]["model_id"] == "mlx-community/Qwen3-TTS-12Hz-0.6B-Base-bf16"
|
||||
|
||||
|
||||
def test_pocket_validation_requires_exact_custom_state(tmp_path):
|
||||
client = PocketTTSDirectClient(voice_state=tmp_path / "missing.pt")
|
||||
|
||||
try:
|
||||
client.validate_generate("hello")
|
||||
except FileNotFoundError as exc:
|
||||
assert "Pocket voice state" in str(exc)
|
||||
else:
|
||||
raise AssertionError("missing Pocket state must not silently fall back")
|
||||
|
||||
|
||||
def test_unified_voice_cli_exposes_explicit_engine_selection():
|
||||
result = runner.invoke(app, ["local-services", "voice", "generate", "--help"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "--engine" in result.output
|
||||
assert "kokoro" in result.output
|
||||
assert "pocket" in result.output
|
||||
assert "qwen3-tts" in result.output
|
||||
|
||||
|
||||
def test_unified_voice_config_cli_is_offline(monkeypatch):
|
||||
result = runner.invoke(app, ["local-services", "voice", "config", "--json"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert set(json.loads(result.stdout)["result"]["engines"]) == {"kokoro", "pocket", "qwen3-tts"}
|
||||
Reference in New Issue
Block a user