feat: overhaul controller menu accessibility
This commit is contained in:
parent
a7837c3711
commit
4f9c85989b
46 changed files with 4091 additions and 396 deletions
457
tests/controller_menu_accessibility_validation.gd
Normal file
457
tests/controller_menu_accessibility_validation.gd
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
extends SceneTree
|
||||
|
||||
const JoinGamePageScene = preload(
|
||||
"res://ui/network/join_game_page.tscn"
|
||||
)
|
||||
const SettingsPanelScene = preload("res://ui/settings_panel.tscn")
|
||||
const BubbleConfirmationScene = preload(
|
||||
"res://ui/components/bubble_menu/bubble_confirmation_page.tscn"
|
||||
)
|
||||
const TitleConfirmationScene = preload(
|
||||
"res://ui/title_confirmation_bubble_page.tscn"
|
||||
)
|
||||
const MailPageType = preload("res://ui/mail_page.gd")
|
||||
const ProfilePageType = preload("res://ui/profile_page.gd")
|
||||
const DialogControllerNavigationType = preload(
|
||||
"res://ui/file_dialog_controller_navigation.gd"
|
||||
)
|
||||
|
||||
var _failures: Array[String] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
await _validate_join_game_navigation()
|
||||
await _validate_data_settings_navigation()
|
||||
await _validate_mail_navigation()
|
||||
await _validate_profile_confirmation_focus()
|
||||
await _validate_confirmation_dialog_navigation()
|
||||
await _validate_bubble_confirmation_navigation()
|
||||
_validate_mapping_capture_contract()
|
||||
if _failures.is_empty():
|
||||
print("Controller menu accessibility validation: PASS")
|
||||
quit(0)
|
||||
return
|
||||
for failure: String in _failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
|
||||
|
||||
func _validate_join_game_navigation() -> void:
|
||||
var page := JoinGamePageScene.instantiate() as Control
|
||||
root.add_child(page)
|
||||
await process_frame
|
||||
page.show()
|
||||
var discover := page.get_node("%DiscoverButton") as Button
|
||||
var direct := page.get_node("%DirectButton") as Button
|
||||
var saved := page.get_node("%SavedButton") as Button
|
||||
var recent := page.get_node("%RecentButton") as Button
|
||||
var address := page.get_node("%Address") as LineEdit
|
||||
var name_edit := page.get_node("%NameEdit") as LineEdit
|
||||
var server_list := page.get_node("%ServerList") as ItemList
|
||||
var refresh := page.get_node("%RefreshButton") as Button
|
||||
var join := page.get_node("%JoinButton") as Button
|
||||
var save := page.get_node("%SaveButton") as Button
|
||||
var edit := page.get_node("%EditButton") as Button
|
||||
var favorite := page.get_node("%FavoriteButton") as Button
|
||||
var delete := page.get_node("%DeleteButton") as Button
|
||||
var cancel := page.get_node("%CancelButton") as Button
|
||||
var back := page.get_node("%BackButton") as Button
|
||||
var modes: Array[Control] = [discover, direct, saved, recent]
|
||||
|
||||
address.hide()
|
||||
name_edit.hide()
|
||||
server_list.show()
|
||||
_set_button_state(refresh, true)
|
||||
_set_button_state(join, true)
|
||||
_set_button_state(save, false)
|
||||
_set_button_state(edit, false)
|
||||
_set_button_state(favorite, false)
|
||||
_set_button_state(delete, false)
|
||||
_set_button_state(cancel, false)
|
||||
_set_button_state(back, true)
|
||||
page.set("_mode", 0)
|
||||
page.call("_configure_controller_navigation")
|
||||
await process_frame
|
||||
_assert_neighbor(discover, &"focus_neighbor_bottom", server_list)
|
||||
_assert_neighbor(server_list, &"focus_neighbor_top", discover)
|
||||
_assert_neighbor(server_list, &"focus_neighbor_bottom", refresh)
|
||||
_assert_neighbor(refresh, &"focus_neighbor_right", join)
|
||||
_assert_neighbor(back, &"focus_neighbor_left", join)
|
||||
var discover_controls: Array[Control] = modes.duplicate()
|
||||
discover_controls.append_array([server_list, refresh, join, back])
|
||||
_assert_directionally_reachable(discover, discover_controls)
|
||||
|
||||
address.show()
|
||||
address.editable = true
|
||||
server_list.hide()
|
||||
_set_button_state(refresh, false)
|
||||
_set_button_state(join, true)
|
||||
_set_button_state(save, true)
|
||||
page.set("_mode", 1)
|
||||
page.call("_configure_controller_navigation")
|
||||
await process_frame
|
||||
_assert_neighbor(direct, &"focus_neighbor_bottom", address)
|
||||
_assert_neighbor(address, &"focus_neighbor_top", direct)
|
||||
_assert_neighbor(address, &"focus_neighbor_bottom", join)
|
||||
var direct_controls: Array[Control] = modes.duplicate()
|
||||
direct_controls.append_array([address, join, save, back])
|
||||
_assert_directionally_reachable(direct, direct_controls)
|
||||
|
||||
name_edit.show()
|
||||
name_edit.editable = true
|
||||
page.set("_name_entry_active", true)
|
||||
page.call("_configure_controller_navigation")
|
||||
await process_frame
|
||||
_assert_neighbor(address, &"focus_neighbor_bottom", name_edit)
|
||||
_assert_neighbor(name_edit, &"focus_neighbor_top", address)
|
||||
page.call("request_back")
|
||||
_expect(
|
||||
not bool(page.get("_name_entry_active")),
|
||||
"Join-game Back should leave the server-name edit substate.",
|
||||
)
|
||||
_expect(
|
||||
page.visible,
|
||||
"Join-game Back should not close the browser from an edit substate.",
|
||||
)
|
||||
await process_frame
|
||||
|
||||
page.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _validate_data_settings_navigation() -> void:
|
||||
var panel := SettingsPanelScene.instantiate() as Control
|
||||
root.add_child(panel)
|
||||
await process_frame
|
||||
panel.show()
|
||||
var data_page := panel.get_node("%DataPage") as SettingsBubblePage
|
||||
data_page.show_page(false)
|
||||
for _frame: int in 2:
|
||||
await process_frame
|
||||
var controls: Array[Control] = [
|
||||
panel.get_node("%OpenDataFolder") as Control,
|
||||
panel.get_node("%ChangeDataFolder") as Control,
|
||||
panel.get_node("%CopyPlayerFingerprint") as Control,
|
||||
panel.get_node("%ExportPlayerIdentity") as Control,
|
||||
panel.get_node("%ImportPlayerIdentity") as Control,
|
||||
panel.get_node("%ExportHostIdentity") as Control,
|
||||
panel.get_node("%ImportHostIdentity") as Control,
|
||||
panel.get_node("%DataBackButton") as Control,
|
||||
]
|
||||
for control: Control in controls:
|
||||
_expect(
|
||||
control.focus_mode == Control.FOCUS_ALL,
|
||||
"Data & Identity control %s is not controller-focusable."
|
||||
% control.name,
|
||||
)
|
||||
_assert_directionally_reachable(controls.front(), controls)
|
||||
panel.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _validate_mail_navigation() -> void:
|
||||
var page := MailPageType.new() as Control
|
||||
root.add_child(page)
|
||||
await process_frame
|
||||
page.call("activate")
|
||||
page.call("set_interactive", true)
|
||||
var inbox_list := page.get("_inbox_list") as VBoxContainer
|
||||
var entry := Button.new()
|
||||
entry.text = "test letter"
|
||||
entry.custom_minimum_size = Vector2(1008.0, 54.0)
|
||||
inbox_list.add_child(entry)
|
||||
await process_frame
|
||||
page.call("_refresh_controller_navigation")
|
||||
var archive_view := page.get("_archive_view_button") as Button
|
||||
var send_mail := page.get("_send_mail_button") as Button
|
||||
_assert_neighbor(
|
||||
archive_view, &"focus_neighbor_right", send_mail
|
||||
)
|
||||
_assert_neighbor(archive_view, &"focus_neighbor_bottom", entry)
|
||||
_assert_neighbor(send_mail, &"focus_neighbor_bottom", entry)
|
||||
_assert_neighbor(entry, &"focus_neighbor_top", archive_view)
|
||||
_assert_directionally_reachable(
|
||||
archive_view, [archive_view, send_mail, entry]
|
||||
)
|
||||
|
||||
var inbox := page.get("_inbox") as Control
|
||||
var compose := page.get("_compose") as Control
|
||||
var letter := page.get("_letter") as Control
|
||||
inbox.hide()
|
||||
compose.hide()
|
||||
letter.show()
|
||||
var accept := page.get("_accept") as Button
|
||||
var decline := page.get("_decline") as Button
|
||||
var close := page.get("_letter_close") as Button
|
||||
var archive := page.get("_archive") as Button
|
||||
var delete := page.get("_delete") as Button
|
||||
accept.show()
|
||||
decline.show()
|
||||
delete.disabled = false
|
||||
page.call("_refresh_controller_navigation")
|
||||
_assert_neighbor(accept, &"focus_neighbor_bottom", decline)
|
||||
_assert_neighbor(decline, &"focus_neighbor_bottom", delete)
|
||||
_assert_neighbor(delete, &"focus_neighbor_left", archive)
|
||||
_assert_neighbor(archive, &"focus_neighbor_left", close)
|
||||
_assert_neighbor(close, &"focus_neighbor_top", close)
|
||||
_assert_directionally_reachable(
|
||||
accept, [accept, decline, close, archive, delete]
|
||||
)
|
||||
page.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _validate_profile_confirmation_focus() -> void:
|
||||
var page := ProfilePageType.new() as Control
|
||||
root.add_child(page)
|
||||
await process_frame
|
||||
page.call("activate")
|
||||
page.call("set_interactive", true)
|
||||
page.call("reset_controller_zone")
|
||||
var suggestions := page.get("_suggestions") as HBoxContainer
|
||||
var suggestion := Button.new()
|
||||
suggestion.text = "alternate name"
|
||||
suggestions.add_child(suggestion)
|
||||
page.call("_apply_controller_zone_focus")
|
||||
var account_controls: Array[Control] = []
|
||||
for item: Variant in page.call("_account_controller_controls"):
|
||||
account_controls.append(item as Control)
|
||||
var preview := page.get("_preview") as Control
|
||||
var reset_view := page.get("_reset_view_button") as Button
|
||||
_expect(
|
||||
suggestion in account_controls,
|
||||
"Profile name-conflict choices are outside the account zone.",
|
||||
)
|
||||
_expect(
|
||||
suggestion.focus_mode == Control.FOCUS_ALL,
|
||||
"Profile name-conflict choices are not controller-focusable.",
|
||||
)
|
||||
_expect(
|
||||
preview.focus_mode == Control.FOCUS_NONE,
|
||||
"The profile preview must not take controller focus.",
|
||||
)
|
||||
_expect(
|
||||
reset_view.focus_mode == Control.FOCUS_NONE,
|
||||
"The profile reset-view button must not take controller focus.",
|
||||
)
|
||||
page.call("_show_confirmation", "defaults")
|
||||
var confirmation := page.get("_discard_confirmation") as Control
|
||||
var confirm := page.get("_confirmation_confirm") as Button
|
||||
var keep_editing := page.get("_keep_editing_button") as Button
|
||||
_expect(confirmation.visible, "The profile confirmation did not open.")
|
||||
_expect(
|
||||
confirm.focus_mode == Control.FOCUS_ALL,
|
||||
"The profile confirmation action is not controller-focusable.",
|
||||
)
|
||||
_expect(
|
||||
keep_editing.focus_mode == Control.FOCUS_ALL,
|
||||
"The profile confirmation cancel action is not controller-focusable.",
|
||||
)
|
||||
for control: Control in account_controls:
|
||||
_expect(
|
||||
control.focus_mode == Control.FOCUS_NONE,
|
||||
"Profile confirmation leaked focus to %s." % control.name,
|
||||
)
|
||||
var cancel_event := InputEventAction.new()
|
||||
cancel_event.action = &"ui_cancel"
|
||||
cancel_event.pressed = true
|
||||
_expect(
|
||||
bool(page.call("handle_controller_input", cancel_event)),
|
||||
"Profile confirmation did not consume controller Back.",
|
||||
)
|
||||
_expect(
|
||||
not confirmation.visible,
|
||||
"Controller Back did not close the profile confirmation.",
|
||||
)
|
||||
for control: Control in account_controls:
|
||||
_expect(
|
||||
control.focus_mode == Control.FOCUS_ALL,
|
||||
"Profile account focus was not restored after confirmation.",
|
||||
)
|
||||
page.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _validate_mapping_capture_contract() -> void:
|
||||
var title_source: String = FileAccess.get_file_as_string(
|
||||
"res://ui/title_screen.gd"
|
||||
)
|
||||
var game_ui_source: String = FileAccess.get_file_as_string(
|
||||
"res://ui/game_ui.gd"
|
||||
)
|
||||
var chat_source: String = FileAccess.get_file_as_string(
|
||||
"res://ui/chat_ui.gd"
|
||||
)
|
||||
var main_source: String = FileAccess.get_file_as_string(
|
||||
"res://main/main.gd"
|
||||
)
|
||||
_expect(
|
||||
title_source.contains(
|
||||
"_settings_panel.is_input_mapping_capturing()"
|
||||
),
|
||||
"Title input does not respect every active binding capture.",
|
||||
)
|
||||
_expect(
|
||||
game_ui_source.contains("or is_input_mapping_capturing()"),
|
||||
"Controller menu scrolling does not respect every binding capture.",
|
||||
)
|
||||
_expect(
|
||||
chat_source.contains("func _configure_controller_focus()"),
|
||||
"Chat does not author controller routes for its exterior controls.",
|
||||
)
|
||||
_expect(
|
||||
chat_source.contains(
|
||||
"Control.FOCUS_ALL if _opened else Control.FOCUS_NONE"
|
||||
),
|
||||
"Passive chat controls can steal world controller focus.",
|
||||
)
|
||||
_expect(
|
||||
main_source.contains("func _configure_popup_dialog("),
|
||||
"Runtime popup dialogs do not receive authored controller routes.",
|
||||
)
|
||||
|
||||
|
||||
func _validate_confirmation_dialog_navigation() -> void:
|
||||
var dialog := ConfirmationDialog.new()
|
||||
dialog.ok_button_text = "continue"
|
||||
var fields := VBoxContainer.new()
|
||||
var first := LineEdit.new()
|
||||
first.placeholder_text = "passphrase"
|
||||
first.custom_minimum_size = Vector2(420.0, 42.0)
|
||||
fields.add_child(first)
|
||||
var second := LineEdit.new()
|
||||
second.placeholder_text = "confirm passphrase"
|
||||
second.custom_minimum_size = Vector2(420.0, 42.0)
|
||||
fields.add_child(second)
|
||||
dialog.add_child(fields)
|
||||
root.add_child(dialog)
|
||||
dialog.popup_centered(Vector2i(560, 300))
|
||||
for _frame: int in 2:
|
||||
await process_frame
|
||||
DialogControllerNavigationType.configure_scope(dialog, first)
|
||||
await process_frame
|
||||
var controls: Array[Control] = (
|
||||
DialogControllerNavigationType.interactive_controls(dialog)
|
||||
)
|
||||
_expect(
|
||||
controls.has(first) and controls.has(second),
|
||||
"Confirmation-dialog text fields are not controller reachable.",
|
||||
)
|
||||
_expect(
|
||||
controls.has(dialog.get_ok_button())
|
||||
and controls.has(dialog.get_cancel_button()),
|
||||
"Confirmation-dialog actions are not controller reachable.",
|
||||
)
|
||||
_assert_directionally_reachable(first, controls)
|
||||
_expect(
|
||||
dialog.gui_get_focus_owner() == first,
|
||||
"Confirmation dialog did not focus its requested entry control.",
|
||||
)
|
||||
dialog.hide()
|
||||
await process_frame
|
||||
root.gui_release_focus()
|
||||
dialog.popup_centered(Vector2i(560, 300))
|
||||
await process_frame
|
||||
DialogControllerNavigationType.configure_scope(dialog, first)
|
||||
await process_frame
|
||||
_expect(
|
||||
dialog.gui_get_focus_owner() == first,
|
||||
"A reopened dialog did not restore controller focus.",
|
||||
)
|
||||
dialog.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _validate_bubble_confirmation_navigation() -> void:
|
||||
for scene: PackedScene in [
|
||||
BubbleConfirmationScene,
|
||||
TitleConfirmationScene,
|
||||
]:
|
||||
var page := scene.instantiate() as Control
|
||||
root.add_child(page)
|
||||
await process_frame
|
||||
page.show()
|
||||
page.call("_set_interactive", true)
|
||||
var confirm := page.get_node("BubbleCluster/ConfirmButton") as Button
|
||||
var cancel := page.get_node("BubbleCluster/CancelButton") as Button
|
||||
_assert_neighbor(confirm, &"focus_neighbor_left", cancel)
|
||||
_assert_neighbor(confirm, &"focus_neighbor_right", cancel)
|
||||
_assert_neighbor(confirm, &"focus_neighbor_top", confirm)
|
||||
_assert_neighbor(confirm, &"focus_neighbor_bottom", confirm)
|
||||
_assert_neighbor(cancel, &"focus_neighbor_left", confirm)
|
||||
_assert_neighbor(cancel, &"focus_neighbor_right", confirm)
|
||||
_assert_neighbor(cancel, &"focus_neighbor_top", cancel)
|
||||
_assert_neighbor(cancel, &"focus_neighbor_bottom", cancel)
|
||||
page.queue_free()
|
||||
await process_frame
|
||||
func _set_button_state(button: Button, shown: bool) -> void:
|
||||
button.visible = shown
|
||||
button.disabled = false
|
||||
|
||||
|
||||
func _assert_neighbor(
|
||||
origin: Control,
|
||||
property: StringName,
|
||||
expected: Control,
|
||||
) -> void:
|
||||
var path: NodePath = origin.get(property)
|
||||
_expect(
|
||||
not path.is_empty(),
|
||||
"%s has no %s neighbor." % [origin.name, property],
|
||||
)
|
||||
if path.is_empty():
|
||||
return
|
||||
var actual := origin.get_node_or_null(path) as Control
|
||||
_expect(
|
||||
actual == expected,
|
||||
"%s points %s to %s instead of %s."
|
||||
% [
|
||||
origin.name,
|
||||
property,
|
||||
actual.name if actual != null else "nothing",
|
||||
expected.name,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
func _assert_directionally_reachable(
|
||||
start: Control,
|
||||
controls: Array[Control],
|
||||
) -> void:
|
||||
var expected: Dictionary[int, bool] = {}
|
||||
for control: Control in controls:
|
||||
expected[control.get_instance_id()] = true
|
||||
var visited: Dictionary[int, bool] = {start.get_instance_id(): true}
|
||||
var pending: Array[Control] = [start]
|
||||
while not pending.is_empty():
|
||||
var current: Control = pending.pop_front()
|
||||
for path: NodePath in [
|
||||
current.focus_neighbor_left,
|
||||
current.focus_neighbor_right,
|
||||
current.focus_neighbor_top,
|
||||
current.focus_neighbor_bottom,
|
||||
]:
|
||||
var neighbor := current.get_node_or_null(path) as Control
|
||||
if (
|
||||
neighbor == null
|
||||
or not expected.has(neighbor.get_instance_id())
|
||||
or visited.has(neighbor.get_instance_id())
|
||||
):
|
||||
continue
|
||||
visited[neighbor.get_instance_id()] = true
|
||||
pending.append(neighbor)
|
||||
_expect(
|
||||
visited.size() == expected.size(),
|
||||
"Only %d of %d controls are directionally reachable from %s."
|
||||
% [visited.size(), expected.size(), start.name],
|
||||
)
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
_failures.append(message)
|
||||
Loading…
Add table
Add a link
Reference in a new issue