Files
punktfunk/clients/apple/Tests/PunktfunkKitTests/ModifierResolveTests.swift
T
enricobuehler b6f59f5000
apple / swift (push) Successful in 1m40s
apple / screenshots (push) Has been cancelled
android / android (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
release / apple (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
fix(apple): resolve macOS modifiers by keyCode — Control was silently dropped
Modifier keys arrive only as flagsChanged, and the direction was recovered
by diffing the device-dependent L/R bits (NX_DEVICE*KEYMASK) alone. Those
bits are undocumented and some keyboards omit them (only the class bit,
e.g. NX_CONTROLMASK, is set), so the diff saw no transition and the key
never reached the host — no Ctrl shortcuts. SDL/Moonlight key off the
event's keyCode for exactly this reason; do the same: keyCode names the
changed key, the class bit says up, the device bits (when present) pick
the side, and a tracked-held-state flip covers keyboards without them.

PUNKTFUNK_INPUT_DEBUG=1 now also logs every flagsChanged (keyCode + raw
flags) so a field report is diagnosable from client logs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:29:40 +02:00

88 lines
4.0 KiB
Swift

#if os(macOS)
import XCTest
@testable import PunktfunkKit
/// Pins the macOS flagsChanged modifier-VK resolution (InputCapture.resolveModifier).
/// Modifier keys arrive only as flagsChanged, which carries no down-vs-up: the changed key
/// is the event's keyCode, and the direction is recovered from the flag bits with a
/// held-state fallback for keyboards that omit the device-dependent L/R bits (the gap that
/// used to silently drop Control when the transition was diffed from those bits alone).
final class ModifierResolveTests: XCTestCase {
/// Resolve with a fixed already-held answer for the fallback path.
private func resolve(
keyCode: UInt16, rawFlags: UInt, held: Bool = false
) -> (vk: UInt32, down: Bool)? {
InputCapture.resolveModifier(keyCode: keyCode, rawFlags: rawFlags) { _ in held }
}
// MARK: Keyboards that report the device-dependent L/R bits (the common case)
func testControlPressAndReleaseWithDeviceBits() {
// Real left-Control down: NX_CONTROLMASK | NX_DEVICELCTLKEYMASK (+ misc low bits).
let down = resolve(keyCode: 59, rawFlags: 0x4_0101)
XCTAssertEqual(down?.vk, 0xA2) // VK_LCONTROL
XCTAssertEqual(down?.down, true)
// Release: the class mask is gone entirely.
let up = resolve(keyCode: 59, rawFlags: 0x100)
XCTAssertEqual(up?.vk, 0xA2)
XCTAssertEqual(up?.down, false)
}
func testRightControlUsesItsOwnDeviceBit() {
let down = resolve(keyCode: 62, rawFlags: 0x4_2000)
XCTAssertEqual(down?.vk, 0xA3) // VK_RCONTROL
XCTAssertEqual(down?.down, true)
}
func testReleasingOneOfTwoHeldControls() {
// Left goes up while right stays held: class mask still set, right device bit
// still set, LEFT device bit cleared the left key must resolve as UP.
let leftUp = resolve(keyCode: 59, rawFlags: 0x4_2000, held: true)
XCTAssertEqual(leftUp?.vk, 0xA2)
XCTAssertEqual(leftUp?.down, false)
}
func testEverySideMapsToItsOwnVK() {
XCTAssertEqual(resolve(keyCode: 56, rawFlags: 0x2_0002)?.vk, 0xA0) // VK_LSHIFT
XCTAssertEqual(resolve(keyCode: 60, rawFlags: 0x2_0004)?.vk, 0xA1) // VK_RSHIFT
XCTAssertEqual(resolve(keyCode: 58, rawFlags: 0x8_0020)?.vk, 0xA4) // VK_LMENU
XCTAssertEqual(resolve(keyCode: 61, rawFlags: 0x8_0040)?.vk, 0xA5) // VK_RMENU
XCTAssertEqual(resolve(keyCode: 55, rawFlags: 0x10_0008)?.vk, 0x5B) // VK_LWIN
XCTAssertEqual(resolve(keyCode: 54, rawFlags: 0x10_0010)?.vk, 0x5C) // VK_RWIN
for (_, down) in [56, 60, 58, 61, 55, 54].compactMap({
self.resolve(keyCode: UInt16($0), rawFlags: 0xFF_FFFF)
}) {
XCTAssertTrue(down)
}
}
// MARK: Keyboards that DON'T report the device bits (the bug this resolver fixes)
func testControlPressWithoutDeviceBitsFallsBackToHeldState() {
// Only NX_CONTROLMASK, no low bits at all: a flag diff of the device bits sees no
// transition and drops the key the fallback must infer DOWN from "not held yet".
let down = resolve(keyCode: 59, rawFlags: 0x4_0000, held: false)
XCTAssertEqual(down?.vk, 0xA2)
XCTAssertEqual(down?.down, true)
// And the mirror release (class cleared) still resolves as UP.
let up = resolve(keyCode: 59, rawFlags: 0, held: true)
XCTAssertEqual(up?.down, false)
}
func testClassBitStillSetButKeyAlreadyHeldResolvesUp() {
// Device-bit-less keyboard, second same-class key still holding the class bit:
// the best available answer for the key that changed is to flip its held state.
let up = resolve(keyCode: 59, rawFlags: 0x4_0000, held: true)
XCTAssertEqual(up?.down, false)
}
// MARK: Modifiers the host doesn't consume on this path
func testFnAndCapsLockResolveToNothing() {
XCTAssertNil(resolve(keyCode: 63, rawFlags: 0x80_0000)) // Fn / Globe
XCTAssertNil(resolve(keyCode: 57, rawFlags: 0x1_0000)) // Caps Lock
}
}
#endif