fix(analytics): prevent database locks during suspension

This commit is contained in:
Rocky
2026-08-21 21:13:13 +08:00
parent 7bd77509b6
commit ac374631ae
11 changed files with 599 additions and 155 deletions
@@ -0,0 +1,179 @@
// AnalyticsUploadSchedulingTests.swift
// OSGKeyboardTests
//
// Foreground upload policy regression tests for batching and suspension safety.
@testable import OSGKeyboard
import XCTest
final class AnalyticsUploadSchedulingTests: XCTestCase {
func testCountThresholdCoalescesSignalsIntoOneUpload() async throws {
let probe = AnalyticsUploadProbe()
let signal = AnalyticsUploadSignal(
policy: AnalyticsUploadPolicy(
eventThreshold: 3,
flushInterval: .seconds(10),
activationDelay: .zero,
maximumBatches: 1
)
)
await signal.install {
await probe.recordUpload()
}
await signal.setActive(true)
await signal.requestUpload()
await signal.requestUpload()
try await Task.sleep(for: .milliseconds(30))
let countBeforeThreshold = await probe.uploadCount()
XCTAssertEqual(countBeforeThreshold, 0)
await signal.requestUpload()
try await waitUntil { await probe.uploadCount() == 1 }
let countAfterThreshold = await probe.uploadCount()
XCTAssertEqual(countAfterThreshold, 1)
}
func testIntervalFlushesAQueueBelowThreshold() async throws {
let probe = AnalyticsUploadProbe()
let signal = AnalyticsUploadSignal(
policy: AnalyticsUploadPolicy(
eventThreshold: 20,
flushInterval: .milliseconds(20),
activationDelay: .zero,
maximumBatches: 1
)
)
await signal.install {
await probe.recordUpload()
}
await signal.setActive(true)
await signal.requestUpload()
try await waitUntil { await probe.uploadCount() == 1 }
let uploadCount = await probe.uploadCount()
XCTAssertEqual(uploadCount, 1)
}
func testInactiveSignalDefersUntilNextActivation() async throws {
let probe = AnalyticsUploadProbe()
let signal = AnalyticsUploadSignal(
policy: AnalyticsUploadPolicy(
eventThreshold: 2,
flushInterval: .milliseconds(20),
activationDelay: .milliseconds(20),
maximumBatches: 1
)
)
await signal.install {
await probe.recordUpload()
}
await signal.requestUpload()
await signal.requestUpload()
try await Task.sleep(for: .milliseconds(40))
let inactiveUploadCount = await probe.uploadCount()
XCTAssertEqual(inactiveUploadCount, 0)
await signal.setActive(true)
try await waitUntil { await probe.uploadCount() == 1 }
let activeUploadCount = await probe.uploadCount()
XCTAssertEqual(activeUploadCount, 1)
}
func testLateActionInstallationDoesNotLoseActivationUpload() async throws {
let probe = AnalyticsUploadProbe()
let signal = AnalyticsUploadSignal(
policy: AnalyticsUploadPolicy(
eventThreshold: 20,
flushInterval: .seconds(10),
activationDelay: .milliseconds(20),
maximumBatches: 1
)
)
await signal.setActive(true)
await signal.requestActivationUpload()
await signal.install {
await probe.recordUpload()
}
try await waitUntil { await probe.uploadCount() == 1 }
let uploadCount = await probe.uploadCount()
XCTAssertEqual(uploadCount, 1)
}
func testPauseCancelsAndWaitsForInFlightUpload() async throws {
let probe = AnalyticsUploadProbe()
let signal = AnalyticsUploadSignal(
policy: AnalyticsUploadPolicy(
eventThreshold: 1,
flushInterval: .seconds(10),
activationDelay: .zero,
maximumBatches: 1
)
)
await signal.install {
await probe.recordStarted()
do {
try await Task.sleep(for: .seconds(30))
} catch {
await probe.recordCancellation()
}
}
await signal.setActive(true)
await signal.requestUpload()
try await waitUntil { await probe.didStart() }
await signal.pauseAndWait()
let wasCancelled = await probe.wasCancelled()
XCTAssertTrue(wasCancelled)
}
private func waitUntil(
timeout: Duration = .seconds(1),
condition: @escaping @Sendable () async -> Bool
) async throws {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
while clock.now < deadline {
if await condition() {
return
}
try await Task.sleep(for: .milliseconds(10))
}
XCTFail("Timed out waiting for analytics upload state")
}
}
private actor AnalyticsUploadProbe {
private var uploads = 0
private var started = false
private var cancelled = false
func recordUpload() {
uploads += 1
}
func uploadCount() -> Int {
uploads
}
func recordStarted() {
started = true
}
func didStart() -> Bool {
started
}
func recordCancellation() {
cancelled = true
}
func wasCancelled() -> Bool {
cancelled
}
}