Add graphical controller profile editor
This commit is contained in:
parent
c2bfaa1d92
commit
eb03d94183
29 changed files with 6965 additions and 308 deletions
212
tests/test_profile_web.py
Normal file
212
tests/test_profile_web.py
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from switch_pico_bridge import config_manager, profile_web
|
||||
from tests.test_config_manager import FakeDevice, custom_profile
|
||||
|
||||
|
||||
@contextmanager
|
||||
def running_server(
|
||||
monkeypatch: pytest.MonkeyPatch, device: FakeDevice
|
||||
) -> Iterator[tuple[str, str]]:
|
||||
server = profile_web.ProfileEditorServer(
|
||||
("127.0.0.1", 0),
|
||||
bus=None,
|
||||
device_address=None,
|
||||
timeout=1.0,
|
||||
)
|
||||
monkeypatch.setattr(server, "find_device", lambda: device)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
yield f"http://127.0.0.1:{server.server_port}", server.mutation_token
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
def request_json(
|
||||
url: str,
|
||||
*,
|
||||
method: str = "GET",
|
||||
value: Any = None,
|
||||
token: str | None = None,
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
data = None
|
||||
headers: dict[str, str] = {}
|
||||
if value is not None:
|
||||
data = json.dumps(value).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
if token is not None:
|
||||
headers["X-Switch-Pico-Token"] = token
|
||||
request = urllib.request.Request(
|
||||
url, data=data, headers=headers, method=method
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=2) as response:
|
||||
return response.status, json.loads(response.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, json.loads(exc.read())
|
||||
|
||||
|
||||
def test_editor_serves_assets_and_complete_schema(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
with running_server(monkeypatch, FakeDevice()) as (base_url, _):
|
||||
with urllib.request.urlopen(f"{base_url}/", timeout=2) as response:
|
||||
page = response.read().decode("utf-8")
|
||||
assert response.headers["Content-Security-Policy"]
|
||||
with urllib.request.urlopen(f"{base_url}/app.js", timeout=2) as response:
|
||||
script_size = len(response.read())
|
||||
assert response.headers["Content-Type"].startswith("text/javascript")
|
||||
with urllib.request.urlopen(
|
||||
f"{base_url}/assets/controller-switch-pro.svg", timeout=2
|
||||
) as response:
|
||||
artwork_size = len(response.read())
|
||||
assert response.headers["Content-Type"] == "image/svg+xml"
|
||||
|
||||
|
||||
status, schema = request_json(f"{base_url}/api/schema")
|
||||
|
||||
assert status == 200
|
||||
assert "Profile Studio" in page
|
||||
assert script_size > 1000
|
||||
assert artwork_size > 10000
|
||||
assert schema["buttons"] == list(config_manager.LOGICAL_BUTTONS)
|
||||
assert schema["controls"] == list(config_manager.LOGICAL_CONTROLS)
|
||||
assert schema["rumble_policies"] == list(config_manager.RUMBLE_POLICIES)
|
||||
assert schema["turbo_modes"] == list(config_manager.TURBO_MODES)
|
||||
assert schema["macro_overrides"] == list(config_manager.MACRO_OVERRIDE_NAMES)
|
||||
assert (
|
||||
config_manager.ControllerProfile.from_json(
|
||||
json.dumps(schema["default_profile"])
|
||||
)
|
||||
== config_manager.ControllerProfile.default()
|
||||
)
|
||||
|
||||
|
||||
def test_editor_identifies_connected_controller_artwork(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
switch = config_manager.ControllerIdentity(
|
||||
True,
|
||||
config_manager.TRANSPORT_CLASSIC,
|
||||
0,
|
||||
bytes.fromhex("010203040506"),
|
||||
0x057E,
|
||||
0x2009,
|
||||
)
|
||||
dualsense = config_manager.ControllerIdentity(
|
||||
True,
|
||||
config_manager.TRANSPORT_CLASSIC,
|
||||
0,
|
||||
bytes.fromhex("111213141516"),
|
||||
0x054C,
|
||||
0x0CE6,
|
||||
)
|
||||
device.profile_identities = [
|
||||
device.global_identity,
|
||||
switch,
|
||||
dualsense,
|
||||
device.stable_identity,
|
||||
]
|
||||
device.active_profiles = {
|
||||
identity.to_bytes(): 0 for identity in device.profile_identities
|
||||
}
|
||||
|
||||
with running_server(monkeypatch, device) as (base_url, _):
|
||||
status, listing = request_json(f"{base_url}/api/profiles")
|
||||
|
||||
assert status == 200
|
||||
assert [
|
||||
identity["controller"] for identity in listing["identities"]
|
||||
] == [
|
||||
{"model": "Generic controller", "style": "generic"},
|
||||
{"model": "Nintendo Switch Pro Controller", "style": "switch"},
|
||||
{"model": "Sony DualSense", "style": "playstation"},
|
||||
{"model": "Xbox controller", "style": "xbox"},
|
||||
]
|
||||
|
||||
|
||||
def test_editor_reads_writes_and_activates_profiles_atomically(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
with running_server(monkeypatch, device) as (base_url, token):
|
||||
status, listing = request_json(f"{base_url}/api/profiles")
|
||||
assert status == 200
|
||||
assert listing["identities"][1]["active_profile"] == 2
|
||||
assert listing["identities"][1]["controller"] == {
|
||||
"model": "Xbox controller",
|
||||
"style": "xbox",
|
||||
}
|
||||
|
||||
status, selected = request_json(f"{base_url}/api/profiles/1/3")
|
||||
assert status == 200
|
||||
assert selected["active"] is False
|
||||
|
||||
profile = custom_profile().to_json_object()
|
||||
status, stored = request_json(
|
||||
f"{base_url}/api/profiles/1/3",
|
||||
method="PUT",
|
||||
value=profile,
|
||||
token=token,
|
||||
)
|
||||
assert status == 200
|
||||
assert stored["stored_generation"] == 8
|
||||
assert (
|
||||
config_manager.ControllerProfile.from_bytes(
|
||||
device.profiles[(device.stable_identity.to_bytes(), 2)]
|
||||
)
|
||||
== custom_profile()
|
||||
)
|
||||
assert device.profile_chunk_sizes == [40, 40, 40, 40, 40, 40, 16]
|
||||
|
||||
status, activated = request_json(
|
||||
f"{base_url}/api/profiles/1/3/activate",
|
||||
method="POST",
|
||||
token=token,
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
assert activated["stored_generation"] == 9
|
||||
assert device.active_profiles[device.stable_identity.to_bytes()] == 2
|
||||
|
||||
|
||||
def test_editor_rejects_invalid_or_unauthorized_mutations(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device = FakeDevice()
|
||||
original = device.profiles[(device.global_identity.to_bytes(), 0)]
|
||||
with running_server(monkeypatch, device) as (base_url, token):
|
||||
status, unauthorized = request_json(
|
||||
f"{base_url}/api/profiles/0/1",
|
||||
method="PUT",
|
||||
value=config_manager.ControllerProfile.default().to_json_object(),
|
||||
)
|
||||
assert status == 403
|
||||
assert "token" in unauthorized["error"]
|
||||
|
||||
malformed = config_manager.ControllerProfile.default().to_json_object()
|
||||
malformed["sticks"]["left"]["outer_saturation"] = 0
|
||||
status, invalid = request_json(
|
||||
f"{base_url}/api/profiles/0/1",
|
||||
method="PUT",
|
||||
value=malformed,
|
||||
token=token,
|
||||
)
|
||||
|
||||
assert status == 400
|
||||
assert "outer_saturation" in invalid["error"]
|
||||
assert device.profiles[(device.global_identity.to_bytes(), 0)] == original
|
||||
Loading…
Add table
Add a link
Reference in a new issue