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) } /// 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() } /// A zero command must silence promptly — the engine (punktfunk-core) emits explicit zeros at /// every policy stop (lease expiry, legacy staleness, session close), and the renderer's only /// job is to apply them. Drive the real queue/ticker (no physical pad) and confirm no wedge. func testZeroCommandSilencesAndTeardownDoesNotDeadlock() { let renderer = RumbleRenderer(policy: .session) renderer.retarget(nil) renderer.apply(low: 0x8000, high: 0x8000) Thread.sleep(forTimeInterval: 0.1) renderer.apply(low: 0, high: 0) Thread.sleep(forTimeInterval: 0.1) // No assertion on private state; this exercises the stop path + serial-queue teardown // without deadlock (the ticker fires on the same queue stop() sync-hops onto). renderer.stop() } func testTuningRelationsTheDesignDependsOn() { // 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, or its // deadline could be overshot by a full period. XCTAssertLessThan(RumbleTuning.tickSeconds, RumbleTuning.hidKeepaliveSeconds) } }