1333 lines
50 KiB
Python
1333 lines
50 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
import urllib.error
|
|
import urllib.request
|
|
from collections.abc import Iterator
|
|
from contextlib import contextmanager
|
|
from dataclasses import replace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
import usb.core
|
|
|
|
from switch_pico_bridge import config_manager, profile_web
|
|
from tests.test_config_manager import FakeDevice, custom_profile, native_rumble_identity
|
|
|
|
|
|
@contextmanager
|
|
def running_server(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
device: FakeDevice | None,
|
|
*,
|
|
operation_timeout: float = 1.0,
|
|
) -> Iterator[tuple[str, str]]:
|
|
server = profile_web.ProfileEditorServer(
|
|
("127.0.0.1", 0),
|
|
bus=None,
|
|
device_address=None,
|
|
timeout=operation_timeout,
|
|
)
|
|
if device is not None:
|
|
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:
|
|
response.read()
|
|
assert response.headers["Content-Security-Policy"]
|
|
with urllib.request.urlopen(f"{base_url}/app.js", timeout=2) as response:
|
|
response.read()
|
|
assert response.headers["Content-Type"].startswith("text/javascript")
|
|
for filename in (
|
|
"switch-pro-controller-simple.svg",
|
|
"switch-2-pro-controller-simple.svg",
|
|
"switch-2-joycon-left.svg",
|
|
"switch-2-joycon-single.svg",
|
|
"switch-2-joycons-connected.svg",
|
|
"ps5-dualsense-simple.svg",
|
|
"xbox-controller-simple.svg",
|
|
"wii-remote-simple.svg",
|
|
"wii-remote-nunchuk-simple.svg",
|
|
):
|
|
with urllib.request.urlopen(
|
|
f"{base_url}/assets/{filename}", timeout=2
|
|
) as response:
|
|
assert response.headers["Content-Type"] == "image/svg+xml"
|
|
assert b"<svg" in response.read()
|
|
for path in (
|
|
"/assets/controller-switch-pro.svg",
|
|
"/assets/controller-dualsense.svg",
|
|
"/assets/controller-xbox.svg",
|
|
"/assets/GAMEPAD_ASSET_LICENSE.txt",
|
|
"/assets/../profile_editor.html",
|
|
"/assets/%2e%2e/profile_editor.html",
|
|
"/assets/%2e%2e%2fprofile_editor.html",
|
|
"/assets/switch-2-joycon-left.svg/../profile_editor.html",
|
|
):
|
|
with pytest.raises(urllib.error.HTTPError) as error:
|
|
urllib.request.urlopen(f"{base_url}{path}", timeout=2)
|
|
assert error.value.code == 404
|
|
|
|
status, schema = request_json(f"{base_url}/api/schema")
|
|
|
|
assert status == 200
|
|
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["swing_sensitivities"] == ["low", "medium", "high"]
|
|
assert schema["combination_window_bounds"] == {"min": 30, "max": 200}
|
|
assert schema["macro_overrides"] == list(config_manager.MACRO_OVERRIDE_NAMES)
|
|
assert schema["profile_capacity"] == 8
|
|
assert (
|
|
config_manager.ControllerProfile.from_json(
|
|
json.dumps(schema["default_profile"])
|
|
)
|
|
== config_manager.ControllerProfile.default()
|
|
)
|
|
|
|
|
|
def test_native_hub_editor_discovers_root_and_preserves_saved_state(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
device = FakeDevice()
|
|
device.firmware_version = (0, 72, 0)
|
|
device.active_mode = 5
|
|
device.capabilities = 7
|
|
device.profiles[(device.global_identity.to_bytes(), 2)] = (
|
|
custom_profile().to_bytes()
|
|
)
|
|
configuration = config_manager.read_configuration(device)
|
|
pairings = config_manager.read_pairings(device)
|
|
profiles = device.profiles.copy()
|
|
active_profiles = device.active_profiles.copy()
|
|
|
|
def find(**arguments: object) -> tuple[FakeDevice, ...]:
|
|
if (arguments["idVendor"], arguments["idProduct"]) == (0x057E, 0x2068):
|
|
return (device,)
|
|
return ()
|
|
|
|
monkeypatch.setattr(config_manager.usb.core, "find", find)
|
|
with running_server(monkeypatch, None) as (base_url, token):
|
|
status, listing = request_json(f"{base_url}/api/profiles")
|
|
assert status == 200
|
|
assert [entry["key"] for entry in listing["identities"]] == [
|
|
identity.to_bytes().hex() for identity in device.profile_identities
|
|
]
|
|
status, selected = request_json(f"{base_url}/api/profiles/1/8")
|
|
assert status == 200
|
|
assert (
|
|
selected["profile"]
|
|
== config_manager.ControllerProfile.default().to_json_object()
|
|
)
|
|
|
|
draft = custom_profile().to_json_object()
|
|
status, _ = request_json(
|
|
f"{base_url}/api/profiles/1/8", method="PUT", value=draft, token=token
|
|
)
|
|
assert status == 200
|
|
status, _ = request_json(
|
|
f"{base_url}/api/profiles/1/8/activate", method="POST", token=token
|
|
)
|
|
assert status == 200
|
|
status, stored = request_json(f"{base_url}/api/profiles/1/8")
|
|
assert status == 200
|
|
assert stored["profile"] == draft
|
|
assert stored["active"] is True
|
|
|
|
profiles[(device.stable_identity.to_bytes(), 7)] = custom_profile().to_bytes()
|
|
active_profiles[device.stable_identity.to_bytes()] = 7
|
|
assert device.profiles == profiles
|
|
assert device.active_profiles == active_profiles
|
|
assert config_manager.read_configuration(device) == configuration
|
|
assert config_manager.read_pairings(device) == pairings
|
|
assert device.active_mode == 5
|
|
assert not {
|
|
config_manager.OP_MODE_SET,
|
|
config_manager.OP_REBOOT,
|
|
config_manager.OP_BOOTSEL_REBOOT,
|
|
}.intersection(device.requests)
|
|
|
|
|
|
def test_editor_disconnect_does_not_block_requests_and_rediscovers_adapter(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
device = FakeDevice()
|
|
attached = True
|
|
transfer = device.ctrl_transfer
|
|
|
|
def find(**arguments: object) -> tuple[FakeDevice, ...]:
|
|
if attached and (arguments["idVendor"], arguments["idProduct"]) == (
|
|
0xCAFE, 0x4010
|
|
):
|
|
return (device,)
|
|
return ()
|
|
|
|
def ctrl_transfer(*args: Any, **kwargs: Any) -> Any:
|
|
if not attached:
|
|
raise usb.core.USBError("device disconnected")
|
|
return transfer(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(config_manager.usb.core, "find", find)
|
|
monkeypatch.setattr(device, "ctrl_transfer", ctrl_transfer)
|
|
with running_server(
|
|
monkeypatch, None, operation_timeout=15.0
|
|
) as (base_url, _):
|
|
status, before = request_json(f"{base_url}/api/profiles")
|
|
assert status == 200
|
|
attached = False
|
|
status, _ = request_json(f"{base_url}/api/profiles/1/1/playtest")
|
|
assert status == 503
|
|
# request_json's two-second deadline is independent of the 15-second
|
|
# transaction timeout. Neither rediscovery nor the next request waits.
|
|
status, unavailable = request_json(f"{base_url}/api/profiles")
|
|
assert status == 400
|
|
assert "error" in unavailable
|
|
status, _ = request_json(f"{base_url}/api/schema")
|
|
assert status == 200
|
|
attached = True
|
|
device.address += 1
|
|
status, after = request_json(f"{base_url}/api/profiles")
|
|
assert status == 200
|
|
assert after == before
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("product_id", "extras"),
|
|
[
|
|
(0x2069, {"c", "gl", "gr"}),
|
|
(0x2067, {"left_sl", "left_sr"}),
|
|
(0x2066, {"c", "right_sl", "right_sr"}),
|
|
],
|
|
)
|
|
def test_switch2_source_choices_stay_physical_with_expanded_outputs(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
product_id: int,
|
|
extras: set[str],
|
|
) -> None:
|
|
device = FakeDevice()
|
|
identity = replace(device.stable_identity, vendor_id=0x057E, product_id=product_id)
|
|
device.profile_identities = [device.global_identity, identity]
|
|
device.active_profiles[identity.to_bytes()] = 0
|
|
with running_server(monkeypatch, device) as (base_url, _):
|
|
status, listing = request_json(f"{base_url}/api/profiles")
|
|
assert status == 200
|
|
status, schema = request_json(f"{base_url}/api/schema")
|
|
assert status == 200
|
|
owner = listing["identities"][1]
|
|
assert owner["controller"]["style"] == "switch"
|
|
assert set(owner["source_controls"]) & set(config_manager.EXTRA_BUTTONS) == extras
|
|
assert set(owner["modifier_controls"]) & set(config_manager.EXTRA_BUTTONS) == extras
|
|
assert set(schema["extra_buttons"]) & set(schema["output_controls"]) == {
|
|
"left_sl", "left_sr", "right_sl", "right_sr",
|
|
}
|
|
assert schema["output_controls"] == list(config_manager.OUTPUT_CONTROLS)
|
|
|
|
|
|
@pytest.mark.parametrize("version", [6, 7, 8, 9])
|
|
def test_editor_migrates_old_profiles_and_saves_swing_without_metadata_loss(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
version: int,
|
|
) -> None:
|
|
device = FakeDevice()
|
|
key = (device.stable_identity.to_bytes(), 1)
|
|
profile = custom_profile()
|
|
if version == 8:
|
|
profile = replace(profile, swing=config_manager.ProfileSwing(3, 0, 24))
|
|
legacy_wire = bytearray(profile.to_bytes())
|
|
legacy_wire[:2] = version.to_bytes(2, "little")
|
|
if version == 6:
|
|
legacy_wire[344:] = bytes(40)
|
|
elif version == 7:
|
|
legacy_wire[364:] = bytes(20)
|
|
elif version == 8:
|
|
legacy_wire[367:] = bytes(17)
|
|
else:
|
|
legacy_wire[376:] = bytes(8)
|
|
device.profiles[key] = bytes(legacy_wire)
|
|
device.profile_aliases[key[0]] = "Living room"
|
|
device.profile_names[key] = "Racing"
|
|
device.playtest_extra_buttons = 0x7F
|
|
with running_server(monkeypatch, device) as (base_url, token):
|
|
status, migrated = request_json(f"{base_url}/api/profiles/1/2")
|
|
assert status == 200
|
|
assert (
|
|
config_manager.ControllerProfile.from_json_object(migrated["profile"])
|
|
== profile
|
|
)
|
|
assert migrated["profile"]["swing"] == profile.swing.to_json_object()
|
|
assert migrated["profile"]["native_joycon_layout"] == "paired"
|
|
assert migrated["profile"]["swap_sticks"] is False
|
|
draft = migrated["profile"]
|
|
draft["extra_button_map"] = dict(
|
|
zip(config_manager.EXTRA_BUTTONS, config_manager.LOGICAL_BUTTONS[:7])
|
|
)
|
|
draft["shift"]["mode"] = "hold"
|
|
draft["shift"]["modifier"] = "c"
|
|
draft["shift"]["extra_button_map"]["right_sr"] = "dpad_right"
|
|
draft["macros"][0]["trigger"] = ["gl", "right_sr"]
|
|
draft["macros"][0]["cancel"] = "gr"
|
|
draft["switching_chord"] = ["left_sl", "left_sr"]
|
|
draft["motion_toggle_chord"] = ["right_sl", "c"]
|
|
draft["swing"] = {
|
|
"button": "west",
|
|
"sensitivity": "high",
|
|
"modifier": "right_trigger",
|
|
"macro": None,
|
|
}
|
|
draft["nunchuk_swing"].update(macro=1, sensitivity="low", modifier="c")
|
|
draft["combined_swing"].update(macro=2, modifier="left_trigger")
|
|
draft["combination_window_ms"] = 30
|
|
status, validated = request_json(
|
|
f"{base_url}/api/profiles/validate",
|
|
method="POST",
|
|
value=draft,
|
|
token=token,
|
|
)
|
|
assert status == 200
|
|
expected_profile = config_manager.ControllerProfile.from_json_object(draft)
|
|
assert (
|
|
config_manager.ControllerProfile.from_json_object(validated["profile"])
|
|
== expected_profile
|
|
)
|
|
assert device.profiles[key] == bytes(legacy_wire)
|
|
status, _ = request_json(
|
|
f"{base_url}/api/profiles/1/2",
|
|
method="PUT",
|
|
value=draft,
|
|
token=token,
|
|
)
|
|
assert status == 200
|
|
status, stored = request_json(f"{base_url}/api/profiles/1/2")
|
|
assert status == 200
|
|
assert (
|
|
config_manager.ControllerProfile.from_json_object(stored["profile"])
|
|
== expected_profile
|
|
)
|
|
assert stored["alias"] == "Living room"
|
|
assert stored["name"] == "Racing"
|
|
assert stored["active"] is True
|
|
status, sample = request_json(f"{base_url}/api/profiles/1/2/playtest")
|
|
assert status == 200
|
|
assert sample["extra_buttons"] == list(config_manager.EXTRA_BUTTONS)
|
|
assert sample["buttons"] == ["south", "dpad_up", "dpad_right"]
|
|
|
|
|
|
@pytest.mark.parametrize("gesture", ["swing", "nunchuk_swing", "combined_swing"])
|
|
def test_editor_rejects_unconfigured_gesture_macro_without_saving(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
gesture: str,
|
|
) -> None:
|
|
device = FakeDevice()
|
|
key = (device.stable_identity.to_bytes(), 1)
|
|
original = device.profiles[key]
|
|
draft = config_manager.ControllerProfile.default().to_json_object()
|
|
draft[gesture]["macro"] = 4
|
|
with running_server(monkeypatch, device) as (base_url, token):
|
|
for endpoint, method in (
|
|
("/api/profiles/validate", "POST"),
|
|
("/api/profiles/1/2", "PUT"),
|
|
):
|
|
status, result = request_json(
|
|
f"{base_url}{endpoint}",
|
|
method=method,
|
|
value=draft,
|
|
token=token,
|
|
)
|
|
assert status == 400
|
|
assert "error" in result
|
|
assert device.profiles[key] == original
|
|
|
|
|
|
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"]["style"], identity["controller"]["layout"])
|
|
for identity in listing["identities"]
|
|
] == [
|
|
("generic", "generic"),
|
|
("switch", "switch-pro"),
|
|
("playstation", "dualsense"),
|
|
("xbox", "xbox"),
|
|
]
|
|
assert [identity["key"] for identity in listing["identities"]] == [
|
|
identity.to_bytes().hex() for identity in device.profile_identities
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def joycon_pair_device() -> FakeDevice:
|
|
device = FakeDevice()
|
|
pair = config_manager.ControllerIdentity.from_bytes(
|
|
bytes.fromhex("0503102030405060C12233445566")
|
|
)
|
|
left, right = pair.joycon_pair_members()
|
|
device.profile_identities = [device.global_identity, left, right, pair]
|
|
device.stable_identity = pair
|
|
device.active_profiles = {
|
|
identity.to_bytes(): index
|
|
for index, identity in enumerate(device.profile_identities)
|
|
}
|
|
default_profile = config_manager.ControllerProfile.default().to_bytes()
|
|
device.profiles = {
|
|
(identity.to_bytes(), index): default_profile
|
|
for identity in device.profile_identities
|
|
for index in range(config_manager.PROFILE_CAPACITY)
|
|
}
|
|
device.playtest_layout = 3
|
|
device.playtest_motion = None
|
|
return device
|
|
|
|
|
|
def test_persistent_pair_owner_is_offline_capable_and_isolated_from_solo_banks(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
joycon_pair_device: FakeDevice,
|
|
) -> None:
|
|
device = joycon_pair_device
|
|
_, left, right, pair = device.profile_identities
|
|
device.playtest_connected = False
|
|
with running_server(monkeypatch, device) as (base_url, token):
|
|
status, listing = request_json(f"{base_url}/api/profiles")
|
|
assert status == 200
|
|
owners = listing["identities"]
|
|
assert [owner["key"] for owner in owners] == [
|
|
identity.to_bytes().hex() for identity in device.profile_identities
|
|
]
|
|
assert [owner["controller"]["layout"] for owner in owners[1:]] == [
|
|
"joycon2-left",
|
|
"joycon2-right",
|
|
"joycon2-pair",
|
|
]
|
|
owner = owners[3]
|
|
assert owner["controller"]["model"] != owners[1]["controller"]["model"]
|
|
assert "50:60" in owner["label"] and "55:66" in owner["label"]
|
|
assert owner["identity"]["members"]["left"]["address"] == left.address_text
|
|
assert owner["identity"]["members"]["left"]["address_type"] == 0
|
|
assert owner["identity"]["members"]["right"]["address"] == right.address_text
|
|
assert owner["identity"]["members"]["right"]["address_type"] == 1
|
|
assert set(owner["source_controls"]) & set(config_manager.EXTRA_BUTTONS) == {
|
|
"c",
|
|
"left_sl",
|
|
"left_sr",
|
|
"right_sl",
|
|
"right_sr",
|
|
}
|
|
status, offline = request_json(f"{base_url}/api/profiles/3/8/playtest")
|
|
assert status == 200
|
|
assert offline["connected"] is False
|
|
assert offline["controller"] == owner["controller"]
|
|
assert offline["source_controls"] == owner["source_controls"]
|
|
|
|
expected = {
|
|
index: config_manager.ControllerProfile.default().to_json_object()
|
|
for index in (1, 2, 3)
|
|
}
|
|
for bank, output in ((3, "north"), (1, "east"), (2, "west")):
|
|
expected[bank]["button_map"]["south"] = output
|
|
status, _ = request_json(
|
|
f"{base_url}/api/profiles/{bank}/8",
|
|
method="PUT",
|
|
value=expected[bank],
|
|
token=token,
|
|
)
|
|
assert status == 200
|
|
for other_bank, expected_profile in expected.items():
|
|
status, stored = request_json(f"{base_url}/api/profiles/{other_bank}/8")
|
|
assert status == 200
|
|
assert stored["profile"] == expected_profile
|
|
for path, value in (
|
|
("profiles/3/8/name", "Pair platformer"),
|
|
("identities/3/alias", "Couch pair"),
|
|
):
|
|
status, _ = request_json(
|
|
f"{base_url}/api/{path}",
|
|
method="PUT",
|
|
value={"value": value},
|
|
token=token,
|
|
)
|
|
assert status == 200
|
|
status, _ = request_json(
|
|
f"{base_url}/api/profiles/3/8/activate",
|
|
method="POST",
|
|
token=token,
|
|
)
|
|
assert status == 200
|
|
status, listing = request_json(f"{base_url}/api/profiles")
|
|
assert status == 200
|
|
assert [owner["active_profile"] for owner in listing["identities"]] == [
|
|
1,
|
|
2,
|
|
3,
|
|
8,
|
|
]
|
|
assert listing["identities"][3]["label"] == "Couch pair"
|
|
assert listing["identities"][3]["key"] == pair.to_bytes().hex()
|
|
for bank in (1, 2, 3):
|
|
status, stored = request_json(f"{base_url}/api/profiles/{bank}/8")
|
|
assert status == 200
|
|
assert stored["name"] == ("Pair platformer" if bank == 3 else "")
|
|
assert stored["alias"] == ("Couch pair" if bank == 3 else "")
|
|
|
|
device.playtest_connected = True
|
|
for bank in (1, 2, 3):
|
|
status, live = request_json(f"{base_url}/api/profiles/{bank}/8/playtest")
|
|
assert status == 200
|
|
assert live["owner_key"] == device.profile_identities[bank].to_bytes().hex()
|
|
assert live["identity_key"] == pair.to_bytes().hex()
|
|
assert (live["identity_key"] == live["owner_key"]) == (bank == 3)
|
|
assert live["label"] == "Couch pair"
|
|
other_pair = config_manager.ControllerIdentity.make_joycon_pair(
|
|
left, replace(right, address=bytes.fromhex("C12233445567"))
|
|
)
|
|
device.stable_identity = other_pair
|
|
status, other_live = request_json(f"{base_url}/api/profiles/3/8/playtest")
|
|
assert status == 200
|
|
assert other_live["owner_key"] == pair.to_bytes().hex()
|
|
assert other_live["identity_key"] == other_pair.to_bytes().hex()
|
|
assert other_live["owner_key"] != other_live["identity_key"]
|
|
|
|
|
|
def test_capture_cannot_bind_pair_input_to_a_solo_owner(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
joycon_pair_device: FakeDevice,
|
|
) -> None:
|
|
device = joycon_pair_device
|
|
left = device.profile_identities[1]
|
|
with running_server(monkeypatch, device) as (base_url, token):
|
|
status, _ = request_json(f"{base_url}/api/profiles/1/1/playtest")
|
|
assert status == 200
|
|
status, _ = request_json(
|
|
f"{base_url}/api/profiles/1/1/capture/start",
|
|
method="POST",
|
|
token=token,
|
|
value={
|
|
"owner_key": left.to_bytes().hex(),
|
|
"capture_id": "solo-bank-paired-input",
|
|
"slot": device.playtest_slot,
|
|
"connection_generation": device.playtest_connection_generation,
|
|
"macro_index": 0,
|
|
"profile": config_manager.ControllerProfile.default().to_json_object(),
|
|
"channels": 1,
|
|
"max_events": 8,
|
|
"axis_quantum": 512,
|
|
"trigger_quantum": 1024,
|
|
"max_duration_ms": 1000,
|
|
},
|
|
)
|
|
assert status == 400
|
|
|
|
|
|
@pytest.mark.parametrize("owner_index", [0, 1])
|
|
def test_live_layout_transitions_do_not_infer_topology_from_profile_owner(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
owner_index: int,
|
|
) -> None:
|
|
device = FakeDevice()
|
|
left = replace(device.stable_identity, vendor_id=0x057E, product_id=0x2067)
|
|
device.stable_identity = left
|
|
device.profile_identities = [device.global_identity, left]
|
|
device.active_profiles[left.to_bytes()] = 0
|
|
device.profiles[(left.to_bytes(), 0)] = (
|
|
config_manager.ControllerProfile.default().to_bytes()
|
|
)
|
|
device.playtest_motion = None
|
|
with running_server(monkeypatch, device) as (base_url, _):
|
|
for code, expected in (
|
|
(1, "joycon2-left"),
|
|
(3, "joycon2-pair"),
|
|
(1, "joycon2-left"),
|
|
):
|
|
device.playtest_layout = code
|
|
status, sample = request_json(
|
|
f"{base_url}/api/profiles/{owner_index}/1/playtest"
|
|
)
|
|
assert status == 200
|
|
assert sample["controller"]["layout"] == expected
|
|
assert (
|
|
sample["owner_key"]
|
|
== device.profile_identities[owner_index].to_bytes().hex()
|
|
)
|
|
assert sample["identity_key"] == left.to_bytes().hex()
|
|
assert sample["identity"]["is_joycon_pair"] is False
|
|
expected_extras = (
|
|
{"c", "left_sl", "left_sr", "right_sl", "right_sr"}
|
|
if code == 3
|
|
else {"left_sl", "left_sr"}
|
|
)
|
|
assert (
|
|
set(sample["source_controls"]) & set(config_manager.EXTRA_BUTTONS)
|
|
== expected_extras
|
|
)
|
|
status, listing = request_json(f"{base_url}/api/profiles")
|
|
assert status == 200
|
|
assert [owner["key"] for owner in listing["identities"]] == [
|
|
device.global_identity.to_bytes().hex(),
|
|
left.to_bytes().hex(),
|
|
]
|
|
assert listing["identities"][1]["controller"]["layout"] == "joycon2-left"
|
|
device.playtest_connected = False
|
|
status, offline = request_json(
|
|
f"{base_url}/api/profiles/{owner_index}/1/playtest"
|
|
)
|
|
assert status == 200
|
|
assert offline["connected"] is False
|
|
assert offline["layout"] is None
|
|
assert offline["controller"] == listing["identities"][owner_index]["controller"]
|
|
|
|
|
|
def test_live_metadata_cannot_turn_an_unrelated_identity_into_a_pair(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
device = FakeDevice()
|
|
device.playtest_layout = 3
|
|
with running_server(monkeypatch, device) as (base_url, _):
|
|
status, sample = request_json(f"{base_url}/api/profiles/0/1/playtest")
|
|
assert status == 200
|
|
assert sample["controller"]["layout"] == "xbox"
|
|
|
|
|
|
@pytest.mark.parametrize("product_id", [0x0306, 0x0330])
|
|
def test_wii_family_reference_preserves_sources_until_live_layout_is_known(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
product_id: int,
|
|
) -> None:
|
|
device = FakeDevice()
|
|
device.stable_identity = replace(
|
|
device.stable_identity, vendor_id=0x057E, product_id=product_id
|
|
)
|
|
device.profile_identities = [device.global_identity, device.stable_identity]
|
|
device.active_profiles[device.stable_identity.to_bytes()] = 0
|
|
device.profiles[device.stable_identity.to_bytes(), 0] = (
|
|
config_manager.ControllerProfile.default().to_bytes()
|
|
)
|
|
with running_server(monkeypatch, device) as (base_url, _):
|
|
status, listing = request_json(f"{base_url}/api/profiles")
|
|
assert status == 200
|
|
owner = listing["identities"][1]
|
|
assert owner["controller"]["layout"] == "wii-reference"
|
|
assert owner["controller"]["style"] == "wii"
|
|
assert owner["source_controls"] == list(config_manager.LOGICAL_CONTROLS)
|
|
device.playtest_connected = False
|
|
status, offline = request_json(f"{base_url}/api/profiles/1/1/playtest")
|
|
assert status == 200
|
|
assert offline["connected"] is False
|
|
assert offline["layout"] is None
|
|
assert offline["controller"] == owner["controller"]
|
|
assert offline["source_controls"] == owner["source_controls"]
|
|
device.playtest_connected = True
|
|
for code, expected in (
|
|
(0, "wii-reference"),
|
|
(4, "wii-remote"),
|
|
(5, "wii-nunchuk"),
|
|
(6, "wii-horizontal"),
|
|
(7, "wii-vertical"),
|
|
(0, "wii-reference"),
|
|
):
|
|
device.playtest_layout = code
|
|
status, sample = request_json(f"{base_url}/api/profiles/0/1/playtest")
|
|
assert status == 200
|
|
assert sample["controller"]["layout"] == expected
|
|
if code:
|
|
assert set(sample["source_controls"]).isdisjoint(
|
|
config_manager.EXTRA_BUTTONS
|
|
)
|
|
assert ("left_shoulder" in sample["source_controls"]) == (code == 5)
|
|
else:
|
|
assert sample["source_controls"] == owner["source_controls"]
|
|
|
|
|
|
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]["key"] == (
|
|
device.stable_identity.to_bytes().hex()
|
|
)
|
|
assert listing["identities"][1]["controller"]["layout"] == "xbox"
|
|
|
|
status, playtest = request_json(f"{base_url}/api/profiles/1/8/playtest")
|
|
assert status == 200
|
|
assert playtest["connected"] is True
|
|
assert playtest["controller"]["layout"] == "xbox"
|
|
assert playtest["buttons"] == [
|
|
"south",
|
|
"dpad_up",
|
|
"dpad_right",
|
|
]
|
|
assert playtest["left_stick"] == {"x": -1234, "y": 2345}
|
|
assert playtest["triggers"] == {"left": 123, "right": 65000}
|
|
assert playtest["capabilities"] == [
|
|
"rumble",
|
|
"lightbar",
|
|
"player_leds",
|
|
"motion",
|
|
]
|
|
|
|
status, selected = request_json(f"{base_url}/api/profiles/1/8")
|
|
assert status == 200
|
|
assert selected["active"] is False
|
|
|
|
profile = custom_profile().to_json_object()
|
|
status, stored = request_json(
|
|
f"{base_url}/api/profiles/1/8",
|
|
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(), 7)]
|
|
)
|
|
== custom_profile()
|
|
)
|
|
status, renamed = request_json(
|
|
f"{base_url}/api/profiles/1/8/name",
|
|
method="PUT",
|
|
value={"value": "Desktop"},
|
|
token=token,
|
|
)
|
|
assert status == 200
|
|
assert renamed["stored_generation"] == 9
|
|
assert device.profile_names[(device.stable_identity.to_bytes(), 7)] == "Desktop"
|
|
|
|
status, aliased = request_json(
|
|
f"{base_url}/api/identities/1/alias",
|
|
method="PUT",
|
|
value={"value": "Desk pad"},
|
|
token=token,
|
|
)
|
|
assert status == 200
|
|
assert aliased["label"] == "Desk pad"
|
|
|
|
status, identified = request_json(
|
|
f"{base_url}/api/identities/1/identify",
|
|
method="POST",
|
|
token=token,
|
|
)
|
|
assert status == 200
|
|
assert identified == {"identified": True}
|
|
assert device.identified_identities == [device.stable_identity.to_bytes()]
|
|
|
|
draft = config_manager.ControllerProfile.default().to_json_object()
|
|
draft["shortcuts"] = {
|
|
"modifier": "left_shoulder",
|
|
"profiles": ["south", None, None, None, None, None, None, "dpad_up"],
|
|
}
|
|
draft["shift"]["mode"] = "hold"
|
|
draft["shift"]["modifier"] = "right_shoulder"
|
|
draft["shift"]["button_map"]["south"] = "north"
|
|
draft["turbo"]["south"] = "burst"
|
|
draft["turbo_settings"]["overrides"]["south"] = {
|
|
"rate_hz": 7,
|
|
"duty_percent": 25,
|
|
"burst_count": 9,
|
|
}
|
|
draft["macros"][0]["playback"] = "repeat"
|
|
draft["macros"][0]["repeat_count"] = 4
|
|
status, copied = request_json(
|
|
f"{base_url}/api/profiles/1/8/copy",
|
|
method="POST",
|
|
value={
|
|
"identity_index": 1,
|
|
"profile_number": 4,
|
|
"profile": draft,
|
|
"name": "Draft copy",
|
|
},
|
|
token=token,
|
|
)
|
|
assert status == 200
|
|
assert copied["stored_generation"] == 12
|
|
assert (
|
|
device.profiles[(device.stable_identity.to_bytes(), 3)]
|
|
== config_manager.ControllerProfile.from_json_object(draft).to_bytes()
|
|
)
|
|
assert (
|
|
device.profile_names[(device.stable_identity.to_bytes(), 3)] == "Draft copy"
|
|
)
|
|
assert (
|
|
device.profiles[(device.stable_identity.to_bytes(), 7)]
|
|
== custom_profile().to_bytes()
|
|
)
|
|
|
|
status, activated = request_json(
|
|
f"{base_url}/api/profiles/1/8/activate",
|
|
method="POST",
|
|
token=token,
|
|
)
|
|
|
|
assert status == 200
|
|
assert activated["stored_generation"] == 13
|
|
assert device.active_profiles[device.stable_identity.to_bytes()] == 7
|
|
|
|
|
|
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
|
|
|
|
|
|
def test_joycon_mode_preserves_configuration_and_all_profile_banks(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
joycon_pair_device: FakeDevice,
|
|
) -> None:
|
|
device = joycon_pair_device
|
|
config_manager.write_configuration(
|
|
device,
|
|
config_manager.AdapterConfiguration(
|
|
135,
|
|
0,
|
|
0,
|
|
config_manager.REQUESTED_MODE_XINPUT,
|
|
(native_rumble_identity(),),
|
|
),
|
|
1.0,
|
|
)
|
|
for index, identity in enumerate(device.profile_identities):
|
|
key = identity.to_bytes()
|
|
device.profiles[(key, index)] = custom_profile().to_bytes()
|
|
device.profile_names[(key, index)] = f"Layout {index}"
|
|
device.profile_aliases[key] = f"Controller {index}"
|
|
before = config_manager.read_configuration(device)
|
|
profiles = device.profiles.copy()
|
|
names = device.profile_names.copy()
|
|
aliases = device.profile_aliases.copy()
|
|
active = device.active_profiles.copy()
|
|
pairings = config_manager.read_pairings(device)
|
|
with running_server(monkeypatch, device) as (base_url, token):
|
|
status, initial = request_json(f"{base_url}/api/joycon-mode")
|
|
assert status == 200
|
|
assert initial == {
|
|
"mode": "paired",
|
|
"generation": before.generation,
|
|
"supported": True,
|
|
}
|
|
for mode in ("individual", "paired"):
|
|
status, committed = request_json(
|
|
f"{base_url}/api/joycon-mode",
|
|
method="PUT",
|
|
value={"mode": mode},
|
|
token=token,
|
|
)
|
|
assert status == 200
|
|
stored = config_manager.read_configuration(device)
|
|
assert committed == {
|
|
"mode": mode,
|
|
"generation": stored.generation,
|
|
"supported": True,
|
|
}
|
|
assert stored.joycon_mode == config_manager.JOYCON_MODE_NAMES.index(mode)
|
|
assert stored.generation > before.generation
|
|
assert (
|
|
replace(
|
|
stored,
|
|
joycon_mode=before.joycon_mode,
|
|
generation=before.generation,
|
|
crc=before.crc,
|
|
)
|
|
== before
|
|
)
|
|
assert request_json(f"{base_url}/api/joycon-mode") == (200, committed)
|
|
assert device.profiles == profiles
|
|
assert device.profile_names == names
|
|
assert device.profile_aliases == aliases
|
|
assert device.active_profiles == active
|
|
assert config_manager.read_pairings(device) == pairings
|
|
assert device.reboot_transaction_ids == []
|
|
|
|
|
|
@pytest.mark.parametrize("schema", [1, 2, 3])
|
|
def test_joycon_mode_legacy_reads_default_and_refuses_mutations(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
schema: int,
|
|
) -> None:
|
|
device = FakeDevice()
|
|
config_manager.write_configuration(
|
|
device,
|
|
config_manager.AdapterConfiguration(
|
|
95,
|
|
0,
|
|
0,
|
|
native_switch_controllers=(native_rumble_identity(),)
|
|
if schema == 3
|
|
else (),
|
|
schema_version=schema,
|
|
),
|
|
1.0,
|
|
)
|
|
before = config_manager.read_configuration(device)
|
|
with running_server(monkeypatch, device) as (base_url, token):
|
|
status, legacy = request_json(f"{base_url}/api/joycon-mode")
|
|
assert status == 200
|
|
assert legacy == {
|
|
"mode": "paired",
|
|
"generation": before.generation,
|
|
"supported": False,
|
|
}
|
|
for mode in ("paired", "individual"):
|
|
status, _ = request_json(
|
|
f"{base_url}/api/joycon-mode",
|
|
method="PUT",
|
|
value={"mode": mode},
|
|
token=token,
|
|
)
|
|
assert status == 400
|
|
assert config_manager.read_configuration(device) == before
|
|
|
|
|
|
def test_joycon_mode_rejects_unauthorized_and_malformed_requests(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
device = FakeDevice()
|
|
config_manager.write_configuration(
|
|
device,
|
|
config_manager.AdapterConfiguration(110, 0, 0),
|
|
1.0,
|
|
)
|
|
before = config_manager.read_configuration(device)
|
|
with running_server(monkeypatch, device) as (base_url, token):
|
|
endpoint = f"{base_url}/api/joycon-mode"
|
|
for supplied_token in (None, "invalid"):
|
|
status, _ = request_json(
|
|
endpoint,
|
|
method="PUT",
|
|
value={"mode": "individual"},
|
|
token=supplied_token,
|
|
)
|
|
assert status == 403
|
|
for body in (
|
|
{},
|
|
[],
|
|
{"mode": 1},
|
|
{"mode": True},
|
|
{"mode": None},
|
|
{"mode": ["individual"]},
|
|
{"mode": "Individual"},
|
|
{"mode": "individual", "pairing_window_seconds": 10},
|
|
):
|
|
status, _ = request_json(
|
|
endpoint,
|
|
method="PUT",
|
|
value=body,
|
|
token=token,
|
|
)
|
|
assert status == 400
|
|
for raw in (
|
|
b"",
|
|
b"{",
|
|
b"\xff",
|
|
b" " * (profile_web._MAXIMUM_REQUEST_BYTES + 1),
|
|
):
|
|
request = urllib.request.Request(
|
|
endpoint,
|
|
data=raw,
|
|
method="PUT",
|
|
headers={"X-Switch-Pico-Token": token},
|
|
)
|
|
with pytest.raises(urllib.error.HTTPError) as rejected:
|
|
urllib.request.urlopen(request, timeout=2)
|
|
assert rejected.value.code == 400
|
|
for method in ("GET", "PUT"):
|
|
request = urllib.request.Request(
|
|
endpoint,
|
|
method=method,
|
|
data=b'{"mode":"individual"}' if method == "PUT" else None,
|
|
headers={"Host": "untrusted.example", "X-Switch-Pico-Token": token},
|
|
)
|
|
with pytest.raises(urllib.error.HTTPError) as rejected:
|
|
urllib.request.urlopen(request, timeout=2)
|
|
assert rejected.value.code == 403
|
|
assert config_manager.read_configuration(device) == before
|
|
|
|
|
|
def test_joycon_mode_commit_is_not_reported_as_confirmed_when_readback_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
device = FakeDevice()
|
|
config_manager.write_configuration(
|
|
device,
|
|
config_manager.AdapterConfiguration(110, 0, 0),
|
|
1.0,
|
|
)
|
|
read_configuration = config_manager.read_configuration
|
|
before = read_configuration(device)
|
|
|
|
def disconnect_after_commit(
|
|
device: config_manager.UsbDevice,
|
|
) -> config_manager.AdapterConfiguration:
|
|
configuration = read_configuration(device)
|
|
if configuration.generation != before.generation:
|
|
raise usb.core.USBError("device disconnected")
|
|
return configuration
|
|
|
|
monkeypatch.setattr(config_manager, "read_configuration", disconnect_after_commit)
|
|
with running_server(monkeypatch, device) as (base_url, token):
|
|
status, response = request_json(
|
|
f"{base_url}/api/joycon-mode",
|
|
method="PUT",
|
|
value={"mode": "individual"},
|
|
token=token,
|
|
)
|
|
assert status == 503
|
|
assert "error" in response
|
|
assert "mode" not in response
|
|
assert (
|
|
read_configuration(device).joycon_mode == config_manager.JOYCON_MODE_INDIVIDUAL
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("method", ["GET", "PUT"])
|
|
def test_joycon_mode_usb_failure_returns_service_unavailable(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
method: str,
|
|
) -> None:
|
|
device = FakeDevice()
|
|
before = device.configuration
|
|
|
|
def disconnected(*args: Any, **kwargs: Any) -> Any:
|
|
raise usb.core.USBError("device disconnected")
|
|
|
|
monkeypatch.setattr(device, "ctrl_transfer", disconnected)
|
|
with running_server(monkeypatch, device) as (base_url, token):
|
|
status, response = request_json(
|
|
f"{base_url}/api/joycon-mode",
|
|
method=method,
|
|
value={"mode": "individual"} if method == "PUT" else None,
|
|
token=token,
|
|
)
|
|
assert status == 503
|
|
assert "error" in response
|
|
assert device.configuration == before
|
|
|
|
|
|
def test_recorder_accepts_first_connection_generation_zero(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
device = FakeDevice()
|
|
device.playtest_connection_generation = 0
|
|
page = config_manager.MacroCapturePage(
|
|
0,
|
|
0,
|
|
0,
|
|
device.playtest_slot,
|
|
0,
|
|
1,
|
|
0,
|
|
0,
|
|
512,
|
|
1024,
|
|
10000,
|
|
8,
|
|
(),
|
|
)
|
|
|
|
def start(*args: Any, **kwargs: Any) -> config_manager.MacroCapturePage:
|
|
nonlocal page
|
|
page = replace(
|
|
page,
|
|
run_id=1,
|
|
state=1,
|
|
total_events=1,
|
|
events=(config_manager.MacroCaptureEvent(0, 0, 0, 0, 0, 0, 0, 0),),
|
|
)
|
|
return page
|
|
|
|
def stop(*args: Any) -> config_manager.MacroCapturePage:
|
|
nonlocal page
|
|
page = replace(page, state=2, elapsed_us=100000)
|
|
return page
|
|
|
|
monkeypatch.setattr(config_manager, "read_macro_capture", lambda *args: page)
|
|
monkeypatch.setattr(config_manager, "start_macro_capture", start)
|
|
monkeypatch.setattr(config_manager, "stop_macro_capture", stop)
|
|
monkeypatch.setattr(config_manager, "collect_macro_capture", lambda *args: page)
|
|
with running_server(monkeypatch, device) as (base_url, token):
|
|
status, _ = request_json(f"{base_url}/api/profiles/1/1/playtest")
|
|
assert status == 200
|
|
identity = device.stable_identity.to_bytes().hex()
|
|
status, started = request_json(
|
|
f"{base_url}/api/profiles/1/1/capture/start",
|
|
method="POST",
|
|
token=token,
|
|
value={
|
|
"capture_id": "first-connection",
|
|
"owner_key": identity,
|
|
"slot": device.playtest_slot,
|
|
"connection_generation": 0,
|
|
"macro_index": 0,
|
|
"profile": config_manager.ControllerProfile.default().to_json_object(),
|
|
"channels": 1,
|
|
"max_events": 8,
|
|
"axis_quantum": 512,
|
|
"trigger_quantum": 1024,
|
|
"max_duration_ms": 10000,
|
|
},
|
|
)
|
|
assert status == 200 and started["state_name"] == "recording"
|
|
status, stopped = request_json(
|
|
f"{base_url}/api/profiles/1/1/capture/stop",
|
|
method="POST",
|
|
token=token,
|
|
value={
|
|
"capture_id": "first-connection",
|
|
"owner_key": identity,
|
|
"connection_generation": 0,
|
|
"run_id": started["run_id"],
|
|
},
|
|
)
|
|
assert status == 200 and stopped["state_name"] == "stopped"
|
|
|
|
|
|
def test_wii_orientation_endpoint_validation_and_failures(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
device = FakeDevice()
|
|
with running_server(monkeypatch, device) as (base_url, token):
|
|
# Missing token
|
|
status, _ = request_json(
|
|
f"{base_url}/api/identities/1/wii-orientation",
|
|
method="POST",
|
|
value={"orientation": "horizontal", "connection_generation": 1},
|
|
)
|
|
assert status == 403
|
|
|
|
# Out of bounds identity index
|
|
status, _ = request_json(
|
|
f"{base_url}/api/identities/99/wii-orientation",
|
|
method="POST",
|
|
token=token,
|
|
value={"orientation": "horizontal", "connection_generation": 1},
|
|
)
|
|
assert status == 400
|
|
|
|
# Invalid / missing fields
|
|
for invalid_body in (
|
|
{},
|
|
{"orientation": "diagonal", "connection_generation": 1},
|
|
{"orientation": "horizontal"},
|
|
{"connection_generation": 1},
|
|
{"orientation": "horizontal", "connection_generation": -1},
|
|
{"orientation": "horizontal", "connection_generation": "1"},
|
|
{"orientation": 123, "connection_generation": 1},
|
|
):
|
|
status, _ = request_json(
|
|
f"{base_url}/api/identities/1/wii-orientation",
|
|
method="POST",
|
|
token=token,
|
|
value=invalid_body,
|
|
)
|
|
assert status == 400
|
|
|
|
# ConfigManagerError from config_manager
|
|
def failing_config(*args: Any, **kwargs: Any) -> None:
|
|
raise config_manager.ConfigManagerError("firmware rejected orientation")
|
|
|
|
monkeypatch.setattr(config_manager, "set_wii_orientation", failing_config)
|
|
status, err = request_json(
|
|
f"{base_url}/api/identities/1/wii-orientation",
|
|
method="POST",
|
|
token=token,
|
|
value={"orientation": "horizontal", "connection_generation": 1},
|
|
)
|
|
assert status == 400
|
|
assert "error" in err
|
|
|
|
# USB error from config_manager
|
|
def usb_failing_config(*args: Any, **kwargs: Any) -> None:
|
|
raise usb.core.USBError("USB pipe error")
|
|
|
|
monkeypatch.setattr(config_manager, "set_wii_orientation", usb_failing_config)
|
|
status, err = request_json(
|
|
f"{base_url}/api/identities/1/wii-orientation",
|
|
method="POST",
|
|
token=token,
|
|
value={"orientation": "horizontal", "connection_generation": 1},
|
|
)
|
|
assert status == 503
|
|
assert "error" in err
|
|
|
|
|
|
def test_native_layout_draft_roundtrip_is_isolated_from_other_profiles(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
device = FakeDevice()
|
|
original_profiles = dict(device.profiles)
|
|
original_active = dict(device.active_profiles)
|
|
original_configuration = device.configuration
|
|
key = (device.stable_identity.to_bytes(), 7)
|
|
with running_server(monkeypatch, device) as (base_url, token):
|
|
status, schema = request_json(f"{base_url}/api/schema")
|
|
assert status == 200
|
|
assert schema["native_joycon_layouts"] == ["paired", "left_solo", "right_solo"]
|
|
status, selected = request_json(f"{base_url}/api/profiles/1/8")
|
|
assert status == 200
|
|
draft = selected["profile"]
|
|
draft["native_joycon_layout"] = "right_solo"
|
|
draft["swap_sticks"] = True
|
|
draft["button_map"]["left_shoulder"] = "right_sl"
|
|
draft["button_map"]["right_shoulder"] = "right_sr"
|
|
draft["shift"].update(mode="hold", modifier="select")
|
|
draft["shift"]["button_map"]["south"] = "left_sl"
|
|
draft["triggers"]["left"]["output"] = "left_sr"
|
|
draft["sticks"]["left"]["inner_deadzone"] = 4000
|
|
status, validated = request_json(
|
|
f"{base_url}/api/profiles/validate", method="POST", value=draft, token=token
|
|
)
|
|
assert status == 200
|
|
assert validated["profile"] == draft
|
|
assert device.profiles == original_profiles
|
|
assert device.active_profiles == original_active
|
|
status, _ = request_json(
|
|
f"{base_url}/api/profiles/1/8", method="PUT",
|
|
value=validated["profile"], token=token,
|
|
)
|
|
assert status == 200
|
|
status, stored = request_json(f"{base_url}/api/profiles/1/8")
|
|
assert status == 200
|
|
assert stored["profile"] == draft
|
|
expected_profiles = dict(original_profiles)
|
|
expected_profiles[key] = config_manager.ControllerProfile.from_json_object(draft).to_bytes()
|
|
assert device.profiles == expected_profiles
|
|
assert device.active_profiles == original_active
|
|
assert device.configuration == original_configuration
|
|
|
|
|
|
def test_left_stick_direction_outputs_roundtrip_and_validation(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
device = FakeDevice()
|
|
original_profiles = dict(device.profiles)
|
|
original_active = dict(device.active_profiles)
|
|
original_configuration = device.configuration
|
|
key = (device.stable_identity.to_bytes(), 0)
|
|
with running_server(monkeypatch, device) as (base_url, token):
|
|
status, schema = request_json(f"{base_url}/api/schema")
|
|
assert status == 200
|
|
assert "left_stick_up" in schema["output_controls"]
|
|
assert "left_stick_down" in schema["output_controls"]
|
|
assert "left_stick_left" in schema["output_controls"]
|
|
assert "left_stick_right" in schema["output_controls"]
|
|
status, selected = request_json(f"{base_url}/api/profiles/1/1")
|
|
assert status == 200
|
|
draft = selected["profile"]
|
|
# Map D-pad to left stick movement
|
|
draft["button_map"]["dpad_up"] = "left_stick_up"
|
|
draft["button_map"]["dpad_down"] = "left_stick_down"
|
|
draft["button_map"]["dpad_left"] = "left_stick_left"
|
|
draft["button_map"]["dpad_right"] = "left_stick_right"
|
|
draft["extra_button_map"]["c"] = "left_stick_up"
|
|
draft["shift"]["button_map"]["south"] = "left_stick_down"
|
|
draft["triggers"]["left"]["output"] = "left_stick_left"
|
|
status, validated = request_json(
|
|
f"{base_url}/api/profiles/validate", method="POST", value=draft, token=token
|
|
)
|
|
assert status == 200
|
|
assert validated["profile"] == draft
|
|
assert device.profiles == original_profiles
|
|
status, _ = request_json(
|
|
f"{base_url}/api/profiles/1/1", method="PUT",
|
|
value=validated["profile"], token=token,
|
|
)
|
|
assert status == 200
|
|
status, stored = request_json(f"{base_url}/api/profiles/1/1")
|
|
assert status == 200
|
|
assert stored["profile"] == draft
|
|
expected_profiles = dict(original_profiles)
|
|
expected_profiles[key] = config_manager.ControllerProfile.from_json_object(draft).to_bytes()
|
|
assert device.profiles == expected_profiles
|
|
assert device.active_profiles == original_active
|
|
assert device.configuration == original_configuration
|