Add graphical controller profile editor
This commit is contained in:
parent
c2bfaa1d92
commit
eb03d94183
29 changed files with 6965 additions and 308 deletions
|
|
@ -1970,13 +1970,22 @@ void test_stateful_host_rumble_restore() {
|
|||
#endif
|
||||
}
|
||||
|
||||
void test_motion_hotkey() {
|
||||
void test_motion_toggle_action() {
|
||||
start_pairing_backend();
|
||||
uni_hid_device_t slot_zero = device(0);
|
||||
uni_hid_device_t slot_one = device(1);
|
||||
require(platform_on_device_ready(&slot_zero) == UNI_ERROR_SUCCESS &&
|
||||
platform_on_device_ready(&slot_one) == UNI_ERROR_SUCCESS,
|
||||
"motion hotkey test controllers did not become ready");
|
||||
"motion action test controllers did not become ready");
|
||||
|
||||
Bluepad32SlotSnapshot backend_snapshot{};
|
||||
bluepad32_input_backend_snapshot(0, &backend_snapshot);
|
||||
require(!bluepad32_input_backend_toggle_motion(
|
||||
0, backend_snapshot.connection_generation + 1u),
|
||||
"stale connection toggled motion");
|
||||
require(bluepad32_input_backend_toggle_motion(
|
||||
0, backend_snapshot.connection_generation),
|
||||
"live connection did not toggle motion");
|
||||
|
||||
uni_controller_t input{};
|
||||
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||
|
|
@ -1985,24 +1994,8 @@ void test_motion_hotkey() {
|
|||
ControllerState snapshot{};
|
||||
require(read_controller_state(0, &snapshot) &&
|
||||
snapshot.motion_sample_count ==
|
||||
(kDefaultMotionEnabled ? 3 : 0),
|
||||
"slot 0 did not start with configured motion state");
|
||||
|
||||
input.gamepad.dpad = kMotionHotkeyDpadMask;
|
||||
input.gamepad.buttons = kMotionHotkeyButtonMask;
|
||||
input.gamepad.misc_buttons = kMotionHotkeyMiscMask;
|
||||
platform_on_controller_data(&slot_zero, &input);
|
||||
Bluepad32SlotSnapshot backend_snapshot{};
|
||||
bluepad32_input_backend_snapshot(0, &backend_snapshot);
|
||||
require(read_controller_state(0, &snapshot) &&
|
||||
backend_snapshot.pre_hotkey_button_mask ==
|
||||
kMotionHotkeyLogicalButtonMask &&
|
||||
snapshot.motion_sample_count ==
|
||||
(kDefaultMotionEnabled ? 0 : 3) &&
|
||||
!snapshot.dpad_up && !snapshot.button_right_shoulder &&
|
||||
!snapshot.button_start,
|
||||
"motion chord was not published pre-hotkey or suppressed "
|
||||
"from normal output");
|
||||
(kDefaultMotionEnabled ? 0 : 3),
|
||||
"motion toggle did not change slot motion publication");
|
||||
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(slot_zero.rumble_calls == 1 &&
|
||||
|
|
@ -2020,12 +2013,6 @@ void test_motion_hotkey() {
|
|||
: kMotionEnabledFeedbackStrongMagnitude),
|
||||
"motion toggle did not send distinct state feedback");
|
||||
|
||||
platform_on_controller_data(&slot_zero, &input);
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(g_slots[0].motion_enabled == !kDefaultMotionEnabled &&
|
||||
slot_zero.rumble_calls == 1,
|
||||
"held motion chord toggled or rumbled more than once");
|
||||
|
||||
uni_controller_t peer_input{};
|
||||
peer_input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||
peer_input.gamepad.accel[0] = 8192;
|
||||
|
|
@ -2033,32 +2020,27 @@ void test_motion_hotkey() {
|
|||
require(read_controller_state(1, &snapshot) &&
|
||||
snapshot.motion_sample_count ==
|
||||
(kDefaultMotionEnabled ? 3 : 0),
|
||||
"slot 0 motion chord changed slot 1 motion state");
|
||||
"slot 0 motion action changed slot 1 motion state");
|
||||
|
||||
input.gamepad = {};
|
||||
platform_on_controller_data(&slot_zero, &input);
|
||||
input.gamepad.accel[0] = 8192;
|
||||
input.gamepad.dpad = kMotionHotkeyDpadMask;
|
||||
input.gamepad.buttons = kMotionHotkeyButtonMask;
|
||||
input.gamepad.misc_buttons = kMotionHotkeyMiscMask;
|
||||
require(bluepad32_input_backend_toggle_motion(
|
||||
0, backend_snapshot.connection_generation),
|
||||
"second live motion toggle failed");
|
||||
platform_on_controller_data(&slot_zero, &input);
|
||||
require(read_controller_state(0, &snapshot) &&
|
||||
snapshot.motion_sample_count ==
|
||||
(kDefaultMotionEnabled ? 3 : 0),
|
||||
"released motion chord did not re-arm or restore motion");
|
||||
process_rumble_timer(&g_rumble_timer);
|
||||
require(g_slots[0].motion_enabled == kDefaultMotionEnabled &&
|
||||
slot_zero.rumble_calls == 2,
|
||||
"second motion chord did not restore configured state");
|
||||
"second motion toggle did not restore configured state");
|
||||
|
||||
platform_on_device_disconnected(&slot_zero);
|
||||
require(!bluepad32_input_backend_toggle_motion(
|
||||
0, backend_snapshot.connection_generation),
|
||||
"disconnected generation toggled motion");
|
||||
uni_hid_device_t replacement = device(0);
|
||||
require(platform_on_device_ready(&replacement) == UNI_ERROR_SUCCESS &&
|
||||
g_slots[0].motion_enabled == kDefaultMotionEnabled &&
|
||||
!g_slots[0].motion_hotkey_latched &&
|
||||
g_slots[0].pre_hotkey_button_mask == 0 &&
|
||||
!g_slots[0].feedback_pending,
|
||||
"disconnect did not reset slot 0 motion hotkey state");
|
||||
"disconnect did not reset slot 0 motion action state");
|
||||
}
|
||||
|
||||
void test_protocol_neutral_analog_state() {
|
||||
|
|
@ -2392,7 +2374,7 @@ int main(int argc, char** argv) {
|
|||
} else if (scenario == "profile-feedback") {
|
||||
test_profile_feedback_scheduler();
|
||||
} else if (scenario == "motion-hotkey") {
|
||||
test_motion_hotkey();
|
||||
test_motion_toggle_action();
|
||||
} else if (scenario == "analog-state") {
|
||||
test_protocol_neutral_analog_state();
|
||||
} else if (scenario == "rumble-mode") {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ unsigned active_snapshot_count = 0;
|
|||
std::array<ActivationAttempt, 32> activation_attempts{};
|
||||
size_t activation_attempt_count = 0;
|
||||
unsigned activation_busy_attempts = 0;
|
||||
uint8_t last_motion_toggle_slot = 0;
|
||||
uint32_t last_motion_toggle_connection_generation = 0;
|
||||
unsigned motion_toggle_count = 0;
|
||||
|
||||
void require(bool condition, const char* message) {
|
||||
if (!condition) {
|
||||
|
|
@ -54,6 +57,9 @@ void prepare_profiles() {
|
|||
activation_attempts = {};
|
||||
activation_attempt_count = 0;
|
||||
activation_busy_attempts = 0;
|
||||
last_motion_toggle_slot = 0;
|
||||
last_motion_toggle_connection_generation = 0;
|
||||
motion_toggle_count = 0;
|
||||
for (uint8_t slot = 0;
|
||||
slot < CONTROLLER_PROFILE_RUNTIME_SLOT_COUNT; ++slot) {
|
||||
FakeProfileRow& row = rows[slot];
|
||||
|
|
@ -390,8 +396,9 @@ void test_initial_profile_indication_once_per_connection() {
|
|||
void test_default_switching_retry_commit_and_feedback() {
|
||||
prepare_profiles();
|
||||
ControllerProfile& initial_profile = rows[0].profiles[0];
|
||||
initial_profile.macro_trigger = static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kLeftShoulder);
|
||||
initial_profile.macro_trigger_mask =
|
||||
logical_button_bit(
|
||||
ControllerProfileLogicalButton::kLeftShoulder);
|
||||
initial_profile.macro_step_count = 2;
|
||||
initial_profile.macro_steps[0].type =
|
||||
ControllerProfileMacroStepType::kState;
|
||||
|
|
@ -526,8 +533,9 @@ void test_identity_promotion_preserves_held_switching() {
|
|||
}
|
||||
|
||||
void configure_motion_suppression_probe(ControllerProfile* profile) {
|
||||
profile->macro_trigger = static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kDpadUp);
|
||||
profile->macro_trigger_mask =
|
||||
logical_button_bit(
|
||||
ControllerProfileLogicalButton::kDpadUp);
|
||||
profile->macro_step_count = 2;
|
||||
profile->macro_steps[0].type =
|
||||
ControllerProfileMacroStepType::kState;
|
||||
|
|
@ -673,11 +681,50 @@ void test_switching_slot_isolation() {
|
|||
}
|
||||
|
||||
|
||||
void test_profile_motion_toggle_supports_trigger_chords() {
|
||||
prepare_profiles();
|
||||
const uint32_t custom_chord =
|
||||
logical_button_bit(ControllerProfileLogicalButton::kSouth) |
|
||||
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL);
|
||||
rows[0].profiles[0].motion_toggle_chord = custom_chord;
|
||||
Bluepad32SlotSnapshot snapshot = make_snapshot(0, 9);
|
||||
|
||||
(void)runtime_transform(0, snapshot, 0);
|
||||
require(motion_toggle_count == 0,
|
||||
"profile refresh toggled motion without a chord");
|
||||
|
||||
apply_button_mask(
|
||||
logical_button_bit(ControllerProfileLogicalButton::kSouth),
|
||||
&snapshot);
|
||||
snapshot.state.left_trigger = UINT16_MAX;
|
||||
ControllerProfileTransformResult output =
|
||||
runtime_transform(0, snapshot, 1);
|
||||
require(motion_toggle_count == 1 &&
|
||||
last_motion_toggle_slot == 0 &&
|
||||
last_motion_toggle_connection_generation == 9 &&
|
||||
controller_profile_extract_button_mask(output.state) == 0 &&
|
||||
output.state.left_trigger == 0,
|
||||
"trigger-backed motion chord did not toggle and consume inputs");
|
||||
|
||||
(void)runtime_transform(0, snapshot, 2);
|
||||
require(motion_toggle_count == 1,
|
||||
"held trigger-backed motion chord toggled twice");
|
||||
snapshot = make_snapshot(0, 9);
|
||||
(void)runtime_transform(0, snapshot, 3);
|
||||
apply_button_mask(
|
||||
logical_button_bit(ControllerProfileLogicalButton::kSouth),
|
||||
&snapshot);
|
||||
snapshot.state.left_trigger = UINT16_MAX;
|
||||
(void)runtime_transform(0, snapshot, 4);
|
||||
require(motion_toggle_count == 2,
|
||||
"released trigger-backed motion chord did not re-arm");
|
||||
}
|
||||
|
||||
void configure_synthetic_profile(uint8_t slot) {
|
||||
ControllerProfile& profile = rows[slot].profiles[0];
|
||||
profile = controller_profile_default(rows[slot].identity, 0);
|
||||
profile.macro_trigger =
|
||||
static_cast<uint8_t>(ControllerProfileLogicalButton::kSouth);
|
||||
profile.macro_trigger_mask =
|
||||
logical_button_bit(ControllerProfileLogicalButton::kSouth);
|
||||
profile.macro_cancel =
|
||||
static_cast<uint8_t>(ControllerProfileLogicalButton::kCapture);
|
||||
profile.macro_step_count = 2;
|
||||
|
|
@ -837,6 +884,14 @@ void test_runtime_slot_synthetic_isolation() {
|
|||
|
||||
} // namespace
|
||||
|
||||
bool bluepad32_input_backend_toggle_motion(
|
||||
uint8_t slot, uint32_t connection_generation) {
|
||||
last_motion_toggle_slot = slot;
|
||||
last_motion_toggle_connection_generation = connection_generation;
|
||||
++motion_toggle_count;
|
||||
return true;
|
||||
}
|
||||
|
||||
uint32_t profile_service_database_generation() {
|
||||
return database_generation;
|
||||
}
|
||||
|
|
@ -889,6 +944,7 @@ int main() {
|
|||
test_identity_promotion_preserves_held_switching();
|
||||
test_switching_uses_pre_hotkey_buttons_only();
|
||||
test_custom_switching_chord_and_wrap();
|
||||
test_profile_motion_toggle_supports_trigger_chords();
|
||||
test_switching_slot_isolation();
|
||||
test_all_runtime_cancellation_causes();
|
||||
test_runtime_slot_synthetic_isolation();
|
||||
|
|
|
|||
|
|
@ -45,9 +45,9 @@ void test_profile_wire_schema() {
|
|||
uint8_t encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(controller_profile_encode(profile, encoded, sizeof(encoded)),
|
||||
"default profile did not encode");
|
||||
require(encoded[0] == 2 && encoded[1] == 0 &&
|
||||
require(encoded[0] == 4 && encoded[1] == 0 &&
|
||||
encoded[2] == 0 && encoded[3] == 1,
|
||||
"profile header is not little-endian v2/256");
|
||||
"profile header is not little-endian v4/256");
|
||||
for (uint8_t index = 0;
|
||||
index < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT; ++index) {
|
||||
require(encoded[4 + index] == index,
|
||||
|
|
@ -64,9 +64,15 @@ void test_profile_wire_schema() {
|
|||
static_cast<uint8_t>(
|
||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8),
|
||||
"default trigger encoding changed");
|
||||
require(encoded[60] == CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
|
||||
encoded[61] == 0 &&
|
||||
encoded[70] == CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL &&
|
||||
encoded[71] == 0,
|
||||
"default trigger mappings are not identity");
|
||||
require(encoded[72] == 0xff && encoded[73] == 0xff &&
|
||||
encoded[74] == 3 && encoded[78] == 0xff &&
|
||||
encoded[79] == 0xff && encoded[80] == 1,
|
||||
encoded[74] == 3 && encoded[78] == 0 &&
|
||||
encoded[79] == 0 && encoded[80] == 1 &&
|
||||
encoded[81] == CONTROLLER_PROFILE_NO_BUTTON,
|
||||
"default rumble or macro encoding changed");
|
||||
for (uint8_t index = 0;
|
||||
index < CONTROLLER_PROFILE_MACRO_STEP_CAPACITY; ++index) {
|
||||
|
|
@ -77,12 +83,40 @@ void test_profile_wire_schema() {
|
|||
ControllerProfile decoded{};
|
||||
require(controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||
"default profile did not decode");
|
||||
ControllerProfile action_profile = profile;
|
||||
action_profile.switching_chord =
|
||||
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL) |
|
||||
(1u << static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kSouth));
|
||||
action_profile.macro_trigger_mask =
|
||||
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL) |
|
||||
(1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL);
|
||||
action_profile.motion_toggle_chord =
|
||||
(1u << CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL);
|
||||
action_profile.macro_cancel =
|
||||
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
|
||||
uint8_t action_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(controller_profile_encode(
|
||||
action_profile, action_encoded, sizeof(action_encoded)) &&
|
||||
action_encoded[75] == 0x2d &&
|
||||
action_encoded[81] ==
|
||||
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
|
||||
controller_profile_decode(
|
||||
action_encoded, sizeof(action_encoded), &decoded) &&
|
||||
decoded.switching_chord ==
|
||||
action_profile.switching_chord &&
|
||||
decoded.motion_toggle_chord ==
|
||||
action_profile.motion_toggle_chord &&
|
||||
decoded.macro_trigger_mask ==
|
||||
action_profile.macro_trigger_mask &&
|
||||
decoded.macro_cancel == action_profile.macro_cancel,
|
||||
"trigger-backed action masks did not round-trip");
|
||||
encoded[252] = 1;
|
||||
require(!controller_profile_decode(encoded, sizeof(encoded), &decoded),
|
||||
"nonzero reserved profile byte was accepted");
|
||||
|
||||
ControllerProfile invalid = profile;
|
||||
invalid.button_map[0] = 16;
|
||||
invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT;
|
||||
require(!controller_profile_validate(invalid),
|
||||
"invalid direct output was accepted");
|
||||
invalid = profile;
|
||||
|
|
@ -156,11 +190,14 @@ void test_legacy_profile_migration() {
|
|||
require(controller_profile_encode(migrated, encoded, sizeof(encoded)),
|
||||
"migrated default profile did not encode");
|
||||
for (size_t index = 0; index < sizeof(encoded); ++index) {
|
||||
const bool schema_byte = index == 0;
|
||||
const bool threshold_byte =
|
||||
const bool migration_byte =
|
||||
index == 0 ||
|
||||
(index >= 58 && index < 60) ||
|
||||
(index >= 68 && index < 70);
|
||||
if (!schema_byte && !threshold_byte) {
|
||||
index == 60 ||
|
||||
(index >= 68 && index < 70) ||
|
||||
index == 70 ||
|
||||
index == 78 || index == 79 || index == 81;
|
||||
if (!migration_byte) {
|
||||
require(encoded[index] == kLegacyDefaultProfile[index],
|
||||
"legacy default profile changed an unrelated byte");
|
||||
}
|
||||
|
|
@ -177,8 +214,12 @@ void test_legacy_profile_migration() {
|
|||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD) &&
|
||||
encoded[69] ==
|
||||
static_cast<uint8_t>(
|
||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8),
|
||||
"migrated default profile did not encode as v2");
|
||||
CONTROLLER_PROFILE_DEFAULT_DIGITAL_THRESHOLD >> 8) &&
|
||||
encoded[60] == CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
|
||||
encoded[70] == CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL &&
|
||||
encoded[78] == 0 && encoded[79] == 0 &&
|
||||
encoded[81] == CONTROLLER_PROFILE_NO_BUTTON,
|
||||
"migrated default profile did not encode as v4");
|
||||
|
||||
require(controller_profile_decode(
|
||||
kLegacyNarrowRawRangeProfile,
|
||||
|
|
@ -196,11 +237,14 @@ void test_legacy_profile_migration() {
|
|||
require(controller_profile_encode(migrated, encoded, sizeof(encoded)),
|
||||
"migrated narrow-raw-range profile did not encode");
|
||||
for (size_t index = 0; index < sizeof(encoded); ++index) {
|
||||
const bool schema_byte = index == 0;
|
||||
const bool threshold_byte =
|
||||
const bool migration_byte =
|
||||
index == 0 ||
|
||||
(index >= 58 && index < 60) ||
|
||||
(index >= 68 && index < 70);
|
||||
if (!schema_byte && !threshold_byte) {
|
||||
index == 60 ||
|
||||
(index >= 68 && index < 70) ||
|
||||
index == 70 ||
|
||||
index == 78 || index == 79 || index == 81;
|
||||
if (!migration_byte) {
|
||||
require(
|
||||
encoded[index] == kLegacyNarrowRawRangeProfile[index],
|
||||
"narrow-raw-range migration changed unrelated profile data");
|
||||
|
|
@ -214,13 +258,68 @@ void test_legacy_profile_migration() {
|
|||
require(migrated.triggers[0].digital_threshold == 0x1234 &&
|
||||
migrated.triggers[1].digital_threshold == 0xabcd,
|
||||
"legacy custom thresholds were not preserved");
|
||||
require(migrated.triggers[0].output ==
|
||||
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
|
||||
migrated.triggers[1].output ==
|
||||
CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL,
|
||||
"legacy trigger identity mappings were not restored");
|
||||
require(controller_profile_encode(migrated, encoded, sizeof(encoded)),
|
||||
"legacy custom-threshold profile did not re-encode");
|
||||
const uint8_t legacy_macro_trigger =
|
||||
kLegacyCustomThresholdProfile[78];
|
||||
const uint16_t expected_macro_trigger =
|
||||
legacy_macro_trigger < CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT
|
||||
? static_cast<uint16_t>(1u << legacy_macro_trigger)
|
||||
: 0;
|
||||
require(migrated.macro_trigger_mask == expected_macro_trigger &&
|
||||
migrated.macro_cancel ==
|
||||
kLegacyCustomThresholdProfile[79],
|
||||
"legacy macro trigger was not migrated to a chord");
|
||||
for (size_t index = 1; index < sizeof(encoded); ++index) {
|
||||
require(encoded[index] == kLegacyCustomThresholdProfile[index],
|
||||
"legacy custom-threshold profile changed data");
|
||||
const bool migration_byte =
|
||||
index == 60 || index == 70 ||
|
||||
index == 78 || index == 79 || index == 81;
|
||||
if (!migration_byte) {
|
||||
require(encoded[index] ==
|
||||
kLegacyCustomThresholdProfile[index],
|
||||
"legacy custom-threshold profile changed data");
|
||||
}
|
||||
}
|
||||
|
||||
const ControllerProfile previous_source =
|
||||
controller_profile_default(controller_identity_global(), 0);
|
||||
uint8_t previous_encoded[CONTROLLER_PROFILE_ENCODED_SIZE]{};
|
||||
require(controller_profile_encode(
|
||||
previous_source, previous_encoded,
|
||||
sizeof(previous_encoded)),
|
||||
"previous-schema fixture did not encode");
|
||||
previous_encoded[0] = static_cast<uint8_t>(
|
||||
CONTROLLER_PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION);
|
||||
previous_encoded[60] = 0;
|
||||
previous_encoded[70] = 0;
|
||||
previous_encoded[78] = static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kSouth);
|
||||
previous_encoded[79] = static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kCapture);
|
||||
previous_encoded[81] = 0;
|
||||
previous_encoded[98] = 0;
|
||||
previous_encoded[99] = 0;
|
||||
require(controller_profile_decode(
|
||||
previous_encoded, sizeof(previous_encoded), &migrated) &&
|
||||
migrated.triggers[0].output ==
|
||||
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL &&
|
||||
migrated.triggers[1].output ==
|
||||
CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL &&
|
||||
migrated.macro_trigger_mask ==
|
||||
static_cast<uint16_t>(
|
||||
1u << static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kSouth)) &&
|
||||
migrated.macro_cancel ==
|
||||
static_cast<uint8_t>(
|
||||
ControllerProfileLogicalButton::kCapture) &&
|
||||
migrated.motion_toggle_chord == 0,
|
||||
"v2 profile controls did not migrate to v4");
|
||||
|
||||
ControllerProfile current =
|
||||
controller_profile_default(controller_identity_global(), 0);
|
||||
current.triggers[0].digital_threshold = 0x8000;
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ uint16_t transform_left_trigger(
|
|||
uint16_t value) {
|
||||
ControllerProfile profile = default_profile();
|
||||
profile.triggers[0] = configuration;
|
||||
profile.triggers[0].output =
|
||||
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
|
||||
ControllerState state{};
|
||||
state.left_trigger = value;
|
||||
return controller_profile_transform(state, profile).state.left_trigger;
|
||||
|
|
@ -107,9 +109,9 @@ void test_button_masks_and_direct_mapping() {
|
|||
"disabled button mapping still produced output");
|
||||
|
||||
ControllerProfile invalid = default_profile();
|
||||
invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_BUTTON_COUNT;
|
||||
invalid.button_map[0] = CONTROLLER_PROFILE_LOGICAL_CONTROL_COUNT;
|
||||
require(!controller_profile_validate(invalid),
|
||||
"logical output 16 was accepted");
|
||||
"logical output 18 was accepted");
|
||||
invalid.button_map[0] = 0xfe;
|
||||
require(!controller_profile_validate(invalid),
|
||||
"logical output 0xfe was accepted");
|
||||
|
|
@ -271,6 +273,8 @@ void test_trigger_boundaries_curves_and_thresholds() {
|
|||
|
||||
ControllerProfile profile = default_profile();
|
||||
profile.triggers[0] = slow;
|
||||
profile.triggers[0].output =
|
||||
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
|
||||
profile.triggers[0].digital_threshold = 12345;
|
||||
profile.triggers[1].digital_threshold = 54321;
|
||||
ControllerState state{};
|
||||
|
|
@ -282,6 +286,41 @@ void test_trigger_boundaries_curves_and_thresholds() {
|
|||
"profile-owned digital thresholds were not returned");
|
||||
}
|
||||
|
||||
void test_trigger_and_button_cross_mapping() {
|
||||
ControllerProfile profile = default_profile();
|
||||
profile.triggers[0].output =
|
||||
static_cast<uint8_t>(ControllerProfileLogicalButton::kSouth);
|
||||
profile.triggers[0].digital_threshold = 30000;
|
||||
profile.triggers[1].output =
|
||||
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
|
||||
profile.triggers[1].digital_threshold = 40000;
|
||||
profile.button_map[
|
||||
static_cast<uint8_t>(ControllerProfileLogicalButton::kEast)] =
|
||||
CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL;
|
||||
|
||||
ControllerState input{};
|
||||
input.left_trigger = 29999;
|
||||
input.right_trigger = 45000;
|
||||
input.button_east = true;
|
||||
ControllerProfileTransformResult output =
|
||||
controller_profile_transform(input, profile);
|
||||
require(!output.state.button_south &&
|
||||
output.state.left_trigger == 45000 &&
|
||||
output.state.right_trigger == UINT16_MAX &&
|
||||
output.left_trigger_digital_threshold == 40000,
|
||||
"cross-mapped triggers lost analog or button output");
|
||||
|
||||
input.left_trigger = 30000;
|
||||
output = controller_profile_transform(input, profile);
|
||||
require(output.state.button_south,
|
||||
"trigger threshold did not produce its mapped button");
|
||||
|
||||
profile.triggers[0].output =
|
||||
CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL;
|
||||
require(!controller_profile_validate(profile),
|
||||
"two analog triggers targeting one output were accepted");
|
||||
}
|
||||
|
||||
void test_default_whole_state_equivalence() {
|
||||
ControllerState input{};
|
||||
input.dpad_up = true;
|
||||
|
|
@ -378,6 +417,7 @@ int main() {
|
|||
test_stick_center_boundaries_and_inversion();
|
||||
test_stick_curves_and_monotonicity();
|
||||
test_trigger_boundaries_curves_and_thresholds();
|
||||
test_trigger_and_button_cross_mapping();
|
||||
test_default_whole_state_equivalence();
|
||||
test_rumble_scaling_and_confirmation_policy();
|
||||
return 0;
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ ControllerProfile profile_with_macro(
|
|||
ControllerProfileLogicalButton::kCapture) {
|
||||
ControllerProfile profile =
|
||||
controller_profile_default(controller_identity_global(), 0);
|
||||
profile.macro_trigger = button_index(trigger);
|
||||
profile.macro_trigger_mask = button_bit(trigger);
|
||||
profile.macro_cancel = button_index(cancel);
|
||||
return profile;
|
||||
}
|
||||
|
|
@ -100,6 +100,54 @@ void test_immediate_press_release_dpad_and_explicit_end() {
|
|||
"explicit end did not clear overrides and restore physical input");
|
||||
}
|
||||
|
||||
void test_macro_trigger_chord_requires_every_button() {
|
||||
ControllerProfile profile =
|
||||
profile_with_macro(ControllerProfileLogicalButton::kSouth);
|
||||
profile.macro_trigger_mask =
|
||||
button_bit(ControllerProfileLogicalButton::kSouth) |
|
||||
(1u << CONTROLLER_PROFILE_LEFT_TRIGGER_CONTROL);
|
||||
profile.macro_cancel =
|
||||
CONTROLLER_PROFILE_RIGHT_TRIGGER_CONTROL;
|
||||
profile.macro_step_count = 2;
|
||||
profile.macro_steps[0].type =
|
||||
ControllerProfileMacroStepType::kState;
|
||||
profile.macro_steps[0].override_flags =
|
||||
kControllerProfileOverrideButtons;
|
||||
profile.macro_steps[0].duration_ms = 100;
|
||||
profile.macro_steps[0].output_button_mask =
|
||||
button_bit(ControllerProfileLogicalButton::kNorth);
|
||||
set_end(&profile, 1);
|
||||
|
||||
ControllerSyntheticInputContext context{};
|
||||
ControllerState input =
|
||||
state_with_buttons(
|
||||
button_bit(ControllerProfileLogicalButton::kSouth));
|
||||
ControllerProfileTransformResult output =
|
||||
controller_synthetic_input_apply(&context, input, profile, 0);
|
||||
require(!context.macro_active &&
|
||||
has_button(output, ControllerProfileLogicalButton::kSouth),
|
||||
"partial macro chord triggered or consumed a normal button");
|
||||
|
||||
input = state_with_buttons(
|
||||
button_bit(ControllerProfileLogicalButton::kSouth));
|
||||
input.left_trigger = UINT16_MAX;
|
||||
output = controller_synthetic_input_apply(
|
||||
&context, input, profile, 1);
|
||||
require(context.macro_active &&
|
||||
has_button(output, ControllerProfileLogicalButton::kNorth) &&
|
||||
!has_button(output, ControllerProfileLogicalButton::kSouth) &&
|
||||
output.state.left_trigger == 0,
|
||||
"completed trigger-backed macro chord did not consume inputs");
|
||||
|
||||
input = controller_neutral_state();
|
||||
input.right_trigger = UINT16_MAX;
|
||||
output = controller_synthetic_input_apply(
|
||||
&context, input, profile, 2);
|
||||
require(!context.macro_active &&
|
||||
output.state.right_trigger == 0,
|
||||
"trigger-backed macro cancellation leaked or stayed active");
|
||||
}
|
||||
|
||||
void test_optional_field_overrides_and_motion_preservation() {
|
||||
ControllerProfile profile =
|
||||
profile_with_macro(ControllerProfileLogicalButton::kSelect);
|
||||
|
|
@ -454,6 +502,7 @@ void test_four_contexts_are_isolated() {
|
|||
|
||||
int main() {
|
||||
test_immediate_press_release_dpad_and_explicit_end();
|
||||
test_macro_trigger_chord_requires_every_button();
|
||||
test_optional_field_overrides_and_motion_preservation();
|
||||
test_zero_max_wait_and_scheduled_catch_up();
|
||||
test_consumption_cancel_precedence_and_duplicate_contributors();
|
||||
|
|
|
|||
|
|
@ -483,7 +483,7 @@ class FakeDevice:
|
|||
def custom_profile() -> config_manager.ControllerProfile:
|
||||
return config_manager.ControllerProfile(
|
||||
button_map=(
|
||||
1,
|
||||
config_manager.LOGICAL_CONTROLS.index("left_trigger"),
|
||||
0,
|
||||
2,
|
||||
3,
|
||||
|
|
@ -506,14 +506,21 @@ def custom_profile() -> config_manager.ControllerProfile:
|
|||
right_stick=config_manager.StickConfig(
|
||||
789, -321, 500, 31000, 192, False, True
|
||||
),
|
||||
left_trigger=config_manager.TriggerConfig(100, 65000, 320, 32000),
|
||||
right_trigger=config_manager.TriggerConfig(200, 64000, 224, 33000),
|
||||
left_trigger=config_manager.TriggerConfig(
|
||||
100, 65000, 320, 32000,
|
||||
config_manager.LOGICAL_CONTROLS.index("right_trigger"),
|
||||
),
|
||||
right_trigger=config_manager.TriggerConfig(
|
||||
200, 64000, 224, 33000,
|
||||
config_manager.LOGICAL_BUTTONS.index("north"),
|
||||
),
|
||||
weak_rumble_scale=77,
|
||||
strong_rumble_scale=201,
|
||||
confirmation_policy=2,
|
||||
switching_chord=(1 << 6) | (1 << 7),
|
||||
macro_trigger=0,
|
||||
macro_cancel=1,
|
||||
switching_chord=(1 << 6) | (1 << 16),
|
||||
motion_toggle_chord=(1 << 5) | (1 << 17),
|
||||
macro_trigger_mask=(1 << 0) | (1 << 16) | (1 << 17),
|
||||
macro_cancel=config_manager.LOGICAL_CONTROLS.index("right_trigger"),
|
||||
macro_steps=(
|
||||
config_manager.MacroStep(
|
||||
0,
|
||||
|
|
@ -1113,13 +1120,18 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
|
|||
config_manager.PROFILE_SCHEMA_VERSION,
|
||||
config_manager.PROFILE_SIZE,
|
||||
)
|
||||
assert encoded[75] == encoded[81] == 0
|
||||
assert encoded[98:100] == b"\x00\x00"
|
||||
assert encoded[75] == 0x2D
|
||||
assert encoded[60:62] == bytes((17, 0))
|
||||
assert encoded[70:72] == bytes((3, 0))
|
||||
assert encoded[81] == config_manager.LOGICAL_CONTROLS.index("right_trigger")
|
||||
assert struct.unpack_from("<H", encoded, 98)[0] == (
|
||||
profile.motion_toggle_chord & 0xFFFF
|
||||
)
|
||||
assert encoded[252:] == bytes(4)
|
||||
assert config_manager.ControllerProfile.from_bytes(encoded) == profile
|
||||
|
||||
serialized = profile.to_json()
|
||||
assert serialized.startswith('{\n "schema_version": 2,\n "size": 256,')
|
||||
assert serialized.startswith('{\n "schema_version": 4,\n "size": 256,')
|
||||
decoded = config_manager.ControllerProfile.from_json(serialized)
|
||||
assert decoded == profile
|
||||
assert decoded.to_json() == serialized
|
||||
|
|
@ -1131,6 +1143,12 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
|
|||
0,
|
||||
config_manager.PROFILE_LEGACY_SCHEMA_VERSION,
|
||||
)
|
||||
legacy_default_wire[60:62] = b"\x00\x00"
|
||||
legacy_default_wire[70:72] = b"\x00\x00"
|
||||
legacy_default_wire[78] = config_manager.PROFILE_NONE_BUTTON
|
||||
legacy_default_wire[79] = config_manager.PROFILE_NONE_BUTTON
|
||||
legacy_default_wire[81] = 0
|
||||
legacy_default_wire[98:100] = b"\x00\x00"
|
||||
struct.pack_into(
|
||||
"<HHHH",
|
||||
legacy_default_wire,
|
||||
|
|
@ -1180,22 +1198,37 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
|
|||
== config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD
|
||||
)
|
||||
|
||||
legacy_custom_wire = bytearray(encoded)
|
||||
previous_wire = bytearray(default_wire)
|
||||
struct.pack_into(
|
||||
"<H",
|
||||
legacy_custom_wire,
|
||||
previous_wire,
|
||||
0,
|
||||
config_manager.PROFILE_LEGACY_SCHEMA_VERSION,
|
||||
config_manager.PROFILE_TRIGGER_THRESHOLD_SCHEMA_VERSION,
|
||||
)
|
||||
assert (
|
||||
config_manager.ControllerProfile.from_bytes(legacy_custom_wire)
|
||||
== profile
|
||||
previous_wire[60:62] = b"\x00\x00"
|
||||
previous_wire[70:72] = b"\x00\x00"
|
||||
previous_wire[78] = 0
|
||||
previous_wire[79] = 1
|
||||
previous_wire[81] = 0
|
||||
previous_wire[98:100] = b"\x00\x00"
|
||||
migrated_previous = config_manager.ControllerProfile.from_bytes(
|
||||
previous_wire
|
||||
)
|
||||
assert migrated_previous.left_trigger.output == 16
|
||||
assert migrated_previous.right_trigger.output == 17
|
||||
assert migrated_previous.macro_trigger_mask == 1
|
||||
assert migrated_previous.macro_cancel == 1
|
||||
assert migrated_previous.motion_toggle_chord == 0
|
||||
assert migrated_previous.to_bytes()[0] == config_manager.PROFILE_SCHEMA_VERSION
|
||||
|
||||
legacy_json_object = default_profile.to_json_object()
|
||||
legacy_json_object["schema_version"] = (
|
||||
config_manager.PROFILE_LEGACY_SCHEMA_VERSION
|
||||
)
|
||||
del legacy_json_object["motion_toggle_chord"]
|
||||
del legacy_json_object["triggers"]["left"]["output"]
|
||||
del legacy_json_object["triggers"]["right"]["output"]
|
||||
legacy_json_object["macro"]["trigger"] = "south"
|
||||
legacy_json_object["triggers"]["left"]["digital_threshold"] = (
|
||||
config_manager.PROFILE_LEGACY_DEFAULT_DIGITAL_THRESHOLD
|
||||
)
|
||||
|
|
@ -1212,15 +1245,31 @@ def test_identity_and_profile_binary_json_round_trip() -> None:
|
|||
assert migrated_json.right_trigger.digital_threshold == 33000
|
||||
assert migrated_json.left_trigger.lower_deadzone == 30000
|
||||
assert migrated_json.left_trigger.upper_saturation == 40000
|
||||
assert migrated_json.left_trigger.output == 16
|
||||
assert migrated_json.right_trigger.output == 17
|
||||
assert migrated_json.macro_trigger_mask == 1
|
||||
assert migrated_json.motion_toggle_chord == 0
|
||||
|
||||
|
||||
def test_trigger_threshold_uses_transformed_output_domain() -> None:
|
||||
for threshold in (0, 0xFFFF):
|
||||
trigger = config_manager.TriggerConfig(30000, 40000, 256, threshold)
|
||||
assert config_manager.TriggerConfig.from_bytes(trigger.to_bytes()) == trigger
|
||||
trigger = config_manager.TriggerConfig(
|
||||
30000, 40000, 256, threshold, 16
|
||||
)
|
||||
assert (
|
||||
config_manager.TriggerConfig.from_bytes(
|
||||
trigger.to_bytes(),
|
||||
schema_version=config_manager.PROFILE_SCHEMA_VERSION,
|
||||
source_index=0,
|
||||
)
|
||||
== trigger
|
||||
)
|
||||
assert (
|
||||
config_manager.TriggerConfig.from_json_object(
|
||||
trigger.to_json_object(), "trigger"
|
||||
trigger.to_json_object(),
|
||||
"trigger",
|
||||
schema_version=config_manager.PROFILE_SCHEMA_VERSION,
|
||||
source_index=0,
|
||||
)
|
||||
== trigger
|
||||
)
|
||||
|
|
@ -1242,7 +1291,7 @@ def test_trigger_threshold_uses_transformed_output_domain() -> None:
|
|||
config_manager.ConfigManagerError,
|
||||
match="trigger digital_threshold",
|
||||
):
|
||||
config_manager.TriggerConfig(30000, 40000, 256, threshold)
|
||||
config_manager.TriggerConfig(30000, 40000, 256, threshold, 16)
|
||||
|
||||
for lower_deadzone, upper_saturation in (
|
||||
(40000, 40000),
|
||||
|
|
@ -1253,7 +1302,7 @@ def test_trigger_threshold_uses_transformed_output_domain() -> None:
|
|||
match="lower_deadzone must be below upper_saturation",
|
||||
):
|
||||
config_manager.TriggerConfig(
|
||||
lower_deadzone, upper_saturation, 256, 0
|
||||
lower_deadzone, upper_saturation, 256, 0, 16
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1369,6 +1418,53 @@ def test_profile_cli_surfaces_late_storage_failure(
|
|||
]
|
||||
|
||||
|
||||
def test_profile_edit_cli_starts_local_web_editor(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from switch_pico_bridge import profile_web
|
||||
|
||||
calls: list[dict[str, object]] = []
|
||||
monkeypatch.setattr(
|
||||
profile_web,
|
||||
"run_profile_editor",
|
||||
lambda **kwargs: calls.append(kwargs),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
config_manager,
|
||||
"_candidate_devices",
|
||||
lambda: (_ for _ in ()).throw(
|
||||
AssertionError("editor startup must not require a connected Pico")
|
||||
),
|
||||
)
|
||||
|
||||
assert (
|
||||
config_manager.main(
|
||||
[
|
||||
"--bus",
|
||||
"3",
|
||||
"--address",
|
||||
"7",
|
||||
"--timeout",
|
||||
"8",
|
||||
"profiles",
|
||||
"edit",
|
||||
"--port",
|
||||
"9000",
|
||||
"--no-browser",
|
||||
]
|
||||
)
|
||||
== 0
|
||||
)
|
||||
assert calls == [
|
||||
{
|
||||
"bus": 3,
|
||||
"address": 7,
|
||||
"timeout": 8.0,
|
||||
"port": 9000,
|
||||
"open_browser": False,
|
||||
}
|
||||
]
|
||||
|
||||
def test_profile_cli_json_round_trip_activate_and_reset(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
|
|
@ -1485,9 +1581,9 @@ def test_malformed_profiles_are_rejected_before_usb(
|
|||
tmp_path: Path,
|
||||
) -> None:
|
||||
malformed_binary = bytearray(config_manager.ControllerProfile.default().to_bytes())
|
||||
malformed_binary[75] = 1
|
||||
malformed_binary[75] = 0xC0
|
||||
with pytest.raises(
|
||||
config_manager.ConfigManagerError, match="reserved fields"
|
||||
config_manager.ConfigManagerError, match="action flags"
|
||||
):
|
||||
config_manager.ControllerProfile.from_bytes(malformed_binary)
|
||||
|
||||
|
|
|
|||
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