Files
punktfunk/clients/apple/Tests/PunktfunkKitTests/GamepadGlyphTests.swift
enricobuehler 90450ff1f6 feat(apple): the legends remember which pad you own
`buttonGlyph` asked the LIVE controller for its `sfSymbolsName` and fell back
to generic letter glyphs, so the moment a DualSense slept, disconnected or
ran flat, every ✕/◯ hint turned into A/B under its owner's hands. Under
`gamepadUIMode == "always"` — the console UI up with no pad by design — they
were never right at all.

GamepadManager now remembers the family of the last controller that was
actually attached (`DefaultsKey.lastGamepadKind`, written on selection and
deliberately never cleared on disconnect), and the legends resolve live →
remembered → generic. Not a user-facing setting: a glyph-style picker is one
more row asking a question the app can answer itself, and the remembered pad
is right essentially always.

GamepadGlyphs maps by POSITION, not by name, because that is what
`GCExtendedGamepad`'s buttonA/B/X/Y are. So ✕ is role `.a` (the bottom
button), and a Switch pad gets Nintendo's transposed labels rather than Xbox
letters printed on the wrong physical buttons.

The test asserts every symbol name RESOLVES on the running OS. That is the
load-bearing one: `Image(systemName:)` draws an unknown name as nothing at
all — no crash, no log, no red build — so a typo would silently blank a
legend on device and no amount of building would catch it.

macOS + tvOS typecheck; the 5 glyph tests pass; the console UI verified
opening its Settings screen in the iPad simulator.
2026-08-10 07:29:33 +02:00

90 lines
4.3 KiB
Swift

// The remembered-controller glyph table.
//
// The load-bearing assertion here is that every SF Symbol name RESOLVES. `Image(systemName:)`
// renders a name the OS doesn't know as NOTHING at all — no crash, no log, no red build — so a
// typo in the table would silently blank a legend cell on real hardware and be invisible until
// someone looked at a device. This test is the only thing standing between that and a release.
import GameController
import XCTest
@testable import PunktfunkKit
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
final class GamepadGlyphTests: XCTestCase {
private let roles: [GamepadButtonRole] = [.a, .b, .x, .y, .leftShoulder, .rightShoulder]
/// Does the running OS actually have this symbol?
private func symbolExists(_ name: String) -> Bool {
#if canImport(UIKit)
return UIImage(systemName: name) != nil
#elseif canImport(AppKit)
return NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil
#else
return true
#endif
}
func testEveryGlyphNameResolvesOnThisOS() {
for kind in PunktfunkConnection.GamepadType.allCases {
for role in roles {
let name = GamepadGlyphs.symbol(role, for: kind)
XCTAssertTrue(
symbolExists(name),
"SF Symbol \"\(name)\" (\(role), \(kind)) does not resolve — the legend cell "
+ "would render blank on device")
}
}
}
/// ✕ is the BOTTOM button on a PlayStation pad, which is `GCExtendedGamepad.buttonA` — the
/// whole point of the table being positional. Getting this backwards would print ◯ where the
/// user has to press ✕.
func testPlayStationFaceButtonsAreShapesInPositionalOrder() {
for kind in [PunktfunkConnection.GamepadType.dualSense, .dualSenseEdge, .dualShock4] {
XCTAssertEqual(GamepadGlyphs.symbol(.a, for: kind), "xmark.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.b, for: kind), "circle.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.x, for: kind), "square.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.y, for: kind), "triangle.circle")
}
}
/// Nintendo's labels sit transposed on the same physical positions: the bottom button (role
/// `.a`) is labelled B, and the right one (role `.b`) is labelled A.
func testSwitchFaceButtonsAreTransposed() {
XCTAssertEqual(GamepadGlyphs.symbol(.a, for: .switchPro), "b.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.b, for: .switchPro), "a.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.x, for: .switchPro), "y.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.y, for: .switchPro), "x.circle")
}
/// `.auto` is what a device that has never seen a controller reports, and Xbox letters are the
/// neutral default — they are also the positional names `GCExtendedGamepad` itself uses.
func testUnknownAndXboxFamiliesUseLetters() {
for kind in [PunktfunkConnection.GamepadType.auto, .xbox360, .xboxOne, .steamDeck] {
XCTAssertEqual(GamepadGlyphs.symbol(.a, for: kind), "a.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.b, for: kind), "b.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.x, for: kind), "x.circle")
XCTAssertEqual(GamepadGlyphs.symbol(.y, for: kind), "y.circle")
}
}
/// The key-path bridge the legends reach this table through (`buttonGlyph` spells its buttons
/// as key paths). A wrong mapping here would print the wrong button on every family at once.
func testRolesResolveFromExtendedGamepadKeyPaths() {
XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonA), .a)
XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonB), .b)
XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonX), .x)
XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonY), .y)
XCTAssertEqual(GamepadButtonRole(keyPath: \.leftShoulder), .leftShoulder)
XCTAssertEqual(GamepadButtonRole(keyPath: \.rightShoulder), .rightShoulder)
// A button outside the six the legends name has no honest glyph on every family, so it
// falls through to the caller's own fallback rather than guessing.
XCTAssertNil(GamepadButtonRole(keyPath: \.leftTrigger))
}
}