rainbow-dragon/Tests/ForgeCameraTests/DebouncedPushTests.swift

112 lines
4.4 KiB
Swift

import XCTest
import Foundation
import ForgeGrade
import ForgeColor
@testable import ForgeCamera
/// Counts pushes with timestamps.
actor CountingDriver: CameraDriver {
nonisolated let capabilities = CameraCapabilities(
vendor: .simulated, supportsNativeCDL: true, lut3dSizes: [17, 33], metadataFields: [])
private(set) var pushes = [(look: FlattenedLook, at: ContinuousClock.Instant)]()
nonisolated var state: AsyncStream<CameraState> {
AsyncStream { _ in }
}
func connect() async throws {}
func disconnect() async {}
func push(look: FlattenedLook) async throws {
pushes.append((look, ContinuousClock.now))
}
}
final class DebouncedPushTests: XCTestCase {
func grade(slope: Float) -> GradeStack {
GradeStack(nodes: [GradeNode(kind: .cdl(CDL(
slope: SIMD3(repeating: slope), offset: .zero, power: .one, saturation: 1)))])
}
// Rapid updates coalesce: >= 1 push, far fewer than updates, trailing value wins.
func testCoalescing() async throws {
let driver = CountingDriver()
let pusher = DebouncedPusher(driver: driver, interval: .milliseconds(50), latticeSize: 17)
// 30 rapid updates (panel spin).
for i in 0..<30 {
await pusher.update(grade: grade(slope: 1.0 + Float(i) * 0.01))
try await Task.sleep(for: .milliseconds(2))
}
// Allow trailing push to flush.
try await Task.sleep(for: .milliseconds(150))
let pushes = await driver.pushes
XCTAssertGreaterThanOrEqual(pushes.count, 1)
XCTAssertLessThan(pushes.count, 10, "expected coalescing, got \(pushes.count) pushes for 30 updates")
// Trailing edge: final pushed look reflects last update (slope 1.29).
let lastCDL = pushes.last!.look.cdl
XCTAssertEqual(lastCDL?.slope.x ?? 0, 1.29, accuracy: 1e-4)
await pusher.stop()
}
// Single update pushes exactly once.
func testSingleUpdateSinglePush() async throws {
let driver = CountingDriver()
let pusher = DebouncedPusher(driver: driver, interval: .milliseconds(30), latticeSize: 17)
await pusher.update(grade: grade(slope: 1.5))
try await Task.sleep(for: .milliseconds(120))
let pushes = await driver.pushes
XCTAssertEqual(pushes.count, 1)
await pusher.stop()
}
// Pushes respect min interval spacing.
func testMinIntervalRespected() async throws {
let driver = CountingDriver()
let pusher = DebouncedPusher(driver: driver, interval: .milliseconds(60), latticeSize: 17)
for i in 0..<20 {
await pusher.update(grade: grade(slope: 1.0 + Float(i) * 0.01))
try await Task.sleep(for: .milliseconds(10))
}
try await Task.sleep(for: .milliseconds(200))
let pushes = await driver.pushes
for pair in zip(pushes, pushes.dropFirst()) {
let gap = pair.0.at.duration(to: pair.1.at)
XCTAssertGreaterThanOrEqual(gap, .milliseconds(55), "pushes too close: \(gap)")
}
await pusher.stop()
}
// Independent pushers per camera: no cross-interference.
func testPerCameraIndependence() async throws {
let driverA = CountingDriver()
let driverB = CountingDriver()
let pusherA = DebouncedPusher(driver: driverA, interval: .milliseconds(30), latticeSize: 17)
let pusherB = DebouncedPusher(driver: driverB, interval: .milliseconds(30), latticeSize: 17)
await pusherA.update(grade: grade(slope: 1.1))
// B gets nothing.
try await Task.sleep(for: .milliseconds(120))
let pushesA = await driverA.pushes
let pushesB = await driverB.pushes
XCTAssertEqual(pushesA.count, 1)
XCTAssertEqual(pushesB.count, 0)
await pusherA.stop()
await pusherB.stop()
}
// stop() cancels pending trailing push.
func testStopCancelsPending() async throws {
let driver = CountingDriver()
let pusher = DebouncedPusher(driver: driver, interval: .milliseconds(100), latticeSize: 17)
await pusher.update(grade: grade(slope: 1.2))
await pusher.update(grade: grade(slope: 1.3)) // pending trailing
await pusher.stop()
try await Task.sleep(for: .milliseconds(200))
let pushes = await driver.pushes
// First push may have fired immediately; trailing must not after stop.
XCTAssertLessThanOrEqual(pushes.count, 1)
}
}