diff --git a/Sources/OpenGestures/Component/Components/EventSource.swift b/Sources/OpenGestures/Component/Components/EventSource.swift new file mode 100644 index 0000000..4392b1e --- /dev/null +++ b/Sources/OpenGestures/Component/Components/EventSource.swift @@ -0,0 +1,121 @@ +// +// EventSource.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - EventSource + +package struct EventSource: Sendable { + package enum Failure: Error, Hashable, Sendable { + case eventFailed + } + + package struct State: GestureComponentState, NestedCustomStringConvertible, Sendable { + package var trackedId: EventID? + + package init() { + trackedId = nil + } + + package init(trackedId: EventID?) { + self.trackedId = trackedId + } + } + + package var state: State + + package init(state: State = State()) { + self.state = state + } +} + +// MARK: - EventSource + GestureComponent + +extension EventSource: GestureComponent { + package typealias Value = E + + package mutating func update( + context: GestureComponentContext + ) throws -> GestureOutput { + guard context.updateSource == .event else { + return .empty(.timeUpdate, metadata: nil) + } + guard let store = matchingEventStore(context: context) else { + return makeEmptyOutput(traceAnnotation: "no event") + } + let event: E? + if let trackedId = state.trackedId { + event = trackedEvent(in: store, matching: trackedId) + } else { + event = store.bindNextUnboundEvent() + if let event { + state.trackedId = event.id + } + } + guard let event else { + if state.trackedId == nil { + return makeEmptyOutput( + traceAnnotation: "no unbound events" + ) + } else { + return makeEmptyOutput( + traceAnnotation: "source is already bound" + ) + } + } + return try makeOutputForEvent(event) + } + + private func trackedEvent( + in store: EventStore, + matching trackedId: EventID + ) -> E? { + return store.events.first { $0.id == trackedId } + } + + private func matchingEventStore( + context: GestureComponentContext + ) -> EventStore? { + guard context.eventStore.accepts(E.self) else { + return nil + } + return unsafeDowncast(context.eventStore, to: EventStore.self) + } + + private func makeOutputForEvent(_ event: E) throws -> GestureOutput { + switch event.phase { + case .began, .active: + return .value(event, metadata: nil) + case .ended: + return .finalValue(event, metadata: nil) + case .failed: + throw Failure.eventFailed + } + } + + private func makeEmptyOutput(traceAnnotation: String) -> GestureOutput { + .empty( + .noData, + metadata: GestureOutputMetadata( + traceAnnotation: UpdateTraceAnnotation(value: traceAnnotation) + ) + ) + } + + package func traits() -> GestureTraitCollection? { + nil + } + + package func capacity(for eventType: EventType.Type) -> Int { + if state.trackedId == nil, eventType == E.self { + return 1 + } + return 0 + } +} + +// MARK: - EventSource + StatefulGestureComponent + +extension EventSource: StatefulGestureComponent {} diff --git a/Sources/OpenGestures/Component/Components/ExpirationComponent.swift b/Sources/OpenGestures/Component/Components/ExpirationComponent.swift new file mode 100644 index 0000000..99a04cf --- /dev/null +++ b/Sources/OpenGestures/Component/Components/ExpirationComponent.swift @@ -0,0 +1,226 @@ +// +// ExpirationComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +import Synchronization + +// MARK: - Expirable + +package protocol Expirable: Sendable { + associatedtype Value: Sendable + + var payload: ExpirablePayload { get } + + var expiration: Expiration? { get } +} + +// MARK: - ExpirationComponent + +package struct ExpirationComponent: Sendable where Upstream: GestureComponent, Upstream.Value: Expirable { + + package var upstream: Upstream + + package struct State: GestureComponentState, NestedCustomStringConvertible, Sendable { + package var request: UpdateRequest? + + package init() { + request = nil + } + + package init(request: UpdateRequest?) { + self.request = request + } + } + + package var state: State + + package init( + upstream: Upstream, + state: State = .init() + ) { + self.upstream = upstream + self.state = state + } + + package enum Failure: Error, Sendable { + case timeout(reason: ExpirationReason) + } +} + +// MARK: - ExpirationComponent + GestureComponent + +extension ExpirationComponent: GestureComponent { + package typealias Value = Upstream.Value.Value +} + +extension ExpirationComponent: CompositeGestureComponent {} + +extension ExpirationComponent: StatefulGestureComponent {} + +extension ExpirationComponent: ValueTransformingComponent { + package mutating func transform( + _ value: Upstream.Value, + isFinal: Bool, + context: GestureComponentContext + ) throws -> GestureOutput { + let metadata = try metadata( + for: value.expiration, + context: context + ) + switch value.payload { + case let .empty(reason): + return .empty(reason, metadata: metadata) + case let .value(payload): + return .value( + payload, + isFinal: isFinal, + metadata: metadata + ) + } + } + + private mutating func metadata( + for expiration: Expiration?, + context: GestureComponentContext + ) throws -> GestureOutputMetadata { + let updatesToSchedule: [UpdateRequest] + let updatesToCancel: [UpdateRequest] + if let expiration { + guard context.currentTime < expiration.deadline else { + throw Failure.timeout(reason: expiration.reason) + } + + if state.request?.targetTime == expiration.deadline { + updatesToSchedule = [] + updatesToCancel = [] + } else { + updatesToCancel = cancelStoredRequest() + updatesToSchedule = [scheduleRequest(for: expiration, context: context)] + } + } else { + updatesToSchedule = [] + updatesToCancel = cancelStoredRequest() + } + return GestureOutputMetadata( + updatesToSchedule: updatesToSchedule, + updatesToCancel: updatesToCancel + ) + } + + private mutating func cancelStoredRequest() -> [UpdateRequest] { + guard let request = state.request else { + return [] + } + state.request = nil + return [request] + } + + private mutating func scheduleRequest( + for expiration: Expiration, + context: GestureComponentContext + ) -> UpdateRequest { + let request = UpdateRequest( + id: ExpirationComponentRequestID.next(), + creationTime: context.currentTime, + targetTime: expiration.deadline, + tag: expiration.reason.rawValue + ) + state.request = request + return request + } +} + +// MARK: - ExpirationComponentRequestID + +// FIXE: Should it in UpdateRequest namespace? +private enum ExpirationComponentRequestID { + private static let nextID = Atomic(UInt32.zero) + + static func next() -> UInt32 { + let (_, id) = nextID.add(1, ordering: .relaxed) + return id + } +} + +// MARK: - ExpirationRecord + +package struct ExpirationRecord: Expirable, NestedCustomStringConvertible, Sendable { + package var payload: ExpirablePayload + package var expiration: Expiration? + + package init( + payload: ExpirablePayload, + expiration: Expiration? + ) { + self.payload = payload + self.expiration = expiration + } +} + +// MARK: - ExpirablePayload + +package enum ExpirablePayload: NestedCustomStringConvertible, Sendable { + case empty(GestureOutputEmptyReason) + case value(Value) + + package func populateNestedDescription(_ nested: inout NestedDescription) { + switch self { + case let .empty(reason): + nested.append(reason, label: "reason") + case let .value(value): + nested.append(value, label: "value") + } + } +} + +// MARK: - GestureOutput + ExpirationRecord + +extension GestureOutput { + package static func value( + _ value: Value, + isFinal: Bool, + expiration: Expiration? + ) -> GestureOutput> { + .value( + ExpirationRecord( + payload: .value(value), + expiration: expiration + ), + isFinal: isFinal + ) + } + + package func expired( + with expiration: Expiration? + ) -> GestureOutput> { + switch self { + case let .empty(reason, metadata): + return .value( + ExpirationRecord( + payload: .empty(reason), + expiration: expiration + ), + metadata: metadata + ) + case let .value(value, metadata): + return .value( + ExpirationRecord( + payload: .value(value), + expiration: expiration + ), + metadata: metadata + ) + case let .finalValue(value, metadata): + return .finalValue( + ExpirationRecord( + payload: .value(value), + expiration: expiration + ), + metadata: metadata + ) + } + } +} diff --git a/Sources/OpenGestures/Component/LongPressComponent.swift b/Sources/OpenGestures/Component/Components/LongPressComponent.swift similarity index 100% rename from Sources/OpenGestures/Component/LongPressComponent.swift rename to Sources/OpenGestures/Component/Components/LongPressComponent.swift diff --git a/Sources/OpenGestures/Component/Components/MapComponent.swift b/Sources/OpenGestures/Component/Components/MapComponent.swift new file mode 100644 index 0000000..b14935c --- /dev/null +++ b/Sources/OpenGestures/Component/Components/MapComponent.swift @@ -0,0 +1,36 @@ +// +// MapComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - MapComponent + +package struct MapComponent: Sendable +where Upstream: GestureComponent, Output: Sendable { + package var upstream: Upstream + package let map: @Sendable (GestureOutput) throws -> GestureOutput + + package init( + upstream: Upstream, + map: @escaping @Sendable (GestureOutput) throws -> GestureOutput + ) { + self.upstream = upstream + self.map = map + } +} + +// MARK: - MapComponent + GestureComponent + +extension MapComponent: GestureComponent { + package typealias Value = Output + + package mutating func update(context: GestureComponentContext) throws -> GestureOutput { + try map(upstream.tracingUpdate(context: context)) + } +} + +// MARK: - MapComponent + CompositeGestureComponent + +extension MapComponent: CompositeGestureComponent {} diff --git a/Sources/OpenGestures/Component/PanComponent.swift b/Sources/OpenGestures/Component/Components/PanComponent.swift similarity index 100% rename from Sources/OpenGestures/Component/PanComponent.swift rename to Sources/OpenGestures/Component/Components/PanComponent.swift diff --git a/Sources/OpenGestures/Component/Components/ReduceComponent.swift b/Sources/OpenGestures/Component/Components/ReduceComponent.swift new file mode 100644 index 0000000..0bc5151 --- /dev/null +++ b/Sources/OpenGestures/Component/Components/ReduceComponent.swift @@ -0,0 +1,58 @@ +// +// ReduceComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - ReduceComponent + +package struct ReduceComponent: Sendable +where Upstream: GestureComponent, Output: Sendable { + package struct State: GestureComponentState, NestedCustomStringConvertible { + package var accumulator: Output? + + package init() { + accumulator = nil + } + } + + package var upstream: Upstream + package var state: State + package let initial: Output + package let reduce: @Sendable (Output, Upstream.Value) throws -> Output + + package init( + upstream: Upstream, + state: State = State(), + initial: Output, + reduce: @escaping @Sendable (Output, Upstream.Value) throws -> Output + ) { + self.upstream = upstream + self.state = state + self.initial = initial + self.reduce = reduce + } +} + +// MARK: - ReduceComponent + Component Protocols + +extension ReduceComponent: GestureComponent { + package typealias Value = Output +} + +extension ReduceComponent: CompositeGestureComponent {} + +extension ReduceComponent: StatefulGestureComponent {} + +extension ReduceComponent: ValueTransformingComponent { + package mutating func transform( + _ value: Upstream.Value, + isFinal: Bool, + context: GestureComponentContext + ) throws -> GestureOutput { + let previous = state.accumulator ?? initial + state.accumulator = try reduce(previous, value) + return .value(state.accumulator!, isFinal: isFinal) + } +} diff --git a/Sources/OpenGestures/Component/Components/RepeatComponent.swift b/Sources/OpenGestures/Component/Components/RepeatComponent.swift new file mode 100644 index 0000000..5b23343 --- /dev/null +++ b/Sources/OpenGestures/Component/Components/RepeatComponent.swift @@ -0,0 +1,124 @@ +// +// RepeatComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - RepeatComponent + +package struct RepeatComponent: Sendable where Upstream: GestureComponent { + package struct State: GestureComponentState, NestedCustomStringConvertible, Sendable { + package var currentCount: Int + package var repeatDeadline: Timestamp? + package var repeatStartTime: Timestamp? + + package init() { + currentCount = 0 + repeatDeadline = nil + repeatStartTime = nil + } + + package init( + currentCount: Int, + repeatDeadline: Timestamp?, + repeatStartTime: Timestamp? + ) { + self.currentCount = currentCount + self.repeatDeadline = repeatDeadline + self.repeatStartTime = repeatStartTime + } + + package var repeatExpiration: Expiration? { + guard let repeatDeadline else { + return nil + } + return Expiration( + deadline: repeatDeadline, + reason: "Repeat deadline expired" + ) + } + } + + package var upstream: Upstream + package var state: State + package let count: Int + package let delay: Duration + + package init( + upstream: Upstream, + state: State = State(), + count: Int, + delay: Duration + ) { + self.upstream = upstream + self.state = state + self.count = count + self.delay = delay + } +} + +// MARK: - RepeatComponent + GestureComponent + +extension RepeatComponent: GestureComponent { + package typealias Value = ExpirationRecord + + package mutating func update(context: GestureComponentContext) throws -> GestureOutput { + if state.currentCount > 0, + state.repeatStartTime == nil, + case .event = context.updateSource { + state.repeatStartTime = context.currentTime + } + var newContext = context + if let repeatStartTime = state.repeatStartTime { + newContext.startTime = repeatStartTime + } + let output = try upstream.tracingUpdate(context: newContext) + guard let value = output.value else { + return .empty(output.emptyReason!, metadata: output.metadata) + } + let newOutput = Self.makeExpirationOutputForNonEmptyOutput( + output, + repeatComponent: &self, + value: value, + context: context + ) + return newOutput.copyWithCombinedMetadata(output.metadata ?? GestureOutputMetadata()) + } + + private static func makeExpirationOutputForNonEmptyOutput( + _ output: GestureOutput, + repeatComponent: inout Self, + value: Upstream.Value, + context: GestureComponentContext + ) -> GestureOutput { + guard output.isFinal else { + return GestureOutput.value( + value, + isFinal: false, + expiration: repeatComponent.state.repeatExpiration + ) + } + repeatComponent.state.currentCount += 1 + guard repeatComponent.state.currentCount < repeatComponent.count else { + return GestureOutput + .finalValue(value, metadata: nil) + .expired(with: nil) + } + repeatComponent.upstream.reset() + repeatComponent.state.repeatStartTime = nil + let repeatDeadline = context.currentTime + repeatComponent.delay + repeatComponent.state.repeatDeadline = repeatDeadline + return GestureOutput.value( + value, + isFinal: false, + expiration: repeatComponent.state.repeatExpiration + ) + } +} + +// MARK: - RepeatComponent + Component Protocols + +extension RepeatComponent: CompositeGestureComponent {} + +extension RepeatComponent: StatefulGestureComponent {} diff --git a/Sources/OpenGestures/Component/TapComponent.swift b/Sources/OpenGestures/Component/Components/TapComponent.swift similarity index 100% rename from Sources/OpenGestures/Component/TapComponent.swift rename to Sources/OpenGestures/Component/Components/TapComponent.swift diff --git a/Sources/OpenGestures/Component/Components/ThresholdComponent.swift b/Sources/OpenGestures/Component/Components/ThresholdComponent.swift new file mode 100644 index 0000000..61d0712 --- /dev/null +++ b/Sources/OpenGestures/Component/Components/ThresholdComponent.swift @@ -0,0 +1,98 @@ +// +// ThresholdComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - ThresholdComponent + +package struct ThresholdComponent: Sendable +where Upstream: GestureComponent, + Upstream.Value: ThresholdAdjustable, + Upstream.Value.VectorType: Sendable +{ + package struct State: GestureComponentState, NestedCustomStringConvertible { + package var initialValue: Upstream.Value? + + package var adjustmentDelta: Upstream.Value.VectorType? + + package init() { + initialValue = nil + adjustmentDelta = nil + } + + package init( + initialValue: Upstream.Value?, + adjustmentDelta: Upstream.Value.VectorType? + ) { + self.initialValue = initialValue + self.adjustmentDelta = adjustmentDelta + } + } + + package enum Failure: Error, Hashable, Sendable { + case notEnoughMovement + } + + package var upstream: Upstream + + package var state: State + + package let threshold: @Sendable (Upstream.Value, Upstream.Value) -> Upstream.Value.Threshold + + package init( + upstream: Upstream, + state: State = State(), + threshold: @escaping @Sendable (Upstream.Value, Upstream.Value) -> Upstream.Value.Threshold + ) { + self.upstream = upstream + self.state = state + self.threshold = threshold + } +} + +// MARK: - ThresholdComponent + Component Protocols + +extension ThresholdComponent: GestureComponent { + package typealias Value = Upstream.Value +} + +extension ThresholdComponent: CompositeGestureComponent {} + +extension ThresholdComponent: StatefulGestureComponent {} + +extension ThresholdComponent: ValueTransformingComponent { + package mutating func transform( + _ value: Upstream.Value, + isFinal: Bool, + context: GestureComponentContext + ) throws -> GestureOutput { + if state.initialValue == nil { + state.initialValue = value + } + let initialValue = state.initialValue! + guard let adjustmentDelta = state.adjustmentDelta else { + guard !isFinal else { + throw Failure.notEnoughMovement + } + var adjustedValue = value + guard let adjustmentDelta = adjustedValue.consume( + threshold(value, initialValue), + from: value.vector - initialValue.vector + ) else { + return .empty( + .filtered, + metadata: GestureOutputMetadata( + traceAnnotation: UpdateTraceAnnotation(value: "not enough movement") + ) + ) + } + state.adjustmentDelta = adjustmentDelta + return .value(adjustedValue, isFinal: false) + } + var adjustedValue = value + adjustedValue.vector -= adjustmentDelta + return .value(adjustedValue, isFinal: isFinal) + } +} diff --git a/Sources/OpenGestures/Component/Components/TimeoutComponent.swift b/Sources/OpenGestures/Component/Components/TimeoutComponent.swift new file mode 100644 index 0000000..44845a1 --- /dev/null +++ b/Sources/OpenGestures/Component/Components/TimeoutComponent.swift @@ -0,0 +1,81 @@ +// +// TimeoutComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - TimeoutComponent + +package struct TimeoutComponent: Sendable where Upstream: GestureComponent { + package struct State: GestureComponentState, NestedCustomStringConvertible, Sendable { + package var fulfilled: Bool + + package init() { + fulfilled = false + } + + package init(fulfilled: Bool) { + self.fulfilled = fulfilled + } + } + + package var upstream: Upstream + package var state: State + package let timeout: Duration + package let tag: String + package let predicate: @Sendable (GestureOutput) -> Bool + + package init( + upstream: Upstream, + state: State = State(), + timeout: Duration, + tag: String, + predicate: @escaping @Sendable (GestureOutput) -> Bool + ) { + self.upstream = upstream + self.state = state + self.timeout = timeout + self.tag = tag + self.predicate = predicate + } +} + +// MARK: - TimeoutComponent + GestureComponent + +extension TimeoutComponent: GestureComponent { + package typealias Value = ExpirationRecord + + package mutating func update(context: GestureComponentContext) throws -> GestureOutput { + let output = try upstream.tracingUpdate(context: context) + guard output.emptyReason != .noData else { + return .empty(.noData, metadata: output.metadata) + } + return output.expired(with: expiration(for: output, context: context)) + } + + private mutating func expiration( + for output: GestureOutput, + context: GestureComponentContext + ) -> Expiration? { + guard timeout != .max, !state.fulfilled else { + return nil + } + + let deadline = context.startTime + timeout + if context.currentTime < deadline, predicate(output) { + state.fulfilled = true + return nil + } + return Expiration( + deadline: deadline, + reason: ExpirationReason(rawValue: tag) + ) + } +} + +// MARK: - TimeoutComponent + Component Protocols + +extension TimeoutComponent: CompositeGestureComponent {} + +extension TimeoutComponent: StatefulGestureComponent {} diff --git a/Sources/OpenGestures/Component/Components/VelocityComponent.swift b/Sources/OpenGestures/Component/Components/VelocityComponent.swift new file mode 100644 index 0000000..57030b3 --- /dev/null +++ b/Sources/OpenGestures/Component/Components/VelocityComponent.swift @@ -0,0 +1,128 @@ +// +// VelocityComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - VelocityComponent + +package struct VelocityComponent: Sendable +where Upstream: GestureComponent, + Upstream.Value: VectorContaining, + Upstream.Value.VectorType: Interpolatable +{ + package struct State: GestureComponentState, NestedCustomStringConvertible { + package var previousValue: Upstream.Value.VectorType? + package var previousVelocity: Upstream.Value.VectorType? + package var previousTime: Timestamp? + + package init() { + previousValue = nil + previousVelocity = nil + previousTime = nil + } + + package init( + previousValue: Upstream.Value.VectorType?, + previousVelocity: Upstream.Value.VectorType?, + previousTime: Timestamp? + ) { + self.previousValue = previousValue + self.previousVelocity = previousVelocity + self.previousTime = previousTime + } + } + + package var upstream: Upstream + package var state: State + package let interpolationWeight: Double + + package init( + upstream: Upstream, + state: State = State(), + interpolationWeight: Double + ) { + self.upstream = upstream + self.state = state + self.interpolationWeight = interpolationWeight + } +} + +// MARK: - VelocityComponent + Component Protocols + +extension VelocityComponent: GestureComponent { + package typealias Value = (value: Upstream.Value, velocity: Upstream.Value.VectorType) +} + +extension VelocityComponent: CompositeGestureComponent {} + +extension VelocityComponent: StatefulGestureComponent {} + +extension VelocityComponent: ValueTransformingComponent { + package mutating func transform( + _ value: Upstream.Value, + isFinal: Bool, + context: GestureComponentContext + ) throws -> GestureOutput { + let currentVector = value.vector + var velocity = makeRawVelocity( + currentVector: currentVector, + currentTime: context.currentTime + ) + if let previousVelocity = state.previousVelocity { + velocity.mix(with: previousVelocity, by: interpolationWeight) + } + + state.previousValue = currentVector + state.previousVelocity = velocity + state.previousTime = context.currentTime + + let result = (value: value, velocity: velocity) + return .value(result, isFinal: isFinal) + } + + private func makeRawVelocity( + currentVector: Upstream.Value.VectorType, + currentTime: Timestamp + ) -> Upstream.Value.VectorType { + guard let previousValue = state.previousValue, + let previousTime = state.previousTime else { + return .zero + } + + let elapsed = previousTime.duration(to: currentTime) + guard elapsed >= .milliseconds(1) else { + return .zero + } + + let movement = currentVector - previousValue + return movement.scaled(byInverseOf: elapsed.asTimeInterval()) + } + + package mutating func transform2( + _ value: Upstream.Value, + isFinal: Bool, + context: GestureComponentContext + ) throws -> GestureOutput { + let currentTime = context.currentTime + var velocity = Upstream.Value.VectorType.zero + if let previousValue = state.previousValue, + let previousTime = state.previousTime { + let elapsed = previousTime.duration(to: currentTime) + if elapsed >= .milliseconds(1) { + let movement = value.vector - previousValue + velocity = movement.scaled(byInverseOf: elapsed.asTimeInterval()) + } + } + if let previousVelocity = state.previousVelocity { + velocity.mix(with: previousVelocity, by: interpolationWeight) + } + state.previousValue = value.vector + state.previousVelocity = velocity + state.previousTime = currentTime + + let result = (value: value, velocity: velocity) + return .value(result, isFinal: isFinal) + } +} diff --git a/Sources/OpenGestures/Component/Expiration.swift b/Sources/OpenGestures/Component/Expiration.swift new file mode 100644 index 0000000..6b27bac --- /dev/null +++ b/Sources/OpenGestures/Component/Expiration.swift @@ -0,0 +1,39 @@ +// +// Expiration.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - Expiration + +package struct Expiration: Sendable { + package var deadline: Timestamp + package var reason: ExpirationReason + + package init( + deadline: Timestamp, + reason: ExpirationReason + ) { + self.deadline = deadline + self.reason = reason + } +} + +// MARK: - ExpirationReason + +package struct ExpirationReason: ExpressibleByStringLiteral, CustomStringConvertible, Sendable { + package let rawValue: String + + package init(rawValue: String) { + self.rawValue = rawValue + } + + package init(stringLiteral value: String) { + self.rawValue = value + } + + package var description: String { + rawValue + } +} diff --git a/Sources/OpenGestures/Component/DiscreteGate.swift b/Sources/OpenGestures/Component/Gate/DiscreteGate.swift similarity index 94% rename from Sources/OpenGestures/Component/DiscreteGate.swift rename to Sources/OpenGestures/Component/Gate/DiscreteGate.swift index a2706c2..fb75847 100644 --- a/Sources/OpenGestures/Component/DiscreteGate.swift +++ b/Sources/OpenGestures/Component/Gate/DiscreteGate.swift @@ -34,7 +34,8 @@ extension DiscreteGate: DiscreteComponent {} extension DiscreteGate: ValueTransformingComponent { package mutating func transform( _ value: Value, - isFinal: Bool + isFinal: Bool, + context: GestureComponentContext ) throws -> GestureOutput { if isFinal { return .finalValue(value, metadata: nil) diff --git a/Sources/OpenGestures/Component/Gate/DurationGate.swift b/Sources/OpenGestures/Component/Gate/DurationGate.swift new file mode 100644 index 0000000..542af7f --- /dev/null +++ b/Sources/OpenGestures/Component/Gate/DurationGate.swift @@ -0,0 +1,92 @@ +// +// DurationGate.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - DurationGate + +package struct DurationGate: Sendable where Upstream: GestureComponent { + package enum Failure: Error, Hashable, Sendable { + case minimumDurationNotReached + } + + package var upstream: Upstream + package let minimumDuration: Duration + package let maximumDuration: Duration + + package init( + upstream: Upstream, + minimumDuration: Duration, + maximumDuration: Duration + ) { + self.upstream = upstream + self.minimumDuration = minimumDuration + self.maximumDuration = maximumDuration + } +} + +// MARK: - DurationGate + GestureComponent + +extension DurationGate: GestureComponent { + package typealias Value = ExpirationRecord +} + +// MARK: - DurationGate + CompositeGestureComponent + +extension DurationGate: CompositeGestureComponent {} + +// MARK: - DurationGate + ValueTransformingComponent + +extension DurationGate: ValueTransformingComponent { + package mutating func transform( + _ value: Upstream.Value, + isFinal: Bool, + context: GestureComponentContext + ) throws -> GestureOutput { + if context.durationSinceStart < minimumDuration { + guard !isFinal else { + throw Failure.minimumDurationNotReached + } + let output = GestureOutput.empty( + .filtered, + metadata: GestureOutputMetadata( + traceAnnotation: UpdateTraceAnnotation(value: "min duration not reached") + ) + ) + return Self.makeExpirationOutput( + output, + from: context.startTime, + after: minimumDuration, + reason: "min duration expired" + ) + } else { + let output = GestureOutput.value( + value, + isFinal: isFinal + ) + return Self.makeExpirationOutput( + output, + from: context.startTime, + after: maximumDuration, + reason: "max duration expired" + ) + } + } + + private static func makeExpirationOutput( + _ output: GestureOutput, + from startTime: Timestamp, + after duration: Duration, + reason: ExpirationReason + ) -> GestureOutput> { + let expiration: Expiration? + if .zero < duration, duration < .max { + expiration = Expiration(deadline: startTime + duration, reason: reason) + } else { + expiration = nil + } + return output.expired(with: expiration) + } +} diff --git a/Sources/OpenGestures/Component/MovementGate.swift b/Sources/OpenGestures/Component/Gate/MovementGate.swift similarity index 91% rename from Sources/OpenGestures/Component/MovementGate.swift rename to Sources/OpenGestures/Component/Gate/MovementGate.swift index d215056..cc927b4 100644 --- a/Sources/OpenGestures/Component/MovementGate.swift +++ b/Sources/OpenGestures/Component/Gate/MovementGate.swift @@ -51,7 +51,8 @@ extension MovementGate: CompositeGestureComponent {} extension MovementGate: ValueTransformingComponent { package mutating func transform( _ value: Upstream.Value, - isFinal: Bool + isFinal: Bool, + context: GestureComponentContext ) throws -> GestureOutput { let movement = value.locationTranslation.magnitude switch restriction { @@ -69,10 +70,6 @@ extension MovementGate: ValueTransformingComponent { throw Failure.tooMuchMovement } } - if isFinal { - return .finalValue(value, metadata: nil) - } else { - return .value(value, metadata: nil) - } + return .value(value, isFinal: isFinal) } } diff --git a/Sources/OpenGestures/Component/Gate/SeparationDistanceGate.swift b/Sources/OpenGestures/Component/Gate/SeparationDistanceGate.swift new file mode 100644 index 0000000..b01272b --- /dev/null +++ b/Sources/OpenGestures/Component/Gate/SeparationDistanceGate.swift @@ -0,0 +1,102 @@ +// +// SeparationDistanceGate.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +import OpenCoreGraphicsShims + +// MARK: - SeparationDistanceGate + +package struct SeparationDistanceGate: Sendable +where Upstream: GestureComponent, + Upstream.Value: Collection, + Upstream.Value.Element: LocationContaining +{ + package enum Failure: Error, Hashable, Sendable { + case exceedsAllowedDistance + } + + package var upstream: Upstream + package let distance: Double + + package init( + upstream: Upstream, + distance: Double + ) { + self.upstream = upstream + self.distance = distance + } +} + +// MARK: - SeparationDistanceGate + GestureComponent + +extension SeparationDistanceGate: GestureComponent { + package typealias Value = Upstream.Value +} + +// MARK: - SeparationDistanceGate + CompositeGestureComponent + +extension SeparationDistanceGate: CompositeGestureComponent {} + +// MARK: - SeparationDistanceGate + ValueTransformingComponent + +extension SeparationDistanceGate: ValueTransformingComponent { + package mutating func transform( + _ value: Upstream.Value, + isFinal: Bool, + context: GestureComponentContext + ) throws -> GestureOutput { + if distance < .greatestFiniteMagnitude, + let separationDistance = value.separationDistance, + distance < separationDistance { + throw Failure.exceedsAllowedDistance + } + return .value(value, isFinal: isFinal) + } +} + +// MARK: - Collection + Separation Distance + +private extension Collection where Element: LocationContaining { + var separationDistance: Double? { + guard count >= 2 else { + return nil + } + + let rect = map { $0.location }.boundingRect + let dx = Double(rect.minX - rect.maxX) + let dy = Double(rect.minY - rect.maxY) + return (dx * dx + dy * dy).squareRoot() + } +} + +// MARK: - CGPoint + Bounding Rect + +extension Collection where Element == CGPoint { + fileprivate var boundingRect: CGRect { + guard !isEmpty else { + return .null + } + let first = self[startIndex] + var minX = Double(first.x) + var minY = Double(first.y) + var maxX = minX + var maxY = minY + for point in self { + let x = Double(point.x) + let y = Double(point.y) + minX = Swift.min(minX, x) + minY = Swift.min(minY, y) + maxX = Swift.max(maxX, x) + maxY = Swift.max(maxY, y) + } + return CGRect( + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY + ) + } +} diff --git a/Sources/OpenGestures/Component/TrackedValue.swift b/Sources/OpenGestures/Component/Track/TrackedValue.swift similarity index 100% rename from Sources/OpenGestures/Component/TrackedValue.swift rename to Sources/OpenGestures/Component/Track/TrackedValue.swift diff --git a/Sources/OpenGestures/Component/UpdateTracer.swift b/Sources/OpenGestures/Component/Track/UpdateTracer.swift similarity index 100% rename from Sources/OpenGestures/Component/UpdateTracer.swift rename to Sources/OpenGestures/Component/Track/UpdateTracer.swift diff --git a/Sources/OpenGestures/Component/ValueTracker.swift b/Sources/OpenGestures/Component/Track/ValueTracker.swift similarity index 89% rename from Sources/OpenGestures/Component/ValueTracker.swift rename to Sources/OpenGestures/Component/Track/ValueTracker.swift index 2ac9cfa..6c87cdf 100644 --- a/Sources/OpenGestures/Component/ValueTracker.swift +++ b/Sources/OpenGestures/Component/Track/ValueTracker.swift @@ -49,7 +49,8 @@ extension ValueTracker: StatefulGestureComponent {} extension ValueTracker: ValueTransformingComponent { package mutating func transform( _ value: Upstream.Value, - isFinal: Bool + isFinal: Bool, + context: GestureComponentContext ) throws -> GestureOutput { let current = valueReader(value) if state.initialValue == nil { @@ -62,10 +63,6 @@ extension ValueTracker: ValueTransformingComponent { initial: state.initialValue! ) state.previousValue = current - if isFinal { - return .finalValue(trackedValue, metadata: nil) - } else { - return .value(trackedValue, metadata: nil) - } + return .value(trackedValue, isFinal: isFinal) } } diff --git a/Sources/OpenGestures/Component/ValueTransformingComponent.swift b/Sources/OpenGestures/Component/ValueTransformingComponent.swift index b01f5a6..f9678b3 100644 --- a/Sources/OpenGestures/Component/ValueTransformingComponent.swift +++ b/Sources/OpenGestures/Component/ValueTransformingComponent.swift @@ -10,7 +10,8 @@ package protocol ValueTransformingComponent: CompositeGestureComponent { mutating func transform( _ value: Upstream.Value, - isFinal: Bool + isFinal: Bool, + context: GestureComponentContext ) throws -> GestureOutput } @@ -22,10 +23,12 @@ extension ValueTransformingComponent { switch output { case let .empty(reason, metadata): return .empty(reason, metadata: metadata) - case let .value(value, _): - return try transform(value, isFinal: false) - case let .finalValue(value, _): - return try transform(value, isFinal: true) + case let .value(value, metadata): + let output = try transform(value, isFinal: false, context: context) + return output.copyWithCombinedMetadata(metadata) + case let .finalValue(value, metadata): + let output = try transform(value, isFinal: true, context: context) + return output.copyWithCombinedMetadata(metadata) } } } @@ -33,12 +36,9 @@ extension ValueTransformingComponent { extension ValueTransformingComponent where Value == Upstream.Value { package mutating func transform( _ value: Upstream.Value, - isFinal: Bool + isFinal: Bool, + context: GestureComponentContext ) throws -> GestureOutput { - if isFinal { - return .finalValue(value, metadata: nil) - } else { - return .value(value, metadata: nil) - } + return .value(value, isFinal: isFinal) } } diff --git a/Sources/OpenGestures/Core/GestureOutput.swift b/Sources/OpenGestures/Core/GestureOutput.swift index 16c8d06..f3d2944 100644 --- a/Sources/OpenGestures/Core/GestureOutput.swift +++ b/Sources/OpenGestures/Core/GestureOutput.swift @@ -29,6 +29,13 @@ extension GestureOutput { } } + package var emptyReason: GestureOutputEmptyReason? { + if case let .empty(reason, _) = self { + return reason + } + return nil + } + public var isFinal: Bool { switch self { case .finalValue: true @@ -46,23 +53,42 @@ extension GestureOutput { metadata } } + + package func copyWithCombinedMetadata(_ other: GestureOutputMetadata?) -> Self { + switch self { + case let .empty(reason, metadata): + return .empty(reason, metadata: .combineUpdateRequests(metadata, other)) + case let .value(value, metadata): + return .value(value, metadata: .combineUpdateRequests(metadata, other)) + case let .finalValue(value, metadata): + return .finalValue(value, metadata: .combineUpdateRequests(metadata, other)) + } + } + + package static func value( + _ value: Value, + isFinal: Bool, + metadata: GestureOutputMetadata? = nil + ) -> Self { + if isFinal { + return .finalValue(value, metadata: metadata) + } else { + return .value(value, metadata: metadata) + } + } } // MARK: - GestureOutput + NestedCustomStringConvertible extension GestureOutput: NestedCustomStringConvertible { package func populateNestedDescription(_ nested: inout NestedDescription) { - let metadata: GestureOutputMetadata? switch self { - case let .empty(reason, m): - nested.append(reason, label: "emptyReason") - metadata = m - case let .value(v, m): - nested.append(v, label: "value") - metadata = m - case let .finalValue(v, m): - nested.append(v, label: "finalValue") - metadata = m + case .empty: + nested.append(emptyReason, label: "emptyReason") + case .value: + nested.append(value, label: "value") + case .finalValue: + nested.append(value, label: "finalValue") } if let metadata { nested.append(metadata, label: "metadata") @@ -94,6 +120,31 @@ public struct GestureOutputMetadata: Sendable { self.updatesToCancel = updatesToCancel self.traceAnnotation = traceAnnotation } + + package static func combineUpdateRequests( + _ first: GestureOutputMetadata?, + _ second: GestureOutputMetadata? + ) -> GestureOutputMetadata? { + switch (first, second) { + case (nil, nil): + return nil + case let (metadata?, nil): + return GestureOutputMetadata( + updatesToSchedule: metadata.updatesToSchedule, + updatesToCancel: metadata.updatesToCancel + ) + case let (nil, metadata?): + return GestureOutputMetadata( + updatesToSchedule: metadata.updatesToSchedule, + updatesToCancel: metadata.updatesToCancel + ) + case let (first?, second?): + return GestureOutputMetadata( + updatesToSchedule: first.updatesToSchedule + second.updatesToSchedule, + updatesToCancel: first.updatesToCancel + second.updatesToCancel + ) + } + } } // MARK: - GestureOutputMetadata + NestedCustomStringConvertible diff --git a/Sources/OpenGestures/Util/Interpolatable.swift b/Sources/OpenGestures/Util/Interpolatable.swift new file mode 100644 index 0000000..994247d --- /dev/null +++ b/Sources/OpenGestures/Util/Interpolatable.swift @@ -0,0 +1,45 @@ +// +// Interpolatable.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +import OpenCoreGraphicsShims + +// MARK: - Interpolatable + +package protocol Interpolatable: Sendable { + func scaled(by rhs: Double) -> Self + + static func + (lhs: Self, rhs: Self) -> Self +} + +// MARK: - Interpolatable Helpers + +extension Interpolatable { + package static func mix( + _ lhs: Self, + _ rhs: Self, + by t: Double + ) -> Self { + rhs.scaled(by: 1 - t) + lhs.scaled(by: t) + } + + package mutating func mix( + with other: Self, + by t: Double + ) { + self = Self.mix(other, self, by: t) + } + + package func scaled(byInverseOf rhs: Double) -> Self { + scaled(by: 1 / rhs) + } +} + +// MARK: - Interpolatable Conformance + +extension CGPoint: Interpolatable {} + +extension CGVector: Interpolatable {} diff --git a/Tests/OpenGesturesTests/Component/Components/EventSourceTests.swift b/Tests/OpenGesturesTests/Component/Components/EventSourceTests.swift new file mode 100644 index 0000000..406c57b --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Components/EventSourceTests.swift @@ -0,0 +1,196 @@ +// +// EventSourceTests.swift +// OpenGesturesTests +// +// Generated + +import OpenCoreGraphicsShims +import OpenGestures +import Testing + +// MARK: - EventSourceTests + +@Suite +struct EventSourceTests { + @Test + func bindsNextUnboundBeganEvent() throws { + let store = EventStore() + store.append([ + touch(id: 1, phase: .began, time: .zero), + ]) + var source = EventSource() + + let output = try source.update(context: eventSourceContext(store: store)) + + guard case let .value(event, metadata) = output else { + Issue.record("Expected value output") + return + } + #expect(event.id == EventID(rawValue: 1)) + #expect(metadata == nil) + #expect(source.state.trackedId == EventID(rawValue: 1)) + #expect(store.boundEventIds == [EventID(rawValue: 1)]) + } + + @Test + func endedTrackedEventProducesFinalValueAndPreservesState() throws { + let store = EventStore( + events: [ + touch(id: 1, phase: .ended, time: .milliseconds(100)), + ], + boundEventIds: [EventID(rawValue: 1)] + ) + var source = EventSource( + state: EventSource.State(trackedId: EventID(rawValue: 1)) + ) + + let output = try source.update(context: eventSourceContext(store: store)) + + guard case let .finalValue(event, metadata) = output else { + Issue.record("Expected final value output") + return + } + #expect(event.phase == .ended) + #expect(metadata == nil) + #expect(source.state.trackedId == EventID(rawValue: 1)) + } + + @Test + func failedTrackedEventThrowsAndPreservesState() throws { + let store = EventStore( + events: [ + touch(id: 1, phase: .failed, time: .milliseconds(100)), + ], + boundEventIds: [EventID(rawValue: 1)] + ) + var source = EventSource( + state: EventSource.State(trackedId: EventID(rawValue: 1)) + ) + + do { + _ = try source.update(context: eventSourceContext(store: store)) + Issue.record("Expected eventFailed") + } catch EventSource.Failure.eventFailed { + #expect(source.state.trackedId == EventID(rawValue: 1)) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func schedulerUpdateProducesTimeUpdateWithoutBinding() throws { + let store = EventStore() + store.append([ + touch(id: 1, phase: .began, time: .zero), + ]) + var source = EventSource() + + let output = try source.update( + context: eventSourceContext( + store: store, + updateSource: .scheduler([1]) + ) + ) + + guard case let .empty(reason, metadata) = output else { + Issue.record("Expected empty output") + return + } + #expect(reason == .timeUpdate) + #expect(metadata == nil) + #expect(source.state.trackedId == nil) + #expect(store.boundEventIds == []) + } + + @Test + func mismatchedEventStoreProducesNoEventTrace() throws { + var source = EventSource() + + let output = try source.update( + context: eventSourceContext(store: EventStore()) + ) + + expectEmpty(output, reason: .noData, traceAnnotation: "no event") + #expect(source.state.trackedId == nil) + } + + @Test + func noUnboundEventsProducesNoUnboundEventsTrace() throws { + let store = EventStore() + var source = EventSource() + + let output = try source.update(context: eventSourceContext(store: store)) + + expectEmpty(output, reason: .noData, traceAnnotation: "no unbound events") + #expect(source.state.trackedId == nil) + } + + @Test + func missingTrackedEventProducesAlreadyBoundTraceWithoutBindingAnotherEvent() throws { + let store = EventStore() + store.append([ + touch(id: 2, phase: .began, time: .zero), + ]) + var source = EventSource( + state: EventSource.State(trackedId: EventID(rawValue: 1)) + ) + + let output = try source.update(context: eventSourceContext(store: store)) + + expectEmpty(output, reason: .noData, traceAnnotation: "source is already bound") + #expect(source.state.trackedId == EventID(rawValue: 1)) + #expect(store.boundEventIds == []) + } + + @Test + func capacityIsOneForMatchingEventTypeOnlyWhenUnbound() { + let unboundSource = EventSource() + let boundSource = EventSource( + state: EventSource.State(trackedId: EventID(rawValue: 1)) + ) + + #expect(unboundSource.capacity(for: TouchEvent.self) == 1) + #expect(unboundSource.capacity(for: MouseEvent.self) == 0) + #expect(boundSource.capacity(for: TouchEvent.self) == 0) + } +} + +private func eventSourceContext( + store: AnyEventStore, + updateSource: GestureUpdateSource = .event +) -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: .zero), + currentTime: Timestamp(value: .zero), + updateSource: updateSource, + eventStore: store + ) +} + +private func expectEmpty( + _ output: GestureOutput, + reason expectedReason: GestureOutputEmptyReason, + traceAnnotation expectedTraceAnnotation: String +) { + guard case let .empty(reason, metadata) = output else { + Issue.record("Expected empty output") + return + } + #expect(reason == expectedReason) + #expect(metadata?.updatesToSchedule.isEmpty == true) + #expect(metadata?.updatesToCancel.isEmpty == true) + #expect(metadata?.traceAnnotation?.value == expectedTraceAnnotation) +} + +private func touch( + id rawValue: Int, + phase: EventPhase, + time: Duration +) -> TouchEvent { + TouchEvent( + id: EventID(rawValue: rawValue), + phase: phase, + timestamp: Timestamp(value: time), + location: CGPoint(x: rawValue, y: rawValue) + ) +} diff --git a/Tests/OpenGesturesTests/Component/Components/ExpirationComponentTests.swift b/Tests/OpenGesturesTests/Component/Components/ExpirationComponentTests.swift new file mode 100644 index 0000000..81f8382 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Components/ExpirationComponentTests.swift @@ -0,0 +1,332 @@ +// +// ExpirationComponentTests.swift +// OpenGesturesTests +// +// Generated + +import OpenGestures +import Testing + +// MARK: - ExpirationComponentTests + +@Suite +struct ExpirationComponentTests { + @Test + func schedulesExpirationAndUnwrapsValuePayload() throws { + var component = ExpirationComponent( + upstream: ExpirationRecordStubComponent(outputs: [ + .value( + expirationRecord( + value: 7, + deadline: .seconds(5), + reason: "timeout" + ), + metadata: nil + ), + ]) + ) + + let output = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(2)) + ) + + guard case let .value(value, metadata) = output else { + Issue.record("Expected value output") + return + } + #expect(value == 7) + + guard let metadata, let request = metadata.updatesToSchedule.first else { + Issue.record("Expected scheduled request metadata") + return + } + #expect(metadata.updatesToSchedule.count == 1) + #expect(metadata.updatesToCancel.isEmpty) + #expect(request.creationTime == Timestamp(value: .seconds(2))) + #expect(request.targetTime == Timestamp(value: .seconds(5))) + #expect(request.tag == "timeout") + #expect(component.state.request == request) + } + + @Test + func reschedulesWhenExpirationDeadlineChanges() throws { + var component = ExpirationComponent( + upstream: ExpirationRecordStubComponent(outputs: [ + .value( + expirationRecord( + value: 7, + deadline: .seconds(5), + reason: "first" + ), + metadata: nil + ), + .value( + expirationRecord( + value: 8, + deadline: .seconds(7), + reason: "second" + ), + metadata: nil + ), + ]) + ) + + _ = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(2)) + ) + guard let firstRequest = component.state.request else { + Issue.record("Expected first request") + return + } + + let output = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(3)) + ) + + guard case let .value(value, metadata) = output else { + Issue.record("Expected value output") + return + } + #expect(value == 8) + + guard let metadata, let scheduledRequest = metadata.updatesToSchedule.first else { + Issue.record("Expected reschedule metadata") + return + } + #expect(metadata.updatesToSchedule.count == 1) + #expect(metadata.updatesToCancel == [firstRequest]) + #expect(scheduledRequest.targetTime == Timestamp(value: .seconds(7))) + #expect(component.state.request == scheduledRequest) + } + + @Test + func unchangedExpirationDeadlineDoesNotReschedule() throws { + var component = ExpirationComponent( + upstream: ExpirationRecordStubComponent(outputs: [ + .value( + expirationRecord( + value: 7, + deadline: .seconds(5), + reason: "first" + ), + metadata: nil + ), + .value( + expirationRecord( + value: 8, + deadline: .seconds(5), + reason: "second" + ), + metadata: nil + ), + ]) + ) + + _ = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(2)) + ) + let firstRequest = component.state.request + + let output = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(3)) + ) + + guard case let .value(value, metadata) = output else { + Issue.record("Expected value output") + return + } + #expect(value == 8) + guard let metadata else { + Issue.record("Expected empty metadata") + return + } + #expect(metadata.updatesToSchedule.isEmpty) + #expect(metadata.updatesToCancel.isEmpty) + #expect(component.state.request == firstRequest) + } + + @Test + func nilExpirationWithoutStoredRequestReturnsEmptyMetadata() throws { + var component = ExpirationComponent( + upstream: ExpirationRecordStubComponent(outputs: [ + .value( + ExpirationRecord( + payload: .value(7), + expiration: nil + ), + metadata: nil + ), + ]) + ) + + let output = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(2)) + ) + + guard case let .value(value, metadata) = output else { + Issue.record("Expected value output") + return + } + #expect(value == 7) + + guard let metadata else { + Issue.record("Expected empty metadata") + return + } + #expect(metadata.updatesToSchedule.isEmpty) + #expect(metadata.updatesToCancel.isEmpty) + #expect(component.state.request == nil) + } + + @Test + func cancelsRequestWhenExpirationClears() throws { + var component = ExpirationComponent( + upstream: ExpirationRecordStubComponent(outputs: [ + .value( + expirationRecord( + value: 7, + deadline: .seconds(5), + reason: "timeout" + ), + metadata: nil + ), + .value( + ExpirationRecord( + payload: .value(8), + expiration: nil + ), + metadata: nil + ), + ]) + ) + + _ = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(2)) + ) + guard let firstRequest = component.state.request else { + Issue.record("Expected first request") + return + } + + let output = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(3)) + ) + + guard case let .value(value, metadata) = output else { + Issue.record("Expected value output") + return + } + #expect(value == 8) + + guard let metadata else { + Issue.record("Expected cancel metadata") + return + } + #expect(metadata.updatesToSchedule.isEmpty) + #expect(metadata.updatesToCancel == [firstRequest]) + #expect(component.state.request == nil) + } + + @Test + func emptyPayloadPreservesReasonAndSchedulesExpiration() throws { + var component = ExpirationComponent( + upstream: ExpirationRecordStubComponent(outputs: [ + .value( + ExpirationRecord( + payload: .empty(.filtered), + expiration: Expiration( + deadline: Timestamp(value: .seconds(5)), + reason: "filtered timeout" + ) + ), + metadata: nil + ), + ]) + ) + + let output = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(2)) + ) + + guard case let .empty(reason, metadata) = output else { + Issue.record("Expected empty output") + return + } + #expect(reason == .filtered) + #expect(metadata?.updatesToSchedule.count == 1) + } + + @Test + func timeoutThrowsWhenCurrentTimeReachesDeadline() throws { + var component = ExpirationComponent( + upstream: ExpirationRecordStubComponent(outputs: [ + .value( + expirationRecord( + value: 7, + deadline: .seconds(5), + reason: "expired" + ), + metadata: nil + ), + ]) + ) + + do { + _ = try component.update( + context: makeExpirationComponentContext(currentTime: .seconds(5)) + ) + Issue.record("Expected timeout failure") + } catch ExpirationComponent.Failure.timeout(let reason) { + #expect(reason.description == "expired") + } catch { + Issue.record("Unexpected error: \(error)") + } + } +} + +private func expirationRecord( + value: Int, + deadline: Duration, + reason: ExpirationReason +) -> ExpirationRecord { + ExpirationRecord( + payload: .value(value), + expiration: Expiration( + deadline: Timestamp(value: deadline), + reason: reason + ) + ) +} + +private func makeExpirationComponentContext( + currentTime: Duration +) -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: .zero), + currentTime: Timestamp(value: currentTime), + updateSource: .event, + eventStore: EventStore() + ) +} + +private struct ExpirationRecordStubComponent: GestureComponent { + var outputs: [GestureOutput>] + + mutating func update( + context: GestureComponentContext + ) throws -> GestureOutput> { + outputs.removeFirst() + } + + mutating func reset() { + outputs.removeAll() + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + 0 + } +} diff --git a/Tests/OpenGesturesTests/Component/Components/MapComponentTests.swift b/Tests/OpenGesturesTests/Component/Components/MapComponentTests.swift new file mode 100644 index 0000000..e0f9cb8 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Components/MapComponentTests.swift @@ -0,0 +1,66 @@ +// +// MapComponentTests.swift +// OpenGesturesTests +// +// Generated + +import OpenGestures +import Testing + +// MARK: - MapComponentTests + +@Suite +struct MapComponentTests { + @Test + func mapsWholeGestureOutput() throws { + var component = MapComponent( + upstream: MapStubComponent(outputs: [ + .value(3, metadata: nil), + ]), + map: { output in + guard case let .value(value, metadata) = output else { + return .empty(.filtered, metadata: nil) + } + return .value(String(value * 2), metadata: metadata) + } + ) + + let output = try component.update(context: mapComponentContext()) + + guard case let .value(value, metadata) = output else { + Issue.record("Expected mapped value output") + return + } + #expect(value == "6") + #expect(metadata == nil) + } +} + +private func mapComponentContext() -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: .zero), + currentTime: Timestamp(value: .zero), + updateSource: .event, + eventStore: EventStore() + ) +} + +private struct MapStubComponent: GestureComponent { + var outputs: [GestureOutput] + + mutating func update(context: GestureComponentContext) throws -> GestureOutput { + outputs.removeFirst() + } + + mutating func reset() { + outputs.removeAll() + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + 0 + } +} diff --git a/Tests/OpenGesturesTests/Component/Components/ReduceComponentTests.swift b/Tests/OpenGesturesTests/Component/Components/ReduceComponentTests.swift new file mode 100644 index 0000000..b3b5bd4 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Components/ReduceComponentTests.swift @@ -0,0 +1,119 @@ +// +// ReduceComponentTests.swift +// OpenGesturesTests +// +// Generated + +import OpenGestures +import Testing + +// MARK: - ReduceComponentTests + +@Suite +struct ReduceComponentTests { + @Test + func accumulatesValuesAndStoresState() throws { + var component = ReduceComponent( + upstream: ReduceStubComponent(outputs: [ + .value(2, metadata: nil), + .finalValue(3, metadata: nil), + ]), + initial: 10, + reduce: { accumulator, value in accumulator + value } + ) + + let firstOutput = try component.update(context: reduceContext()) + let finalOutput = try component.update(context: reduceContext()) + + guard case let .value(firstValue, firstMetadata) = firstOutput else { + Issue.record("Expected first reduced value") + return + } + #expect(firstValue == 12) + #expect(firstMetadata == nil) + #expect(component.state.accumulator == 15) + + guard case let .finalValue(finalValue, finalMetadata) = finalOutput else { + Issue.record("Expected final reduced value") + return + } + #expect(finalValue == 15) + #expect(finalMetadata == nil) + #expect(component.state.accumulator == 15) + } + + @Test + func emptyOutputPassesThroughWithoutReducing() throws { + let metadata = GestureOutputMetadata(traceAnnotation: UpdateTraceAnnotation(value: "empty")) + var component = ReduceComponent( + upstream: ReduceStubComponent(outputs: [ + .empty(.filtered, metadata: metadata), + ]), + initial: 10, + reduce: { _, _ in + throw ReduceTestError.unexpectedReduce + } + ) + + let output = try component.update(context: reduceContext()) + + guard case let .empty(reason, outputMetadata) = output else { + Issue.record("Expected empty output") + return + } + #expect(reason == .filtered) + #expect(outputMetadata?.traceAnnotation?.value == "empty") + #expect(component.state.accumulator == nil) + } + + @Test + func throwingReduceDoesNotStoreAccumulator() throws { + var component = ReduceComponent( + upstream: ReduceStubComponent(outputs: [ + .value(2, metadata: nil), + ]), + initial: 10, + reduce: { _, _ in + throw ReduceTestError.unexpectedReduce + } + ) + + #expect(throws: ReduceTestError.unexpectedReduce) { + try component.update(context: reduceContext()) + } + #expect(component.state.accumulator == nil) + } +} + +private enum ReduceTestError: Error { + case unexpectedReduce +} + +private func reduceContext() -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: .zero), + currentTime: Timestamp(value: .zero), + updateSource: .event, + eventStore: EventStore() + ) +} + +private struct ReduceStubComponent: GestureComponent { + var outputs: [GestureOutput] + + mutating func update(context: GestureComponentContext) throws -> GestureOutput { + outputs.removeFirst() + } + + mutating func reset() { + outputs.removeAll() + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + 0 + } +} diff --git a/Tests/OpenGesturesTests/Component/Components/RepeatComponentTests.swift b/Tests/OpenGesturesTests/Component/Components/RepeatComponentTests.swift new file mode 100644 index 0000000..ce0df32 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Components/RepeatComponentTests.swift @@ -0,0 +1,253 @@ +// +// RepeatComponentTests.swift +// OpenGesturesTests +// +// Generated + +import OpenGestures +import Testing + +// MARK: - RepeatComponentTests + +@Suite +struct RepeatComponentTests { + @Test + func finalValueBeforeRequiredCountProducesRepeatExpiration() throws { + var component = RepeatComponent( + upstream: RepeatStubComponent(outputs: [ + .finalValue(7, metadata: nil), + ]), + count: 2, + delay: .seconds(1) + ) + + let output = try component.update( + context: repeatComponentContext(currentTime: .seconds(3)) + ) + + guard case let .value(record, metadata) = output else { + Issue.record("Expected repeat value output") + return + } + guard case let .value(value) = record.payload else { + Issue.record("Expected value repeat payload") + return + } + #expect(value == 7) + #expect(record.expiration?.deadline == Timestamp(value: .seconds(4))) + #expect(record.expiration?.reason.description == "Repeat deadline expired") + #expect(component.state.currentCount == 1) + #expect(component.state.repeatDeadline == Timestamp(value: .seconds(4))) + #expect(component.state.repeatStartTime == nil) + #expect(component.upstream.resetCount == 1) + #expect(metadata != nil) + #expect(metadata?.traceAnnotation == nil) + } + + @Test + func finalValueAtRequiredCountCompletes() throws { + var component = RepeatComponent( + upstream: RepeatStubComponent(outputs: [ + .finalValue(7, metadata: nil), + ]), + state: RepeatComponent.State( + currentCount: 1, + repeatDeadline: Timestamp(value: .seconds(4)), + repeatStartTime: Timestamp(value: .seconds(3)) + ), + count: 2, + delay: .seconds(1) + ) + + let output = try component.update( + context: repeatComponentContext(currentTime: .seconds(4)) + ) + + guard case let .finalValue(record, metadata) = output else { + Issue.record("Expected final repeat output") + return + } + guard case let .value(value) = record.payload else { + Issue.record("Expected value payload") + return + } + #expect(value == 7) + #expect(record.expiration == nil) + #expect(component.state.currentCount == 2) + #expect(component.state.repeatDeadline == Timestamp(value: .seconds(4))) + #expect(component.state.repeatStartTime == Timestamp(value: .seconds(3))) + #expect(metadata != nil) + #expect(metadata?.traceAnnotation == nil) + } + + @Test + func emptyOutputPassesThroughWithoutExpirationRecord() throws { + let metadata = GestureOutputMetadata(traceAnnotation: UpdateTraceAnnotation(value: "empty")) + var component = RepeatComponent( + upstream: RepeatStubComponent(outputs: [ + .empty(.filtered, metadata: metadata), + ]), + state: RepeatComponent.State( + currentCount: 1, + repeatDeadline: Timestamp(value: .seconds(5)), + repeatStartTime: Timestamp(value: .seconds(3)) + ), + count: 2, + delay: .seconds(1) + ) + + let output = try component.update( + context: repeatComponentContext(currentTime: .seconds(4)) + ) + + guard case let .empty(reason, outputMetadata) = output else { + Issue.record("Expected empty output") + return + } + #expect(reason == .filtered) + #expect(outputMetadata?.traceAnnotation?.value == "empty") + } + + @Test + func nonFinalValueCarriesRepeatExpiration() throws { + let metadata = GestureOutputMetadata(traceAnnotation: UpdateTraceAnnotation(value: "value")) + var component = RepeatComponent( + upstream: RepeatStubComponent(outputs: [ + .value(9, metadata: metadata), + ]), + state: RepeatComponent.State( + currentCount: 1, + repeatDeadline: Timestamp(value: .seconds(5)), + repeatStartTime: Timestamp(value: .seconds(3)) + ), + count: 2, + delay: .seconds(1) + ) + + let output = try component.update( + context: repeatComponentContext(currentTime: .seconds(4)) + ) + + guard case let .value(record, outputMetadata) = output else { + Issue.record("Expected value output") + return + } + guard case let .value(value) = record.payload else { + Issue.record("Expected value payload") + return + } + #expect(value == 9) + #expect(record.expiration?.deadline == Timestamp(value: .seconds(5))) + #expect(outputMetadata != nil) + #expect(outputMetadata?.traceAnnotation == nil) + #expect(component.upstream.contexts.first?.startTime == Timestamp(value: .seconds(3))) + } + + @Test + func nonFinalValueUsesRepeatDeadlineWhenPresent() throws { + var component = RepeatComponent( + upstream: RepeatStubComponent(outputs: [ + .value(9, metadata: nil), + ]), + state: RepeatComponent.State( + currentCount: 0, + repeatDeadline: Timestamp(value: .seconds(5)), + repeatStartTime: nil + ), + count: 2, + delay: .seconds(1) + ) + + let output = try component.update( + context: repeatComponentContext(currentTime: .seconds(4)) + ) + + guard case let .value(record, metadata) = output else { + Issue.record("Expected value output") + return + } + #expect(record.expiration?.deadline == Timestamp(value: .seconds(5))) + #expect(record.expiration?.reason.description == "Repeat deadline expired") + #expect(metadata != nil) + #expect(metadata?.traceAnnotation == nil) + } + + @Test + func existingRepeatStartTimeAdjustsContextWhenCurrentCountIsZero() throws { + let repeatStartTime = Timestamp(value: .seconds(2)) + var component = RepeatComponent( + upstream: RepeatStubComponent(outputs: [ + .value(9, metadata: nil), + ]), + state: RepeatComponent.State( + currentCount: 0, + repeatDeadline: nil, + repeatStartTime: repeatStartTime + ), + count: 2, + delay: .seconds(1) + ) + + _ = try component.update( + context: repeatComponentContext(currentTime: .seconds(4)) + ) + + #expect(component.state.repeatStartTime == repeatStartTime) + #expect(component.upstream.contexts.first?.startTime == repeatStartTime) + } + + @Test + func firstEventAfterRepeatCapturesRepeatStartTime() throws { + var component = RepeatComponent( + upstream: RepeatStubComponent(outputs: [ + .value(9, metadata: nil), + ]), + state: RepeatComponent.State( + currentCount: 1, + repeatDeadline: Timestamp(value: .seconds(5)), + repeatStartTime: nil + ), + count: 2, + delay: .seconds(1) + ) + + _ = try component.update( + context: repeatComponentContext(currentTime: .seconds(4)) + ) + + #expect(component.state.repeatStartTime == Timestamp(value: .seconds(4))) + #expect(component.upstream.contexts.first?.startTime == Timestamp(value: .seconds(4))) + } +} + +private func repeatComponentContext(currentTime: Duration) -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: .zero), + currentTime: Timestamp(value: currentTime), + updateSource: .event, + eventStore: EventStore() + ) +} + +private struct RepeatStubComponent: GestureComponent { + var outputs: [GestureOutput] + var contexts: [GestureComponentContext] = [] + var resetCount = 0 + + mutating func update(context: GestureComponentContext) throws -> GestureOutput { + contexts.append(context) + return outputs.removeFirst() + } + + mutating func reset() { + resetCount += 1 + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + 0 + } +} diff --git a/Tests/OpenGesturesTests/Component/Components/ThresholdComponentTests.swift b/Tests/OpenGesturesTests/Component/Components/ThresholdComponentTests.swift new file mode 100644 index 0000000..ffc680d --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Components/ThresholdComponentTests.swift @@ -0,0 +1,139 @@ +// +// ThresholdComponentTests.swift +// OpenGesturesTests +// +// Generated + +import OpenCoreGraphicsShims +import OpenGestures +import Testing + +// MARK: - ThresholdComponentTests + +@Suite +struct ThresholdComponentTests { + @Test + func filtersUntilMovementReachesThreshold() throws { + var component = ThresholdComponent( + upstream: ThresholdPointStubComponent(outputs: [ + .value(CGPoint.zero, metadata: nil), + .value(CGPoint(x: 3, y: 4), metadata: nil), + ]), + threshold: { _, _ in 5 } + ) + + let filteredOutput = try component.update(context: thresholdContext()) + let valueOutput = try component.update(context: thresholdContext()) + + guard case let .empty(reason, filteredMetadata) = filteredOutput else { + Issue.record("Expected filtered output") + return + } + #expect(reason == .filtered) + #expect(filteredMetadata != nil) + #expect(filteredMetadata?.traceAnnotation == nil) + #expect(component.state.initialValue == CGPoint.zero) + + guard case let .value(value, valueMetadata) = valueOutput else { + Issue.record("Expected threshold-adjusted value") + return + } + #expect(value == CGPoint.zero) + #expect(component.state.adjustmentDelta == CGPoint(x: 3, y: 4)) + #expect(valueMetadata == nil) + } + + @Test + func existingAdjustmentDeltaOffsetsValuesAndPreservesFinal() throws { + var component = ThresholdComponent( + upstream: ThresholdPointStubComponent(outputs: [ + .finalValue(CGPoint(x: 6, y: 8), metadata: nil), + ]), + state: ThresholdComponent.State( + initialValue: CGPoint.zero, + adjustmentDelta: CGPoint(x: 3, y: 4) + ), + threshold: { _, _ in 5 } + ) + + let output = try component.update(context: thresholdContext()) + + guard case let .finalValue(value, metadata) = output else { + Issue.record("Expected final adjusted value") + return + } + #expect(value == CGPoint(x: 3, y: 4)) + #expect(metadata == nil) + } + + @Test + func thresholdClosureReceivesCurrentValueBeforeInitialValue() throws { + var component = ThresholdComponent( + upstream: ThresholdPointStubComponent(outputs: [ + .value(CGPoint.zero, metadata: nil), + .value(CGPoint(x: 6, y: 8), metadata: nil), + ]), + threshold: { currentValue, initialValue in + currentValue == CGPoint(x: 6, y: 8) && initialValue == .zero ? 5 : 20 + } + ) + + _ = try component.update(context: thresholdContext()) + let output = try component.update(context: thresholdContext()) + + guard case let .value(value, metadata) = output else { + Issue.record("Expected threshold-adjusted value") + return + } + #expect(value == CGPoint(x: 3, y: 4)) + #expect(metadata == nil) + } + + @Test + func finalBeforeThresholdThrows() throws { + typealias Component = ThresholdComponent + var component = Component( + upstream: ThresholdPointStubComponent(outputs: [ + .finalValue(CGPoint.zero, metadata: nil), + ]), + threshold: { _, _ in 5 } + ) + + do { + _ = try component.update(context: thresholdContext()) + Issue.record("Expected notEnoughMovement") + } catch Component.Failure.notEnoughMovement { + } catch { + Issue.record("Unexpected error: \(error)") + } + } +} + +private func thresholdContext() -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: .zero), + currentTime: Timestamp(value: .zero), + updateSource: .event, + eventStore: EventStore() + ) +} + +private struct ThresholdPointStubComponent: GestureComponent { + var outputs: [GestureOutput] + + mutating func update(context: GestureComponentContext) throws -> GestureOutput { + outputs.removeFirst() + } + + mutating func reset() { + outputs.removeAll() + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + 0 + } +} diff --git a/Tests/OpenGesturesTests/Component/Components/TimeoutComponentTests.swift b/Tests/OpenGesturesTests/Component/Components/TimeoutComponentTests.swift new file mode 100644 index 0000000..19ac32d --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Components/TimeoutComponentTests.swift @@ -0,0 +1,232 @@ +// +// TimeoutComponentTests.swift +// OpenGesturesTests +// +// Generated + +import OpenGestures +import Testing + +// MARK: - TimeoutComponentTests + +@Suite +struct TimeoutComponentTests { + @Test + func attachesExpirationUntilPredicateIsFulfilled() throws { + var component = TimeoutComponent( + upstream: TimeoutStubComponent(outputs: [ + .value(7, metadata: nil), + ]), + timeout: .seconds(2), + tag: "timeout", + predicate: { _ in false } + ) + + let output = try component.update(context: timeoutComponentContext()) + + guard case let .value(record, metadata) = output else { + Issue.record("Expected value output") + return + } + guard case let .value(value) = record.payload else { + Issue.record("Expected value payload") + return + } + #expect(value == 7) + #expect(metadata == nil) + #expect(record.expiration?.deadline == Timestamp(value: .seconds(2))) + #expect(record.expiration?.reason.description == "timeout") + #expect(component.state.fulfilled == false) + } + + @Test + func fulfilledPredicateClearsExpiration() throws { + var component = TimeoutComponent( + upstream: TimeoutStubComponent(outputs: [ + .finalValue(7, metadata: nil), + ]), + timeout: .seconds(2), + tag: "timeout", + predicate: { $0.isFinal } + ) + + let output = try component.update(context: timeoutComponentContext()) + + guard case let .finalValue(record, metadata) = output else { + Issue.record("Expected final value output") + return + } + #expect(record.expiration == nil) + #expect(metadata == nil) + #expect(component.state.fulfilled == true) + } + + @Test + func noDataEmptyOutputBypassesExpirationAndPredicate() throws { + let probe = PredicateProbe(result: true) + let metadata = GestureOutputMetadata( + traceAnnotation: UpdateTraceAnnotation(value: "no event") + ) + var component = TimeoutComponent( + upstream: TimeoutStubComponent(outputs: [ + .empty(.noData, metadata: metadata), + ]), + timeout: .seconds(2), + tag: "timeout", + predicate: { probe.call($0) } + ) + + let output = try component.update(context: timeoutComponentContext()) + + guard case let .empty(reason, outputMetadata) = output else { + Issue.record("Expected empty output") + return + } + #expect(reason == .noData) + #expect(outputMetadata?.traceAnnotation?.value == "no event") + #expect(probe.callCount == 0) + #expect(component.state.fulfilled == false) + } + + @Test + func predicateIsSkippedOnceCurrentTimeReachesDeadline() throws { + let probe = PredicateProbe(result: true) + var component = TimeoutComponent( + upstream: TimeoutStubComponent(outputs: [ + .value(7, metadata: nil), + ]), + timeout: .seconds(2), + tag: "timeout", + predicate: { probe.call($0) } + ) + + let output = try component.update( + context: timeoutComponentContext(currentTime: .seconds(2)) + ) + + guard case let .value(record, _) = output else { + Issue.record("Expected value output") + return + } + #expect(record.expiration?.deadline == Timestamp(value: .seconds(2))) + #expect(probe.callCount == 0) + #expect(component.state.fulfilled == false) + } + + @Test + func zeroTimeoutStillProducesImmediateExpiration() throws { + let probe = PredicateProbe(result: true) + var component = TimeoutComponent( + upstream: TimeoutStubComponent(outputs: [ + .value(7, metadata: nil), + ]), + timeout: .zero, + tag: "timeout", + predicate: { probe.call($0) } + ) + + let output = try component.update(context: timeoutComponentContext()) + + guard case let .value(record, _) = output else { + Issue.record("Expected value output") + return + } + #expect(record.expiration?.deadline == Timestamp(value: .zero)) + #expect(record.expiration?.reason.description == "timeout") + #expect(probe.callCount == 0) + #expect(component.state.fulfilled == false) + } + + @Test + func maxTimeoutSuppressesExpirationAndPredicate() throws { + let probe = PredicateProbe(result: true) + var component = TimeoutComponent( + upstream: TimeoutStubComponent(outputs: [ + .value(7, metadata: nil), + ]), + timeout: .max, + tag: "timeout", + predicate: { probe.call($0) } + ) + + let output = try component.update(context: timeoutComponentContext()) + + guard case let .value(record, _) = output else { + Issue.record("Expected value output") + return + } + #expect(record.expiration == nil) + #expect(probe.callCount == 0) + #expect(component.state.fulfilled == false) + } + + @Test + func fulfilledStateSuppressesExpirationAndPredicate() throws { + let probe = PredicateProbe(result: true) + var component = TimeoutComponent( + upstream: TimeoutStubComponent(outputs: [ + .value(7, metadata: nil), + ]), + state: .init(fulfilled: true), + timeout: .seconds(2), + tag: "timeout", + predicate: { probe.call($0) } + ) + + let output = try component.update(context: timeoutComponentContext()) + + guard case let .value(record, _) = output else { + Issue.record("Expected value output") + return + } + #expect(record.expiration == nil) + #expect(probe.callCount == 0) + #expect(component.state.fulfilled == true) + } +} + +private func timeoutComponentContext( + startTime: Duration = .zero, + currentTime: Duration = .zero +) -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: startTime), + currentTime: Timestamp(value: currentTime), + updateSource: .event, + eventStore: EventStore() + ) +} + +private final class PredicateProbe: @unchecked Sendable { + var callCount = 0 + var result: Bool + + init(result: Bool) { + self.result = result + } + + func call(_ output: GestureOutput) -> Bool { + callCount += 1 + return result + } +} + +private struct TimeoutStubComponent: GestureComponent { + var outputs: [GestureOutput] + + mutating func update(context: GestureComponentContext) throws -> GestureOutput { + outputs.removeFirst() + } + + mutating func reset() { + outputs.removeAll() + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + 0 + } +} diff --git a/Tests/OpenGesturesTests/Component/Components/VelocityComponentTests.swift b/Tests/OpenGesturesTests/Component/Components/VelocityComponentTests.swift new file mode 100644 index 0000000..e7e2861 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Components/VelocityComponentTests.swift @@ -0,0 +1,137 @@ +// +// VelocityComponentTests.swift +// OpenGesturesTests +// +// Generated + +import OpenCoreGraphicsShims +import OpenGestures +import Testing + +// MARK: - VelocityComponentTests + +@Suite +struct VelocityComponentTests { + @Test + func firstValueProducesZeroVelocityAndStoresState() throws { + var component = VelocityComponent( + upstream: PointStubComponent(outputs: [ + .value(CGPoint(x: 10, y: 0), metadata: nil), + ]), + interpolationWeight: 1 + ) + + let output = try component.update(context: velocityContext(currentTime: .seconds(1))) + + guard case let .value(result, metadata) = output else { + Issue.record("Expected velocity value") + return + } + #expect(result.value == CGPoint(x: 10, y: 0)) + #expect(result.velocity == CGPoint.zero) + #expect(component.state.previousValue == CGPoint(x: 10, y: 0)) + #expect(component.state.previousVelocity == CGPoint.zero) + #expect(component.state.previousTime == Timestamp(value: .seconds(1))) + #expect(metadata == nil) + } + + @Test + func computesVelocityFromElapsedTime() throws { + var component = VelocityComponent( + upstream: PointStubComponent(outputs: [ + .value(CGPoint(x: 10, y: 0), metadata: nil), + ]), + state: VelocityComponent.State( + previousValue: CGPoint.zero, + previousVelocity: nil, + previousTime: Timestamp(value: .seconds(1)) + ), + interpolationWeight: 1 + ) + + let output = try component.update(context: velocityContext(currentTime: .seconds(3))) + + guard case let .value(result, metadata) = output else { + Issue.record("Expected velocity value") + return + } + #expect(result.velocity == CGPoint(x: 5, y: 0)) + #expect(component.state.previousVelocity == CGPoint(x: 5, y: 0)) + #expect(metadata == nil) + } + + @Test + func interpolatesRawVelocityWithPreviousVelocity() throws { + var component = VelocityComponent( + upstream: PointStubComponent(outputs: [ + .value(CGPoint(x: 20, y: 0), metadata: nil), + ]), + state: VelocityComponent.State( + previousValue: CGPoint.zero, + previousVelocity: CGPoint(x: 2, y: 0), + previousTime: Timestamp(value: .zero) + ), + interpolationWeight: 0.25 + ) + + let output = try component.update(context: velocityContext(currentTime: .seconds(2))) + + guard case let .value(result, _) = output else { + Issue.record("Expected velocity value") + return + } + #expect(result.velocity == CGPoint(x: 8, y: 0)) + } + + @Test + func reusesPreviousVelocityForSubMillisecondElapsedTime() throws { + var component = VelocityComponent( + upstream: PointStubComponent(outputs: [ + .value(CGPoint(x: 20, y: 0), metadata: nil), + ]), + state: VelocityComponent.State( + previousValue: CGPoint.zero, + previousVelocity: CGPoint(x: 7, y: 0), + previousTime: Timestamp(value: .zero) + ), + interpolationWeight: 1 + ) + + let output = try component.update(context: velocityContext(currentTime: .microseconds(500))) + + guard case let .value(result, _) = output else { + Issue.record("Expected velocity value") + return + } + #expect(result.velocity == CGPoint(x: 7, y: 0)) + } +} + +private func velocityContext(currentTime: Duration) -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: .zero), + currentTime: Timestamp(value: currentTime), + updateSource: .event, + eventStore: EventStore() + ) +} + +private struct PointStubComponent: GestureComponent { + var outputs: [GestureOutput] + + mutating func update(context: GestureComponentContext) throws -> GestureOutput { + outputs.removeFirst() + } + + mutating func reset() { + outputs.removeAll() + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + 0 + } +} diff --git a/Tests/OpenGesturesTests/Component/ExpirationTests.swift b/Tests/OpenGesturesTests/Component/ExpirationTests.swift new file mode 100644 index 0000000..9073779 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/ExpirationTests.swift @@ -0,0 +1,23 @@ +// +// ExpirationTests.swift +// OpenGesturesTests +// +// Generated + +import OpenGestures +import Testing + +// MARK: - ExpirationTests + +@Suite +struct ExpirationTests { + @Test + func expirablePayloadDescriptionUsesFrameworkLabels() { + let emptyDescription = ExpirablePayload.empty(.filtered).description + #expect(emptyDescription.contains("reason: filtered")) + #expect(!emptyDescription.contains("empty:")) + + let valueDescription = ExpirablePayload.value(7).description + #expect(valueDescription.contains("value: 7")) + } +} diff --git a/Tests/OpenGesturesTests/Component/Gate/DurationGateTests.swift b/Tests/OpenGesturesTests/Component/Gate/DurationGateTests.swift new file mode 100644 index 0000000..98dffc9 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Gate/DurationGateTests.swift @@ -0,0 +1,126 @@ +// +// DurationGateTests.swift +// OpenGesturesTests +// +// Generated + +import OpenGestures +import Testing + +// MARK: - DurationGateTests + +@Suite +struct DurationGateTests { + @Test + func filtersNonFinalValuesUntilMinimumDurationIsReached() throws { + var component = DurationGate( + upstream: IntStubComponent(outputs: [ + .value(7, metadata: nil), + ]), + minimumDuration: .seconds(2), + maximumDuration: .seconds(10) + ) + + let output = try component.update( + context: makeDurationGateContext(currentTime: .seconds(1)) + ) + + guard case let .value(record, metadata) = output else { + Issue.record("Expected wrapped value output") + return + } + guard case let .empty(reason) = record.payload else { + Issue.record("Expected empty payload") + return + } + #expect(reason == .filtered) + #expect(metadata != nil) + #expect(metadata?.traceAnnotation == nil) + #expect(record.expiration?.deadline == Timestamp(value: .seconds(2))) + #expect(record.expiration?.reason.description == "min duration expired") + } + + @Test + func finalValueBeforeMinimumDurationThrows() throws { + var component = DurationGate( + upstream: IntStubComponent(outputs: [ + .finalValue(7, metadata: nil), + ]), + minimumDuration: .seconds(2), + maximumDuration: .seconds(10) + ) + + do { + _ = try component.update( + context: makeDurationGateContext(currentTime: .seconds(1)) + ) + Issue.record("Expected minimumDurationNotReached") + } catch DurationGate.Failure.minimumDurationNotReached { + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func valuesAfterMinimumDurationCarryMaximumExpiration() throws { + var component = DurationGate( + upstream: IntStubComponent(outputs: [ + .finalValue(9, metadata: nil), + ]), + minimumDuration: .seconds(2), + maximumDuration: .seconds(10) + ) + + let output = try component.update( + context: makeDurationGateContext( + startTime: .seconds(3), + currentTime: .seconds(6) + ) + ) + + guard case let .finalValue(record, metadata) = output else { + Issue.record("Expected wrapped final value") + return + } + guard case let .value(value) = record.payload else { + Issue.record("Expected value payload") + return + } + #expect(value == 9) + #expect(metadata == nil) + #expect(record.expiration?.deadline == Timestamp(value: .seconds(13))) + #expect(record.expiration?.reason.description == "max duration expired") + } +} + +private func makeDurationGateContext( + startTime: Duration = .zero, + currentTime: Duration +) -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: startTime), + currentTime: Timestamp(value: currentTime), + updateSource: .event, + eventStore: EventStore() + ) +} + +private struct IntStubComponent: GestureComponent { + var outputs: [GestureOutput] + + mutating func update(context: GestureComponentContext) throws -> GestureOutput { + outputs.removeFirst() + } + + mutating func reset() { + outputs.removeAll() + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + 0 + } +} diff --git a/Tests/OpenGesturesTests/Component/MovementGateTests.swift b/Tests/OpenGesturesTests/Component/Gate/MovementGateTests.swift similarity index 97% rename from Tests/OpenGesturesTests/Component/MovementGateTests.swift rename to Tests/OpenGesturesTests/Component/Gate/MovementGateTests.swift index 38d564a..be59b29 100644 --- a/Tests/OpenGesturesTests/Component/MovementGateTests.swift +++ b/Tests/OpenGesturesTests/Component/Gate/MovementGateTests.swift @@ -31,7 +31,8 @@ struct MovementGateTests { return } #expect(reason == .filtered) - #expect(filteredMetadata?.traceAnnotation?.value == "not enough movement") + #expect(filteredMetadata != nil) + #expect(filteredMetadata?.traceAnnotation == nil) guard case let .finalValue(finalValue, finalMetadata) = finalOutput else { Issue.record("Expected final value output") diff --git a/Tests/OpenGesturesTests/Component/Gate/SeparationDistanceGateTests.swift b/Tests/OpenGesturesTests/Component/Gate/SeparationDistanceGateTests.swift new file mode 100644 index 0000000..cb00851 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Gate/SeparationDistanceGateTests.swift @@ -0,0 +1,108 @@ +// +// SeparationDistanceGateTests.swift +// OpenGesturesTests +// +// Generated + +import OpenCoreGraphicsShims +import OpenGestures +import Testing + +// MARK: - SeparationDistanceGateTests + +@Suite +struct SeparationDistanceGateTests { + @Test + func passesWhenBoundingDistanceIsWithinLimit() throws { + var component = SeparationDistanceGate( + upstream: LocationArrayStubComponent(outputs: [ + .value([ + CGPoint(x: 0, y: 0), + CGPoint(x: 3, y: 4), + ], metadata: nil), + ]), + distance: 5 + ) + + let output = try component.update(context: makeSeparationDistanceGateContext()) + + guard case let .value(value, metadata) = output else { + Issue.record("Expected value output") + return + } + #expect(value == [CGPoint(x: 0, y: 0), CGPoint(x: 3, y: 4)]) + #expect(metadata == nil) + } + + @Test + func throwsWhenBoundingDistanceExceedsLimit() throws { + var component = SeparationDistanceGate( + upstream: LocationArrayStubComponent(outputs: [ + .value([ + CGPoint(x: -3, y: -4), + CGPoint(x: 3, y: 4), + ], metadata: nil), + ]), + distance: 9 + ) + + do { + _ = try component.update(context: makeSeparationDistanceGateContext()) + Issue.record("Expected exceedsAllowedDistance") + } catch SeparationDistanceGate.Failure.exceedsAllowedDistance { + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func greatestFiniteDistanceDisablesTheGate() throws { + var component = SeparationDistanceGate( + upstream: LocationArrayStubComponent(outputs: [ + .finalValue([ + CGPoint(x: -1_000, y: -1_000), + CGPoint(x: 1_000, y: 1_000), + ], metadata: nil), + ]), + distance: .greatestFiniteMagnitude + ) + + let output = try component.update(context: makeSeparationDistanceGateContext()) + + guard case let .finalValue(value, metadata) = output else { + Issue.record("Expected final value output") + return + } + #expect(value.count == 2) + #expect(metadata == nil) + } +} + +private func makeSeparationDistanceGateContext() -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: .zero), + currentTime: Timestamp(value: .zero), + updateSource: .event, + eventStore: EventStore() + ) +} + +private struct LocationArrayStubComponent: GestureComponent { + var outputs: [GestureOutput<[CGPoint]>] + + mutating func update(context: GestureComponentContext) throws -> GestureOutput<[CGPoint]> { + outputs.removeFirst() + } + + mutating func reset() { + outputs.removeAll() + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + 0 + } +} diff --git a/Tests/OpenGesturesTests/Component/UpdateTracerTests.swift b/Tests/OpenGesturesTests/Component/Track/UpdateTracerTests.swift similarity index 100% rename from Tests/OpenGesturesTests/Component/UpdateTracerTests.swift rename to Tests/OpenGesturesTests/Component/Track/UpdateTracerTests.swift diff --git a/Tests/OpenGesturesTests/Component/ValueTransformingComponentTests.swift b/Tests/OpenGesturesTests/Component/ValueTransformingComponentTests.swift index 0904f63..15996dc 100644 --- a/Tests/OpenGesturesTests/Component/ValueTransformingComponentTests.swift +++ b/Tests/OpenGesturesTests/Component/ValueTransformingComponentTests.swift @@ -53,14 +53,16 @@ struct ValueTransformingComponentTests { return } #expect(reason == .filtered) - #expect(valueMetadata?.traceAnnotation?.value == "not final event") + #expect(valueMetadata != nil) + #expect(valueMetadata?.traceAnnotation == nil) guard case let .finalValue(finalValue, finalMetadata) = finalOutput else { Issue.record("Expected final value output") return } #expect(finalValue == 5) - #expect(finalMetadata == nil) + #expect(finalMetadata != nil) + #expect(finalMetadata?.traceAnnotation == nil) } @Test diff --git a/Tests/OpenGesturesTests/Core/GestureOutputTests.swift b/Tests/OpenGesturesTests/Core/GestureOutputTests.swift new file mode 100644 index 0000000..eff617b --- /dev/null +++ b/Tests/OpenGesturesTests/Core/GestureOutputTests.swift @@ -0,0 +1,87 @@ +// +// GestureOutputTests.swift +// OpenGesturesTests + +import OpenGestures +import Testing + +// MARK: - GestureOutputTests + +@Suite +struct GestureOutputTests { + + // MARK: - copyWithCombinedMetadata + + @Test + func copyWithCombinedMetadataCombinesMetadataAndPreservesValueCase() { + let updateToSchedule = UpdateRequest( + id: 1, + creationTime: Timestamp(value: .seconds(1)), + targetTime: Timestamp(value: .seconds(2)), + tag: "schedule" + ) + let updateToCancel = UpdateRequest( + id: 2, + creationTime: Timestamp(value: .seconds(3)), + targetTime: Timestamp(value: .seconds(4)), + tag: "cancel" + ) + let output: GestureOutput = .value( + 7, + metadata: GestureOutputMetadata( + updatesToSchedule: [updateToSchedule], + traceAnnotation: UpdateTraceAnnotation(value: "existing") + ) + ) + + let replaced = output.copyWithCombinedMetadata(GestureOutputMetadata( + updatesToCancel: [updateToCancel], + traceAnnotation: UpdateTraceAnnotation(value: "replacement") + )) + + guard case let .value(value, metadata) = replaced else { + Issue.record("Expected value output") + return + } + #expect(value == 7) + #expect(metadata?.updatesToSchedule == [updateToSchedule]) + #expect(metadata?.updatesToCancel == [updateToCancel]) + #expect(metadata?.traceAnnotation == nil) + } + + @Test + func copyWithCombinedMetadataPreservesEmptyAndFinalCases() { + let emptyOutput: GestureOutput = .empty(.filtered, metadata: nil) + let finalOutput: GestureOutput = .finalValue(9, metadata: nil) + + let replacement = GestureOutputMetadata( + traceAnnotation: UpdateTraceAnnotation(value: "replacement") + ) + let emptyOutputWithMetadata = emptyOutput.copyWithCombinedMetadata(replacement) + let finalOutputWithMetadata = finalOutput.copyWithCombinedMetadata(replacement) + + guard case let .empty(reason, emptyMetadata) = emptyOutputWithMetadata else { + Issue.record("Expected empty output") + return + } + guard case let .finalValue(value, finalMetadata) = finalOutputWithMetadata else { + Issue.record("Expected final value output") + return + } + #expect(reason == .filtered) + #expect(emptyMetadata != nil) + #expect(emptyMetadata?.traceAnnotation == nil) + #expect(value == 9) + #expect(finalMetadata != nil) + #expect(finalMetadata?.traceAnnotation == nil) + } + + @Test + func copyWithCombinedMetadataKeepsNilWhenBothSidesAreNil() { + let output: GestureOutput = .value(3, metadata: nil) + + let copied = output.copyWithCombinedMetadata(nil) + + #expect(copied.metadata == nil) + } +} diff --git a/Tests/OpenGesturesTests/Util/InterpolatableTests.swift b/Tests/OpenGesturesTests/Util/InterpolatableTests.swift new file mode 100644 index 0000000..eb17728 --- /dev/null +++ b/Tests/OpenGesturesTests/Util/InterpolatableTests.swift @@ -0,0 +1,126 @@ +// +// InterpolatableTests.swift +// OpenGesturesTests + +import OpenCoreGraphicsShims +import OpenGestures +import Testing + +@Suite +struct InterpolatableTests { + @Test(arguments: [ + ( + CGPoint(x: 10, y: 20), + CGPoint(x: 2, y: 6), + 0.25, + CGPoint(x: 4, y: 9.5) + ), + ( + CGPoint(x: -4, y: 8), + CGPoint(x: 12, y: -16), + 0.5, + CGPoint(x: 4, y: -4) + ), + ( + CGPoint(x: 3, y: -9), + CGPoint(x: -5, y: 7), + 1, + CGPoint(x: 3, y: -9) + ), + ]) + func pointMixWeightsFirstOperandByT( + _ lhs: CGPoint, + _ rhs: CGPoint, + _ t: Double, + _ expected: CGPoint + ) { + #expect(CGPoint.mix(lhs, rhs, by: t) == expected) + + var value = rhs + value.mix(with: lhs, by: t) + #expect(value == expected) + } + + @Test(arguments: [ + ( + CGVector(dx: 10, dy: 20), + CGVector(dx: 2, dy: 6), + 0.25, + CGVector(dx: 4, dy: 9.5) + ), + ( + CGVector(dx: -4, dy: 8), + CGVector(dx: 12, dy: -16), + 0.5, + CGVector(dx: 4, dy: -4) + ), + ( + CGVector(dx: 3, dy: -9), + CGVector(dx: -5, dy: 7), + 1, + CGVector(dx: 3, dy: -9) + ), + ]) + func vectorMixWeightsFirstOperandByT( + _ lhs: CGVector, + _ rhs: CGVector, + _ t: Double, + _ expected: CGVector + ) { + #expect(CGVector.mix(lhs, rhs, by: t) == expected) + + var value = rhs + value.mix(with: lhs, by: t) + #expect(value == expected) + } + + @Test(arguments: [ + ( + CGPoint(x: 10, y: 20), + 4.0, + CGPoint(x: 2.5, y: 5) + ), + ( + CGPoint(x: -8, y: 12), + 2.0, + CGPoint(x: -4, y: 6) + ), + ( + CGPoint(x: 3, y: -9), + 1.0, + CGPoint(x: 3, y: -9) + ), + ]) + func pointScaledByInverseOfUsesReciprocalScale( + _ value: CGPoint, + _ scale: Double, + _ expected: CGPoint + ) { + #expect(value.scaled(byInverseOf: scale) == expected) + } + + @Test(arguments: [ + ( + CGVector(dx: 10, dy: 20), + 4.0, + CGVector(dx: 2.5, dy: 5) + ), + ( + CGVector(dx: -8, dy: 12), + 2.0, + CGVector(dx: -4, dy: 6) + ), + ( + CGVector(dx: 3, dy: -9), + 1.0, + CGVector(dx: 3, dy: -9) + ), + ]) + func vectorScaledByInverseOfUsesReciprocalScale( + _ value: CGVector, + _ scale: Double, + _ expected: CGVector + ) { + #expect(value.scaled(byInverseOf: scale) == expected) + } +}