396c3453f5
apple / swift (push) Successful in 1m8s
ci / rust (push) Successful in 1m59s
ci / web (push) Successful in 51s
android / android (push) Successful in 3m44s
ci / docs-site (push) Successful in 1m3s
deb / build-publish (push) Successful in 3m11s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m47s
release / apple (push) Successful in 8m38s
apple / screenshots (push) Successful in 5m27s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m26s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m16s
Ground-up RumbleRenderer rewrite around one principle: rumble is idempotent state on a lossy channel, and the actuator's divergence from it must be bounded, not best-effort. The old renderer rebuilt an infinite-duration CHHapticAdvancedPatternPlayer per 0xCA datagram via an async stop; one stop lost inside CoreHaptics left an unstoppable player buzzing forever (the "entered the menu and rumble never stopped" bug). - Finite 4 s segments, never infinite events — a leaked player self-silences; steady levels re-arm seamlessly ON the engine timeline (no stop/start race) - GamepadFeedback drains the rumble plane DRY per cycle, newest-wins (was one datagram per 8 ms through a 16-deep drop-newest queue = lag + shed stops) - Host 500 ms state refreshes dedupe to a liveness stamp; zero applies immediately; nonzero ramps throttle to one rebake/25 ms per motor - Throwing player stop escalates to engine.stop() (kills leaked players); 1.6 s staleness watchdog (Policy.session) force-silences on a dead channel; the test panel holds levels via Policy.manual - Plain makePlayer, NEVER makeAdvancedPlayer: gamecontrollerd's controller haptics server advertises `adv players: 0`, and iOS 27 beta 2 hard-drops advanced loads with an XPC decode fault (-4811/4097, rumble silently dead). Live-verified on an iOS 27 beta 2 iPhone: DualSense rumble works - Split-handle engines fall back to one combined .default engine on repeated failure; renderer publishes health transitions and the test panel shows them (a refused system service no longer reads as silent app breakage) - Per-motor sharpness on split handles (0.3 heavy / 0.7 light); macOS DualSense raw-HID path gains a ~1 s keepalive re-write while nonzero - RumbleTuningTests pin the scheduling math, tuning relations, and a queue/ticker teardown smoke test Stuck-rumble streaming repro revalidation on glass still pending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
98 lines
4.9 KiB
Swift
98 lines
4.9 KiB
Swift
import XCTest
|
|
|
|
@testable import PunktfunkKit
|
|
|
|
/// Pins the rumble renderer's pure scheduling/mapping decisions and the relations between its
|
|
/// tuning constants that the design depends on (see `RumbleRenderer`'s invariants). No
|
|
/// CHHapticEngine or physical pad involved.
|
|
final class RumbleTuningTests: XCTestCase {
|
|
func testAmplitudeMapsWireRangeToUnitInterval() {
|
|
XCTAssertEqual(RumbleTuning.amplitude(0), 0)
|
|
XCTAssertEqual(RumbleTuning.amplitude(0xFFFF), 1)
|
|
XCTAssertEqual(RumbleTuning.amplitude(0x8000), Float(0x8000) / 65535, accuracy: 1e-6)
|
|
// Monotonic — a stronger wire value can never render weaker.
|
|
XCTAssertLessThan(RumbleTuning.amplitude(0x1000), RumbleTuning.amplitude(0x2000))
|
|
}
|
|
|
|
func testHidByteMapsWireRangeToPadRange() {
|
|
XCTAssertEqual(RumbleTuning.hidByte(0), 0)
|
|
XCTAssertEqual(RumbleTuning.hidByte(0xFFFF), 255)
|
|
XCTAssertEqual(RumbleTuning.hidByte(0x8000), 0x80)
|
|
}
|
|
|
|
func testCombinedActuatorRendersStrongerMotor() {
|
|
XCTAssertEqual(RumbleTuning.combined(low: 0x4000, high: 0x8000), 0x8000)
|
|
XCTAssertEqual(RumbleTuning.combined(low: 0x8000, high: 0x4000), 0x8000)
|
|
XCTAssertEqual(RumbleTuning.combined(low: 0, high: 0), 0)
|
|
}
|
|
|
|
func testLevelDedupeEpsilon() {
|
|
// An identical host refresh (and LSB jitter) is the same level — no player rebuild.
|
|
XCTAssertTrue(RumbleTuning.sameLevel(0.5, 0.5))
|
|
XCTAssertTrue(RumbleTuning.sameLevel(0.5, 0.5 + RumbleTuning.levelEpsilon))
|
|
// A real level change is not.
|
|
XCTAssertFalse(RumbleTuning.sameLevel(0.5, 0.5 + RumbleTuning.levelEpsilon * 3))
|
|
XCTAssertFalse(RumbleTuning.sameLevel(0, 1))
|
|
}
|
|
|
|
func testRearmDecision() {
|
|
let ends: TimeInterval = 100
|
|
XCTAssertFalse(
|
|
RumbleTuning.shouldRearm(endsAt: ends, now: ends - RumbleTuning.rearmHeadroom - 0.1))
|
|
XCTAssertTrue(
|
|
RumbleTuning.shouldRearm(endsAt: ends, now: ends - RumbleTuning.rearmHeadroom + 0.1))
|
|
// Even a segment already past its end re-arms (the gap already happened; recover).
|
|
XCTAssertTrue(RumbleTuning.shouldRearm(endsAt: ends, now: ends + 1))
|
|
}
|
|
|
|
func testHandoffStartsAtSegmentEndNeverInThePast() {
|
|
// Successor starts exactly at the predecessor's end...
|
|
XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 99.5), 100)
|
|
// ...unless that instant already passed — then start immediately, not in the past.
|
|
XCTAssertEqual(RumbleTuning.handoffStart(endsAt: 100, now: 100.5), 100.5)
|
|
}
|
|
|
|
func testPolicies() {
|
|
// The session policy ties motor life to wire liveness; the manual (test-panel) policy
|
|
// holds a level indefinitely.
|
|
XCTAssertNotNil(RumbleRenderer.Policy.session.staleAfter)
|
|
XCTAssertNil(RumbleRenderer.Policy.manual.staleAfter)
|
|
}
|
|
|
|
/// Exercise the renderer's queue/ticker machinery without a physical pad: a wire-rate call
|
|
/// storm, an audible target left to the ticker (watchdog path), then `stop()` — which runs
|
|
/// `queue.sync` against the same serial queue the ticker fires on and must not deadlock.
|
|
func testRendererSurvivesCallStormAndTeardownWithoutController() {
|
|
let renderer = RumbleRenderer(policy: .session)
|
|
renderer.retarget(nil)
|
|
for i in 0..<500 {
|
|
renderer.apply(
|
|
low: i % 2 == 0 ? 0x8000 : 0, high: UInt16(truncatingIfNeeded: i &* 37))
|
|
}
|
|
// Leave a nonzero target long enough for the ticker to spin a few times.
|
|
renderer.apply(low: 0x4000, high: 0x4000)
|
|
Thread.sleep(forTimeInterval: 0.2)
|
|
renderer.stop()
|
|
}
|
|
|
|
func testTuningRelationsTheDesignDependsOn() {
|
|
// The watchdog must tolerate a couple of lost 500 ms host refreshes (heals, not gaps)
|
|
// but trip well before a stuck rumble reads as "still going".
|
|
XCTAssertGreaterThan(RumbleTuning.sessionStaleSeconds, 2 * 0.5)
|
|
XCTAssertLessThanOrEqual(RumbleTuning.sessionStaleSeconds, 2.5)
|
|
// Re-arm headroom must clear several ticker periods, or a steady rumble could miss the
|
|
// segment boundary and gap.
|
|
XCTAssertGreaterThanOrEqual(
|
|
RumbleTuning.rearmHeadroom, 4 * RumbleTuning.tickSeconds)
|
|
// The headroom must fit inside a segment, or re-arm would trigger instantly forever.
|
|
XCTAssertLessThan(RumbleTuning.rearmHeadroom, RumbleTuning.segmentSeconds)
|
|
// The rebake throttle must be far under the host refresh period, or refreshed level
|
|
// changes would queue behind it; and under a frame at 30 fps so ramps stay smooth.
|
|
XCTAssertLessThan(RumbleTuning.minRebakeSeconds, 1.0 / 30)
|
|
// The ticker (which lands throttled levels) must outpace the HID keepalive and the
|
|
// watchdog, or those deadlines could be overshot by a full period.
|
|
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.hidKeepaliveSeconds)
|
|
XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.sessionStaleSeconds)
|
|
}
|
|
}
|