- commands.yaml с рабочими примерами: медиа, громкость, поиск, папки, блокировка; перечитывается автоматически, ошибки не ломают остальные команды - Действия run / open / http / keys; вывод и ответы возвращаются модели - Вызов через модель (tool calling) и мгновенно по точным фразам, в том числе с параметрами - Подтверждение «да/нет» для опасных команд, после голосового вопроса микрофон включается сам - Безопасность: запуск без оболочки, защита аргументов cmd/PowerShell/.bat, переменные окружения раскрываются только в шаблоне - Вкладка «Команды» в настройках - Тесты разбора, действий, фраз и полных сценариев через Assistant Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
119 lines
4.3 KiB
Python
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+")
|