From 1ac4e6a75439bbc152f9b41e1e2b2dcef78b7eec Mon Sep 17 00:00:00 2001 From: Forge Dev Date: Fri, 10 Jul 2026 20:09:00 +0000 Subject: [PATCH] =?UTF-8?q?camera:=20DebouncedPusher=20=E2=80=94=20interva?= =?UTF-8?q?l-limited=20camera=20pushes,=20guaranteed=20trailing=20edge,=20?= =?UTF-8?q?per-camera=20independence=20=E2=80=94=205=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/ForgeCamera/DebouncedPusher.swift | 69 +++++++++++ .../ForgeCameraTests/DebouncedPushTests.swift | 112 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 Sources/ForgeCamera/DebouncedPusher.swift create mode 100644 Tests/ForgeCameraTests/DebouncedPushTests.swift diff --git a/Sources/ForgeCamera/DebouncedPusher.swift b/Sources/ForgeCamera/DebouncedPusher.swift new file mode 100644 index 0000000..f63f6a5 --- /dev/null +++ b/Sources/ForgeCamera/DebouncedPusher.swift @@ -0,0 +1,69 @@ +import Foundation +import ForgeGrade + +/// Rate-limits camera look pushes. Preview updates instantly elsewhere; +/// camera gets at most one push per interval, trailing edge guaranteed — +/// final panel position always lands on camera. +public actor DebouncedPusher { + private let driver: any CameraDriver + private let interval: Duration + private let latticeSize: Int + + private var latestGrade: GradeStack? + private var lastPushAt: ContinuousClock.Instant? + private var pending: Task? + private var stopped = false + + public init(driver: any CameraDriver, interval: Duration = .milliseconds(150), latticeSize: Int = 33) { + self.driver = driver + self.interval = interval + self.latticeSize = latticeSize + } + + /// Record new grade; push now if interval elapsed, else schedule trailing push. + public func update(grade: GradeStack) { + guard !stopped else { return } + latestGrade = grade + + let now = ContinuousClock.now + if let last = lastPushAt, last.duration(to: now) < interval { + scheduleTrailing(after: interval - last.duration(to: now)) + } else { + pushNow() + } + } + + private func scheduleTrailing(after delay: Duration) { + guard pending == nil else { return } // one trailing push covers all updates + pending = Task { + try? await Task.sleep(for: delay) + await self.firePending() + } + } + + private func firePending() { + pending = nil + guard !stopped else { return } + pushNow() + } + + private func pushNow() { + guard let grade = latestGrade else { return } + lastPushAt = ContinuousClock.now + let caps = driver.capabilities + let look = Flattener.flatten( + stack: grade, + latticeSize: caps.lut3dSizes.contains(latticeSize) ? latticeSize : (caps.lut3dSizes.max() ?? latticeSize), + splitLeadingCDL: caps.supportsNativeCDL) + let driver = self.driver + Task { + try? await driver.push(look: look) + } + } + + public func stop() { + stopped = true + pending?.cancel() + pending = nil + } +} diff --git a/Tests/ForgeCameraTests/DebouncedPushTests.swift b/Tests/ForgeCameraTests/DebouncedPushTests.swift new file mode 100644 index 0000000..9e76821 --- /dev/null +++ b/Tests/ForgeCameraTests/DebouncedPushTests.swift @@ -0,0 +1,112 @@ +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) + } +}