initial Commit

This commit is contained in:
2026-05-10 12:46:33 +02:00
commit 108f08645c
36 changed files with 8688 additions and 0 deletions

286
tests/test_backend.py Normal file
View File

@@ -0,0 +1,286 @@
from __future__ import annotations
import asyncio
import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from backend.database import Database
from backend.services.actions import ActionEngine
from backend.services.pico import parse_pico_line
from backend.services.plugins import PluginContext, PluginManager
from plugins.clipboard_tools import ClipboardToolsPlugin
from plugins.http_requests import HTTPRequestsPlugin
class DummyWs:
async def broadcast(self, *_args, **_kwargs):
return None
class DummyApp:
def __init__(self, db):
self.db = db
self.ws = DummyWs()
self.actions = type("Actions", (), {"keys": type("Keys", (), {"press_combo": staticmethod(_noop_press_combo)})()})()
def public_state(self):
return self.db.state()
async def _noop_press_combo(_combo):
return None
def test_parse_pico_event_accepts_current_firmware_json():
event = parse_pico_line('{"button":2,"pin":27,"event":"down","pressed":true}')
assert event is not None
assert event.button == 2
assert event.pin == 27
assert event.event == "down"
assert event.pressed is True
def test_parse_pico_event_ignores_startup_text():
assert parse_pico_line("streamdeck-pico ready") is None
assert parse_pico_line("pins=28,27,26") is None
def test_database_seeds_profiles_folders_and_buttons(tmp_path: Path):
db = Database(tmp_path / "streamdeck.sqlite")
state = db.state()
assert len(state["profiles"]) == 1
assert len(state["folders"]) == 1
assert len(state["buttons"]) == 10
def test_move_physical_button_swaps_mapping(tmp_path: Path):
db = Database(tmp_path / "streamdeck.sqlite")
folder_id = db.get_setting("active_folder_id")
db.move_physical_button(folder_id, position=1, physical_button=2)
buttons = [button for button in db.state()["buttons"] if button["folder_id"] == folder_id]
by_pos = {button["position"]: button["physical_button"] for button in buttons}
assert by_pos[1] == 2
assert by_pos[2] == 1
def test_physical_layout_syncs_to_all_profiles_and_folders_without_actions(tmp_path: Path):
db = Database(tmp_path / "streamdeck.sqlite")
first_profile_id = db.get_setting("active_profile_id")
canonical_folder_id = db.get_setting("active_folder_id")
extra_folder_id = db.create_folder(first_profile_id, canonical_folder_id, "Extra")
second_profile_id = db.create_profile("Second")
second_root_id = db.get_setting("active_folder_id")
second_button = next(
button for button in db.state()["buttons"]
if button["folder_id"] == second_root_id and button["position"] == 1
)
db.update_button(second_button["id"], {"action_type": "key_combo", "action_config": {"combo": "ctrl+s"}})
db.move_physical_button(canonical_folder_id, position=1, physical_button=2)
state = db.state()
for folder_id in {canonical_folder_id, extra_folder_id, second_root_id}:
buttons = [button for button in state["buttons"] if button["folder_id"] == folder_id]
by_pos = {button["position"]: button["physical_button"] for button in buttons}
assert by_pos[1] == 2
assert by_pos[2] == 1
updated_second_button = db.get_button(second_button["id"])
assert updated_second_button["action_type"] == "key_combo"
assert updated_second_button["action_config"] == {"combo": "ctrl+s"}
def test_physical_layout_cannot_be_changed_outside_canonical_folder(tmp_path: Path):
db = Database(tmp_path / "streamdeck.sqlite")
profile_id = db.get_setting("active_profile_id")
root_id = db.get_setting("active_folder_id")
folder_id = db.create_folder(profile_id, root_id, "Locked")
button = next(button for button in db.state()["buttons"] if button["folder_id"] == folder_id and button["position"] == 1)
try:
db.move_physical_button(folder_id, position=1, physical_button=2)
except ValueError as exc:
assert "first profile's root folder" in str(exc)
else:
raise AssertionError("Expected non-canonical folder move to fail.")
try:
db.update_button(button["id"], {"physical_button": 2})
except ValueError as exc:
assert "first profile's root folder" in str(exc)
else:
raise AssertionError("Expected non-canonical physical update to fail.")
def test_folder_rotation_moves_to_next_folder_and_wraps(tmp_path: Path):
db = Database(tmp_path / "streamdeck.sqlite")
profile_id = db.get_setting("active_profile_id")
root_id = db.get_setting("active_folder_id")
first_id = db.create_folder(profile_id, root_id, "First")
second_id = db.create_folder(profile_id, root_id, "Second")
db.set_setting("active_folder_id", root_id)
engine = ActionEngine(DummyApp(db))
asyncio.run(engine.execute("folder_rotation", {"direction": "next"}))
assert db.get_setting("active_folder_id") == first_id
asyncio.run(engine.execute("folder_rotation", {"direction": "next"}))
assert db.get_setting("active_folder_id") == second_id
asyncio.run(engine.execute("folder_rotation", {"direction": "next"}))
assert db.get_setting("active_folder_id") == root_id
def test_profiles_and_folders_can_be_renamed_and_deleted(tmp_path: Path):
db = Database(tmp_path / "streamdeck.sqlite")
profile_id = db.get_setting("active_profile_id")
root_id = db.get_setting("active_folder_id")
db.update_profile(profile_id, name="Renamed Profile")
db.update_folder(root_id, name="Renamed Root")
folder_id = db.create_folder(profile_id, root_id, "Temporary")
state = db.state()
assert state["profiles"][0]["name"] == "Renamed Profile"
assert next(folder for folder in state["folders"] if folder["id"] == root_id)["name"] == "Renamed Root"
db.delete_folder(folder_id)
assert all(folder["id"] != folder_id for folder in db.state()["folders"])
def test_first_profile_and_root_folder_are_protected(tmp_path: Path):
db = Database(tmp_path / "streamdeck.sqlite")
first_profile_id = db.get_setting("active_profile_id")
root_id = db.get_setting("active_folder_id")
try:
db.delete_profile(first_profile_id)
except ValueError as exc:
assert "first profile" in str(exc)
else:
raise AssertionError("Expected first profile delete to fail.")
try:
db.delete_folder(root_id)
except ValueError as exc:
assert "root folder" in str(exc)
else:
raise AssertionError("Expected root folder delete to fail.")
def test_plugin_failure_is_isolated(tmp_path: Path):
plugin_dir = tmp_path / "plugins"
plugin_dir.mkdir()
(plugin_dir / "bad.py").write_text("raise RuntimeError('boom')", encoding="utf-8")
manager = PluginManager(plugin_dir)
db = Database(tmp_path / "streamdeck.sqlite")
manager.load_all(PluginContext(DummyApp(db)))
plugins = manager.public_plugins()
assert plugins[0]["enabled"] is False
assert "boom" in plugins[0]["error"]
def test_http_request_plugin_posts_templated_json(tmp_path: Path):
received = {}
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", "0"))
received["path"] = self.path
received["token"] = self.headers.get("X-Test-Token")
received["body"] = self.rfile.read(length).decode("utf-8")
self.send_response(200)
self.end_headers()
self.wfile.write(b'{"ok":true}')
def log_message(self, *_args):
return None
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
db = Database(tmp_path / "streamdeck.sqlite")
plugin = HTTPRequestsPlugin()
plugin.execute_action(
PluginContext(DummyApp(db)),
"send_request",
{
"method": "POST",
"url": f"http://127.0.0.1:{server.server_port}/webhook/{{{{event.button}}}}",
"headers": [{"key": "X-Test-Token", "value": "button-{{event.button}}"}],
"body": '{"button":"{{event.button}}","event":"{{event.event}}"}',
"timeout": 3,
"log_response": True,
},
{"button": 4, "event": "down"},
)
finally:
server.shutdown()
thread.join(timeout=5)
assert received["path"] == "/webhook/4"
assert received["token"] == "button-4"
assert json.loads(received["body"]) == {"button": "4", "event": "down"}
def test_clipboard_plugin_copy_renders_event_tokens(tmp_path: Path):
db = Database(tmp_path / "streamdeck.sqlite")
plugin = ClipboardToolsPlugin()
writes = []
plugin._set_clipboard_text = writes.append
asyncio.run(
plugin.execute_action(
PluginContext(DummyApp(db)),
"copy_text",
{"text": "Button {{event.button}} went {{event.event}}"},
{"button": 7, "event": "up"},
)
)
assert writes == ["Button 7 went up"]
def test_clipboard_plugin_paste_restores_previous_value(tmp_path: Path):
db = Database(tmp_path / "streamdeck.sqlite")
plugin = ClipboardToolsPlugin()
writes = []
presses = []
plugin._get_clipboard_text = lambda: "original clipboard"
plugin._set_clipboard_text = writes.append
app = DummyApp(db)
app.actions = type(
"Actions",
(),
{"keys": type("Keys", (), {"press_combo": staticmethod(lambda combo: _record_combo(presses, combo))})()},
)()
asyncio.run(
plugin.execute_action(
PluginContext(app),
"paste_snippet",
{"text": "Snippet {{event.button}}", "restore_clipboard": True, "restore_delay_ms": 0},
{"button": 2},
)
)
assert writes == ["Snippet 2", "original clipboard"]
assert presses == ["ctrl+v"]
def test_clipboard_plugin_transform_modes_cover_common_cases():
plugin = ClipboardToolsPlugin()
assert plugin._transform_text("Mixed Value", {"transform": "uppercase"}) == "MIXED VALUE"
assert plugin._transform_text(" Mixed Value ", {"transform": "collapse_whitespace"}) == "Mixed Value"
assert plugin._transform_text("Mixed Value", {"transform": "snake_case"}) == "mixed_value"
assert plugin._transform_text("mixed value", {"transform": "camel_case"}) == "mixedValue"
assert plugin._transform_text("hello world", {"transform": "replace", "find": "world", "replace": "deck"}) == "hello deck"
assert plugin._transform_text("world", {"transform": "prefix", "extra_text": "hello "}) == "hello world"
async def _record_combo(store, combo):
store.append(combo)