| | |
| | | #!/bin/bash |
| | | # Round01 keyboard navigation fixture QA. It drives deterministic key commands |
| | | # through the Quick Switch debug hook and verifies stable selection, no wrapping, |
| | | # visible scrolling, Tab ignore, Enter activation reporting, and clean close. |
| | | # through the Quick Switch debug hook and verifies vertical App-index focus, |
| | | # column-local movement, visible scrolling, Tab ignore, Enter activation reporting, |
| | | # and clean close. |
| | | |
| | | set -euo pipefail |
| | | |
| | |
| | | source "$SCRIPT_DIR/build-output-paths.sh" |
| | | APP="$BUILD_CURRENT_APP" |
| | | REPORT="$BUILD_REPORT_ROOT/round01-keyboard-navigation-fixture-report.json" |
| | | FIXTURE_APP_COUNT="${ALIGNER_ROUND1_KEYBOARD_FIXTURE_APP_COUNT:-8}" |
| | | FIXTURE_APP_COUNT="${ALIGNER_ROUND1_KEYBOARD_FIXTURE_APP_COUNT:-12}" |
| | | FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_KEYBOARD_FIXTURE_WINDOWS_PER_APP:-8}" |
| | | KEY_SEQUENCE="${ALIGNER_ROUND1_KEYBOARD_SEQUENCE:-right,down,down,down,down,down,down,down,left,right,right,right,right,right,right,right,right,tab,enter}" |
| | | KEY_SEQUENCE="${ALIGNER_ROUND1_KEYBOARD_SEQUENCE:-A,down,down,down,down,down,down,down,down,tab,enter}" |
| | | EXPECTED_APP_INDEX="${ALIGNER_ROUND1_KEYBOARD_EXPECTED_APP_INDEX:-10}" |
| | | EXPECTED_WINDOW_INDEX="${ALIGNER_ROUND1_KEYBOARD_EXPECTED_WINDOW_INDEX:-$((FIXTURE_WINDOWS_PER_APP - 1))}" |
| | | REPORT_WAIT="${ALIGNER_ROUND1_KEYBOARD_REPORT_WAIT:-6.0}" |
| | | |
| | | fail() { |
| | |
| | | } |
| | | |
| | | assert_report() { |
| | | /usr/bin/python3 - "$REPORT" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" "$KEY_SEQUENCE" <<'PY' |
| | | /usr/bin/python3 - "$REPORT" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" "$KEY_SEQUENCE" "$EXPECTED_APP_INDEX" "$EXPECTED_WINDOW_INDEX" <<'PY' |
| | | import json |
| | | import sys |
| | | |
| | |
| | | fixture_app_count = int(sys.argv[2]) |
| | | fixture_windows_per_app = int(sys.argv[3]) |
| | | key_sequence = [part for part in sys.argv[4].split(",") if part] |
| | | expected_selected_app_group_index = int(sys.argv[5]) |
| | | expected_selected_window_index = int(sys.argv[6]) |
| | | |
| | | with open(path, "r", encoding="utf-8") as file: |
| | | report = json.load(file) |
| | |
| | | root = report.get("rootView", {}) |
| | | columns = root.get("waterfallColumns", []) |
| | | items = root.get("appShelfItems", []) |
| | | index_symbols = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ" |
| | | |
| | | def normalized_key_command(command): |
| | | upper = command.upper() |
| | | if len(upper) == 1 and upper in index_symbols: |
| | | return f"app:{upper}" |
| | | if command == "tab": |
| | | return "tabIgnored" |
| | | return command |
| | | |
| | | require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") |
| | | require(report.get("quickSwitchVisible") is False, "Enter activation must close Quick Switch") |
| | |
| | | require(len(columns) == fixture_app_count, "Waterfall column reports must match fixture count") |
| | | require(len(items) == fixture_app_count, "App Shelf item reports must match fixture count") |
| | | |
| | | expected_selected_app_group_index = fixture_app_count - 1 |
| | | expected_selected_window_index = 0 |
| | | selected_column = columns[expected_selected_app_group_index] |
| | | selected_card = selected_column.get("cards", [])[expected_selected_window_index] |
| | | expected_selected_window_id = selected_card.get("windowID") |
| | | |
| | | require(root.get("selectedAppGroupIndex") == expected_selected_app_group_index, "right-arrow navigation must stop on the final App column") |
| | | require(root.get("selectedWindowIndex") == expected_selected_window_index, "right-arrow into new columns must fall back to window index zero") |
| | | require(root.get("selectedAppGroupIndex") == expected_selected_app_group_index, "index-key App focus plus down-arrow navigation must select the expected App column") |
| | | require(root.get("selectedWindowIndex") == expected_selected_window_index, "down-arrow navigation must move within the focused App column") |
| | | require(root.get("selectedWindowID") == expected_selected_window_id, "selectedWindowID must match selected card") |
| | | require(root.get("keyboardFocusedAppGroupIndex") == expected_selected_app_group_index, "keyboard App focus must stay on the target App") |
| | | require(root.get("hoveredAppGroupIndex") == expected_selected_app_group_index, "keyboard App focus must expose hoveredAppGroupIndex") |
| | | require(root.get("hoverTargetKind") == "app", "keyboard App focus must use an App hover target") |
| | | require(root.get("hoverTargetSource") == "keyboard", "keyboard App focus must report keyboard source") |
| | | expected_app_index_command = next((normalized_key_command(command) for command in key_sequence if normalized_key_command(command).startswith("app:")), None) |
| | | require(root.get("lastKeyboardAppIndexCommand") == expected_app_index_command, "App index command must be reported") |
| | | require(report.get("lastCommittedWindowID") == expected_selected_window_id, "Enter commit must be surfaced at session level") |
| | | require(report.get("lastCommitSource") == "keyboard", "Enter commit source must be keyboard") |
| | | require(report.get("lastActivationWindowID") == expected_selected_window_id, "Enter activation window must match selected card") |
| | |
| | | require(root.get("lastCommittedWindowIndex") == expected_selected_window_index, "Enter commit window index must match selected card") |
| | | |
| | | commands = root.get("keyboardCommandsApplied", []) |
| | | require(commands == ["tabIgnored" if command == "tab" else command for command in key_sequence], "debug keyboard sequence must be applied in order with Tab ignored") |
| | | require(commands == [normalized_key_command(command) for command in key_sequence], "debug keyboard sequence must be applied in order with App index and Tab normalized") |
| | | require(root.get("tabIgnoredCount") == 1, "Tab must be ignored exactly once") |
| | | require(root.get("lastKeyboardCommand") == "enter", "Enter must be the final keyboard command") |
| | | require(root.get("boundaryBounceCount") == 0, "in-range down-arrow movement must not report a boundary bounce") |
| | | |
| | | selected_cards = [card for column in columns for card in column.get("cards", []) if card.get("isSelected") is True] |
| | | require(len(selected_cards) == 1, "Waterfall must expose exactly one selected card after keyboard navigation") |
| | | require(selected_cards[0].get("windowID") == expected_selected_window_id, "selected Waterfall card must match selectedWindowID") |
| | | require("selectedVisualSuppressed" in selected_cards[0].get("visualStates", []), "non-hovered keyboard-selected Waterfall card must suppress selected visual state") |
| | | require(selected_cards[0].get("shineVisible") is False, "non-hovered keyboard-selected Waterfall card must not expose shine") |
| | | require(selected_cards[0].get("zPosition", 0) == 0, "non-hovered keyboard-selected Waterfall card must not float above normal cards") |
| | | require(selected_cards[0].get("isKeyboardFocused") is True, "keyboard-selected Waterfall card must expose keyboard focus") |
| | | require("selected" in selected_cards[0].get("visualStates", []), "keyboard-selected Waterfall card must expose selected visual state") |
| | | require("keyboardFocused" in selected_cards[0].get("visualStates", []), "keyboard-selected Waterfall card must expose keyboardFocused visual state") |
| | | require("selectedVisualSuppressed" not in selected_cards[0].get("visualStates", []), "keyboard-selected Waterfall card must not suppress selected visual state") |
| | | require(selected_cards[0].get("shineVisible") is True, "keyboard-selected Waterfall card must expose shine") |
| | | require(selected_cards[0].get("zPosition", 0) > 0, "keyboard-selected Waterfall card must float above normal cards") |
| | | require(selected_cards[0].get("titleBarBackgroundColor", {}).get("alpha", 0) > 0.10, "keyboard-selected Waterfall card title bar must expose blue fill") |
| | | first_selected_column_card = selected_column.get("cards", [])[0] |
| | | if first_selected_column_card.get("windowID") != expected_selected_window_id: |
| | | require("appLinked" not in first_selected_column_card.get("visualStates", []), "App-linked first card must yield to keyboard-focused window") |
| | | require(first_selected_column_card.get("titleBarBackgroundColor", {}).get("alpha", 0) <= 0.01, "first card title bar must clear App hover fill while another window has keyboard focus") |
| | | |
| | | selected_items = [item for item in items if item.get("isSelected") is True] |
| | | require(len(selected_items) == 1, "App Shelf must expose exactly one selected App") |
| | | require(selected_items[0].get("index") == expected_selected_app_group_index, "App Shelf selection must follow keyboard-selected Waterfall column") |
| | | require(selected_items[0].get("isHovered") is True, "App Shelf selected App must also expose keyboard hover") |
| | | require("selected" in selected_items[0].get("visualStates", []), "keyboard-focused selected App Shelf item must expose selected visual") |
| | | require("hover" in selected_items[0].get("visualStates", []), "keyboard-focused selected App Shelf item must expose hover visual") |
| | | |
| | | history = { |
| | | entry.get("appGroupIndex"): entry.get("windowIndex") |
| | | for entry in root.get("columnSelectionHistory", []) |
| | | } |
| | | require(history.get(1) == fixture_windows_per_app - 1, "left/right navigation must restore the remembered window index for column 1") |
| | | require(history.get(expected_selected_app_group_index) == 0, "final column history must remember selected window zero") |
| | | require(history.get(expected_selected_app_group_index) == expected_selected_window_index, "final column history must remember selected window") |
| | | |
| | | require(root.get("waterfallScrollable") is True, "fixture must make Waterfall horizontally scrollable") |
| | | require(root.get("waterfallMaxScrollOffset", 0) > 0, "Waterfall must expose horizontal overflow") |
| | | require(root.get("waterfallScrollOffset", 0) > 0, "selected far-right App column must auto-scroll into view") |
| | | require(columns[1].get("maxVerticalScrollOffset", 0) > 0, "fixture column 1 must expose vertical overflow") |
| | | require(columns[1].get("verticalScrollOffset", 0) > 0, "down-arrow navigation must auto-scroll the selected window into view") |
| | | require(selected_column.get("maxVerticalScrollOffset", 0) > 0, "selected fixture column must expose vertical overflow") |
| | | require(selected_column.get("verticalScrollOffset", 0) > 0, "down-arrow navigation must auto-scroll the selected window into view") |
| | | |
| | | frame = selected_cards[0].get("visibleFrame", {}) |
| | | column_frame = selected_column.get("visibleFrame", {}) |
| | | header_frame = selected_column.get("headerFrame", {}) |
| | | cards_clip_frame = selected_column.get("cardsClipFrame", {}) |
| | | visibility_tolerance = 1 |
| | | require(frame.get("x", -1) >= 0, "selected Waterfall card must be horizontally visible") |
| | | require(frame.get("x", 0) + frame.get("width", 0) <= root.get("waterfallVisibleWidth", 0) + 1, "selected Waterfall card must fit in visible Waterfall width") |
| | | require(frame.get("y", -1) >= 0, "selected Waterfall card must be vertically visible") |
| | | require(frame.get("y", -1) >= -visibility_tolerance, "selected Waterfall card must be vertically visible") |
| | | require(frame.get("y", 0) + frame.get("height", 0) <= root.get("waterfallFrame", {}).get("height", 0) + 1, "selected Waterfall card must fit in visible Waterfall height") |
| | | require(selected_column.get("cardsClipMasksToBounds") is True, "vertical Waterfall column must clip cards below the pinned header") |
| | | require(cards_clip_frame.get("y", -1) == 0, "cards clip layer must start below the column bottom edge") |
| | | require(cards_clip_frame.get("height", 0) <= header_frame.get("y", 0) + 1, "cards clip layer must stop before the pinned header") |
| | | header_min_y = column_frame.get("y", 0) + header_frame.get("y", 0) |
| | | require(frame.get("y", 0) + frame.get("height", 0) <= header_min_y + 1, "selected Waterfall card must not overlap the pinned column header") |
| | | |
| | | print(json.dumps({ |
| | | "selectedAppGroupIndex": root.get("selectedAppGroupIndex"), |
| | | "selectedWindowIndex": root.get("selectedWindowIndex"), |
| | | "selectedWindowID": root.get("selectedWindowID"), |
| | | "lastCommittedWindowID": root.get("lastCommittedWindowID"), |
| | | "lastActivationResult": report.get("lastActivationResult"), |
| | | "quickSwitchVisible": report.get("quickSwitchVisible"), |
| | | "waterfallScrollOffset": root.get("waterfallScrollOffset"), |
| | | "column1VerticalScrollOffset": columns[1].get("verticalScrollOffset"), |
| | | "tabIgnoredCount": root.get("tabIgnoredCount") |
| | | }, indent=2, ensure_ascii=False)) |
| | | "lastActivationResult": report.get("lastActivationResult"), |
| | | "quickSwitchVisible": report.get("quickSwitchVisible"), |
| | | "waterfallScrollOffset": root.get("waterfallScrollOffset"), |
| | | "selectedColumnVerticalScrollOffset": selected_column.get("verticalScrollOffset"), |
| | | "keyboardFocusedAppGroupIndex": root.get("keyboardFocusedAppGroupIndex"), |
| | | "tabIgnoredCount": root.get("tabIgnoredCount") |
| | | }, indent=2, ensure_ascii=False)) |
| | | PY |
| | | } |
| | | |