Files
agr-assistent/tests/test_commands_model.py
mrleo1nidandClaude Opus 5 04029f3750 Этап 8: настраиваемые команды
- commands.yaml с рабочими примерами: медиа, громкость, поиск, папки, блокировка;
  перечитывается автоматически, ошибки не ломают остальные команды
- Действия run / open / http / keys; вывод и ответы возвращаются модели
- Вызов через модель (tool calling) и мгновенно по точным фразам, в том числе с параметрами
- Подтверждение «да/нет» для опасных команд, после голосового вопроса микрофон включается сам
- Безопасность: запуск без оболочки, защита аргументов cmd/PowerShell/.bat,
  переменные окружения раскрываются только в шаблоне
- Вкладка «Команды» в настройках
- Тесты разбора, действий, фраз и полных сценариев через Assistant

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 04:38:57 +03:00

119 lines
4.3 KiB
Python

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