Этап 8: настраиваемые команды
- commands.yaml с рабочими примерами: медиа, громкость, поиск, папки, блокировка; перечитывается автоматически, ошибки не ломают остальные команды - Действия run / open / http / keys; вывод и ответы возвращаются модели - Вызов через модель (tool calling) и мгновенно по точным фразам, в том числе с параметрами - Подтверждение «да/нет» для опасных команд, после голосового вопроса микрофон включается сам - Безопасность: запуск без оболочки, защита аргументов cmd/PowerShell/.bat, переменные окружения раскрываются только в шаблоне - Вкладка «Команды» в настройках - Тесты разбора, действий, фраз и полных сценариев через Assistant Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
84d8aaa848
commit
04029f3750
@@ -6,9 +6,11 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PySide6.QtCore import QCoreApplication
|
||||
from PySide6.QtCore import QCoreApplication, QObject, Signal
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from agr_assistent.commands import actions
|
||||
from agr_assistent.commands.catalog import CommandCatalog
|
||||
from agr_assistent.config import LLMConfig, ProviderConfig
|
||||
from agr_assistent.core.assistant import Assistant
|
||||
from agr_assistent.core.memory import MemoryStore
|
||||
@@ -52,7 +54,14 @@ def memory(tmp_path: Path) -> MemoryStore:
|
||||
store.close()
|
||||
|
||||
|
||||
def _assistant(server: FakeLLMServer, memory: MemoryStore, *, auto_save: bool = True) -> Assistant:
|
||||
def _assistant(
|
||||
server: FakeLLMServer,
|
||||
memory: MemoryStore,
|
||||
*,
|
||||
auto_save: bool = True,
|
||||
commands: CommandCatalog | None = None,
|
||||
voice: object = None,
|
||||
) -> Assistant:
|
||||
config = LLMConfig(
|
||||
provider="fake",
|
||||
providers={"fake": ProviderConfig("fake", server.base_url, "key", "fake-model")},
|
||||
@@ -62,7 +71,14 @@ def _assistant(server: FakeLLMServer, memory: MemoryStore, *, auto_save: bool =
|
||||
timeout_seconds=10,
|
||||
)
|
||||
speaker = Speaker(_SilentEngine(), _SilentPlayer(), enabled=False)
|
||||
return Assistant(config, speaker, None, memory=memory, memory_auto_save=auto_save)
|
||||
return Assistant(
|
||||
config,
|
||||
speaker,
|
||||
voice, # type: ignore[arg-type]
|
||||
memory=memory,
|
||||
memory_auto_save=auto_save,
|
||||
commands=commands,
|
||||
)
|
||||
|
||||
|
||||
def test_remember_request_runs_tool_and_answers(
|
||||
@@ -139,3 +155,190 @@ def test_auto_save_can_be_disabled(
|
||||
_wait_until(lambda: bool(finished))
|
||||
|
||||
assert "requested_by_user=false" not in fake_llm.requests[0]["messages"][0]["content"]
|
||||
|
||||
|
||||
# --- команды
|
||||
|
||||
_COMMANDS_YAML = """
|
||||
commands:
|
||||
- name: open_downloads
|
||||
description: Открыть папку «Загрузки»
|
||||
phrases: [открой загрузки]
|
||||
reply: Открываю.
|
||||
action: {type: open, target: "C:/Downloads"}
|
||||
- name: web_search
|
||||
description: Найти в интернете
|
||||
parameters:
|
||||
query: {type: string}
|
||||
action: {type: open, target: "https://example.com/?q={query}"}
|
||||
- name: shutdown_computer
|
||||
description: Выключить компьютер
|
||||
phrases: [выключи компьютер]
|
||||
confirm: true
|
||||
action: {type: open, target: "shutdown://now"}
|
||||
"""
|
||||
|
||||
|
||||
class _FakeVoice(QObject):
|
||||
listening_started = Signal()
|
||||
recognizing_started = Signal()
|
||||
finished = Signal()
|
||||
recognized = Signal(str)
|
||||
error_occurred = Signal(str)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.started = 0
|
||||
self.is_listening = False
|
||||
self.is_recognizing = False
|
||||
self.is_active = False
|
||||
|
||||
def start(self) -> None:
|
||||
self.started += 1
|
||||
|
||||
def stop(self) -> None:
|
||||
pass
|
||||
|
||||
def cancel(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def opened(monkeypatch: pytest.MonkeyPatch) -> list[str]:
|
||||
targets: list[str] = []
|
||||
monkeypatch.setattr(actions, "open_target", targets.append)
|
||||
return targets
|
||||
|
||||
|
||||
def _commands_assistant(
|
||||
server: FakeLLMServer, memory: MemoryStore, tmp_path: Path, voice: _FakeVoice | None = None
|
||||
) -> tuple[Assistant, CommandCatalog]:
|
||||
path = tmp_path / "commands.yaml"
|
||||
path.write_text(_COMMANDS_YAML, encoding="utf-8")
|
||||
catalog = CommandCatalog(path)
|
||||
return _assistant(server, memory, commands=catalog, voice=voice), catalog
|
||||
|
||||
|
||||
def _collect(assistant: Assistant) -> dict[str, list]:
|
||||
events: dict[str, list] = {"tools": [], "replies": [], "errors": []}
|
||||
assistant.tool_executed.connect(lambda display, ok: events["tools"].append((display, ok)))
|
||||
assistant.reply_finished.connect(events["replies"].append)
|
||||
assistant.error_occurred.connect(events["errors"].append)
|
||||
return events
|
||||
|
||||
|
||||
def test_exact_phrase_runs_without_model(
|
||||
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore, tmp_path: Path, opened: list[str]
|
||||
) -> None:
|
||||
assistant, _catalog = _commands_assistant(fake_llm, memory, tmp_path)
|
||||
events = _collect(assistant)
|
||||
|
||||
assistant.send("Открой загрузки!")
|
||||
_wait_until(lambda: bool(events["replies"]))
|
||||
|
||||
assert opened == ["C:/Downloads"]
|
||||
assert events["replies"] == ["Открываю."]
|
||||
assert events["tools"] == [("Открыл: Открыть папку «Загрузки»", True)]
|
||||
assert fake_llm.requests == []
|
||||
|
||||
|
||||
def test_model_calls_command_tool(
|
||||
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore, tmp_path: Path, opened: list[str]
|
||||
) -> None:
|
||||
fake_llm.replies += [
|
||||
Reply(tool_calls=[("web_search", '{"query": "погода в Казани"}')]),
|
||||
Reply(text="Открыл поиск."),
|
||||
]
|
||||
assistant, _catalog = _commands_assistant(fake_llm, memory, tmp_path)
|
||||
events = _collect(assistant)
|
||||
|
||||
assistant.send("Поищи погоду в Казани")
|
||||
_wait_until(lambda: bool(events["replies"]))
|
||||
|
||||
assert opened == ["https://example.com/?q=%D0%BF%D0%BE%D0%B3%D0%BE%D0%B4%D0%B0%20%D0%B2%20%D0%9A%D0%B0%D0%B7%D0%B0%D0%BD%D0%B8"]
|
||||
tool_names = {tool["function"]["name"] for tool in fake_llm.requests[0]["tools"]}
|
||||
assert {"web_search", "open_downloads", "remember"} <= tool_names
|
||||
assert "команды для управления компьютером" in fake_llm.requests[0]["messages"][0]["content"]
|
||||
assert events["replies"] == ["Открыл поиск."]
|
||||
|
||||
|
||||
def test_model_command_with_confirmation_waits_for_yes(
|
||||
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore, tmp_path: Path, opened: list[str]
|
||||
) -> None:
|
||||
fake_llm.replies += [
|
||||
Reply(tool_calls=[("shutdown_computer", "{}")]),
|
||||
Reply(text="Выключить компьютер?"),
|
||||
]
|
||||
assistant, _catalog = _commands_assistant(fake_llm, memory, tmp_path)
|
||||
events = _collect(assistant)
|
||||
|
||||
assistant.send("Выключи комп, пожалуйста")
|
||||
_wait_until(lambda: len(events["replies"]) == 1)
|
||||
assert opened == []
|
||||
assert events["tools"] == [("Ждёт подтверждения: Выключить компьютер", True)]
|
||||
assert "НЕ выполнена" in fake_llm.requests[1]["messages"][-1]["content"]
|
||||
|
||||
assistant.send("Да")
|
||||
_wait_until(lambda: len(events["replies"]) == 2)
|
||||
assert opened == ["shutdown://now"]
|
||||
assert events["replies"][1] == "Готово."
|
||||
assert len(fake_llm.requests) == 2 # «да» обработано без модели
|
||||
|
||||
|
||||
def test_phrase_confirmation_can_be_declined_or_dropped(
|
||||
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore, tmp_path: Path, opened: list[str]
|
||||
) -> None:
|
||||
fake_llm.replies.append(Reply(text="Не понял."))
|
||||
assistant, _catalog = _commands_assistant(fake_llm, memory, tmp_path)
|
||||
events = _collect(assistant)
|
||||
|
||||
assistant.send("Выключи компьютер")
|
||||
_wait_until(lambda: len(events["replies"]) == 1)
|
||||
assert events["replies"][0] == "Выполнить: Выключить компьютер? Скажите «да» или «нет»."
|
||||
|
||||
assistant.send("нет")
|
||||
_wait_until(lambda: len(events["replies"]) == 2)
|
||||
assert events["replies"][1] == "Хорошо, не выполняю."
|
||||
|
||||
# После отказа «да» уже ничего не подтверждает и уходит модели
|
||||
assistant.send("да")
|
||||
_wait_until(lambda: len(events["replies"]) == 3)
|
||||
assert opened == []
|
||||
assert len(fake_llm.requests) == 1
|
||||
|
||||
|
||||
def test_voice_confirmation_starts_listening(
|
||||
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore, tmp_path: Path, opened: list[str]
|
||||
) -> None:
|
||||
voice = _FakeVoice()
|
||||
assistant, _catalog = _commands_assistant(fake_llm, memory, tmp_path, voice)
|
||||
events = _collect(assistant)
|
||||
|
||||
assistant.send("выключи компьютер", by_voice=True)
|
||||
_wait_until(lambda: voice.started == 1)
|
||||
|
||||
assistant.send("открой загрузки", by_voice=True)
|
||||
_wait_until(lambda: len(events["replies"]) == 2)
|
||||
for _ in range(20):
|
||||
QCoreApplication.processEvents()
|
||||
time.sleep(0.005)
|
||||
assert voice.started == 1 # без вопроса микрофон сам не включается
|
||||
|
||||
|
||||
def test_commands_file_changes_are_picked_up(
|
||||
qapp: QApplication, fake_llm: FakeLLMServer, memory: MemoryStore, tmp_path: Path, opened: list[str]
|
||||
) -> None:
|
||||
assistant, catalog = _commands_assistant(fake_llm, memory, tmp_path)
|
||||
events = _collect(assistant)
|
||||
|
||||
catalog.path.write_text(
|
||||
_COMMANDS_YAML.replace("[открой загрузки]", "[покажи загрузки]")
|
||||
+ " - name: broken\n description: x\n action: {type: nope}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
catalog.reload()
|
||||
assistant.send("покажи загрузки")
|
||||
_wait_until(lambda: bool(events["replies"]))
|
||||
|
||||
assert opened == ["C:/Downloads"]
|
||||
assert any("broken" in error for error in events["errors"])
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agr_assistent.commands import actions
|
||||
from agr_assistent.commands.model import parse_command
|
||||
|
||||
|
||||
def _command(action: dict[str, Any], parameters: dict[str, Any] | None = None): # type: ignore[no-untyped-def]
|
||||
return parse_command(
|
||||
{"name": "test", "description": "Тест", "parameters": parameters, "action": action}
|
||||
)
|
||||
|
||||
|
||||
def test_substitution_encodes_urls_and_keeps_json_types() -> None:
|
||||
values = {"query": "кофе & чай", "level": 30, "on": True}
|
||||
|
||||
assert actions.substitute("q={query}", values, url_encode=True) == (
|
||||
"q=%D0%BA%D0%BE%D1%84%D0%B5%20%26%20%D1%87%D0%B0%D0%B9"
|
||||
)
|
||||
assert actions.substitute("{missing}-{level}", values) == "-30"
|
||||
assert actions.substitute_json(
|
||||
{"level": "{level}", "text": "уровень {level}", "flags": ["{on}"]}, values
|
||||
) == {"level": 30, "text": "уровень 30", "flags": [True]}
|
||||
|
||||
|
||||
def test_parameter_values_cannot_reach_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("TEST_SECRET_TOKEN", "секрет")
|
||||
values = {"value": "${TEST_SECRET_TOKEN}"}
|
||||
|
||||
assert actions.substitute_json("Bearer ${TEST_SECRET_TOKEN} {value}", values) == (
|
||||
"Bearer секрет ${TEST_SECRET_TOKEN}"
|
||||
)
|
||||
|
||||
|
||||
def test_run_waits_and_returns_output() -> None:
|
||||
command = _command(
|
||||
{
|
||||
"type": "run",
|
||||
"program": sys.executable,
|
||||
"args": ["-c", "import sys; print('привет', sys.argv[1])", "{name}"],
|
||||
"wait": True,
|
||||
},
|
||||
{"name": {"type": "string"}},
|
||||
)
|
||||
|
||||
outcome = actions.execute(command, {"name": "мир"})
|
||||
|
||||
assert outcome.ok
|
||||
assert "привет мир" in outcome.content
|
||||
|
||||
|
||||
def test_run_reports_exit_code_and_timeout() -> None:
|
||||
failing = _command(
|
||||
{"type": "run", "program": sys.executable, "args": ["-c", "raise SystemExit(3)"], "wait": True}
|
||||
)
|
||||
slow = _command(
|
||||
{
|
||||
"type": "run",
|
||||
"program": sys.executable,
|
||||
"args": ["-c", "import time; time.sleep(5)"],
|
||||
"wait": True,
|
||||
"timeout_seconds": 0.5,
|
||||
}
|
||||
)
|
||||
|
||||
failed = actions.execute(failing, {})
|
||||
assert not failed.ok and "Код завершения 3" in failed.content
|
||||
timed_out = actions.execute(slow, {})
|
||||
assert not timed_out.ok and "не завершилась" in timed_out.content
|
||||
assert not actions.execute(_command({"type": "run", "program": "no-such-program-xyz"}), {}).ok
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="bat-файлы есть только в Windows")
|
||||
def test_batch_file_rejects_cmd_metacharacters(tmp_path: Path) -> None:
|
||||
marker = tmp_path / "ran.txt"
|
||||
script = tmp_path / "echo.bat"
|
||||
script.write_text(f'@echo %1 > "{marker}"\n', encoding="utf-8")
|
||||
command = _command(
|
||||
{"type": "run", "program": str(script), "args": ["{text}"], "wait": True},
|
||||
{"text": {"type": "string"}},
|
||||
)
|
||||
|
||||
refused = actions.execute(command, {"text": "hi & calc"})
|
||||
assert not refused.ok and "недопустимые символы" in refused.content
|
||||
assert not marker.exists()
|
||||
|
||||
assert actions.execute(command, {"text": "hello"}).ok
|
||||
assert marker.exists()
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="cmd и powershell есть только в Windows")
|
||||
def test_shell_interpreters_reject_injection_but_run_plain_values() -> None:
|
||||
cmd = _command(
|
||||
{"type": "run", "program": "cmd", "args": ["/c", "echo", "{text}"], "wait": True},
|
||||
{"text": {"type": "string"}},
|
||||
)
|
||||
powershell = _command(
|
||||
{
|
||||
"type": "run",
|
||||
"program": "powershell",
|
||||
"args": ["-NoProfile", "-Command", "Write-Output '{text}'"],
|
||||
"wait": True,
|
||||
},
|
||||
{"text": {"type": "string"}},
|
||||
)
|
||||
|
||||
assert not actions.execute(cmd, {"text": "hi & calc"}).ok
|
||||
assert not actions.execute(powershell, {"text": "x'; Remove-Item C:\\temp"}).ok
|
||||
|
||||
echoed = actions.execute(cmd, {"text": "Привет"})
|
||||
assert echoed.ok and "Привет" in echoed.content # вывод cmd в OEM-кодировке декодируется
|
||||
written = actions.execute(powershell, {"text": "Мир"})
|
||||
assert written.ok and "Мир" in written.content
|
||||
|
||||
|
||||
def test_open_uses_encoded_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
opened: list[str] = []
|
||||
monkeypatch.setattr(actions, "open_target", opened.append)
|
||||
command = _command(
|
||||
{"type": "open", "target": "https://example.com/search?q={query}"},
|
||||
{"query": {"type": "string"}},
|
||||
)
|
||||
|
||||
assert actions.execute(command, {"query": "a b"}).ok
|
||||
assert opened == ["https://example.com/search?q=a%20b"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http_server() -> Iterator[tuple[str, list[dict[str, Any]]]]:
|
||||
received: list[dict[str, Any]] = []
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args: object) -> None:
|
||||
pass
|
||||
|
||||
def _respond(self) -> None:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
received.append(
|
||||
{
|
||||
"method": self.command,
|
||||
"path": self.path,
|
||||
"auth": self.headers.get("Authorization"),
|
||||
"body": json.loads(self.rfile.read(length)) if length else None,
|
||||
}
|
||||
)
|
||||
status = 500 if self.path.startswith("/fail") else 200
|
||||
payload = json.dumps({"state": "ok"}).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
do_GET = do_POST = _respond
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
yield f"http://127.0.0.1:{server.server_port}", received
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def test_http_sends_json_and_secret_headers(
|
||||
http_server: tuple[str, list[dict[str, Any]]], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
base_url, received = http_server
|
||||
monkeypatch.setenv("TEST_HA_TOKEN", "token123")
|
||||
command = _command(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"url": base_url + "/light/turn_{state}",
|
||||
"headers": {"Authorization": "Bearer ${TEST_HA_TOKEN}"},
|
||||
"json": {"entity_id": "light.room", "brightness": "{level}"},
|
||||
},
|
||||
{"state": {"type": "string", "enum": ["on", "off"]}, "level": {"type": "integer"}},
|
||||
)
|
||||
|
||||
outcome = actions.execute(command, {"state": "on", "level": 80})
|
||||
|
||||
assert outcome.ok and "HTTP 200" in outcome.content
|
||||
assert received == [
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/light/turn_on",
|
||||
"auth": "Bearer token123",
|
||||
"body": {"entity_id": "light.room", "brightness": 80},
|
||||
}
|
||||
]
|
||||
assert "token123" not in outcome.content + outcome.display
|
||||
|
||||
|
||||
def test_http_error_status_is_failure(http_server: tuple[str, list[dict[str, Any]]]) -> None:
|
||||
base_url, _received = http_server
|
||||
|
||||
outcome = actions.execute(_command({"type": "http", "url": base_url + "/fail"}), {})
|
||||
|
||||
assert not outcome.ok and "HTTP 500" in outcome.content
|
||||
assert not actions.execute(_command({"type": "http", "url": "http://127.0.0.1:9/"}), {}).ok
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="клавиши эмулируются только в Windows")
|
||||
def test_keys_repeat(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
pressed: list[list[int]] = []
|
||||
monkeypatch.setattr(actions, "send_keys", pressed.append)
|
||||
command = _command(
|
||||
{"type": "keys", "keys": "volume_up", "repeat": "{steps}"},
|
||||
{"steps": {"type": "integer", "default": 3}},
|
||||
)
|
||||
|
||||
assert actions.execute(command, {}).ok
|
||||
assert pressed == [[0xAF]] * 3
|
||||
@@ -0,0 +1,118 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agr_assistent.commands.catalog import default_commands_text
|
||||
from agr_assistent.commands.model import CommandError, load_commands, parse_command, parse_key_combo
|
||||
|
||||
|
||||
def _command(**overrides: object) -> dict:
|
||||
item = {"name": "test", "description": "Тест", "action": {"type": "open", "target": "https://x.io"}}
|
||||
item.update(overrides)
|
||||
return item
|
||||
|
||||
|
||||
def test_default_commands_file_is_valid(tmp_path: Path) -> None:
|
||||
path = tmp_path / "commands.yaml"
|
||||
path.write_text(default_commands_text(), encoding="utf-8")
|
||||
|
||||
commands, errors = load_commands(path)
|
||||
|
||||
assert errors == []
|
||||
assert {command.name for command in commands} >= {"media_play_pause", "volume_up", "web_search"}
|
||||
|
||||
|
||||
def test_commented_examples_are_valid_too(tmp_path: Path) -> None:
|
||||
"""Примеры в конце файла закомментированы — раскомментированные, они тоже должны загружаться."""
|
||||
lines = default_commands_text().splitlines()
|
||||
start = next(i for i, line in enumerate(lines) if "--- Примеры" in line)
|
||||
uncommented = lines[: start + 1] + [
|
||||
line.replace(" # ", " ", 1) if line.startswith(" # ") else line
|
||||
for line in lines[start + 1 :]
|
||||
]
|
||||
path = tmp_path / "commands.yaml"
|
||||
path.write_text("\n".join(uncommented), encoding="utf-8")
|
||||
|
||||
commands, errors = load_commands(path)
|
||||
|
||||
assert errors == []
|
||||
assert {"shutdown_computer", "disk_space", "room_light"} <= {c.name for c in commands}
|
||||
|
||||
|
||||
def test_parameters_schema_defaults_and_summary() -> None:
|
||||
command = parse_command(
|
||||
_command(
|
||||
parameters={
|
||||
"level": {"type": "integer", "minimum": 0, "maximum": 100},
|
||||
"steps": {"type": "integer", "default": 5},
|
||||
"note": "Просто описание",
|
||||
},
|
||||
action={"type": "keys", "keys": "volume_up", "repeat": "{steps}"},
|
||||
)
|
||||
)
|
||||
|
||||
assert command.schema() == {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"level": {"type": "integer", "minimum": 0, "maximum": 100},
|
||||
"steps": {"type": "integer"},
|
||||
"note": {"type": "string", "description": "Просто описание"},
|
||||
},
|
||||
"required": ["level", "note"],
|
||||
}
|
||||
assert command.with_defaults({"level": 30}) == {"steps": 5, "level": 30}
|
||||
assert command.summary({"level": 30}) == "Тест (steps: 5, level: 30)"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("overrides", "message"),
|
||||
[
|
||||
({"name": "плохое имя"}, "латиница"),
|
||||
({"name": "remember"}, "занято"),
|
||||
({"description": ""}, "описание"),
|
||||
({"action": {"type": "shell", "command": "rm"}}, "run, open, http или keys"),
|
||||
({"action": {"type": "run"}}, "program"),
|
||||
({"action": {"type": "http", "url": "ftp://x"}}, "http://"),
|
||||
({"action": {"type": "keys", "keys": "ctrl+bogus"}}, "неизвестная клавиша"),
|
||||
({"action": {"type": "open", "target": "https://x.io/{query}"}}, "неизвестные параметры"),
|
||||
(
|
||||
{"parameters": {"level": {"type": "integer"}}, "phrases": ["громкость"]},
|
||||
"не хватает обязательных",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_invalid_commands_are_rejected(overrides: dict, message: str) -> None:
|
||||
with pytest.raises(CommandError, match=message):
|
||||
parse_command(_command(**overrides))
|
||||
|
||||
|
||||
def test_one_broken_command_does_not_break_others(tmp_path: Path) -> None:
|
||||
path = tmp_path / "commands.yaml"
|
||||
path.write_text(
|
||||
"""
|
||||
commands:
|
||||
- name: good
|
||||
description: Хорошая
|
||||
action: {type: open, target: "https://x.io"}
|
||||
- name: bad
|
||||
description: Плохая
|
||||
action: {type: nope}
|
||||
- name: good
|
||||
description: Дубль
|
||||
action: {type: open, target: "https://y.io"}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
commands, errors = load_commands(path)
|
||||
|
||||
assert [command.name for command in commands] == ["good"]
|
||||
assert len(errors) == 2
|
||||
assert "bad" in errors[0] and "уже используется" in errors[1]
|
||||
|
||||
|
||||
def test_key_combos() -> None:
|
||||
assert parse_key_combo("ctrl+shift+esc") == [0x11, 0x10, 0x1B]
|
||||
assert parse_key_combo("volume_mute") == [0xAD]
|
||||
with pytest.raises(CommandError):
|
||||
parse_key_combo("ctrl+")
|
||||
@@ -0,0 +1,48 @@
|
||||
from agr_assistent.commands.matching import confirmation_decision, match_phrase, normalize
|
||||
from agr_assistent.commands.model import parse_command
|
||||
|
||||
_COMMANDS = [
|
||||
parse_command(
|
||||
{
|
||||
"name": "pause",
|
||||
"description": "Пауза",
|
||||
"phrases": ["Поставь на паузу", "пауза"],
|
||||
"action": {"type": "keys", "keys": "media_play_pause"},
|
||||
}
|
||||
),
|
||||
parse_command(
|
||||
{
|
||||
"name": "volume",
|
||||
"description": "Громкость",
|
||||
"phrases": ["громкость {level} процентов", "громкость {level}"],
|
||||
"parameters": {"level": {"type": "integer", "minimum": 0, "maximum": 100}},
|
||||
"action": {"type": "keys", "keys": "volume_up"},
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_normalize() -> None:
|
||||
assert normalize(" Ещё, пожалуйста!! ") == "еще пожалуйста"
|
||||
|
||||
|
||||
def test_whole_phrase_must_match() -> None:
|
||||
assert match_phrase(_COMMANDS, "Пауза.")[0].name == "pause"
|
||||
assert match_phrase(_COMMANDS, "поставь на паузу")[0].name == "pause"
|
||||
assert match_phrase(_COMMANDS, "пауза в работе") is None
|
||||
assert match_phrase(_COMMANDS, "") is None
|
||||
|
||||
|
||||
def test_phrase_parameters_are_validated() -> None:
|
||||
command, arguments = match_phrase(_COMMANDS, "Громкость 30 процентов")
|
||||
assert command.name == "volume" and arguments == {"level": 30}
|
||||
|
||||
assert match_phrase(_COMMANDS, "громкость 30")[1] == {"level": 30}
|
||||
assert match_phrase(_COMMANDS, "громкость 300") is None
|
||||
assert match_phrase(_COMMANDS, "громкость много") is None
|
||||
|
||||
|
||||
def test_confirmation_decision() -> None:
|
||||
assert confirmation_decision("Да!") is True
|
||||
assert confirmation_decision("не надо") is False
|
||||
assert confirmation_decision("да, но сначала сохрани файл") is None
|
||||
Reference in New Issue
Block a user