ci / web (pull_request) Successful in 1m0s
ci / bun-nix (pull_request) Successful in 1m11s
ci / docs-site (pull_request) Successful in 1m18s
apple / swift (pull_request) Successful in 1m34s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 4m5s
ci / rust (pull_request) Successful in 6m27s
An Apple TV session had no way to the stats overlay at all. Every other client cycles it in-stream — Ctrl+Alt+Shift+S on the desktops, a three-finger tap on touch — and tvOS has neither a keyboard nor a screen to tap, so the only route was Settings before connecting (or a profile). The docs' own "cycle with" table simply had no row for it. Two surfaces, because an Apple TV may have a controller in the room or only the remote: - Select + X on a controller, cycling one tier per completion. Built like Android's mic chord (Select + Y) and deliberately disjoint from the escape chord — X is none of its four buttons, so reaching for one can never trip the other. Read off the wire mask like the escape chord, so a Select the hold-Select gesture has turned into a guide can't cycle the overlay on its way past. Available on every Apple platform: a controller in both hands is exactly the case the keyboard combo and the three-finger tap can't serve. - Hold Play/Pause on the Siri Remote. Its right-click is therefore deferred until the press resolves — a tap still right-clicks, delivered on release with the release trailing by TAP_PRESS — because a right button held for half a second is a context menu on every desktop this streams. A non-forwarding slot now claims the stats chord's elements too, alongside the escape chord's: on tvOS an unclaimed button's press stays the system's and the chord would silently never complete. Tests pin both chords' masks against their GameController alias lists, that the two overlap only on Select, and that the claim list covers both without duplicates — the failure mode is nothing happening, with nothing logged.
54 lines
2.6 KiB
Swift
54 lines
2.6 KiB
Swift
import GameController
|
|
import XCTest
|
|
|
|
@testable import PunktfunkKit
|
|
|
|
/// The escape chord's mask and its GameController alias list have to describe the same four
|
|
/// buttons. `GamepadCapture.openSlot` claims the system gesture of every element while forwarding
|
|
/// is on, but only of `chordElements` — `escapeChordElements` plus the stats chord's — while it is
|
|
/// off, so if this alias list ever stops covering the mask, the missing button's press stays the
|
|
/// system's and the chord never completes. (`GamepadStatsChordTests` pins the claim list itself.)
|
|
///
|
|
/// That matters most on tvOS, where this chord is the only controller way out of a stream: the
|
|
/// symptom is a session nobody can leave with the pad in their hands, and nothing logs or crashes.
|
|
/// Hence a test on the invariant rather than trusting the comment beside it.
|
|
@MainActor
|
|
final class GamepadEscapeChordTests: XCTestCase {
|
|
|
|
/// The intended alias↔bit pairing, spelled out independently of the implementation.
|
|
private let pairing: [(alias: String, bit: UInt32)] = [
|
|
(GCInputLeftShoulder, GamepadWire.leftShoulder),
|
|
(GCInputRightShoulder, GamepadWire.rightShoulder),
|
|
(GCInputButtonMenu, GamepadWire.start),
|
|
(GCInputButtonOptions, GamepadWire.back),
|
|
]
|
|
|
|
func testChordMaskIsExactlyTheFourPairedButtons() {
|
|
XCTAssertEqual(
|
|
pairing.reduce(UInt32(0)) { $0 | $1.bit },
|
|
GamepadCapture.escapeChord,
|
|
"the chord mask and the alias pairing describe different buttons")
|
|
}
|
|
|
|
func testEveryChordBitHasAnElementToClaim() {
|
|
// One alias per bit — a mask that grew a fifth button without a matching alias would
|
|
// leave that button's gesture with the OS while forwarding is off.
|
|
XCTAssertEqual(
|
|
GamepadCapture.escapeChordElements.count,
|
|
GamepadCapture.escapeChord.nonzeroBitCount,
|
|
"alias list and chord mask differ in size")
|
|
XCTAssertEqual(GamepadCapture.escapeChordElements, pairing.map(\.alias))
|
|
}
|
|
|
|
/// The claim list is a strict subset of what a forwarding slot takes — it is a NARROWING of
|
|
/// the full sweep, never an extra grab, and it must not be empty (that would be "skip", which
|
|
/// is the behaviour this deliberately avoids).
|
|
func testClaimListIsNonEmptyAndAllDistinct() {
|
|
XCTAssertFalse(GamepadCapture.escapeChordElements.isEmpty)
|
|
XCTAssertEqual(
|
|
Set(GamepadCapture.escapeChordElements).count,
|
|
GamepadCapture.escapeChordElements.count,
|
|
"a repeated alias would mean a chord bit has no element")
|
|
}
|
|
}
|