Files
OSGKeyboard/OSGKeyboardTests/FlowPiPRecoveryPolicyTests.swift
rocky 6e64f96dce fix(flow): spread PiP recovery attempts across the budget after a media server reset
- Schedule up to four attempts at fixed offsets (0, 0.5, 1.5, 3 s) from the start of recovery instead of short gaps after each fast failure.
- Wake the wait early when AVAudioSession reports mediaServicesWereReset.
- Re-apply the playback category and reactivate the session instead of trusting cached state from before the reset.
2026-10-20 23:17:46 +02:00

231 lines
7.0 KiB
Swift

// FlowPiPRecoveryPolicyTests.swift
// OSGKeyboardTests
@testable import OSGKeyboard
import XCTest
final class FlowPiPRecoveryPolicyTests: XCTestCase {
// MARK: - Reconciliation
func testNoSessionIntentNeverStartsRecovery() {
XCTAssertEqual(
FlowPiPRecoveryPolicy.reconciliationDecision(
wantsActiveSession: false,
isAppForeground: true,
isPictureInPictureActive: false
),
.noSessionIntent
)
}
func testActivePiPIsIdempotent() {
XCTAssertEqual(
FlowPiPRecoveryPolicy.reconciliationDecision(
wantsActiveSession: true,
isAppForeground: true,
isPictureInPictureActive: true
),
.alreadyActive
)
}
func testTakeoverWaitsWhileAppIsInBackground() {
XCTAssertEqual(
FlowPiPRecoveryPolicy.reconciliationDecision(
wantsActiveSession: true,
isAppForeground: false,
isPictureInPictureActive: false
),
.waitForForeground
)
}
func testReturningToForegroundStartsRecovery() {
XCTAssertEqual(
FlowPiPRecoveryPolicy.reconciliationDecision(
wantsActiveSession: true,
isAppForeground: true,
isPictureInPictureActive: false
),
.startRecovery
)
}
// MARK: - Bounded retry
func testRetryPolicySpreadsFourAttemptsAcrossTheBudget() {
XCTAssertEqual(FlowPiPRecoveryPolicy.maxAttempts, 4)
let offsets = (1...FlowPiPRecoveryPolicy.maxAttempts).map {
FlowPiPRecoveryPolicy.attemptOffset(beforeAttempt: $0)
}
XCTAssertEqual(offsets, [0, 0.5, 1.5, 3])
XCTAssertEqual(FlowPiPRecoveryPolicy.totalBudget, 5)
// The last attempt must still leave time for PiP to become active.
XCTAssertLessThan(offsets.last ?? 0, FlowPiPRecoveryPolicy.totalBudget - 1)
}
func testRetryTimeoutsNeverExceedRemainingBudget() {
XCTAssertEqual(
FlowPiPRecoveryPolicy.hostTimeout(attempt: 1, remainingBudget: 0.4),
0.4
)
XCTAssertEqual(
FlowPiPRecoveryPolicy.hostTimeout(attempt: 2, remainingBudget: 4),
0.25
)
XCTAssertEqual(
FlowPiPRecoveryPolicy.activeTimeout(remainingBudget: 0.3),
0.3
)
XCTAssertEqual(
FlowPiPRecoveryPolicy.activeTimeout(remainingBudget: 4),
1.4
)
XCTAssertEqual(
FlowPiPRecoveryPolicy.activeTimeout(remainingBudget: 0.01),
0.01
)
}
// MARK: - Late completion safety
func testCurrentForegroundOperationCanContinue() {
XCTAssertTrue(
FlowPiPRecoveryPolicy.canContinue(
operation: 7,
currentOperation: 7,
wantsActiveSession: true,
isAppForeground: true,
taskIsCancelled: false
)
)
}
func testOldGenerationCannotOverwriteNewRecovery() {
XCTAssertFalse(
FlowPiPRecoveryPolicy.canContinue(
operation: 6,
currentOperation: 7,
wantsActiveSession: true,
isAppForeground: true,
taskIsCancelled: false
)
)
}
func testExplicitEndPreventsLateRecovery() {
XCTAssertFalse(
FlowPiPRecoveryPolicy.canContinue(
operation: 7,
currentOperation: 8,
wantsActiveSession: false,
isAppForeground: true,
taskIsCancelled: true
)
)
}
func testBackgroundAndCancellationBothStopRecovery() {
XCTAssertFalse(
FlowPiPRecoveryPolicy.canContinue(
operation: 7,
currentOperation: 7,
wantsActiveSession: true,
isAppForeground: false,
taskIsCancelled: false
)
)
XCTAssertFalse(
FlowPiPRecoveryPolicy.canContinue(
operation: 7,
currentOperation: 7,
wantsActiveSession: true,
isAppForeground: true,
taskIsCancelled: true
)
)
}
}
final class FlowPiPLossReportGateTests: XCTestCase {
/// The backgrounded heartbeat re-evaluates a lost PiP every second. One
/// incident must produce one report, not one per tick.
func testRepeatedLossChecksWithinOneEpisodeReportOnce() {
var gate = FlowPiPLossReportGate()
XCTAssertTrue(gate.shouldReport())
for _ in 0..<30 {
XCTAssertFalse(gate.shouldReport())
}
}
func testPiPBecomingActiveOpensANewEpisode() {
var gate = FlowPiPLossReportGate()
XCTAssertTrue(gate.shouldReport())
XCTAssertFalse(gate.shouldReport())
gate.pipBecameActive()
XCTAssertTrue(gate.shouldReport())
}
}
final class FlowSessionWarningReportGateTests: XCTestCase {
/// The whole point: a toast raised by a path nobody instrumented must still
/// produce a report.
func testUninstrumentedWarningIsReported() {
var gate = FlowSessionWarningReportGate()
XCTAssertTrue(
gate.shouldReport(
warning: "cannot start voice session",
lastExplicitReportAt: nil,
now: Date()
)
)
}
/// A path that already wrote a precise report must not be double-counted.
func testWarningIsSuppressedWhenAnExplicitReportJustLanded() {
var gate = FlowSessionWarningReportGate()
let now = Date()
XCTAssertFalse(
gate.shouldReport(
warning: "permissions missing",
lastExplicitReportAt: now.addingTimeInterval(-0.2),
now: now
)
)
}
func testOlderExplicitReportDoesNotSuppressANewWarning() {
var gate = FlowSessionWarningReportGate()
let now = Date()
XCTAssertTrue(
gate.shouldReport(
warning: "cannot start voice session",
lastExplicitReportAt: now.addingTimeInterval(
-(FlowSessionWarningReportGate.duplicateGrace + 1)
),
now: now
)
)
}
/// A toast that stays up must not re-report on every state republish.
func testSameWarningReportsOnlyOncePerEpisode() {
var gate = FlowSessionWarningReportGate()
let now = Date()
XCTAssertTrue(
gate.shouldReport(warning: "same", lastExplicitReportAt: nil, now: now)
)
XCTAssertFalse(
gate.shouldReport(warning: "same", lastExplicitReportAt: nil, now: now)
)
// Cleared and shown again is a genuinely new toast.
gate.warningCleared()
XCTAssertTrue(
gate.shouldReport(warning: "same", lastExplicitReportAt: nil, now: now)
)
}
}