diff --git a/Sources/OpenGestures/Component/Components/CombinerComponent.swift b/Sources/OpenGestures/Component/Components/CombinerComponent.swift new file mode 100644 index 0000000..32852d5 --- /dev/null +++ b/Sources/OpenGestures/Component/Components/CombinerComponent.swift @@ -0,0 +1,182 @@ +// +// CombinerComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - CombinerComponent + +package struct CombinerComponent: Sendable +where repeat each Upstream: GestureComponent, Output: Sendable { + package var upstream: (repeat CombinerElement) + package let outputCombiner: GestureOutputCombiner + package let resetComponentsOnCompletion: Bool + + package init( + upstream: (repeat CombinerElement), + outputCombiner: GestureOutputCombiner, + resetComponentsOnCompletion: Bool + ) { + self.upstream = upstream + self.outputCombiner = outputCombiner + self.resetComponentsOnCompletion = resetComponentsOnCompletion + } +} + +// MARK: - CombinerComponent + GestureComponent + +extension CombinerComponent: GestureComponent { + package typealias Value = Output + + package mutating func update(context: GestureComponentContext) throws -> GestureOutput { + let updated = (repeat try Self.updateElement( + each upstream, + context: context, + resetComponentsOnCompletion: resetComponentsOnCompletion + )) + let outputs = (repeat (each updated).output) + upstream = (repeat (each updated).component) + return try outputCombiner.combine(repeat each outputs) + } + + private static func updateElement( + _ component: CombinerElement, + context: GestureComponentContext, + resetComponentsOnCompletion: Bool + ) throws -> (component: CombinerElement, output: GestureOutput) { + var component = component + let output = try component.tracingUpdate(context: context) + if resetComponentsOnCompletion, output.isFinal { + component.reset() + } + return (component, output) + } + + package mutating func reset() { + upstream = (repeat { + var element = each upstream + element.reset() + return element + }()) + } + + package mutating func traits() -> GestureTraitCollection? { + var result: GestureTraitCollection? + upstream = (repeat Self.collectTraits(from: each upstream, into: &result)) + return result + } + + private static func collectTraits( + from component: CombinerElement, + into result: inout GestureTraitCollection? + ) -> CombinerElement { + var component = component + let traits = component.traits() + let newResult: GestureTraitCollection? + if let result, let traits { + newResult = result.merging(traits) + } else if let result { + newResult = result + } else { + newResult = traits + } + result = newResult + return component + } + + package mutating func capacity(for eventType: EventType.Type) -> Int { + let updated = (repeat { + var component = each upstream + let capacity = component.capacity(for: eventType) + return (component: component, capacity: capacity) + }()) + upstream = (repeat (each updated).component) + + var total = 0 + for capacity in repeat (each updated).capacity { + total += capacity + } + return total + } +} + +// MARK: - CombinerElement + +package struct CombinerElement: Sendable where Upstream: GestureComponent { + package struct State: GestureComponentState, NestedCustomStringConvertible, Sendable { + package var cachedOutput: GestureOutput? + package var isDirty: Bool + + package init() { + cachedOutput = nil + isDirty = false + } + } + + package var upstream: Upstream + package var state: State + + package init( + upstream: Upstream, + state: State = State() + ) { + self.upstream = upstream + self.state = state + } +} + +extension CombinerElement: ReplicatingValue { + package func replicated() -> Self { + var copy = self + copy.reset() + return copy + } +} + +extension CombinerElement: GestureComponent { + package typealias Value = Upstream.Value + + package mutating func update(context: GestureComponentContext) throws -> GestureOutput { + state.isDirty = true + guard let cachedOutput = state.cachedOutput, cachedOutput.isFinal else { + let output = try upstream.tracingUpdate(context: context) + guard !output.isEmpty else { + guard let cachedOutput = state.cachedOutput else { + return output + } + return cachedOutput.copyWithCombinedMetadata( + output.metadata ?? GestureOutputMetadata() + ) + } + state.cachedOutput = output.copyClearingMetadata() + return output + } + return cachedOutput + } + + package mutating func reset() { + guard state.isDirty else { + return + } + state = State() + upstream.reset() + } + + package mutating func traits() -> GestureTraitCollection? { + state.isDirty = true + return upstream.traits() + } + + package mutating func capacity(for eventType: EventType.Type) -> Int { + state.isDirty = true + guard let cachedOutput = state.cachedOutput, cachedOutput.isFinal else { + return upstream.capacity(for: eventType) + } + return 0 + } +} + +extension CombinerElement: StatefulGestureComponent {} + +extension CombinerElement: CompositeGestureComponent {} diff --git a/Sources/OpenGestures/Component/Components/DynamicCombinerComponent.swift b/Sources/OpenGestures/Component/Components/DynamicCombinerComponent.swift new file mode 100644 index 0000000..07033fe --- /dev/null +++ b/Sources/OpenGestures/Component/Components/DynamicCombinerComponent.swift @@ -0,0 +1,140 @@ +// +// DynamicCombinerComponent.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - DynamicCombinerComponent + +package struct DynamicCombinerComponent: Sendable where Upstream: GestureComponent { + package enum Failure: Error, Hashable, Sendable { + case limitExceeded + } + + package var upstreams: ReplicatingList> + package let outputCombiner: GestureOutputArrayCombiner + package let initialCount: Int + package let limit: Int + package let failOnExceedingLimit: Bool + package let resetComponentsOnCompletion: Bool + + // TBA + package init( + upstream: Upstream, + outputCombiner: GestureOutputArrayCombiner, + initialCount: Int, + limit: Int, + failOnExceedingLimit: Bool, + resetComponentsOnCompletion: Bool + ) { + self.upstreams = ReplicatingList( + prototype: CombinerElement(upstream: upstream), + count: initialCount + ) + self.outputCombiner = outputCombiner + self.initialCount = initialCount + self.limit = limit + self.failOnExceedingLimit = failOnExceedingLimit + self.resetComponentsOnCompletion = resetComponentsOnCompletion + } + + package init( + upstreams: ReplicatingList>, + outputCombiner: GestureOutputArrayCombiner, + initialCount: Int, + limit: Int, + failOnExceedingLimit: Bool, + resetComponentsOnCompletion: Bool + ) { + self.upstreams = upstreams + self.outputCombiner = outputCombiner + self.initialCount = initialCount + self.limit = limit + self.failOnExceedingLimit = failOnExceedingLimit + self.resetComponentsOnCompletion = resetComponentsOnCompletion + } +} + +// MARK: - DynamicCombinerComponent + GestureComponent + +extension DynamicCombinerComponent: GestureComponent { + package typealias Value = [Upstream.Value] + + package mutating func update(context: GestureComponentContext) throws -> GestureOutput { + guard !upstreams.isEmpty else { + return .empty( + .filtered, + metadata: GestureOutputMetadata( + traceAnnotation: UpdateTraceAnnotation(value: "no upstreams") + ) + ) + } + + var outputs: [GestureOutput] = [] + var noDataCount = 0 + for index in upstreams.indices { + let output = try upstreams[index].tracingUpdate(context: context) + outputs.append(output) + if output.emptyReason == .noData { + noDataCount += 1 + } + } + if noDataCount == 0, context.updateSource == .event { + let limit = limit + while upstreams.count < limit || failOnExceedingLimit { + upstreams.appendReplications(1) + let index = upstreams.count - 1 + let output = try upstreams[index].tracingUpdate(context: context) + guard output.emptyReason != .noData else { + upstreams.removeLast(1) + break + } + if failOnExceedingLimit, limit < upstreams.count { + throw Failure.limitExceeded + } else { + outputs.append(output) + } + } + } + if resetComponentsOnCompletion { + for index in outputs.indices.reversed() where outputs[index].isFinal { + if initialCount >= upstreams.count { + upstreams[index].reset() + } else { + upstreams.remove(at: index) + } + } + } + return try outputCombiner.combine(outputs) + } + + package mutating func reset() { + upstreams.resize(to: initialCount) + for index in upstreams.indices { + upstreams[index].reset() + } + } + + package mutating func traits() -> GestureTraitCollection? { + var prototype = upstreams.prototype() + return prototype.traits() + } + + package mutating func capacity(for eventType: EventType.Type) -> Int { + var total = 0 + for index in upstreams.indices { + total += upstreams[index].capacity(for: eventType) + } + + var prototype = upstreams.prototype() + let prototypeCapacity = prototype.capacity(for: eventType) + guard prototypeCapacity >= 1 else { + return total + } + guard upstreams.count < limit || failOnExceedingLimit else { + return total + } + return Swift.min(limit, total + prototypeCapacity) + } +} diff --git a/Sources/OpenGestures/Component/CompositeGestureComponent.swift b/Sources/OpenGestures/Component/CompositeGestureComponent.swift index 657aeeb..8981501 100644 --- a/Sources/OpenGestures/Component/CompositeGestureComponent.swift +++ b/Sources/OpenGestures/Component/CompositeGestureComponent.swift @@ -18,11 +18,11 @@ extension CompositeGestureComponent { upstream.reset() } - public func traits() -> GestureTraitCollection? { + public mutating func traits() -> GestureTraitCollection? { upstream.traits() } - public func capacity(for eventType: E.Type) -> Int { + public mutating func capacity(for eventType: E.Type) -> Int { upstream.capacity(for: eventType) } } diff --git a/Sources/OpenGestures/Component/GestureComponent.swift b/Sources/OpenGestures/Component/GestureComponent.swift index d893e61..18bd905 100644 --- a/Sources/OpenGestures/Component/GestureComponent.swift +++ b/Sources/OpenGestures/Component/GestureComponent.swift @@ -12,8 +12,8 @@ public protocol GestureComponent: Sendable { associatedtype Value: Sendable mutating func update(context: GestureComponentContext) throws -> GestureOutput mutating func reset() - func traits() -> GestureTraitCollection? - func capacity(for eventType: E.Type) -> Int + mutating func traits() -> GestureTraitCollection? + mutating func capacity(for eventType: E.Type) -> Int } // MARK: - GestureComponent + Tracing diff --git a/Sources/OpenGestures/Core/GestureOutput.swift b/Sources/OpenGestures/Core/GestureOutput.swift index f3d2944..77eea80 100644 --- a/Sources/OpenGestures/Core/GestureOutput.swift +++ b/Sources/OpenGestures/Core/GestureOutput.swift @@ -54,6 +54,17 @@ extension GestureOutput { } } + package func copyClearingMetadata() -> Self { + switch self { + case let .empty(reason, _): + return .empty(reason, metadata: nil) + case let .value(value, _): + return .value(value, metadata: nil) + case let .finalValue(value, _): + return .finalValue(value, metadata: nil) + } + } + package func copyWithCombinedMetadata(_ other: GestureOutputMetadata?) -> Self { switch self { case let .empty(reason, metadata): @@ -161,48 +172,3 @@ package struct UpdateTraceAnnotation: Sendable { } } -// MARK: - GestureOutputStatusCombiner - -package struct GestureOutputStatusCombiner: Sendable { - package var combine: @Sendable ([GestureOutputStatus]) throws -> GestureOutputStatus - - package init(combine: @escaping @Sendable ([GestureOutputStatus]) throws -> GestureOutputStatus) { - self.combine = combine - } -} - -// MARK: - GestureOutputStatus - -package enum GestureOutputStatus: Hashable, Sendable { - case empty - case value - case finalValue -} - -// MARK: - GestureOutputArrayCombiner - -package struct GestureOutputArrayCombiner: Sendable { - package let statusCombiner: GestureOutputStatusCombiner - - package init(statusCombiner: GestureOutputStatusCombiner) { - self.statusCombiner = statusCombiner - } -} - -// MARK: - GestureOutputCombiner - -package struct GestureOutputCombiner: Sendable { - package let combineValues: (@Sendable (repeat each A) throws -> B)? - package let combineOptionals: (@Sendable (repeat (each A)?) throws -> B)? - package let statusCombiner: GestureOutputStatusCombiner - - package init( - combineValues: (@Sendable (repeat each A) throws -> B)?, - combineOptionals: (@Sendable (repeat (each A)?) throws -> B)?, - statusCombiner: GestureOutputStatusCombiner - ) { - self.combineValues = combineValues - self.combineOptionals = combineOptionals - self.statusCombiner = statusCombiner - } -} diff --git a/Sources/OpenGestures/Core/GestureOutputCombiner.swift b/Sources/OpenGestures/Core/GestureOutputCombiner.swift new file mode 100644 index 0000000..8b21410 --- /dev/null +++ b/Sources/OpenGestures/Core/GestureOutputCombiner.swift @@ -0,0 +1,135 @@ +// +// GestureOutputCombiner.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - GestureOutputStatusCombiner + +package struct GestureOutputStatusCombiner: Sendable { + package var combine: @Sendable ([GestureOutputStatus]) throws -> GestureOutputStatus + + package init(combine: @escaping @Sendable ([GestureOutputStatus]) throws -> GestureOutputStatus) { + self.combine = combine + } +} + +// MARK: - GestureOutputStatus + +package enum GestureOutputStatus: Hashable, Sendable { + case empty + case value + case finalValue +} + +extension GestureOutput { + package var status: GestureOutputStatus { + switch self { + case .empty: + return .empty + case .value: + return .value + case .finalValue: + return .finalValue + } + } +} + +// MARK: - GestureOutputArrayCombiner + +package struct GestureOutputArrayCombiner: Sendable { + package let statusCombiner: GestureOutputStatusCombiner + + package init(statusCombiner: GestureOutputStatusCombiner) { + self.statusCombiner = statusCombiner + } + + package func combine(_ outputs: [GestureOutput]) throws -> GestureOutput<[Value]> { + var statuses: [GestureOutputStatus] = [] + var values: [Value] = [] + var metadata: GestureOutputMetadata? + var emptyReason: GestureOutputEmptyReason? + var lastOutputEmptyReason: GestureOutputEmptyReason? + var hasProcessedOutput = false + var hasPriorOutput = false + for output in outputs { + hasPriorOutput = hasProcessedOutput + if let value = output.value { + values.append(value) + } + statuses.append(output.status) + metadata = GestureOutputMetadata.combineUpdateRequests(metadata, output.metadata) + lastOutputEmptyReason = output.emptyReason + hasProcessedOutput = true + } + if hasProcessedOutput { + emptyReason = hasPriorOutput || lastOutputEmptyReason != nil ? .filtered : .noData + } + let status = try statusCombiner.combine(statuses) + switch status { + case .empty: + return .empty(emptyReason!, metadata: metadata) + case .value: + return .value(values, metadata: metadata) + case .finalValue: + return .finalValue(values, metadata: metadata) + } + } +} + +// MARK: - GestureOutputCombiner + +package struct GestureOutputCombiner: Sendable { + package let combineValues: (@Sendable (repeat each Value) throws -> Output)? + package let combineOptionals: (@Sendable (repeat (each Value)?) throws -> Output)? + package let statusCombiner: GestureOutputStatusCombiner + + package init( + combineValues: (@Sendable (repeat each Value) throws -> Output)?, + combineOptionals: (@Sendable (repeat (each Value)?) throws -> Output)?, + statusCombiner: GestureOutputStatusCombiner + ) { + self.combineValues = combineValues + self.combineOptionals = combineOptionals + self.statusCombiner = statusCombiner + } + + package func combine(_ outputs: repeat GestureOutput) throws -> GestureOutput { + var statuses: [GestureOutputStatus] = [] + var metadata: GestureOutputMetadata? + var emptyReason: GestureOutputEmptyReason? + var lastOutputEmptyReason: GestureOutputEmptyReason? + var hasProcessedOutput = false + var hasPriorOutput = false + for output in repeat each outputs { + hasPriorOutput = hasProcessedOutput + statuses.append(output.status) + metadata = GestureOutputMetadata.combineUpdateRequests(metadata, output.metadata) + lastOutputEmptyReason = output.emptyReason + hasProcessedOutput = true + } + if hasProcessedOutput { + emptyReason = hasPriorOutput || lastOutputEmptyReason != nil ? .filtered : .noData + } + let status = try statusCombiner.combine(statuses) + switch status { + case .empty: + return .empty(emptyReason!, metadata: metadata) + case .value, .finalValue: + let value: Output + if let combineOptionals { + value = try combineOptionals(repeat (each outputs).value) + } else if let combineValues { + value = try combineValues(repeat (each outputs).value!) + } else { + preconditionFailure("Invalid combiner configuration") + } + if status == .value { + return .value(value, metadata: metadata) + } else { + return .finalValue(value, metadata: metadata) + } + } + } +} diff --git a/Sources/OpenGestures/Core/GestureTrait.swift b/Sources/OpenGestures/Core/GestureTrait.swift index cb6c02e..14670b3 100644 --- a/Sources/OpenGestures/Core/GestureTrait.swift +++ b/Sources/OpenGestures/Core/GestureTrait.swift @@ -188,8 +188,10 @@ extension GestureTraitCollection: NestedCustomStringConvertible { // MARK: - GestureTraitCollection + Mergeable extension GestureTraitCollection: Mergeable { - package mutating func merge(_ other: GestureTraitCollection) { - _traits.merge(other._traits) { $1 } + package func merging(_ other: GestureTraitCollection) -> GestureTraitCollection { + var result = self + result._traits.merge(other._traits) { $1 } + return result } } diff --git a/Sources/OpenGestures/Util/Mergeable.swift b/Sources/OpenGestures/Util/Mergeable.swift index 298174b..bf1c31c 100644 --- a/Sources/OpenGestures/Util/Mergeable.swift +++ b/Sources/OpenGestures/Util/Mergeable.swift @@ -8,5 +8,11 @@ // MARK: - Mergeable package protocol Mergeable { - mutating func merge(_ other: Self) + func merging(_ other: Self) -> Self +} + +extension Mergeable { + package mutating func merge(_ other: Self) { + self = merging(other) + } } diff --git a/Sources/OpenGestures/Util/ReplicatingList.swift b/Sources/OpenGestures/Util/ReplicatingList.swift new file mode 100644 index 0000000..ddb2a9f --- /dev/null +++ b/Sources/OpenGestures/Util/ReplicatingList.swift @@ -0,0 +1,170 @@ +// +// ReplicatingList.swift +// OpenGestures +// +// Audited for 9126.1.5 +// Status: Complete + +// MARK: - ReplicatingValue + +package protocol ReplicatingValue: Sendable { + func replicated() -> Self +} + +extension ReplicatingValue { + package func replications(count: Int) -> [Self] { + Array(repeating: replicated(), count: count) + } +} + +// MARK: - ReplicatingList + +package struct ReplicatingList: Collection, Sendable where Element: ReplicatingValue { + package enum Storage: Sendable { + case empty(Element) + case single(Element) + case multiple([Element]) + } + + package var storage: Storage + + // TBA + package init(prototype: Element, count: Int = 0) { + precondition(count >= 0, "Count must be non-negative") + storage = .empty(prototype) + if count > 0 { + appendReplications(count) + } + } + + package var startIndex: Int { 0 } + + package var endIndex: Int { + count + } + + package var count: Int { + switch storage { + case .empty: + return 0 + case .single: + return 1 + case let .multiple(elements): + return elements.count + } + } + + package func index(after index: Int) -> Int { + index + 1 + } + + package subscript(position: Int) -> Element { + get { + switch storage { + case .empty: + preconditionFailure("Index out of range") + case let .single(element): + precondition(position == 0, "Index out of range") + return element + case let .multiple(elements): + return elements[position] + } + } + set { + switch storage { + case .empty: + preconditionFailure("Index out of range") + case .single: + precondition(position == 0, "Index out of range") + storage = .single(newValue) + case var .multiple(elements): + elements[position] = newValue + storage = .multiple(elements) + } + } + } + + package func prototype() -> Element { + switch storage { + case let .empty(element): + return element + case let .single(element): + return element.replicated() + case let .multiple(elements): + return elements[0].replicated() + } + } + + package mutating func appendReplications(_ count: Int) { + precondition(count >= 1, "Count must be positive") + switch storage { + case let .empty(prototype): + if count == 1 { + storage = .single(prototype.replicated()) + } else { + storage = .multiple(prototype.replications(count: count)) + } + case let .single(element): + storage = .multiple( + [element] + element.replications(count: count) + ) + case let .multiple(elements): + storage = .multiple( + elements + elements[0].replications(count: count) + ) + } + } + + package mutating func removeLast(_ removedCount: Int) { + precondition(removedCount >= 1, "Count must be positive") + let newCount = count - removedCount + guard newCount < count else { + return + } + switch storage { + case .empty: + return + case let .single(element): + storage = .empty(element.replicated()) + case let .multiple(elements): + switch newCount { + case 0: + storage = .empty(elements[0].replicated()) + case 1: + storage = .single(elements[0]) + default: + storage = .multiple(Array(elements.prefix(newCount))) + } + } + } + + package mutating func remove(at index: Int) { + switch storage { + case .empty: + preconditionFailure("Index out of range") + case let .single(element): + precondition(index == 0, "Index out of range") + storage = .empty(element.replicated()) + case var .multiple(elements): + elements.remove(at: index) + switch elements.count { + case 1: + storage = .single(elements[0]) + default: + storage = .multiple(elements) + } + } + } + + package mutating func resize(to newCount: Int) { + precondition(newCount >= 0, "Count must be non-negative") + guard count != newCount else { + return + } + if count > newCount { + removeLast(count - newCount) + } else { + appendReplications(newCount - count) + } + } +} diff --git a/Tests/OpenGesturesTests/Component/Components/DynamicCombinerComponentTests.swift b/Tests/OpenGesturesTests/Component/Components/DynamicCombinerComponentTests.swift new file mode 100644 index 0000000..530e036 --- /dev/null +++ b/Tests/OpenGesturesTests/Component/Components/DynamicCombinerComponentTests.swift @@ -0,0 +1,426 @@ +// +// DynamicCombinerComponentTests.swift +// OpenGesturesTests +// +// Generated + +import OpenGestures +import Testing + +// MARK: - DynamicCombinerComponentTests + +@Suite +struct DynamicCombinerComponentTests { + @Test + func combinesReplicatedOutputsUsingStatusCombiner() throws { + var upstreams = ReplicatingList( + prototype: CombinerElement( + upstream: DynamicCombinerStubComponent(outputs: [ + .empty(.noData, metadata: nil), + ]) + ), + count: 2 + ) + upstreams[0] = CombinerElement( + upstream: DynamicCombinerStubComponent(outputs: [ + .value(1, metadata: nil), + ]) + ) + upstreams[1] = CombinerElement( + upstream: DynamicCombinerStubComponent(outputs: [ + .finalValue(2, metadata: nil), + ]) + ) + var component = DynamicCombinerComponent( + upstreams: upstreams, + outputCombiner: GestureOutputArrayCombiner( + statusCombiner: GestureOutputStatusCombiner { statuses in + statuses.contains(.finalValue) ? .finalValue : .value + } + ), + initialCount: 0, + limit: 2, + failOnExceedingLimit: false, + resetComponentsOnCompletion: false + ) + + let output = try component.update(context: dynamicCombinerContext()) + + guard case let .finalValue(values, metadata) = output else { + Issue.record("Expected final combined output") + return + } + #expect(values == [1, 2]) + #expect(metadata == nil) + } + + @Test + func emptyListReturnsFilteredNoUpstreamsTrace() throws { + var component = DynamicCombinerComponent( + upstream: DynamicCombinerStubComponent(outputs: [ + .value(1, metadata: nil), + ]), + outputCombiner: GestureOutputArrayCombiner( + statusCombiner: GestureOutputStatusCombiner { _ in .value } + ), + initialCount: 0, + limit: 2, + failOnExceedingLimit: false, + resetComponentsOnCompletion: false + ) + + let output = try component.update(context: dynamicCombinerContext()) + + guard case let .empty(reason, metadata) = output else { + Issue.record("Expected empty output") + return + } + #expect(reason == .filtered) + #expect(metadata?.traceAnnotation?.value == "no upstreams") + } + + @Test + func combinerElementReplaysCachedValueWhenUpstreamHasNoData() throws { + var element = CombinerElement( + upstream: DynamicCombinerStubComponent(outputs: [ + .value(5, metadata: nil), + .empty( + .noData, + metadata: GestureOutputMetadata( + traceAnnotation: UpdateTraceAnnotation(value: "tick") + ) + ), + ]) + ) + + _ = try element.update(context: dynamicCombinerContext()) + let output = try element.update(context: dynamicCombinerContext()) + + guard case let .value(value, metadata) = output else { + Issue.record("Expected cached value output") + return + } + #expect(value == 5) + #expect(metadata != nil) + #expect(metadata?.traceAnnotation == nil) + } + + @Test + func combinerElementReplaysCachedValueWithEmptyMetadataWhenUpstreamHasNoMetadata() throws { + var element = CombinerElement( + upstream: DynamicCombinerStubComponent(outputs: [ + .value(5, metadata: nil), + .empty(.noData, metadata: nil), + ]) + ) + + _ = try element.update(context: dynamicCombinerContext()) + let output = try element.update(context: dynamicCombinerContext()) + + guard case let .value(value, metadata) = output else { + Issue.record("Expected cached value output") + return + } + #expect(value == 5) + #expect(metadata != nil) + #expect(metadata?.updatesToSchedule.isEmpty == true) + #expect(metadata?.updatesToCancel.isEmpty == true) + #expect(metadata?.traceAnnotation == nil) + } + + @Test + func combinerElementReplaysCachedFinalWithoutUpdatingUpstream() throws { + var element = CombinerElement( + upstream: DynamicCombinerStubComponent(outputs: [ + .finalValue(5, metadata: nil), + .value(9, metadata: nil), + ]) + ) + + _ = try element.update(context: dynamicCombinerContext()) + let output = try element.update(context: dynamicCombinerContext()) + + guard case let .finalValue(value, _) = output else { + Issue.record("Expected cached final output") + return + } + #expect(value == 5) + #expect(element.upstream.updateCount == 1) + } + + @Test + func dynamicExpansionStopsOnlyAtNoDataEmpty() throws { + let sharedOutputs = SharedOutputBox(outputs: [ + .value(1, metadata: nil), + .empty(.filtered, metadata: nil), + .empty(.noData, metadata: nil), + ]) + let statusProbe = StatusProbe(result: .value) + let upstreams = ReplicatingList( + prototype: CombinerElement( + upstream: DynamicCombinerStubComponent( + outputs: [], + sharedOutputs: sharedOutputs + ) + ), + count: 1 + ) + var component = DynamicCombinerComponent( + upstreams: upstreams, + outputCombiner: GestureOutputArrayCombiner( + statusCombiner: GestureOutputStatusCombiner { try statusProbe.combine($0) } + ), + initialCount: 0, + limit: 4, + failOnExceedingLimit: false, + resetComponentsOnCompletion: false + ) + + let output = try component.update(context: dynamicCombinerContext()) + + guard case let .value(values, _) = output else { + Issue.record("Expected value output") + return + } + #expect(values == [1]) + #expect(statusProbe.statuses == [[.value, .empty]]) + #expect(component.upstreams.count == 2) + } + + @Test + func schedulerUpdateDoesNotDynamicallyExpand() throws { + let sharedOutputs = SharedOutputBox(outputs: [ + .value(1, metadata: nil), + .value(2, metadata: nil), + ]) + let statusProbe = StatusProbe(result: .value) + let upstreams = ReplicatingList( + prototype: CombinerElement( + upstream: DynamicCombinerStubComponent( + outputs: [], + sharedOutputs: sharedOutputs + ) + ), + count: 1 + ) + var component = DynamicCombinerComponent( + upstreams: upstreams, + outputCombiner: GestureOutputArrayCombiner( + statusCombiner: GestureOutputStatusCombiner { try statusProbe.combine($0) } + ), + initialCount: 0, + limit: 4, + failOnExceedingLimit: false, + resetComponentsOnCompletion: false + ) + + let output = try component.update(context: dynamicCombinerContext(updateSource: .scheduler([1]))) + + guard case let .value(values, _) = output else { + Issue.record("Expected value output") + return + } + #expect(values == [1]) + #expect(statusProbe.statuses == [[.value]]) + #expect(component.upstreams.count == 1) + #expect(sharedOutputs.outputs.count == 1) + } + + @Test + func limitProbeThrowsAfterAppendingExceededElement() throws { + let sharedOutputs = SharedOutputBox(outputs: [ + .value(1, metadata: nil), + .value(2, metadata: nil), + ]) + let upstreams = ReplicatingList( + prototype: CombinerElement( + upstream: DynamicCombinerStubComponent( + outputs: [], + sharedOutputs: sharedOutputs + ) + ), + count: 1 + ) + var component = DynamicCombinerComponent( + upstreams: upstreams, + outputCombiner: GestureOutputArrayCombiner( + statusCombiner: GestureOutputStatusCombiner { _ in .value } + ), + initialCount: 0, + limit: 1, + failOnExceedingLimit: true, + resetComponentsOnCompletion: false + ) + + #expect(throws: DynamicCombinerComponent.Failure.limitExceeded) { + _ = try component.update(context: dynamicCombinerContext()) + } + #expect(component.upstreams.count == 2) + } + + @Test + func finalOutputsResetOrRemoveTheirOwnUpstreams() throws { + let upstreams = ReplicatingList( + prototype: CombinerElement( + upstream: DynamicCombinerStubComponent(outputs: [ + .finalValue(2, metadata: nil), + ]) + ), + count: 1 + ) + var component = DynamicCombinerComponent( + upstreams: upstreams, + outputCombiner: GestureOutputArrayCombiner( + statusCombiner: GestureOutputStatusCombiner { _ in .finalValue } + ), + initialCount: 1, + limit: 1, + failOnExceedingLimit: false, + resetComponentsOnCompletion: true + ) + + _ = try component.update(context: dynamicCombinerContext()) + + #expect(component.upstreams.count == 1) + #expect(component.upstreams[0].state.cachedOutput == nil) + #expect(component.upstreams[0].state.isDirty == false) + #expect(component.upstreams[0].upstream.resetCount == 1) + } + + @Test + func resetResizesToInitialCountAndResetsRemainingElements() { + var upstreams = ReplicatingList( + prototype: CombinerElement( + upstream: DynamicCombinerStubComponent(outputs: []) + ), + count: 2 + ) + upstreams[0].state.isDirty = true + upstreams[1].state.isDirty = true + var component = DynamicCombinerComponent( + upstreams: upstreams, + outputCombiner: GestureOutputArrayCombiner( + statusCombiner: GestureOutputStatusCombiner { _ in .value } + ), + initialCount: 1, + limit: 2, + failOnExceedingLimit: false, + resetComponentsOnCompletion: false + ) + + component.reset() + + #expect(component.upstreams.count == 1) + #expect(component.upstreams[0].upstream.resetCount == 1) + #expect(component.upstreams[0].state.isDirty == false) + } + + @Test + func capacityIncludesOnePotentialReplicationSlot() { + let upstreams = ReplicatingList( + prototype: CombinerElement( + upstream: DynamicCombinerStubComponent(outputs: [], capacityValue: 2) + ), + count: 2 + ) + var component = DynamicCombinerComponent( + upstreams: upstreams, + outputCombiner: GestureOutputArrayCombiner( + statusCombiner: GestureOutputStatusCombiner { _ in .value } + ), + initialCount: 0, + limit: 5, + failOnExceedingLimit: false, + resetComponentsOnCompletion: false + ) + + #expect(component.capacity(for: TouchEvent.self) == 5) + } + + @Test + func combinerElementCapacityIsZeroAfterCachedFinalOutput() { + var state = CombinerElement.State() + state.cachedOutput = .finalValue(1, metadata: nil) + var element = CombinerElement( + upstream: DynamicCombinerStubComponent(outputs: [], capacityValue: 3), + state: state + ) + + #expect(element.capacity(for: TouchEvent.self) == 0) + } +} + +private func dynamicCombinerContext( + updateSource: GestureUpdateSource = .event +) -> GestureComponentContext { + GestureComponentContext( + startTime: Timestamp(value: .zero), + currentTime: Timestamp(value: .zero), + updateSource: updateSource, + eventStore: EventStore() + ) +} + +private final class SharedOutputBox: @unchecked Sendable { + var outputs: [GestureOutput] + + init(outputs: [GestureOutput]) { + self.outputs = outputs + } +} + +private final class StatusProbe: @unchecked Sendable { + var statuses: [[GestureOutputStatus]] = [] + var result: GestureOutputStatus + + init(result: GestureOutputStatus) { + self.result = result + } + + func combine(_ statuses: [GestureOutputStatus]) throws -> GestureOutputStatus { + self.statuses.append(statuses) + return result + } +} + +private struct DynamicCombinerStubComponent: GestureComponent { + var outputs: [GestureOutput] + var sharedOutputs: SharedOutputBox? + var capacityValue: Int + var updateCount: Int + var resetCount: Int + + init( + outputs: [GestureOutput], + sharedOutputs: SharedOutputBox? = nil, + capacityValue: Int = 0, + updateCount: Int = 0, + resetCount: Int = 0 + ) { + self.outputs = outputs + self.sharedOutputs = sharedOutputs + self.capacityValue = capacityValue + self.updateCount = updateCount + self.resetCount = resetCount + } + + mutating func update(context: GestureComponentContext) throws -> GestureOutput { + updateCount += 1 + if let sharedOutputs { + return sharedOutputs.outputs.removeFirst() + } + return outputs.removeFirst() + } + + mutating func reset() { + resetCount += 1 + } + + func traits() -> GestureTraitCollection? { + nil + } + + func capacity(for eventType: E.Type) -> Int { + capacityValue + } +} diff --git a/Tests/OpenGesturesTests/Core/GestureOutputCombinerTests.swift b/Tests/OpenGesturesTests/Core/GestureOutputCombinerTests.swift new file mode 100644 index 0000000..c2cc6ff --- /dev/null +++ b/Tests/OpenGesturesTests/Core/GestureOutputCombinerTests.swift @@ -0,0 +1,119 @@ +// +// GestureOutputCombinerTests.swift +// OpenGesturesTests +// +// Generated + +import OpenGestures +import Testing + +// MARK: - GestureOutputArrayCombinerTests + +@Suite +struct GestureOutputArrayCombinerTests { + @Test + func arrayCombinerUsesFilteredReasonForMultiOutputEmptyStatus() throws { + let combiner = GestureOutputArrayCombiner( + statusCombiner: GestureOutputStatusCombiner { _ in .empty } + ) + + let output = try combiner.combine([ + .value(1, metadata: nil), + .value(2, metadata: nil), + ]) + + guard case let .empty(reason, metadata) = output else { + Issue.record("Expected empty output") + return + } + #expect(reason == .filtered) + #expect(metadata == nil) + } + + @Test + func arrayCombinerUsesNoDataReasonForSingleNonEmptyOutputWhenStatusIsEmpty() throws { + let combiner = GestureOutputArrayCombiner( + statusCombiner: GestureOutputStatusCombiner { _ in .empty } + ) + + let output = try combiner.combine([ + .value(1, metadata: nil), + ]) + + guard case let .empty(reason, metadata) = output else { + Issue.record("Expected empty output") + return + } + #expect(reason == .noData) + #expect(metadata == nil) + } + + @Test(arguments: [ + GestureOutputEmptyReason.noData, + .filtered, + .timeUpdate, + ]) + func arrayCombinerUsesFilteredReasonForSingleEmptyOutputWhenStatusIsEmpty( + _ sourceReason: GestureOutputEmptyReason + ) throws { + let combiner = GestureOutputArrayCombiner( + statusCombiner: GestureOutputStatusCombiner { _ in .empty } + ) + + let output = try combiner.combine([ + .empty(sourceReason, metadata: nil), + ]) + + guard case let .empty(reason, metadata) = output else { + Issue.record("Expected empty output") + return + } + #expect(reason == .filtered) + #expect(metadata == nil) + } + + @Test + func arrayCombinerCombinesUpdateRequestMetadataAndDropsTraceAnnotations() throws { + 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 combiner = GestureOutputArrayCombiner( + statusCombiner: GestureOutputStatusCombiner { _ in .value } + ) + + let output = try combiner.combine([ + .value( + 1, + metadata: GestureOutputMetadata( + updatesToSchedule: [updateToSchedule], + traceAnnotation: UpdateTraceAnnotation(value: "first") + ) + ), + .empty( + .filtered, + metadata: GestureOutputMetadata( + updatesToCancel: [updateToCancel], + traceAnnotation: UpdateTraceAnnotation(value: "second") + ) + ), + ]) + + guard case let .value(values, metadata) = output else { + Issue.record("Expected value output") + return + } + #expect(values == [1]) + #expect(metadata?.updatesToSchedule == [updateToSchedule]) + #expect(metadata?.updatesToCancel == [updateToCancel]) + #expect(metadata?.traceAnnotation == nil) + } +} diff --git a/Tests/OpenGesturesTests/Util/ReplicatingListTests.swift b/Tests/OpenGesturesTests/Util/ReplicatingListTests.swift new file mode 100644 index 0000000..abdf2fd --- /dev/null +++ b/Tests/OpenGesturesTests/Util/ReplicatingListTests.swift @@ -0,0 +1,110 @@ +// +// ReplicatingListTests.swift +// OpenGesturesTests +// +// Generated + +import OpenGestures +import Testing + +// MARK: - ReplicatingListTests + +@Suite +struct ReplicatingListTests { + + // MARK: - Storage + + @Test + func testReplicationAndResizingPreservePrototypeStorage() { + var list = ReplicatingList(prototype: ReplicatingValueProbe(id: 7, generation: 0)) + + #expect(list.isEmpty == true) + #expect(list.prototype() == ReplicatingValueProbe(id: 7, generation: 0)) + + list.appendReplications(2) + + #expect(Array(list) == [ + ReplicatingValueProbe(id: 7, generation: 1), + ReplicatingValueProbe(id: 7, generation: 1), + ]) + + list.remove(at: 0) + + #expect(Array(list) == [ + ReplicatingValueProbe(id: 7, generation: 1), + ]) + #expect(list.prototype() == ReplicatingValueProbe(id: 7, generation: 2)) + + list.remove(at: 0) + + #expect(list.isEmpty == true) + #expect(list.prototype() == ReplicatingValueProbe(id: 7, generation: 2)) + + list.resize(to: 0) + + #expect(list.isEmpty == true) + #expect(list.prototype() == ReplicatingValueProbe(id: 7, generation: 2)) + + list.resize(to: 1) + + #expect(Array(list) == [ + ReplicatingValueProbe(id: 7, generation: 3), + ]) + + list.appendReplications(2) + + #expect(Array(list) == [ + ReplicatingValueProbe(id: 7, generation: 3), + ReplicatingValueProbe(id: 7, generation: 4), + ReplicatingValueProbe(id: 7, generation: 4), + ]) + #expect(list.prototype() == ReplicatingValueProbe(id: 7, generation: 4)) + + list.appendReplications(1) + + #expect(Array(list) == [ + ReplicatingValueProbe(id: 7, generation: 3), + ReplicatingValueProbe(id: 7, generation: 4), + ReplicatingValueProbe(id: 7, generation: 4), + ReplicatingValueProbe(id: 7, generation: 4), + ]) + + list.removeLast(2) + + #expect(Array(list) == [ + ReplicatingValueProbe(id: 7, generation: 3), + ReplicatingValueProbe(id: 7, generation: 4), + ]) + #expect(list.prototype() == ReplicatingValueProbe(id: 7, generation: 4)) + + list.removeLast(1) + + #expect(Array(list) == [ + ReplicatingValueProbe(id: 7, generation: 3), + ]) + #expect(list.prototype() == ReplicatingValueProbe(id: 7, generation: 4)) + + list.removeLast(1) + + #expect(list.isEmpty == true) + #expect(list.prototype() == ReplicatingValueProbe(id: 7, generation: 4)) + + var multipleToEmpty = ReplicatingList(prototype: ReplicatingValueProbe(id: 8, generation: 0)) + multipleToEmpty.appendReplications(2) + multipleToEmpty.removeLast(2) + + #expect(multipleToEmpty.isEmpty == true) + #expect(multipleToEmpty.prototype() == ReplicatingValueProbe(id: 8, generation: 2)) + } +} + +// MARK: - ReplicatingValueProbe + +private struct ReplicatingValueProbe: Equatable, ReplicatingValue { + var id: Int + var generation: Int + + func replicated() -> Self { + Self(id: id, generation: generation + 1) + } +}