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 { 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) } }