camera: DebouncedPusher — interval-limited camera pushes, guaranteed trailing edge, per-camera independence — 5 tests
This commit is contained in:
parent
bc90bfdf8e
commit
1ac4e6a754
2 changed files with 181 additions and 0 deletions
69
Sources/ForgeCamera/DebouncedPusher.swift
Normal file
69
Sources/ForgeCamera/DebouncedPusher.swift
Normal file
|
|
@ -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<Void, Never>?
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
112
Tests/ForgeCameraTests/DebouncedPushTests.swift
Normal file
112
Tests/ForgeCameraTests/DebouncedPushTests.swift
Normal file
|
|
@ -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<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue